Compare commits

...

16 Commits

Author SHA1 Message Date
2462d79053 v0.1.35 - 사용자 노출 문구 한글화와 펼침 폭 보정 2026-04-22 17:38:57 +09:00
4e1263348e v0.1.34 - 모바일 달력 카드 밀도와 버튼 크기 조정 2026-04-22 17:32:39 +09:00
4d48176555 v0.1.33 - 모바일 플래너 본문 레이아웃 개선 2026-04-22 17:29:07 +09:00
c372f325ab v0.1.32 - 패널 드로어 애니메이션과 모바일 밀도 조정 2026-04-22 17:12:41 +09:00
f11c0d3cef v0.1.31 - 집중 시간 한글 표기와 통계 문구 정리 2026-04-22 16:42:25 +09:00
98d4209958 v0.1.30 - 태블릿과 모바일용 내비게이션 드로어 정리 2026-04-22 16:30:21 +09:00
bf06cd28c1 v0.1.29 - 반응형 우측 패널 오버레이와 2페이지 보기 배율 조정 2026-04-22 15:43:41 +09:00
962a338b3d v0.1.28 - 사이드 레이아웃 재정리와 2페이지 인쇄 보정 2026-04-22 12:10:21 +09:00
9e96a57504 v0.1.27 - 달력 기준과 우측 패널 레이아웃 정리 2026-04-22 11:33:17 +09:00
905a0caf75 v0.1.26 - 빈 날짜 이동 시 불필요한 삭제 알림 제거 2026-04-22 11:25:09 +09:00
e221254c60 v0.1.25 - 빈 JSON 본문 요청 오류 수정 2026-04-22 11:20:22 +09:00
1738a7d3b6 v0.1.24 - 개발용과 배포용 실행 문서 추가 2026-04-22 11:14:29 +09:00
9760c9dc0e v0.1.23 - 개발용 자동 새로고침과 로그인 화면 정렬 2026-04-22 11:08:58 +09:00
b972209c2f v0.1.22 - 로그인 오류 문구와 비로그인 화면 정리 2026-04-22 11:05:46 +09:00
bc2e981577 v0.1.21 - 우측 요약 패널 실제 데이터 연결 2026-04-22 10:59:30 +09:00
8ff4c979fa v0.1.20 - PostgreSQL 전환 및 Docker Compose 초안 추가 2026-04-22 10:48:24 +09:00
32 changed files with 1667 additions and 505 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
dist
.git
.gitignore
backend/node_modules
backend/data
backend/drizzle
*.log

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG VITE_API_BASE_URL=/api
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
RUN npm run build
FROM nginx:1.27-alpine
COPY deploy/nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

View File

