(() => { const csrf = document.querySelector('meta[name=csrf-token]').content; const call = async (method, url, body) => { const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': csrf }, body: body ? JSON.stringify(body) : undefined, }); if (r.status === 419) { location.reload(); return r; } return r; }; const showErr = async (el, r) => { const d = await r.json().catch(() => ({})); el.textContent = d.message || Object.values(d.errors || {}).flat().join(' ') || 'Ошибка'; el.hidden = false; }; // Звёзды: 90 по небу, 170 в полосе (дуга −14°). Генератор с фиксированным зерном, // чтобы небо было одинаковым при каждой перезагрузке и не «прыгало» после действий. let seed = 0x48414430; // "HAD0" const rand = () => { seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; const star = (color, size, bright, x, y, dur, delay) => { const s = document.createElement('div'); s.style.cssText = `position:absolute;border-radius:50%;left:${x}%;top:${y}%;width:${size}px;height:${size}px;background:${color};${bright ? `box-shadow:0 0 6px ${color};` : ''}animation:skTwinkle ${dur}s ease-in-out ${delay}s infinite`; return s; }; const sky = document.getElementById('stars'), band = document.getElementById('band'); for (let i = 0; i < 90; i++) sky.appendChild(star(rand() < .2 ? '#8FD6DC' : '#F2ECE0', rand() < .85 ? 1.5 : 2.5, false, (rand() * 100).toFixed(2), (rand() * 100).toFixed(2), (3 + rand() * 5).toFixed(1), (-rand() * 8).toFixed(1))); for (let j = 0; j < 170; j++) { const bright = rand() < .08, r = rand(); const color = r < .68 ? '#F2ECE0' : (r < .88 ? '#F7CEDC' : '#8FD6DC'); const g = (rand() + rand() + rand()) / 3, x = rand() * 100; const arc = 28 * Math.pow((x - 50) / 50, 2) + Math.max(0, 35 - x) * .35; band.appendChild(star(color, bright ? 2.2 : (0.8 + rand() * 1.1).toFixed(1), bright, x.toFixed(2), (6 + g * 55 + arc).toFixed(2), (2.5 + rand() * 5).toFixed(1), (-rand() * 8).toFixed(1))); } // Панели архив/настройки document.querySelectorAll('.panel .head').forEach(h => h.addEventListener('click', () => { const body = h.nextElementSibling; body.hidden = !body.hidden; h.querySelector('.chev').textContent = body.hidden ? '▼' : '▲'; })); // Форма добавления const addform = document.getElementById('addform'); const toggleAdd = () => { addform.hidden = !addform.hidden; document.getElementById('add-err').hidden = true; }; document.getElementById('toggle-add').addEventListener('click', toggleAdd); document.getElementById('cancel-add').addEventListener('click', toggleAdd); document.getElementById('add').addEventListener('submit', async e => { e.preventDefault(); const f = new FormData(e.target); const r = await call('POST', '/me/events', { title: f.get('title'), subtitle: f.get('subtitle') || null, due_date: f.get('due_date'), due_time: f.get('due_time') || null }); if (r.ok) location.reload(); else showErr(document.getElementById('add-err'), r); }); // «Помню» / «Сделано» document.querySelectorAll('.card button[data-action]').forEach(btn => btn.addEventListener('click', async () => { const card = btn.closest('.card'); card.querySelectorAll('button').forEach(b => b.disabled = true); const r = await call('POST', `/me/events/${card.dataset.id}/${btn.dataset.action}`); if (r.ok) location.reload(); else if (r.status !== 419) { card.querySelectorAll('button').forEach(b => b.disabled = false); showErr(document.getElementById('card-err'), r); } })); // Настройки document.getElementById('profile').addEventListener('submit', async e => { e.preventDefault(); const f = new FormData(e.target); const r = await call('PATCH', '/me', { tz: f.get('tz'), quiet_start: f.get('quiet_start'), quiet_end: f.get('quiet_end') }); if (r.ok) location.reload(); else showErr(document.getElementById('profile-err'), r); }); document.getElementById('webhook').addEventListener('submit', async e => { e.preventDefault(); const f = new FormData(e.target); const r = await call('POST', '/me/channels', { type: 'webhook', config: { deliver_url: f.get('deliver_url'), presence_url: f.get('presence_url') } }); if (r.ok) location.reload(); else showErr(document.getElementById('webhook-err'), r); }); document.querySelectorAll('[data-delete-channel]').forEach(btn => btn.addEventListener('click', async () => { const r = await call('DELETE', `/me/channels/${btn.dataset.deleteChannel}`); if (r.ok) location.reload(); })); document.getElementById('tg-connect').addEventListener('click', async e => { const r = await call('POST', '/me/channels/telegram/link'); if (!r.ok) return; const d = await r.json(); document.getElementById('tg-code').textContent = `/start ${d.code}`; document.getElementById('tg-link').href = d.bot_url; e.target.hidden = true; document.getElementById('tg-box').hidden = false; }); // Присутствие: heartbeat только при видимой вкладке и активности за 3 минуты. let lastActivity = Date.now(); ['pointerdown', 'pointermove', 'keydown', 'scroll'].forEach(ev => addEventListener(ev, () => { lastActivity = Date.now(); }, { passive: true })); const heartbeat = () => { if (document.visibilityState !== 'visible') return; if (Date.now() - lastActivity > 3 * 60 * 1000) return; call('POST', '/me/heartbeat').catch(() => {}); }; heartbeat(); setInterval(heartbeat, 30 * 1000); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { lastActivity = Date.now(); heartbeat(); } }); })();