add project file upload to student form, project_files table with backup/restore, download in journal, and crash-proof entries endpoint

This commit is contained in:
dev
2026-09-06 11:29:34 +03:00
parent c2c5f2ed42
commit c5fdf006f5
5 changed files with 175 additions and 14 deletions
+8
View File
@@ -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()
);
+4
View File
@@ -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}
+78
View File
@@ -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)}
<button type="button" class="cam" onclick="openCam()">Камера</button>
</div>
</div>
<div class="files-card">
<div class="files-head">
<label>Файлы проекта</label>
<button type="button" class="files-clear" id="filesClearBtn" onclick="clearFiles()">Очистить</button>
</div>
<button type="button" class="files-btn" onclick="document.getElementById('filesInput').click()">Выбрать файлы</button>
<input type="file" id="filesInput" multiple hidden>
<div class="files-list" id="filesList"></div>
</div>
</div>
<div class="fields-col">
@@ -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');
+9
View File
@@ -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 `<div class="entry-files">${files.map(f =>
`<a href="${API}/api/files/${f.id}" download>${esc(f.name)}</a>`
).join('')}</div>`;
}
function gridEntryHTML(e) {
return `
<div class="card">
@@ -115,6 +122,7 @@ function gridEntryHTML(e) {
<div class="name">${esc(e.student_name)}</div>
<div class="group">${esc(e.group_name)}</div>
<div class="desc">${esc(e.description)}</div>
${filesHTML(e.files)}
<div class="time">${new Date(e.created_at).toLocaleString('ru')}</div>
</div>
<button class="edit" onclick="openEdit(${e.id})" title="Редактировать">✎</button>
@@ -133,6 +141,7 @@ function cardEntryHTML(e) {
<div class="name">${esc(e.student_name)}</div>
<div class="group">${esc(e.group_name)}</div>
<div class="desc">${esc(e.description)}</div>
${filesHTML(e.files)}
</div>
<div class="foot">
<span>${new Date(e.created_at).toLocaleString('ru')}</span>
+76 -14
View File
@@ -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');