feat(branding): system name/logo, horizontal logo on all pages
This commit is contained in:
+12
-1
@@ -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}
|
||||
|
||||
+24
-1
@@ -68,7 +68,10 @@ function buildSidebar(active) {
|
||||
const userName = currentUser?.name || currentUser?.username || '';
|
||||
const roleLabel = isAdmin() ? 'Администратор' : 'Тутор';
|
||||
el.innerHTML = `
|
||||
<div class="logo">Админ-панель</div>
|
||||
<div class="logo">
|
||||
<span class="logo-ic" id="sidebarLogo"></span>
|
||||
<span id="sidebarName">Админ-панель</span>
|
||||
</div>
|
||||
<div class="user-box">
|
||||
<div class="user-name">${esc(userName)}</div>
|
||||
<div class="user-role">${roleLabel}</div>
|
||||
@@ -83,6 +86,26 @@ function buildSidebar(active) {
|
||||
</div>`;
|
||||
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() {
|
||||
|
||||
+11
-3
@@ -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)}
|
||||
<nav>
|
||||
<a href="admin.html">Админка</a>
|
||||
</nav>
|
||||
<h1 id="title">Что мы узнали на занятии</h1>
|
||||
<p class="sub">Отметь, что ты делал(а) на занятии</p>
|
||||
<div class="brand-head">
|
||||
<img class="brand-logo" id="brandLogo" alt="" hidden>
|
||||
<div class="brand-text">
|
||||
<h1 id="title">Что мы узнали на занятии</h1>
|
||||
<p class="sub">Отметь, что ты делал(а) на занятии</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="form" id="form">
|
||||
<div class="photo-col">
|
||||
|
||||
@@ -14,3 +14,7 @@ if (details) {
|
||||
})();
|
||||
|
||||
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(() => {});
|
||||
@@ -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);
|
||||
|
||||
@@ -29,3 +29,22 @@ 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);
|
||||
|
||||
(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 */ }
|
||||
})();
|
||||
+11
-3
@@ -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,'"').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')}
|
||||
+83
-2
@@ -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('<span class="spinner dark"></span> Загрузка...');
|
||||
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(`<span class="fail">${esc(d.error || 'Ошибка загрузки')}</span>`);
|
||||
}
|
||||
} catch {
|
||||
logoStatusText('<span class="fail">Ошибка сети</span>');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
e.target.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('logoRemoveBtn').addEventListener('click', async () => {
|
||||
if (!confirm('Удалить логотип? Название системы сохранится.')) return;
|
||||
logoStatusText('<span class="spinner dark"></span> Удаление...');
|
||||
try {
|
||||
const res = await fetch(`${API}/api/settings/logo`, { method: 'DELETE', headers: hdr() });
|
||||
if (res.ok) {
|
||||
currentLogo = '';
|
||||
logoOriginal = '';
|
||||
renderLogoPreview();
|
||||
logoStatusText('');
|
||||
showToast('Логотип удалён');
|
||||
} else {
|
||||
logoStatusText('<span class="fail">Ошибка удаления</span>');
|
||||
}
|
||||
} catch {
|
||||
logoStatusText('<span class="fail">Ошибка сети</span>');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault();
|
||||
|
||||
+8
-2
@@ -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
|
||||
</head>
|
||||
<body>
|
||||
<div class="login">
|
||||
<h1>Админ-панель</h1>
|
||||
<div class="brand">
|
||||
<div class="logo" id="loginLogo"></div>
|
||||
<h1 id="loginTitle">Админ-панель</h1>
|
||||
</div>
|
||||
<input type="text" id="userInput" placeholder="Логин" autocomplete="username" autofocus>
|
||||
<input type="password" id="pwdInput" placeholder="Пароль" autocomplete="current-password">
|
||||
<input type="text" id="hpWebsite" tabindex="-1" autocomplete="off" style="display:none" value="">
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<div class="settings-layout">
|
||||
<aside class="settings-nav" id="settingsNav">
|
||||
<a href="#sec-system" data-nav="sec-system" class="active"><i data-lucide="bar-chart-3"></i> Система</a>
|
||||
<a href="#sec-brand" data-nav="sec-brand"><i data-lucide="palette"></i> Брендинг</a>
|
||||
<a href="#sec-antispam" data-nav="sec-antispam"><i data-lucide="shield"></i> Антиспам</a>
|
||||
<a href="#sec-share" data-nav="sec-share"><i data-lucide="link"></i> Публичные ссылки</a>
|
||||
<a href="#sec-form" data-nav="sec-form"><i data-lucide="file-text"></i> Форма ответа</a>
|
||||
@@ -42,6 +43,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" id="sec-brand">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="palette"></i></div>
|
||||
<h3>Брендинг</h3>
|
||||
</div>
|
||||
<p class="hint">Название и логотип отображаются в админ-панели, на форме ответа ученика, странице входа и в отчёте.</p>
|
||||
<div class="settings-field">
|
||||
<label for="systemName">Название системы</label>
|
||||
<input type="text" id="systemName" class="settings-input" maxlength="60" placeholder="WhatIDo">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Логотип</label>
|
||||
<div class="logo-picker">
|
||||
<div class="logo-preview" id="logoPreview">
|
||||
<span id="logoPreviewFallback">—</span>
|
||||
<img id="logoPreviewImg" alt="Логотип">
|
||||
</div>
|
||||
<div class="logo-picker-actions">
|
||||
<div class="logo-picker-btns">
|
||||
<button class="btn-primary ghost" id="logoUploadBtn" type="button"><i data-lucide="upload"></i> Загрузить логотип</button>
|
||||
<button class="btn-primary danger-ghost" id="logoRemoveBtn" type="button" hidden><i data-lucide="trash-2"></i> Удалить</button>
|
||||
</div>
|
||||
<input type="file" id="logoInput" accept="image/*" style="display:none">
|
||||
<div class="logo-upload-status" id="logoUploadStatus"></div>
|
||||
<span class="hint">Рекомендуется широкое изображение с прозрачным фоном (PNG/WebP), не более 1 МБ</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" id="sec-antispam">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="shield"></i></div>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user