@@ -4,7 +4,7 @@
- 프로젝트명: 10 Minute Planner 웹 UI - 프로젝트명: 10 Minute Planner 웹 UI
- 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript - 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript
- 현재 기준 버전: `v0.1.19` - 현재 기준 버전: `v0.1.35` 준비 중
- Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git` - Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git`
## 기준 디자인 ## 기준 디자인
@@ -33,6 +33,11 @@
- 백엔드 인증 라우트: `backend/src/routes/auth.js` - 백엔드 인증 라우트: `backend/src/routes/auth.js`
- 백엔드 목표 라우트: `backend/src/routes/goals.js` - 백엔드 목표 라우트: `backend/src/routes/goals.js`
- 백엔드 비밀번호/세션 유틸: `backend/src/lib/password.js` - 백엔드 비밀번호/세션 유틸: `backend/src/lib/password.js`
- Docker Compose 진입점: `docker-compose.yml`
- 프론트 Dockerfile: `Dockerfile`
- 백엔드 Dockerfile: `backend/Dockerfile`
- nginx 프록시 설정: `deploy/nginx/default.conf`
- 실행 가이드 문서: `README.md`
- Tailwind 설정은 완료되어 있으며, 이 프로젝트의 스타일링 기준으로 유지한다. - Tailwind 설정은 완료되어 있으며, 이 프로젝트의 스타일링 기준으로 유지한다.
- 현재 선택 날짜는 시스템 날짜 기준으로 시작한다. - 현재 선택 날짜는 시스템 날짜 기준으로 시작한다.
- `COMMENT`, `TASKS`, `MEMO`는 화면에서 바로 편집할 수 있다. - `COMMENT`, `TASKS`, `MEMO`는 화면에서 바로 편집할 수 있다.
@@ -73,7 +78,7 @@
- `1-UP`은 여백이 과하지 않도록 다시 확대했고, `2-UP`은 한 페이지 고정 안정성을 위해 가로 폭과 세로 높이를 조금 더 보수적으로 조정했다. - `1-UP`은 여백이 과하지 않도록 다시 확대했고, `2-UP`은 한 페이지 고정 안정성을 위해 가로 폭과 세로 높이를 조금 더 보수적으로 조정했다.
- `1-UP`은 세로 가운데 정렬을 없애고 상단 기준으로 붙여야 여백이 덜 커 보인다. - `1-UP`은 세로 가운데 정렬을 없애고 상단 기준으로 붙여야 여백이 덜 커 보인다.
- 현재 `1-UP`은 프레임 자체를 A4 세로에 가깝게 키우고 배율을 크게 올려 빈 여백을 줄이는 방향으로 맞추고 있다. - 현재 `1-UP`은 프레임 자체를 A4 세로에 가깝게 키우고 배율을 크게 올려 빈 여백을 줄이는 방향으로 맞추고 있다.
- 백엔드 초안은 `Fastify + Drizzle + SQLite` 조합이며, 현재는 `/health`, `/api/meta` 정도의 기본 라우트만 있다. - 백엔드 초안은 `Fastify + Drizzle + PostgreSQL` 조합으로 전환되었다.
- 백엔드에는 `/api/auth/signup`, `/api/auth/login`, `/api/auth/me`가 추가되었다. - 백엔드에는 `/api/auth/signup`, `/api/auth/login`, `/api/auth/me`가 추가되었다.
- 백엔드에는 `/api/planner/:entryDate` 단건 조회/저장과 `/api/planner?from=...&to=...` 범위 조회가 추가되었다. - 백엔드에는 `/api/planner/:entryDate` 단건 조회/저장과 `/api/planner?from=...&to=...` 범위 조회가 추가되었다.
- 비밀번호는 Node 내장 `crypto.scrypt` 기반 해시로 저장하고, 세션 토큰은 `auth_sessions` 테이블에 해시 형태로 저장한다. - 비밀번호는 Node 내장 `crypto.scrypt` 기반 해시로 저장하고, 세션 토큰은 `auth_sessions` 테이블에 해시 형태로 저장한다.
@@ -107,8 +112,8 @@
- 공유를 위해 나중에 이미지 저장 기능도 필요하지만, 실제 출력 품질과 텍스트 선명도는 HTML/CSS 인쇄 레이아웃을 우선 유지하는 편이 좋다. - 공유를 위해 나중에 이미지 저장 기능도 필요하지만, 실제 출력 품질과 텍스트 선명도는 HTML/CSS 인쇄 레이아웃을 우선 유지하는 편이 좋다.
- 원격 저장소 `origin``https://git.sori.studio/zenn/planner.sori.studio.git`로 연결되어 있다. - 원격 저장소 `origin``https://git.sori.studio/zenn/planner.sori.studio.git`로 연결되어 있다.
- 앞으로 버전 체크포인트 커밋은 `v0.1.7 - 작업 요약`처럼 버전 뒤에 짧은 작업 설명을 함께 남기는 형식으로 통일한다. - 앞으로 버전 체크포인트 커밋은 `v0.1.7 - 작업 요약`처럼 버전 뒤에 짧은 작업 설명을 함께 남기는 형식으로 통일한다.
- 이후 배포 단계에서는 `docker-compose.yml`도 함께 작성해야 하며, 포트 번호와 서비스 구성은 추후 사용자와 확정한다. - `docker-compose.yml` 초안은 이미 추가되었고, 포트 번호와 실제 외부 공개 범위는 NAS 배포 단계에서 다시 확정하면 된다.
- `backend/.env.example`에는 기본 `PORT`, `DB_FILE`, `CORS_ORIGIN` 예시가 들어 있다. - `backend/.env.example`에는 기본 `PORT`, `DATABASE_URL`, `CORS_ORIGIN` 예시가 들어 있다.
## 다음 권장 작업 ## 다음 권장 작업
@@ -116,8 +121,8 @@
- 목표나 통계 기능보다 먼저, 플래너 본문의 입력과 상호작용을 우선 구현한다. - 목표나 통계 기능보다 먼저, 플래너 본문의 입력과 상호작용을 우선 구현한다.
- 통계 화면 구현은 현재 `localStorage` 기반으로 먼저 진행해도 된다. - 통계 화면 구현은 현재 `localStorage` 기반으로 먼저 진행해도 된다.
- DB는 기능 탐색 속도를 해치지 않는 선에서, 저장 레이어를 분리할 수 있는 적절한 시점에 붙이는 것이 좋다. - DB는 기능 탐색 속도를 해치지 않는 선에서, 저장 레이어를 분리할 수 있는 적절한 시점에 붙이는 것이 좋다.
- 현재 기준 추천 백엔드 방향은 `Vue 프론트엔드 + Node.js API + SQLite 또는 PostgreSQL`이다. - 현재 기준 추천 백엔드 방향은 `Vue 프론트엔드 + Node.js API + PostgreSQL`이다.
- 현재는 SQLite로 시작하되, 확장 시 PostgreSQL로 옮길 수 있게 Drizzle 기반 스키마를 유지한다. - Docker 배포를 시작하는 시점이므로 SQLite보다 PostgreSQL을 기본 저장소로 유지하는 편이 낫다.
- 현재 인증 방식은 Bearer 토큰 기반의 간단한 세션 구조이며, 추후 쿠키/리프레시 토큰 전략으로 확장할 수 있다. - 현재 인증 방식은 Bearer 토큰 기반의 간단한 세션 구조이며, 추후 쿠키/리프레시 토큰 전략으로 확장할 수 있다.
- 다음 프론트 단계에서는 `src/lib/plannerStorage.js`를 유지하되, 인증 이후 백엔드 저장소 adapter를 추가해서 `localStorage`와 전환 가능하게 만드는 흐름이 좋다. - 다음 프론트 단계에서는 `src/lib/plannerStorage.js`를 유지하되, 인증 이후 백엔드 저장소 adapter를 추가해서 `localStorage`와 전환 가능하게 만드는 흐름이 좋다.
- 현재 프론트는 인증만 연결된 상태이고, 플래너 저장은 아직 `localStorage` 기준이다. - 현재 프론트는 인증만 연결된 상태이고, 플래너 저장은 아직 `localStorage` 기준이다.
@@ -135,12 +140,54 @@
- TASK LABELS도 별도 버튼 2개 대신 동일한 토글 UI로 단순화했다. ON이면 01~15를 채우고 OFF이면 비운다. - TASK LABELS도 별도 버튼 2개 대신 동일한 토글 UI로 단순화했다. ON이면 01~15를 채우고 OFF이면 비운다.
- SETTINGS 화면이 추가되어 닉네임, 이메일, 비밀번호 변경을 분리해서 관리할 수 있다. - SETTINGS 화면이 추가되어 닉네임, 이메일, 비밀번호 변경을 분리해서 관리할 수 있다.
- 백엔드에는 `/api/auth/profile`, `/api/auth/password`, `/api/goals/:goalId` 수정 API가 추가되었다. - 백엔드에는 `/api/auth/profile`, `/api/auth/password`, `/api/goals/:goalId` 수정 API가 추가되었다.
- 백엔드에는 `/api/goals/:goalId` 삭제 API도 추가되었다.
- 왼쪽 사이드, 플래너 본문 래퍼, 오른쪽 정보 패널 모두 둥근 카드 톤으로 맞춰서 화면 전체의 통일감을 높였다. - 왼쪽 사이드, 플래너 본문 래퍼, 오른쪽 정보 패널 모두 둥근 카드 톤으로 맞춰서 화면 전체의 통일감을 높였다.
- 플래너 집중 보기에서는 본문과 오른쪽 패널이 각각 독립 스크롤되도록 바뀌어서 동시에 참조하기 쉽다. - 플래너 집중 보기에서는 본문과 오른쪽 패널이 각각 독립 스크롤되도록 바뀌어서 동시에 참조하기 쉽다.
- TASK LABELS, D-DAY 토글은 공통 사이즈의 스위치로 통일했고, `translate` 기반 애니메이션으로 부드럽게 움직이게 했다. - TASK LABELS, D-DAY 토글은 공통 사이즈의 스위치로 통일했고, `translate` 기반 애니메이션으로 부드럽게 움직이게 했다.
- 목표 생성 폼은 기본적으로 `표시 시작일 = 오늘`, `표시 종료일 = 목표일` 흐름으로 자동 채워진다. - 목표 생성 폼은 기본적으로 `표시 시작일 = 오늘`, `표시 종료일 = 목표일` 흐름으로 자동 채워진다.
- D-DAY 기간은 서로 겹칠 수 없고, 프론트와 백엔드 모두 중복 기간을 감지하면 저장을 막는다. - D-DAY 기간은 서로 겹칠 수 없고, 프론트와 백엔드 모두 중복 기간을 감지하면 저장을 막는다.
- 현재 날짜에 적용된 목표가 있는 경우 D-DAY는 기본적으로 보이고, 해당 날짜에서만 토글로 숨길 수 있다. - 현재 날짜에 적용된 목표가 있는 경우 D-DAY는 기본적으로 보이고, 해당 날짜에서만 토글로 숨길 수 있다.
- 목표 상태 개념은 제거했고, 목표는 기간이 있으면 곧바로 D-DAY 후보가 되는 단순 구조로 정리했다.
- GOALS 화면에서는 수정 중인 카드가 시각적으로 강조되고, 목표 삭제 버튼이 추가되었다.
- 목표 삭제 시 과거 날짜를 포함해 어떤 날짜에서도 해당 목표는 더 이상 표시되지 않는다.
- 우측 패널의 `PREV SNAPSHOT`은 선택 날짜 이전의 가장 최근 기록을 읽어서 날짜, 집중 시간, 완료 개수, 코멘트 또는 대표 작업을 보여준다.
- 우측 패널의 `READ NEXT`는 다음 날 첫 작업, 오늘의 미완료 작업, 오늘 코멘트를 기반으로 이어보기 문구를 자동 생성한다.
- 백엔드는 SQLite 파일 기반 구조에서 PostgreSQL 연결 구조로 교체되었다.
- `planner_entries.payload`는 문자열이 아니라 PostgreSQL `JSONB`로 저장되도록 바뀌었다.
- `docker-compose.yml` 기준으로 `postgres`, `backend`, `frontend(nginx)` 3개 서비스 초안이 추가되었다.
- 프론트는 nginx에서 `/api`를 백엔드로 프록시하는 구조라서, 배포 시 브라우저가 별도 API 포트를 직접 알 필요가 없다.
- 프론트 API 클라이언트는 `VITE_API_BASE_URL` 끝에 `/api`가 포함되어 있어도 `/api/api/...` 중복 주소가 생기지 않도록 정규화한다.
- 로그인 실패 시에는 내부 라우트 문자열이나 서버 경로를 그대로 노출하지 않고, 사용자용 안내 문구만 보여준다.
- 비로그인 상태에서는 왼쪽 사이드 내비게이션을 숨기고, 중앙 로그인 안내 화면만 보여주도록 정리했다.
- 배포용 `docker-compose.yml`과 별도로 개발용 `docker-compose.dev.yml`을 추가했다.
- 개발용 compose는 프론트 `5173`, 백엔드 `3001`, PostgreSQL `5432`를 열고, 소스 마운트 + Vite HMR + Node watch 기반으로 자동 반영된다.
- 개발/배포 실행 방법은 루트 `README.md`에 먼저 적고, `HANDOFF.md`에는 변경 이유와 주의사항 위주로 남긴다.
- API 클라이언트는 body가 없는 `GET`, `DELETE` 요청에 `Content-Type: application/json`을 붙이지 않도록 수정했다. 날짜 선택 시 발생하던 `Body cannot be empty...` 오류는 이 문제였다.
- 날짜 이동 중 목표 자동 보정으로 `goalEnabled`만 꺼지는 경우에는, 실제 내용이 없는 기록이라면 클라우드 동기화 토스트를 띄우지 않도록 정리했다.
- 집중 보기에서 오른쪽 패널 폭을 넓혀 `1920x1080` 기준 활용도를 높였고, `TASK LABELS / D-DAY / CALENDAR`는 상단 고정 영역으로 올렸다.
- `STATS``NEXT DAY`는 반반 카드가 아니라 각각 한 줄씩 쓰도록 바꿔서 날짜 길이에 따라 높이가 흔들리는 문제를 줄였다.
- 미니 달력은 42칸 기준으로 렌더링하고, 요일 헤더를 `SUN ~ SAT` 순서로 고정해서 일요일 시작 달력 기준을 더 명확하게 맞췄다.
- 집중 보기 오른쪽 패널은 다시 정리해서 `왼쪽 컬럼: CALENDAR / TASK LABELS / D-DAY`, `오른쪽 컬럼: STATS / NEXT DAY`, `하단 전체 폭: READ NEXT / PREV SNAPSHOT` 구조로 맞췄다.
- 달력은 과하게 키우지 않고 컴팩트한 크기를 유지한 채, 우측 정보 패널 내부 배치만 다시 조정하는 쪽으로 방향을 잡았다.
- `PRINT 2-UP`은 브라우저/프린터 기본 여백 차이로 오른쪽 페이지가 잘릴 수 있어서, 프레임 폭과 스케일을 조금 더 보수적으로 줄여 안정성을 높였다.
- 플래너 집중 보기 반응형 기준은 `1620px 이상: 우측 패널 2열`, `1280px 이상 ~ 1619px 이하: 우측 패널 1열 + 최대 360px`, `1280px 미만: 우측 패널 오버레이` 구조로 다시 정리했다.
- 오버레이 구간에서는 본문 오른쪽 위의 `OPEN SIDE PANEL` 버튼으로 패널을 열고, 배경 클릭이나 `CLOSE` 버튼으로 닫는다.
- `2 PAGE SPREAD`는 화면 폭을 기준으로 배율을 자동 계산해서 오른쪽 페이지가 잘리는 현상을 줄이는 방향으로 조정했다.
- `1280px` 미만에서는 왼쪽 내비게이션도 본문 위에 쌓이지 않고 `MENU` 버튼으로 여는 드로어형 패널로 전환된다.
- 태블릿/모바일 구간에서는 `왼쪽 내비게이션 드로어 + 오른쪽 정보 패널 오버레이 + 본문 단일 컬럼` 조합으로 보는 흐름을 기본값으로 삼는다.
- 통계 화면과 우측 `FOCUSED TIME` 요약처럼 사용자에게 보여주는 집중 시간 표기는 `00H 00M` 대신 `00시간 00분` 한글 형식으로 바꿨다.
- 좌측 메뉴 드로어와 우측 정보 패널 오버레이는 이제 열고 닫힐 때 페이드 + 슬라이드 애니메이션이 적용된다.
- 모바일처럼 좁은 화면에서는 본문 래퍼 패딩을 조금 줄이고, 우측 패널 열기 버튼 문구를 `INFO`로 축약해 밀도를 낮췄다.
- 플래너 본문은 작은 화면에서 상단 정보 영역이 세로로 쌓이고, `TIME TABLE`이 아래로 내려가도록 조정했다.
- 모바일 구간에서는 TASKS / MEMO 행 높이와 좌우 패딩을 조금 줄여 입력 밀도를 낮췄고, 타임테이블은 필요할 때만 최소 가로 스크롤이 생기도록 바뀌었다.
- 미니 달력은 모바일 구간에서 패딩, 월 이동 버튼, 요일 헤더, 날짜 셀 크기를 한 단계 더 줄여서 카드 내부 밀도를 정리했다.
- 연도 선택 팝오버는 좁은 화면에서 카드 전체 폭을 활용하고, 넓은 화면에서는 기존 우측 드롭다운 폭을 유지한다.
- 플래너 본문 안의 `TOTAL TIME` 라벨도 `총 시간`으로 바꿔서 영어 라벨을 줄였다.
- 사용자 노출 메뉴 문구는 `보기 방식 / 날짜 이동 / 인쇄 / 1페이지 + 정보 / 2페이지 펼침 / 이전 날 / 다음 날 / 1장 인쇄 / 2장 인쇄`처럼 한글 중심으로 정리하기 시작했다.
- 2페이지 펼침 보기 배율 계산에서 데스크톱 여유 폭을 더 보수적으로 잡아, `1920px` 근처에서 우측 페이지가 잘려 가로 스크롤이 생기던 문제를 줄이는 방향으로 조정했다.
- 달력 날짜 버튼은 셀 안쪽에 고정 크기 원형 버튼으로 다시 배치해서 모바일에서 서로 겹쳐 보이는 현상을 줄였다.
- 비로그인 랜딩 카드는 상단 고정이 아니라 화면 중앙에 오도록 정렬을 수정했다.
- 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다.
- 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다. - 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다.
- Docker Compose는 프론트엔드와 백엔드를 함께 올리는 기준으로 설계하되, NAS 환경에 맞는 볼륨과 재시작 정책도 함께 고려한다. - Docker Compose는 프론트엔드와 백엔드를 함께 올리는 기준으로 설계하되, NAS 환경에 맞는 볼륨과 재시작 정책도 함께 고려한다.

57
README.md Normal file
View File

