feat: анти-спам блокировка повторной отправки на главной (cooldown по spam_interval_min)
- index.html/index.js: экран подтверждения отправки с обратным отсчётом - локальный запрет повторной отправки через localStorage (по настройке spam_interval_min) - server.js: отдаём spam_interval_min в public-settings (default 30) - Dockerfile: timeout для apk add, чтобы сборка не зависала на недоступном зеркале - docker-compose.yml: отключаем cloudflared сервис (закомментирован)
This commit is contained in:
@@ -63,6 +63,12 @@ textarea{resize:vertical;min-height:160px;font-family:inherit;line-height:1.5}
|
||||
.toast.show{opacity:1}
|
||||
.toast.error{background:#dc2626}
|
||||
|
||||
.sent-panel{width:100%;max-width:900px;margin:32px auto 0;background:var(--card);border:2px solid var(--accent);border-radius:var(--radius);padding:40px 32px;display:none;flex-direction:column;gap:20px;align-items:center;text-align:center;box-shadow:0 8px 24px rgba(37,99,235,.18)}
|
||||
.sent-check{width:88px;height:88px;border-radius:50%;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;font-size:2.6rem}
|
||||
.sent-panel h2{font-size:1.6rem;font-weight:700;letter-spacing:-.02em;color:var(--text)}
|
||||
.sent-panel p{font-size:1.05rem;color:var(--muted);max-width:520px}
|
||||
.sent-timer{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);padding:14px 20px;font-size:1rem;font-weight:600;color:var(--text)}
|
||||
|
||||
nav{position:fixed;top:16px;right:16px;display:flex;gap:8px}
|
||||
nav a{color:var(--muted);text-decoration:none;font-size:.8rem;padding:6px 12px;border-radius:8px;transition:color .15s}
|
||||
nav a:hover{color:var(--text)}
|
||||
@@ -132,6 +138,13 @@ nav a:hover{color:var(--text)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="sent-panel" id="sentPanel">
|
||||
<div class="sent-check">✓</div>
|
||||
<h2 id="sentTitle">Запись отправлена!</h2>
|
||||
<p id="sentText">Спасибо! Твоя запись уже передана преподавателю.</p>
|
||||
<div class="sent-timer" id="sentTimer"></div>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="f-left" id="footerLeft"></div>
|
||||
<div class="f-right" id="footerRight"></div>
|
||||
|
||||
+60
-4
@@ -15,6 +15,61 @@ 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 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 + ' Б';
|
||||
@@ -99,7 +154,10 @@ if (params.get('nameInput')) {
|
||||
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;
|
||||
const c = parseInt(f.spam_interval_min, 10);
|
||||
if (Number.isFinite(c) && c >= 1) entryCooldownMin = c;
|
||||
} catch (e) { /* ignore */ }
|
||||
checkPreviousSend();
|
||||
})();
|
||||
|
||||
function showPreview(src) {
|
||||
@@ -177,10 +235,8 @@ form.addEventListener('submit', async (e) => {
|
||||
form.reset();
|
||||
removePhoto();
|
||||
clearFiles();
|
||||
toast.textContent = 'Готово!';
|
||||
toast.classList.remove('error');
|
||||
toast.classList.add('show');
|
||||
setTimeout(() => toast.classList.remove('show'), 2500);
|
||||
localStorage.setItem(SENT_KEY, String(Date.now()));
|
||||
showSent('Запись отправлена!', 'Спасибо! Твоя запись уже передана преподавателю.', Date.now() + cooldownMs());
|
||||
} catch (err) {
|
||||
alert('Ошибка: ' + err.message);
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user