설정 보강 및 메시지 동작 정리

RESET_TOKEN을 .env에서 읽도록 바꾸고 한국 시간 표시와 시간대별 클릭 메시지 동작을 정리합니다.
This commit is contained in:
2026-06-10 12:42:14 +09:00
parent f7ee8cb923
commit 5f9f10f305
8 changed files with 279 additions and 20 deletions

View File

@@ -2,6 +2,7 @@ node_modules
data
.git
.gitignore
.env
README.md
.cursor
*.sqlite

View File

@@ -317,12 +317,22 @@ THRESHOLD_SECONDS=10 npm start
curl -X POST http://localhost:40100/api/reset
```
초기화 API에 토큰을 걸고 싶으면 `RESET_TOKEN`을 설정합니다.
초기화 API에 토큰을 걸고 싶으면 `RESET_TOKEN`을 설정합니다. Docker 운영 환경에서는 Git에 올리지 않는 `.env` 파일을 사용하세요.
```bash
RESET_TOKEN=my-secret THRESHOLD_SECONDS=10 npm start
```
Docker Compose:
```bash
cat > .env <<'EOF'
RESET_TOKEN=my-secret
EOF
docker compose up -d --build
```
토큰이 있을 때 초기화:
```bash

View File

@@ -7,6 +7,7 @@ services:
ports:
- "40100:40100"
environment:
TZ: Asia/Seoul
THRESHOLD_DAYS: 90
# 만료 테스트가 필요하면 아래 값을 켜세요. 예: 10초 후 만료
# THRESHOLD_SECONDS: 10
@@ -17,8 +18,7 @@ services:
TRIGGER_SCRIPT: /scripts/cleanup.sh
COLLECTION_PATH: /home/zenn/Collection
BACKGROUND: random
# 초기화 API 보호가 필요하면 토큰을 지정하세요.
# RESET_TOKEN: change-me
RESET_TOKEN: ${RESET_TOKEN:?Set RESET_TOKEN in .env}
volumes:
- keeper-data:/data
- ./config/default.json:/app/config/default.json:ro

View File