@@ -0,0 +1,57 @@
# 10 Minute Planner
Vue 3 + TailwindCSS + Fastify + PostgreSQL 기반의 `10분 플래너 다이어리` 프로젝트다.
## 실행 방법
### 개발용
코드를 수정하면서 자동 새로고침까지 보려면 개발용 compose를 사용한다.
```bash
docker compose -f docker-compose.dev.yml up
```
개발용 포트:
- 프론트엔드: `http://localhost:5173`
- 백엔드 API: `http://localhost:3001`
- PostgreSQL: `localhost:5432`
개발용 특징:
- 프론트는 Vite HMR로 저장 즉시 화면이 반영된다.
- 백엔드는 `node --watch`로 파일 변경 시 자동 재시작된다.
- 즉, 개발 중에는 매번 새로 빌드할 필요 없이 `docker compose -f docker-compose.dev.yml up`만 켜두면 된다.
### 배포용
실서비스나 최종 확인용으로는 배포용 compose를 사용한다.
```bash
docker compose up -d --build
```
배포용 포트:
- 프론트엔드: `http://localhost:8080`
- PostgreSQL: `localhost:5432`
배포용 특징:
- 프론트는 빌드 결과물을 nginx로 서빙한다.
- 브라우저에서는 `/api` 경로로 백엔드에 접근한다.
- 수정 사항 반영 시에는 다시 빌드가 필요하다.
## 문서
- 작업 규칙: [`AGENTS.md`](./AGENTS.md)
- 진행 상태 / 체크리스트: [`TODO.md`](./TODO.md)
- 인수인계 메모: [`HANDOFF.md`](./HANDOFF.md)
## 현재 방향
- 기본 UX는 `1페이지 + 우측 정보 패널`
- 보조 모드는 `2페이지 펼침 보기`
- 스타일링은 Vue + TailwindCSS
- 장기적으로는 Docker 기반으로 UGREEN NAS 배포 예정

11
TODO.md
View File

@@ -38,7 +38,7 @@
- [x] 목표 관리 패널 기본 구조를 설계한다. - [x] 목표 관리 패널 기본 구조를 설계한다.
- [x] 선택한 목표 기준으로 `D-DAY`가 자동 계산되게 한다. - [x] 선택한 목표 기준으로 `D-DAY`가 자동 계산되게 한다.
- [ ] 우측 요약 패널의 `PREV SNAPSHOT`, `READ NEXT`를 실제 데이터 기반으로 연결한다. - [x] 우측 요약 패널의 `PREV SNAPSHOT`, `READ NEXT`를 실제 데이터 기반으로 연결한다.
- [ ] 다음날 할 일 자동 제안 규칙을 정리한다. - [ ] 다음날 할 일 자동 제안 규칙을 정리한다.
- [x] 오른쪽 패널에 `D-DAY 사용` 토글과 목표 검색/선택 UI를 추가한다. - [x] 오른쪽 패널에 `D-DAY 사용` 토글과 목표 검색/선택 UI를 추가한다.
- [x] 목표를 여러 개 생성하고 날짜별 대표 목표를 선택할 수 있게 한다. - [x] 목표를 여러 개 생성하고 날짜별 대표 목표를 선택할 수 있게 한다.
@@ -88,9 +88,10 @@
- [x] A4 가로 기준 2장 출력 모드를 지원한다. - [x] A4 가로 기준 2장 출력 모드를 지원한다.
- [x] `1-UP` 세로 인쇄 / `2-UP` 가로 인쇄 기준을 분리한다. - [x] `1-UP` 세로 인쇄 / `2-UP` 가로 인쇄 기준을 분리한다.
- [ ] 공유를 위한 이미지 저장 기능을 추가한다. - [ ] 공유를 위한 이미지 저장 기능을 추가한다.
- [ ] Docker 배포 구조를 정리한다. - [x] Docker 배포 구조를 정리한다.
- [ ] UGREEN NAS 기준 `docker-compose.yml` 초안을 작성한다. - [x] UGREEN NAS 기준 `docker-compose.yml` 초안을 작성한다.
- [x] 백엔드 기본 스캐폴딩을 추가한다. - [x] 백엔드 기본 스캐폴딩을 추가한다.
- [x] PostgreSQL 전환 초안을 적용한다.
## 메모 ## 메모
@@ -104,10 +105,12 @@
- 실제 인쇄는 HTML/CSS 기반 프린트 레이아웃으로 유지하고, 공유용으로는 별도의 이미지 저장 기능을 추가하는 방향이 적합하다. - 실제 인쇄는 HTML/CSS 기반 프린트 레이아웃으로 유지하고, 공유용으로는 별도의 이미지 저장 기능을 추가하는 방향이 적합하다.
- 최종 배포는 UGREEN NAS에서 Docker 기반으로 동작할 예정이며, 포트와 실제 서비스 구성은 추후 확정한다. - 최종 배포는 UGREEN NAS에서 Docker 기반으로 동작할 예정이며, 포트와 실제 서비스 구성은 추후 확정한다.
- 백엔드는 빠른 목업이면 PocketBase도 가능하지만, 현재 방향상 커스텀 로직과 확장성을 생각하면 전용 Node.js API + DB 조합을 우선 검토한다. - 백엔드는 빠른 목업이면 PocketBase도 가능하지만, 현재 방향상 커스텀 로직과 확장성을 생각하면 전용 Node.js API + DB 조합을 우선 검토한다.
- 현재 백엔드는 `backend/` 폴더에 `Fastify + Drizzle + SQLite` 기준 초안이 추가되었다. - 현재 백엔드는 `backend/` 폴더에 `Fastify + Drizzle + PostgreSQL` 기준으로 전환 중이다.
- 현재 백엔드는 회원가입, 로그인, 현재 사용자 확인용 기본 인증 API까지 포함한다. - 현재 백엔드는 회원가입, 로그인, 현재 사용자 확인용 기본 인증 API까지 포함한다.
- 현재 백엔드는 사용자별 플래너 단건 저장/조회와 범위 조회 API까지 포함한다. - 현재 백엔드는 사용자별 플래너 단건 저장/조회와 범위 조회 API까지 포함한다.
- 현재 백엔드는 사용자별 목표 목록 조회와 목표 생성 API까지 포함한다. - 현재 백엔드는 사용자별 목표 목록 조회와 목표 생성 API까지 포함한다.
- 현재는 `docker-compose.yml``postgres + backend + frontend(nginx)` 초안을 올릴 수 있게 정리했다.
- 현재 환경에서는 Docker 데몬이 꺼져 있어 `docker compose build` 실검증은 아직 완료하지 못했다.
- 프론트에는 로그인/회원가입 모달과 현재 사용자 상태 표시가 추가되었다. - 프론트에는 로그인/회원가입 모달과 현재 사용자 상태 표시가 추가되었다.
- 로그인 상태일 때는 서버 저장을 우선 사용하는 흐름으로 전환 중이다. - 로그인 상태일 때는 서버 저장을 우선 사용하는 흐름으로 전환 중이다.
- 로그인 전에는 플래너 본문을 사용하지 못하도록 막고, 인증 후 사용 흐름으로 정리했다. - 로그인 전에는 플래너 본문을 사용하지 못하도록 막고, 인증 후 사용 흐름으로 정리했다.

View File

@@ -1,3 +1,3 @@
PORT=3001 PORT=3001
DB_FILE=./data/planner.sqlite DATABASE_URL=postgresql://planner:planner1234@localhost:5432/ten_minute_planner
CORS_ORIGIN=http://localhost:5173 CORS_ORIGIN=http://localhost:5173

14
backend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:22-alpine AS base
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src ./src
COPY .env.example ./.env.example
COPY drizzle.config.js ./drizzle.config.js
EXPOSE 3001
CMD ["node", "src/server.js"]

View File

@@ -10,8 +10,8 @@ config({ path: path.join(__dirname, '.env') })
export default { export default {
schema: './src/db/schema.js', schema: './src/db/schema.js',
out: './drizzle', out: './drizzle',
dialect: 'sqlite', dialect: 'postgresql',
dbCredentials: { dbCredentials: {
url: process.env.DB_FILE ?? './data/planner.sqlite', url: process.env.DATABASE_URL ?? 'postgresql://planner:planner1234@localhost:5432/ten_minute_planner',
}, },
} }

View File

