diff --git a/public/admin.css b/public/admin.css index e7b072b..dd12f7f 100644 --- a/public/admin.css +++ b/public/admin.css @@ -4,7 +4,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b .layout{display:flex;min-height:100dvh} .sidebar{width:var(--sidebar);background:var(--card);border-right:1px solid var(--border);padding:24px 0;position:fixed;top:0;left:0;height:100dvh;display:flex;flex-direction:column} -.sidebar .logo{font-size:1.1rem;font-weight:700;padding:0 20px 20px;border-bottom:1px solid var(--border)} +.sidebar .logo{font-size:1.1rem;font-weight:700;padding:0 20px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;min-width:0} +.sidebar .logo-ic{height:28px;width:auto;min-width:28px;border-radius:8px;background:var(--bg);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0} +.sidebar .logo-ic img{max-width:100%;max-height:100%;object-fit:contain} +.sidebar #sidebarName{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0} .user-box{padding:14px 20px;border-bottom:1px solid var(--border);display:flex;flex-direction:column;gap:2px} .user-box .user-name{font-weight:600;font-size:.9rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .user-box .user-role{color:var(--muted);font-size:.72rem} @@ -300,6 +303,14 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b .save-bar .hint{color:var(--muted);font-size:.85rem;display:inline-flex;align-items:center;gap:8px} @media(max-width:640px){.save-bar{left:16px;right:16px}} +.logo-picker{display:flex;align-items:flex-start;gap:20px;flex-wrap:wrap} +.logo-preview{min-width:96px;min-height:72px;max-height:96px;height:auto;border:1px dashed var(--border);border-radius:14px;background:var(--bg);display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0;font-size:1.4rem;font-weight:700;color:var(--muted)} +.logo-preview img{max-width:100%;max-height:100%;object-fit:contain;display:none} +.logo-picker-actions{display:flex;flex-direction:column;gap:10px;align-items:flex-start;min-width:0} +.logo-picker-btns{display:flex;gap:10px;flex-wrap:wrap} +.logo-upload-status{font-size:.8rem;color:var(--muted);min-height:18px;display:flex;align-items:center;gap:8px} +.logo-upload-status .fail{color:#ef4444} + /* --- Дашборд --- */ .dash-grid{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1fr);gap:20px;align-items:start} .dash-col{display:flex;flex-direction:column;gap:20px;min-width:0} diff --git a/public/admin.js b/public/admin.js index 34a8bb4..7d156e3 100644 --- a/public/admin.js +++ b/public/admin.js @@ -68,7 +68,10 @@ function buildSidebar(active) { const userName = currentUser?.name || currentUser?.username || ''; const roleLabel = isAdmin() ? 'Администратор' : 'Тутор'; el.innerHTML = ` - +
${esc(userName)}
${roleLabel}
@@ -83,6 +86,26 @@ function buildSidebar(active) {
`; renderIcons(); loadVersion(); + loadBrand(); +} + +async function loadBrand() { + const logoEl = document.getElementById('sidebarLogo'); + const nameEl = document.getElementById('sidebarName'); + if (!logoEl || !nameEl) return; + try { + const res = await fetch(`${API}/api/public-settings`); + if (!res.ok) return; + const s = await res.json(); + if ((s.system_name || '').trim()) nameEl.textContent = s.system_name; + if (s.system_logo) { + const img = document.createElement('img'); + img.src = s.system_logo; + img.alt = ''; + logoEl.classList.add('has-logo'); + logoEl.appendChild(img); + } + } catch {} } async function loadVersion() { diff --git a/public/index.html b/public/index.html index f5ac99b..6fdd985 100644 --- a/public/index.html +++ b/public/index.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;padding:24px 16px} h1{font-size:1.5rem;font-weight:600;margin-bottom:8px;letter-spacing:-.02em;text-align:center} -.sub{color:var(--muted);font-size:.85rem;margin-bottom:32px;text-align:center} +.sub{color:var(--muted);font-size:.85rem;text-align:center} +.brand-head{display:flex;align-items:center;justify-content:center;gap:16px;margin-bottom:32px} +.brand-logo{height:76px;width:auto;max-width:min(46vw,340px);object-fit:contain;flex-shrink:0} +.brand-text{display:flex;flex-direction:column;align-items:flex-start} .form{width:100%;max-width:900px;margin:0 auto;display:grid;grid-template-columns:320px 1fr;gap:32px;align-items:start} @media(max-width:760px){.form{grid-template-columns:1fr;max-width:480px}} @@ -89,8 +92,13 @@ nav a:hover{color:var(--text)} -

Что мы узнали на занятии

-

Отметь, что ты делал(а) на занятии

+
+ +
+

Что мы узнали на занятии

+

Отметь, что ты делал(а) на занятии

+
+
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);