diff --git a/db/init.sql b/db/init.sql
index de932ac..5364117 100644
--- a/db/init.sql
+++ b/db/init.sql
@@ -59,9 +59,10 @@ CREATE INDEX IF NOT EXISTS idx_group_photos_group_id ON group_photos(group_id);
CREATE TABLE IF NOT EXISTS project_files (
id SERIAL PRIMARY KEY,
- entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
+ entry_id INT REFERENCES entries(id) ON DELETE CASCADE,
token VARCHAR(64) UNIQUE NOT NULL,
path VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
- created_at TIMESTAMPTZ DEFAULT now()
+ created_at TIMESTAMPTZ DEFAULT now(),
+ detached_at TIMESTAMPTZ
);
diff --git a/db/migration.sql b/db/migration.sql
index 4deb86f..826c82e 100644
--- a/db/migration.sql
+++ b/db/migration.sql
@@ -45,6 +45,9 @@ UPDATE project_files SET token = md5(random()::text || clock_timestamp()::text)
ALTER TABLE project_files ALTER COLUMN token SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS project_files_token_key ON project_files(token);
+ALTER TABLE project_files ALTER COLUMN entry_id DROP NOT NULL;
+ALTER TABLE project_files ADD COLUMN IF NOT EXISTS detached_at TIMESTAMPTZ;
+
ALTER TABLE entries ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS group_photos (
diff --git a/public/admin.css b/public/admin.css
index 03a94dc..8573f75 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -46,9 +46,20 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.card .desc{color:var(--muted);font-size:.85rem;margin-top:8px;line-height:1.4;white-space:pre-wrap}
.card .time{color:var(--muted);font-size:.75rem;margin-top:8px}
.entry-files{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
+.entry-files .f-item{display:inline-flex;align-items:center;gap:2px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:2px}
+.entry-files .f-item:hover{border-color:var(--accent)}
+.entry-files .f-item a{display:inline-flex;align-items:center;gap:4px;color:var(--text);text-decoration:none;padding:2px 6px;font-size:.75rem;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.entry-files .f-item a:hover{color:var(--accent)}
+.entry-files .f-item a::before{content:'📎'}
+.entry-files .f-detach{background:none;border:none;color:var(--muted);cursor:pointer;font-size:.75rem;line-height:1;padding:2px 5px;border-radius:4px}
+.entry-files .f-detach:hover{color:#ef4444;background:rgba(239,68,68,.1)}
.entry-files a{display:inline-flex;align-items:center;gap:4px;background:var(--bg);border:1px solid var(--border);color:var(--text);text-decoration:none;border-radius:6px;padding:4px 8px;font-size:.75rem;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.entry-files a:hover{border-color:var(--accent);color:var(--accent)}
.entry-files a::before{content:'📎'}
+.f-actions{display:flex;align-items:center;flex-shrink:0}
+.f-action{background:none;border:1px solid var(--border);color:var(--muted);cursor:pointer;border-radius:8px;padding:6px 10px;font-size:.75rem;white-space:nowrap}
+.f-action:hover{color:var(--accent);border-color:var(--accent)}
+.f-action.f-del:hover{color:#ef4444;border-color:#ef4444}
.card .del{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;padding:4px;flex-shrink:0}
.card .del:hover{color:#ef4444}
.card .edit{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.05rem;padding:4px;flex-shrink:0}
diff --git a/public/error.html b/public/error.html
new file mode 100644
index 0000000..fe967f7
--- /dev/null
+++ b/public/error.html
@@ -0,0 +1,57 @@
+
+
+
+
+
+Ошибка — WhatIDo
+
+
+
+
+
+
+
404
+
Страница не найдена
+
Запрашиваемая страница не существует или была перемещена.
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/files.html b/public/files.html
index 9b26494..10e567e 100644
--- a/public/files.html
+++ b/public/files.html
@@ -23,6 +23,12 @@
+
+
+
+
+
+
@@ -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'
+ ? `Откреплён от записи, не привязан
`
+ : `${esc(f.student_name)} · ${esc(f.group_name)}
`;
+ const action = mode === 'detached'
+ ? ``
+ : ``;
return `
${thumb}
-
${esc(f.student_name)} · ${esc(f.group_name)}
+ ${meta}
${new Date(f.created_at).toLocaleString('ru')}${f.size ? ' · ' + humanSize(f.size) : ''}
+
${action}
`;
}
@@ -79,13 +106,29 @@ function renderGrid() {
const grid = document.getElementById('grid');
if (!currentFiles.length) {
grid.className = 'grid';
- grid.innerHTML = 'Файлов не найдено
';
+ grid.innerHTML = mode === 'detached'
+ ? 'Откреплённых файлов нет
'
+ : 'Файлов не найдено
';
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();
diff --git a/public/journal.html b/public/journal.html
index 4edbbab..c85f74d 100644
--- a/public/journal.html
+++ b/public/journal.html
@@ -113,11 +113,34 @@ function isImageFile(name) {
function filesHTML(files) {
if (!files || !files.length) return '';
- return `${files.map(f =>
- isImageFile(f.name)
+ return `
${files.map(f => {
+ const link = isImageFile(f.name)
? `
${esc(f.name)}`
- : `
${esc(f.name)}`
- ).join('')}
`;
+ : `
${esc(f.name)}`;
+ return `
${link}`;
+ }).join('')}
`;
+}
+
+function findFile(id) {
+ for (const e of currentEntries) {
+ const f = (e.files || []).find(x => x.id === id);
+ if (f) return f;
+ }
+ return null;
+}
+
+async function detachFile(id) {
+ const f = findFile(id);
+ if (!f) return;
+ if (!confirm(`Открепить файл «${f.name}» от записи? Он переместится в «Файлы → Откреплённые».`)) return;
+ const res = await fetch(`${API}/api/files/${id}/detach`, { method: 'POST', headers: hdr() });
+ if (res.ok) {
+ showToast('Файл откреплён');
+ loadEntries();
+ } else {
+ const err = await res.json().catch(() => ({}));
+ alert(err.error || 'Ошибка');
+ }
}
function gridEntryHTML(e) {
diff --git a/server.js b/server.js
index 460d726..9620508 100644
--- a/server.js
+++ b/server.js
@@ -25,6 +25,14 @@ function requireAdmin(req, res, next) {
next();
}
+function fixFilename(str) {
+ try {
+ return Buffer.from(str, 'latin1').toString('utf8');
+ } catch {
+ return str;
+ }
+}
+
const upload = multer({
storage: multer.diskStorage({
destination: (_, __, cb) => {
@@ -32,12 +40,14 @@ const upload = multer({
cb(null, 'uploads');
},
filename: (_, file, cb) => {
- const ext = path.extname(file.originalname) || '.jpg';
+ const original = fixFilename(file.originalname);
+ const ext = path.extname(original) || '.jpg';
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
},
}),
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
+ file.originalname = fixFilename(file.originalname);
if (file.fieldname === 'photo' && !file.mimetype.startsWith('image/')) cb(new Error('Only images'));
else cb(null, true);
},
@@ -609,7 +619,7 @@ app.get('/api/entries', requireAdmin, async (req, res) => {
if (rows.length) {
const ids = rows.map(r => r.id);
const fRes = await pool.query(
- 'SELECT entry_id, token, name FROM project_files WHERE entry_id = ANY($1) ORDER BY id',
+ 'SELECT id, entry_id, token, name FROM project_files WHERE entry_id = ANY($1) ORDER BY id',
[ids]
);
files = {};
@@ -623,7 +633,7 @@ app.get('/api/entries', requireAdmin, async (req, res) => {
app.get('/api/entries/:id/files', requireAdmin, async (req, res) => {
const { rows } = await pool.query(
- 'SELECT token, name FROM project_files WHERE entry_id = $1 ORDER BY id',
+ 'SELECT id, token, name FROM project_files WHERE entry_id = $1 ORDER BY id',
[req.params.id]
);
res.json(rows);
@@ -668,6 +678,51 @@ app.get('/api/files', requireAdmin, async (req, res) => {
res.json({ files, total });
});
+app.get('/api/files/detached', requireAdmin, async (req, res) => {
+ const { search, limit, offset } = req.query;
+ const conditions = [];
+ const params = [];
+ conditions.push('entry_id IS NULL');
+ if (search) { params.push(`%${search}%`); conditions.push(`name ILIKE $${params.length}`); }
+ const where = conditions.join(' AND ');
+ const { rows: crows } = await pool.query(
+ `SELECT count(*)::int AS n FROM project_files WHERE ${where}`,
+ params
+ );
+ const total = crows[0].n;
+ let q = `SELECT id, token, name, path, created_at FROM project_files
+ WHERE ${where} ORDER BY created_at DESC`;
+ const qparams = params.slice();
+ const lim = parseInt(limit, 10);
+ if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
+ const off = parseInt(offset, 10);
+ if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; }
+ const { rows } = await pool.query(q, qparams);
+ const files = rows.map(r => {
+ let size = 0;
+ try { size = fs.statSync(path.join(__dirname, r.path)).size; } catch {}
+ return { id: r.id, token: r.token, name: r.name, created_at: r.created_at, size };
+ });
+ res.json({ files, total });
+});
+
+app.post('/api/files/:id/detach', requireAdmin, async (req, res) => {
+ const { rows } = await pool.query(
+ 'UPDATE project_files SET entry_id = NULL, detached_at = now() WHERE id = $1 RETURNING *',
+ [req.params.id]
+ );
+ if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
+ res.json({ ok: true });
+});
+
+app.delete('/api/files/:id', requireAdmin, async (req, res) => {
+ const { rows } = await pool.query('SELECT path FROM project_files WHERE id = $1', [req.params.id]);
+ if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
+ safeUnlink(rows[0].path);
+ await pool.query('DELETE FROM project_files WHERE id = $1', [req.params.id]);
+ res.json({ ok: true });
+});
+
app.get('/api/files/:token', async (req, res) => {
const { rows } = await pool.query('SELECT path, name FROM project_files WHERE token = $1', [req.params.token]);
if (!rows.length) return res.status(404).json({ error: 'Not found' });
@@ -866,7 +921,7 @@ app.get('/api/trash', requireAdmin, async (req, res) => {
let files = {};
if (rows.length) {
const fRes = await pool.query(
- 'SELECT entry_id, token, name FROM project_files WHERE entry_id = ANY($1) ORDER BY id',
+ 'SELECT id, entry_id, token, name FROM project_files WHERE entry_id = ANY($1) ORDER BY id',
[rows.map(r => r.id)]
);
fRes.rows.forEach(f => { (files[f.entry_id] = files[f.entry_id] || []).push(f); });
@@ -887,6 +942,43 @@ app.delete('/api/trash', requireAdmin, async (req, res) => {
res.json({ ok: true, deleted: d.rowCount });
});
+// --- Error handlers ---
+const ERROR_HTML = fs.readFileSync(path.join(__dirname, 'public', 'error.html'), 'utf8');
+
+function isApiRoute(req) {
+ return req.path.startsWith('/api/') || req.path.startsWith('/s/');
+}
+
+function escapeHtml(str) {
+ return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''');
+}
+
+function renderErrorPage(code, title, message, details) {
+ return ERROR_HTML
+ .replace('id="errorCode">404', `id="errorCode">${code}`)
+ .replace('id="errorTitle">Страница не найдена', `id="errorTitle">${title}`)
+ .replace('id="errorMessage">Запрашиваемая страница не существует или была перемещена.', `id="errorMessage">${message}`)
+ .replace('style="display:none"', details ? '' : 'style="display:none"')
+ .replace('', details ? `${escapeHtml(details)}` : '');
+}
+
+app.use((req, res, next) => {
+ if (isApiRoute(req)) {
+ return res.status(404).json({ error: 'Not found' });
+ }
+ res.status(404).send(renderErrorPage(404, 'Страница не найдена', 'Запрашиваемая страница не существует или была перемещена.'));
+});
+
+app.use((err, req, res, next) => {
+ console.error('Error:', err);
+ if (isApiRoute(req)) {
+ return res.status(500).json({ error: 'Internal server error' });
+ }
+ const msg = process.env.NODE_ENV === 'production' ? 'Произошла ошибка на сервере.' : (err?.message || 'Internal server error');
+ const details = process.env.NODE_ENV === 'production' ? '' : (err?.stack || '');
+ res.status(500).send(renderErrorPage(500, 'Ошибка сервера', msg, details));
+});
+
const PORT = process.env.PORT || 3000;
const HTTPS_PORT = process.env.HTTPS_PORT || 3443;