@@ -9,10 +9,10 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fastify/cors": "^10.0.1", "@fastify/cors": "^10.0.1",
"better-sqlite3": "^11.8.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"drizzle-orm": "^0.39.1", "drizzle-orm": "^0.39.1",
"fastify": "^5.2.1", "fastify": "^5.2.1",
"pg": "^8.13.3",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
@@ -1083,7 +1083,9 @@
"url": "https://feross.org/support" "url": "https://feross.org/support"
} }
], ],
"license": "MIT" "license": "MIT",
"optional": true,
"peer": true
}, },
"node_modules/better-sqlite3": { "node_modules/better-sqlite3": {
"version": "11.10.0", "version": "11.10.0",
@@ -1091,6 +1093,8 @@
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"bindings": "^1.5.0", "bindings": "^1.5.0",
"prebuild-install": "^7.1.1" "prebuild-install": "^7.1.1"
@@ -1101,6 +1105,8 @@
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"file-uri-to-path": "1.0.0" "file-uri-to-path": "1.0.0"
} }
@@ -1110,6 +1116,8 @@
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"buffer": "^5.5.0", "buffer": "^5.5.0",
"inherits": "^2.0.4", "inherits": "^2.0.4",
@@ -1135,6 +1143,8 @@
} }
], ],
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"base64-js": "^1.3.1", "base64-js": "^1.3.1",
"ieee754": "^1.1.13" "ieee754": "^1.1.13"
@@ -1151,7 +1161,9 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC" "license": "ISC",
"optional": true,
"peer": true
}, },
"node_modules/cookie": { "node_modules/cookie": {
"version": "1.1.1", "version": "1.1.1",
@@ -1189,6 +1201,8 @@
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"mimic-response": "^3.1.0" "mimic-response": "^3.1.0"
}, },
@@ -1204,6 +1218,8 @@
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=4.0.0" "node": ">=4.0.0"
} }
@@ -1222,6 +1238,8 @@
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -1377,6 +1395,8 @@
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"once": "^1.4.0" "once": "^1.4.0"
} }
@@ -1451,6 +1471,8 @@
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)", "license": "(MIT OR WTFPL)",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@@ -1578,7 +1600,9 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT" "license": "MIT",
"optional": true,
"peer": true
}, },
"node_modules/find-my-way": { "node_modules/find-my-way": {
"version": "9.5.0", "version": "9.5.0",
@@ -1598,7 +1622,9 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT" "license": "MIT",
"optional": true,
"peer": true
}, },
"node_modules/gel": { "node_modules/gel": {
"version": "2.2.0", "version": "2.2.0",
@@ -1638,7 +1664,9 @@
"version": "0.0.0", "version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT" "license": "MIT",
"optional": true,
"peer": true
}, },
"node_modules/ieee754": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
@@ -1658,19 +1686,25 @@
"url": "https://feross.org/support" "url": "https://feross.org/support"
} }
], ],
"license": "BSD-3-Clause" "license": "BSD-3-Clause",
"optional": true,
"peer": true
}, },
"node_modules/inherits": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC",
"optional": true,
"peer": true
}, },
"node_modules/ini": { "node_modules/ini": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC" "license": "ISC",
"optional": true,
"peer": true
}, },
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "2.3.0", "version": "2.3.0",
@@ -1758,6 +1792,8 @@
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@@ -1770,6 +1806,8 @@
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
@@ -1778,7 +1816,9 @@
"version": "0.5.3", "version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT" "license": "MIT",
"optional": true,
"peer": true
}, },
"node_modules/mnemonist": { "node_modules/mnemonist": {
"version": "0.40.0", "version": "0.40.0",
@@ -1800,13 +1840,17 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT" "license": "MIT",
"optional": true,
"peer": true
}, },
"node_modules/node-abi": { "node_modules/node-abi": {
"version": "3.89.0", "version": "3.89.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
"integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"semver": "^7.3.5" "semver": "^7.3.5"
}, },
@@ -1834,10 +1878,101 @@
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC", "license": "ISC",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
"pg-protocol": "^1.13.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/pino": { "node_modules/pino": {
"version": "10.3.1", "version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
@@ -1875,12 +2010,53 @@
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prebuild-install": { "node_modules/prebuild-install": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"detect-libc": "^2.0.0", "detect-libc": "^2.0.0",
"expand-template": "^2.0.3", "expand-template": "^2.0.3",
@@ -1923,6 +2099,8 @@
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"end-of-stream": "^1.1.0", "end-of-stream": "^1.1.0",
"once": "^1.3.1" "once": "^1.3.1"
@@ -1939,6 +2117,8 @@
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)", "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"deep-extend": "^0.6.0", "deep-extend": "^0.6.0",
"ini": "~1.3.0", "ini": "~1.3.0",
@@ -1954,6 +2134,8 @@
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"inherits": "^2.0.3", "inherits": "^2.0.3",
"string_decoder": "^1.1.1", "string_decoder": "^1.1.1",
@@ -2034,7 +2216,9 @@
"url": "https://feross.org/support" "url": "https://feross.org/support"
} }
], ],
"license": "MIT" "license": "MIT",
"optional": true,
"peer": true
}, },
"node_modules/safe-regex2": { "node_modules/safe-regex2": {
"version": "5.1.1", "version": "5.1.1",
@@ -2132,7 +2316,9 @@
"url": "https://feross.org/support" "url": "https://feross.org/support"
} }
], ],
"license": "MIT" "license": "MIT",
"optional": true,
"peer": true
}, },
"node_modules/simple-get": { "node_modules/simple-get": {
"version": "4.0.1", "version": "4.0.1",
@@ -2153,6 +2339,8 @@
} }
], ],
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"decompress-response": "^6.0.0", "decompress-response": "^6.0.0",
"once": "^1.3.1", "once": "^1.3.1",
@@ -2203,6 +2391,8 @@
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "~5.2.0" "safe-buffer": "~5.2.0"
} }
@@ -2212,6 +2402,8 @@
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -2221,6 +2413,8 @@
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"chownr": "^1.1.1", "chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2", "mkdirp-classic": "^0.5.2",
@@ -2233,6 +2427,8 @@
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"bl": "^4.0.3", "bl": "^4.0.3",
"end-of-stream": "^1.4.1", "end-of-stream": "^1.4.1",
@@ -2270,6 +2466,8 @@
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
}, },
@@ -2281,7 +2479,9 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "license": "MIT",
"optional": true,
"peer": true
}, },
"node_modules/which": { "node_modules/which": {
"version": "4.0.0", "version": "4.0.0",
@@ -2303,7 +2503,18 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC",
"optional": true,
"peer": true
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",

View File

@@ -11,10 +11,10 @@
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^10.0.1", "@fastify/cors": "^10.0.1",
"better-sqlite3": "^11.8.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"drizzle-orm": "^0.39.1", "drizzle-orm": "^0.39.1",
"fastify": "^5.2.1", "fastify": "^5.2.1",
"pg": "^8.13.3",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -10,7 +10,7 @@ config({ path: path.join(__dirname, '..', '.env') })
const envSchema = z.object({ const envSchema = z.object({
PORT: z.coerce.number().default(3001), PORT: z.coerce.number().default(3001),
DB_FILE: z.string().default('./data/planner.sqlite'), DATABASE_URL: z.string().min(1).default('postgresql://planner:planner1234@localhost:5432/ten_minute_planner'),
CORS_ORIGIN: z.string().default('http://localhost:5173'), CORS_ORIGIN: z.string().default('http://localhost:5173'),
SESSION_TTL_DAYS: z.coerce.number().default(30), SESSION_TTL_DAYS: z.coerce.number().default(30),
}) })

View File

@@ -1,17 +1,12 @@
import fs from 'node:fs' import { drizzle } from 'drizzle-orm/node-postgres'
import path from 'node:path' import pg from 'pg'
import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import { env } from '../config.js' import { env } from '../config.js'
import * as schema from './schema.js' import * as schema from './schema.js'
function ensureDatabaseDirectory(dbFile) { const { Pool } = pg
const absoluteDbPath = path.resolve(dbFile)
fs.mkdirSync(path.dirname(absoluteDbPath), { recursive: true })
return absoluteDbPath
}
const sqlite = new Database(ensureDatabaseDirectory(env.DB_FILE)) export const pool = new Pool({
connectionString: env.DATABASE_URL,
})
export const db = drizzle(sqlite, { schema }) export const db = drizzle(pool, { schema })
export { sqlite }

View File

@@ -1,63 +1,55 @@
import { sqlite } from './client.js' import { pool } from './client.js'
function ensureColumn(tableName, columnName, definition) { export async function ensureDatabaseSchema() {
const columns = sqlite.prepare(`PRAGMA table_info(${tableName})`).all() await pool.query(`
const hasColumn = columns.some((column) => column.name === columnName)
if (!hasColumn) {
sqlite.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition}`)
}
}
export function ensureDatabaseSchema() {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id SERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE, email VARCHAR(255) NOT NULL UNIQUE,
password_hash TEXT NOT NULL, password_hash VARCHAR(255) NOT NULL,
nickname TEXT NOT NULL, nickname VARCHAR(60) NOT NULL,
created_at INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL,
updated_at INTEGER NOT NULL updated_at TIMESTAMPTZ NOT NULL
); );
CREATE TABLE IF NOT EXISTS auth_sessions ( CREATE TABLE IF NOT EXISTS auth_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT, id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE, token_hash VARCHAR(255) NOT NULL UNIQUE,
expires_at INTEGER NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
created_at INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE INDEX IF NOT EXISTS auth_sessions_user_id_idx
ON auth_sessions (user_id);
CREATE TABLE IF NOT EXISTS planner_entries ( CREATE TABLE IF NOT EXISTS planner_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT, id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
entry_date TEXT NOT NULL, entry_date VARCHAR(10) NOT NULL,
payload TEXT NOT NULL, payload JSONB NOT NULL,
created_at INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL,
updated_at INTEGER NOT NULL, updated_at TIMESTAMPTZ NOT NULL
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE UNIQUE INDEX IF NOT EXISTS planner_entries_user_date_unique CREATE UNIQUE INDEX IF NOT EXISTS planner_entries_user_date_unique
ON planner_entries (user_id, entry_date); ON planner_entries (user_id, entry_date);
CREATE TABLE IF NOT EXISTS goals ( CREATE INDEX IF NOT EXISTS planner_entries_user_id_idx
id INTEGER PRIMARY KEY AUTOINCREMENT, ON planner_entries (user_id);
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
target_date TEXT NOT NULL,
active_from TEXT,
active_until TEXT,
status TEXT NOT NULL DEFAULT 'active',
color TEXT NOT NULL DEFAULT '#1c1917',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
completed_at INTEGER,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`)
ensureColumn('goals', 'active_from', 'TEXT') CREATE TABLE IF NOT EXISTS goals (
ensureColumn('goals', 'active_until', 'TEXT') id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(120) NOT NULL,
target_date VARCHAR(10) NOT NULL,
active_from VARCHAR(10),
active_until VARCHAR(10),
color VARCHAR(32) NOT NULL DEFAULT '#1c1917',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS goals_user_id_idx
ON goals (user_id);
`)
} }

View File

@@ -1,47 +1,67 @@
import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core' import {
integer,
index,
jsonb,
pgTable,
serial,
timestamp,
uniqueIndex,
varchar,
} from 'drizzle-orm/pg-core'
export const users = sqliteTable('users', { export const users = pgTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }), id: serial('id').primaryKey(),
email: text('email').notNull().unique(), email: varchar('email', { length: 255 }).notNull().unique(),
passwordHash: text('password_hash').notNull(), passwordHash: varchar('password_hash', { length: 255 }).notNull(),
nickname: text('nickname').notNull(), nickname: varchar('nickname', { length: 60 }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(),
}) })
export const authSessions = sqliteTable('auth_sessions', { export const authSessions = pgTable(
id: integer('id').primaryKey({ autoIncrement: true }), 'auth_sessions',
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
tokenHash: text('token_hash').notNull().unique(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
})
export const plannerEntries = sqliteTable(
'planner_entries',
{ {
id: integer('id').primaryKey({ autoIncrement: true }), id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
entryDate: text('entry_date').notNull(), tokenHash: varchar('token_hash', { length: 255 }).notNull().unique(),
payload: text('payload').notNull(), expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
}, },
(table) => ({ (table) => ({
userDateUnique: uniqueIndex('planner_entries_user_date_unique').on(table.userId, table.entryDate), userIndex: index('auth_sessions_user_id_idx').on(table.userId),
}), }),
) )
export const goals = sqliteTable('goals', { export const plannerEntries = pgTable(
id: integer('id').primaryKey({ autoIncrement: true }), 'planner_entries',
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), {
title: text('title').notNull(), id: serial('id').primaryKey(),
targetDate: text('target_date').notNull(), userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
activeFrom: text('active_from'), entryDate: varchar('entry_date', { length: 10 }).notNull(),
activeUntil: text('active_until'), payload: jsonb('payload').notNull(),
status: text('status').notNull().default('active'), createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
color: text('color').notNull().default('#1c1917'), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), },
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), (table) => ({
completedAt: integer('completed_at', { mode: 'timestamp_ms' }), userDateUnique: uniqueIndex('planner_entries_user_date_unique').on(table.userId, table.entryDate),
}) userIndex: index('planner_entries_user_id_idx').on(table.userId),
}),
)
export const goals = pgTable(
'goals',
{
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
title: varchar('title', { length: 120 }).notNull(),
targetDate: varchar('target_date', { length: 10 }).notNull(),
activeFrom: varchar('active_from', { length: 10 }),
activeUntil: varchar('active_until', { length: 10 }),
color: varchar('color', { length: 32 }).notNull().default('#1c1917'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(),
},
(table) => ({
userIndex: index('goals_user_id_idx').on(table.userId),
}),
)

View File

@@ -9,7 +9,6 @@ const goalSchema = z.object({
targetDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), targetDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
activeFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), activeFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
activeUntil: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), activeUntil: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
status: z.enum(['active', 'done', 'archived']).optional(),
color: z.string().trim().min(4).max(32).optional(), color: z.string().trim().min(4).max(32).optional(),
}) })
@@ -18,13 +17,11 @@ const goalUpdateSchema = z.object({
targetDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), targetDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
activeFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), activeFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
activeUntil: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), activeUntil: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
status: z.enum(['active', 'done', 'archived']).optional(),
color: z.string().trim().min(4).max(32).optional(), color: z.string().trim().min(4).max(32).optional(),
}) })
const goalQuerySchema = z.object({ const goalQuerySchema = z.object({
query: z.string().trim().optional(), query: z.string().trim().optional(),
status: z.enum(['active', 'done', 'archived', 'all']).optional(),
}) })
async function requireAuthenticatedUser(request, reply) { async function requireAuthenticatedUser(request, reply) {
@@ -48,10 +45,9 @@ async function validateGoalSchedule({
userId, userId,
activeFrom, activeFrom,
activeUntil, activeUntil,
status,
excludeGoalId = null, excludeGoalId = null,
}) { }) {
if (!activeFrom || !activeUntil || status !== 'active') { if (!activeFrom || !activeUntil) {
return null return null
} }
@@ -65,7 +61,7 @@ async function validateGoalSchedule({
return false return false
} }
if (goal.status !== 'active' || !goal.activeFrom || !goal.activeUntil) { if (!goal.activeFrom || !goal.activeUntil) {
return false return false
} }
@@ -92,10 +88,6 @@ export async function registerGoalRoutes(app) {
const filters = [eq(goals.userId, user.id)] const filters = [eq(goals.userId, user.id)]
if (query.data.status && query.data.status !== 'all') {
filters.push(eq(goals.status, query.data.status))
}
if (query.data.query) { if (query.data.query) {
filters.push(like(goals.title, `%${query.data.query}%`)) filters.push(like(goals.title, `%${query.data.query}%`))
} }
@@ -141,7 +133,6 @@ export async function registerGoalRoutes(app) {
userId: user.id, userId: user.id,
activeFrom: payload.data.activeFrom ?? null, activeFrom: payload.data.activeFrom ?? null,
activeUntil: payload.data.activeUntil ?? null, activeUntil: payload.data.activeUntil ?? null,
status: payload.data.status ?? 'active',
}) })
if (overlappedGoal) { if (overlappedGoal) {
@@ -161,10 +152,8 @@ export async function registerGoalRoutes(app) {
activeFrom: payload.data.activeFrom ?? null, activeFrom: payload.data.activeFrom ?? null,
activeUntil: payload.data.activeUntil ?? null, activeUntil: payload.data.activeUntil ?? null,
color: payload.data.color ?? '#1c1917', color: payload.data.color ?? '#1c1917',
status: payload.data.status ?? 'active',
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
completedAt: payload.data.status === 'done' ? now : null,
}) })
.returning() .returning()
@@ -214,8 +203,6 @@ export async function registerGoalRoutes(app) {
const nextActiveFrom = payload.data.activeFrom !== undefined ? payload.data.activeFrom : existingGoal.activeFrom const nextActiveFrom = payload.data.activeFrom !== undefined ? payload.data.activeFrom : existingGoal.activeFrom
const nextActiveUntil = payload.data.activeUntil !== undefined ? payload.data.activeUntil : existingGoal.activeUntil const nextActiveUntil = payload.data.activeUntil !== undefined ? payload.data.activeUntil : existingGoal.activeUntil
const nextStatus = payload.data.status ?? existingGoal.status
if ((nextActiveFrom && !nextActiveUntil) || (!nextActiveFrom && nextActiveUntil)) { if ((nextActiveFrom && !nextActiveUntil) || (!nextActiveFrom && nextActiveUntil)) {
return reply.code(400).send({ return reply.code(400).send({
message: '표시 시작일과 종료일은 함께 입력해 주세요.', message: '표시 시작일과 종료일은 함께 입력해 주세요.',
@@ -232,7 +219,6 @@ export async function registerGoalRoutes(app) {
userId: user.id, userId: user.id,
activeFrom: nextActiveFrom, activeFrom: nextActiveFrom,
activeUntil: nextActiveUntil, activeUntil: nextActiveUntil,
status: nextStatus,
excludeGoalId: existingGoal.id, excludeGoalId: existingGoal.id,
}) })
@@ -262,22 +248,10 @@ export async function registerGoalRoutes(app) {
nextValues.activeUntil = payload.data.activeUntil nextValues.activeUntil = payload.data.activeUntil
} }
if (payload.data.status !== undefined) {
nextValues.status = payload.data.status
}
if (payload.data.color !== undefined) { if (payload.data.color !== undefined) {
nextValues.color = payload.data.color nextValues.color = payload.data.color
} }
if (payload.data.status === 'done' && !existingGoal.completedAt) {
nextValues.completedAt = new Date()
}
if (payload.data.status && payload.data.status !== 'done') {
nextValues.completedAt = null
}
const [goal] = await db const [goal] = await db
.update(goals) .update(goals)
.set(nextValues) .set(nextValues)
@@ -289,4 +263,42 @@ export async function registerGoalRoutes(app) {
goal, goal,
} }
}) })
app.delete('/api/goals/:goalId', async (request, reply) => {
const user = await requireAuthenticatedUser(request, reply)
if (!user) {
return
}
const params = z.object({
goalId: z.coerce.number().int().positive(),
}).safeParse(request.params)
if (!params.success) {
return reply.code(400).send({
message: '목표 식별자가 올바르지 않습니다.',
})
}
const [existingGoal] = await db
.select()
.from(goals)
.where(and(eq(goals.id, params.data.goalId), eq(goals.userId, user.id)))
.limit(1)
if (!existingGoal) {
return reply.code(404).send({
message: '목표를 찾을 수 없습니다.',
})
}
await db
.delete(goals)
.where(and(eq(goals.id, params.data.goalId), eq(goals.userId, user.id)))
return {
message: '목표가 삭제되었습니다.',
}
})
} }

