From 5f9f10f3057c358b8e33c53ffdd43e066ea064d2 Mon Sep 17 00:00:00 2001 From: zenn Date: Wed, 10 Jun 2026 12:42:14 +0900 Subject: [PATCH] =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B3=B4=EA=B0=95=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EB=8F=99=EC=9E=91=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RESET_TOKEN을 .env에서 읽도록 바꾸고 한국 시간 표시와 시간대별 클릭 메시지 동작을 정리합니다. --- .dockerignore | 1 + README.md | 12 +++- docker-compose.yml | 4 +- docs/DEPLOY.md | 13 ++++ public/app.js | 64 +++++++++++++++++- public/custom.js | 163 ++++++++++++++++++++++++++++++++++++++++++++- public/styles.css | 5 -- src/db.js | 37 +++++++--- 8 files changed, 279 insertions(+), 20 deletions(-) diff --git a/.dockerignore b/.dockerignore index 04f4b3a..c670186 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,7 @@ node_modules data .git .gitignore +.env README.md .cursor *.sqlite diff --git a/README.md b/README.md index 5c2460e..35fbf1f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 5a575a4..edbf536 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index caf11d0..2fd7490 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -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` --- diff --git a/public/app.js b/public/app.js index 4d1ad87..ddfc119 100644 --- a/public/app.js +++ b/public/app.js @@ -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); diff --git a/public/custom.js b/public/custom.js index 30c6ec1..1b48ecc 100644 --- a/public/custom.js +++ b/public/custom.js @@ -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: [ + '오늘은 왠지 기다리고 있었어.', + '이 메시지를 본 사람은 별로 없어.', + '가끔은 내가 먼저 기억하는 건지 헷갈려.', + '버튼이 눌리면 여기 시간도 조금 움직여.', + '이 문장을 읽고 있다면 충분해.' + ], }; diff --git a/public/styles.css b/public/styles.css index f17e06d..29193a8 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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 { diff --git a/src/db.js b/src/db.js index 9bb323a..1a80669 100644 --- a/src/db.js +++ b/src/db.js @@ -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()