master #1
@@ -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 .save{background:var(--accent);color:#fff}
|
||||
.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}
|
||||
.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}
|
||||
@@ -93,6 +93,34 @@ function fallbackCopy(text, done) {
|
||||
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) {
|
||||
const done = () => showToast('Скопировано: ' + text);
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
</div>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="admin.js"></script>
|
||||
<script src="vendor/jszip.min.js"></script>
|
||||
<script>
|
||||
// --- Groups ---
|
||||
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
|
||||
@@ -55,6 +56,7 @@ async function loadGroups() {
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="save-sched" onclick="saveGroup(${g.id})">Сохранить</button>
|
||||
<button class="btn-link" onclick="exportGroup(${g.id}, '${esc(g.name).replace(/'/g, ''')}')" title="Экспортировать записи группы">CSV</button>
|
||||
<button class="btn-link" onclick="exportGroupUrls(${g.id}, '${esc(g.name).replace(/'/g, ''')}')" title="Скачать .url файлы учеников группы">URL</button>
|
||||
</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>
|
||||
@@ -121,6 +123,15 @@ async function exportGroup(id, name) {
|
||||
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(); });
|
||||
|
||||
(async () => {
|
||||
|
||||
+31
-10
@@ -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}}
|
||||
|
||||
.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 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 .cam{background:var(--accent);color:#fff;border-color:var(--accent)}
|
||||
.photo-btns .cam:hover{opacity:.85}
|
||||
.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.show{display:block}
|
||||
.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.has-photo{border-style:solid}
|
||||
.remove-photo{background:none;border:none;color:#ef4444;font-size:.85rem;cursor:pointer;padding:4px;display:none}
|
||||
.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: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.show{opacity:1}
|
||||
.toast.error{background:#dc2626}
|
||||
@@ -69,13 +74,13 @@ nav a:hover{color:var(--text)}
|
||||
<form class="form" id="form">
|
||||
<div class="photo-col">
|
||||
<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">
|
||||
<button type="button" class="cam" onclick="openCam()">Камера</button>
|
||||
</div>
|
||||
<div class="preview-wrap">
|
||||
<img class="preview" id="preview">
|
||||
<button type="button" class="remove-photo" id="removePhotoBtn" onclick="removePhoto()">Убрать фото</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -102,6 +107,11 @@ nav a:hover{color:var(--text)}
|
||||
</div>
|
||||
</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-box">
|
||||
<video id="camVideo" autoplay playsinline></video>
|
||||
@@ -119,6 +129,7 @@ nav a:hover{color:var(--text)}
|
||||
const API = location.origin;
|
||||
const form = document.getElementById('form');
|
||||
const preview = document.getElementById('preview');
|
||||
const placeholderSrc = preview.src;
|
||||
const removePhotoBtn = document.getElementById('removePhotoBtn');
|
||||
const groupInput = document.getElementById('groupInput');
|
||||
const toast = document.getElementById('toast');
|
||||
@@ -165,16 +176,23 @@ if (params.get('nameInput')) {
|
||||
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) {
|
||||
preview.src = src;
|
||||
preview.classList.add('show');
|
||||
preview.classList.add('has-photo');
|
||||
removePhotoBtn.classList.add('show');
|
||||
}
|
||||
|
||||
function removePhoto() {
|
||||
preview.classList.remove('show');
|
||||
preview.src = placeholderSrc;
|
||||
preview.classList.remove('has-photo');
|
||||
removePhotoBtn.classList.remove('show');
|
||||
capturedBlob = null;
|
||||
}
|
||||
@@ -197,7 +215,10 @@ function closeCam() {
|
||||
function capture() {
|
||||
camCanvas.width = camVideo.videoWidth;
|
||||
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 => {
|
||||
capturedBlob = blob;
|
||||
showPreview(URL.createObjectURL(blob));
|
||||
|
||||
+1
-1
@@ -85,7 +85,7 @@
|
||||
<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)">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+26
-1
@@ -24,6 +24,25 @@
|
||||
</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">
|
||||
<h3 style="margin:0 0 8px">Резервная копия</h3>
|
||||
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.</p>
|
||||
@@ -43,6 +62,8 @@ async function loadSettings() {
|
||||
const res = await fetch(`${API}/api/settings`, { headers: hdr() });
|
||||
const s = await res.json();
|
||||
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() {
|
||||
@@ -51,7 +72,11 @@ async function saveSettings() {
|
||||
const res = await fetch(`${API}/api/settings`, {
|
||||
method: 'PUT',
|
||||
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('Настройки сохранены');
|
||||
else { const e = await res.json(); alert(e.error); }
|
||||
|
||||
@@ -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">
|
||||
<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="exportStudentsUrls()">Экспорт URL</button>
|
||||
</div>
|
||||
<div class="list" id="studentList"></div>
|
||||
<div class="pager" id="pager"></div>
|
||||
@@ -67,6 +68,7 @@
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="admin.js"></script>
|
||||
<script src="vendor/jszip.min.js"></script>
|
||||
<script>
|
||||
// --- Students ---
|
||||
let allStudents = [];
|
||||
@@ -216,6 +218,15 @@ function exportStudentsCSV() {
|
||||
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 ---
|
||||
async function openBatchAttach() {
|
||||
const sel = document.getElementById('attachGroup');
|
||||
|
||||
Vendored
+13
File diff suppressed because one or more lines are too long
@@ -62,6 +62,13 @@ app.get('/api/settings', requireAdmin, async (_, res) => {
|
||||
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) => {
|
||||
const { settings } = req.body;
|
||||
if (!settings || typeof settings !== 'object') return res.status(400).json({ error: 'settings required' });
|
||||
|
||||
Reference in New Issue
Block a user