View File

@@ -67,10 +67,7 @@ export async function registerPlannerRoutes(app) {
.orderBy(asc(plannerEntries.entryDate)) .orderBy(asc(plannerEntries.entryDate))
return { return {
entries: entries.map((entry) => ({ entries,
...entry,
payload: JSON.parse(entry.payload),
})),
} }
}) })
@@ -101,12 +98,7 @@ export async function registerPlannerRoutes(app) {
.limit(1) .limit(1)
return { return {
entry: entry entry: entry ?? null,
? {
...entry,
payload: JSON.parse(entry.payload),
}
: null,
} }
}) })
@@ -141,14 +133,14 @@ export async function registerPlannerRoutes(app) {
.values({ .values({
userId: user.id, userId: user.id,
entryDate: dateResult.data, entryDate: dateResult.data,
payload: JSON.stringify(payloadResult.data.payload), payload: payloadResult.data.payload,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [plannerEntries.userId, plannerEntries.entryDate], target: [plannerEntries.userId, plannerEntries.entryDate],
set: { set: {
payload: JSON.stringify(payloadResult.data.payload), payload: payloadResult.data.payload,
updatedAt: now, updatedAt: now,
}, },
}) })
@@ -156,10 +148,7 @@ export async function registerPlannerRoutes(app) {
return { return {
message: '플래너가 저장되었습니다.', message: '플래너가 저장되었습니다.',
entry: { entry,
...entry,
payload: JSON.parse(entry.payload),
},
} }
}) })

View File

