Часовой пояс и формат времени (24h/12h) перенесены из браузера в настройки приложения: валидация IANA-зоны, сид в db/init.sql + db/migration.sql, отдача в GET /api/public-settings, раздел #sec-datetime в настройках с живым превью. Границы суток в SQL переведены на tzDayStart/tzDayEnd + bindTz ($TZ$) — раньше $n::date по TIMESTAMPTZ считал дни в UTC (у контейнера TimeZone=UTC) и молча сдвигал выборку на день; хардкод Europe/Moscow вычищен, now() заменён на tzWall(). Новый public/js/datetime.js: три семейства хелперов (instant / чистая DATE-строка без Date() / чистая TIME-строка), подключён на всех страницах включая публичные share/report/index, initDateTime() встроен в checkAuth(). Прямые toLocale*/getFullYear/toISOString().slice(0,10) в public/ выпилены. Попутно: bindTz добавляет параметр только при наличии $TZ$ в SQL (иначе запрос виснет вечно без global error handler) — регрессия закрыта в api.smoketest.js; починены off-by-one месяца в report.js и группировка по дате в share.js.
474 lines
18 KiB
JavaScript
474 lines
18 KiB
JavaScript
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 moduleInput = document.getElementById('moduleInput');
|
|
const moduleInputList = document.getElementById('moduleInputList');
|
|
const moduleField = document.getElementById('moduleField');
|
|
const moduleNames = new Map();
|
|
let moduleSearchTimer = null;
|
|
const toast = document.getElementById('toast');
|
|
const camVideo = document.getElementById('camVideo');
|
|
const camCanvas = document.getElementById('camCanvas');
|
|
const camModal = document.getElementById('camModal');
|
|
let capturedBlob = null;
|
|
let stream = null;
|
|
let camOpening = false;
|
|
const descInput = document.getElementById('descInput');
|
|
const filesInput = document.getElementById('filesInput');
|
|
const filesList = document.getElementById('filesList');
|
|
const filesClearBtn = document.getElementById('filesClearBtn');
|
|
let selectedFiles = [];
|
|
const sentPanel = document.getElementById('sentPanel');
|
|
const sentTitle = document.getElementById('sentTitle');
|
|
const sentText = document.getElementById('sentText');
|
|
const sentTimer = document.getElementById('sentTimer');
|
|
const SENT_KEY = 'whatido_entry_sent_at';
|
|
let entryCooldownMin = 30;
|
|
let captureWidth = 640;
|
|
let captureHeight = 480;
|
|
let captureQuality = 0.92;
|
|
let sentTimerId = null;
|
|
|
|
function unlockForm() {
|
|
clearInterval(sentTimerId);
|
|
sentTimerId = null;
|
|
localStorage.removeItem(SENT_KEY);
|
|
sentPanel.style.display = 'none';
|
|
form.style.display = 'grid';
|
|
toast.textContent = 'Можно отправить новую запись';
|
|
toast.classList.add('show');
|
|
setTimeout(() => toast.classList.remove('show'), 2500);
|
|
}
|
|
|
|
function tickSentTimer(unlockTime) {
|
|
const remain = Math.max(0, unlockTime - Date.now());
|
|
if (remain <= 0) {
|
|
sentTimer.textContent = '';
|
|
unlockForm();
|
|
return;
|
|
}
|
|
const m = Math.floor(remain / 60000);
|
|
const s = Math.floor((remain % 60000) / 1000);
|
|
sentTimer.textContent = 'Новую запись можно отправить через ' + m + ' мин ' + s + ' сек';
|
|
}
|
|
|
|
function showSent(title, text, unlockTime) {
|
|
form.style.display = 'none';
|
|
sentPanel.style.display = 'flex';
|
|
sentTitle.textContent = title;
|
|
sentText.textContent = text;
|
|
tickSentTimer(unlockTime);
|
|
clearInterval(sentTimerId);
|
|
sentTimerId = setInterval(() => tickSentTimer(unlockTime), 1000);
|
|
}
|
|
|
|
function cooldownMs() { return entryCooldownMin * 60 * 1000; }
|
|
|
|
function checkPreviousSend() {
|
|
const raw = localStorage.getItem(SENT_KEY);
|
|
if (!raw) return;
|
|
const sentAt = parseInt(raw, 10);
|
|
if (!Number.isFinite(sentAt)) { localStorage.removeItem(SENT_KEY); return; }
|
|
const unlockTime = sentAt + cooldownMs();
|
|
if (Date.now() < unlockTime) {
|
|
showSent('Запись уже отправлена', 'Ты уже отправил(а) запись сегодня. Дождись окончания отсчёта, чтобы отправить новую.', unlockTime);
|
|
} else {
|
|
localStorage.removeItem(SENT_KEY);
|
|
}
|
|
}
|
|
|
|
function humanSize(b) {
|
|
if (b < 1024) return b + ' Б';
|
|
if (b < 1048576) return (b / 1024).toFixed(1) + ' КБ';
|
|
return (b / 1048576).toFixed(1) + ' МБ';
|
|
}
|
|
|
|
function renderFiles() {
|
|
filesList.innerHTML = '';
|
|
selectedFiles.forEach((f, i) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'file-row';
|
|
const name = document.createElement('span');
|
|
name.className = 'f-name';
|
|
name.textContent = f.name;
|
|
name.title = f.name;
|
|
const size = document.createElement('span');
|
|
size.className = 'f-size';
|
|
size.textContent = humanSize(f.size);
|
|
const del = document.createElement('button');
|
|
del.type = 'button';
|
|
del.textContent = '✕';
|
|
del.addEventListener('click', () => removeFile(i));
|
|
row.append(name, size, del);
|
|
filesList.appendChild(row);
|
|
});
|
|
filesClearBtn.classList.toggle('show', selectedFiles.length > 0);
|
|
}
|
|
|
|
function removeFile(i) {
|
|
selectedFiles.splice(i, 1);
|
|
renderFiles();
|
|
}
|
|
|
|
function clearFiles() {
|
|
selectedFiles = [];
|
|
filesInput.value = '';
|
|
renderFiles();
|
|
}
|
|
|
|
const MAX_FILES = 10;
|
|
const DEFAULT_FILE_LIMIT_MB = 50;
|
|
const DEFAULT_TOTAL_LIMIT_MB = 200;
|
|
let MAX_FILE_BYTES = DEFAULT_FILE_LIMIT_MB * 1024 * 1024;
|
|
let MAX_TOTAL_BYTES = DEFAULT_TOTAL_LIMIT_MB * 1024 * 1024;
|
|
const BLOCKED_FILE_EXT = /\.(?:html?|js|mjs|cjs|svg|xml|json|map|wasm|php\d?|phtml|asp|aspx|jsp|sh|bat|cmd|cgi|exe|dll|com|msi|scr|hta|vbs|py|r|rb|htaccess)$/i;
|
|
const MIME_EXT = {
|
|
'image/png': '.png', 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp',
|
|
'image/bmp': '.bmp', 'image/avif': '.avif', 'image/heic': '.heic', 'image/heif': '.heif', 'image/svg+xml': '.svg',
|
|
'application/pdf': '.pdf', 'application/zip': '.zip', 'application/x-zip-compressed': '.zip',
|
|
'application/msword': '.doc', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
|
|
'application/vnd.ms-excel': '.xls', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
|
|
'application/vnd.ms-powerpoint': '.ppt', 'application/vnd.openxmlformats-officedocument.presentationml.presentation': '.pptx',
|
|
'text/plain': '.txt', 'text/csv': '.csv', 'application/rtf': '.rtf',
|
|
'application/x-rar-compressed': '.rar', 'application/vnd.rar': '.rar', 'application/x-7z-compressed': '.7z'
|
|
};
|
|
let notifyTimer = null;
|
|
|
|
function notify(text, isError) {
|
|
toast.textContent = text;
|
|
toast.classList.add('show');
|
|
toast.classList.toggle('error', !!isError);
|
|
clearTimeout(notifyTimer);
|
|
notifyTimer = setTimeout(() => toast.classList.remove('show', 'error'), 3200);
|
|
}
|
|
|
|
function extOf(name) {
|
|
const s = String(name || '');
|
|
const i = s.lastIndexOf('.');
|
|
return i > 0 ? s.slice(i).toLowerCase() : '';
|
|
}
|
|
|
|
function extFromFile(file) {
|
|
const named = extOf(file.name);
|
|
if (/^\.[a-z0-9]{1,8}$/i.test(named)) return named;
|
|
const byMime = MIME_EXT[String(file.type || '').toLowerCase()];
|
|
if (byMime) return byMime;
|
|
const sub = String(file.type || '').split('/')[1] || '';
|
|
return /^[a-z0-9]{1,8}$/i.test(sub) ? '.' + sub : '';
|
|
}
|
|
|
|
function pasteFileName(file) {
|
|
const base = String(file.name || '').trim();
|
|
const stem = base.replace(/\.[a-z0-9]{1,8}$/i, '');
|
|
if (base && !/^(image|photo|picture|blob|file|screenshot|снимок|скриншот|изображение|файл)$/i.test(stem)) return base;
|
|
const d = new Date();
|
|
const pad = n => String(n).padStart(2, '0');
|
|
const stamp = `${todayIso()} ${nowHm().replace(':', '-')}-${pad(d.getSeconds())}`;
|
|
return `Вставка ${stamp}${extFromFile(file)}`;
|
|
}
|
|
|
|
function totalUploadBytes() {
|
|
return (capturedBlob ? capturedBlob.size : 0) + selectedFiles.reduce((sum, f) => sum + f.size, 0);
|
|
}
|
|
|
|
function applyUploadLimits(fileMb, totalMb) {
|
|
const f = parseInt(fileMb, 10);
|
|
const t = parseInt(totalMb, 10);
|
|
const fileLimit = Number.isFinite(f) && f >= 1 ? f : DEFAULT_FILE_LIMIT_MB;
|
|
const totalLimit = Number.isFinite(t) && t >= fileLimit ? t : Math.max(DEFAULT_TOTAL_LIMIT_MB, fileLimit);
|
|
MAX_FILE_BYTES = fileLimit * 1024 * 1024;
|
|
MAX_TOTAL_BYTES = totalLimit * 1024 * 1024;
|
|
}
|
|
|
|
function addFiles(list) {
|
|
const added = [];
|
|
const skipped = [];
|
|
const fileLimitMb = Math.round(MAX_FILE_BYTES / 1024 / 1024);
|
|
const totalLimitMb = Math.round(MAX_TOTAL_BYTES / 1024 / 1024);
|
|
for (const file of list) {
|
|
if (selectedFiles.length >= MAX_FILES) { skipped.push(`Можно прикрепить не более ${MAX_FILES} файлов`); break; }
|
|
if (file.size > MAX_FILE_BYTES) { skipped.push(`«${file.name}» больше ${fileLimitMb} МБ`); continue; }
|
|
if (BLOCKED_FILE_EXT.test(extOf(file.name))) { skipped.push(`«${file.name}»: недопустимый тип файла`); continue; }
|
|
if (totalUploadBytes() + file.size > MAX_TOTAL_BYTES) { skipped.push(`Суммарный размер файлов — не более ${totalLimitMb} МБ`); break; }
|
|
selectedFiles.push(file);
|
|
added.push(file.name);
|
|
}
|
|
if (added.length) renderFiles();
|
|
return { added, skipped };
|
|
}
|
|
|
|
filesInput.addEventListener('change', () => {
|
|
const res = addFiles([...filesInput.files]);
|
|
filesInput.value = '';
|
|
if (res.skipped.length) alert(res.skipped[0]);
|
|
});
|
|
|
|
document.addEventListener('paste', (e) => {
|
|
if (form.style.display === 'none') return;
|
|
const clip = e.clipboardData;
|
|
if (!clip) return;
|
|
const pasted = [];
|
|
for (const item of Array.from(clip.items || [])) {
|
|
if (item.kind !== 'file') continue;
|
|
const file = item.getAsFile();
|
|
if (file) pasted.push(file);
|
|
}
|
|
if (!pasted.length && clip.files && clip.files.length) pasted.push(...Array.from(clip.files));
|
|
if (!pasted.length) return;
|
|
e.preventDefault();
|
|
let filesAdded = 0;
|
|
const skipped = [];
|
|
for (const raw of pasted) {
|
|
const name = pasteFileName(raw);
|
|
const file = raw.name === name ? raw : new File([raw], name, { type: raw.type || 'application/octet-stream', lastModified: raw.lastModified });
|
|
const res = addFiles([file]);
|
|
filesAdded += res.added.length;
|
|
skipped.push(...res.skipped);
|
|
}
|
|
if (!filesAdded) {
|
|
if (skipped.length) notify(skipped[0], true);
|
|
return;
|
|
}
|
|
const summary = filesAdded === 1 ? 'Файл добавлен' : `Файлов добавлено: ${filesAdded}`;
|
|
notify(summary + (skipped.length ? '. ' + skipped[0] : ''), skipped.length > 0);
|
|
});
|
|
|
|
const params = new URLSearchParams(location.search);
|
|
let studentName = '';
|
|
function escName(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
|
if (params.get('nameInput')) {
|
|
studentName = params.get('nameInput');
|
|
document.getElementById('nameInput').value = studentName;
|
|
const field = document.getElementById('nameField');
|
|
field.innerHTML = `<label>Фамилия и имя</label><div class="fixed-name" id="fixedName">${escName(studentName)}</div>`;
|
|
}
|
|
|
|
function renderModuleOptions(list) {
|
|
list.forEach(m => moduleNames.set(m.name, m.id));
|
|
moduleInputList.innerHTML = '';
|
|
list.forEach(m => {
|
|
const opt = document.createElement('option');
|
|
opt.value = m.name;
|
|
moduleInputList.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
async function fetchModules(q, limit) {
|
|
const p = new URLSearchParams();
|
|
if (q) p.set('search', q);
|
|
p.set('limit', String(limit));
|
|
p.set('active', '1');
|
|
try {
|
|
const res = await fetch(`${API}/api/modules?${p}`);
|
|
if (!res.ok) return [];
|
|
const data = await res.json();
|
|
return data.modules || [];
|
|
} catch (e) { return []; }
|
|
}
|
|
|
|
async function resolveModuleId(name) {
|
|
const n = (name || '').trim();
|
|
if (!n) return null;
|
|
if (moduleNames.has(n)) return moduleNames.get(n);
|
|
const list = await fetchModules(n, 5);
|
|
renderModuleOptions(list);
|
|
const lower = n.toLowerCase();
|
|
const exact = list.find(m => m.name.toLowerCase() === lower);
|
|
return exact ? exact.id : null;
|
|
}
|
|
|
|
(async () => {
|
|
await initDateTime();
|
|
try {
|
|
const list = await fetchModules('', 50);
|
|
if (list.length) {
|
|
renderModuleOptions(list);
|
|
moduleInput.required = true;
|
|
moduleField.style.display = '';
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
const res = await fetch(`${API}/api/students`);
|
|
const students = await res.json();
|
|
const me = studentName ? students.find(s => s.name === studentName) : null;
|
|
if (me && me.group_id) {
|
|
groupInput.innerHTML = `<option value="${me.group_id}" selected>${escName(me.group_name || `Группа ${me.group_id}`)}</option>`;
|
|
groupInput.value = String(me.group_id);
|
|
} else {
|
|
const groupsRes = await fetch(`${API}/api/groups/active`);
|
|
const groups = await groupsRes.json();
|
|
groups.forEach(g => {
|
|
const opt = document.createElement('option');
|
|
opt.value = g.id;
|
|
opt.textContent = g.name;
|
|
groupInput.appendChild(opt);
|
|
});
|
|
if (!groups.length) {
|
|
groupInput.innerHTML = '<option value="" disabled selected>Нет активных групп</option>';
|
|
}
|
|
}
|
|
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);
|
|
if (Number.isFinite(c) && c >= 1) entryCooldownMin = c;
|
|
const w = parseInt(f.photo_capture_width, 10);
|
|
const h = parseInt(f.photo_capture_height, 10);
|
|
if (Number.isFinite(w) && w >= 160 && w <= 4096) captureWidth = w;
|
|
if (Number.isFinite(h) && h >= 120 && h <= 4096) captureHeight = h;
|
|
const q = parseFloat(f.photo_capture_quality);
|
|
if (Number.isFinite(q) && q >= 0.5 && q <= 1) captureQuality = q;
|
|
applyUploadLimits(f.upload_file_limit_mb, f.upload_total_limit_mb);
|
|
if (f.camera_enabled === 'false') {
|
|
const camBtns = document.getElementById('camBtns');
|
|
if (camBtns) camBtns.style.display = 'none';
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
checkPreviousSend();
|
|
})();
|
|
|
|
function showPreview(src) {
|
|
preview.src = src;
|
|
preview.classList.add('has-photo');
|
|
removePhotoBtn.classList.add('show');
|
|
}
|
|
|
|
function removePhoto() {
|
|
preview.src = placeholderSrc;
|
|
preview.classList.remove('has-photo');
|
|
removePhotoBtn.classList.remove('show');
|
|
capturedBlob = null;
|
|
}
|
|
|
|
async function openCam() {
|
|
if (camOpening) return;
|
|
if (stream && camModal.classList.contains('open')) return;
|
|
camOpening = true;
|
|
try {
|
|
const s = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: captureWidth }, height: { ideal: captureHeight } } });
|
|
if (camModal.classList.contains('open')) {
|
|
s.getTracks().forEach(t => t.stop());
|
|
return;
|
|
}
|
|
stream = s;
|
|
camVideo.srcObject = stream;
|
|
camModal.classList.add('open');
|
|
} catch (e) {
|
|
alert('Не удалось открыть камеру: ' + e.message);
|
|
} finally {
|
|
camOpening = false;
|
|
}
|
|
}
|
|
|
|
function closeCam() {
|
|
camModal.classList.remove('open');
|
|
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
|
|
}
|
|
|
|
function capture() {
|
|
if (!camVideo.videoWidth || !camVideo.videoHeight) {
|
|
notify('Камера ещё не готова, попробуйте через секунду', true);
|
|
return;
|
|
}
|
|
camCanvas.width = camVideo.videoWidth;
|
|
camCanvas.height = camVideo.videoHeight;
|
|
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));
|
|
closeCam();
|
|
}, 'image/jpeg', captureQuality);
|
|
}
|
|
|
|
form.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
if (!capturedBlob) {
|
|
toast.textContent = 'Сначала сделайте фотографию';
|
|
toast.classList.add('show', 'error');
|
|
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
|
|
return;
|
|
}
|
|
let moduleId = '';
|
|
if (moduleField.style.display !== 'none') {
|
|
moduleId = await resolveModuleId(moduleInput.value);
|
|
if (!moduleId) {
|
|
toast.textContent = 'Выберите тему модуля из списка подсказок';
|
|
toast.classList.add('show', 'error');
|
|
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
|
|
return;
|
|
}
|
|
}
|
|
const btn = document.getElementById('submitBtn');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Отправка...';
|
|
|
|
const fd = new FormData();
|
|
fd.append('photo', capturedBlob, 'photo.jpg');
|
|
selectedFiles.forEach(f => fd.append('files', f, f.name));
|
|
fd.append('student_name', studentName || document.getElementById('nameInput').value);
|
|
fd.append('group_id', groupInput.value);
|
|
fd.append('module_id', moduleId || '');
|
|
fd.append('description', document.getElementById('descInput').value);
|
|
fd.append('website', document.getElementById('hpWebsite').value);
|
|
|
|
try {
|
|
const res = await fetch(`${API}/api/entries`, { method: 'POST', body: fd });
|
|
if (res.status === 429) {
|
|
const e = await res.json().catch(() => ({}));
|
|
toast.textContent = e.error || 'Уже ответили: подождите';
|
|
toast.classList.add('show', 'error');
|
|
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
|
|
return;
|
|
}
|
|
if (!res.ok) throw new Error(await res.text());
|
|
form.reset();
|
|
removePhoto();
|
|
clearFiles();
|
|
localStorage.setItem(SENT_KEY, String(Date.now()));
|
|
showSent('Запись отправлена!', 'Спасибо! Твоя запись уже передана преподавателю.', Date.now() + cooldownMs());
|
|
} catch (err) {
|
|
alert('Ошибка: ' + err.message);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Отправить';
|
|
}
|
|
});
|
|
|
|
removePhotoBtn.addEventListener('click', removePhoto);
|
|
document.getElementById('openCamBtn').addEventListener('click', openCam);
|
|
preview.addEventListener('click', openCam);
|
|
preview.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openCam(); }
|
|
});
|
|
filesClearBtn.addEventListener('click', clearFiles);
|
|
document.getElementById('filesBtn').addEventListener('click', () => document.getElementById('filesInput').click());
|
|
document.getElementById('cancelCamBtn').addEventListener('click', closeCam);
|
|
document.getElementById('captureBtn').addEventListener('click', capture);
|
|
moduleInput.addEventListener('input', () => {
|
|
clearTimeout(moduleSearchTimer);
|
|
const q = moduleInput.value.trim();
|
|
moduleSearchTimer = setTimeout(async () => {
|
|
renderModuleOptions(await fetchModules(q, 20));
|
|
}, 250);
|
|
});
|
|
moduleInput.addEventListener('focus', async () => {
|
|
if (!moduleInput.value.trim()) renderModuleOptions(await fetchModules('', 50));
|
|
}); |