master #1

Closed
admin wants to merge 19 commits from master into main
9 changed files with 130 additions and 12 deletions
Showing only changes of commit d7a479c008 - Show all commits
+2
View File
@@ -144,6 +144,8 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.edit-modal .actions button{flex:1;padding:12px;border:none;border-radius:8px;font-size:.9rem;font-weight:600;cursor:pointer} .edit-modal .actions button{flex:1;padding:12px;border:none;border-radius:8px;font-size:.9rem;font-weight:600;cursor:pointer}
.edit-modal .save{background:var(--accent);color:#fff} .edit-modal .save{background:var(--accent);color:#fff}
.edit-modal .cancel{background:var(--border);color:var(--text)} .edit-modal .cancel{background:var(--border);color:var(--text)}
.edit-modal .cancel-btn{display:flex;flex:1;align-items:center;justify-content:center;padding:12px;border-radius:8px;font-size:.9rem;font-weight:600;cursor:pointer;text-decoration:none;background:var(--border);color:var(--text);transition:opacity .15s}
.edit-modal .cancel-btn:hover{opacity:.85}
.edit-modal .actions button:hover{opacity:.85} .edit-modal .actions button:hover{opacity:.85}
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#16a34a;color:#fff;padding:12px 24px;border-radius:var(--radius);font-size:.9rem;opacity:0;transition:opacity .3s;pointer-events:none;z-index:200} .toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#16a34a;color:#fff;padding:12px 24px;border-radius:var(--radius);font-size:.9rem;opacity:0;transition:opacity .3s;pointer-events:none;z-index:200}
.toast.show{opacity:1} .toast.show{opacity:1}
+28
View File
@@ -93,6 +93,34 @@ function fallbackCopy(text, done) {
document.body.removeChild(ta); document.body.removeChild(ta);
} }
function studentUrlContent(name) {
const url = `${location.origin}/?nameInput=${encodeURIComponent(name)}`;
return `[InternetShortcut]\r\nURL=${url}\r\n`;
}
function safeWindowsName(name) {
return String(name).replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, '_');
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
async function exportStudentUrlZip(students, zipName) {
if (typeof JSZip === 'undefined') { alert('JSZip не загружен'); return; }
const zip = new JSZip();
students.forEach(s => {
zip.file(safeWindowsName(s.name) + '.url', studentUrlContent(s.name));
});
const blob = await zip.generateAsync({ type: 'blob' });
downloadBlob(blob, zipName);
}
function copyText(text) { function copyText(text) {
const done = () => showToast('Скопировано: ' + text); const done = () => showToast('Скопировано: ' + text);
if (navigator.clipboard && window.isSecureContext) { if (navigator.clipboard && window.isSecureContext) {
+11
View File
@@ -20,6 +20,7 @@
</div> </div>
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="admin.js"></script> <script src="admin.js"></script>
<script src="vendor/jszip.min.js"></script>
<script> <script>
// --- Groups --- // --- Groups ---
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб']; const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
@@ -55,6 +56,7 @@ async function loadGroups() {
<div style="display:flex;gap:8px"> <div style="display:flex;gap:8px">
<button class="save-sched" onclick="saveGroup(${g.id})">Сохранить</button> <button class="save-sched" onclick="saveGroup(${g.id})">Сохранить</button>
<button class="btn-link" onclick="exportGroup(${g.id}, '${esc(g.name).replace(/'/g, '&#39;')}')" title="Экспортировать записи группы">CSV</button> <button class="btn-link" onclick="exportGroup(${g.id}, '${esc(g.name).replace(/'/g, '&#39;')}')" title="Экспортировать записи группы">CSV</button>
<button class="btn-link" onclick="exportGroupUrls(${g.id}, '${esc(g.name).replace(/'/g, '&#39;')}')" title="Скачать .url файлы учеников группы">URL</button>
</div> </div>
<div class="sched-status">${schedSet ? `${DAYS[g.day_of_week]} ${g.time_start.slice(0,5)}–${g.time_end.slice(0,5)}` : 'Не настроено'}</div> <div class="sched-status">${schedSet ? `${DAYS[g.day_of_week]} ${g.time_start.slice(0,5)}–${g.time_end.slice(0,5)}` : 'Не настроено'}</div>
</div> </div>
@@ -121,6 +123,15 @@ async function exportGroup(id, name) {
showToast(`Экспортировано записей: ${entries.length}`); showToast(`Экспортировано записей: ${entries.length}`);
} }
async function exportGroupUrls(groupId, groupName) {
const res = await fetch(`${API}/api/students`, { headers: hdr() });
const students = await res.json();
const list = students.filter(s => s.group_id === groupId);
if (!list.length) { alert('В группе нет учеников'); return; }
await exportStudentUrlZip(list, `group_${groupName.replace(/[\\/:*?"<>|]/g, '_')}.zip`);
showToast(`Экспортировано: ${list.length}`);
}
document.getElementById('newGroup').addEventListener('keydown', e => { if (e.key === 'Enter') addGroup(); }); document.getElementById('newGroup').addEventListener('keydown', e => { if (e.key === 'Enter') addGroup(); });
(async () => { (async () => {
+31 -10
View File
@@ -15,15 +15,15 @@ h1{font-size:1.5rem;font-weight:600;margin-bottom:8px;letter-spacing:-.02em;text
@media(max-width:760px){.form{grid-template-columns:1fr;max-width:480px}} @media(max-width:760px){.form{grid-template-columns:1fr;max-width:480px}}
.photo-col{display:flex;flex-direction:column;gap:16px} .photo-col{display:flex;flex-direction:column;gap:16px}
.photo-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:24px;display:flex;flex-direction:column;gap:16px;align-items:center} .photo-card{border-radius:var(--radius);padding:24px;display:flex;flex-direction:column;gap:16px;align-items:center}
.photo-btns{display:flex;flex-direction:column;gap:12px;width:100%} .photo-btns{display:flex;flex-direction:column;gap:12px;width:100%}
.photo-btns button{width:100%;border-radius:var(--radius);padding:14px 16px;font-size:.95rem;font-weight:600;cursor:pointer;border:1px solid var(--border);background:var(--card);color:var(--text);transition:border .15s,background .15s} .photo-btns button{width:100%;border-radius:var(--radius);padding:14px 16px;font-size:.95rem;font-weight:600;cursor:pointer;border:1px solid var(--border);background:var(--card);color:var(--text);transition:border .15s,background .15s}
.photo-btns button:hover{border-color:var(--accent)} .photo-btns button:hover{border-color:var(--accent)}
.photo-btns .cam{background:var(--accent);color:#fff;border-color:var(--accent)} .photo-btns .cam{background:var(--accent);color:#fff;border-color:var(--accent)}
.photo-btns .cam:hover{opacity:.85} .photo-btns .cam:hover{opacity:.85}
.preview-wrap{width:100%;display:flex;flex-direction:column;align-items:center;gap:8px} .preview-wrap{width:100%;display:flex;flex-direction:column;align-items:center;gap:8px}
.preview{max-width:100%;width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:var(--radius);background:var(--bg);display:none} .preview{max-width:100%;width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:var(--radius);background:var(--card);display:block;border:1px dashed var(--border)}
.preview.show{display:block} .preview.has-photo{border-style:solid}
.remove-photo{background:none;border:none;color:#ef4444;font-size:.85rem;cursor:pointer;padding:4px;display:none} .remove-photo{background:none;border:none;color:#ef4444;font-size:.85rem;cursor:pointer;padding:4px;display:none}
.remove-photo.show{display:block} .remove-photo.show{display:block}
@@ -39,6 +39,11 @@ textarea{resize:vertical;min-height:160px;font-family:inherit;line-height:1.5}
.btn:hover{opacity:.85} .btn:hover{opacity:.85}
.btn:disabled{opacity:.4;cursor:not-allowed} .btn:disabled{opacity:.4;cursor:not-allowed}
.footer{max-width:900px;margin:48px auto 0;padding-top:24px;border-top:1px solid var(--border);display:flex;justify-content:space-between;gap:16px;font-size:.85rem;color:var(--muted)}
.footer .f-left{flex:1;text-align:left}
.footer .f-right{flex:1;text-align:right}
@media(max-width:760px){.footer{flex-direction:column;text-align:center}.footer .f-left,.footer .f-right{text-align:center}}
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#16a34a;color:#fff;padding:12px 24px;border-radius:var(--radius);font-size:.9rem;opacity:0;transition:opacity .3s;pointer-events:none;z-index:200} .toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#16a34a;color:#fff;padding:12px 24px;border-radius:var(--radius);font-size:.9rem;opacity:0;transition:opacity .3s;pointer-events:none;z-index:200}
.toast.show{opacity:1} .toast.show{opacity:1}
.toast.error{background:#dc2626} .toast.error{background:#dc2626}
@@ -69,13 +74,13 @@ nav a:hover{color:var(--text)}
<form class="form" id="form"> <form class="form" id="form">
<div class="photo-col"> <div class="photo-col">
<div class="photo-card"> <div class="photo-card">
<div class="preview-wrap">
<img class="preview" id="preview" alt="Фото" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='480'%3E%3Crect width='100%25' height='100%25' fill='none' stroke='%23475569' stroke-width='2' stroke-dasharray='8 8'/%3E%3Ctext x='50%25' y='50%25' fill='%2364748b' font-family='sans-serif' font-size='28' text-anchor='middle' dominant-baseline='middle'%3EФото%3C/text%3E%3C/svg%3E">
<button type="button" class="remove-photo" id="removePhotoBtn" onclick="removePhoto()">Убрать фото</button>
</div>
<div class="photo-btns"> <div class="photo-btns">
<button type="button" class="cam" onclick="openCam()">Камера</button> <button type="button" class="cam" onclick="openCam()">Камера</button>
</div> </div>
<div class="preview-wrap">
<img class="preview" id="preview">
<button type="button" class="remove-photo" id="removePhotoBtn" onclick="removePhoto()">Убрать фото</button>
</div>
</div> </div>
</div> </div>
@@ -102,6 +107,11 @@ nav a:hover{color:var(--text)}
</div> </div>
</form> </form>
<footer class="footer">
<div class="f-left" id="footerLeft"></div>
<div class="f-right" id="footerRight"></div>
</footer>
<div class="modal" id="camModal"> <div class="modal" id="camModal">
<div class="modal-box"> <div class="modal-box">
<video id="camVideo" autoplay playsinline></video> <video id="camVideo" autoplay playsinline></video>
@@ -119,6 +129,7 @@ nav a:hover{color:var(--text)}
const API = location.origin; const API = location.origin;
const form = document.getElementById('form'); const form = document.getElementById('form');
const preview = document.getElementById('preview'); const preview = document.getElementById('preview');
const placeholderSrc = preview.src;
const removePhotoBtn = document.getElementById('removePhotoBtn'); const removePhotoBtn = document.getElementById('removePhotoBtn');
const groupInput = document.getElementById('groupInput'); const groupInput = document.getElementById('groupInput');
const toast = document.getElementById('toast'); const toast = document.getElementById('toast');
@@ -165,16 +176,23 @@ if (params.get('nameInput')) {
groupInput.innerHTML = '<option value="" disabled selected>Нет активных групп</option>'; groupInput.innerHTML = '<option value="" disabled selected>Нет активных групп</option>';
} }
} }
try {
const fRes = await fetch(`${API}/api/public-settings`);
const f = await fRes.json();
if (f.footer_left) document.getElementById('footerLeft').textContent = f.footer_left;
if (f.footer_right) document.getElementById('footerRight').textContent = f.footer_right;
} catch (e) { /* ignore */ }
})(); })();
function showPreview(src) { function showPreview(src) {
preview.src = src; preview.src = src;
preview.classList.add('show'); preview.classList.add('has-photo');
removePhotoBtn.classList.add('show'); removePhotoBtn.classList.add('show');
} }
function removePhoto() { function removePhoto() {
preview.classList.remove('show'); preview.src = placeholderSrc;
preview.classList.remove('has-photo');
removePhotoBtn.classList.remove('show'); removePhotoBtn.classList.remove('show');
capturedBlob = null; capturedBlob = null;
} }
@@ -197,7 +215,10 @@ function closeCam() {
function capture() { function capture() {
camCanvas.width = camVideo.videoWidth; camCanvas.width = camVideo.videoWidth;
camCanvas.height = camVideo.videoHeight; camCanvas.height = camVideo.videoHeight;
camCanvas.getContext('2d').drawImage(camVideo, 0, 0); const ctx = camCanvas.getContext('2d');
ctx.translate(camCanvas.width, 0);
ctx.scale(-1, 1);
ctx.drawImage(camVideo, 0, 0);
camCanvas.toBlob(blob => { camCanvas.toBlob(blob => {
capturedBlob = blob; capturedBlob = blob;
showPreview(URL.createObjectURL(blob)); showPreview(URL.createObjectURL(blob));
+1 -1
View File
@@ -85,7 +85,7 @@
<div style="font-size:.85rem;color:var(--muted);margin-bottom:8px">Открывается публичная страница с карточками — без входа и выбора параметров. Ссылку можно изменить или удалить в разделе «Ссылки».</div> <div style="font-size:.85rem;color:var(--muted);margin-bottom:8px">Открывается публичная страница с карточками — без входа и выбора параметров. Ссылку можно изменить или удалить в разделе «Ссылки».</div>
<input type="text" id="linkUrlResult" readonly style="font-size:.8rem;color:var(--accent)"> <input type="text" id="linkUrlResult" readonly style="font-size:.8rem;color:var(--accent)">
<div class="actions"> <div class="actions">
<a class="cancel" href="links.html" style="text-decoration:none">К списку ссылок</a> <a class="cancel-btn" href="links.html">К списку ссылок</a>
<button class="save" onclick="copyLink()">Копировать</button> <button class="save" onclick="copyLink()">Копировать</button>
</div> </div>
</div> </div>
+26 -1
View File
@@ -24,6 +24,25 @@
</div> </div>
</div> </div>
<div class="settings-card">
<h3 style="margin:0 0 8px">Футер на форме</h3>
<p class="hint" style="margin-bottom:16px">Тексты показываются внизу формы ответа ученика.</p>
<div class="settings-row">
<div>
<label for="footerLeft">Текст слева</label>
<p class="hint">Нижняя часть формы, слева</p>
</div>
<input type="text" id="footerLeft" style="width:280px;padding:10px 14px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text);font-size:.9rem" placeholder="">
</div>
<div class="settings-row" style="margin-top:16px">
<div>
<label for="footerRight">Текст справа</label>
<p class="hint">Нижняя часть формы, справа</p>
</div>
<input type="text" id="footerRight" style="width:280px;padding:10px 14px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text);font-size:.9rem" placeholder="">
</div>
</div>
<div class="settings-card"> <div class="settings-card">
<h3 style="margin:0 0 8px">Резервная копия</h3> <h3 style="margin:0 0 8px">Резервная копия</h3>
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.</p> <p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.</p>
@@ -43,6 +62,8 @@ async function loadSettings() {
const res = await fetch(`${API}/api/settings`, { headers: hdr() }); const res = await fetch(`${API}/api/settings`, { headers: hdr() });
const s = await res.json(); const s = await res.json();
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('footerRight').value = s.footer_right || '';
} }
async function saveSettings() { async function saveSettings() {
@@ -51,7 +72,11 @@ async function saveSettings() {
const res = await fetch(`${API}/api/settings`, { const res = await fetch(`${API}/api/settings`, {
method: 'PUT', method: 'PUT',
headers: hdrJson(), headers: hdrJson(),
body: JSON.stringify({ settings: { spam_interval_min: String(Math.round(Number(v))) } }) body: JSON.stringify({ settings: {
spam_interval_min: String(Math.round(Number(v))),
footer_left: document.getElementById('footerLeft').value.trim(),
footer_right: document.getElementById('footerRight').value.trim(),
} })
}); });
if (res.ok) showToast('Настройки сохранены'); if (res.ok) showToast('Настройки сохранены');
else { const e = await res.json(); alert(e.error); } else { const e = await res.json(); alert(e.error); }
+11
View File
@@ -22,6 +22,7 @@
<input type="text" id="studentSearch" class="search-input" placeholder="Поиск по имени..." style="flex:1;min-width:200px;max-width:340px;padding:10px 14px;font-size:.9rem"> <input type="text" id="studentSearch" class="search-input" placeholder="Поиск по имени..." style="flex:1;min-width:200px;max-width:340px;padding:10px 14px;font-size:.9rem">
<select id="studentGroupFilter" class="search-input" style="padding:10px 14px"><option value="">Все группы</option></select> <select id="studentGroupFilter" class="search-input" style="padding:10px 14px"><option value="">Все группы</option></select>
<button class="btn-link" onclick="exportStudentsCSV()">Экспорт CSV</button> <button class="btn-link" onclick="exportStudentsCSV()">Экспорт CSV</button>
<button class="btn-link" onclick="exportStudentsUrls()">Экспорт URL</button>
</div> </div>
<div class="list" id="studentList"></div> <div class="list" id="studentList"></div>
<div class="pager" id="pager"></div> <div class="pager" id="pager"></div>
@@ -67,6 +68,7 @@
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="admin.js"></script> <script src="admin.js"></script>
<script src="vendor/jszip.min.js"></script>
<script> <script>
// --- Students --- // --- Students ---
let allStudents = []; let allStudents = [];
@@ -216,6 +218,15 @@ function exportStudentsCSV() {
showToast(`Экспортировано: ${list.length}`); showToast(`Экспортировано: ${list.length}`);
} }
async function exportStudentsUrls() {
const gid = document.getElementById('studentGroupFilter').value;
const list = gid ? allStudents.filter(s => String(s.group_id) === gid) : allStudents;
if (!list.length) { alert('Нет данных для экспорта'); return; }
const groupName = gid ? (allGroups.find(g => g.id == gid)?.name || 'group') : 'all';
await exportStudentUrlZip(list, `students_${groupName.replace(/[\\/:*?"<>|]/g, '_')}.zip`);
showToast(`Экспортировано: ${list.length}`);
}
// --- Batch attach --- // --- Batch attach ---
async function openBatchAttach() { async function openBatchAttach() {
const sel = document.getElementById('attachGroup'); const sel = document.getElementById('attachGroup');
+13
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -62,6 +62,13 @@ app.get('/api/settings', requireAdmin, async (_, res) => {
res.json(out); res.json(out);
}); });
app.get('/api/public-settings', async (_, res) => {
const keys = ['footer_left', 'footer_right'];
const out = {};
for (const k of keys) out[k] = await getSetting(k, '');
res.json(out);
});
app.put('/api/settings', requireAdmin, async (req, res) => { app.put('/api/settings', requireAdmin, async (req, res) => {
const { settings } = req.body; const { settings } = req.body;
if (!settings || typeof settings !== 'object') return res.status(400).json({ error: 'settings required' }); if (!settings || typeof settings !== 'object') return res.status(400).json({ error: 'settings required' });