@@ -1,7 +1,7 @@
import Fastify from 'fastify' import Fastify from 'fastify'
import cors from '@fastify/cors' import cors from '@fastify/cors'
import { env } from './config.js' import { env } from './config.js'
import { sqlite } from './db/client.js' import { pool } from './db/client.js'
import { ensureDatabaseSchema } from './db/init.js' import { ensureDatabaseSchema } from './db/init.js'
import { registerAuthRoutes } from './routes/auth.js' import { registerAuthRoutes } from './routes/auth.js'
import { registerGoalRoutes } from './routes/goals.js' import { registerGoalRoutes } from './routes/goals.js'
@@ -11,7 +11,7 @@ const app = Fastify({
logger: true, logger: true,
}) })
ensureDatabaseSchema() await ensureDatabaseSchema()
await app.register(cors, { await app.register(cors, {
origin: env.CORS_ORIGIN, origin: env.CORS_ORIGIN,
@@ -23,13 +23,14 @@ await registerGoalRoutes(app)
await registerPlannerRoutes(app) await registerPlannerRoutes(app)
app.get('/health', async () => { app.get('/health', async () => {
const version = sqlite.prepare('select sqlite_version() as version').get() const versionResult = await pool.query('select version() as version')
const version = versionResult.rows[0]
return { return {
status: 'ok', status: 'ok',
service: 'ten-minute-planner-backend', service: 'ten-minute-planner-backend',
database: { database: {
client: 'sqlite', client: 'postgresql',
version: version?.version ?? 'unknown', version: version?.version ?? 'unknown',
}, },
} }
@@ -37,11 +38,11 @@ app.get('/health', async () => {
app.get('/api/meta', async () => ({ app.get('/api/meta', async () => ({
auth: 'active', auth: 'active',
storage: 'sqlite', storage: 'postgresql',
orm: 'drizzle', orm: 'drizzle',
notes: [ notes: [
'회원가입, 로그인, 현재 사용자 확인 API가 준비되어 있습니다.', '회원가입, 로그인, 현재 사용자 확인 API가 준비되어 있습니다.',
'사용자별 목표 목록과 생성 API가 준비되어 있습니다.', '사용자별 목표 목록, 수정, 삭제 API가 준비되어 있습니다.',
'사용자별 플래너 저장 및 조회 API가 준비되어 있습니다.', '사용자별 플래너 저장 및 조회 API가 준비되어 있습니다.',
], ],
})) }))

26
deploy/nginx/default.conf Normal file
View File

@@ -0,0 +1,26 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:3001/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /health {
proxy_pass http://backend:3001/health;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location / {
try_files $uri $uri/ /index.html;
}
}

61
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,61 @@
services:
postgres:
image: postgres:16-alpine
container_name: ten-minute-postgres-dev
environment:
POSTGRES_DB: ten_minute_planner
POSTGRES_USER: planner
POSTGRES_PASSWORD: planner1234
volumes:
- postgres_dev_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U planner -d ten_minute_planner"]
interval: 10s
timeout: 5s
retries: 10
restart: unless-stopped
backend:
image: node:22-alpine
container_name: ten-minute-backend-dev
working_dir: /app
command: sh -c "npm install && npm run dev"
environment:
PORT: 3001
DATABASE_URL: postgresql://planner:planner1234@postgres:5432/ten_minute_planner
CORS_ORIGIN: http://localhost:5173
SESSION_TTL_DAYS: 30
volumes:
- ./backend:/app
- backend_node_modules:/app/node_modules
depends_on:
postgres:
condition: service_healthy
ports:
- "3001:3001"
restart: unless-stopped
frontend:
image: node:22-alpine
container_name: ten-minute-frontend-dev
working_dir: /app
command: sh -c "npm install && npm run dev"
environment:
VITE_API_BASE_URL: http://localhost:3001
CHOKIDAR_USEPOLLING: "true"
volumes:
- .:/app
- /app/backend
- frontend_node_modules:/app/node_modules
depends_on:
- backend
ports:
- "5173:5173"
restart: unless-stopped
volumes:
postgres_dev_data:
backend_node_modules:
frontend_node_modules:

49
docker-compose.yml Normal file
View File

@@ -0,0 +1,49 @@
services:
postgres:
image: postgres:16-alpine
container_name: ten-minute-postgres
environment:
POSTGRES_DB: ten_minute_planner
POSTGRES_USER: planner
POSTGRES_PASSWORD: planner1234
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U planner -d ten_minute_planner"]
interval: 10s
timeout: 5s
retries: 10
restart: unless-stopped
backend:
build:
context: ./backend
container_name: ten-minute-backend
environment:
PORT: 3001
DATABASE_URL: postgresql://planner:planner1234@postgres:5432/ten_minute_planner
CORS_ORIGIN: http://localhost:8080
SESSION_TTL_DAYS: 30
depends_on:
postgres:
condition: service_healthy
expose:
- "3001"
restart: unless-stopped
frontend:
build:
context: .
args:
VITE_API_BASE_URL: /api
container_name: ten-minute-frontend
depends_on:
- backend
ports:
- "8080:80"
restart: unless-stopped
volumes:
postgres_data:

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "ten-minute-planner", "name": "ten-minute-planner",
"version": "0.1.19", "version": "0.1.35",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ten-minute-planner", "name": "ten-minute-planner",
"version": "0.1.19", "version": "0.1.35",
"dependencies": { "dependencies": {
"vue": "^3.5.13" "vue": "^3.5.13"
}, },

View File

@@ -1,7 +1,7 @@
{ {
"name": "ten-minute-planner", "name": "ten-minute-planner",
"private": true, "private": true,
"version": "0.1.19", "version": "0.1.35",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

File diff suppressed because it is too large Load Diff

View File

@@ -8,10 +8,6 @@ const props = defineProps({
type: String, type: String,
default: '', default: '',
}, },
status: {
type: String,
default: 'all',
},
form: { form: {
type: Object, type: Object,
required: true, required: true,
@@ -36,12 +32,12 @@ const props = defineProps({
const emit = defineEmits([ const emit = defineEmits([
'update:query', 'update:query',
'update:status',
'update:form-field', 'update:form-field',
'submit:create', 'submit:create',
'start-edit', 'start-edit',
'cancel-edit', 'cancel-edit',
'submit:update', 'submit:update',
'delete-goal',
]) ])
function updateField(field, event) { function updateField(field, event) {
@@ -109,19 +105,6 @@ function isActiveOnSelectedDate(goal) {
</div> </div>
</div> </div>
<div class="space-y-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">상태</label>
<select
:value="form.status"
class="w-full rounded-2xl border border-stone-300 bg-white px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-500"
@change="updateField('status', $event)"
>
<option value="active">진행 </option>
<option value="done">완료</option>
<option value="archived">보관</option>
</select>
</div>
<p class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-3 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-600"> <p class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-3 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-600">
여기서 목표와 표시 기간을 설정해 두면, 플래너 작성 화면에서는 해당 날짜에 보여줄지 여부만 간단히 ON/OFF 있습니다. 여기서 목표와 표시 기간을 설정해 두면, 플래너 작성 화면에서는 해당 날짜에 보여줄지 여부만 간단히 ON/OFF 있습니다.
</p> </p>
@@ -161,7 +144,7 @@ function isActiveOnSelectedDate(goal) {
목표가 많아져도 플래너 작성 화면이 길어지지 않도록, 전체 관리는 화면에서 처리합니다. 목표가 많아져도 플래너 작성 화면이 길어지지 않도록, 전체 관리는 화면에서 처리합니다.
</p> </p>
</div> </div>
<div class="grid gap-2 sm:grid-cols-[220px_140px]"> <div class="grid gap-2 sm:grid-cols-[220px]">
<input <input
:value="query" :value="query"
type="text" type="text"
@@ -169,16 +152,6 @@ function isActiveOnSelectedDate(goal) {
placeholder="목표 검색" placeholder="목표 검색"
@input="emit('update:query', $event.target.value)" @input="emit('update:query', $event.target.value)"
/> />
<select
:value="status"
class="rounded-2xl border border-stone-300 bg-white px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-500"
@change="emit('update:status', $event.target.value)"
>
<option value="all">전체</option>
<option value="active">진행 </option>
<option value="done">완료</option>
<option value="archived">보관</option>
</select>
</div> </div>
</div> </div>
@@ -186,17 +159,18 @@ function isActiveOnSelectedDate(goal) {
<article <article
v-for="goal in goals" v-for="goal in goals"
:key="goal.id" :key="goal.id"
class="rounded-[24px] border border-stone-200 bg-white px-5 py-5" class="rounded-[24px] border px-5 py-5 transition"
:class="editingGoalId === goal.id ? 'border-stone-900 bg-[#f7f1e7] shadow-[0_18px_40px_rgba(28,25,23,0.10)]' : 'border-stone-200 bg-white'"
> >
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between"> <div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div class="space-y-2"> <div class="space-y-2">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<p class="text-lg font-semibold tracking-[-0.03em] text-stone-900">{{ goal.title }}</p> <p class="text-lg font-semibold tracking-[-0.03em] text-stone-900">{{ goal.title }}</p>
<span <span
class="rounded-full px-3 py-1 text-[10px] font-bold tracking-[0.16em]" v-if="editingGoalId === goal.id"
:class="goal.status === 'done' ? 'bg-emerald-100 text-emerald-700' : goal.status === 'archived' ? 'bg-stone-200 text-stone-600' : 'bg-stone-900 text-white'" class="rounded-full bg-stone-900 px-3 py-1 text-[10px] font-bold tracking-[0.16em] text-white"
> >
{{ goal.status === 'done' ? '완료' : goal.status === 'archived' ? '보관' : '진행 중' }} 수정
</span> </span>
<span <span
v-if="isActiveOnSelectedDate(goal)" v-if="isActiveOnSelectedDate(goal)"
@@ -211,13 +185,22 @@ function isActiveOnSelectedDate(goal) {
</p> </p>
</div> </div>
<button <div class="flex flex-wrap gap-2">
type="button" <button
class="rounded-full border border-stone-300 px-4 py-2 text-[11px] font-bold tracking-[0.16em] text-stone-600 transition hover:border-stone-500 hover:text-stone-900" type="button"
@click="emit('start-edit', goal)" class="rounded-full border border-stone-300 px-4 py-2 text-[11px] font-bold tracking-[0.16em] text-stone-600 transition hover:border-stone-500 hover:text-stone-900"
> @click="emit('start-edit', goal)"
수정 >
</button> 수정
</button>
<button
type="button"
class="rounded-full border border-red-200 px-4 py-2 text-[11px] font-bold tracking-[0.16em] text-red-500 transition hover:border-red-400 hover:bg-red-50"
@click="emit('delete-goal', goal)"
>
삭제
</button>
</div>
</div> </div>
</article> </article>

View File

@@ -40,23 +40,23 @@ function selectYear(year) {
</script> </script>
<template> <template>
<section class="border border-stone-200 bg-white/80 p-5"> <section class="rounded-[24px] border border-stone-200 bg-white/82 p-4 shadow-[0_12px_36px_rgba(28,25,23,0.05)] sm:p-5">
<div class="relative mb-4 flex items-start justify-between gap-4"> <div class="relative mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div> <div class="min-w-0">
<h2 class="text-[11px] font-bold tracking-[0.22em] text-ink">CALENDAR</h2> <h2 class="text-[11px] font-bold tracking-[0.22em] text-ink">CALENDAR</h2>
<div class="mt-2 flex items-center gap-3"> <div class="mt-2 flex items-center gap-2 sm:gap-3">
<button <button
type="button" type="button"
class="rounded-full border border-stone-200 px-2 py-1 text-xs font-bold text-stone-600 transition hover:border-stone-400 hover:text-ink" class="flex h-9 w-9 items-center justify-center rounded-full border border-stone-200 text-xs font-bold text-stone-600 transition hover:border-stone-400 hover:text-ink sm:h-auto sm:w-auto sm:px-2 sm:py-1"
@click="emit('shift-month', -1)" @click="emit('shift-month', -1)"
> >
</button> </button>
<div class="flex items-center gap-2"> <div class="flex min-w-0 items-center gap-1.5 sm:gap-2">
<p class="text-base font-semibold tracking-[-0.04em] text-stone-900">{{ monthLabel }}</p> <p class="text-[15px] font-semibold tracking-[-0.04em] text-stone-900 sm:text-base">{{ monthLabel }}</p>
<button <button
type="button" type="button"
class="rounded-full px-2 py-1 text-[11px] font-semibold tracking-[0.16em] text-stone-500 transition hover:bg-stone-100 hover:text-ink" class="rounded-full px-2 py-1 text-[10px] font-semibold tracking-[0.16em] text-stone-500 transition hover:bg-stone-100 hover:text-ink sm:text-[11px]"
@click="isYearPickerOpen = !isYearPickerOpen" @click="isYearPickerOpen = !isYearPickerOpen"
> >
{{ yearLabel }} {{ yearLabel }}
@@ -64,7 +64,7 @@ function selectYear(year) {
</div> </div>
<button <button
type="button" type="button"
class="rounded-full border border-stone-200 px-2 py-1 text-xs font-bold text-stone-600 transition hover:border-stone-400 hover:text-ink" class="flex h-9 w-9 items-center justify-center rounded-full border border-stone-200 text-xs font-bold text-stone-600 transition hover:border-stone-400 hover:text-ink sm:h-auto sm:w-auto sm:px-2 sm:py-1"
@click="emit('shift-month', 1)" @click="emit('shift-month', 1)"
> >
@@ -73,7 +73,7 @@ function selectYear(year) {
</div> </div>
<button <button
type="button" type="button"
class="rounded-full border border-stone-200 px-3 py-2 text-[10px] font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink" class="shrink-0 self-start rounded-full border border-stone-200 px-3 py-2 text-[10px] font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-400 hover:text-ink sm:self-auto"
@click="emit('go-today')" @click="emit('go-today')"
> >
TODAY TODAY
@@ -81,7 +81,7 @@ function selectYear(year) {
<div <div
v-if="isYearPickerOpen" v-if="isYearPickerOpen"
class="absolute right-0 top-14 z-10 w-[220px] rounded-2xl border border-stone-200 bg-white p-4 shadow-lg" class="absolute left-0 right-0 top-[88px] z-10 rounded-2xl border border-stone-200 bg-white p-4 shadow-lg sm:left-auto sm:right-0 sm:top-14 sm:w-[220px]"
> >
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<button <button
@@ -116,31 +116,38 @@ function selectYear(year) {
</div> </div>
</div> </div>
</div> </div>
<div class="mb-3 grid grid-cols-7 gap-2 text-center text-[10px] font-bold tracking-[0.12em] text-stone-400"> <div class="mb-3 grid grid-cols-7 gap-1.5 text-center text-[9px] font-bold tracking-[0.1em] text-stone-400 sm:gap-2 sm:text-[10px] sm:tracking-[0.12em]">
<span v-for="weekday in ['S', 'M', 'T', 'W', 'T', 'F', 'S']" :key="weekday">{{ weekday }}</span> <span
v-for="weekday in ['일', '월', '화', '수', '목', '금', '토']"
:key="weekday"
>
{{ weekday }}
</span>
</div> </div>
<div class="grid grid-cols-7 gap-2"> <div class="grid grid-cols-7 gap-1.5 sm:gap-2">
<button <div
v-for="day in days" v-for="day in days"
:key="day.key" :key="day.key"
type="button" class="flex items-center justify-center"
class="aspect-square rounded-full border text-[11px] font-semibold transition"
:class="[
day.key === selectedKey
? 'border-ink bg-ink text-white'
: 'border-stone-200 bg-stone-50 text-stone-700 hover:border-stone-400 hover:bg-white',
day.isCurrentMonth ? '' : 'opacity-35',
]"
@click="emit('select', day.date)"
> >
<span class="relative flex h-full w-full items-center justify-center"> <button
type="button"
class="relative flex size-8 items-center justify-center rounded-full border text-[10px] font-semibold transition sm:size-10 sm:text-[11px]"
:class="[
day.key === selectedKey
? 'border-ink bg-ink text-white'
: 'border-stone-200 bg-stone-50 text-stone-700 hover:border-stone-400 hover:bg-white',
day.isCurrentMonth ? '' : 'opacity-35',
]"
@click="emit('select', day.date)"
>
<span>{{ day.label }}</span> <span>{{ day.label }}</span>
<span <span
v-if="markedKeys.includes(day.key)" v-if="markedKeys.includes(day.key)"
class="absolute bottom-[3px] h-[5px] w-[5px] rounded-full bg-red-500" class="absolute bottom-[3px] h-1 w-1 rounded-full bg-red-500 sm:h-[5px] sm:w-[5px]"
/> />
</span> </button>
</button> </div>
</div> </div>
</section> </section>
</template> </template>

View File

@@ -119,52 +119,52 @@ onBeforeUnmount(() => {
<template> <template>
<article <article
class="planner-sheet flex w-full max-w-[762px] flex-col gap-3 bg-paper px-6 py-6 text-[10px] font-bold tracking-[0.16em] text-ink shadow-paper sm:px-12 sm:py-12" class="planner-sheet flex w-full max-w-[762px] flex-col gap-3 bg-paper px-4 py-4 text-[10px] font-bold tracking-[0.16em] text-ink shadow-paper sm:px-8 sm:py-8 lg:px-12 lg:py-12"
> >
<div class="flex flex-col gap-4 py-[18px]"> <div class="flex flex-col gap-4 py-3 sm:py-[18px]">
<div class="flex gap-4"> <div class="flex flex-col gap-3 sm:gap-4" :class="props.showDday ? 'sm:flex-row' : ''">
<div class="relative h-[90px] border-t border-ink px-[10px] pt-[10px]" :class="props.showDday ? 'w-[394px] flex-1' : 'w-full flex-1'"> <div class="relative min-h-[82px] border-t border-ink px-[10px] pt-[10px]" :class="props.showDday ? 'w-full sm:w-[394px] sm:flex-1' : 'w-full flex-1'">
<span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">YEAR / MONTH / DAY</span> <span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">YEAR / MONTH / DAY</span>
<p class="pt-6 text-xs tracking-[0.24em] text-ink sm:text-sm"> <p class="pt-5 text-[11px] tracking-[0.2em] text-ink sm:pt-6 sm:text-sm">
<span>{{ dateMain }}</span> <span>{{ dateMain }}</span>
<span class="ml-1" :class="dateWeekdayTone">{{ dateWeekday }}</span> <span class="ml-1" :class="dateWeekdayTone">{{ dateWeekday }}</span>
</p> </p>
</div> </div>
<div v-if="props.showDday" class="relative h-[90px] w-[210px] border-t border-ink px-[10px] pt-[10px]"> <div v-if="props.showDday" class="relative min-h-[82px] w-full border-t border-ink px-[10px] pt-[10px] sm:w-[210px]">
<span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">D-DAY</span> <span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">D-DAY</span>
<p class="pt-6 text-xs tracking-[0.24em] text-ink sm:text-sm">{{ dday }}</p> <p class="pt-5 text-[11px] tracking-[0.2em] text-ink sm:pt-6 sm:text-sm">{{ dday }}</p>
</div> </div>
</div> </div>
<div class="flex gap-4 border-b border-ink pb-[18px]"> <div class="flex flex-col gap-3 border-b border-ink pb-3 sm:gap-4 sm:pb-[18px] lg:flex-row">
<div class="relative h-[90px] w-[394px] flex-1 border-t border-ink px-[10px] pt-[10px]"> <div class="relative min-h-[82px] w-full flex-1 border-t border-ink px-[10px] pt-[10px] lg:w-[394px]">
<span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">COMMENT</span> <span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">COMMENT</span>
<textarea <textarea
:value="comment" :value="comment"
rows="3" rows="3"
class="mt-4 h-[56px] w-full resize-none bg-transparent pt-2 text-[11px] font-semibold normal-case tracking-[0.08em] text-stone-700 outline-none placeholder:text-stone-400 sm:text-xs" class="mt-3 h-[54px] w-full resize-none bg-transparent pt-2 text-[11px] font-semibold normal-case tracking-[0.06em] text-stone-700 outline-none placeholder:text-stone-400 sm:mt-4 sm:text-xs"
placeholder="오늘의 코멘트를 적어 주세요." placeholder="오늘의 코멘트를 적어 주세요."
@input="emit('update:comment', $event.target.value)" @input="emit('update:comment', $event.target.value)"
/> />
</div> </div>
<div class="relative h-[90px] w-[210px] border-t border-ink px-[10px] pt-[10px]"> <div class="relative min-h-[82px] w-full border-t border-ink px-[10px] pt-[10px] lg:w-[210px]">
<span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">TOTAL TIME</span> <span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted"> 시간</span>
<p class="pt-6 text-xs tracking-[0.24em] text-ink sm:text-sm">{{ totalTime }}</p> <p class="pt-5 text-[11px] tracking-[0.2em] text-ink sm:pt-6 sm:text-sm">{{ totalTime }}</p>
</div> </div>
</div> </div>
</div> </div>
<div class="flex gap-4 py-[10px]"> <div class="flex flex-col gap-5 py-[10px] lg:flex-row lg:gap-4">
<div class="flex w-[394px] flex-1 flex-col gap-9"> <div class="flex w-full flex-1 flex-col gap-7 sm:gap-9 lg:w-[394px]">
<section class="relative"> <section class="relative">
<div class="absolute -top-[9px] left-0 bg-paper px-[2px] text-muted">TASKS</div> <div class="absolute -top-[9px] left-0 bg-paper px-[2px] text-muted">TASKS</div>
<div class="border-t border-ink"> <div class="border-t border-ink">
<div <div
v-for="(task, index) in tasks" v-for="(task, index) in tasks"
:key="task.id" :key="task.id"
class="flex h-[38px] items-center border-b" class="flex min-h-[38px] items-center border-b"
:class="index % 5 === 4 || index === tasks.length - 1 ? 'border-ink' : 'border-line'" :class="index % 5 === 4 || index === tasks.length - 1 ? 'border-ink' : 'border-line'"
> >
<div class="h-full w-[62px] border-r border-dashed border-ink px-2 py-[7px]"> <div class="h-full w-[52px] shrink-0 border-r border-dashed border-ink px-1.5 py-[7px] sm:w-[62px] sm:px-2">
<input <input
:value="task.label" :value="task.label"
type="text" type="text"
@@ -172,16 +172,16 @@ onBeforeUnmount(() => {
@input="emit('update:task-label', { index, value: $event.target.value })" @input="emit('update:task-label', { index, value: $event.target.value })"
/> />
</div> </div>
<div class="flex min-w-0 flex-1 items-center px-3"> <div class="flex min-w-0 flex-1 items-center px-2 sm:px-3">
<input <input
:value="task.title" :value="task.title"
type="text" type="text"
class="w-full truncate bg-transparent text-[11px] font-semibold normal-case tracking-[0.06em] text-stone-800 outline-none placeholder:text-stone-400" class="w-full truncate bg-transparent text-[10px] font-semibold normal-case tracking-[0.04em] text-stone-800 outline-none placeholder:text-stone-400 sm:text-[11px] sm:tracking-[0.06em]"
:placeholder="shouldShowTaskPlaceholder(index) ? '할 일을 입력해 주세요.' : ''" :placeholder="shouldShowTaskPlaceholder(index) ? '할 일을 입력해 주세요.' : ''"
@input="emit('update:task-title', { index, value: $event.target.value })" @input="emit('update:task-title', { index, value: $event.target.value })"
/> />
</div> </div>
<div class="flex h-full w-[42px] items-center justify-center p-[10px]"> <div class="flex h-full w-[36px] shrink-0 items-center justify-center p-[8px] sm:w-[42px] sm:p-[10px]">
<button <button
type="button" type="button"
class="flex h-full w-full items-center justify-center border border-dashed transition" class="flex h-full w-full items-center justify-center border border-dashed transition"
@@ -201,10 +201,10 @@ onBeforeUnmount(() => {
<div <div
v-for="(memoItem, index) in memo" v-for="(memoItem, index) in memo"
:key="`memo-${index}`" :key="`memo-${index}`"
class="flex h-[38px] items-center border-b" class="flex min-h-[38px] items-center border-b"
:class="index === memo.length - 1 ? 'border-ink' : 'border-line'" :class="index === memo.length - 1 ? 'border-ink' : 'border-line'"
> >
<div class="h-full w-[62px] border-r border-dashed border-ink px-2 py-[7px]"> <div class="h-full w-[52px] shrink-0 border-r border-dashed border-ink px-1.5 py-[7px] sm:w-[62px] sm:px-2">
<input <input
:value="memoItem.label" :value="memoItem.label"
type="text" type="text"
@@ -212,11 +212,11 @@ onBeforeUnmount(() => {
@input="emit('update:memo-label', { index, value: $event.target.value })" @input="emit('update:memo-label', { index, value: $event.target.value })"
/> />
</div> </div>
<div class="flex flex-1 items-center px-3"> <div class="flex flex-1 items-center px-2 sm:px-3">
<input <input
:value="memoItem.text" :value="memoItem.text"
type="text" type="text"
class="w-full bg-transparent text-[11px] font-semibold normal-case tracking-[0.06em] text-stone-700 outline-none placeholder:text-stone-400" class="w-full bg-transparent text-[10px] font-semibold normal-case tracking-[0.04em] text-stone-700 outline-none placeholder:text-stone-400 sm:text-[11px] sm:tracking-[0.06em]"
@input="emit('update:memo', { index, value: $event.target.value })" @input="emit('update:memo', { index, value: $event.target.value })"
/> />
</div> </div>
@@ -225,30 +225,32 @@ onBeforeUnmount(() => {
</section> </section>
</div> </div>
<section class="relative w-[210px] shrink-0"> <section class="relative w-full shrink-0 lg:w-[210px]">
<div class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">TIME TABLE</div> <div class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">TIME TABLE</div>
<div class="border-t border-ink"> <div class="overflow-x-auto pb-1">
<div <div class="min-w-[210px] border-t border-ink">
v-for="(hour, index) in hours"
:key="`${hour}-${index}`"
class="flex h-[30px] border-b"
:class="index === hours.length - 1 ? 'border-ink' : 'border-line'"
>
<div <div
class="flex h-full w-[30px] touch-none select-none items-center justify-center border-r border-ink text-[9px] text-ink" v-for="(hour, index) in hours"
@pointerdown.prevent :key="`${hour}-${index}`"
class="flex h-[26px] border-b sm:h-[30px]"
:class="index === hours.length - 1 ? 'border-ink' : 'border-line'"
> >
{{ hour }} <div
class="flex h-full w-[30px] touch-none select-none items-center justify-center border-r border-ink text-[9px] text-ink"
@pointerdown.prevent
>
{{ hour }}
</div>
<div
v-for="quarter in 6"
:key="quarter"
:class="props.timetable[index * 6 + quarter - 1] ? 'bg-stone-800/90' : 'bg-transparent'"
class="h-full w-[30px] cursor-crosshair border-r border-dashed border-line transition-colors last:border-r-0 touch-none select-none"
@contextmenu.prevent
@pointerdown.prevent="startTimetableDrag(index * 6 + quarter - 1, $event)"
@pointerenter="moveTimetableDrag(index * 6 + quarter - 1)"
/>
</div> </div>
<div
v-for="quarter in 6"
:key="quarter"
:class="props.timetable[index * 6 + quarter - 1] ? 'bg-stone-800/90' : 'bg-transparent'"
class="h-full w-[30px] cursor-crosshair border-r border-dashed border-line transition-colors last:border-r-0 touch-none select-none"
@contextmenu.prevent
@pointerdown.prevent="startTimetableDrag(index * 6 + quarter - 1, $event)"
@pointerenter="moveTimetableDrag(index * 6 + quarter - 1)"
/>
</div> </div>
</div> </div>
</section> </section>

46
src/lib/apiBase.js Normal file
View File

@@ -0,0 +1,46 @@
const DEFAULT_API_BASE_URL = 'http://localhost:3001'
function normalizeBaseUrl(baseUrl) {
return (baseUrl || DEFAULT_API_BASE_URL).replace(/\/+$/, '')
}
function normalizePath(path) {
return path.startsWith('/') ? path : `/${path}`
}
export function buildApiUrl(path) {
const baseUrl = normalizeBaseUrl(import.meta.env.VITE_API_BASE_URL)
const normalizedPath = normalizePath(path)
if (baseUrl.endsWith('/api') && normalizedPath.startsWith('/api/')) {
return `${baseUrl}${normalizedPath.slice(4)}`
}
return `${baseUrl}${normalizedPath}`
}
export function toUserFacingApiError(error, fallbackMessage) {
const rawMessage = `${error?.message ?? ''}`.trim()
if (!rawMessage) {
return fallbackMessage
}
if (
rawMessage.includes('Failed to fetch') ||
rawMessage.includes('NetworkError') ||
rawMessage.includes('Load failed')
) {
return '서버에 연결하지 못했습니다. 잠시 후 다시 시도해 주세요.'
}
if (
rawMessage.includes('Route ') ||
rawMessage.includes('not found') ||
rawMessage.includes('/api/')
) {
return '로그인 요청을 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.'
}
return rawMessage
}

View File

@@ -1,25 +1,26 @@
const AUTH_STORAGE_KEY = 'ten-minute-planner-auth' const AUTH_STORAGE_KEY = 'ten-minute-planner-auth'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001' import { buildApiUrl, toUserFacingApiError } from './apiBase'
function buildHeaders(token, extraHeaders = {}) { function buildHeaders(token, hasBody, extraHeaders = {}) {
return { return {
'Content-Type': 'application/json', ...(hasBody ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
...extraHeaders, ...extraHeaders,
} }
} }
async function request(path, { method = 'GET', token, body } = {}) { async function request(path, { method = 'GET', token, body } = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, { const hasBody = body !== undefined
const response = await fetch(buildApiUrl(path), {
method, method,
headers: buildHeaders(token), headers: buildHeaders(token, hasBody),
body: body ? JSON.stringify(body) : undefined, body: hasBody ? JSON.stringify(body) : undefined,
}) })
const data = await response.json().catch(() => ({})) const data = await response.json().catch(() => ({}))
if (!response.ok) { if (!response.ok) {
throw new Error(data.message || '요청 처리 중 문제가 발생했습니다.') throw new Error(toUserFacingApiError(data, '요청 처리 중 문제가 발생했습니다.'))
} }
return data return data

View File

@@ -1,23 +1,24 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001' import { buildApiUrl, toUserFacingApiError } from './apiBase'
function buildHeaders(token) { function buildHeaders(token, hasBody) {
return { return {
'Content-Type': 'application/json', ...(hasBody ? { 'Content-Type': 'application/json' } : {}),
Authorization: `Bearer ${token}`, ...(token ? { Authorization: `Bearer ${token}` } : {}),
} }
} }
async function request(path, { method = 'GET', token, body } = {}) { async function request(path, { method = 'GET', token, body } = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, { const hasBody = body !== undefined
const response = await fetch(buildApiUrl(path), {
method, method,
headers: buildHeaders(token), headers: buildHeaders(token, hasBody),
body: body ? JSON.stringify(body) : undefined, body: hasBody ? JSON.stringify(body) : undefined,
}) })
const data = await response.json().catch(() => ({})) const data = await response.json().catch(() => ({}))
if (!response.ok) { if (!response.ok) {
throw new Error(data.message || '목표 데이터를 처리하지 못했습니다.') throw new Error(toUserFacingApiError(data, '목표 데이터를 처리하지 못했습니다.'))
} }
return data return data
@@ -55,3 +56,10 @@ export async function updateGoal(token, goalId, payload) {
body: payload, body: payload,
}) })
} }
export async function deleteGoal(token, goalId) {
return request(`/api/goals/${goalId}`, {
method: 'DELETE',
token,
})
}

View File

@@ -1,23 +1,24 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001' import { buildApiUrl, toUserFacingApiError } from './apiBase'
function buildHeaders(token) { function buildHeaders(token, hasBody) {
return { return {
'Content-Type': 'application/json', ...(hasBody ? { 'Content-Type': 'application/json' } : {}),
Authorization: `Bearer ${token}`, ...(token ? { Authorization: `Bearer ${token}` } : {}),
} }
} }
async function request(path, { method = 'GET', token, body } = {}) { async function request(path, { method = 'GET', token, body } = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, { const hasBody = body !== undefined
const response = await fetch(buildApiUrl(path), {
method, method,
headers: buildHeaders(token), headers: buildHeaders(token, hasBody),
body: body ? JSON.stringify(body) : undefined, body: hasBody ? JSON.stringify(body) : undefined,
}) })
const data = await response.json().catch(() => ({})) const data = await response.json().catch(() => ({}))
if (!response.ok) { if (!response.ok) {
throw new Error(data.message || '플래너 데이터를 처리하지 못했습니다.') throw new Error(toUserFacingApiError(data, '플래너 데이터를 처리하지 못했습니다.'))
} }
return data return data

View File

@@ -88,15 +88,15 @@
.print-paper--double { .print-paper--double {
display: grid !important; display: grid !important;
grid-template-columns: repeat(2, 141mm); grid-template-columns: repeat(2, 139mm);
justify-content: center; justify-content: center;
align-content: center; align-content: center;
column-gap: 3mm; column-gap: 4mm;
} }
body[data-print-layout='double'] .print-paper { body[data-print-layout='double'] .print-paper {
width: 287mm; width: 285mm;
height: 198mm; height: 196mm;
} }
body[data-print-layout='single'] .print-paper--single .print-sheet-frame { body[data-print-layout='single'] .print-paper--single .print-sheet-frame {
@@ -113,8 +113,8 @@
} }
body[data-print-layout='double'] .print-paper--double .print-sheet-frame { body[data-print-layout='double'] .print-paper--double .print-sheet-frame {
width: 141mm; width: 139mm;
height: 198mm; height: 196mm;
} }
.planner-sheet { .planner-sheet {
@@ -134,7 +134,7 @@
width: 762px !important; width: 762px !important;
max-width: none !important; max-width: none !important;
transform-origin: top left; transform-origin: top left;
transform: scale(0.694); transform: scale(0.684);
box-shadow: none !important; box-shadow: none !important;
background: #ffffff !important; background: #ffffff !important;
} }
@@ -150,3 +150,34 @@
display: none !important; display: none !important;
} }
} }
.panel-fade-enter-active,
.panel-fade-leave-active {
transition: opacity 220ms ease;
}
.panel-fade-enter-from,
.panel-fade-leave-to {
opacity: 0;
}
.drawer-left-enter-active,
.drawer-left-leave-active,
.drawer-right-enter-active,
.drawer-right-leave-active {
transition:
transform 260ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 220ms ease;
}
.drawer-left-enter-from,
.drawer-left-leave-to {
opacity: 0;
transform: translateX(-24px) scale(0.985);
}
.drawer-right-enter-from,
.drawer-right-leave-to {
opacity: 0;
transform: translateX(24px) scale(0.985);
}

View File

@@ -3,4 +3,12 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
server: {
host: '0.0.0.0',
port: 5173,
strictPort: true,
watch: {
usePolling: true,
},
},
}) })