From 034c92b1601fec70f8338517c3ef47534075493d Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 6 Sep 2026 11:29:34 +0300 Subject: [PATCH] add project file upload to student form, project_files table with backup/restore, download in journal, and crash-proof entries endpoint --- db/init.sql | 8 ++++ public/admin.css | 4 ++ public/index.html | 78 +++++++++++++++++++++++++++++++++++++++ public/journal.html | 9 +++++ server.js | 90 ++++++++++++++++++++++++++++++++++++++------- 5 files changed, 175 insertions(+), 14 deletions(-) diff --git a/db/init.sql b/db/init.sql index 947bc55..2486c39 100644 --- a/db/init.sql +++ b/db/init.sql @@ -44,3 +44,11 @@ INSERT INTO groups (name) VALUES ('Intermediate'), ('Advanced') ON CONFLICT (name) DO NOTHING; + +CREATE TABLE IF NOT EXISTS project_files ( + id SERIAL PRIMARY KEY, + entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE, + path VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() +); diff --git a/public/admin.css b/public/admin.css index 49b29c1..3f6f1ca 100644 --- a/public/admin.css +++ b/public/admin.css @@ -45,6 +45,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b .card .group{color:var(--accent);font-size:.8rem;margin-top:2px} .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 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:'📎'} .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/index.html b/public/index.html index 4a4d2c5..df3dfee 100644 --- a/public/index.html +++ b/public/index.html @@ -21,6 +21,21 @@ h1{font-size:1.5rem;font-weight:600;margin-bottom:8px;letter-spacing:-.02em;text .photo-btns button:hover{border-color:var(--accent)} .photo-btns .cam{background:var(--accent);color:#fff;border-color:var(--accent)} .photo-btns .cam:hover{opacity:.85} +.photo-btns .files-btn p{font-size:.8rem;font-weight:500;margin:0} +.files-btn{width:100%;border-radius:var(--radius);padding:12px 16px;font-size:.9rem;font-weight:600;cursor:pointer;border:1px solid var(--border);background:var(--card);color:var(--text);transition:border .15s,background .15s} +.files-btn:hover{border-color:var(--accent)} +.files-card{width:100%;border:1px dashed var(--border);border-radius:var(--radius);padding:16px;display:flex;flex-direction:column;gap:12px;background:var(--bg)} +.files-card .files-head{display:flex;align-items:center;justify-content:space-between;gap:8px} +.files-card label{font-size:.8rem;font-weight:600;color:var(--text);text-transform:none;letter-spacing:0} +.files-card .files-clear{background:none;border:none;color:#ef4444;font-size:.8rem;cursor:pointer;padding:0;display:none} +.files-card .files-clear.show{display:block} +.files-list{display:flex;flex-direction:column;gap:6px;max-height:180px;overflow-y:auto} +.files-list:empty{display:none} +.files-list .file-row{display:flex;align-items:center;gap:8px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:8px 10px;font-size:.8rem} +.files-list .file-row .f-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)} +.files-list .file-row .f-size{color:var(--muted);flex-shrink:0} +.files-list .file-row button{background:none;border:none;color:var(--muted);cursor:pointer;font-size:.85rem;padding:0 2px;flex-shrink:0} +.files-list .file-row button:hover{color:#ef4444} .preview-wrap{width:100%;display:flex;flex-direction:column;align-items:center;gap:8px} .preview{max-width:100%;width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:var(--radius);background:var(--card);display:block;border:1px dashed var(--border)} .preview.has-photo{border-style:solid} @@ -82,6 +97,15 @@ nav a:hover{color:var(--text)} +
+
+ + +
+ + +
+
@@ -139,6 +163,58 @@ const camModal = document.getElementById('camModal'); let capturedBlob = null; let stream = null; const descInput = document.getElementById('descInput'); +const filesInput = document.getElementById('filesInput'); +const filesList = document.getElementById('filesList'); +const filesClearBtn = document.getElementById('filesClearBtn'); +let selectedFiles = []; + +function humanSize(b) { + if (b < 1024) return b + ' Б'; + if (b < 1048576) return (b / 1024).toFixed(1) + ' КБ'; + return (b / 1048576).toFixed(1) + ' МБ'; +} + +function renderFiles() { + filesList.innerHTML = ''; + selectedFiles.forEach((f, i) => { + const row = document.createElement('div'); + row.className = 'file-row'; + const name = document.createElement('span'); + name.className = 'f-name'; + name.textContent = f.name; + name.title = f.name; + const size = document.createElement('span'); + size.className = 'f-size'; + size.textContent = humanSize(f.size); + const del = document.createElement('button'); + del.type = 'button'; + del.textContent = '✕'; + del.onclick = () => removeFile(i); + row.append(name, size, del); + filesList.appendChild(row); + }); + filesClearBtn.classList.toggle('show', selectedFiles.length > 0); +} + +function removeFile(i) { + selectedFiles.splice(i, 1); + renderFiles(); +} + +function clearFiles() { + selectedFiles = []; + filesInput.value = ''; + renderFiles(); +} + +filesInput.addEventListener('change', () => { + const keep = 10 - selectedFiles.length; + if (keep <= 0) { alert('Можно прикрепить не более 10 файлов'); filesInput.value = ''; return; } + selectedFiles = selectedFiles.concat([...filesInput.files].slice(0, keep)); + if ([...filesInput.files].length > keep) alert('Можно прикрепить не более 10 файлов'); + filesInput.value = ''; + renderFiles(); +}); const params = new URLSearchParams(location.search); let studentName = ''; @@ -234,6 +310,7 @@ form.addEventListener('submit', async (e) => { const fd = new FormData(); if (capturedBlob) fd.append('photo', capturedBlob, 'photo.jpg'); + selectedFiles.forEach(f => fd.append('files', f, f.name)); fd.append('student_name', studentName || document.getElementById('nameInput').value); fd.append('group_id', groupInput.value); fd.append('description', document.getElementById('descInput').value); @@ -250,6 +327,7 @@ form.addEventListener('submit', async (e) => { if (!res.ok) throw new Error(await res.text()); form.reset(); removePhoto(); + clearFiles(); toast.textContent = 'Готово!'; toast.classList.remove('error'); toast.classList.add('show'); diff --git a/public/journal.html b/public/journal.html index 886ab06..2b9bbf5 100644 --- a/public/journal.html +++ b/public/journal.html @@ -107,6 +107,13 @@ let totalEntries = 0; let searchTimer = null; let view = localStorage.getItem('journalView') === 'cards' ? 'cards' : 'list'; +function filesHTML(files) { + if (!files || !files.length) return ''; + return `
${files.map(f => + `${esc(f.name)}` + ).join('')}
`; +} + function gridEntryHTML(e) { return `
@@ -115,6 +122,7 @@ function gridEntryHTML(e) {
${esc(e.student_name)}
${esc(e.group_name)}
${esc(e.description)}
+ ${filesHTML(e.files)}
${new Date(e.created_at).toLocaleString('ru')}
@@ -133,6 +141,7 @@ function cardEntryHTML(e) {
${esc(e.student_name)}
${esc(e.group_name)}
${esc(e.description)}
+ ${filesHTML(e.files)}
${new Date(e.created_at).toLocaleString('ru')} diff --git a/server.js b/server.js index 3dd1b01..345d418 100644 --- a/server.js +++ b/server.js @@ -37,9 +37,9 @@ const upload = multer({ }, }), limits: { fileSize: 10 * 1024 * 1024 }, - fileFilter: (_, file, cb) => { - if (file.mimetype.startsWith('image/')) cb(null, true); - else cb(new Error('Only images')); + fileFilter: (req, file, cb) => { + if (file.fieldname === 'photo' && !file.mimetype.startsWith('image/')) cb(new Error('Only images')); + else cb(null, true); }, }); @@ -109,15 +109,16 @@ const SAFE_NAME = /^[\w,.()-]+$/; app.get('/api/backup', requireAdmin, async (_, res) => { const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-')); try { - const [g, s, e, st] = await Promise.all([ + const [g, s, e, st, pf] = await Promise.all([ pool.query('SELECT * FROM groups ORDER BY id'), pool.query('SELECT * FROM students ORDER BY id'), pool.query('SELECT * FROM entries ORDER BY id'), pool.query('SELECT key, value FROM settings'), + pool.query('SELECT * FROM project_files ORDER BY id'), ]); const settings = {}; st.rows.forEach(r => { settings[r.key] = r.value; }); - const payload = { version: 1, created_at: new Date().toISOString(), groups: g.rows, students: s.rows, entries: e.rows, settings }; + const payload = { version: 1, created_at: new Date().toISOString(), groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows }; fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload)); fs.mkdirSync(path.join(staging, 'uploads'), { recursive: true }); const dir = path.join(__dirname, 'uploads'); @@ -191,13 +192,19 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req [x.id, x.student_name, x.group_id, x.description, x.photo_path ?? null, x.created_at] ); } + for (const x of data.project_files || []) { + await client.query( + 'INSERT INTO project_files (id, entry_id, path, name, created_at) VALUES ($1,$2,$3,$4,$5)', + [x.id, x.entry_id, x.path, x.name, x.created_at] + ); + } for (const [k, v] of Object.entries(data.settings || {})) { await client.query( 'INSERT INTO settings (key, value) VALUES ($1,$2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value', [k, String(v ?? '')] ); } - for (const tbl of ['groups', 'students', 'entries']) { + for (const tbl of ['groups', 'students', 'entries', 'project_files']) { const r = await client.query('SELECT COALESCE(MAX(id), 1) AS m FROM ' + tbl); await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3)', [tbl, 'id', r.rows[0].m]); } @@ -470,9 +477,38 @@ app.get('/api/entries', requireAdmin, async (req, res) => { const off = parseInt(offset, 10); if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; } const { rows } = await pool.query(q, qparams); + let files; + if (rows.length) { + const ids = rows.map(r => r.id); + const fRes = await pool.query( + 'SELECT id, entry_id, path, name FROM project_files WHERE entry_id = ANY($1) ORDER BY id', + [ids] + ); + files = {}; + fRes.rows.forEach(f => { (files[f.entry_id] = files[f.entry_id] || []).push(f); }); + } else { + files = {}; + } + rows.forEach(r => { r.files = files[r.id] || []; }); res.json({ entries: rows, total }); }); +app.get('/api/entries/:id/files', requireAdmin, async (req, res) => { + const { rows } = await pool.query( + 'SELECT id, path, name FROM project_files WHERE entry_id = $1 ORDER BY id', + [req.params.id] + ); + res.json(rows); +}); + +app.get('/api/files/:id', requireAdmin, async (req, res) => { + const { rows } = await pool.query('SELECT path, name FROM project_files WHERE id = $1', [req.params.id]); + if (!rows.length) return res.status(404).json({ error: 'Not found' }); + const fp = path.join(__dirname, rows[0].path); + if (!fs.existsSync(fp)) return res.status(404).json({ error: 'File missing' }); + res.download(fp, rows[0].name); +}); + app.get('/api/stats', requireAdmin, async (_, res) => { const [entries, groups, students, today] = await Promise.all([ pool.query('SELECT count(*)::int AS n FROM entries'), @@ -488,10 +524,13 @@ app.get('/api/stats', requireAdmin, async (_, res) => { }); }); -app.post('/api/entries', upload.single('photo'), async (req, res) => { +app.post('/api/entries', upload.fields([{ name: 'photo', maxCount: 1 }, { name: 'files', maxCount: 10 }]), async (req, res) => { const { student_name, group_id, description } = req.body; + const photo = req.files?.photo?.[0] || null; + const projectFiles = req.files?.files || []; if (!student_name?.trim() || !group_id || !description?.trim()) { - removeUpload(req.file); + removeUpload(photo); + projectFiles.forEach(removeUpload); return res.status(400).json({ error: 'All fields required' }); } const intervalMin = parseInt(await getSetting('spam_interval_min', '30'), 10) || 0; @@ -501,11 +540,12 @@ app.post('/api/entries', upload.single('photo'), async (req, res) => { [student_name.trim(), intervalMin] ); if (dup.rows[0].n > 0) { - removeUpload(req.file); + removeUpload(photo); + projectFiles.forEach(removeUpload); return res.status(429).json({ error: `Уже ответили: подождите ${intervalMin} минут` }); } } - const photo_path = req.file ? `/uploads/${req.file.filename}` : null; + const photo_path = photo ? `/uploads/${photo.filename}` : null; const client = await pool.connect(); try { await client.query('BEGIN'); @@ -518,11 +558,20 @@ app.post('/api/entries', upload.single('photo'), async (req, res) => { VALUES ($1, $2, $3, $4) RETURNING *`, [student_name.trim(), group_id, description.trim(), photo_path] ); + for (const f of projectFiles) { + await client.query( + 'INSERT INTO project_files (entry_id, path, name) VALUES ($1, $2, $3)', + [rows[0].id, `/uploads/${f.filename}`, f.originalname] + ); + } await client.query('COMMIT'); - res.status(201).json(rows[0]); + res.status(201).json({ ...rows[0], files: projectFiles.length }); } catch (e) { - await client.query('ROLLBACK'); - throw e; + await client.query('ROLLBACK').catch(() => {}); + removeUpload(photo); + projectFiles.forEach(removeUpload); + console.error('POST /api/entries:', e); + res.status(500).json({ error: e.message }); } finally { client.release(); } @@ -549,19 +598,32 @@ app.put('/api/entries/:id', requireAdmin, async (req, res) => { app.delete('/api/entries/:id', requireAdmin, async (req, res) => { const { rows } = await pool.query( - 'DELETE FROM entries WHERE id = $1 RETURNING photo_path', + 'SELECT photo_path FROM entries WHERE id = $1', [req.params.id] ); if (rows[0]?.photo_path) { const fp = path.join(__dirname, rows[0].photo_path); if (fs.existsSync(fp)) fs.unlinkSync(fp); } + const fRes = await pool.query( + 'SELECT path FROM project_files WHERE entry_id = $1', + [req.params.id] + ); + for (const r of fRes.rows) { + const fp = path.join(__dirname, r.path); + if (fs.existsSync(fp)) fs.unlinkSync(fp); + } + await pool.query('DELETE FROM project_files WHERE entry_id = $1', [req.params.id]); + await pool.query('DELETE FROM entries WHERE id = $1', [req.params.id]); res.json({ ok: true }); }); const PORT = process.env.PORT || 3000; const HTTPS_PORT = process.env.HTTPS_PORT || 3443; +process.on('unhandledRejection', (err) => { console.error('Unhandled rejection:', err); }); +process.on('uncaughtException', (err) => { console.error('Uncaught exception:', err); }); + const certPath = path.join(__dirname, 'certs', 'cert.pem'); const keyPath = path.join(__dirname, 'certs', 'key.pem');