const custom = window.LAST_SIGNAL_CUSTOM ?? {}; const activeEmojis = custom.emojis?.length ? custom.emojis : ['✨']; const activeClickMessages = custom.clickMessages?.length ? custom.clickMessages : ['기록은 남겨둘게.']; const timeMessageGroups = { dawn: custom.dawnMessages ?? [], morning: custom.morningMessages ?? [], afternoon: custom.afternoonMessages ?? [], evening: custom.eveningMessages ?? [], }; const noticeMessageGroups = { month: custom.monthVisitMessages ?? [], longAbsent: custom.longAbsentMessages ?? [], warning: custom.warningMessages ?? [], final: custom.finalWarningMessages ?? [], }; const DAY_IN_MS = 24 * 60 * 60 * 1000; const NOTICE_THRESHOLDS = { final: 3, warning: 15, longAbsent: 30, month: 60, }; const SEOUL_TIME_ZONE = 'Asia/Seoul'; const KEEPER_TEXT_LINES = custom.keeperTextLines?.length ? custom.keeperTextLines : ['Last Signal']; const FAREWELL_TEXT_LINES = custom.farewellTextLines?.length ? custom.farewellTextLines : ['안녕.', '고마웠어.']; const elements = { backgroundLayer: document.getElementById('background-layer'), effectLayer: document.getElementById('effect-layer'), noticePanel: document.getElementById('notice-panel'), keeperText: document.getElementById('keeper-text'), farewellScreen: document.getElementById('farewell-screen'), lastCheck: document.getElementById('last-check'), remainingDays: document.getElementById('remaining-days'), totalChecks: document.getElementById('total-checks'), recentTraces: document.getElementById('recent-traces'), }; let currentStatus = null; let expired = false; let expiryTransitionStarted = false; let appliedBackground = null; let particleCleanupTimer = null; let expiryFinishTimer = null; let sessionNoticeLevel = null; let sessionNoticeMessage = null; function renderKeeperText() { elements.keeperText.replaceChildren(); KEEPER_TEXT_LINES.forEach((line) => { const row = document.createElement('p'); row.textContent = line || '\u00A0'; elements.keeperText.appendChild(row); }); } function renderFarewellText() { elements.farewellScreen.replaceChildren(); FAREWELL_TEXT_LINES.forEach((line, index) => { const row = document.createElement('p'); row.textContent = line || '\u00A0'; row.style.setProperty('--gather-delay', `${1.75 + index * 0.28}s`); row.style.setProperty('--start-x', `${index % 2 === 0 ? -1 : 1}rem`); row.style.setProperty('--start-y', `${1.4 + index * 0.3}rem`); elements.farewellScreen.appendChild(row); }); } function randomEmoji() { return randomFrom(activeEmojis); } function randomMessage() { const candidates = activeClickMessages.map((message) => ({ text: message, type: 'normal', })); addWeightedMessages(candidates, getTimeMessages(), 3); return randomFrom(candidates); } function randomFrom(items) { return items[Math.floor(Math.random() * items.length)]; } function addWeightedMessages(candidates, messages, weight, type = 'normal') { if (!messages.length) { return; } for (let count = 0; count < weight; count += 1) { candidates.push(...messages.map((message) => ({ text: message, type }))); } } function getTimeMessages(date = new Date()) { const hour = getSeoulHour(date); if (hour < 6) { return timeMessageGroups.dawn; } if (hour < 12) { return timeMessageGroups.morning; } if (hour < 18) { return timeMessageGroups.afternoon; } return timeMessageGroups.evening; } function getSeoulHour(date) { const hour = new Intl.DateTimeFormat('ko-KR', { timeZone: SEOUL_TIME_ZONE, hour: '2-digit', hour12: false, }) .formatToParts(date) .find((part) => part.type === 'hour')?.value ?? '0'; return Number(hour); } function showNotice(message, level = 'info') { elements.noticePanel.textContent = message; elements.noticePanel.className = `notice-panel is-${level}`; elements.noticePanel.hidden = false; } function hideNotice() { elements.noticePanel.hidden = true; elements.noticePanel.textContent = ''; elements.noticePanel.className = 'notice-panel'; } function getNoticeLevel(status) { const remainingMs = Math.max(0, new Date(status.deadline_at).getTime() - Date.now()); const remainingDays = remainingMs / DAY_IN_MS; if (remainingDays <= NOTICE_THRESHOLDS.final) { return 'final'; } if (remainingDays <= NOTICE_THRESHOLDS.warning) { return 'warning'; } if (remainingDays <= NOTICE_THRESHOLDS.longAbsent) { return 'longAbsent'; } if (remainingDays <= NOTICE_THRESHOLDS.month) { return 'month'; } return null; } function renderNotice(status) { if (!status || status.expired || expired) { hideNotice(); return; } const level = getNoticeLevel(status); if (!level) { sessionNoticeLevel = null; sessionNoticeMessage = null; hideNotice(); return; } if (level !== sessionNoticeLevel) { sessionNoticeLevel = level; sessionNoticeMessage = randomFrom(noticeMessageGroups[level] ?? []); } if (!sessionNoticeMessage) { hideNotice(); return; } showNotice(sessionNoticeMessage, level); } function applyBackground(filename) { if (!filename || appliedBackground === filename) { return; } appliedBackground = filename; elements.backgroundLayer.style.backgroundImage = `linear-gradient(rgba(255,255,255,0.18), rgba(255,255,255,0.18)), url('/assets/backgrounds/${filename}')`; } function updateStatus(status) { const wasInitialized = currentStatus !== null; const wasExpired = expired || Boolean(currentStatus?.expired); currentStatus = status; renderTraces(status.recent_traces); applyBackground(status.background); if (status.expired) { if (!expiryTransitionStarted && wasInitialized && !wasExpired) { startExpiryTransition(); } else if (!expiryTransitionStarted) { showExpiredImmediately(); } } else { clearExpiryState(); } renderLiveStats(); renderNotice(status); } function renderLiveStats() { if (!currentStatus) { return; } elements.lastCheck.textContent = formatRelativeTime(currentStatus.last_check_at); elements.remainingDays.textContent = formatCountdown(currentStatus.deadline_at); elements.totalChecks.textContent = `${Number(currentStatus.total_checks).toLocaleString('ko-KR')}회`; if (!expired && !expiryTransitionStarted && Date.now() >= new Date(currentStatus.deadline_at).getTime()) { currentStatus.expired = true; startExpiryTransition(); } } function startExpiryTransition() { if (expiryTransitionStarted) { expired = true; return; } expiryTransitionStarted = true; expired = true; prepareParticleExit(); document.body.classList.add('is-expiring'); window.clearTimeout(expiryFinishTimer); expiryFinishTimer = window.setTimeout(() => { document.body.classList.remove('is-expiring'); document.body.classList.add('is-expired'); }, 4800); window.clearTimeout(particleCleanupTimer); particleCleanupTimer = window.setTimeout(removeTextParticles, 2400); } function showExpiredImmediately() { expired = true; expiryTransitionStarted = true; window.clearTimeout(expiryFinishTimer); window.clearTimeout(particleCleanupTimer); removeTextParticles(); document.body.classList.remove('is-expiring'); document.body.classList.add('is-expired'); } function clearExpiryState() { if (!expired && !expiryTransitionStarted) { return; } expired = false; expiryTransitionStarted = false; document.body.classList.remove('is-expiring', 'is-expired'); window.clearTimeout(expiryFinishTimer); window.clearTimeout(particleCleanupTimer); removeTextParticles(); } function prepareParticleExit() { removeTextParticles(); const targets = document.querySelectorAll( '.keeper-text p, .text-stats div, .traces-panel h2, .recent-traces li' ); targets.forEach((target) => { createTextParticles(target); }); } function createTextParticles(target) { const text = target.textContent ?? ''; const visibleText = text.trim(); const rect = target.getBoundingClientRect(); if (!visibleText || rect.width === 0 || rect.height === 0) { return; } const computedStyle = window.getComputedStyle(target); const characters = Array.from(text); const characterWidth = rect.width / Math.max(characters.length, 1); characters.forEach((character, index) => { if (!character.trim()) { return; } const x = rect.left + characterWidth * (index + 0.5); const y = rect.top + rect.height / 2; createTextParticle(character, x, y, computedStyle); }); } function createTextParticle(character, x, y, computedStyle) { const particle = document.createElement('span'); particle.className = 'text-particle'; particle.textContent = character; particle.style.left = `${x}px`; particle.style.top = `${y}px`; particle.style.color = computedStyle.color; particle.style.font = computedStyle.font; particle.style.fontWeight = computedStyle.fontWeight; particle.style.letterSpacing = computedStyle.letterSpacing; particle.style.textShadow = computedStyle.textShadow; const angle = Math.random() * Math.PI * 2; const distance = 60 + Math.random() * 180; const dx = Math.cos(angle) * distance; const dy = Math.sin(angle) * distance; const rotation = -35 + Math.random() * 70; particle.style.setProperty('--particle-x', `${dx}px`); particle.style.setProperty('--particle-y', `${dy}px`); particle.style.setProperty('--particle-rotation', `${rotation}deg`); particle.style.setProperty('--particle-delay', `${Math.random() * 0.45}s`); elements.effectLayer.appendChild(particle); } function removeTextParticles() { elements.effectLayer.querySelectorAll('.text-particle').forEach((particle) => { particle.remove(); }); } function formatRelativeTime(isoString) { const diffSeconds = Math.max(0, Math.floor((Date.now() - new Date(isoString).getTime()) / 1000)); if (diffSeconds < 60) { return `${diffSeconds}초 전`; } const diffMinutes = Math.floor(diffSeconds / 60); if (diffMinutes < 60) { return `${diffMinutes}분 전`; } const diffHours = Math.floor(diffMinutes / 60); if (diffHours < 24) { return `${diffHours}시간 전`; } const diffDays = Math.floor(diffHours / 24); return `${diffDays}일 전`; } function formatCountdown(isoString) { let remainingSeconds = Math.max( 0, Math.floor((new Date(isoString).getTime() - Date.now()) / 1000) ); const days = Math.floor(remainingSeconds / 86400); remainingSeconds %= 86400; const hours = Math.floor(remainingSeconds / 3600); remainingSeconds %= 3600; const minutes = Math.floor(remainingSeconds / 60); const seconds = remainingSeconds % 60; return `${days}일 ${hours}시간 ${minutes}분 ${seconds}초`; } function renderTraces(traces) { elements.recentTraces.innerHTML = ''; const visibleTraces = traces.slice(0, 5); if (!visibleTraces.length) { const item = document.createElement('li'); item.className = 'empty'; item.textContent = '아직 흔적이 없어요'; elements.recentTraces.appendChild(item); return; } visibleTraces.forEach((trace) => { const item = document.createElement('li'); const emoji = document.createElement('span'); emoji.className = 'emoji'; emoji.textContent = trace.emoji; const time = document.createElement('span'); time.className = 'time'; time.textContent = trace.time; item.appendChild(emoji); item.appendChild(time); elements.recentTraces.appendChild(item); }); } function spawnClickMessage(emoji, message) { const node = document.createElement('div'); node.className = 'click-message'; node.classList.add(`is-${message.type}`); node.textContent = `${emoji} ${message.text}`; elements.effectLayer.appendChild(node); const { x, y } = getRandomMessagePosition(node); node.style.left = `${x}px`; node.style.top = `${y}px`; window.setTimeout(() => { node.remove(); }, 1800); } function getRandomMessagePosition(node) { const safeMargin = 32; const rect = node.getBoundingClientRect(); const width = rect.width || 240; const height = rect.height || 40; const minX = safeMargin + width / 2; const maxX = Math.max(minX, window.innerWidth - safeMargin - width / 2); const minY = safeMargin; const maxY = Math.max(minY, window.innerHeight - safeMargin - height); return { x: randomBetween(minX, maxX), y: randomBetween(minY, maxY), }; } function randomBetween(min, max) { return min + Math.random() * (max - min); } async function loadStatus() { const response = await fetch('/api/status'); const status = await response.json(); updateStatus(status); } async function handleCheck() { if (expired) { return; } const emoji = randomEmoji(); const message = randomMessage(); spawnClickMessage(emoji, message); try { const response = await fetch('/api/check', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ emoji }), }); const result = await response.json(); updateStatus(result.status); } catch (error) { showNotice('잠시 문제가 생겼어요. 다시 눌러주세요.', 'error'); } } document.addEventListener('click', handleCheck); renderKeeperText(); renderFarewellText(); loadStatus().catch(() => { showNotice('상태를 불러오지 못했어요.', 'error'); }); window.setInterval(renderLiveStats, 1000); window.setInterval(() => { loadStatus().catch(() => {}); }, 1000);