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:
@@ -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 A–Z</option>
|
||||
<option value="-name">Name Z–A</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('"','"')}\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>
|
||||
|
||||
Reference in New Issue
Block a user