499 lines
13 KiB
JavaScript
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);
|