341 lines
12 KiB
JavaScript
341 lines
12 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;
|
|
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();
|
|
}
|
|
|
|
filesInput.addEventListener('change', () => {
|
|
const keep = 10 - selectedFiles.length;
|
|
if (keep <= 0) { alert('Можно прикрепить не более 10 файлов'); filesInput.value = ''; return; }
|
|
selectedFiles = selectedFiles.concat([...filesInput.files].slice(0, keep));
|
|
if ([...filesInput.files].length > keep) alert('Можно прикрепить не более 10 файлов');
|
|
filesInput.value = '';
|
|
renderFiles();
|
|
});
|
|
|
|
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 () => {
|
|
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;
|
|
} 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() {
|
|
try {
|
|
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: captureWidth }, height: { ideal: captureHeight } } });
|
|
camVideo.srcObject = stream;
|
|
camModal.classList.add('open');
|
|
} catch (e) {
|
|
alert('Не удалось открыть камеру: ' + e.message);
|
|
}
|
|
}
|
|
|
|
function closeCam() {
|
|
camModal.classList.remove('open');
|
|
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
|
|
}
|
|
|
|
function capture() {
|
|
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);
|
|
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));
|
|
}); |