461 lines
29 KiB
HTML
461 lines
29 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>Menu – Immich Drop</title>
|
||
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<script>
|
||
tailwind.config = { darkMode: 'class' };
|
||
</script>
|
||
</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-2xl 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">
|
||
<h1 class="text-2xl font-semibold">Create Upload Link</h1>
|
||
<div class="flex items-center gap-2">
|
||
<a id="linkPublicUploader" href="/" class="hidden rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Public uploader</a>
|
||
<a href="/logout" id="btnLogout" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Logout</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>
|
||
|
||
<section class="rounded-2xl border bg-white dark:bg-gray-800 dark:border-gray-700 p-4 space-y-4">
|
||
<div>
|
||
<div class="text-sm font-medium mb-1">Target album (optional)</div>
|
||
<div id="albumControls" class="space-y-2">
|
||
<div id="albumSelectWrap" class="hidden">
|
||
<select id="albumSelect" class="w-full rounded-lg border px-3 py-3 bg-white dark:bg-gray-900 dark:border-gray-700"></select>
|
||
</div>
|
||
<div id="albumInputWrap" class="hidden">
|
||
<input id="albumInput" placeholder="Album name (leave blank for none)" class="w-full rounded-lg border px-3 py-3 bg-white dark:bg-gray-900 dark:border-gray-700" />
|
||
<button id="btnCreateAlbum" class="mt-2 w-full sm:w-auto rounded-xl bg-black text-white px-4 py-3 dark:bg-white dark:text-black">Create album</button>
|
||
</div>
|
||
<div id="albumHint" class="text-sm text-gray-500"></div>
|
||
</div>
|
||
</div>
|
||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||
<div>
|
||
<div class="text-sm font-medium mb-1">Usage</div>
|
||
<select id="usage" class="w-full rounded-lg border px-3 py-3 bg-white dark:bg-gray-900 dark:border-gray-700">
|
||
<option value="1">One-time</option>
|
||
<option value="-1">Indefinite</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<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>
|
||
</div>
|
||
<div id="result" class="hidden rounded-xl border p-3 text-sm space-y-2">
|
||
<div id="linkRow" class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||
<input id="linkOut" class="flex-1 rounded-lg border px-3 py-3 bg-white dark:bg-gray-900 dark:border-gray-700" readonly />
|
||
<button id="btnCopy" type="button" class="rounded-xl border px-4 py-3 text-sm dark:border-gray-600" aria-label="Copy link">Copy</button>
|
||
</div>
|
||
<div>
|
||
<img id="qrImg" alt="QR" class="h-32 w-32 md:h-40 md:w-40" />
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Manage links -->
|
||
<section class="rounded-2xl border bg-white dark:bg-gray-800 dark:border-gray-700 p-4 space-y-3">
|
||
<div class="flex items-center justify-between gap-2 flex-wrap">
|
||
<h2 class="text-lg font-medium">Manage Links</h2>
|
||
<div class="flex items-center gap-2">
|
||
<input id="searchQ" placeholder="Search" class="rounded-lg border px-3 py-2 bg-white dark:bg-gray-900 dark:border-gray-700"/>
|
||
<select id="sortSel" class="rounded-lg border px-3 py-2 bg-white dark:bg-gray-900 dark:border-gray-700">
|
||
<option value="-created">Newest</option>
|
||
<option value="created">Oldest</option>
|
||
<option value="-expires">Expiry desc</option>
|
||
<option value="expires">Expiry asc</option>
|
||
<option value="name">Name A–Z</option>
|
||
<option value="-name">Name Z–A</option>
|
||
</select>
|
||
<button id="btnRefresh" class="rounded-xl border px-3 py-2 text-sm dark:border-gray-600">Refresh</button>
|
||
</div>
|
||
</div>
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full text-sm">
|
||
<thead>
|
||
<tr class="text-left border-b dark:border-gray-700">
|
||
<th class="py-2"><input id="chkAll" type="checkbox"/></th>
|
||
<th class="py-2" style="width: 45%;">Name</th>
|
||
<th class="py-2" style="width: 18%;">Status</th>
|
||
<th class="py-2">Uses</th>
|
||
<th class="py-2">Expires</th>
|
||
<th class="py-2">Album</th>
|
||
<th class="py-2">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="invitesTBody"></tbody>
|
||
</table>
|
||
</div>
|
||
<!-- Footer inside the panel to keep bulk actions within the box -->
|
||
<div class="pt-3 mt-2 border-t dark:border-gray-700 flex items-center justify-end gap-2 flex-wrap">
|
||
<button id="btnDisableSel" class="rounded-xl border px-3 py-2 text-sm dark:border-gray-600">Disable selected</button>
|
||
<button id="btnEnableSel" class="rounded-xl border px-3 py-2 text-sm dark:border-gray-600">Enable selected</button>
|
||
<button id="btnDeleteSel" class="rounded-xl border px-3 py-2 text-sm border-red-400 text-red-600 dark:border-red-600">Delete selected</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="text-xs text-gray-500">
|
||
If album listing or creation is forbidden by your token, specify a fixed album in the .env file as IMMICH_ALBUM_NAME.
|
||
</section>
|
||
</div>
|
||
|
||
<script src="/static/header.js"></script>
|
||
<script>
|
||
const albumSelectWrap = document.getElementById('albumSelectWrap');
|
||
const albumSelect = document.getElementById('albumSelect');
|
||
const albumInputWrap = document.getElementById('albumInputWrap');
|
||
const albumInput = document.getElementById('albumInput');
|
||
const albumHint = document.getElementById('albumHint');
|
||
const btnCreateAlbum = document.getElementById('btnCreateAlbum');
|
||
const btnCreate = document.getElementById('btnCreate');
|
||
const usage = document.getElementById('usage');
|
||
const days = document.getElementById('days');
|
||
const result = document.getElementById('result');
|
||
const btnLogout = document.getElementById('btnLogout');
|
||
const btnTheme = document.getElementById('btnTheme');
|
||
const btnPing = document.getElementById('btnPing');
|
||
const pingStatus = document.getElementById('pingStatus');
|
||
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');
|
||
result.classList.remove('hidden');
|
||
}
|
||
|
||
async function loadAlbums(){
|
||
try {
|
||
const r = await fetch('/api/albums');
|
||
if (r.status === 403) {
|
||
albumHint.textContent = 'Listing albums is forbidden with current credentials. You can still type a new album name or leave it blank to upload without an album. If IMMICH_ALBUM_NAME is set in .env, that will be used for non-invite uploads.';
|
||
albumInputWrap.classList.remove('hidden');
|
||
return;
|
||
}
|
||
const list = await r.json();
|
||
if (Array.isArray(list)){
|
||
const opts = [{id:'', name:'— No album —'}].concat(list.map(a => ({id:a.id, name:(a.albumName || a.title || a.id)})));
|
||
albumSelect.innerHTML = opts.map(a => `<option value="${a.id}">${a.name}</option>`).join('');
|
||
albumSelectWrap.classList.remove('hidden');
|
||
albumInputWrap.classList.remove('hidden');
|
||
albumHint.textContent = 'Pick an existing album, or type a new name and click Create album. Select “— No album —” or leave the field blank to skip album association.';
|
||
}
|
||
} catch (e) {
|
||
albumHint.textContent = 'Failed to load albums.';
|
||
}
|
||
}
|
||
|
||
btnCreateAlbum.onclick = async () => {
|
||
const name = albumInput.value.trim();
|
||
if (!name) return;
|
||
try{
|
||
const r = await fetch('/api/albums', { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify({ name }) });
|
||
const j = await r.json().catch(()=>({}));
|
||
if(!r.ok){ showResult('err', j.error || 'Album create failed'); return; }
|
||
showResult('ok', `Album created: ${j.albumName || j.id || name}`);
|
||
try { await loadAlbums(); } catch {}
|
||
}catch(err){ showResult('err', String(err)); }
|
||
};
|
||
|
||
btnCreate.onclick = async () => {
|
||
let albumId = null, albumName = null;
|
||
if (!albumSelectWrap.classList.contains('hidden') && albumSelect.value) {
|
||
albumId = albumSelect.value;
|
||
} else if (albumInput.value.trim()) {
|
||
albumName = albumInput.value.trim();
|
||
}
|
||
const payload = { maxUses: parseInt(usage.value, 10) };
|
||
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(()=>({}));
|
||
if(!r.ok){ showResult('err', j.error || 'Failed to create link'); return; }
|
||
const link = j.absoluteUrl || (location.origin + j.url);
|
||
showResult('ok', '');
|
||
linkOut.value = link;
|
||
// Build QR via backend PNG generator (no external libs)
|
||
qrImg.src = `/api/qr?text=${encodeURIComponent(link)}`;
|
||
// Also refresh the managed links list and highlight the new entry
|
||
if (j && j.token) { try { LAST_CREATED_TOKEN = j.token; } catch {} }
|
||
try { await loadInvites(); } catch {}
|
||
}catch(err){ showResult('err', String(err)); }
|
||
};
|
||
|
||
// Logout handled via plain link to /logout (clears session + redirects)
|
||
btnCopy.onclick = ()=>{
|
||
const text = linkOut.value || '';
|
||
if (!text) return;
|
||
const flash = ()=>{ btnCopy.textContent='Copied'; setTimeout(()=>btnCopy.textContent='Copy', 1200); };
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(text).then(flash).catch(()=>{
|
||
try{ const ta=document.createElement('textarea'); ta.value=text; ta.setAttribute('readonly',''); ta.style.position='absolute'; ta.style.left='-9999px'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); flash(); }catch{}
|
||
});
|
||
} else {
|
||
try{ const ta=document.createElement('textarea'); ta.value=text; ta.setAttribute('readonly',''); ta.style.position='absolute'; ta.style.left='-9999px'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); flash(); }catch{}
|
||
}
|
||
};
|
||
// header.js wires theme + ping and shows banner consistently
|
||
|
||
loadAlbums();
|
||
|
||
// --- Manage UI logic ---
|
||
const searchQ = document.getElementById('searchQ');
|
||
const sortSel = document.getElementById('sortSel');
|
||
const btnRefresh = document.getElementById('btnRefresh');
|
||
const invitesTBody = document.getElementById('invitesTBody');
|
||
const chkAll = document.getElementById('chkAll');
|
||
const btnDisableSel = document.getElementById('btnDisableSel');
|
||
const btnEnableSel = document.getElementById('btnEnableSel');
|
||
const btnDeleteSel = document.getElementById('btnDeleteSel');
|
||
let INVITES = [];
|
||
let LAST_CREATED_TOKEN = null;
|
||
|
||
async function loadInvites(){
|
||
const params = new URLSearchParams();
|
||
const q = (searchQ.value||'').trim(); if (q) params.set('q', q);
|
||
const sort = (sortSel.value||'').trim(); if (sort) params.set('sort', sort);
|
||
try{
|
||
const r = await fetch('/api/invites?'+params.toString());
|
||
const j = await r.json();
|
||
INVITES = (j && j.items) ? j.items : [];
|
||
}catch{ INVITES = []; }
|
||
renderInvites();
|
||
}
|
||
function fmtDayMonth(iso){ try{ const d = new Date(iso); return d.toLocaleDateString(undefined,{ day:'2-digit', month:'short' }); }catch{return '—';} }
|
||
function statusBadge(row){
|
||
// Red reasons override disabled (yellow)
|
||
const inactive = String(row.inactiveReason||'');
|
||
if (/expired|claimed|exhausted/i.test(inactive)){
|
||
const label = inactive.charAt(0).toUpperCase()+inactive.slice(1);
|
||
return `<span class="inline-flex items-center rounded-full bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300 px-2 py-0.5">${label}</span>`;
|
||
}
|
||
if (row.active){
|
||
return `<span class="inline-flex items-center rounded-full bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300 px-2 py-0.5">Active</span>`;
|
||
}
|
||
if (/disabled/i.test(inactive)){
|
||
return `<span class="inline-flex items-center rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 px-2 py-0.5">Disabled</span>`;
|
||
}
|
||
return `<span>${row.active?'Active':'Inactive'}</span>`;
|
||
}
|
||
function renderInvites(){
|
||
invitesTBody.innerHTML = INVITES.map(row => {
|
||
const status = statusBadge(row);
|
||
const uses = (typeof row.remaining==='number') ? `${row.used||0}/${(row.maxUses<0)?'∞':row.maxUses}` : `${row.used||0}/${(row.maxUses<0)?'∞':row.maxUses??'?'}`;
|
||
const exp = row.expiresAt ? `<span title="${new Date(row.expiresAt).toLocaleString()}">${fmtDayMonth(row.expiresAt)}</span>` : '—';
|
||
const url = location.origin + '/invite/' + row.token;
|
||
return `
|
||
<tr class="border-b dark:border-gray-800" data-token="${row.token}">
|
||
<td class="py-2"><input class="chkRow" type="checkbox" data-token="${row.token}"/></td>
|
||
<td class="py-2" style="width:45%;">
|
||
<input class="inName w-full rounded border px-2 py-1 bg-white dark:bg-gray-900 dark:border-gray-700" data-token="${row.token}" value="${(row.name||'').replaceAll('"','"')}" title="${(row.name||'').replaceAll('"','"')}"/>
|
||
</td>
|
||
<td class="py-2">${status}</td>
|
||
<td class="py-2">${uses}</td>
|
||
<td class="py-2">
|
||
<input class="inExpires w-36 rounded border px-2 py-1 bg-white dark:bg-gray-900 dark:border-gray-700" type="date" data-token="${row.token}" value="${row.expiresAt? new Date(row.expiresAt).toISOString().slice(0,10):''}" title="${row.expiresAt? new Date(row.expiresAt).toLocaleString():''}"/>
|
||
</td>
|
||
<td class="py-2">${row.albumName || '—'}</td>
|
||
<td class="py-2">
|
||
<div class="flex items-center gap-1 whitespace-nowrap">
|
||
<button class="btnDetails group relative rounded-xl border px-2 py-1 text-xs dark:border-gray-600 inline-flex items-center" data-token="${row.token}" aria-label="Show details">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12 3a9 9 0 1 1 0 18A9 9 0 0 1 12 3zm0 4a1.25 1.25 0 1 0 0 2.5A1.25 1.25 0 0 0 12 7zm-1.5 4.5h3v6h-3v-6z"/></svg>
|
||
<span class="pointer-events-none absolute -top-8 left-1/2 -translate-x-1/2 rounded bg-black text-white text-xs px-2 py-1 opacity-0 group-hover:opacity-100 dark:bg-gray-700">Details</span>
|
||
</button>
|
||
<button class="btnQR group relative rounded-xl border px-2 py-1 text-xs dark:border-gray-600 inline-flex items-center" data-url="${url}" aria-label="Show QR code">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M3 3h8v8H3V3zm2 2v4h4V5H5zm8-2h8v8h-8V3zm2 2v4h4V5h-4zM3 13h8v8H3v-8zm2 2v4h4v-4H5zm12 0h-2v2h2v2h-4v2h6v-6h-2v0zm-4-2h2v2h-2v-2zm6-2h2v2h-2v-2z"/></svg>
|
||
<span class="pointer-events-none absolute -top-8 left-1/2 -translate-x-1/2 rounded bg-black text-white text-xs px-2 py-1 opacity-0 group-hover:opacity-100 dark:bg-gray-700">Show QR</span>
|
||
</button>
|
||
<a class="group relative rounded-xl border px-2 py-1 text-xs dark:border-gray-600 inline-flex items-center" target="_blank" href="${url}" aria-label="Open link">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M14 3h7v7h-2V6.414l-9.293 9.293-1.414-1.414L17.586 5H14V3z"/><path d="M5 5h6v2H7v10h10v-4h2v6H5V5z"/></svg>
|
||
<span class="pointer-events-none absolute -top-8 left-1/2 -translate-x-1/2 rounded bg-black text-white text-xs px-2 py-1 opacity-0 group-hover:opacity-100 dark:bg-gray-700">Open</span>
|
||
</a>
|
||
<button class="btnCopyLink group relative rounded-xl border px-2 py-1 text-xs dark:border-gray-600 inline-flex items-center" data-url="${url}" aria-label="Copy link">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M16 1H4a2 2 0 0 0-2 2v12h2V3h12V1zm3 4H8a2 2 0 0 0-2 2v14h13a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2zm0 16H8V7h11v14z"/></svg>
|
||
<span class="pointer-events-none absolute -top-8 left-1/2 -translate-x-1/2 rounded bg-black text-white text-xs px-2 py-1 opacity-0 group-hover:opacity-100 dark:bg-gray-700">Copy</span>
|
||
</button>
|
||
<button class="btnSave group relative rounded-xl border px-2 py-1 text-xs dark:border-gray-600 inline-flex items-center opacity-50 cursor-not-allowed" data-token="${row.token}" aria-label="Save changes" disabled>
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M17 3H7a2 2 0 0 0-2 2v14l7-3 7 3V5a2 2 0 0 0-2-2zM7 5h10v10l-5-2-5 2V5z"/></svg>
|
||
<span class="pointer-events-none absolute -top-8 left-1/2 -translate-x-1/2 rounded bg-black text-white text-xs px-2 py-1 opacity-0 group-hover:opacity-100 dark:bg-gray-700">Save</span>
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>`;
|
||
}).join('');
|
||
// wire row actions
|
||
// helper to compute change state and toggle Save
|
||
function updateSaveState(token){
|
||
const inName = invitesTBody.querySelector(`.inName[data-token="${token}"]`);
|
||
const inExp = invitesTBody.querySelector(`.inExpires[data-token="${token}"]`);
|
||
const btn = invitesTBody.querySelector(`.btnSave[data-token="${token}"]`);
|
||
if (!inName || !inExp || !btn) return;
|
||
const origName = inName.getAttribute('data-original') || '';
|
||
const origExp = inExp.getAttribute('data-original') || '';
|
||
const curName = (inName.value||'').trim();
|
||
const curExp = inExp.value || '';
|
||
const changed = (curName !== origName) || (curExp !== origExp);
|
||
btn.disabled = !changed;
|
||
btn.classList.toggle('opacity-50', !changed);
|
||
btn.classList.toggle('cursor-not-allowed', !changed);
|
||
}
|
||
// set original values as data attributes and wire change listeners
|
||
INVITES.forEach(row => {
|
||
const token = row.token;
|
||
const inName = invitesTBody.querySelector(`.inName[data-token="${token}"]`);
|
||
const inExp = invitesTBody.querySelector(`.inExpires[data-token="${token}"]`);
|
||
if (inName) inName.setAttribute('data-original', (row.name||'').trim());
|
||
const dStr = row.expiresAt? new Date(row.expiresAt).toISOString().slice(0,10) : '';
|
||
if (inExp) inExp.setAttribute('data-original', dStr);
|
||
if (inName) inName.addEventListener('input', ()=>updateSaveState(token));
|
||
if (inExp) inExp.addEventListener('change', ()=>updateSaveState(token));
|
||
updateSaveState(token);
|
||
});
|
||
|
||
invitesTBody.querySelectorAll('.btnSave').forEach(btn => btn.onclick = async ()=>{
|
||
const token = btn.getAttribute('data-token');
|
||
if (btn.disabled) return;
|
||
const name = invitesTBody.querySelector(`.inName[data-token="${token}"]`).value.trim();
|
||
const expVal = invitesTBody.querySelector(`.inExpires[data-token="${token}"]`).value;
|
||
const payload = { name };
|
||
if (expVal) {
|
||
// Set to end-of-day local to avoid off-by-one day on save
|
||
const dt = new Date(expVal);
|
||
dt.setHours(23,59,59,999);
|
||
payload.expiresAt = dt.toISOString().slice(0,19);
|
||
} else { payload.expiresAt = null; }
|
||
try{
|
||
const r = await fetch(`/api/invite/${token}`, { method:'PATCH', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify(payload) });
|
||
if (!r.ok) throw new Error('Update failed');
|
||
await loadInvites();
|
||
}catch(e){ showResult('err', String(e.message||e)); }
|
||
});
|
||
invitesTBody.querySelectorAll('.btnDetails').forEach(btn => btn.onclick = async ()=>{
|
||
const token = btn.getAttribute('data-token');
|
||
try{
|
||
const r = await fetch(`/api/invite/${token}/uploads`);
|
||
const j = await r.json();
|
||
const items = (j && j.items) ? j.items : [];
|
||
const html = `
|
||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||
<div class="max-w-3xl w-full rounded-2xl bg-white dark:bg-gray-900 border dark:border-gray-700 p-4">
|
||
<div class="flex items-center justify-between mb-2">
|
||
<div class="text-lg font-medium">Uploads</div>
|
||
<button class="dlgClose rounded-lg border px-2 py-1 text-xs dark:border-gray-600">Close</button>
|
||
</div>
|
||
<div class="max-h-[60vh] overflow-auto">
|
||
${items.length? `<table class="w-full text-sm"><thead><tr class="text-left border-b dark:border-gray-700"><th class="py-1">When</th><th class="py-1">IP</th><th class="py-1">Filename</th><th class="py-1">Size</th><th class="py-1">Fingerprint</th></tr></thead><tbody>` + items.map(it=>`<tr class="border-b dark:border-gray-800"><td class="py-1">${new Date(it.uploadedAt).toLocaleString()}</td><td class="py-1">${it.ip||''}</td><td class="py-1">${it.filename||''}</td><td class="py-1">${(it.size||0).toLocaleString()}</td><td class="py-1">${(it.fingerprint||'').slice(0,16)}</td></tr>`).join('') + `</tbody></table>` : '<div class="text-sm text-gray-500">No uploads yet.</div>'}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
const wrap = document.createElement('div');
|
||
wrap.innerHTML = html;
|
||
const dlg = wrap.firstElementChild;
|
||
document.body.appendChild(dlg);
|
||
dlg.querySelectorAll('.dlgClose').forEach(b=> b.onclick = ()=>{ try{ dlg.remove(); }catch{} });
|
||
}catch(e){ showResult('err', 'Failed to load uploads'); }
|
||
});
|
||
invitesTBody.querySelectorAll('.btnQR').forEach(btn => btn.onclick = ()=>{
|
||
const url = btn.getAttribute('data-url');
|
||
const html = `
|
||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||
<div class="max-w-md w-full rounded-2xl bg-white dark:bg-gray-900 border dark:border-gray-700 p-4">
|
||
<div class="flex items-center justify-between mb-2">
|
||
<div class="text-lg font-medium">QR Code</div>
|
||
<button class="dlgClose rounded-lg border px-2 py-1 text-xs dark:border-gray-600">Close</button>
|
||
</div>
|
||
<div class="flex items-center gap-4">
|
||
<img src="/api/qr?text=${encodeURIComponent(url)}" alt="QR" class="h-40 w-40"/>
|
||
<div class="text-sm break-all">${url}</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
const wrap = document.createElement('div');
|
||
wrap.innerHTML = html;
|
||
const dlg = wrap.firstElementChild;
|
||
document.body.appendChild(dlg);
|
||
dlg.querySelectorAll('.dlgClose').forEach(b=> b.onclick = ()=>{ try{ dlg.remove(); }catch{} });
|
||
});
|
||
invitesTBody.querySelectorAll('.btnCopyLink').forEach(btn => btn.onclick = ()=>{
|
||
const url = btn.getAttribute('data-url');
|
||
const flash = ()=>{ btn.setAttribute('data-old', btn.innerHTML); btn.innerHTML='Copied'; setTimeout(()=>{ const old=btn.getAttribute('data-old'); if(old) btn.innerHTML=old; }, 1000); };
|
||
if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(url).then(flash).catch(()=>{}); }
|
||
});
|
||
if (chkAll) chkAll.checked = false;
|
||
// Highlight and scroll to newly created invite if present
|
||
if (LAST_CREATED_TOKEN) {
|
||
const tr = invitesTBody.querySelector(`tr[data-token="${LAST_CREATED_TOKEN}"]`);
|
||
if (tr) {
|
||
tr.classList.add('bg-yellow-50','dark:bg-yellow-900/30');
|
||
try { tr.scrollIntoView({ behavior:'smooth', block:'center' }); } catch {}
|
||
setTimeout(()=>{ try{ tr.classList.remove('bg-yellow-50','dark:bg-yellow-900/30'); }catch{} }, 1600);
|
||
}
|
||
LAST_CREATED_TOKEN = null;
|
||
}
|
||
}
|
||
btnRefresh.onclick = loadInvites;
|
||
searchQ.oninput = ()=>{ clearTimeout(searchQ._t); searchQ._t = setTimeout(loadInvites, 300); };
|
||
sortSel.onchange = loadInvites;
|
||
chkAll.onchange = ()=>{ invitesTBody.querySelectorAll('.chkRow').forEach(c=>{ c.checked = chkAll.checked; }); };
|
||
btnDisableSel.onclick = async ()=>{
|
||
const toks = Array.from(invitesTBody.querySelectorAll('.chkRow:checked')).map(x=>x.getAttribute('data-token'));
|
||
if (!toks.length) return;
|
||
try{
|
||
const r = await fetch('/api/invites/bulk', { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify({ tokens: toks, action:'disable' }) });
|
||
if (!r.ok) throw new Error('Bulk disable failed');
|
||
await loadInvites();
|
||
}catch(e){ showResult('err', String(e.message||e)); }
|
||
};
|
||
btnEnableSel.onclick = async ()=>{
|
||
const toks = Array.from(invitesTBody.querySelectorAll('.chkRow:checked')).map(x=>x.getAttribute('data-token'));
|
||
if (!toks.length) return;
|
||
try{
|
||
const r = await fetch('/api/invites/bulk', { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify({ tokens: toks, action:'enable' }) });
|
||
if (!r.ok) throw new Error('Bulk enable failed');
|
||
await loadInvites();
|
||
}catch(e){ showResult('err', String(e.message||e)); }
|
||
};
|
||
btnDeleteSel.onclick = async ()=>{
|
||
const toks = Array.from(invitesTBody.querySelectorAll('.chkRow:checked')).map(x=>x.getAttribute('data-token'));
|
||
if (!toks.length) return;
|
||
if (!confirm('Are you sure? This cannot be undone.')) return;
|
||
try{
|
||
const r = await fetch('/api/invites/delete', { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify({ tokens: toks }) });
|
||
if (!r.ok) throw new Error('Delete failed');
|
||
await loadInvites();
|
||
}catch(e){ showResult('err', String(e.message||e)); }
|
||
};
|
||
|
||
// Initial load
|
||
loadInvites();
|
||
</script>
|
||
</body>
|
||
</html>
|