Files
deadman-switch/public/app.js
zenn d56c6ea1ac Gmail 알림 메일과 남은 기간 안내 메시지 추가
메일 확인 링크로 생존 확인을 갱신하고, 화면 토스트 대신 남은 기간별 안내 문구를 표시합니다.
2026-06-10 13:52:15 +09:00

499 lines
13 KiB
JavaScript

const custom = window.COLLECTION_KEEPER_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
: ['Collection Keeper'];
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);