diff --git a/public/js/error.js b/public/js/error.js
index 9ef477d..984d517 100644
--- a/public/js/error.js
+++ b/public/js/error.js
@@ -13,4 +13,8 @@ if (details) {
}
})();
-document.querySelector('.error-actions button').addEventListener('click', () => history.back());
\ No newline at end of file
+document.querySelector('.error-actions button').addEventListener('click', () => history.back());
+
+fetch('/api/public-settings').then(r => r.ok ? r.json() : null).then(s => {
+ if (s && (s.system_name || '').trim()) document.title = `Ошибка — ${s.system_name}`;
+}).catch(() => {});
\ No newline at end of file
diff --git a/public/js/index.js b/public/js/index.js
index 3cc0c90..1439ff1 100644
--- a/public/js/index.js
+++ b/public/js/index.js
@@ -202,6 +202,17 @@ async function resolveModuleId(name) {
try {
const fRes = await fetch(`${API}/api/public-settings`);
const f = await fRes.json();
+ const title = document.getElementById('title');
+ if (title && (f.system_name || '').trim()) {
+ title.textContent = f.system_name;
+ document.title = f.system_name;
+ }
+ const brandLogo = document.getElementById('brandLogo');
+ if (brandLogo && f.system_logo) {
+ brandLogo.src = f.system_logo;
+ brandLogo.hidden = false;
+ brandLogo.alt = f.system_name || '';
+ }
if (f.footer_left) document.getElementById('footerLeft').textContent = f.footer_left;
if (f.footer_right) document.getElementById('footerRight').textContent = f.footer_right;
const c = parseInt(f.spam_interval_min, 10);
diff --git a/public/js/login.js b/public/js/login.js
index 7b14d73..ba597d5 100644
--- a/public/js/login.js
+++ b/public/js/login.js
@@ -28,4 +28,23 @@ async function doLogin() {
document.getElementById('pwdInput').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
document.getElementById('userInput').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
-document.querySelector('.login button').addEventListener('click', doLogin);
\ No newline at end of file
+document.querySelector('.login button').addEventListener('click', doLogin);
+
+(async () => {
+ try {
+ const res = await fetch(`${API}/api/public-settings`);
+ if (!res.ok) return;
+ const s = await res.json();
+ const title = document.getElementById('loginTitle');
+ if (title && (s.system_name || '').trim()) title.textContent = s.system_name;
+ const logo = document.getElementById('loginLogo');
+ if (logo && s.system_logo) {
+ const img = document.createElement('img');
+ img.src = s.system_logo;
+ img.alt = '';
+ logo.appendChild(img);
+ logo.style.display = 'flex';
+ }
+ document.title = title ? title.textContent + ' — Вход' : document.title;
+ } catch { /* ignore */ }
+})();
\ No newline at end of file
diff --git a/public/js/report.js b/public/js/report.js
index e7ac12e..5f73b3f 100644
--- a/public/js/report.js
+++ b/public/js/report.js
@@ -1,4 +1,12 @@
-const state={token:'',password:null,showNames:true,cdTarget:0,cdTimer:null,cdBound:false};
+const state={token:'',password:null,showNames:true,cdTarget:0,cdTimer:null,cdBound:false,systemName:'WhatIDo'};
+
+function loadBrand(){
+ return fetch('/api/public-settings').then(function(res){
+ return res.ok?res.json():null;
+ }).then(function(s){
+ if(s&&(s.system_name||'').trim())state.systemName=s.system_name;
+ }).catch(function(){});
+}
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''')}
function pad(n){return String(n).padStart(2,'0')}
@@ -440,7 +448,7 @@ function renderFooter(data,heading,pool){
const label=btn.querySelector('.text-block-wrap-div');
if(label)label.textContent='Перейти по ссылке';
}
- setText('footCopy','WhatIDo © '+new Date().getFullYear());
+ setText('footCopy',state.systemName+' © '+new Date().getFullYear());
setText('footBrand',heading);
const fbox=document.getElementById('footerPhotoBox');
const fimg=document.getElementById('footerPhoto');
@@ -542,5 +550,5 @@ function bindEvents(){
bindEvents();
makeNavLinks();
-loadReport();
+loadBrand().then(function(){loadReport()});
function closeLightbox(){const lb=document.getElementById('lightbox');if(lb)lb.classList.remove('is-open')}
\ No newline at end of file
diff --git a/public/js/settings.js b/public/js/settings.js
index 030c456..f34a7a7 100644
--- a/public/js/settings.js
+++ b/public/js/settings.js
@@ -1,5 +1,5 @@
-const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine'];
-const SECTION_IDS = ['sec-system', 'sec-antispam', 'sec-share', 'sec-form', 'sec-ai', 'sec-backup', 'sec-bans'];
+const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine'];
+const SECTION_IDS = ['sec-system', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-form', 'sec-bans'];
const SPAM_MAX = 10080;
const BAN_REASONS = {
'honeypot': 'Антиспам-поле',
@@ -8,6 +8,21 @@ const BAN_REASONS = {
'manual': 'Вручную',
};
+let currentLogo = '';
+let logoOriginal = '';
+
+function renderLogoPreview() {
+ const img = document.getElementById('logoPreviewImg');
+ const fb = document.getElementById('logoPreviewFallback');
+ const rm = document.getElementById('logoRemoveBtn');
+ if (!img || !fb || !rm) return;
+ const hasLogo = !!currentLogo;
+ img.style.display = hasLogo ? 'block' : 'none';
+ fb.style.display = hasLogo ? 'none' : '';
+ if (hasLogo) img.src = currentLogo;
+ rm.hidden = !hasLogo;
+}
+
function fieldValue(el) {
return el.type === 'checkbox' ? String(el.checked) : el.value;
}
@@ -101,6 +116,8 @@ function revertChanges() {
if (el.type === 'checkbox') el.checked = original === 'true';
else el.value = original;
});
+ currentLogo = logoOriginal;
+ renderLogoPreview();
clearFieldError('spamInterval');
updateSpamUi();
updateDirty();
@@ -143,6 +160,10 @@ async function loadSettings() {
]);
if (!sRes.ok) { showError('Не удалось загрузить настройки'); return; }
const s = await sRes.json();
+ document.getElementById('systemName').value = s.system_name !== undefined ? s.system_name : 'WhatIDo';
+ currentLogo = s.system_logo || '';
+ logoOriginal = currentLogo;
+ renderLogoPreview();
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
document.getElementById('footerLeft').value = s.footer_left || '';
document.getElementById('footerRight').value = s.footer_right || '';
@@ -487,6 +508,7 @@ async function saveSettings() {
method: 'PUT',
headers: hdrJson(),
body: JSON.stringify({ settings: {
+ system_name: document.getElementById('systemName').value.trim(),
spam_interval_min: String(Math.round(Number(v))),
footer_left: document.getElementById('footerLeft').value.trim(),
footer_right: document.getElementById('footerRight').value.trim(),
@@ -504,6 +526,7 @@ async function saveSettings() {
});
if (res.ok) {
showToast('Настройки сохранены');
+ logoOriginal = currentLogo;
snapshotDirty();
updateSpamUi();
} else {
@@ -637,6 +660,64 @@ if (statsRefreshBtn) statsRefreshBtn.addEventListener('click', async () => {
statsRefreshBtn.classList.remove('spinning');
});
+function logoStatusText(html) {
+ const st = document.getElementById('logoUploadStatus');
+ if (st) st.innerHTML = html;
+}
+
+document.getElementById('logoUploadBtn').addEventListener('click', () => document.getElementById('logoInput').click());
+
+document.getElementById('logoInput').addEventListener('change', async (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+ const btn = document.getElementById('logoUploadBtn');
+ if (file.size > 1024 * 1024) {
+ e.target.value = '';
+ showError('Логотип не должен превышать 1 МБ');
+ return;
+ }
+ btn.disabled = true;
+ logoStatusText('
Загрузка...');
+ try {
+ const fd = new FormData();
+ fd.append('logo', file);
+ const res = await fetch(`${API}/api/settings/logo`, { method: 'POST', headers: hdr(), body: fd });
+ const d = await res.json().catch(() => ({}));
+ if (res.ok) {
+ currentLogo = d.system_logo || '';
+ renderLogoPreview();
+ logoStatusText('');
+ showToast('Логотип обновлён');
+ } else {
+ logoStatusText(`
${esc(d.error || 'Ошибка загрузки')}`);
+ }
+ } catch {
+ logoStatusText('
Ошибка сети');
+ } finally {
+ btn.disabled = false;
+ e.target.value = '';
+ }
+});
+
+document.getElementById('logoRemoveBtn').addEventListener('click', async () => {
+ if (!confirm('Удалить логотип? Название системы сохранится.')) return;
+ logoStatusText('
Удаление...');
+ try {
+ const res = await fetch(`${API}/api/settings/logo`, { method: 'DELETE', headers: hdr() });
+ if (res.ok) {
+ currentLogo = '';
+ logoOriginal = '';
+ renderLogoPreview();
+ logoStatusText('');
+ showToast('Логотип удалён');
+ } else {
+ logoStatusText('
Ошибка удаления');
+ }
+ } catch {
+ logoStatusText('
Ошибка сети');
+ }
+});
+
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault();
diff --git a/public/login.html b/public/login.html
index 8fbbd6f..09d911a 100644
--- a/public/login.html
+++ b/public/login.html
@@ -9,7 +9,10 @@
:root{--bg:#f5f5f5;--card:#fff;--border:#e5e5e5;--accent:#2563eb;--text:#171717;--muted:#737373;--radius:12px}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100dvh;display:flex;align-items:center;justify-content:center}
.login{max-width:320px;width:100%;text-align:center;background:var(--card);border:1px solid var(--border);border-radius:16px;padding:40px 32px}
-.login h1{font-size:1.3rem;font-weight:600;margin-bottom:24px}
+.login .brand{display:flex;flex-direction:column;align-items:center;gap:14px;margin-bottom:24px}
+.login .brand .logo{height:48px;width:auto;min-width:48px;border-radius:14px;background:var(--bg);border:1px solid var(--border);display:none;align-items:center;justify-content:center;overflow:hidden}
+.login .brand .logo img{max-width:100%;max-height:100%;object-fit:contain}
+.login h1{font-size:1.3rem;font-weight:600;margin:0}
.login input{width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:var(--radius);padding:14px 16px;font-size:.95rem;outline:none;margin-bottom:12px;text-align:center}
.login input:focus{border-color:var(--accent)}
.login button{width:100%;background:var(--accent);color:#fff;border:none;border-radius:var(--radius);padding:14px;font-size:1rem;font-weight:600;cursor:pointer}
@@ -22,7 +25,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
+
+
+
Название и логотип отображаются в админ-панели, на форме ответа ученика, странице входа и в отчёте.
+
+
+
+
+
+
+
+
+
—
+
![Логотип]()
+
+
+
+
+
+
+
+
+
Рекомендуется широкое изображение с прозрачным фоном (PNG/WebP), не более 1 МБ
+
+
+
+
+
diff --git a/server.js b/server.js
index 7e6e495..701c253 100644
--- a/server.js
+++ b/server.js
@@ -616,7 +616,7 @@ async function removeEntryFiles(entryId) {
async function sweepOrphanedUploads() {
const dir = path.join(__dirname, 'uploads');
if (!fs.existsSync(dir)) return;
- const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }, { rows: pendingJobs }, { rows: mphotos }, { rows: sphotos }] = await Promise.all([
+ const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }, { rows: pendingJobs }, { rows: mphotos }, { rows: sphotos }, { rows: logos }] = await Promise.all([
pool.query('SELECT photo_path AS p FROM entries WHERE photo_path IS NOT NULL'),
pool.query('SELECT path AS p FROM project_files'),
pool.query('SELECT photo_path AS p FROM group_photos'),
@@ -624,9 +624,10 @@ async function sweepOrphanedUploads() {
pool.query(`SELECT after_path AS p FROM photo_jobs WHERE status = 'done' AND applied = false AND after_path IS NOT NULL`),
pool.query('SELECT photo_path AS p FROM modules WHERE photo_path IS NOT NULL'),
pool.query('SELECT photo_path AS p FROM student_photos'),
+ pool.query(`SELECT value AS p FROM settings WHERE key = 'system_logo' AND value IS NOT NULL AND value <> ''`),
]);
const refs = new Set();
- [...photos, ...files, ...gphotos, ...ephotos, ...pendingJobs, ...mphotos, ...sphotos].forEach(r => refs.add('/' + String(r.p).replace(/^\/+/, '')));
+ [...photos, ...files, ...gphotos, ...ephotos, ...pendingJobs, ...mphotos, ...sphotos, ...logos].forEach(r => refs.add('/' + String(r.p).replace(/^\/+/, '')));
for (const f of fs.readdirSync(dir)) {
const fp = path.join(dir, f);
if (!fs.statSync(fp).isFile()) continue;
@@ -1037,8 +1038,8 @@ app.get('/api/settings', requireAdmin, async (_, res) => {
app.get('/api/public-settings', apiLimiter, async (_, res) => {
const out = await cacheWrap('public-settings', PUBLIC_TTL_MS, async () => {
- const keys = ['footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos', 'cookie_notice_text', 'spam_interval_min', 'photo_capture_resolution', 'photo_capture_quality', 'photo_enhance_engine'];
- const defaults = { spam_interval_min: '30', photo_capture_resolution: '640x480', photo_capture_quality: '0.92', photo_enhance_engine: 'auto' };
+ const keys = ['system_name', 'system_logo', 'footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos', 'cookie_notice_text', 'spam_interval_min', 'photo_capture_resolution', 'photo_capture_quality', 'photo_enhance_engine'];
+ const defaults = { system_name: 'WhatIDo', system_logo: '', spam_interval_min: '30', photo_capture_resolution: '640x480', photo_capture_quality: '0.92', photo_enhance_engine: 'auto' };
const result = {};
for (const k of keys) result[k] = await getSetting(k, defaults[k] || '');
const rm = /^(\d{2,5})x(\d{2,5})$/.exec(result.photo_capture_resolution);
@@ -1081,6 +1082,12 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
if (key === 'photo_enhance_engine' && !['auto', 'server', 'client'].includes(String(value))) {
return res.status(400).json({ error: 'photo_enhance_engine должен быть auto, server или client' });
}
+ if (key === 'system_name' && String(value).trim().length > 60) {
+ return res.status(400).json({ error: 'system_name не может быть длиннее 60 символов' });
+ }
+ if (key === 'system_logo' && String(value) !== '' && !isSafeUploadPath(String(value))) {
+ return res.status(400).json({ error: 'system_logo — некорректный путь' });
+ }
}
const client = await pool.connect();
try {
@@ -1107,6 +1114,54 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
}
});
+const logoUpload = upload.single('logo');
+app.post('/api/settings/logo', requireAdmin, (req, res, next) => {
+ logoUpload(req, res, (err) => {
+ if (err) {
+ if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Файл слишком большой (макс. 10 МБ)' });
+ if (err.message === 'Only images') return res.status(400).json({ error: 'Логотип: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif)' });
+ if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла' });
+ return res.status(400).json({ error: 'Недопустимый файл' });
+ }
+ next();
+ });
+}, async (req, res) => {
+ if (!req.file) return res.status(400).json({ error: 'Файл обязателен' });
+ const ext = (path.extname(req.file.originalname || '') || '').toLowerCase();
+ if (!ALLOWED_IMAGE_EXT.has(ext)) {
+ removeUpload(req.file);
+ return res.status(400).json({ error: 'Логотип: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)' });
+ }
+ try {
+ await convertPhoto(req.file);
+ const finalPath = `/uploads/${req.file.filename}`;
+ const old = await getSetting('system_logo', '');
+ if (old && isSafeUploadPath(old) && old !== finalPath) safeUnlink(old);
+ await pool.query(
+ `INSERT INTO settings (key, value) VALUES ('system_logo', $1)
+ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
+ [finalPath]
+ );
+ await logAudit(req, 'settings.logo.upload', { path: finalPath });
+ invalidateSettings();
+ res.json({ system_logo: finalPath });
+ } catch (e) {
+ removeUpload(req.file);
+ throw e;
+ }
+});
+
+app.delete('/api/settings/logo', requireAdmin, async (req, res) => {
+ const old = await getSetting('system_logo', '');
+ if (old) {
+ await pool.query(`UPDATE settings SET value = '' WHERE key = 'system_logo'`);
+ if (isSafeUploadPath(old)) safeUnlink(old);
+ }
+ await logAudit(req, 'settings.logo.remove', {});
+ invalidateSettings();
+ res.json({ ok: true, system_logo: '' });
+});
+
app.get('/api/audit', requireAdmin, async (req, res) => {
const limit = Math.min(parseInt(req.query.limit, 10) || 100, 1000);
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);