add detach files feature, utf8 filename fix, and error pages

This commit is contained in:
dev
2026-09-07 09:42:28 +03:00
parent 71803d4dcf
commit f13031e9b3
7 changed files with 258 additions and 23 deletions
+61 -13
View File
@@ -23,6 +23,12 @@
<div><label>Группа</label><select id="groupFilter"><option value="">Все группы</option></select></div>
<div><label>С</label><input type="date" id="dateFrom"></div>
<div><label>По</label><input type="date" id="dateTo"></div>
<div><label>Тип</label>
<div class="view-toggle" id="tabToggle">
<button data-mode="attached" onclick="setMode('attached')">Прикреплённые</button>
<button data-mode="detached" onclick="setMode('detached')">Откреплённые</button>
</div>
</div>
</div>
<div class="grid" id="grid"><div class="empty">Загрузка...</div></div>
<div class="pager" id="pager"></div>
@@ -43,6 +49,20 @@ let currentFiles = [];
let groupsLoaded = false;
let studentsLoaded = false;
let searchTimer = null;
let mode = 'attached';
function setMode(m) {
mode = m === 'detached' ? 'detached' : 'attached';
document.querySelectorAll('#tabToggle button').forEach(b => {
b.classList.toggle('active', b.dataset.mode === mode);
});
const show = mode === 'attached';
const keep = ['searchInput', 'studentFilter', 'groupFilter', 'dateFrom', 'dateTo'].map(id => document.getElementById(id));
if (!show) { keep.forEach(el => { el.value = ''; }); }
keep.forEach(el => { el.parentElement.style.display = show ? '' : 'none'; });
page = 1;
loadFiles();
}
function isImageFile(name) {
return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
@@ -63,14 +83,21 @@ function fileCardHTML(f) {
const target = isImageFile(f.name)
? `href="#" onclick="showImg('${url}');return false;"`
: `href="${url}" download`;
const meta = mode === 'detached'
? `<div class="group" style="color:#f59e0b">Откреплён от записи, не привязан</div>`
: `<div class="group">${esc(f.student_name)} · ${esc(f.group_name)}</div>`;
const action = mode === 'detached'
? `<button class="f-action f-del" onclick="deleteFile(${f.id})" title="Удалить файл навсегда">Удалить</button>`
: `<button class="f-action" onclick="detachFile(${f.id})" title="Открепить файл от записи">Открепить</button>`;
return `
<div class="card">
${thumb}
<div class="info">
<div class="file-name"><a ${target}>${esc(f.name)}</a></div>
<div class="group">${esc(f.student_name)} · ${esc(f.group_name)}</div>
${meta}
<div class="time">${new Date(f.created_at).toLocaleString('ru')}${f.size ? ' · ' + humanSize(f.size) : ''}</div>
</div>
<div class="f-actions">${action}</div>
</div>
`;
}
@@ -79,13 +106,29 @@ function renderGrid() {
const grid = document.getElementById('grid');
if (!currentFiles.length) {
grid.className = 'grid';
grid.innerHTML = '<div class="empty">Файлов не найдено</div>';
grid.innerHTML = mode === 'detached'
? '<div class="empty">Откреплённых файлов нет</div>'
: '<div class="empty">Файлов не найдено</div>';
return;
}
grid.className = 'grid';
grid.innerHTML = currentFiles.map(fileCardHTML).join('');
}
async function detachFile(id) {
if (!confirm('Открепить файл от записи? Он переместится во вкладку «Откреплённые».')) return;
const res = await fetch(`${API}/api/files/${id}/detach`, { method: 'POST', headers: hdr() });
if (res.ok) { showToast('Файл откреплён'); loadFiles(); }
else { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); }
}
async function deleteFile(id) {
if (!confirm('Удалить файл навсегда? Отменить это действие нельзя.')) return;
const res = await fetch(`${API}/api/files/${id}`, { method: 'DELETE', headers: hdr() });
if (res.ok) { showToast('Файл удалён'); loadFiles(); }
else { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); }
}
async function loadGroupOptions() {
if (groupsLoaded) return;
const sel = document.getElementById('groupFilter');
@@ -111,19 +154,21 @@ async function loadStudentOptions() {
async function loadFiles() {
const p = new URLSearchParams();
const q = document.getElementById('searchInput').value;
const sn = document.getElementById('studentFilter').value;
const gid = document.getElementById('groupFilter').value;
const df = document.getElementById('dateFrom').value;
const dt = document.getElementById('dateTo').value;
if (q) p.set('search', q);
if (sn) p.set('student_name', sn);
if (gid) p.set('group_id', gid);
if (df) p.set('date_from', df);
if (dt) p.set('date_to', dt);
if (mode === 'attached') {
const q = document.getElementById('searchInput').value;
const sn = document.getElementById('studentFilter').value;
const gid = document.getElementById('groupFilter').value;
const df = document.getElementById('dateFrom').value;
const dt = document.getElementById('dateTo').value;
if (q) p.set('search', q);
if (sn) p.set('student_name', sn);
if (gid) p.set('group_id', gid);
if (df) p.set('date_from', df);
if (dt) p.set('date_to', dt);
}
p.set('limit', PAGE_SIZE);
p.set('offset', (page - 1) * PAGE_SIZE);
const res = await fetch(`${API}/api/files?${p}`, { headers: hdr() });
const res = await fetch(`${API}/api/files${mode === 'detached' ? '/detached' : ''}?${p}`, { headers: hdr() });
const data = await res.json();
currentFiles = data.files;
totalFiles = data.total;
@@ -148,6 +193,9 @@ document.getElementById('searchInput').addEventListener('input', () => {
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
document.querySelectorAll('#tabToggle button').forEach(b => {
b.classList.toggle('active', b.dataset.mode === mode);
});
loadGroupOptions();
loadStudentOptions();
loadFiles();