const custom = window.COLLECTION_KEEPER_CUSTOM ?? {}; const activeEmojis = custom.emojis?.length ? custom.emojis : ['✨']; const activeClickMessages = custom.clickMessages?.length ? custom.clickMessages : ['기록은 남겨둘게.']; const KEEPER_TEXT_LINES = custom.keeperTextLines?.length ? custom.keeperTextLines : ['Collection Keeper']; const FAREWELL_TEXT_LINES = custom.farewellTextLines?.length ? custom.farewellTextLines : ['안녕.', '고마웠어.']; const elements = { backgroundLayer: document.getElementById('background-layer'), effectLayer: document.getElementById('effect-layer'), toast: document.getElementById('toast'), 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 toastTimer = null; let expired = false; let expiryTransitionStarted = false; let appliedBackground = null; let particleCleanupTimer = null; let expiryFinishTimer = 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 activeEmojis[Math.floor(Math.random() * activeEmojis.length)]; } function randomMessage() { return activeClickMessages[Math.floor(Math.random() * activeClickMessages.length)]; } function showToast(message) { window.clearTimeout(toastTimer); elements.toast.textContent = message; elements.toast.classList.add('is-visible'); toastTimer = window.setTimeout(() => { elements.toast.classList.remove('is-visible'); }, 2200); } 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 (wasInitialized && !wasExpired) { startExpiryTransition(); } else { showExpiredImmediately(); } } else { clearExpiryState(); } renderLiveStats(); } 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.textContent = `${emoji} ${message}`; 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); if (result.expired || result.status.expired) { showToast('이미 시간이 지났어요.'); return; } showToast(result.accepted ? `${emoji} 방금 확인했어요.` : `${emoji} 잠시 후 다시 눌러주세요.`); } catch (error) { showToast('잠시 문제가 생겼어요. 다시 눌러주세요.'); } } document.addEventListener('click', handleCheck); renderKeeperText(); renderFarewellText(); loadStatus().catch(() => { showToast('상태를 불러오지 못했어요.'); }); window.setInterval(renderLiveStats, 1000); window.setInterval(() => { loadStatus().catch(() => {}); }, 1000);