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}
|
.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{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{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-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}
|
.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}
|
.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}}
|
@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-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}
|
.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 userName = currentUser?.name || currentUser?.username || '';
|
||||||
const roleLabel = isAdmin() ? 'Администратор' : 'Тутор';
|
const roleLabel = isAdmin() ? 'Администратор' : 'Тутор';
|
||||||
el.innerHTML = `
|
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-box">
|
||||||
<div class="user-name">${esc(userName)}</div>
|
<div class="user-name">${esc(userName)}</div>
|
||||||
<div class="user-role">${roleLabel}</div>
|
<div class="user-role">${roleLabel}</div>
|
||||||
@@ -83,6 +86,26 @@ function buildSidebar(active) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
renderIcons();
|
renderIcons();
|
||||||
loadVersion();
|
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() {
|
async function loadVersion() {
|
||||||
|
|||||||
+11
-3
@@ -9,7 +9,10 @@
|
|||||||
:root{--bg:#f5f5f5;--card:#fff;--border:#e5e5e5;--accent:#2563eb;--text:#171717;--muted:#737373;--radius:12px}
|
: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}
|
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}
|
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}
|
.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}}
|
@media(max-width:760px){.form{grid-template-columns:1fr;max-width:480px}}
|
||||||
@@ -89,8 +92,13 @@ nav a:hover{color:var(--text)}
|
|||||||
<nav>
|
<nav>
|
||||||
<a href="admin.html">Админка</a>
|
<a href="admin.html">Админка</a>
|
||||||
</nav>
|
</nav>
|
||||||
<h1 id="title">Что мы узнали на занятии</h1>
|
<div class="brand-head">
|
||||||
<p class="sub">Отметь, что ты делал(а) на занятии</p>
|
<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">
|
<form class="form" id="form">
|
||||||
<div class="photo-col">
|
<div class="photo-col">
|
||||||
|
|||||||
@@ -14,3 +14,7 @@ if (details) {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
document.querySelector('.error-actions button').addEventListener('click', () => history.back());
|
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 {
|
try {
|
||||||
const fRes = await fetch(`${API}/api/public-settings`);
|
const fRes = await fetch(`${API}/api/public-settings`);
|
||||||
const f = await fRes.json();
|
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_left) document.getElementById('footerLeft').textContent = f.footer_left;
|
||||||
if (f.footer_right) document.getElementById('footerRight').textContent = f.footer_right;
|
if (f.footer_right) document.getElementById('footerRight').textContent = f.footer_right;
|
||||||
const c = parseInt(f.spam_interval_min, 10);
|
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('pwdInput').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
|
||||||
document.getElementById('userInput').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);
|
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 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')}
|
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');
|
const label=btn.querySelector('.text-block-wrap-div');
|
||||||
if(label)label.textContent='Перейти по ссылке';
|
if(label)label.textContent='Перейти по ссылке';
|
||||||
}
|
}
|
||||||
setText('footCopy','WhatIDo © '+new Date().getFullYear());
|
setText('footCopy',state.systemName+' © '+new Date().getFullYear());
|
||||||
setText('footBrand',heading);
|
setText('footBrand',heading);
|
||||||
const fbox=document.getElementById('footerPhotoBox');
|
const fbox=document.getElementById('footerPhotoBox');
|
||||||
const fimg=document.getElementById('footerPhoto');
|
const fimg=document.getElementById('footerPhoto');
|
||||||
@@ -542,5 +550,5 @@ function bindEvents(){
|
|||||||
|
|
||||||
bindEvents();
|
bindEvents();
|
||||||
makeNavLinks();
|
makeNavLinks();
|
||||||
loadReport();
|
loadBrand().then(function(){loadReport()});
|
||||||
function closeLightbox(){const lb=document.getElementById('lightbox');if(lb)lb.classList.remove('is-open')}
|
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 DIRTY_FIELDS = ['systemName', '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 SECTION_IDS = ['sec-system', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-form', 'sec-bans'];
|
||||||
const SPAM_MAX = 10080;
|
const SPAM_MAX = 10080;
|
||||||
const BAN_REASONS = {
|
const BAN_REASONS = {
|
||||||
'honeypot': 'Антиспам-поле',
|
'honeypot': 'Антиспам-поле',
|
||||||
@@ -8,6 +8,21 @@ const BAN_REASONS = {
|
|||||||
'manual': 'Вручную',
|
'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) {
|
function fieldValue(el) {
|
||||||
return el.type === 'checkbox' ? String(el.checked) : el.value;
|
return el.type === 'checkbox' ? String(el.checked) : el.value;
|
||||||
}
|
}
|
||||||
@@ -101,6 +116,8 @@ function revertChanges() {
|
|||||||
if (el.type === 'checkbox') el.checked = original === 'true';
|
if (el.type === 'checkbox') el.checked = original === 'true';
|
||||||
else el.value = original;
|
else el.value = original;
|
||||||
});
|
});
|
||||||
|
currentLogo = logoOriginal;
|
||||||
|
renderLogoPreview();
|
||||||
clearFieldError('spamInterval');
|
clearFieldError('spamInterval');
|
||||||
updateSpamUi();
|
updateSpamUi();
|
||||||
updateDirty();
|
updateDirty();
|
||||||
@@ -143,6 +160,10 @@ async function loadSettings() {
|
|||||||
]);
|
]);
|
||||||
if (!sRes.ok) { showError('Не удалось загрузить настройки'); return; }
|
if (!sRes.ok) { showError('Не удалось загрузить настройки'); return; }
|
||||||
const s = await sRes.json();
|
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;
|
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
|
||||||
document.getElementById('footerLeft').value = s.footer_left || '';
|
document.getElementById('footerLeft').value = s.footer_left || '';
|
||||||
document.getElementById('footerRight').value = s.footer_right || '';
|
document.getElementById('footerRight').value = s.footer_right || '';
|
||||||
@@ -487,6 +508,7 @@ async function saveSettings() {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: hdrJson(),
|
headers: hdrJson(),
|
||||||
body: JSON.stringify({ settings: {
|
body: JSON.stringify({ settings: {
|
||||||
|
system_name: document.getElementById('systemName').value.trim(),
|
||||||
spam_interval_min: String(Math.round(Number(v))),
|
spam_interval_min: String(Math.round(Number(v))),
|
||||||
footer_left: document.getElementById('footerLeft').value.trim(),
|
footer_left: document.getElementById('footerLeft').value.trim(),
|
||||||
footer_right: document.getElementById('footerRight').value.trim(),
|
footer_right: document.getElementById('footerRight').value.trim(),
|
||||||
@@ -504,6 +526,7 @@ async function saveSettings() {
|
|||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
showToast('Настройки сохранены');
|
showToast('Настройки сохранены');
|
||||||
|
logoOriginal = currentLogo;
|
||||||
snapshotDirty();
|
snapshotDirty();
|
||||||
updateSpamUi();
|
updateSpamUi();
|
||||||
} else {
|
} else {
|
||||||
@@ -637,6 +660,64 @@ if (statsRefreshBtn) statsRefreshBtn.addEventListener('click', async () => {
|
|||||||
statsRefreshBtn.classList.remove('spinning');
|
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 => {
|
document.addEventListener('keydown', e => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
+8
-2
@@ -9,7 +9,10 @@
|
|||||||
:root{--bg:#f5f5f5;--card:#fff;--border:#e5e5e5;--accent:#2563eb;--text:#171717;--muted:#737373;--radius:12px}
|
: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}
|
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{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{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 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}
|
.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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="login">
|
<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="text" id="userInput" placeholder="Логин" autocomplete="username" autofocus>
|
||||||
<input type="password" id="pwdInput" placeholder="Пароль" autocomplete="current-password">
|
<input type="password" id="pwdInput" placeholder="Пароль" autocomplete="current-password">
|
||||||
<input type="text" id="hpWebsite" tabindex="-1" autocomplete="off" style="display:none" value="">
|
<input type="text" id="hpWebsite" tabindex="-1" autocomplete="off" style="display:none" value="">
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<div class="settings-layout">
|
<div class="settings-layout">
|
||||||
<aside class="settings-nav" id="settingsNav">
|
<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-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-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-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>
|
<a href="#sec-form" data-nav="sec-form"><i data-lucide="file-text"></i> Форма ответа</a>
|
||||||
@@ -42,6 +43,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="settings-card" id="sec-antispam">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div class="ic"><i data-lucide="shield"></i></div>
|
<div class="ic"><i data-lucide="shield"></i></div>
|
||||||
|
|||||||
@@ -616,7 +616,7 @@ async function removeEntryFiles(entryId) {
|
|||||||
async function sweepOrphanedUploads() {
|
async function sweepOrphanedUploads() {
|
||||||
const dir = path.join(__dirname, 'uploads');
|
const dir = path.join(__dirname, 'uploads');
|
||||||
if (!fs.existsSync(dir)) return;
|
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 photo_path AS p FROM entries WHERE photo_path IS NOT NULL'),
|
||||||
pool.query('SELECT path AS p FROM project_files'),
|
pool.query('SELECT path AS p FROM project_files'),
|
||||||
pool.query('SELECT photo_path AS p FROM group_photos'),
|
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 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 modules WHERE photo_path IS NOT NULL'),
|
||||||
pool.query('SELECT photo_path AS p FROM student_photos'),
|
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();
|
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)) {
|
for (const f of fs.readdirSync(dir)) {
|
||||||
const fp = path.join(dir, f);
|
const fp = path.join(dir, f);
|
||||||
if (!fs.statSync(fp).isFile()) continue;
|
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) => {
|
app.get('/api/public-settings', apiLimiter, async (_, res) => {
|
||||||
const out = await cacheWrap('public-settings', PUBLIC_TTL_MS, async () => {
|
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 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 = { spam_interval_min: '30', photo_capture_resolution: '640x480', photo_capture_quality: '0.92', photo_enhance_engine: 'auto' };
|
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 = {};
|
const result = {};
|
||||||
for (const k of keys) result[k] = await getSetting(k, defaults[k] || '');
|
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);
|
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))) {
|
if (key === 'photo_enhance_engine' && !['auto', 'server', 'client'].includes(String(value))) {
|
||||||
return res.status(400).json({ error: 'photo_enhance_engine должен быть auto, server или client' });
|
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();
|
const client = await pool.connect();
|
||||||
try {
|
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) => {
|
app.get('/api/audit', requireAdmin, async (req, res) => {
|
||||||
const limit = Math.min(parseInt(req.query.limit, 10) || 100, 1000);
|
const limit = Math.min(parseInt(req.query.limit, 10) || 100, 1000);
|
||||||
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
|
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user