DragonWind/public/share.html
Zac Gaetano c03b7ef491 Add per-user upload quotas/permissions and share link system
- Per-user quota tracking (MB limit + uploadedBytes counter)
- Per-user allowed folders restriction (empty = all folders allowed)
- Admin permissions modal: quota config, folder checkboxes, usage reset
- Share links: create tokenized upload URLs with expiry, max-uses, folder
- Public share.html upload page (no auth required) with drag-drop + progress
- Backend routes: GET/PUT /api/users/:u/permissions, POST .../quota/reset
- Backend routes: GET/POST /api/sharelinks, DELETE .../token, GET /share/:token
- migrateData() ensures existing user records gain new fields on startup
- Frontend JS: loadUsers quota column, openPermissions modal, loadShareLinks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:58:29 -04:00

198 lines
9.8 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Dragon Wind · Upload</title>
<link rel="icon" href="/dragon-icon.png" type="image/png"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet"/>
<style>
:root{
--bg:#03040a;--bg-card:#0d1117;--border:#1e2535;--border-bright:#2d3a50;
--text:#e8eaf0;--text-dim:#4a5568;--text-secondary:#8892a4;
--blue:#1e4bd8;--blue-bright:#3060ff;
--success:#22c55e;--success-bg:rgba(34,197,94,.08);
--error:#ef4444;--error-bg:rgba(239,68,68,.08);
--dragon:#e05c1a;--dragon-bright:#ff7733;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:'Outfit',sans-serif;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:2rem}
.card{width:100%;max-width:520px;background:var(--bg-card);border:1px solid var(--border);border-radius:20px;padding:2.5rem 2rem;box-shadow:0 24px 80px rgba(0,0,0,.5)}
.brand{display:flex;flex-direction:column;align-items:center;gap:.5rem;margin-bottom:2rem;text-align:center}
.brand-icon{width:64px;height:64px;object-fit:contain;filter:drop-shadow(0 4px 16px rgba(30,75,216,.4))}
.brand-title{font-size:1.3rem;font-weight:700;background:linear-gradient(135deg,#fff,#8892a4);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.brand-label{font-size:.78rem;color:var(--text-secondary);margin-top:.1rem}
.brand-folder{font-size:.7rem;color:var(--blue-bright);font-family:'JetBrains Mono',monospace;background:rgba(30,75,216,.1);border:1px solid rgba(30,75,216,.2);border-radius:5px;padding:.15rem .5rem;margin-top:.3rem}
.drop-zone{border:2px dashed var(--border-bright);border-radius:14px;padding:2.5rem 1.5rem;text-align:center;cursor:pointer;transition:all .2s;margin-bottom:1.2rem;position:relative}
.drop-zone.dragover{border-color:var(--blue-bright);background:rgba(30,75,216,.07)}
.drop-zone.has-files{border-color:var(--blue);background:rgba(30,75,216,.05)}
.drop-icon{font-size:2.2rem;margin-bottom:.75rem}
.drop-text{font-size:.9rem;color:var(--text-secondary)}
.drop-sub{font-size:.73rem;color:var(--text-dim);margin-top:.35rem}
#file-input{display:none}
.file-list{margin-bottom:1.2rem;display:flex;flex-direction:column;gap:.4rem;max-height:200px;overflow-y:auto}
.file-item{display:flex;align-items:center;justify-content:space-between;padding:.45rem .75rem;background:rgba(255,255,255,.03);border:1px solid var(--border);border-radius:8px;font-size:.78rem}
.file-item-name{color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;margin-right:.5rem}
.file-item-size{color:var(--text-dim);font-family:'JetBrains Mono',monospace;font-size:.68rem;flex-shrink:0}
.file-item-rm{background:none;border:none;color:var(--text-dim);cursor:pointer;padding:0 .25rem;font-size:.9rem;line-height:1;flex-shrink:0}
.file-item-rm:hover{color:var(--error)}
.btn-upload{width:100%;padding:.85rem;font-family:'Outfit',sans-serif;font-size:.95rem;font-weight:700;color:#fff;background:linear-gradient(135deg,var(--blue),var(--blue-bright));border:none;border-radius:11px;cursor:pointer;transition:all .2s}
.btn-upload:hover:not(:disabled){transform:translateY(-1px);box-shadow:0 6px 24px rgba(30,75,216,.4)}
.btn-upload:disabled{opacity:.4;cursor:not-allowed;transform:none}
.progress-wrap{height:6px;background:var(--border);border-radius:3px;margin-top:1rem;overflow:hidden;display:none}
.progress-bar{height:100%;width:0;background:linear-gradient(90deg,var(--blue),var(--blue-bright));border-radius:3px;transition:width .3s}
.status{padding:.7rem 1rem;border-radius:9px;font-size:.82rem;font-weight:600;margin-top:.9rem;display:none;text-align:center}
.status.success{background:var(--success-bg);color:var(--success);border:1px solid rgba(34,197,94,.2);display:block}
.status.error{background:var(--error-bg);color:var(--error);border:1px solid rgba(239,68,68,.2);display:block}
.footer{margin-top:2rem;font-size:.65rem;color:var(--text-dim);text-align:center;line-height:1.7}
.footer strong{color:var(--text-secondary)}
.expired{text-align:center;padding:2rem 0}
.expired-icon{font-size:3rem;margin-bottom:1rem}
.expired-title{font-size:1.1rem;font-weight:700;color:var(--text);margin-bottom:.5rem}
.expired-msg{font-size:.83rem;color:var(--text-secondary)}
</style>
</head>
<body>
<div class="card" id="card">
<div style="text-align:center;padding:2rem 0" id="loading">
<div style="font-size:1.5rem;margin-bottom:.5rem">🌪️</div>
<div style="color:var(--text-dim);font-size:.85rem">Loading…</div>
</div>
</div>
<div class="footer">
Built by <strong>Zac Gaetano</strong> &amp; <strong>Wild Dragon LLC</strong> &nbsp;·&nbsp;
In partnership with <strong>Broadcast Management Group</strong>
</div>
<script>
const token = location.pathname.split('/share/')[1];
let linkInfo = null;
let files = [];
async function init() {
try {
const r = await fetch(`/api/sharelinks/${token}/info`);
const d = await r.json();
if (!d.success) return showExpired(d.error);
linkInfo = d;
renderUploader();
} catch(e) { showExpired('Could not load upload link.'); }
}
function showExpired(msg) {
document.getElementById('card').innerHTML = `
<div class="expired">
<div class="expired-icon">⏰</div>
<div class="expired-title">Link Unavailable</div>
<div class="expired-msg">${msg || 'This upload link is no longer valid.'}</div>
</div>`;
}
function renderUploader() {
const expiry = linkInfo.expiresAt
? `Expires ${new Date(linkInfo.expiresAt).toLocaleString()}` : 'No expiry';
document.getElementById('card').innerHTML = `
<div class="brand">
<img class="brand-icon" src="/dragon-icon.png" alt="Dragon Wind"/>
<div class="brand-title">Dragon Wind Upload</div>
<div class="brand-label">${esc(linkInfo.label)}</div>
${linkInfo.folder ? `<div class="brand-folder">📁 ${esc(linkInfo.folder)}</div>` : ''}
</div>
<div class="drop-zone" id="drop-zone" onclick="document.getElementById('file-input').click()">
<div class="drop-icon">📂</div>
<div class="drop-text">Drop files here or click to browse</div>
<div class="drop-sub">${esc(expiry)}</div>
</div>
<input type="file" id="file-input" multiple/>
<div class="file-list" id="file-list"></div>
<button class="btn-upload" id="upload-btn" disabled onclick="doUpload()">Upload Files</button>
<div class="progress-wrap" id="progress-wrap"><div class="progress-bar" id="progress-bar"></div></div>
<div class="status" id="status"></div>`;
const dz = document.getElementById('drop-zone');
const fi = document.getElementById('file-input');
dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('dragover'); });
dz.addEventListener('dragleave', () => dz.classList.remove('dragover'));
dz.addEventListener('drop', e => { e.preventDefault(); dz.classList.remove('dragover'); addFiles(e.dataTransfer.files); });
fi.addEventListener('change', () => addFiles(fi.files));
}
function addFiles(newFiles) {
for (const f of newFiles) files.push(f);
renderFiles();
}
function removeFile(i) { files.splice(i, 1); renderFiles(); }
function renderFiles() {
const list = document.getElementById('file-list');
const btn = document.getElementById('upload-btn');
const dz = document.getElementById('drop-zone');
list.innerHTML = files.map((f, i) => `
<div class="file-item">
<span class="file-item-name">${esc(f.name)}</span>
<span class="file-item-size">${fmtSize(f.size)}</span>
<button class="file-item-rm" onclick="removeFile(${i})">✕</button>
</div>`).join('');
btn.disabled = files.length === 0;
dz.classList.toggle('has-files', files.length > 0);
}
async function doUpload() {
if (!files.length) return;
const btn = document.getElementById('upload-btn');
const status = document.getElementById('status');
const progressWrap = document.getElementById('progress-wrap');
const progressBar = document.getElementById('progress-bar');
btn.disabled = true; btn.textContent = 'Uploading…';
status.className = 'status'; status.textContent = '';
progressWrap.style.display = 'block'; progressBar.style.width = '0%';
const form = new FormData();
for (const f of files) form.append('files', f);
const xhr = new XMLHttpRequest();
xhr.open('POST', `/api/sharelinks/${token}/upload`);
xhr.upload.onprogress = e => {
if (e.lengthComputable) progressBar.style.width = (e.loaded/e.total*100) + '%';
};
xhr.onload = () => {
progressBar.style.width = '100%';
try {
const d = JSON.parse(xhr.responseText);
if (d.success) {
status.className = 'status success';
status.textContent = `${d.uploaded.length} file${d.uploaded.length!==1?'s':''} uploaded successfully!`;
files = []; renderFiles();
btn.textContent = 'Upload Files';
} else {
status.className = 'status error';
status.textContent = `${d.error}`;
btn.disabled = false; btn.textContent = 'Upload Files';
}
} catch(e) {
status.className = 'status error';
status.textContent = '❌ Upload failed — unexpected response';
btn.disabled = false; btn.textContent = 'Upload Files';
}
};
xhr.onerror = () => {
status.className = 'status error';
status.textContent = '❌ Network error — please try again';
btn.disabled = false; btn.textContent = 'Upload Files';
};
xhr.send(form);
}
function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function fmtSize(b) {
if (b < 1024) return b + ' B';
if (b < 1048576) return (b/1024).toFixed(1) + ' KB';
if (b < 1073741824) return (b/1048576).toFixed(1) + ' MB';
return (b/1073741824).toFixed(2) + ' GB';
}
init();
</script>
</body>
</html>