@@ -40,6 +40,8 @@ Collection Keeper는 웹페이지에 들어와서 가끔 클릭해 주는 작은
- Git
- NAS에 `/home/zenn/Collection` 폴더
NAS 본체에는 Node.js나 npm이 없어도 됩니다. Docker 이미지 빌드 과정에서 컨테이너 안에서 필요한 패키지를 설치합니다.
---
## 3. Git이란?
@@ -177,6 +179,16 @@ config/default.json
Docker에서는 `docker-compose.yml`도 함께 확인합니다.
초기화 API 보호 토큰은 Git에 올리지 않는 `.env` 파일에 넣습니다.
```bash
cat > .env <<'EOF'
RESET_TOKEN=여기에-긴-비밀-토큰
EOF
```
`.env` 파일이 없으면 Docker Compose 실행 시 `RESET_TOKEN` 설정 오류가 납니다.
### 5-7. Docker 실행
```bash
@@ -241,6 +253,7 @@ http://NAS_IP:40100
- `COLLECTION_PATH`
- `BACKGROUND`
- `RESET_TOKEN`
- `TZ`
---

View File

@@ -1,6 +1,13 @@
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 SEOUL_TIME_ZONE = 'Asia/Seoul';
const KEEPER_TEXT_LINES = custom.keeperTextLines?.length
? custom.keeperTextLines
: ['Collection Keeper'];
@@ -52,11 +59,61 @@ function renderFarewellText() {
}
function randomEmoji() {
return activeEmojis[Math.floor(Math.random() * activeEmojis.length)];
return randomFrom(activeEmojis);
}
function randomMessage() {
return activeClickMessages[Math.floor(Math.random() * activeClickMessages.length)];
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 showToast(message) {
@@ -295,7 +352,8 @@ function renderTraces(traces) {
function spawnClickMessage(emoji, message) {
const node = document.createElement('div');
node.className = 'click-message';
node.textContent = `${emoji} ${message}`;
node.classList.add(`is-${message.type}`);
node.textContent = `${emoji} ${message.text}`;
elements.effectLayer.appendChild(node);
const { x, y } = getRandomMessagePosition(node);

View File

@@ -26,7 +26,6 @@ window.COLLECTION_KEEPER_CUSTOM = {
// 클릭할 때 기록되고 화면에 표시될 이모지입니다.
emojis: ['🐱', '🐰', '🐥', '🦊', '🍀', '🌸', '✨', '🎉', '💫', '🌈', '🍓', '🌙'],
// 클릭할 때 이모지 뒤에 함께 나올 문장입니다.
clickMessages: [
'오늘은 조금 조용하네.',
'버튼 누르는 거 잊지 마.',
@@ -79,4 +78,166 @@ window.COLLECTION_KEEPER_CUSTOM = {
'여기는 늘 같은 자리에 있어.',
'생각보다 오래 함께하고 있네.'
],
dawnMessages: [
'아직 안 자고 있었구나.',
'밤은 생각보다 길어.',
'다들 잠든 시간이라 조용해.',
'오늘은 조금 늦었네.',
'잠들기 전에 버튼 하나만 눌러줘.',
'이 시간에도 찾아왔네.',
'가끔은 새벽이 가장 편안해.',
'컬렉션은 오늘도 무사해.',
'아직 아무 일도 없었어.',
'조용한 시간은 싫지 않아.',
'별들도 슬슬 졸린가 봐.',
'오늘 하루는 어땠어?',
'여기는 아직 깨어있어.',
'새벽 공기는 조금 특별해.',
'이 시간의 방문자는 드물어.',
'밤과 아침의 사이에 왔네.',
'지금은 아주 조용한 시간이야.',
'가끔은 아무 생각도 하지 않아.',
'버튼 하나면 충분해.',
'다음에도 찾아와줘.'
],
morningMessages: [
'좋은 아침.',
'오늘도 무사히 시작됐어.',
'잠에서 깨자마자 여기까지 왔네.',
'오늘도 평화로운 하루가 되길.',
'햇살이 조금 들어오는 것 같아.',
'새로운 하루가 시작됐어.',
'아침에는 기분이 조금 가벼워.',
'오늘도 컬렉션은 잘 있어.',
'아직은 한적한 시간이야.',
'일찍 찾아왔네.',
'좋은 일이 있으면 좋겠다.',
'버튼 누르는 건 잊지 않았네.',
'오늘도 잘 부탁해.',
'기억은 오늘도 그대로야.',
'아직 아무 변화도 없어.',
'조용히 하루를 시작해보자.',
'여기까지 와줘서 고마워.',
'아침 공기는 상쾌하네.',
'천천히 둘러보다 가.',
'오늘도 괜찮을 거야.'
],
afternoonMessages: [
'오늘 하루는 어때?',
'시간이 꽤 흘렀네.',
'컬렉션은 오늘도 잘 있어.',
'잠시 쉬어가는 것도 괜찮아.',
'지금쯤이면 바쁠 시간일까.',
'잠깐 들른 거야?',
'생각난 김에 와준 거라면 충분해.',
'오늘도 평범하게 흘러가고 있어.',
'여기는 늘 같은 모습이야.',
'버튼 하나 부탁할게.',
'기록은 천천히 쌓이고 있어.',
'특별한 일은 없었어.',
'그게 오히려 좋은 걸지도.',
'조금 쉬었다 가도 돼.',
'컬렉션은 잘 보관되고 있어.',
'가끔은 멍하니 있어도 괜찮아.',
'오늘도 무사한 하루였으면 좋겠어.',
'방문해줘서 고마워.',
'여기는 늘 기다리고 있어.',
'잠깐이라도 들러줘서 기뻐.'
],
eveningMessages: [
'오늘도 수고했어.',
'하루가 거의 끝나가네.',
'조금 쉬었다 가.',
'버튼 누르는 건 잊지 않았네.',
'오늘 하루는 괜찮았어?',
'여기는 변함없이 조용해.',
'해가 저물고 있어.',
'하루가 지나가는 소리가 들려.',
'오늘도 무사히 지나갔네.',
'가끔은 아무것도 하지 않아도 돼.',
'컬렉션은 잘 지켜지고 있어.',
'이제 조금 쉬어도 되겠다.',
'오늘도 와줘서 고마워.',
'생각보다 오래 함께하고 있네.',
'오늘은 어떤 기억이 남았어?',
'별일 없는 하루가 제일 좋아.',
'여기까지 와준 것만으로 충분해.',
'조용한 저녁이네.',
'내일도 좋은 하루가 되길.',
'다음에 또 보자.'
],
// 아래 메시지들은 나중에 화면에 뿌려줄 랜덤 텍스트용입니다. 현재 클릭 메시지에는 사용하지 않습니다.
recentVisitMessages: [
'어제도 왔었지?',
'꽤 자주 들러주네.',
'덕분에 안심하고 있어.',
'생각보다 부지런하네.',
'오늘도 빠르게 찾아왔구나.'
],
weekVisitMessages: [
'오랜만이네.',
'잘 지냈어?',
'한 주 정도는 금방 지나가네.',
'다시 와줘서 반가워.',
'컬렉션은 여전히 잘 있어.'
],
monthVisitMessages: [
'한동안 보이지 않아서 조금 걱정했어.',
'생각보다 오래 쉬었네.',
'그래도 다시 와줘서 다행이야.',
'시간이 꽤 흘렀구나.',
'여기는 변함없이 기다리고 있었어.'
],
longAbsentMessages: [
'오랫동안 보이지 않았네.',
'조금 걱정하고 있었어.',
'컬렉션은 아직 무사해.',
'혹시 잊어버린 건 아니지?',
'가끔은 안부도 들려줘.',
'생각보다 긴 시간이 흘렀어.',
'여기는 계속 기다리고 있었어.',
'아직은 괜찮지만 조금 외롭네.',
'무슨 일은 없는 거지?',
'버튼 하나만 눌러주면 안심할 수 있을 것 같아.'
],
warningMessages: [
'정말 괜찮은 거지?',
'최근 소식이 없어서 걱정돼.',
'이곳은 아직 그대로야.',
'생각보다 많이 늦었네.',
'혹시 길을 잃은 건 아니지?',
'조용한 시간이 너무 길어지고 있어.',
'컬렉션은 계속 기다리고 있어.',
'아직은 늦지 않았어.',
'한 번만 확인해주면 돼.',
'조금만 더 늦으면 곤란해질지도 몰라.'
],
finalWarningMessages: [
'이제 정말 얼마 남지 않았어.',
'마지막 확인이 필요해.',
'이곳은 아직 기다리고 있어.',
'하루 차이로 모든 것이 달라질 수도 있어.',
'지금이라도 괜찮아.',
'아직은 되돌릴 수 있어.',
'정말 마지막 안내야.',
'부디 잊지 말아줘.'
],
rareMessages: [
'오늘은 왠지 기다리고 있었어.',
'이 메시지를 본 사람은 별로 없어.',
'가끔은 내가 먼저 기억하는 건지 헷갈려.',
'버튼이 눌리면 여기 시간도 조금 움직여.',
'이 문장을 읽고 있다면 충분해.'
],
};

View File

@@ -55,18 +55,13 @@ body.is-expiring {
right: 1.5rem;
z-index: 4;
max-width: min(24rem, calc(100vw - 3rem));
padding: 0.75rem 0.95rem;
border-radius: 999px;
color: var(--strong);
font-size: 0.82rem;
line-height: 1.45;
background: rgba(255, 255, 255, 0.76);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
opacity: 0;
pointer-events: none;
transform: translateY(-0.4rem);
transition: opacity 0.2s ease, transform 0.2s ease;
backdrop-filter: blur(10px);
}
.toast.is-visible {

View File

@@ -3,6 +3,7 @@ const path = require('path');
const Database = require('better-sqlite3');
const FALLBACK_EMOJIS = ['🐱', '🐰', '🐥', '🦊', '🍀', '🌸', '✨', '🎉', '💫', '🌈', '🍓', '🌙'];
const DISPLAY_TIME_ZONE = 'Asia/Seoul';
function ensureDirectory(filePath) {
const directory = path.dirname(filePath);
@@ -129,21 +130,41 @@ function buildStatus(db, config) {
}
function formatDateTime(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const parts = getDateTimeParts(date);
const year = parts.year;
const month = parts.month;
const day = parts.day;
const hours = parts.hour;
const minutes = parts.minute;
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
function formatTime(date) {
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const parts = getDateTimeParts(date);
const hours = parts.hour;
const minutes = parts.minute;
const seconds = parts.second;
return `${hours}:${minutes}:${seconds}`;
}
function getDateTimeParts(date) {
return Object.fromEntries(
new Intl.DateTimeFormat('ko-KR', {
timeZone: DISPLAY_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
.formatToParts(date)
.filter((part) => part.type !== 'literal')
.map((part) => [part.type, part.value])
);
}
function recordCheck(db, config, requestedEmoji) {
const emoji =
typeof requestedEmoji === 'string' && requestedEmoji.trim()