feat: Add admin file viewer with thumbnail generation and gallery

Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
This commit is contained in:
2026-05-18 15:45:06 +00:00
parent 89b7b1bd23
commit ca5131f497
2 changed files with 285 additions and 0 deletions

View File

@@ -117,6 +117,40 @@
</div>
</section>
<!-- Manage Files -->
<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 Files</h2>
<div class="flex items-center gap-2">
<input id="filesSearchQ" placeholder="Search" class="rounded-lg border px-3 py-2 bg-white dark:bg-gray-900 dark:border-gray-700"/>
<select id="filesSortSel" class="rounded-lg border px-3 py-2 bg-white dark:bg-gray-900 dark:border-gray-700">
<option value="-modified">Newest</option>
<option value="modified">Oldest</option>
<option value="name">Name AZ</option>
<option value="-name">Name ZA</option>
<option value="-file_count">File count desc</option>
<option value="file_count">File count asc</option>
<option value="-total_size">Size desc</option>
<option value="total_size">Size asc</option>
</select>
<button id="btnFilesRefresh" 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" style="width: 50%;">Folder</th>
<th class="py-2">Files</th>
<th class="py-2">Size</th>
<th class="py-2">Modified</th>
</tr>
</thead>
<tbody id="filesTBody"></tbody>
</table>
</div>
</section>
<section class="text-xs text-gray-500">
Admin link page
</section>
@@ -441,6 +475,100 @@
// Initial load
loadInvites();
// --- Manage Files UI logic ---
const filesSearchQ = document.getElementById('filesSearchQ');
const filesSortSel = document.getElementById('filesSortSel');
const btnFilesRefresh = document.getElementById('btnFilesRefresh');
const filesTBody = document.getElementById('filesTBody');
let DIRS = [];
function humanSize(bytes){
if (!bytes) return '0 B';
const k = 1024, sizes = ['B','KB','MB','GB','TB'];
const i = Math.floor(Math.log(bytes)/Math.log(k));
return (bytes/Math.pow(k,i)).toFixed(1)+' '+sizes[i];
}
function fmtDayMonthForFiles(iso){ try{ const d = new Date(iso); return d.toLocaleDateString(undefined,{ day:'2-digit', month:'short' }); }catch{return '—';} }
async function loadDirs(){
const params = new URLSearchParams();
const q = (filesSearchQ.value||'').trim(); if (q) params.set('q', q);
const sort = (filesSortSel.value||'').trim(); if (sort) params.set('sort', sort);
try{
const r = await fetch('/api/files/dirs?'+params.toString());
const j = await r.json();
DIRS = (j && j.items) ? j.items : [];
} catch { DIRS = []; }
renderDirs();
}
function renderDirs(){
filesTBody.innerHTML = DIRS.map(dir => {
const modified = `<span title="${new Date(dir.modified).toLocaleString()}">${fmtDayMonthForFiles(dir.modified)}</span>`;
return `
<tr class="border-b dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900/50 cursor-pointer" data-path-b64="${dir.path_b64}">
<td class="py-2">${dir.path}</td>
<td class="py-2">${dir.file_count}</td>
<td class="py-2">${humanSize(dir.total_size)}</td>
<td class="py-2">${modified}</td>
</tr>`;
}).join('');
filesTBody.querySelectorAll('tr').forEach(tr => {
tr.onclick = () => showGallery(tr.dataset.pathB64);
});
}
async function showGallery(path_b64) {
if (!path_b64) return;
let files = [];
try {
const r = await fetch(`/api/files/list/${path_b64}`);
if (!r.ok) throw new Error(await r.text());
const j = await r.json();
files = j.items || [];
} catch (e) {
showResult('err', 'Failed to load files: ' + String(e));
return;
}
const html = `
<div class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div class="max-w-6xl w-full h-[90vh] rounded-2xl bg-white dark:bg-gray-900 border dark:border-gray-700 p-4 flex flex-col">
<div class="flex items-center justify-between mb-2">
<div class="text-lg font-medium">Files</div>
<button class="dlgClose rounded-lg border px-2 py-1 text-xs dark:border-gray-600">Close</button>
</div>
<div class="flex-1 overflow-auto">
${files.length ? `<div class="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-4">` + files.map(it => {
const thumbUrl = it.is_image ? `/api/files/thumb/${it.path_b64}` : '';
const fullUrl = `/api/files/full/${it.path_b64}`;
return `<a href="${fullUrl}" target="_blank" title="${it.name.replaceAll('"','&quot;')}\n${humanSize(it.size)}" class="group relative aspect-square rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
${it.is_image ?
`<img src="${thumbUrl}" class="w-full h-full object-cover" loading="lazy"/>` :
`<svg xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>`
}
<div class="absolute bottom-0 left-0 right-0 bg-black/50 text-white text-xs p-1 truncate pointer-events-none">${it.name}</div>
</a>`
}).join('') + `</div>` : '<div class="text-sm text-gray-500">No files in this directory.</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 {} });
}
btnFilesRefresh.onclick = loadDirs;
filesSearchQ.oninput = () => { clearTimeout(filesSearchQ._t); filesSearchQ._t = setTimeout(loadDirs, 300); };
filesSortSel.onchange = loadDirs;
loadDirs();
</script>
</body>
</html>