- 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>
198 lines
9.8 KiB
HTML
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> & <strong>Wild Dragon LLC</strong> ·
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
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>
|