Compare commits

...

56 Commits

Author SHA1 Message Date
4ed7ee6c7d 모바일 플래너 여백과 타임테이블 터치 개선 2026-04-27 15:19:33 +09:00
d5af1c9525 정적 이미지 경로를 assets로 이동 2026-04-27 13:43:50 +09:00
e9f61b249e 파비콘 교체와 툴팁 표시 개선 2026-04-27 13:30:14 +09:00
e1c4a90249 사이트 메타 정보와 파비콘 추가 2026-04-27 13:22:34 +09:00
3dd3109c8b v0.1.56 - 타임테이블 안내와 달력 클릭 보정 2026-04-24 17:14:04 +09:00
23dcd1c778 v0.1.55 - 타임테이블 복사 메뉴와 사용 안내 추가 2026-04-24 16:52:56 +09:00
8d6ac66677 v0.1.54 - 타임테이블 날짜 복사와 모바일 폭 보정 2026-04-24 16:39:38 +09:00
ce5872c93c v0.1.53 - 관리자 목록 UX와 이월 배지 보정 2026-04-24 16:16:39 +09:00
315e0cdb0c v0.1.52 - handoff 정리와 이월 배지 보정 2026-04-24 16:09:59 +09:00
2ae172f0ce v0.1.51 - 관리자 사용자 상세 조회 추가 2026-04-24 12:35:25 +09:00
ec9a334035 v0.1.50 - 인증 재전송 버튼 조건 보정 2026-04-24 11:55:29 +09:00
39a731138b v0.1.49 - 인증 재전송 버튼 노출 조건 조정 2026-04-24 11:51:30 +09:00
0013f03bb3 v0.1.48 - 인증 메일 재전송과 TODO 정리 2026-04-24 11:47:58 +09:00
317a2ce8af v0.1.47 - 관리자 계정 정리 기능 추가 2026-04-24 11:43:22 +09:00
c442e0d8bb v0.1.46 - TODO 우선순위와 메일 정책 정리 2026-04-24 11:35:35 +09:00
6d4f2228cc v0.1.45 - 로그아웃 API와 세션 상태 안내 추가 2026-04-24 10:39:42 +09:00
684413a098 v0.1.44 - 날짜 기준과 TODO 정리 2026-04-24 10:10:34 +09:00
a38714dfe4 v0.1.43 - 인증 강제와 회원 탈퇴 흐름 정리 2026-04-24 10:04:44 +09:00
54f4b34e5e v0.1.62 - 운영 인증 링크 노출 제한 2026-04-23 18:01:48 +09:00
d59795b089 v0.1.61 - Resend 메일 발송 연결 2026-04-23 17:18:11 +09:00
8a46c507e9 v0.1.60 - 닉네임 중복 검증과 비밀번호 재설정 연결 2026-04-23 16:47:37 +09:00
a78ad7e8fb v0.1.59 - 랜딩 문구 표시 정리 2026-04-23 16:36:32 +09:00
477d453888 v0.1.58 - 1페이지 인쇄 A4 중앙 기준 정리 2026-04-23 16:32:42 +09:00
75abcd7333 v0.1.57 - 2페이지 인쇄 중앙 정렬 2026-04-23 16:31:30 +09:00
803ecb34ad v0.1.56 - 인쇄 모달 출력 제외 2026-04-23 16:28:35 +09:00
c1c7288127 v0.1.55 - 기간 인쇄 모달 추가 2026-04-23 16:23:27 +09:00
cb309cf0fa v0.1.54 - 통계 그래프 반응형 폭 조정 2026-04-23 15:55:09 +09:00
e9839d85e2 v0.1.53 - 통계 기록 목록과 차트 드래그 보정 2026-04-23 15:40:21 +09:00
b80994d114 v0.1.52 - 이월 배지와 통계 설명 팝업 정리 2026-04-23 15:32:48 +09:00
6c69658d33 v0.1.51 - 통계 차트와 플래너 하단 정렬 보정 2026-04-23 15:20:43 +09:00
bebd8ed8a6 v0.1.50 - 이월 할 일 흐름과 사이드 패널 정리 2026-04-23 15:01:50 +09:00
6d9aa2c002 v0.1.49 - 데모 페이지 스크롤 구조 단순화 2026-04-23 13:56:19 +09:00
33ba9a2ab1 v0.1.48 - 데모 읽기 전용 스크롤 수정 2026-04-23 13:52:45 +09:00
ff20e90768 v0.1.47 - 데모 스크롤과 2페이지 보기 보정 2026-04-23 13:51:06 +09:00
744202077f v0.1.46 - 비로그인 데모 화면 추가 2026-04-23 13:48:40 +09:00
e847ddd227 v0.1.45 - 로그인 유지 옵션과 랜딩 문구 정리 2026-04-23 13:42:53 +09:00
1f2d9ddc54 v0.1.44 - 로그인 화면 모바일 정리 2026-04-23 13:39:16 +09:00
44932f9724 v0.1.43 - 플래너 라벨과 가이드 동작 정리 2026-04-23 13:34:48 +09:00
4a80721824 v0.1.42 - 관리자 자동 계정과 로그인 정리 2026-04-22 18:44:15 +09:00
403d0a0c5a v0.1.41 - 배포 설정과 계정 화면 정리 반영 2026-04-22 18:39:05 +09:00
8f96c22c6d v0.1.40 - 관리자 대시보드 기본 구조 추가 2026-04-22 18:38:31 +09:00
b18af56c3c v0.1.39 - 달력 원형과 D-DAY 표시 상태 보정 2026-04-22 18:13:50 +09:00
3c3b0d20dd v0.1.38 - 인쇄 출력 보정과 인증 토큰 기반 확장 2026-04-22 18:02:23 +09:00
bf62eb12bb v0.1.37 - 총 시간 한글화와 인쇄 타임테이블 보정 2026-04-22 17:53:10 +09:00
370c169c1c v0.1.37 - 총 시간 한글화와 인쇄 타임테이블 보정 2026-04-22 17:49:01 +09:00
249a68e89d v0.1.36 - 사이드 소개 영역 서비스 톤으로 정리 2026-04-22 17:44:41 +09:00
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
34 changed files with 5377 additions and 684 deletions

13
.env.example Normal file
View File

@@ -0,0 +1,13 @@
POSTGRES_DB=ten_minute_planner
POSTGRES_USER=replace-with-private-db-user
POSTGRES_PASSWORD=replace-with-private-db-password
DATABASE_URL=postgresql://replace-with-private-db-user:replace-with-private-db-password@postgres:5432/ten_minute_planner
ADMIN_ACCOUNT_ID=replace-with-private-admin-id
ADMIN_ACCOUNT_PASSWORD=replace-with-private-admin-password
ADMIN_ACCOUNT_EMAIL=admin@example.com
ADMIN_ACCOUNT_NICKNAME=Planner Admin
APP_BASE_URL=https://planner.sori.studio
RESEND_API_KEY=replace-with-private-resend-api-key
MAIL_FROM_EMAIL=planner@sori.studio
MAIL_FROM_NAME=10 Minute Planner
AUTH_PREVIEW_LINKS=false

2
.gitignore vendored
View File

@@ -3,5 +3,7 @@ backend/node_modules/
dist/ dist/
.DS_Store .DS_Store
*.log *.log
.env
.env.dev
backend/.env backend/.env
backend/data/ backend/data/

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.25` 준비 중 - 현재 기준 버전: `v0.1.56`
- Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git` - Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git`
## 기준 디자인 ## 기준 디자인
@@ -38,6 +38,7 @@
- 백엔드 Dockerfile: `backend/Dockerfile` - 백엔드 Dockerfile: `backend/Dockerfile`
- nginx 프록시 설정: `deploy/nginx/default.conf` - nginx 프록시 설정: `deploy/nginx/default.conf`
- 실행 가이드 문서: `README.md` - 실행 가이드 문서: `README.md`
- 사이트 기본 메타 태그와 파비콘/링크 이미지: `index.html`, `public/assets/favicon.png`, `public/assets/og-image.png`
- Tailwind 설정은 완료되어 있으며, 이 프로젝트의 스타일링 기준으로 유지한다. - Tailwind 설정은 완료되어 있으며, 이 프로젝트의 스타일링 기준으로 유지한다.
- 현재 선택 날짜는 시스템 날짜 기준으로 시작한다. - 현재 선택 날짜는 시스템 날짜 기준으로 시작한다.
- `COMMENT`, `TASKS`, `MEMO`는 화면에서 바로 편집할 수 있다. - `COMMENT`, `TASKS`, `MEMO`는 화면에서 바로 편집할 수 있다.
@@ -49,11 +50,17 @@
- `TIME TABLE`은 우클릭 드래그 시 선택된 블록을 지우는 방식으로도 편집할 수 있다. - `TIME TABLE`은 우클릭 드래그 시 선택된 블록을 지우는 방식으로도 편집할 수 있다.
- `TIME TABLE` 숫자 영역은 선택/드래그로 텍스트가 잡히지 않도록 막아두었다. - `TIME TABLE` 숫자 영역은 선택/드래그로 텍스트가 잡히지 않도록 막아두었다.
- `TOTAL TIME`은 타임테이블에서 선택된 블록 수를 기준으로 자동 계산된다. - `TOTAL TIME`은 타임테이블에서 선택된 블록 수를 기준으로 자동 계산된다.
- `TIME TABLE` 라벨은 왼쪽 클릭 시 현재 날짜 타임테이블을 복사하고, 오른쪽 클릭 시 붙여넣기 메뉴를 연다.
- `TIME TABLE` 라벨 오른쪽의 `?` 아이콘으로 복사/붙여넣기 사용법을 바로 볼 수 있다.
- 타임테이블 복사/붙여넣기 결과는 오른쪽 아래 상태 토스트로 바로 안내한다.
- 모바일과 태블릿처럼 `TIME TABLE`이 아래로 내려가는 구간에서는 6칸 그리드가 남는 폭을 더 넓게 채우도록 조정했다.
- 미니 달력의 월 이동, 연도 선택, 날짜 버튼은 `mousedown.prevent`로 포커스만 잡히고 실제 이동은 두 번째 클릭에 되는 느낌을 줄이도록 보정했다.
- 달력은 연/월 이동이 가능하며, 현재 보이는 월과 선택된 날짜 상태를 분리해서 관리한다. - 달력은 연/월 이동이 가능하며, 현재 보이는 월과 선택된 날짜 상태를 분리해서 관리한다.
- 달력 상단은 월 좌우 화살표, 클릭형 연도 선택, `TODAY` 버튼 구조로 동작한다. - 달력 상단은 월 좌우 화살표, 클릭형 연도 선택, `TODAY` 버튼 구조로 동작한다.
- 입력 내용이 있는 날짜는 달력 하단에 빨간 점으로 표시된다. - 입력 내용이 있는 날짜는 달력 하단에 빨간 점으로 표시된다.
- 상단 날짜 표시에서는 토요일의 `(토)`만 파란색, 일요일의 `(일)`만 빨간색으로 표시한다. - 상단 날짜 표시에서는 토요일의 `(토)`만 파란색, 일요일의 `(일)`만 빨간색으로 표시한다.
- 플래너 상태는 `localStorage`에 저장되며, 날짜별 기록과 선택 날짜, 달력 보고 있던 월까지 복원다. - 플래너 상태는 `localStorage`에 저장되며, 날짜별 기록과 통계 범위 같은 UI 상태를 복원다.
- 플래너 기록 자체는 로컬에 남기되, 앱을 새로 열 때 기본 기준 날짜는 마지막 열람 날짜가 아니라 항상 오늘 날짜로 맞춘다.
- `localStorage` 접근 로직은 `src/lib/plannerStorage.js`로 분리하기 시작했고, 이후 API/DB adapter로 교체하기 쉬운 구조로 정리 중이다. - `localStorage` 접근 로직은 `src/lib/plannerStorage.js`로 분리하기 시작했고, 이후 API/DB adapter로 교체하기 쉬운 구조로 정리 중이다.
- 프론트는 헤더에서 `LOGIN` / `SIGN UP` 모달을 열 수 있고, 로그인 상태면 닉네임과 `LOGOUT` 버튼을 표시한다. - 프론트는 헤더에서 `LOGIN` / `SIGN UP` 모달을 열 수 있고, 로그인 상태면 닉네임과 `LOGOUT` 버튼을 표시한다.
- 인증 토큰과 현재 사용자 정보는 프론트 로컬 저장소에 따로 유지하고, 앱 시작 시 `/api/auth/me`로 세션 복원을 시도한다. - 인증 토큰과 현재 사용자 정보는 프론트 로컬 저장소에 따로 유지하고, 앱 시작 시 `/api/auth/me`로 세션 복원을 시도한다.
@@ -80,6 +87,9 @@
- 현재 `1-UP`은 프레임 자체를 A4 세로에 가깝게 키우고 배율을 크게 올려 빈 여백을 줄이는 방향으로 맞추고 있다. - 현재 `1-UP`은 프레임 자체를 A4 세로에 가깝게 키우고 배율을 크게 올려 빈 여백을 줄이는 방향으로 맞추고 있다.
- 백엔드 초안은 `Fastify + Drizzle + PostgreSQL` 조합으로 전환되었다. - 백엔드 초안은 `Fastify + Drizzle + PostgreSQL` 조합으로 전환되었다.
- 백엔드에는 `/api/auth/signup`, `/api/auth/login`, `/api/auth/me`가 추가되었다. - 백엔드에는 `/api/auth/signup`, `/api/auth/login`, `/api/auth/me`가 추가되었다.
- 백엔드에는 `/api/auth/verification/request`, `/api/auth/verification/confirm` 이메일 인증 토큰 API가 추가되었다.
- 백엔드에는 `/api/auth/password-reset/request`, `/api/auth/password-reset/confirm` 비밀번호 재설정 토큰 API가 추가되었다.
- 백엔드에는 `/api/admin/overview` 관리자 요약 API가 추가되었다.
- 백엔드에는 `/api/planner/:entryDate` 단건 조회/저장과 `/api/planner?from=...&to=...` 범위 조회가 추가되었다. - 백엔드에는 `/api/planner/:entryDate` 단건 조회/저장과 `/api/planner?from=...&to=...` 범위 조회가 추가되었다.
- 비밀번호는 Node 내장 `crypto.scrypt` 기반 해시로 저장하고, 세션 토큰은 `auth_sessions` 테이블에 해시 형태로 저장한다. - 비밀번호는 Node 내장 `crypto.scrypt` 기반 해시로 저장하고, 세션 토큰은 `auth_sessions` 테이블에 해시 형태로 저장한다.
- 초기 실행 시 `backend/src/db/init.js`에서 테이블이 없으면 자동 생성하도록 맞춰두었다. - 초기 실행 시 `backend/src/db/init.js`에서 테이블이 없으면 자동 생성하도록 맞춰두었다.
@@ -117,56 +127,64 @@
## 다음 권장 작업 ## 다음 권장 작업
- `TODO.md` 기준으로 작은 단위씩 구현을 진행한다. - 목표 화면에 완료 처리와 보관 상태를 분리해서, 진행 중 목표와 지난 목표를 더 명확하게 나눈다.
- 목표나 통계 기능보다 먼저, 플래너 본문의 입력과 상호작용을 우선 구현한다. - `READ NEXT`의 자동 제안 규칙을 더 자연스럽게 다듬고, 빈 상태 문구도 상황별로 정리한다.
- 통계 화면 구현은 현재 `localStorage`으로 먼저 진행해도 된다. - 공유용 이미지 저장 기능을 인쇄 레이아웃과 같은으로 설계하고 구현한다.
- DB는 기능 탐색 속도를 해치지 않는 선에서, 저장 레이어를 분리할 수 있는 적절한 시점에 붙이는 것이 좋다. - 로그인/인증 관련 rate limit 정책을 정해서 무차별 대입 시도를 방어한다.
- 현재 기준 추천 백엔드 방향은 `Vue 프론트엔드 + Node.js API + PostgreSQL`다. - 배포용 `docker-compose.yml`은 현재 PostgreSQL 외부 포트 `45432`, 프론트 외부 포트 `48081` 기준이며, DB 계정/비밀번호는 루트 `.env`에서 주입한다.
- Docker 배포를 시작하는 시점이므로 SQLite보다 PostgreSQL을 기본 저장소로 유지하는 편이 낫다. - 설정 화면의 보조 메모 카드는 주석 처리되어 현재는 보이지 않는다.
- 현재 인증 방식은 Bearer 토큰 기반의 간단한 세션 구조이며, 추후 쿠키/리프레시 토큰 전략으로 확장할 수 있다.
- 다음 프론트 단계에서는 `src/lib/plannerStorage.js`를 유지하되, 인증 이후 백엔드 저장소 adapter를 추가해서 `localStorage`와 전환 가능하게 만드는 흐름이 좋다.
- 현재 프론트는 인증만 연결된 상태이고, 플래너 저장은 아직 `localStorage` 기준이다.
- 로그인 상태에서는 플래너 수정 시 날짜별 서버 저장을 예약하고, 로그인 직후에는 서버 데이터를 먼저 가져오도록 연결하기 시작했다.
- 현재는 로컬 저장도 계속 유지하면서 서버 저장을 병행하는 과도기 구조다.
- 로그인 시 서버 플래너 데이터로 `plannerRecords`를 교체하고, 로그아웃 시에는 로컬 저장 기반 데이터로 다시 복귀하도록 정리했다.
- 이로 인해 다른 사용자 로그인 시 이전 로컬 데이터가 서버 계정 데이터와 섞일 위험을 줄였다.
- 로그인 상태에서 특정 날짜의 플래너 내용을 완전히 비우면, 서버 저장 대신 해당 날짜 엔트리를 삭제하도록 정리했다.
- 현재는 로그인 전 플래너 진입을 막고, 인증 후에만 실제 플래너/통계 화면을 사용하도록 변경했다.
- 클라우드 저장 상태는 헤더가 아니라 오른쪽 하단의 작은 토스트 형태로 표시되도록 변경했다.
- 저장 완료 토스트는 한 줄짜리의 작은 상태 문구로 줄여서 존재감을 낮췄다.
- D-DAY는 플래너 안에서 목표를 고르는 구조가 아니라, GOALS 화면에서 목표와 표시 기간을 먼저 설정하는 구조로 정리했다.
- 플래너 화면 오른쪽 패널에서는 현재 날짜에 적용된 목표가 있을 때만 D-DAY 토글을 켤 수 있고, 목표 검색 UI는 제거했다.
- 목표는 `active_from`, `active_until` 기간을 가질 수 있고, 현재 날짜가 그 범위에 들어올 때만 플래너 본문 D-DAY 후보가 된다.
- TASK LABELS도 별도 버튼 2개 대신 동일한 토글 UI로 단순화했다. ON이면 01~15를 채우고 OFF이면 비운다.
- SETTINGS 화면이 추가되어 닉네임, 이메일, 비밀번호 변경을 분리해서 관리할 수 있다.
- 백엔드에는 `/api/auth/profile`, `/api/auth/password`, `/api/goals/:goalId` 수정 API가 추가되었다.
- 백엔드에는 `/api/goals/:goalId` 삭제 API도 추가되었다.
- 왼쪽 사이드, 플래너 본문 래퍼, 오른쪽 정보 패널 모두 둥근 카드 톤으로 맞춰서 화면 전체의 통일감을 높였다.
- 플래너 집중 보기에서는 본문과 오른쪽 패널이 각각 독립 스크롤되도록 바뀌어서 동시에 참조하기 쉽다.
- TASK LABELS, D-DAY 토글은 공통 사이즈의 스위치로 통일했고, `translate` 기반 애니메이션으로 부드럽게 움직이게 했다.
- 목표 생성 폼은 기본적으로 `표시 시작일 = 오늘`, `표시 종료일 = 목표일` 흐름으로 자동 채워진다.
- 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...` 오류는 이 문제였다.
- 비로그인 랜딩 카드는 상단 고정이 아니라 화면 중앙에 오도록 정렬을 수정했다. - 비로그인 랜딩 카드는 상단 고정이 아니라 화면 중앙에 오도록 정렬을 수정했다.
- 로그인/회원가입 문구와 플레이스홀더는 일반 사용자 기준으로 정리했고, 사이드바 계정 카드의 `ADMIN/USER` 역할 텍스트는 숨겼다.
- 오른쪽 정보 패널에 `미완료 항목 이월` 버튼을 추가했다. 현재 날짜의 체크 안 된 할 일을 다음 날짜의 비어 있는 할 일 칸에 순서대로 복사한다.
- TASK 체크 버튼은 키보드 탭 이동 시 focus ring이 보이도록 개선했다. TASK 행은 클릭만으로 선택되지 않고, 포인터를 누른 채 다른 행까지 드래그했을 때부터 전역 pointer move 기준으로 여러 행을 선택한다. input에서 드래그를 시작해도 두 번째 행으로 넘어가면 기존 input 포커스를 blur 처리해 `Delete` / `Backspace`는 선택한 할 일 제목과 체크 상태를 한 번에 비우고, `Escape`는 선택만 해제하도록 했다.
- 인쇄 전용 CSS에서 COMMENT와 총 시간 영역의 폭을 고정하고 textarea overflow를 숨겨, PRINT 시 COMMENT가 우측 시간 영역과 겹치지 않도록 보정했다.
- 오른쪽 패널의 `TASK LABELS``D-DAY 사용`은 한 카드 안의 두 줄 토글로 압축했다. 설명 문구는 공통 `GuideTooltip` 컴포넌트로 옮겼고, 물음표 버튼 클릭으로 열고 다시 클릭하거나 외부 클릭으로 닫는다. 각 툴팁은 `더 이상 보지 않기`로 숨길 수 있으며 SETTINGS의 `가이드 다시 보기`에서 전체 복원할 수 있다.
- 오른쪽 패널의 `READ NEXT``PREV SNAPSHOT`은 별도 가로 카드가 아니라 `NEXT DAY` 카드 아래쪽에 세로로 배치했다.
- READ NEXT는 내일 첫 작업과 오늘 미처리 할 일 개수만 보여주도록 줄였고, 오늘 코멘트 반복 노출은 제거했다.
- 플래너 본문 시간 라벨은 `총 시간`에서 `FOCUSED TIME`으로 바꿨다. 인쇄 CSS에서 COMMENT/FOCUSED TIME 라벨이 잘리지 않도록 부모 overflow를 열고, COMMENT는 남는 폭을 채우며 FOCUSED TIME은 오른쪽 210px 칸에 붙도록 조정했다.
- Docker 배포용 루트 `.env`와 개발용 `.env.dev`를 분리했다. `docker-compose.yml`은 운영 `.env`, `docker-compose.dev.yml`은 기존 dev Postgres 볼륨과 맞는 `.env.dev`를 읽는다.
- STATS의 `RANGE FLOW`는 항목 수에 따라 막대 폭과 간격을 조정한다. 1주일 내외는 넓은 막대로 카드 폭을 채우고, 1달 내외는 좁은 막대와 요약 라벨로 한 화면에서 흐름을 보며, 세부 날짜와 집중 시간은 막대 hover 팝업으로 확인한다.
- 왼쪽 사이드바의 인쇄 영역은 `PRINT` 버튼 하나로 줄이고, 클릭 시 모달에서 시작일/종료일과 `1페이지씩` 또는 `2페이지씩` 출력 방식을 선택한다. 인쇄 전용 렌더링은 선택 기간의 날짜를 순서대로 여러 장 생성하며, 2페이지씩 출력에서 홀수 날짜가 남으면 오른쪽은 빈 페이지 프레임으로 둔다.
- 회원가입과 프로필 수정 시 이메일뿐 아니라 닉네임 중복도 서버에서 409로 막는다. 비밀번호 재설정 API는 로그인 모달의 `비밀번호 찾기` 흐름과 연결되어 있고, `/reset-password?token=...` URL로 들어오면 새 비밀번호 설정 모드가 열린다. 실제 메일 발송 전까지는 백엔드 응답의 `resetPreviewUrl`을 개발용 링크로 표시한다.
- Resend 메일러가 추가되었다. `RESEND_API_KEY`, `MAIL_FROM_EMAIL`, `MAIL_FROM_NAME`, `APP_BASE_URL` 환경변수를 설정하면 이메일 인증과 비밀번호 재설정 메일을 실제로 발송한다. API 키는 저장소에 커밋하지 말고 루트 `.env`/`.env.dev`에만 넣는다.
- 메일 발송 인프라는 현재 Resend 기준으로 확정했다. 자동 로그아웃 옵션은 플래너를 오래 띄워두는 사용 흐름과 맞지 않아 TODO 대상에서 제외했다.
- 인증/비밀번호 재설정 개발용 링크는 `AUTH_PREVIEW_LINKS=true`일 때만 API 응답에 포함된다. 운영 `.env`는 반드시 `AUTH_PREVIEW_LINKS=false`로 두어야 하며, 현재 예시 파일도 false가 기본값이다.
- 회원가입은 이제 자동 로그인되지 않는다. 인증 메일 발송 후 `이메일 인증 후 로그인` 안내만 보여주고, 일반 사용자는 이메일 인증 전까지 로그인할 수 없다.
- 기존에 발급된 세션이라도 일반 사용자 이메일 인증이 안 되어 있으면 `/api/auth/me` 단계에서 세션을 즉시 폐기한다. 운영 중 인증 정책을 켠 뒤에도 미인증 세션이 남지 않게 하기 위한 장치다.
- 비밀번호 재설정/이메일 인증용 개발 링크는 `AUTH_PREVIEW_LINKS=true`여도 `APP_BASE_URL``localhost` 또는 `127.0.0.1`일 때만 응답에 포함한다. 상용 서버에서 링크가 그대로 보이면 이 조건부터 확인한다.
- 프론트는 `/verify-email?token=...` 진입 시 인증을 바로 확정하고 로그인 모달에 결과 메시지를 띄운다. `/reset-password?token=...`은 기존처럼 비밀번호 재설정 모달을 연다.
- 로그인 모달에는 `이메일 인증 메일 다시 보내기` 버튼이 추가되었다. 이메일 주소를 입력한 상태에서 바로 인증 메일 재전송을 요청할 수 있어, 가입 후 메일을 못 받은 사용자가 로그인 단계에서 막히지 않게 했다.
- 인증 메일 재전송 버튼은 항상 보이지 않는다. 로그인 모달에서 이메일 인증 관련 안내 메시지가 보일 때만 메시지 박스 오른쪽에 함께 노출한다.
- 인증 메일 재전송 버튼은 현재 `이메일 인증을 완료한 뒤 로그인해 주세요.` 안내가 있을 때만 표시한다. 인증 완료 메시지나 일반 안내 문구에서는 보이지 않도록 조건을 좁혔다.
- SETTINGS 화면에 일반 사용자 전용 `회원 탈퇴` 카드가 추가되었다. 현재 비밀번호 확인 후 계정, 플래너 기록, 목표, 세션, 인증 토큰이 함께 삭제된다. 기본 관리자 계정은 이 경로에서 삭제하지 못하게 막는다.
- `/api/auth/logout`이 추가되어 로그아웃 시 프론트 저장 토큰만 지우는 것이 아니라 서버 세션도 함께 폐기한다.
- SETTINGS 화면 왼쪽 카드에 현재 기기 로그인 유지 방식(`로그인 유지` 또는 브라우저 세션만 유지), 최근 로그인 시각, 이메일 인증 상태를 보여준다.
- 현재 로그인 유지 방식은 `authPersist` 상태로 함께 들고 가며, 프로필 저장 후에도 원래 저장 방식(localStorage/sessionStorage)을 유지하도록 정리했다.
- 사용자 테이블에 `disabledAt` 컬럼이 추가되었다. 관리자가 비활성화한 계정은 즉시 현재 세션이 모두 종료되고, 이후 로그인도 차단된다.
- 관리자 화면에서 일반 사용자 계정에 대해 `비활성화/다시 허용`, `강제 로그아웃`, `삭제`를 실행할 수 있다. 관리자 계정은 이 화면에서 비활성화하거나 삭제하지 못하게 막는다.
- 관리자 사용자 목록에는 현재 활성 세션 수와 비활성화 상태가 함께 표시된다.
- `5173` 포트가 직접 `npm run dev` 없이 열려 있으면 대부분 `ten-minute-frontend-dev` 컨테이너가 떠 있는 상태다. 개발용 Docker를 끄려면 `docker compose -f docker-compose.dev.yml down`을 사용한다.
- 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다. - 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다.
- 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다. - 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다.
- Docker Compose는 프론트엔드와 백엔드를 함께 올리는 기준으로 설계하되, NAS 환경에 맞는 볼륨과 재시작 정책도 함께 고려한다. - Docker Compose는 프론트엔드와 백엔드를 함께 올리는 기준으로 설계하되, NAS 환경에 맞는 볼륨과 재시작 정책도 함께 고려한다.
- Docker Compose 개발/배포 파일에는 `TZ=Asia/Seoul`을 명시해 NAS와 컨테이너 내부 기준 시간이 한국 시간으로 맞도록 유지한다.
- 플래너 본문 라벨은 더 이상 `bg-paper` 배경으로 선을 덮지 않는다. `라벨 + 오른쪽 선` 구조로 바꿔 화면과 인쇄에서 노란 배경이 튀지 않도록 정리했다.
- 날짜에 적용되는 목표가 새로 생기면 D-DAY는 기본 표시된다. 사용자가 해당 날짜에서 직접 `D-DAY 사용`을 끈 경우에만 로컬 숨김 목록에 저장해 다시 숨긴다.
- 비로그인 랜딩은 모바일에서 `카드 안 카드`처럼 보이지 않도록 기능 설명 카드를 얇은 리스트로 단순화했고, `LOGIN` / `SIGN UP` 버튼은 같은 너비와 높이로 맞췄다. 로그인/회원가입 모달도 하단 전환 영역을 별도 카드 대신 구분선 형태로 정리했다.
- 로그인 모달에 `로그인 유지` 체크박스를 추가했다. 기본값은 OFF이며, OFF 상태에서는 인증 토큰을 `sessionStorage`에 저장해 브라우저 세션이 끝나면 사라지고, ON 상태에서만 `localStorage`에 저장한다.
- 현재 로그아웃은 `/api/auth/logout`으로 서버 세션까지 함께 폐기한다. 자동 로그아웃 옵션은 플래너를 오래 열어두는 사용 흐름과 맞지 않아 추가 대상에서 제외했다.
- 비로그인 랜딩에 `DEMO VIEW`를 추가했다. 데모는 실제 저장/로그인 상태와 분리된 읽기 전용 샘플이며, 어제/오늘/내일 3일치 플래너를 전환해서 제품 감각을 먼저 볼 수 있다.
- 플래너 본문 `MEMO``TIME TABLE` 하단 높이를 맞추기 위해 TASK/MEMO 리스트 간격과 행 높이를 조정했다. TASK 드래그 선택 피드백은 레이아웃 흔들림을 줄이도록 ring 대신 배경색만 사용한다.
- 이월된 할 일은 `carryoverFrom` 날짜를 가진다. TASK 본문에는 `이월` 배지를 표시하고, 클릭하면 오른쪽 `READ NEXT` 영역에 원래 시작 날짜를 안내한다.
- 이월된 할 일을 완료할 때는 이전 날짜의 같은 이월 항목까지 모두 체크할지, 현재 날짜만 체크할지 선택한다. 기본값은 `항상 물어보기`이며, SETTINGS의 `CARRYOVER CHECK`에서 `항상 이전까지 체크` / `항상 오늘만 체크`로 바꿀 수 있다.
- 오른쪽 플래너 사이드바의 중복 `STATS` 카드는 제거했다. 미완료 항목 이월 버튼은 `READ NEXT` 카드 아래로 이동했다.
- 통계 화면은 진입 시 END DATE를 오늘로 보정한다. `최근 1주`, `최근 1달` 빠른 선택을 추가했고, 기존 `WEEKLY FLOW`는 선택 범위 안에서 기록이 있는 날짜별 집중 흐름을 보여주는 `RANGE FLOW`로 이름과 라벨을 정리했다.
- `BEST DAY`는 선택 기간 안에서 집중 시간이 가장 긴 날짜를 고르고, `RECENT RECORDS`는 선택 기간 안의 기록을 날짜 내림차순으로 최대 5개 보여준다.
- `CARRYOVER TASK` 선택 모달은 ESC로 닫힌다. 이월 배지의 시작일 안내는 오른쪽 패널 메시지 대신 배지 옆 팝업으로 표시한다.
- 통계의 `BEST DAY`, `RECENT RECORDS` 기준 설명은 본문 문장 대신 물음표 가이드 팝업으로 제공한다.
- 운영 링크 미리보기용 title/description/Open Graph/Twitter Card 메타 태그를 추가했다. 루트 PNG 요청이 앞단 프록시에서 403이 나는 환경을 피하기 위해 파비콘은 `/assets/favicon.png`, 링크 카드 이미지는 `/assets/og-image.png` 경로로 제공한다.
- 모바일 focus 화면에서는 플래너 바깥 래핑 카드의 padding을 제거해 플래너 자체 여백만 남긴다.
- 모바일 타임테이블은 일반 터치/스크롤 중에는 칠하지 않고, 약 420ms 롱터치가 성립된 뒤에만 드래그 편집을 시작한다.
## 갱신 규칙 ## 갱신 규칙

136
README.md
View File

@@ -2,6 +2,129 @@
Vue 3 + TailwindCSS + Fastify + PostgreSQL 기반의 `10분 플래너 다이어리` 프로젝트다. Vue 3 + TailwindCSS + Fastify + PostgreSQL 기반의 `10분 플래너 다이어리` 프로젝트다.
## 시작 전에
이 프로젝트는 `Docker``Docker Compose`가 설치된 환경을 기준으로 실행한다.
NAS나 서버에서 처음 올리는 경우 흐름은 아래처럼 생각하면 된다.
1. 프로젝트를 받을 폴더로 이동한다.
2. `git clone`으로 저장소를 내려받는다.
3. 내려받은 프로젝트 폴더로 들어간다.
4. `docker compose`로 컨테이너를 빌드하고 실행한다.
예시:
```bash
cd /volume1/docker
git clone https://git.sori.studio/zenn/planner.sori.studio.git .
cd planner.sori.studio
cp .env.example .env
docker compose up -d --build
```
처음 한 번은 이미지 빌드 때문에 시간이 걸릴 수 있다.
실행 전에 `.env`의 DB와 관리자 계정 환경변수는 운영자만 아는 값으로 반드시 바꾼다.
## 초보자용 빠른 실행
### 1. 프로젝트 받기
원하는 작업 폴더로 이동한 뒤 저장소를 내려받는다.
```bash
cd /원하는/폴더
git clone https://git.sori.studio/zenn/planner.sori.studio.git
cd planner.sori.studio
```
### 2. 배포용으로 바로 실행하기
실제 동작 확인이나 NAS 상시 실행은 아래 명령으로 시작한다.
```bash
cp .env.example .env
# .env에서 POSTGRES_*, DATABASE_URL, ADMIN_ACCOUNT_* 값을 비공개 운영 값으로 수정한다.
docker compose up -d --build
```
의미:
- `up`: 컨테이너를 실행한다.
- `-d`: 백그라운드에서 실행한다.
- `--build`: 이미지가 없거나 코드가 바뀌었을 때 다시 빌드한다.
브라우저 접속 주소:
- 프론트엔드: `http://NAS주소:48081`
- PostgreSQL: `NAS주소:45432`
관리자 계정은 백엔드 시작 시 `.env``ADMIN_ACCOUNT_*` 값으로 자동 생성된다.
관리자 아이디와 비밀번호는 저장소 문서에 적지 말고, 운영자만 접근 가능한 비공개 환경변수 파일에서 관리한다.
일반 사용자는 회원가입 후 이메일 인증을 완료해야 로그인할 수 있다.
운영 서버에서는 비밀번호 재설정/이메일 인증용 미리보기 링크가 API 응답에 노출되지 않도록 유지한다.
현재 `docker-compose.yml` 기준 내부 구성:
- 프론트엔드 nginx
- 백엔드 Fastify
- PostgreSQL
- 배포/개발 컨테이너 시간대: `Asia/Seoul`
### 3. 실행 상태 확인하기
```bash
docker compose ps
```
로그를 보고 싶으면:
```bash
docker compose logs -f
```
특정 서비스만 보고 싶으면:
```bash
docker compose logs -f frontend
docker compose logs -f backend
docker compose logs -f postgres
```
### 4. 종료하기
```bash
docker compose down
```
데이터베이스 볼륨까지 완전히 지우고 처음부터 다시 시작하고 싶을 때만 아래 명령을 사용한다.
```bash
docker compose down -v
```
주의:
- `-v`는 PostgreSQL 데이터까지 지울 수 있으니 정말 초기화가 필요할 때만 사용한다.
### 5. 코드 수정 후 다시 반영하기
배포용 compose는 코드가 자동 반영되지 않는다.
코드를 수정했다면 프로젝트 폴더 안에서 다시 실행한다.
```bash
docker compose up -d --build
```
즉, NAS에서 배포용으로 돌릴 때는 보통 아래 순서를 반복한다.
```bash
cd /volume1/docker/planner.sori.studio
git pull
docker compose up -d --build
```
## 실행 방법 ## 실행 방법
### 개발용 ### 개발용
@@ -12,6 +135,15 @@ Vue 3 + TailwindCSS + Fastify + PostgreSQL 기반의 `10분 플래너 다이어
docker compose -f docker-compose.dev.yml up docker compose -f docker-compose.dev.yml up
``` ```
개발용도 처음 시작할 때는 아래처럼 프로젝트 폴더 안에서 실행하면 된다.
```bash
cd /원하는/폴더
git clone https://git.sori.studio/zenn/planner.sori.studio.git
cd planner.sori.studio
docker compose -f docker-compose.dev.yml up
```
개발용 포트: 개발용 포트:
- 프론트엔드: `http://localhost:5173` - 프론트엔드: `http://localhost:5173`
@@ -34,8 +166,8 @@ docker compose up -d --build
배포용 포트: 배포용 포트:
- 프론트엔드: `http://localhost:8080` - 프론트엔드: `http://localhost:48081`
- PostgreSQL: `localhost:5432` - PostgreSQL: `localhost:45432`
배포용 특징: 배포용 특징:

63
TODO.md
View File

@@ -18,6 +18,7 @@
- [x] `TIME TABLE`을 마우스 드래그로 칠할 수 있게 만든다. - [x] `TIME TABLE`을 마우스 드래그로 칠할 수 있게 만든다.
- [x] `TIME TABLE` 드래그가 여러 줄을 지나가더라도 시간 흐름 기준으로 연속 선택되도록 처리한다. - [x] `TIME TABLE` 드래그가 여러 줄을 지나가더라도 시간 흐름 기준으로 연속 선택되도록 처리한다.
- [x] 선택된 `TIME TABLE` 구간을 기준으로 `TOTAL TIME`을 자동 계산한다. - [x] 선택된 `TIME TABLE` 구간을 기준으로 `TOTAL TIME`을 자동 계산한다.
- [x] 원하는 날짜의 `TIME TABLE`을 다른 날짜로 복사할 수 있게 한다.
## 2단계: 달력과 이동 기능 ## 2단계: 달력과 이동 기능
@@ -33,6 +34,7 @@
- [x] 달력 상단은 좌우 화살표로 월 이동하는 구조가 더 적합하다. - [x] 달력 상단은 좌우 화살표로 월 이동하는 구조가 더 적합하다.
- [x] 연도 클릭 시 연도 선택 UI가 열려야 한다. - [x] 연도 클릭 시 연도 선택 UI가 열려야 한다.
- [x] 오늘 날짜로 즉시 돌아가는 버튼이 필요하다. - [x] 오늘 날짜로 즉시 돌아가는 버튼이 필요하다.
- [x] 모바일에서 플래너 래핑 카드 여백을 줄이고 타임테이블은 롱터치 후 드래그로만 편집되게 한다.
## 3단계: 목표와 회고 기능 ## 3단계: 목표와 회고 기능
@@ -46,21 +48,19 @@
- [x] 목표별로 D-DAY 표시 시작일과 종료일을 설정할 수 있게 한다. - [x] 목표별로 D-DAY 표시 시작일과 종료일을 설정할 수 있게 한다.
- [x] 목표 표시 기간이 서로 겹치면 저장되지 않도록 막는다. - [x] 목표 표시 기간이 서로 겹치면 저장되지 않도록 막는다.
- [ ] 목표 완료 처리와 보관 상태를 구분한다. - [ ] 목표 완료 처리와 보관 상태를 구분한다.
- [ ] 목표 편집/삭제 UI를 추가한다. - [x] 목표 편집/삭제 UI를 추가한다.
- [ ] 목표 목록 정렬 규칙과 검색 UX를 다듬는다. - [ ] 목표 목록 정렬 규칙과 검색 UX를 다듬는다.
## 4단계: 데이터 구조와 저장 ## 4단계: 데이터 구조와 저장
- [ ] 플래너 데이터 구조를 날짜별 상태 중심으로 정리한다. - [x] 플래너 데이터 구조를 날짜별 상태 중심으로 정리한다.
- [x] 입력 데이터의 저장 위치를 결정한다. - [x] 입력 데이터의 저장 위치를 결정한다.
- [x] 로컬 저장 또는 외부 저장 방식 중 우선 구현 방식을 정한다. - [x] 로컬 저장 또는 외부 저장 방식 중 우선 구현 방식을 정한다.
- [x] 입력 상태가 새로고침 후에도 유지되도록 만든다. - [x] 입력 상태가 새로고침 후에도 유지되도록 만든다.
- [x] DB 전환 시점을 잡을 수 있도록 저장 레이어를 분리한다. - [x] DB 전환 시점을 잡을 수 있도록 저장 레이어를 분리한다.
- [ ] 회원 가입 및 로그인 구조를 고려한 사용자별 데이터 모델을 설계한다.
- [x] 회원 가입 및 로그인 구조를 고려한 사용자별 데이터 모델을 설계한다. - [x] 회원 가입 및 로그인 구조를 고려한 사용자별 데이터 모델을 설계한다.
- [ ] 사용자별 문서 저장/조회 흐름을 정리한다.
- [x] 사용자별 문서 저장/조회 흐름을 정리한다. - [x] 사용자별 문서 저장/조회 흐름을 정리한다.
- [ ] 출력용 문서 포맷과 프린트 흐름을 고려한 데이터 구조를 정리한다. - [x] 출력용 문서 포맷과 프린트 흐름을 고려한 데이터 구조를 정리한다.
## 추가 반영 메모 ## 추가 반영 메모
@@ -73,52 +73,37 @@
- [x] 통계 페이지 라우팅 또는 화면 전환 구조를 설계한다. - [x] 통계 페이지 라우팅 또는 화면 전환 구조를 설계한다.
- [x] 집중 시간, 완료율, 연속 기록 같은 핵심 지표를 정의한다. - [x] 집중 시간, 완료율, 연속 기록 같은 핵심 지표를 정의한다.
- [x] 사용자가 시작일과 종료일을 선택해서 기간별 통계를 볼 수 있게 한다. - [x] 사용자가 시작일과 종료일을 선택해서 기간별 통계를 볼 수 있게 한다.
- [ ] 사용자 개인 통계 화면 기준을 정리한다. - [x] 사용자 개인 통계 화면 기준을 정리한다.
## 6단계: 계정 및 서비스 확장 ## 6단계: 계정 및 서비스 확장
- [ ] 회원 가입 / 로그인 방식 후보를 정리한다.
- [x] 회원 가입 / 로그인 방식 후보를 정리한다. - [x] 회원 가입 / 로그인 방식 후보를 정리한다.
- [x] 사용자 설정 화면에서 닉네임 / 이메일 / 비밀번호 수정 흐름을 분리한다. - [x] 사용자 설정 화면에서 닉네임 / 이메일 / 비밀번호 수정 흐름을 분리한다.
- [x] 상단 헤더를 왼쪽 사이드 내비게이션 구조로 재배치한다. - [x] 상단 헤더를 왼쪽 사이드 내비게이션 구조로 재배치한다.
- [x] 본문과 오른쪽 패널이 각각 독립 스크롤되도록 조정한다. - [x] 본문과 오른쪽 패널이 각각 독립 스크롤되도록 조정한다.
- [ ] 사용자별 문서 분리 저장 구조를 설계한다. - [x] 사용자별 문서 분리 저장 구조를 설계한다.
- [ ] 공유가 아닌 개인 보관용 서비스 흐름으로 요구사항을 정리한다. - [x] 공유가 아닌 개인 보관용 서비스 흐름으로 요구사항을 정리한다.
- [x] 향후 출력 기능을 위한 인쇄 레이아웃 요구사항을 정리한다. - [x] 향후 출력 기능을 위한 인쇄 레이아웃 요구사항을 정리한다.
- [x] A4 가로 기준 2장 출력 모드를 지원한다. - [x] A4 가로 기준 2장 출력 모드를 지원한다.
- [x] `1-UP` 세로 인쇄 / `2-UP` 가로 인쇄 기준을 분리한다. - [x] `1-UP` 세로 인쇄 / `2-UP` 가로 인쇄 기준을 분리한다.
- [ ] 공유를 위한 이미지 저장 기능을 추가한다. - [ ] 공유를 위한 이미지 저장 기능을 추가한다.
- [x] Docker 배포 구조를 정리한다. - [x] Docker 배포 구조를 정리한다.
- [x] UGREEN NAS 기준 `docker-compose.yml` 초안을 작성한다. - [x] UGREEN NAS 기준 `docker-compose.yml` 초안을 작성한다.
- [x] 운영 링크 미리보기용 사이트 제목/소개글 메타 태그와 파비콘을 추가한다.
- [x] 백엔드 기본 스캐폴딩을 추가한다. - [x] 백엔드 기본 스캐폴딩을 추가한다.
- [x] PostgreSQL 전환 초안을 적용한다. - [x] PostgreSQL 전환 초안을 적용한다.
- [x] 로그인 화면 문구와 관리자 정보 노출 지점을 일반 사용자 기준으로 정리한다.
## 메모 - [x] 비로그인 사용자가 저장 없이 볼 수 있는 3일치 샘플 데모 화면을 추가한다.
- [x] 미완료 항목을 다음 날짜 빈칸으로 이월하는 버튼을 추가한다.
- D-DAY는 현재 보류 상태다. 목표 패널 설계 후 연결한다. - [x] 이월된 할 일에 배지를 표시하고 원래 시작 날짜를 확인할 수 있게 한다.
- D-DAY는 본문에 직접 입력하는 방식보다, 별도 목표 목록에서 선택한 대표 목표를 보여주는 구조가 더 적합하다. - [x] 이월된 할 일을 체크할 때 이전 날짜까지 함께 완료할지 선택하는 정책을 추가한다.
- 목표가 없는 경우 본문 D-DAY 영역은 숨기고, 오른쪽 패널의 `D-DAY 사용` 메뉴에서 검색/선택하도록 유도한다. - [x] 이메일 인증 플로우를 설계하고 구현한다.
- `TIME TABLE` 드래그는 단순 사각형 선택이 아니라 시간 셀 단위의 연속 선택으로 해석한다. - [x] 비밀번호 찾기 / 재설정 토큰 흐름을 추가한다.
- 현재는 `localStorage`로 개발을 진행하지만, 적절한 시점에 DB를 붙여 사용자별 저장 구조로 확장해야 한다. - [ ] 로그인 및 인증 관련 rate limit / 잠금 정책을 추가한다.
- 현재 `localStorage` 저장 로직은 분리 가능한 형태로 정리 중이며, 이후 API/DB adapter로 교체하기 쉽게 유지한다. - [x] 로그인 유지 여부를 사용자가 선택할 수 있게 한다.
- 최종적으로는 회원 가입 후 각자 자신의 문서를 작성/관리하고, 개인 통계를 확인하며, 특정 날짜 문서를 출력할 수 있어야 한다. - [x] 설정 화면에서 현재 기기 로그인 상태와 저장 방식을 안내한다.
- 실제 인쇄는 HTML/CSS 기반 프린트 레이아웃으로 유지하고, 공유용으로는 별도의 이미지 저장 기능을 추가하는 방향이 적합하다. - [x] 서버 세션을 명시적으로 폐기하는 로그아웃 API를 추가한다.
- 최종 배포는 UGREEN NAS에서 Docker으로 동작할 예정이며, 포트와 실제 서비스 구성은 추후 확정한다. - [x] 메일 발송 인프라와 발신 도메인 정책을 Resend으로 확정한다.
- 백엔드는 빠른 목업이면 PocketBase도 가능하지만, 현재 방향상 커스텀 로직과 확장성을 생각하면 전용 Node.js API + DB 조합을 우선 검토한다. - [x] 관리자 페이지에서 계정 비활성화 / 강제 로그아웃 / 삭제 기능을 추가한다.
- 현재 백엔드는 `backend/` 폴더에 `Fastify + Drizzle + PostgreSQL` 기준으로 전환 중이다. - [x] 관리자 페이지에서 사용자별 문서 상세 조회 기능을 추가한다.
- 현재 백엔드는 회원가입, 로그인, 현재 사용자 확인용 기본 인증 API까지 포함한다. - [x] 관리자 페이지에서 검색 / 정렬 / 필터 UX를 추가한다.
- 현재 백엔드는 사용자별 플래너 단건 저장/조회와 범위 조회 API까지 포함한다.
- 현재 백엔드는 사용자별 목표 목록 조회와 목표 생성 API까지 포함한다.
- 현재는 `docker-compose.yml``postgres + backend + frontend(nginx)` 초안을 올릴 수 있게 정리했다.
- 현재 환경에서는 Docker 데몬이 꺼져 있어 `docker compose build` 실검증은 아직 완료하지 못했다.
- 프론트에는 로그인/회원가입 모달과 현재 사용자 상태 표시가 추가되었다.
- 로그인 상태일 때는 서버 저장을 우선 사용하는 흐름으로 전환 중이다.
- 로그인 전에는 플래너 본문을 사용하지 못하도록 막고, 인증 후 사용 흐름으로 정리했다.
- 현재는 각 날짜 플래너가 대표 목표 하나를 선택해 `D-DAY`에 연결하는 구조다.
- 목표는 별도 GOALS 화면에서 검색/생성/기간 설정을 관리하고, 플래너에서는 표시 ON/OFF만 다룬다.
- 목표가 현재 날짜에 적용되지 않았거나 `D-DAY 사용`이 꺼져 있으면 본문 `D-DAY` 영역은 숨긴다.
- 현재 날짜에 적용된 목표가 있으면 D-DAY는 기본적으로 보이고, 사용자가 해당 날짜에서만 OFF로 끌 수 있다.
- 목표 생성 시 표시 시작일 기본값은 오늘, 표시 종료일 기본값은 목표일로 맞춘다.
- 표시 기간이 다른 진행 중 목표와 겹치면 프론트와 백엔드 모두 저장을 막는다.
- TASK LABELS는 버튼 묶음 대신 동일한 토글 패턴으로 단순화했다.
- 구현할 때마다 완료된 항목은 체크하고, 큰 결정사항은 `HANDOFF.md`에도 함께 반영한다.

View File

@@ -1,3 +1,8 @@
PORT=3001 PORT=3001
DATABASE_URL=postgresql://planner:planner1234@localhost:5432/ten_minute_planner DATABASE_URL=postgresql://planner:planner1234@localhost:5432/ten_minute_planner
CORS_ORIGIN=http://localhost:5173 CORS_ORIGIN=http://localhost:5173
APP_BASE_URL=http://localhost:5173
ADMIN_ACCOUNT_ID=replace-with-private-admin-id
ADMIN_ACCOUNT_PASSWORD=replace-with-private-admin-password
ADMIN_ACCOUNT_EMAIL=admin@example.com
ADMIN_ACCOUNT_NICKNAME=Planner Admin

View File

@@ -13,6 +13,15 @@ const envSchema = z.object({
DATABASE_URL: z.string().min(1).default('postgresql://planner:planner1234@localhost:5432/ten_minute_planner'), 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),
APP_BASE_URL: z.string().default('http://localhost:5173'),
RESEND_API_KEY: z.string().optional(),
MAIL_FROM_EMAIL: z.string().email().default('planner@sori.studio'),
MAIL_FROM_NAME: z.string().default('10 Minute Planner'),
AUTH_PREVIEW_LINKS: z.coerce.boolean().default(false),
ADMIN_ACCOUNT_ID: z.string().min(1),
ADMIN_ACCOUNT_PASSWORD: z.string().min(12),
ADMIN_ACCOUNT_EMAIL: z.string().email(),
ADMIN_ACCOUNT_NICKNAME: z.string().min(1),
}) })
export const env = envSchema.parse(process.env) export const env = envSchema.parse(process.env)

View File

@@ -5,12 +5,32 @@ export async function ensureDatabaseSchema() {
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE, email VARCHAR(255) NOT NULL UNIQUE,
login_id VARCHAR(60) UNIQUE,
password_hash VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL,
nickname VARCHAR(60) NOT NULL, nickname VARCHAR(60) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'user',
disabled_at TIMESTAMPTZ,
email_verified_at TIMESTAMPTZ,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL updated_at TIMESTAMPTZ NOT NULL
); );
ALTER TABLE users
ADD COLUMN IF NOT EXISTS login_id VARCHAR(60);
ALTER TABLE users
ADD COLUMN IF NOT EXISTS role VARCHAR(20) NOT NULL DEFAULT 'user';
ALTER TABLE users
ADD COLUMN IF NOT EXISTS disabled_at TIMESTAMPTZ;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS auth_sessions ( CREATE TABLE IF NOT EXISTS auth_sessions (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -51,5 +71,29 @@ export async function ensureDatabaseSchema() {
CREATE INDEX IF NOT EXISTS goals_user_id_idx CREATE INDEX IF NOT EXISTS goals_user_id_idx
ON goals (user_id); ON goals (user_id);
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS email_verification_tokens_user_id_idx
ON email_verification_tokens (user_id);
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS password_reset_tokens_user_id_idx
ON password_reset_tokens (user_id);
`) `)
} }

View File

@@ -12,8 +12,13 @@ import {
export const users = pgTable('users', { export const users = pgTable('users', {
id: serial('id').primaryKey(), id: serial('id').primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(), email: varchar('email', { length: 255 }).notNull().unique(),
loginId: varchar('login_id', { length: 60 }).unique(),
passwordHash: varchar('password_hash', { length: 255 }).notNull(), passwordHash: varchar('password_hash', { length: 255 }).notNull(),
nickname: varchar('nickname', { length: 60 }).notNull(), nickname: varchar('nickname', { length: 60 }).notNull(),
role: varchar('role', { length: 20 }).notNull().default('user'),
disabledAt: timestamp('disabled_at', { withTimezone: true }),
emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }),
lastLoginAt: timestamp('last_login_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(),
}) })
@@ -65,3 +70,33 @@ export const goals = pgTable(
userIndex: index('goals_user_id_idx').on(table.userId), userIndex: index('goals_user_id_idx').on(table.userId),
}), }),
) )
export const emailVerificationTokens = pgTable(
'email_verification_tokens',
{
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
tokenHash: varchar('token_hash', { length: 255 }).notNull().unique(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
usedAt: timestamp('used_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
},
(table) => ({
userIndex: index('email_verification_tokens_user_id_idx').on(table.userId),
}),
)
export const passwordResetTokens = pgTable(
'password_reset_tokens',
{
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
tokenHash: varchar('token_hash', { length: 255 }).notNull().unique(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
usedAt: timestamp('used_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
},
(table) => ({
userIndex: index('password_reset_tokens_user_id_idx').on(table.userId),
}),
)

View File

@@ -0,0 +1,69 @@
import { and, eq, isNull, ne, or } from 'drizzle-orm'
import { env } from '../config.js'
import { db } from '../db/client.js'
import { users } from '../db/schema.js'
import { hashPassword } from './password.js'
export async function ensureAdminAccount() {
const loginId = env.ADMIN_ACCOUNT_ID.trim()
const email = env.ADMIN_ACCOUNT_EMAIL.trim().toLowerCase()
const nickname = env.ADMIN_ACCOUNT_NICKNAME.trim()
const password = env.ADMIN_ACCOUNT_PASSWORD
const now = new Date()
await db
.update(users)
.set({
role: 'user',
updatedAt: now,
})
.where(
and(
eq(users.role, 'admin'),
or(
ne(users.loginId, loginId),
isNull(users.loginId),
),
),
)
const [existingAdmin] = await db
.select()
.from(users)
.where(
or(
eq(users.loginId, loginId),
eq(users.email, email),
),
)
.limit(1)
if (existingAdmin) {
await db
.update(users)
.set({
email,
nickname,
role: 'admin',
updatedAt: now,
})
.where(eq(users.id, existingAdmin.id))
return
}
const passwordHash = await hashPassword(password)
await db.insert(users).values({
email,
loginId,
passwordHash,
nickname,
role: 'admin',
emailVerifiedAt: now,
lastLoginAt: null,
createdAt: now,
updatedAt: now,
})
}

View File

@@ -14,6 +14,10 @@ function getBearerToken(request) {
return authorization.slice('Bearer '.length).trim() return authorization.slice('Bearer '.length).trim()
} }
export function getSessionTokenFromRequest(request) {
return getBearerToken(request)
}
export async function createSession(userId) { export async function createSession(userId) {
const token = createSessionToken() const token = createSessionToken()
const tokenHash = hashSessionToken(token) const tokenHash = hashSessionToken(token)
@@ -61,5 +65,29 @@ export async function findAuthenticatedUser(request) {
.where(eq(users.id, session.userId)) .where(eq(users.id, session.userId))
.limit(1) .limit(1)
if (user?.disabledAt) {
await db.delete(authSessions).where(eq(authSessions.id, session.id))
return null
}
if (user && user.role !== 'admin' && !user.emailVerifiedAt) {
await db.delete(authSessions).where(eq(authSessions.id, session.id))
return null
}
return user ?? null return user ?? null
} }
export async function revokeSessionByToken(token) {
if (!token) {
return false
}
const tokenHash = hashSessionToken(token)
const deletedSessions = await db
.delete(authSessions)
.where(eq(authSessions.tokenHash, tokenHash))
.returning({ id: authSessions.id })
return deletedSessions.length > 0
}

89
backend/src/lib/mailer.js Normal file
View File

@@ -0,0 +1,89 @@
import { env } from '../config.js'
const RESEND_API_URL = 'https://api.resend.com/emails'
function getFromAddress() {
return `${env.MAIL_FROM_NAME} <${env.MAIL_FROM_EMAIL}>`
}
async function sendWithResend({ to, subject, html, text }) {
if (!env.RESEND_API_KEY) {
return {
skipped: true,
reason: 'RESEND_API_KEY is not configured.',
}
}
const response = await fetch(RESEND_API_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${env.RESEND_API_KEY}`,
'Content-Type': 'application/json',
'User-Agent': 'ten-minute-planner/1.0',
},
body: JSON.stringify({
from: getFromAddress(),
to,
subject,
html,
text,
}),
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(data.message || data.error?.message || 'Resend 메일 발송에 실패했습니다.')
}
return {
skipped: false,
id: data.id,
}
}
function buildLinkHtml({ title, description, linkUrl, buttonLabel }) {
return `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #1c1917; line-height: 1.6;">
<p style="font-size: 12px; letter-spacing: 0.18em; text-transform: uppercase; color: #78716c;">10 Minute Planner</p>
<h1 style="font-size: 24px; margin: 12px 0;">${title}</h1>
<p style="font-size: 15px; color: #57534e;">${description}</p>
<p style="margin: 24px 0;">
<a href="${linkUrl}" style="display: inline-block; border-radius: 999px; background: #1c1917; color: #ffffff; padding: 12px 18px; font-size: 13px; font-weight: 700; text-decoration: none;">
${buttonLabel}
</a>
</p>
<p style="font-size: 12px; color: #78716c;">버튼이 열리지 않으면 아래 링크를 복사해서 브라우저에 붙여넣어 주세요.</p>
<p style="font-size: 12px; word-break: break-all; color: #57534e;">${linkUrl}</p>
<p style="font-size: 12px; color: #a8a29e;">이 링크는 30분 동안 사용할 수 있습니다.</p>
</div>
`
}
export function sendVerificationEmail({ to, linkUrl }) {
return sendWithResend({
to,
subject: '[10 Minute Planner] 이메일 인증',
html: buildLinkHtml({
title: '이메일 인증을 완료해 주세요.',
description: '아래 버튼을 눌러 10 Minute Planner 계정의 이메일 인증을 완료할 수 있습니다.',
linkUrl,
buttonLabel: '이메일 인증하기',
}),
text: `10 Minute Planner 이메일 인증 링크입니다.\n${linkUrl}\n이 링크는 30분 동안 사용할 수 있습니다.`,
})
}
export function sendPasswordResetEmail({ to, linkUrl }) {
return sendWithResend({
to,
subject: '[10 Minute Planner] 비밀번호 재설정',
html: buildLinkHtml({
title: '비밀번호를 재설정해 주세요.',
description: '아래 버튼을 눌러 새 비밀번호를 설정할 수 있습니다. 요청하지 않았다면 이 메일은 무시해 주세요.',
linkUrl,
buttonLabel: '비밀번호 재설정',
}),
text: `10 Minute Planner 비밀번호 재설정 링크입니다.\n${linkUrl}\n요청하지 않았다면 이 메일은 무시해 주세요.`,
})
}

343
backend/src/routes/admin.js Normal file
View File

@@ -0,0 +1,343 @@
import { and, eq, sql } from 'drizzle-orm'
import { z } from 'zod'
import { db } from '../db/client.js'
import { findAuthenticatedUser } from '../lib/authSession.js'
import { authSessions, users } from '../db/schema.js'
const adminUserIdSchema = z.coerce.number().int().positive()
const adminStatusSchema = z.object({
disabled: z.boolean(),
})
async function requireAdminUser(request, reply) {
const user = await findAuthenticatedUser(request)
if (!user) {
reply.code(401).send({
message: '인증이 필요합니다.',
})
return null
}
if (user.role !== 'admin') {
reply.code(403).send({
message: '관리자만 접근할 수 있습니다.',
})
return null
}
return user
}
export async function registerAdminRoutes(app) {
app.get('/api/admin/overview', async (request, reply) => {
const adminUser = await requireAdminUser(request, reply)
if (!adminUser) {
return
}
const [summary] = await db
.select({
totalUsers: sql`count(*)::int`,
totalAdmins: sql`count(*) filter (where ${users.role} = 'admin')::int`,
disabledUsers: sql`count(*) filter (where ${users.disabledAt} is not null)::int`,
verifiedUsers: sql`count(*) filter (where ${users.emailVerifiedAt} is not null)::int`,
activeUsers30d: sql`count(*) filter (where ${users.lastLoginAt} >= now() - interval '30 days')::int`,
newUsers7d: sql`count(*) filter (where ${users.createdAt} >= now() - interval '7 days')::int`,
})
.from(users)
const plannerCountResult = await db.execute(
sql`select count(*)::int as count from planner_entries`,
)
const goalCountResult = await db.execute(
sql`select count(*)::int as count from goals`,
)
const userRowsResult = await db.execute(sql`
select
u.id,
u.nickname,
u.email,
u.role,
u.disabled_at as "disabledAt",
u.created_at as "createdAt",
u.email_verified_at as "emailVerifiedAt",
u.last_login_at as "lastLoginAt",
count(distinct pe.id)::int as "plannerEntryCount",
count(distinct g.id)::int as "goalCount",
count(distinct s.id)::int as "activeSessionCount",
max(pe.entry_date) as "lastEntryDate",
max(pe.updated_at) as "lastEntryUpdatedAt"
from users u
left join planner_entries pe on pe.user_id = u.id
left join goals g on g.user_id = u.id
left join auth_sessions s on s.user_id = u.id and s.expires_at > now()
group by u.id
order by coalesce(u.last_login_at, u.created_at) desc, u.id desc
`)
const recentLoginsResult = await db.execute(sql`
select
id,
nickname,
email,
role,
last_login_at as "lastLoginAt"
from users
where last_login_at is not null
order by last_login_at desc
limit 5
`)
return {
summary: {
totalUsers: summary?.totalUsers ?? 0,
totalAdmins: summary?.totalAdmins ?? 0,
verifiedUsers: summary?.verifiedUsers ?? 0,
activeUsers30d: summary?.activeUsers30d ?? 0,
newUsers7d: summary?.newUsers7d ?? 0,
disabledUsers: summary?.disabledUsers ?? 0,
totalPlannerEntries: plannerCountResult.rows[0]?.count ?? 0,
totalGoals: goalCountResult.rows[0]?.count ?? 0,
},
users: userRowsResult.rows.map((row) => ({
...row,
isActiveRecently: row.lastLoginAt
? new Date(row.lastLoginAt).getTime() >= Date.now() - 30 * 24 * 60 * 60 * 1000
: false,
})),
recentLogins: recentLoginsResult.rows,
}
})
app.get('/api/admin/users/:userId/detail', async (request, reply) => {
const adminUser = await requireAdminUser(request, reply)
if (!adminUser) {
return
}
const userIdResult = adminUserIdSchema.safeParse(request.params.userId)
if (!userIdResult.success) {
return reply.code(400).send({
message: '대상 사용자 값이 올바르지 않습니다.',
})
}
const userId = userIdResult.data
const detailResult = await db.execute(sql`
select
u.id,
u.nickname,
u.email,
u.role,
u.disabled_at as "disabledAt",
u.created_at as "createdAt",
u.updated_at as "updatedAt",
u.email_verified_at as "emailVerifiedAt",
u.last_login_at as "lastLoginAt",
count(distinct pe.id)::int as "plannerEntryCount",
count(distinct g.id)::int as "goalCount",
count(distinct s.id)::int as "activeSessionCount"
from users u
left join planner_entries pe on pe.user_id = u.id
left join goals g on g.user_id = u.id
left join auth_sessions s on s.user_id = u.id and s.expires_at > now()
where u.id = ${userId}
group by u.id
limit 1
`)
const detailUser = detailResult.rows[0]
if (!detailUser) {
return reply.code(404).send({
message: '대상 사용자를 찾을 수 없습니다.',
})
}
const plannerEntriesResult = await db.execute(sql`
select
entry_date as "entryDate",
payload,
created_at as "createdAt",
updated_at as "updatedAt"
from planner_entries
where user_id = ${userId}
order by entry_date desc
limit 12
`)
const goalsResult = await db.execute(sql`
select
id,
title,
target_date as "targetDate",
active_from as "activeFrom",
active_until as "activeUntil",
color,
created_at as "createdAt",
updated_at as "updatedAt"
from goals
where user_id = ${userId}
order by updated_at desc, id desc
limit 20
`)
return {
user: detailUser,
plannerEntries: plannerEntriesResult.rows,
goals: goalsResult.rows,
}
})
app.put('/api/admin/users/:userId/status', async (request, reply) => {
const adminUser = await requireAdminUser(request, reply)
if (!adminUser) {
return
}
const userIdResult = adminUserIdSchema.safeParse(request.params.userId)
const payload = adminStatusSchema.safeParse(request.body)
if (!userIdResult.success || !payload.success) {
return reply.code(400).send({
message: '관리자 요청 값이 올바르지 않습니다.',
})
}
const userId = userIdResult.data
const [targetUser] = await db
.select()
.from(users)
.where(eq(users.id, userId))
.limit(1)
if (!targetUser) {
return reply.code(404).send({
message: '대상 사용자를 찾을 수 없습니다.',
})
}
if (targetUser.role === 'admin') {
return reply.code(403).send({
message: '관리자 계정 상태는 여기서 변경할 수 없습니다.',
})
}
const disabled = payload.data.disabled
const now = new Date()
const [updatedUser] = await db
.update(users)
.set({
disabledAt: disabled ? now : null,
updatedAt: now,
})
.where(eq(users.id, userId))
.returning()
if (disabled) {
await db.delete(authSessions).where(eq(authSessions.userId, userId))
}
return {
message: disabled ? '계정을 비활성화했습니다.' : '계정을 다시 사용할 수 있게 했습니다.',
user: updatedUser,
}
})
app.post('/api/admin/users/:userId/revoke-sessions', async (request, reply) => {
const adminUser = await requireAdminUser(request, reply)
if (!adminUser) {
return
}
const userIdResult = adminUserIdSchema.safeParse(request.params.userId)
if (!userIdResult.success) {
return reply.code(400).send({
message: '대상 사용자 값이 올바르지 않습니다.',
})
}
const userId = userIdResult.data
const [targetUser] = await db
.select()
.from(users)
.where(eq(users.id, userId))
.limit(1)
if (!targetUser) {
return reply.code(404).send({
message: '대상 사용자를 찾을 수 없습니다.',
})
}
if (targetUser.role === 'admin') {
return reply.code(403).send({
message: '관리자 계정 세션은 여기서 정리하지 않습니다.',
})
}
const deletedSessions = await db
.delete(authSessions)
.where(eq(authSessions.userId, userId))
.returning({ id: authSessions.id })
return {
message: deletedSessions.length > 0 ? '해당 사용자의 로그인 세션을 모두 종료했습니다.' : '종료할 로그인 세션이 없습니다.',
revokedCount: deletedSessions.length,
}
})
app.delete('/api/admin/users/:userId', async (request, reply) => {
const adminUser = await requireAdminUser(request, reply)
if (!adminUser) {
return
}
const userIdResult = adminUserIdSchema.safeParse(request.params.userId)
if (!userIdResult.success) {
return reply.code(400).send({
message: '대상 사용자 값이 올바르지 않습니다.',
})
}
const userId = userIdResult.data
const [targetUser] = await db
.select()
.from(users)
.where(eq(users.id, userId))
.limit(1)
if (!targetUser) {
return reply.code(404).send({
message: '대상 사용자를 찾을 수 없습니다.',
})
}
if (targetUser.role === 'admin') {
return reply.code(403).send({
message: '관리자 계정은 여기서 삭제할 수 없습니다.',
})
}
await db.delete(users).where(and(eq(users.id, userId), eq(users.role, 'user')))
return {
message: '사용자 계정과 관련 데이터를 삭제했습니다.',
}
})
}

View File

@@ -1,9 +1,11 @@
import { eq } from 'drizzle-orm' import { and, eq, gt, isNull, or } from 'drizzle-orm'
import { z } from 'zod' import { z } from 'zod'
import { db } from '../db/client.js' import { db } from '../db/client.js'
import { users } from '../db/schema.js' import { authSessions, emailVerificationTokens, passwordResetTokens, users } from '../db/schema.js'
import { hashPassword, verifyPassword } from '../lib/password.js' import { createSessionToken, hashSessionToken, hashPassword, verifyPassword } from '../lib/password.js'
import { createSession, findAuthenticatedUser } from '../lib/authSession.js' import { createSession, findAuthenticatedUser, getSessionTokenFromRequest, revokeSessionByToken } from '../lib/authSession.js'
import { sendPasswordResetEmail, sendVerificationEmail } from '../lib/mailer.js'
import { env } from '../config.js'
const signupSchema = z.object({ const signupSchema = z.object({
email: z.string().trim().email(), email: z.string().trim().email(),
@@ -12,7 +14,7 @@ const signupSchema = z.object({
}) })
const loginSchema = z.object({ const loginSchema = z.object({
email: z.string().trim().email(), email: z.string().trim().min(1).max(255),
password: z.string().min(1).max(72), password: z.string().min(1).max(72),
}) })
@@ -26,16 +28,117 @@ const passwordSchema = z.object({
newPassword: z.string().min(8).max(72), newPassword: z.string().min(8).max(72),
}) })
const deleteAccountSchema = z.object({
currentPassword: z.string().min(1).max(72),
})
const verificationRequestSchema = z.object({
email: z.string().trim().email().optional(),
})
const verificationConfirmSchema = z.object({
token: z.string().trim().min(20).max(255),
})
const passwordResetRequestSchema = z.object({
email: z.string().trim().email(),
})
const passwordResetConfirmSchema = z.object({
token: z.string().trim().min(20).max(255),
newPassword: z.string().min(8).max(72),
})
const TOKEN_TTL_MS = 1000 * 60 * 30
function buildPreviewUrl(pathname, token) {
const url = new URL(pathname, env.APP_BASE_URL)
url.searchParams.set('token', token)
return url.toString()
}
async function createEmailVerificationToken(userId) {
const token = createSessionToken()
const tokenHash = hashSessionToken(token)
const now = new Date()
const expiresAt = new Date(now.getTime() + TOKEN_TTL_MS)
await db.delete(emailVerificationTokens).where(eq(emailVerificationTokens.userId, userId))
await db.insert(emailVerificationTokens).values({
userId,
tokenHash,
expiresAt,
createdAt: now,
})
return {
token,
previewUrl: buildPreviewUrl('/verify-email', token),
}
}
async function createPasswordResetToken(userId) {
const token = createSessionToken()
const tokenHash = hashSessionToken(token)
const now = new Date()
const expiresAt = new Date(now.getTime() + TOKEN_TTL_MS)
await db.delete(passwordResetTokens).where(eq(passwordResetTokens.userId, userId))
await db.insert(passwordResetTokens).values({
userId,
tokenHash,
expiresAt,
createdAt: now,
})
return {
token,
previewUrl: buildPreviewUrl('/reset-password', token),
}
}
function sanitizeUser(user) { function sanitizeUser(user) {
return { return {
id: user.id, id: user.id,
email: user.email, email: user.email,
loginId: user.loginId,
nickname: user.nickname, nickname: user.nickname,
role: user.role,
disabledAt: user.disabledAt,
emailVerifiedAt: user.emailVerifiedAt,
lastLoginAt: user.lastLoginAt,
createdAt: user.createdAt, createdAt: user.createdAt,
updatedAt: user.updatedAt, updatedAt: user.updatedAt,
} }
} }
function withPreviewUrl(payload, key, previewUrl) {
const allowPreviewLinks =
env.AUTH_PREVIEW_LINKS &&
/localhost|127\.0\.0\.1/i.test(env.APP_BASE_URL)
if (!allowPreviewLinks) {
return payload
}
return {
...payload,
[key]: previewUrl,
}
}
async function findUserByNickname(nickname) {
const [user] = await db
.select()
.from(users)
.where(eq(users.nickname, nickname))
.limit(1)
return user ?? null
}
export async function registerAuthRoutes(app) { export async function registerAuthRoutes(app) {
app.post('/api/auth/signup', async (request, reply) => { app.post('/api/auth/signup', async (request, reply) => {
const payload = signupSchema.safeParse(request.body) const payload = signupSchema.safeParse(request.body)
@@ -62,27 +165,41 @@ export async function registerAuthRoutes(app) {
}) })
} }
const existingNicknameUser = await findUserByNickname(nickname)
if (existingNicknameUser) {
return reply.code(409).send({
message: '이미 사용 중인 닉네임입니다.',
})
}
const now = new Date() const now = new Date()
const passwordHash = await hashPassword(password) const passwordHash = await hashPassword(password)
const [user] = await db const [user] = await db
.insert(users) .insert(users)
.values({ .values({
email: normalizedEmail, email: normalizedEmail,
loginId: null,
passwordHash, passwordHash,
nickname, nickname,
role: 'user',
emailVerifiedAt: null,
lastLoginAt: null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
.returning() .returning()
const { token } = await createSession(user.id) const verification = await createEmailVerificationToken(user.id)
await sendVerificationEmail({
return reply.code(201).send({ to: user.email,
message: '회원가입이 완료되었습니다.', linkUrl: verification.previewUrl,
token,
user: sanitizeUser(user),
}) })
return reply.code(201).send(withPreviewUrl({
message: '회원가입이 완료되었습니다. 이메일 인증 후 로그인해 주세요.',
requiresEmailVerification: true,
}, 'verificationPreviewUrl', verification.previewUrl))
}) })
app.post('/api/auth/login', async (request, reply) => { app.post('/api/auth/login', async (request, reply) => {
@@ -95,17 +212,23 @@ export async function registerAuthRoutes(app) {
}) })
} }
const normalizedEmail = payload.data.email.toLowerCase() const identifier = payload.data.email.trim()
const normalizedEmail = identifier.toLowerCase()
const [user] = await db const [user] = await db
.select() .select()
.from(users) .from(users)
.where(eq(users.email, normalizedEmail)) .where(
or(
eq(users.email, normalizedEmail),
eq(users.loginId, identifier),
),
)
.limit(1) .limit(1)
if (!user) { if (!user) {
return reply.code(401).send({ return reply.code(401).send({
message: '이메일 또는 비밀번호가 올바르지 않습니다.', message: '이메일, 아이디 또는 비밀번호가 올바르지 않습니다.',
}) })
} }
@@ -113,16 +236,39 @@ export async function registerAuthRoutes(app) {
if (!passwordMatches) { if (!passwordMatches) {
return reply.code(401).send({ return reply.code(401).send({
message: '이메일 또는 비밀번호가 올바르지 않습니다.', message: '이메일, 아이디 또는 비밀번호가 올바르지 않습니다.',
}) })
} }
if (user.disabledAt) {
return reply.code(403).send({
message: '비활성화된 계정입니다. 관리자에게 문의해 주세요.',
})
}
if (user.role !== 'admin' && !user.emailVerifiedAt) {
return reply.code(403).send({
message: '이메일 인증을 완료한 뒤 로그인해 주세요.',
})
}
const now = new Date()
const [updatedUser] = await db
.update(users)
.set({
lastLoginAt: now,
updatedAt: now,
})
.where(eq(users.id, user.id))
.returning()
const { token } = await createSession(user.id) const { token } = await createSession(user.id)
return { return {
message: '로그인에 성공했습니다.', message: '로그인에 성공했습니다.',
token, token,
user: sanitizeUser(user), user: sanitizeUser(updatedUser),
} }
}) })
@@ -140,6 +286,22 @@ export async function registerAuthRoutes(app) {
} }
}) })
app.post('/api/auth/logout', async (request, reply) => {
const token = getSessionTokenFromRequest(request)
if (!token) {
return reply.code(401).send({
message: '인증이 필요합니다.',
})
}
await revokeSessionByToken(token)
return {
message: '로그아웃되었습니다.',
}
})
app.put('/api/auth/profile', async (request, reply) => { app.put('/api/auth/profile', async (request, reply) => {
const user = await findAuthenticatedUser(request) const user = await findAuthenticatedUser(request)
@@ -172,6 +334,14 @@ export async function registerAuthRoutes(app) {
}) })
} }
const existingNicknameUser = await findUserByNickname(payload.data.nickname)
if (existingNicknameUser && existingNicknameUser.id !== user.id) {
return reply.code(409).send({
message: '이미 사용 중인 닉네임입니다.',
})
}
const [updatedUser] = await db const [updatedUser] = await db
.update(users) .update(users)
.set({ .set({
@@ -228,4 +398,234 @@ export async function registerAuthRoutes(app) {
message: '비밀번호가 변경되었습니다.', message: '비밀번호가 변경되었습니다.',
} }
}) })
app.delete('/api/auth/account', async (request, reply) => {
const user = await findAuthenticatedUser(request)
if (!user) {
return reply.code(401).send({
message: '인증이 필요합니다.',
})
}
if (user.role === 'admin') {
return reply.code(403).send({
message: '기본 관리자 계정은 여기서 삭제할 수 없습니다.',
})
}
const payload = deleteAccountSchema.safeParse(request.body)
if (!payload.success) {
return reply.code(400).send({
message: '회원 탈퇴 확인 비밀번호를 입력해 주세요.',
issues: payload.error.flatten(),
})
}
const passwordMatches = await verifyPassword(payload.data.currentPassword, user.passwordHash)
if (!passwordMatches) {
return reply.code(401).send({
message: '현재 비밀번호가 올바르지 않습니다.',
})
}
await db.delete(authSessions).where(eq(authSessions.userId, user.id))
await db.delete(users).where(eq(users.id, user.id))
return {
message: '회원 탈퇴가 완료되었습니다.',
}
})
app.post('/api/auth/verification/request', async (request, reply) => {
const authenticatedUser = await findAuthenticatedUser(request)
const payload = verificationRequestSchema.safeParse(request.body ?? {})
if (!payload.success) {
return reply.code(400).send({
message: '이메일 입력값이 올바르지 않습니다.',
issues: payload.error.flatten(),
})
}
const normalizedEmail = (payload.data.email || authenticatedUser?.email || '').toLowerCase()
if (!normalizedEmail) {
return reply.code(400).send({
message: '인증 메일을 받을 이메일이 필요합니다.',
})
}
const [user] = await db
.select()
.from(users)
.where(eq(users.email, normalizedEmail))
.limit(1)
if (!user) {
return {
message: '입력한 이메일로 인증 안내를 보낼 준비가 되면 처리됩니다.',
}
}
if (user.emailVerifiedAt) {
return {
message: '이미 이메일 인증이 완료된 계정입니다.',
}
}
const verification = await createEmailVerificationToken(user.id)
await sendVerificationEmail({
to: user.email,
linkUrl: verification.previewUrl,
})
return withPreviewUrl({
message: '이메일 인증 링크를 준비했습니다.',
}, 'verificationPreviewUrl', verification.previewUrl)
})
app.post('/api/auth/verification/confirm', async (request, reply) => {
const payload = verificationConfirmSchema.safeParse(request.body)
if (!payload.success) {
return reply.code(400).send({
message: '인증 토큰이 올바르지 않습니다.',
issues: payload.error.flatten(),
})
}
const tokenHash = hashSessionToken(payload.data.token)
const [verification] = await db
.select()
.from(emailVerificationTokens)
.where(
and(
eq(emailVerificationTokens.tokenHash, tokenHash),
isNull(emailVerificationTokens.usedAt),
gt(emailVerificationTokens.expiresAt, new Date()),
),
)
.limit(1)
if (!verification) {
return reply.code(400).send({
message: '이미 사용했거나 만료된 인증 링크입니다.',
})
}
const now = new Date()
await db
.update(users)
.set({
emailVerifiedAt: now,
updatedAt: now,
})
.where(eq(users.id, verification.userId))
await db
.update(emailVerificationTokens)
.set({
usedAt: now,
})
.where(eq(emailVerificationTokens.id, verification.id))
return {
message: '이메일 인증이 완료되었습니다.',
}
})
app.post('/api/auth/password-reset/request', async (request, reply) => {
const payload = passwordResetRequestSchema.safeParse(request.body)
if (!payload.success) {
return reply.code(400).send({
message: '이메일 입력값이 올바르지 않습니다.',
issues: payload.error.flatten(),
})
}
const normalizedEmail = payload.data.email.toLowerCase()
const [user] = await db
.select()
.from(users)
.where(eq(users.email, normalizedEmail))
.limit(1)
if (!user) {
return {
message: '입력한 이메일로 재설정 안내를 보낼 준비가 되면 처리됩니다.',
}
}
const reset = await createPasswordResetToken(user.id)
await sendPasswordResetEmail({
to: user.email,
linkUrl: reset.previewUrl,
})
return withPreviewUrl({
message: '비밀번호 재설정 링크를 준비했습니다.',
}, 'resetPreviewUrl', reset.previewUrl)
})
app.post('/api/auth/password-reset/confirm', async (request, reply) => {
const payload = passwordResetConfirmSchema.safeParse(request.body)
if (!payload.success) {
return reply.code(400).send({
message: '비밀번호 재설정 입력값이 올바르지 않습니다.',
issues: payload.error.flatten(),
})
}
const tokenHash = hashSessionToken(payload.data.token)
const [resetToken] = await db
.select()
.from(passwordResetTokens)
.where(
and(
eq(passwordResetTokens.tokenHash, tokenHash),
isNull(passwordResetTokens.usedAt),
gt(passwordResetTokens.expiresAt, new Date()),
),
)
.limit(1)
if (!resetToken) {
return reply.code(400).send({
message: '이미 사용했거나 만료된 재설정 링크입니다.',
})
}
const now = new Date()
const passwordHash = await hashPassword(payload.data.newPassword)
await db
.update(users)
.set({
passwordHash,
updatedAt: now,
})
.where(eq(users.id, resetToken.userId))
await db
.update(passwordResetTokens)
.set({
usedAt: now,
})
.where(eq(passwordResetTokens.id, resetToken.id))
await db.delete(authSessions).where(eq(authSessions.userId, resetToken.userId))
return {
message: '비밀번호가 재설정되었습니다. 다시 로그인해 주세요.',
}
})
} }

View File

@@ -3,7 +3,9 @@ import cors from '@fastify/cors'
import { env } from './config.js' import { env } from './config.js'
import { pool } from './db/client.js' import { pool } from './db/client.js'
import { ensureDatabaseSchema } from './db/init.js' import { ensureDatabaseSchema } from './db/init.js'
import { ensureAdminAccount } from './lib/adminAccount.js'
import { registerAuthRoutes } from './routes/auth.js' import { registerAuthRoutes } from './routes/auth.js'
import { registerAdminRoutes } from './routes/admin.js'
import { registerGoalRoutes } from './routes/goals.js' import { registerGoalRoutes } from './routes/goals.js'
import { registerPlannerRoutes } from './routes/planner.js' import { registerPlannerRoutes } from './routes/planner.js'
@@ -12,6 +14,7 @@ const app = Fastify({
}) })
await ensureDatabaseSchema() await ensureDatabaseSchema()
await ensureAdminAccount()
await app.register(cors, { await app.register(cors, {
origin: env.CORS_ORIGIN, origin: env.CORS_ORIGIN,
@@ -19,6 +22,7 @@ await app.register(cors, {
}) })
await registerAuthRoutes(app) await registerAuthRoutes(app)
await registerAdminRoutes(app)
await registerGoalRoutes(app) await registerGoalRoutes(app)
await registerPlannerRoutes(app) await registerPlannerRoutes(app)
@@ -42,6 +46,7 @@ app.get('/api/meta', async () => ({
orm: 'drizzle', orm: 'drizzle',
notes: [ notes: [
'회원가입, 로그인, 현재 사용자 확인 API가 준비되어 있습니다.', '회원가입, 로그인, 현재 사용자 확인 API가 준비되어 있습니다.',
'관리자용 사용자 현황 요약 API가 준비되어 있습니다.',
'사용자별 목표 목록, 수정, 삭제 API가 준비되어 있습니다.', '사용자별 목표 목록, 수정, 삭제 API가 준비되어 있습니다.',
'사용자별 플래너 저장 및 조회 API가 준비되어 있습니다.', '사용자별 플래너 저장 및 조회 API가 준비되어 있습니다.',
], ],

View File

@@ -2,16 +2,16 @@ services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
container_name: ten-minute-postgres-dev container_name: ten-minute-postgres-dev
env_file:
- ./.env.dev
environment: environment:
POSTGRES_DB: ten_minute_planner TZ: Asia/Seoul
POSTGRES_USER: planner
POSTGRES_PASSWORD: planner1234
volumes: volumes:
- postgres_dev_data:/var/lib/postgresql/data - postgres_dev_data:/var/lib/postgresql/data
ports: ports:
- "5432:5432" - "5432:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U planner -d ten_minute_planner"] test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 10 retries: 10
@@ -22,11 +22,13 @@ services:
container_name: ten-minute-backend-dev container_name: ten-minute-backend-dev
working_dir: /app working_dir: /app
command: sh -c "npm install && npm run dev" command: sh -c "npm install && npm run dev"
env_file:
- ./.env.dev
environment: environment:
PORT: 3001 PORT: 3001
DATABASE_URL: postgresql://planner:planner1234@postgres:5432/ten_minute_planner
CORS_ORIGIN: http://localhost:5173 CORS_ORIGIN: http://localhost:5173
SESSION_TTL_DAYS: 30 SESSION_TTL_DAYS: 30
TZ: Asia/Seoul
volumes: volumes:
- ./backend:/app - ./backend:/app
- backend_node_modules:/app/node_modules - backend_node_modules:/app/node_modules
@@ -45,6 +47,7 @@ services:
environment: environment:
VITE_API_BASE_URL: http://localhost:3001 VITE_API_BASE_URL: http://localhost:3001
CHOKIDAR_USEPOLLING: "true" CHOKIDAR_USEPOLLING: "true"
TZ: Asia/Seoul
volumes: volumes:
- .:/app - .:/app
- /app/backend - /app/backend

View File

@@ -2,16 +2,16 @@ services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
container_name: ten-minute-postgres container_name: ten-minute-postgres
env_file:
- ./.env
environment: environment:
POSTGRES_DB: ten_minute_planner TZ: Asia/Seoul
POSTGRES_USER: planner
POSTGRES_PASSWORD: planner1234
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
ports: ports:
- "5432:5432" - "45432:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U planner -d ten_minute_planner"] test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 10 retries: 10
@@ -21,11 +21,13 @@ services:
build: build:
context: ./backend context: ./backend
container_name: ten-minute-backend container_name: ten-minute-backend
env_file:
- ./.env
environment: environment:
PORT: 3001 PORT: 3001
DATABASE_URL: postgresql://planner:planner1234@postgres:5432/ten_minute_planner CORS_ORIGIN: http://localhost:48081
CORS_ORIGIN: http://localhost:8080
SESSION_TTL_DAYS: 30 SESSION_TTL_DAYS: 30
TZ: Asia/Seoul
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -39,10 +41,12 @@ services:
args: args:
VITE_API_BASE_URL: /api VITE_API_BASE_URL: /api
container_name: ten-minute-frontend container_name: ten-minute-frontend
environment:
TZ: Asia/Seoul
depends_on: depends_on:
- backend - backend
ports: ports:
- "8080:80" - "48081:80"
restart: unless-stopped restart: unless-stopped
volumes: volumes:

View File

@@ -3,7 +3,32 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>10분 플래너</title> <meta name="theme-color" content="#f5f2eb" />
<meta
name="description"
content="10분 단위로 하루를 기록하고 계획하는 차분한 종이 다이어리형 플래너입니다."
/>
<meta property="og:type" content="website" />
<meta property="og:locale" content="ko_KR" />
<meta property="og:site_name" content="10 Minute Planner" />
<meta property="og:title" content="10 Minute Planner" />
<meta
property="og:description"
content="10분 단위로 하루를 기록하고 계획하는 차분한 종이 다이어리형 플래너입니다."
/>
<meta property="og:image" content="/assets/og-image.png" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1731" />
<meta property="og:image:height" content="909" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="10 Minute Planner" />
<meta
name="twitter:description"
content="10분 단위로 하루를 기록하고 계획하는 차분한 종이 다이어리형 플래너입니다."
/>
<meta name="twitter:image" content="/assets/og-image.png" />
<link rel="icon" type="image/png" href="/assets/favicon.png" />
<title>10 Minute Planner</title>
</head> </head>
<body class="bg-stone-100"> <body class="bg-stone-100">
<div id="app"></div> <div id="app"></div>

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "ten-minute-planner", "name": "ten-minute-planner",
"version": "0.1.25", "version": "0.1.56",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ten-minute-planner", "name": "ten-minute-planner",
"version": "0.1.25", "version": "0.1.56",
"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.25", "version": "0.1.56",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

BIN
public/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
public/assets/og-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,510 @@
<script setup>
import { computed, ref } from 'vue'
const props = defineProps({
summary: {
type: Object,
required: true,
},
users: {
type: Array,
required: true,
},
selectedUserId: {
type: Number,
default: null,
},
userDetail: {
type: Object,
default: null,
},
recentLogins: {
type: Array,
required: true,
},
busy: {
type: Boolean,
default: false,
},
message: {
type: String,
default: '',
},
actionBusyUserId: {
type: Number,
default: null,
},
detailBusy: {
type: Boolean,
default: false,
},
})
const emit = defineEmits([
'select-user',
'toggle-user-status',
'revoke-user-sessions',
'delete-user',
])
const userSearch = ref('')
const userStatusFilter = ref('all')
const userSort = ref('lastLoginDesc')
function formatDate(value) {
if (!value) {
return '기록 없음'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return '기록 없음'
}
return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date)
}
function getPlannerSummary(payload) {
if (!payload || typeof payload !== 'object') {
return {
comment: '코멘트 없음',
taskCount: 0,
completedCount: 0,
memoCount: 0,
}
}
const tasks = Array.isArray(payload.tasks) ? payload.tasks : []
const memo = Array.isArray(payload.memo) ? payload.memo : []
const activeTasks = tasks.filter((task) => task?.title?.trim?.())
return {
comment: payload.comment?.trim?.() || '코멘트 없음',
taskCount: activeTasks.length,
completedCount: activeTasks.filter((task) => task.checked).length,
memoCount: memo.filter((item) => item?.text?.trim?.() || item?.label?.trim?.()).length,
}
}
const filteredUsers = computed(() => {
const search = userSearch.value.trim().toLowerCase()
const filtered = props.users.filter((user) => {
const matchesSearch = !search
|| String(user.id).includes(search)
|| user.nickname?.toLowerCase().includes(search)
|| user.email?.toLowerCase().includes(search)
if (!matchesSearch) {
return false
}
switch (userStatusFilter.value) {
case 'active':
return !user.disabledAt && user.isActiveRecently
case 'disabled':
return Boolean(user.disabledAt)
case 'unverified':
return !user.emailVerifiedAt
case 'admin':
return user.role === 'admin'
case 'member':
return user.role !== 'admin'
default:
return true
}
})
return [...filtered].sort((left, right) => {
switch (userSort.value) {
case 'plannerDesc':
return right.plannerEntryCount - left.plannerEntryCount || right.id - left.id
case 'goalDesc':
return right.goalCount - left.goalCount || right.id - left.id
case 'createdDesc':
return new Date(right.createdAt || 0).getTime() - new Date(left.createdAt || 0).getTime() || right.id - left.id
case 'nicknameAsc':
return String(left.nickname || '').localeCompare(String(right.nickname || ''), 'ko')
case 'lastLoginDesc':
default:
return new Date(right.lastLoginAt || right.createdAt || 0).getTime()
- new Date(left.lastLoginAt || left.createdAt || 0).getTime()
|| right.id - left.id
}
})
})
const filteredUsersSummary = computed(() => {
const total = filteredUsers.value.length
const disabled = filteredUsers.value.filter((user) => user.disabledAt).length
const active = filteredUsers.value.filter((user) => !user.disabledAt && user.isActiveRecently).length
return { total, disabled, active }
})
</script>
<template>
<section class="grid gap-6">
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<article class="rounded-[28px] border border-white/60 bg-white/80 p-6">
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">Total Users</p>
<p class="mt-4 text-4xl font-semibold tracking-[-0.06em] text-stone-900">{{ summary.totalUsers }}</p>
<p class="mt-2 text-sm font-semibold text-stone-500">현재 가입된 전체 계정 </p>
</article>
<article class="rounded-[28px] border border-white/60 bg-white/80 p-6">
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">Active 30 Days</p>
<p class="mt-4 text-4xl font-semibold tracking-[-0.06em] text-stone-900">{{ summary.activeUsers30d }}</p>
<p class="mt-2 text-sm font-semibold text-stone-500">최근 30 안에 접속한 사용자</p>
</article>
<article class="rounded-[28px] border border-white/60 bg-white/80 p-6">
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">Planner Entries</p>
<p class="mt-4 text-4xl font-semibold tracking-[-0.06em] text-stone-900">{{ summary.totalPlannerEntries }}</p>
<p class="mt-2 text-sm font-semibold text-stone-500">전체 작성된 날짜 문서 </p>
</article>
<article class="rounded-[28px] border border-white/60 bg-white/80 p-6">
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">Verified / Admin</p>
<p class="mt-4 text-3xl font-semibold tracking-[-0.06em] text-stone-900">{{ summary.verifiedUsers }} / {{ summary.totalAdmins }}</p>
<p class="mt-2 text-sm font-semibold text-stone-500">인증 완료 계정 / 관리자 </p>
</article>
<article class="rounded-[28px] border border-white/60 bg-white/80 p-6">
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">Disabled Users</p>
<p class="mt-4 text-4xl font-semibold tracking-[-0.06em] text-stone-900">{{ summary.disabledUsers ?? 0 }}</p>
<p class="mt-2 text-sm font-semibold text-stone-500">관리자가 비활성화한 계정 </p>
</article>
</div>
<div class="grid gap-6 xl:grid-cols-[320px_minmax(0,1fr)]">
<aside class="rounded-[28px] border border-white/60 bg-white/75 p-6">
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">운영 요약</p>
<div class="mt-5 space-y-4">
<div class="rounded-2xl border border-stone-200 bg-white px-4 py-4">
<p class="text-[10px] font-bold tracking-[0.18em] text-stone-500">최근 7 신규 가입</p>
<p class="mt-2 text-2xl font-semibold tracking-[-0.05em] text-stone-900">{{ summary.newUsers7d }}</p>
</div>
<div class="rounded-2xl border border-stone-200 bg-white px-4 py-4">
<p class="text-[10px] font-bold tracking-[0.18em] text-stone-500">전체 목표 </p>
<p class="mt-2 text-2xl font-semibold tracking-[-0.05em] text-stone-900">{{ summary.totalGoals }}</p>
</div>
</div>
<div class="mt-6">
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">최근 접속</p>
<div class="mt-4 space-y-3">
<article
v-for="user in recentLogins"
:key="`recent-${user.id}`"
class="rounded-2xl border border-stone-200 bg-white px-4 py-4"
>
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-semibold text-stone-900">{{ user.nickname }}</p>
<span class="rounded-full bg-stone-100 px-2 py-1 text-[10px] font-bold tracking-[0.12em] text-stone-600">
{{ user.role }}
</span>
</div>
<p class="mt-1 text-xs font-semibold text-stone-500">{{ user.email }}</p>
<p class="mt-3 text-[11px] font-semibold text-stone-600">{{ formatDate(user.lastLoginAt) }}</p>
</article>
<p v-if="recentLogins.length === 0" class="rounded-2xl border border-dashed border-stone-300 bg-white/80 px-4 py-4 text-sm font-semibold text-stone-500">
아직 접속 기록이 없습니다.
</p>
</div>
</div>
</aside>
<section class="rounded-[28px] border border-white/60 bg-white/75 p-6">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">사용자 목록</p>
<p class="mt-2 text-sm font-semibold text-stone-500">최종 접속일, 작성 문서 , 목표 수를 번에 있습니다.</p>
</div>
<div v-if="busy" class="rounded-full bg-stone-900 px-4 py-2 text-[11px] font-bold tracking-[0.16em] text-white">
불러오는 중...
</div>
</div>
<p
v-if="message"
class="mt-4 rounded-2xl border border-stone-300 bg-white/80 px-4 py-3 text-sm font-semibold leading-6 text-stone-700"
>
{{ message }}
</p>
<div class="mt-5 grid gap-3 rounded-[24px] border border-stone-200 bg-[#fcfaf6] p-4 md:grid-cols-[minmax(0,1.2fr)_180px_180px]">
<label class="grid gap-2">
<span class="text-[10px] font-bold uppercase tracking-[0.18em] text-stone-500">검색</span>
<input
v-model="userSearch"
type="text"
placeholder="닉네임, 이메일, ID 검색"
class="h-11 rounded-2xl border border-stone-200 bg-white px-4 text-sm font-semibold text-stone-700 outline-none transition placeholder:text-stone-400 focus:border-stone-400"
/>
</label>
<label class="grid gap-2">
<span class="text-[10px] font-bold uppercase tracking-[0.18em] text-stone-500">상태 필터</span>
<select
v-model="userStatusFilter"
class="h-11 rounded-2xl border border-stone-200 bg-white px-4 text-sm font-semibold text-stone-700 outline-none transition focus:border-stone-400"
>
<option value="all">전체 사용자</option>
<option value="active">최근 활동</option>
<option value="disabled">비활성화</option>
<option value="unverified">미인증</option>
<option value="member">일반 사용자</option>
<option value="admin">관리자</option>
</select>
</label>
<label class="grid gap-2">
<span class="text-[10px] font-bold uppercase tracking-[0.18em] text-stone-500">정렬</span>
<select
v-model="userSort"
class="h-11 rounded-2xl border border-stone-200 bg-white px-4 text-sm font-semibold text-stone-700 outline-none transition focus:border-stone-400"
>
<option value="lastLoginDesc">최근 접속순</option>
<option value="plannerDesc">문서 많은 </option>
<option value="goalDesc">목표 많은 </option>
<option value="createdDesc">최근 가입순</option>
<option value="nicknameAsc">이름순</option>
</select>
</label>
</div>
<div class="mt-4 flex flex-wrap items-center gap-2 text-[11px] font-bold tracking-[0.12em] text-stone-500">
<span class="rounded-full bg-white px-3 py-2">표시 {{ filteredUsersSummary.total }}</span>
<span class="rounded-full bg-white px-3 py-2">활동 {{ filteredUsersSummary.active }}</span>
<span class="rounded-full bg-white px-3 py-2">비활성 {{ filteredUsersSummary.disabled }}</span>
</div>
<div class="mt-5 overflow-hidden rounded-[24px] border border-stone-200 bg-white">
<div class="hidden grid-cols-[84px_minmax(0,1.2fr)_110px_150px_90px_90px_150px_190px] gap-3 border-b border-stone-200 bg-[#f8f4ed] px-5 py-4 text-[10px] font-bold uppercase tracking-[0.18em] text-stone-500 xl:grid">
<span>ID</span>
<span>사용자</span>
<span>권한</span>
<span>최종 접속</span>
<span>문서 </span>
<span>목표 </span>
<span>상태</span>
<span>관리</span>
</div>
<div class="divide-y divide-stone-200">
<article
v-for="user in filteredUsers"
:key="user.id"
class="px-5 py-4"
>
<div class="grid gap-3 xl:grid-cols-[84px_minmax(0,1.2fr)_110px_150px_90px_90px_150px_190px] xl:items-center">
<p class="text-xs font-bold tracking-[0.14em] text-stone-500">#{{ user.id }}</p>
<div>
<p class="text-sm font-semibold text-stone-900">{{ user.nickname }}</p>
<p class="mt-1 text-xs font-semibold text-stone-500">{{ user.email }}</p>
<button
type="button"
class="mt-2 text-[11px] font-bold tracking-[0.12em] text-stone-500 underline underline-offset-4 transition hover:text-stone-900"
@click="emit('select-user', user)"
>
{{ selectedUserId === user.id ? '상세 닫기' : '상세 보기' }}
</button>
</div>
<p class="text-sm font-semibold text-stone-700">{{ user.role }}</p>
<p class="text-sm font-semibold text-stone-700">{{ formatDate(user.lastLoginAt) }}</p>
<p class="text-sm font-semibold text-stone-700">{{ user.plannerEntryCount }}</p>
<p class="text-sm font-semibold text-stone-700">{{ user.goalCount }}</p>
<div class="flex flex-wrap items-center gap-2">
<span
class="rounded-full px-3 py-1 text-[10px] font-bold tracking-[0.14em]"
:class="user.disabledAt ? 'bg-rose-100 text-rose-700' : user.isActiveRecently ? 'bg-emerald-100 text-emerald-700' : 'bg-stone-100 text-stone-500'"
>
{{ user.disabledAt ? '비활성화' : user.isActiveRecently ? '활동 중' : '휴면 가능성' }}
</span>
<span
class="rounded-full px-3 py-1 text-[10px] font-bold tracking-[0.14em]"
:class="user.emailVerifiedAt ? 'bg-sky-100 text-sky-700' : 'bg-amber-100 text-amber-700'"
>
{{ user.emailVerifiedAt ? '이메일 인증' : '미인증' }}
</span>
<span
class="rounded-full px-3 py-1 text-[10px] font-bold tracking-[0.14em]"
:class="user.activeSessionCount > 0 ? 'bg-violet-100 text-violet-700' : 'bg-stone-100 text-stone-500'"
>
세션 {{ user.activeSessionCount }}
</span>
</div>
<div class="flex flex-wrap items-center gap-2 xl:justify-end">
<button
v-if="user.role !== 'admin'"
type="button"
class="rounded-full border px-3 py-2 text-[10px] font-bold tracking-[0.14em] transition"
:class="user.disabledAt ? 'border-emerald-300 text-emerald-700 hover:bg-emerald-50' : 'border-amber-300 text-amber-700 hover:bg-amber-50'"
:disabled="actionBusyUserId === user.id"
@click="emit('toggle-user-status', user)"
>
{{ user.disabledAt ? '다시 허용' : '비활성화' }}
</button>
<button
v-if="user.role !== 'admin'"
type="button"
class="rounded-full border border-stone-300 px-3 py-2 text-[10px] font-bold tracking-[0.14em] text-stone-700 transition hover:bg-stone-100"
:disabled="actionBusyUserId === user.id"
@click="emit('revoke-user-sessions', user)"
>
강제 로그아웃
</button>
<button
v-if="user.role !== 'admin'"
type="button"
class="rounded-full border border-rose-300 px-3 py-2 text-[10px] font-bold tracking-[0.14em] text-rose-700 transition hover:bg-rose-50"
:disabled="actionBusyUserId === user.id"
@click="emit('delete-user', user)"
>
삭제
</button>
<span
v-if="actionBusyUserId === user.id"
class="text-[10px] font-bold tracking-[0.12em] text-stone-400"
>
처리 ...
</span>
</div>
</div>
</article>
<div
v-if="!busy && filteredUsers.length === 0"
class="px-5 py-10 text-center text-sm font-semibold text-stone-500"
>
조건에 맞는 사용자가 없습니다.
</div>
</div>
</div>
<section class="border-t border-stone-200 bg-[#fcfaf6] px-5 py-5">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">사용자 상세</p>
<p class="mt-2 text-sm font-semibold text-stone-500">선택한 사용자의 최근 플래너 기록과 목표를 확인합니다.</p>
</div>
<div
v-if="detailBusy"
class="rounded-full bg-stone-900 px-4 py-2 text-[11px] font-bold tracking-[0.16em] text-white"
>
불러오는 ...
</div>
</div>
<div
v-if="userDetail?.user"
class="mt-5 grid gap-5 xl:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)]"
>
<div class="rounded-[24px] border border-stone-200 bg-white p-5">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-lg font-semibold text-stone-900">{{ userDetail.user.nickname }}</p>
<p class="mt-1 text-sm font-semibold text-stone-500">{{ userDetail.user.email }}</p>
</div>
<div class="flex flex-wrap justify-end gap-2">
<span class="rounded-full bg-stone-100 px-3 py-1 text-[10px] font-bold tracking-[0.14em] text-stone-600">{{ userDetail.user.role }}</span>
<span
class="rounded-full px-3 py-1 text-[10px] font-bold tracking-[0.14em]"
:class="userDetail.user.disabledAt ? 'bg-rose-100 text-rose-700' : 'bg-emerald-100 text-emerald-700'"
>
{{ userDetail.user.disabledAt ? '비활성화' : '사용 가능' }}
</span>
</div>
</div>
<div class="mt-5 grid gap-3 md:grid-cols-3">
<div class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-4">
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">최근 로그인</p>
<p class="mt-2 text-sm font-semibold text-stone-800">{{ formatDate(userDetail.user.lastLoginAt) }}</p>
</div>
<div class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-4">
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">문서 / 목표</p>
<p class="mt-2 text-sm font-semibold text-stone-800">{{ userDetail.user.plannerEntryCount }} / {{ userDetail.user.goalCount }}</p>
</div>
<div class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-4">
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">활성 세션</p>
<p class="mt-2 text-sm font-semibold text-stone-800">{{ userDetail.user.activeSessionCount }}</p>
</div>
</div>
<div class="mt-6">
<p class="text-[11px] font-bold uppercase tracking-[0.2em] text-stone-500">최근 플래너 기록</p>
<div class="mt-3 space-y-3">
<article
v-for="entry in userDetail.plannerEntries"
:key="entry.entryDate"
class="rounded-2xl border border-stone-200 bg-[#fffdfa] px-4 py-4"
>
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-semibold text-stone-900">{{ entry.entryDate }}</p>
<p class="text-[11px] font-semibold text-stone-500">{{ formatDate(entry.updatedAt) }}</p>
</div>
<p class="mt-3 text-sm font-semibold leading-6 text-stone-700">{{ getPlannerSummary(entry.payload).comment }}</p>
<div class="mt-3 flex flex-wrap gap-2 text-[11px] font-bold tracking-[0.12em] text-stone-500">
<span>작업 {{ getPlannerSummary(entry.payload).taskCount }}</span>
<span>완료 {{ getPlannerSummary(entry.payload).completedCount }}</span>
<span>메모 {{ getPlannerSummary(entry.payload).memoCount }}</span>
</div>
</article>
<p
v-if="userDetail.plannerEntries.length === 0"
class="rounded-2xl border border-dashed border-stone-300 bg-white px-4 py-4 text-sm font-semibold text-stone-500"
>
아직 작성한 플래너 기록이 없습니다.
</p>
</div>
</div>
</div>
<div class="rounded-[24px] border border-stone-200 bg-white p-5">
<p class="text-[11px] font-bold uppercase tracking-[0.2em] text-stone-500">목표 목록</p>
<div class="mt-3 space-y-3">
<article
v-for="goal in userDetail.goals"
:key="goal.id"
class="rounded-2xl border border-stone-200 bg-[#fffdfa] px-4 py-4"
>
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-semibold text-stone-900">{{ goal.title }}</p>
<span class="rounded-full border border-stone-200 px-3 py-1 text-[10px] font-bold tracking-[0.12em] text-stone-500">
{{ goal.targetDate }}
</span>
</div>
<div class="mt-3 flex flex-wrap gap-2 text-[11px] font-bold tracking-[0.12em] text-stone-500">
<span>표시 시작 {{ goal.activeFrom || '미설정' }}</span>
<span>표시 종료 {{ goal.activeUntil || '미설정' }}</span>
</div>
</article>
<p
v-if="userDetail.goals.length === 0"
class="rounded-2xl border border-dashed border-stone-300 bg-white px-4 py-4 text-sm font-semibold text-stone-500"
>
등록된 목표가 없습니다.
</p>
</div>
</div>
</div>
<div
v-else
class="mt-5 rounded-[24px] border border-dashed border-stone-300 bg-white px-5 py-10 text-center text-sm font-semibold text-stone-500"
>
사용자 목록에서 `상세 보기` 눌러 기록과 목표를 확인해 주세요.
</div>
</section>
</section>
</div>
</section>
</template>

View File

@@ -20,11 +20,16 @@ const props = defineProps({
type: String, type: String,
default: '', default: '',
}, },
showResendVerification: {
type: Boolean,
default: false,
},
}) })
const emit = defineEmits([ const emit = defineEmits([
'close', 'close',
'submit', 'submit',
'resend-verification',
'switch-mode', 'switch-mode',
'update:field', 'update:field',
]) ])
@@ -32,25 +37,77 @@ const emit = defineEmits([
function updateField(field, event) { function updateField(field, event) {
emit('update:field', { emit('update:field', {
field, field,
value: event.target.value, value: event.target.type === 'checkbox' ? event.target.checked : event.target.value,
}) })
} }
function getTitle(mode) {
if (mode === 'signup') {
return '회원가입'
}
if (mode === 'reset-request') {
return '비밀번호 찾기'
}
if (mode === 'reset-confirm') {
return '새 비밀번호 설정'
}
return '로그인'
}
function getDescription(mode) {
if (mode === 'signup') {
return '기록을 저장할 계정을 만들어요.'
}
if (mode === 'reset-request') {
return '가입한 이메일로 재설정 링크를 받을 수 있습니다.'
}
if (mode === 'reset-confirm') {
return '메일로 받은 링크의 토큰과 새 비밀번호를 확인합니다.'
}
return '내 플래너를 이어서 열어요.'
}
function getSubmitLabel(mode, busy) {
if (busy) {
return '처리 중...'
}
if (mode === 'signup') {
return '가입하기'
}
if (mode === 'reset-request') {
return '재설정 링크 받기'
}
if (mode === 'reset-confirm') {
return '비밀번호 재설정'
}
return '로그인하기'
}
</script> </script>
<template> <template>
<div <div
v-if="open" v-if="open"
class="fixed inset-0 z-50 flex items-center justify-center bg-stone-900/45 px-4 py-8 backdrop-blur-sm" class="fixed inset-0 z-50 flex items-center justify-center bg-stone-900/45 px-4 py-6 backdrop-blur-sm"
> >
<div class="w-full max-w-md rounded-[28px] border border-white/60 bg-[#f6f1e8] p-6 shadow-2xl sm:p-7"> <div class="w-full max-w-[420px] rounded-[26px] border border-white/70 bg-[#f7f2ea] p-5 shadow-[0_24px_80px_rgba(28,25,23,0.2)] sm:p-6">
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div class="space-y-2"> <div>
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">Account</p> <p class="text-[10px] font-bold uppercase tracking-[0.24em] text-stone-500">10 Minute Planner</p>
<h2 class="text-2xl font-semibold tracking-[-0.04em] text-stone-900"> <h2 class="mt-2 text-2xl font-semibold tracking-[-0.04em] text-stone-900">
{{ mode === 'login' ? '로그인' : '회원가입' }} {{ getTitle(mode) }}
</h2> </h2>
<p class="text-sm leading-6 text-stone-600"> <p class="mt-2 text-sm leading-6 text-stone-600">
{{ mode === 'login' ? '저장된 플래너를 다시 이어서 볼 수 있습니다.' : '사용자별 기록과 통계를 연결하기 위한 계정을 만듭니다.' }} {{ getDescription(mode) }}
</p> </p>
</div> </div>
<button <button
@@ -68,57 +125,116 @@ function updateField(field, event) {
<input <input
:value="form.nickname" :value="form.nickname"
type="text" type="text"
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" class="w-full rounded-2xl border border-stone-300 bg-white/90 px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-500"
placeholder="닉네임을 입력해 주세요." placeholder="닉네임을 입력해 주세요."
@input="updateField('nickname', $event)" @input="updateField('nickname', $event)"
/> />
</div> </div>
<div class="space-y-2"> <div v-if="mode !== 'reset-confirm'" class="space-y-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">이메일</label> <label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">
{{ mode === 'login' ? '이메일 또는 아이디' : '이메일' }}
</label>
<input <input
:value="form.email" :value="form.email"
type="email" :type="mode === 'login' ? 'text' : 'email'"
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" class="w-full rounded-2xl border border-stone-300 bg-white/90 px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-500"
placeholder="zenn@example.com" :placeholder="mode === 'login' ? '이메일 또는 아이디를 입력해 주세요.' : 'you@example.com'"
@input="updateField('email', $event)" @input="updateField('email', $event)"
/> />
</div> </div>
<div class="space-y-2"> <div v-if="mode === 'reset-confirm'" class="space-y-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">재설정 토큰</label>
<input
:value="form.resetToken"
type="text"
class="w-full rounded-2xl border border-stone-300 bg-white/90 px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-500"
placeholder="메일 링크의 토큰"
@input="updateField('resetToken', $event)"
/>
</div>
<div v-if="mode === 'login' || mode === 'signup'" class="space-y-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">비밀번호</label> <label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">비밀번호</label>
<input <input
:value="form.password" :value="form.password"
type="password" type="password"
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" class="w-full rounded-2xl border border-stone-300 bg-white/90 px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-500"
placeholder="8자 이상 입력해 주세요." placeholder="8자 이상 입력해 주세요."
@input="updateField('password', $event)" @input="updateField('password', $event)"
/> />
</div> </div>
<p <div v-if="mode === 'reset-confirm'" class="space-y-2">
v-if="message" <label class="text-[11px] font-bold tracking-[0.16em] text-stone-600"> 비밀번호</label>
class="rounded-2xl border border-stone-300 bg-white/80 px-4 py-3 text-sm font-semibold leading-6 text-stone-700" <input
:value="form.newPassword"
type="password"
class="w-full rounded-2xl border border-stone-300 bg-white/90 px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-500"
placeholder="8자 이상 입력해 주세요."
@input="updateField('newPassword', $event)"
/>
</div>
<label
v-if="mode === 'login'"
class="-mt-1 flex items-center gap-2 px-1 text-left"
> >
{{ message }} <input
</p> :checked="form.rememberSession"
type="checkbox"
class="h-4 w-4 shrink-0 accent-stone-900"
@change="updateField('rememberSession', $event)"
/>
<span class="text-xs font-bold tracking-[0.08em] text-stone-700">로그인 상태 유지</span>
</label>
<div
v-if="message"
class="rounded-2xl border border-stone-300 bg-white/80 px-4 py-3"
>
<div class="flex items-start justify-between gap-4">
<p class="min-w-0 text-sm font-semibold leading-6 text-stone-700">
{{ message }}
</p>
<button
v-if="showResendVerification"
type="button"
class="shrink-0 rounded-full border border-stone-300 px-3 py-2 text-[10px] font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-500 hover:text-stone-900"
:disabled="busy"
@click="emit('resend-verification')"
>
재전송
</button>
</div>
</div>
<button <button
type="submit" type="submit"
class="w-full rounded-full bg-stone-900 px-5 py-3 text-xs font-bold tracking-[0.18em] text-white transition hover:bg-stone-700 disabled:cursor-not-allowed disabled:bg-stone-400" class="w-full rounded-full bg-stone-900 px-5 py-3 text-xs font-bold tracking-[0.18em] text-white transition hover:bg-stone-700 disabled:cursor-not-allowed disabled:bg-stone-400"
:disabled="busy" :disabled="busy"
> >
{{ busy ? '처리 중...' : mode === 'login' ? 'LOGIN' : 'SIGN UP' }} {{ getSubmitLabel(mode, busy) }}
</button> </button>
</form> </form>
<div class="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-stone-300 bg-white/70 px-4 py-3"> <button
v-if="mode === 'login'"
type="button"
class="mt-4 w-full text-center text-xs font-bold tracking-[0.14em] text-stone-500 underline underline-offset-4 transition hover:text-stone-900"
@click="emit('switch-mode', 'reset-request')"
>
비밀번호를 잊으셨나요?
</button>
<div class="mt-5 flex items-center justify-center gap-2 border-t border-stone-300/70 pt-4">
<p class="text-sm font-semibold text-stone-600"> <p class="text-sm font-semibold text-stone-600">
{{ mode === 'login' ? '아직 계정이 나요?' : '이미 계정이 있나요?' }} {{ mode === 'signup' ? '이미 계정이 나요?' : '계정 화면으로 돌아갈까요?' }}
</p> </p>
<button <button
type="button" type="button"
class="text-xs font-bold tracking-[0.16em] text-stone-900 underline underline-offset-4" class="text-xs font-bold tracking-[0.14em] text-stone-900 underline underline-offset-4"
@click="emit('switch-mode', mode === 'login' ? 'signup' : 'login')" @click="emit('switch-mode', mode === 'login' ? 'signup' : 'login')"
> >
{{ mode === 'login' ? '회원가입' : '로그인' }} {{ mode === 'login' ? '회원가입' : '로그인' }}

View File

@@ -0,0 +1,143 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
const props = defineProps({
title: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
visible: {
type: Boolean,
default: true,
},
dismissible: {
type: Boolean,
default: true,
},
buttonLabel: {
type: String,
default: '?',
},
})
const emit = defineEmits(['dismiss'])
const open = ref(false)
const rootRef = ref(null)
const buttonRef = ref(null)
const popupStyle = ref({})
const isCompactButtonLabel = computed(() => String(props.buttonLabel || '').trim().length <= 1)
function close() {
open.value = false
}
function updatePopupPosition() {
if (!open.value || !buttonRef.value || typeof window === 'undefined') {
return
}
const rect = buttonRef.value.getBoundingClientRect()
const viewportWidth = window.innerWidth
const preferredWidth = Math.min(256, Math.max(196, viewportWidth - 24))
const horizontalPadding = 12
let left = rect.left
if (left + preferredWidth > viewportWidth - horizontalPadding) {
left = viewportWidth - preferredWidth - horizontalPadding
}
if (left < horizontalPadding) {
left = horizontalPadding
}
popupStyle.value = {
left: `${left}px`,
top: `${rect.bottom + 8}px`,
width: `${preferredWidth}px`,
maxWidth: `calc(100vw - ${horizontalPadding * 2}px)`,
}
}
async function toggle() {
if (!props.visible) {
return
}
open.value = !open.value
if (open.value) {
await nextTick()
updatePopupPosition()
}
}
function closeFromOutside(event) {
if (!open.value || rootRef.value?.contains(event.target)) {
return
}
close()
}
function dismiss() {
emit('dismiss')
close()
}
if (typeof window !== 'undefined') {
window.addEventListener('pointerdown', closeFromOutside)
window.addEventListener('resize', updatePopupPosition)
window.addEventListener('scroll', updatePopupPosition, true)
}
onBeforeUnmount(() => {
window.removeEventListener('pointerdown', closeFromOutside)
window.removeEventListener('resize', updatePopupPosition)
window.removeEventListener('scroll', updatePopupPosition, true)
})
</script>
<template>
<span
v-if="visible"
ref="rootRef"
class="relative inline-flex print:hidden"
>
<button
ref="buttonRef"
type="button"
class="inline-flex shrink-0 items-center justify-center whitespace-nowrap border border-stone-300 bg-white text-[10px] font-bold text-stone-500 transition hover:border-stone-500 hover:text-stone-900 focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2"
:class="isCompactButtonLabel ? 'h-5 w-5 rounded-full' : 'min-h-[22px] rounded-full px-2 py-1 leading-none'"
aria-label="가이드 보기"
:aria-expanded="open"
@click.stop="toggle"
>
{{ buttonLabel }}
</button>
<Teleport to="body">
<span
v-if="open"
class="fixed z-[120] rounded-2xl border border-stone-200 bg-white p-4 text-left shadow-[0_18px_50px_rgba(28,25,23,0.16)] print:hidden"
:style="popupStyle"
@pointerdown.stop
>
<span class="block text-[10px] font-bold uppercase tracking-[0.2em] text-stone-500">{{ title }}</span>
<span class="mt-2 block break-words text-[11px] font-semibold leading-5 tracking-[0.04em] text-stone-700">{{ description }}</span>
<button
v-if="dismissible"
type="button"
class="mt-3 rounded-full border border-stone-200 px-3 py-2 text-[10px] font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-500 hover:text-stone-900"
@click="dismiss"
>
이상 보지 않기
</button>
</span>
</Teleport>
</span>
</template>

View File

@@ -40,23 +40,25 @@ function selectYear(year) {
</script> </script>
<template> <template>
<section class="rounded-[24px] border border-stone-200 bg-white/82 p-5 shadow-[0_12px_36px_rgba(28,25,23,0.05)]"> <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"
@mousedown.prevent
@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]"
@mousedown.prevent
@click="isYearPickerOpen = !isYearPickerOpen" @click="isYearPickerOpen = !isYearPickerOpen"
> >
{{ yearLabel }} {{ yearLabel }}
@@ -64,7 +66,8 @@ 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"
@mousedown.prevent
@click="emit('shift-month', 1)" @click="emit('shift-month', 1)"
> >
@@ -73,7 +76,8 @@ 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"
@mousedown.prevent
@click="emit('go-today')" @click="emit('go-today')"
> >
TODAY TODAY
@@ -81,12 +85,13 @@ 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
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="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"
@mousedown.prevent
@click="emit('shift-year', -12)" @click="emit('shift-year', -12)"
> >
@@ -97,6 +102,7 @@ function selectYear(year) {
<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="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"
@mousedown.prevent
@click="emit('shift-year', 12)" @click="emit('shift-year', 12)"
> >
@@ -109,6 +115,7 @@ function selectYear(year) {
type="button" type="button"
class="rounded-xl border px-3 py-2 text-[11px] font-semibold transition" class="rounded-xl border px-3 py-2 text-[11px] font-semibold transition"
:class="year === currentYearNumber ? 'border-ink bg-ink text-white' : 'border-stone-200 text-stone-700 hover:border-stone-400 hover:bg-stone-50'" :class="year === currentYearNumber ? 'border-ink bg-ink text-white' : 'border-stone-200 text-stone-700 hover:border-stone-400 hover:bg-stone-50'"
@mousedown.prevent
@click="selectYear(year)" @click="selectYear(year)"
> >
{{ year }} {{ year }}
@@ -116,31 +123,39 @@ 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 h-10 w-10 shrink-0 items-center justify-center rounded-full border text-[10px] font-semibold transition sm:h-[44px] sm:w-[44px] 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',
]"
@mousedown.prevent
@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

@@ -1,5 +1,6 @@
<script setup> <script setup>
import { onBeforeUnmount } from 'vue' import { onBeforeUnmount, ref } from 'vue'
import GuideTooltip from './GuideTooltip.vue'
const props = defineProps({ const props = defineProps({
dateMain: { dateMain: {
@@ -50,6 +51,10 @@ const props = defineProps({
type: String, type: String,
default: 'SORI.STUDIO', default: 'SORI.STUDIO',
}, },
readonly: {
type: Boolean,
default: false,
},
}) })
const emit = defineEmits([ const emit = defineEmits([
@@ -57,12 +62,22 @@ const emit = defineEmits([
'update:task-label', 'update:task-label',
'update:task-title', 'update:task-title',
'toggle:task', 'toggle:task',
'clear:tasks',
'update:memo-label', 'update:memo-label',
'update:memo', 'update:memo',
'update:timetable', 'update:timetable',
'timetable-action',
'timetable-contextmenu',
]) ])
const LONG_PRESS_DELAY_MS = 420
const TOUCH_MOVE_CANCEL_DISTANCE = 10
let dragState = null let dragState = null
let timetableTouchTimer = null
let taskSelectionDrag = null
const selectedTaskIndexes = ref(new Set())
const pendingTimetableTouchIndex = ref(null)
function shouldShowTaskPlaceholder(index) { function shouldShowTaskPlaceholder(index) {
return index === 0 && props.tasks.every((task) => !task.title.trim()) return index === 0 && props.tasks.every((task) => !task.title.trim())
@@ -80,24 +95,83 @@ function buildTimedRange(baseTimetable, startIndex, endIndex, nextValue) {
return nextTimetable return nextTimetable
} }
function clearTimetableTouchTimer() {
if (timetableTouchTimer) {
window.clearTimeout(timetableTouchTimer)
timetableTouchTimer = null
}
}
function beginTimetableDrag(index, shouldFill) {
dragState = {
startIndex: index,
baseTimetable: [...props.timetable],
nextValue: shouldFill,
isActive: true,
isTouchPending: false,
}
pendingTimetableTouchIndex.value = null
emit('update:timetable', buildTimedRange(dragState.baseTimetable, index, index, dragState.nextValue))
}
function getTimetableCellIndexAtPoint(clientX, clientY) {
const cell = document.elementFromPoint(clientX, clientY)?.closest('[data-timetable-index]')
const index = Number(cell?.dataset.timetableIndex)
return Number.isInteger(index) ? index : null
}
function getTimetableCellIndexFromPointer(event) {
return getTimetableCellIndexAtPoint(event.clientX, event.clientY)
}
function startTimetableDrag(index, event) { function startTimetableDrag(index, event) {
if (props.readonly) {
return
}
if (event.button !== 0 && event.button !== 2) { if (event.button !== 0 && event.button !== 2) {
return return
} }
const shouldFill = event.button === 2 ? false : !props.timetable[index] const shouldFill = event.button === 2 ? false : !props.timetable[index]
dragState = { if (event.pointerType === 'touch') {
startIndex: index, clearTimetableTouchTimer()
baseTimetable: [...props.timetable], pendingTimetableTouchIndex.value = index
nextValue: shouldFill, dragState = {
startIndex: index,
baseTimetable: [...props.timetable],
nextValue: shouldFill,
isActive: false,
isTouchPending: true,
touchStartX: event.clientX,
touchStartY: event.clientY,
pointerId: event.pointerId,
}
event.currentTarget?.setPointerCapture?.(event.pointerId)
timetableTouchTimer = window.setTimeout(() => {
if (!dragState?.isTouchPending || dragState.pointerId !== event.pointerId) {
return
}
dragState.isTouchPending = false
dragState.isActive = true
pendingTimetableTouchIndex.value = null
emit('update:timetable', buildTimedRange(dragState.baseTimetable, index, index, dragState.nextValue))
}, LONG_PRESS_DELAY_MS)
return
} }
emit('update:timetable', buildTimedRange(dragState.baseTimetable, index, index, dragState.nextValue)) event.preventDefault()
beginTimetableDrag(index, shouldFill)
} }
function moveTimetableDrag(index) { function moveTimetableDrag(index) {
if (!dragState) { if (props.readonly || !dragState?.isActive) {
return return
} }
@@ -107,84 +181,327 @@ function moveTimetableDrag(index) {
) )
} }
function moveTimetableDragFromPointer(event) {
if (props.readonly || !dragState) {
return
}
if (dragState.isTouchPending) {
const movedX = Math.abs(event.clientX - dragState.touchStartX)
const movedY = Math.abs(event.clientY - dragState.touchStartY)
if (movedX > TOUCH_MOVE_CANCEL_DISTANCE || movedY > TOUCH_MOVE_CANCEL_DISTANCE) {
stopTimetableDrag()
}
return
}
if (!dragState.isActive) {
return
}
if (event.pointerType === 'touch') {
event.preventDefault()
}
const index = getTimetableCellIndexFromPointer(event)
if (index !== null) {
moveTimetableDrag(index)
}
}
function moveTimetableDragFromTouch(event) {
if (props.readonly || !dragState) {
return
}
const touch = event.touches[0]
if (!touch) {
return
}
if (dragState.isTouchPending) {
const movedX = Math.abs(touch.clientX - dragState.touchStartX)
const movedY = Math.abs(touch.clientY - dragState.touchStartY)
if (movedX > TOUCH_MOVE_CANCEL_DISTANCE || movedY > TOUCH_MOVE_CANCEL_DISTANCE) {
stopTimetableDrag()
}
return
}
if (!dragState.isActive) {
return
}
event.preventDefault()
const index = getTimetableCellIndexAtPoint(touch.clientX, touch.clientY)
if (index !== null) {
moveTimetableDrag(index)
}
}
function stopTimetableDrag() { function stopTimetableDrag() {
clearTimetableTouchTimer()
pendingTimetableTouchIndex.value = null
dragState = null dragState = null
} }
function isTaskSelectionBlockedTarget(target) {
return Boolean(target.closest('button, textarea, [contenteditable="true"]'))
}
function getTaskSelectionRange(startIndex, endIndex) {
const rangeStart = Math.min(startIndex, endIndex)
const rangeEnd = Math.max(startIndex, endIndex)
return new Set(Array.from({ length: rangeEnd - rangeStart + 1 }, (_, offset) => rangeStart + offset))
}
function isTaskSelected(index) {
return selectedTaskIndexes.value.has(index)
}
function clearTaskSelection() {
selectedTaskIndexes.value = new Set()
taskSelectionDrag = null
}
function clearTaskSelectionOnFocus() {
if (taskSelectionDrag) {
return
}
clearTaskSelection()
}
function startTaskSelection(index, event) {
if (props.readonly) {
return
}
if (event.button !== 0 || isTaskSelectionBlockedTarget(event.target)) {
return
}
selectedTaskIndexes.value = new Set()
taskSelectionDrag = {
startIndex: index,
isSelecting: false,
}
}
function moveTaskSelection(index) {
if (!taskSelectionDrag) {
return
}
if (!taskSelectionDrag.isSelecting && index === taskSelectionDrag.startIndex) {
return
}
if (!taskSelectionDrag.isSelecting) {
taskSelectionDrag.isSelecting = true
document.activeElement?.blur?.()
window.getSelection()?.removeAllRanges?.()
}
selectedTaskIndexes.value = getTaskSelectionRange(taskSelectionDrag.startIndex, index)
}
function moveTaskSelectionFromPointer(event) {
if (!taskSelectionDrag) {
return
}
const taskRow = document.elementFromPoint(event.clientX, event.clientY)?.closest('[data-task-index]')
const index = Number(taskRow?.dataset.taskIndex)
if (Number.isInteger(index)) {
moveTaskSelection(index)
}
}
function stopTaskSelection() {
if (taskSelectionDrag && !taskSelectionDrag.isSelecting) {
selectedTaskIndexes.value = new Set()
}
taskSelectionDrag = null
}
function clearSelectedTasks(event) {
if (props.readonly) {
return
}
if (selectedTaskIndexes.value.size === 0) {
return
}
if (event.key === 'Escape') {
event.preventDefault()
clearTaskSelection()
return
}
if (!['Backspace', 'Delete'].includes(event.key)) {
return
}
event.preventDefault()
emit('clear:tasks', [...selectedTaskIndexes.value])
clearTaskSelection()
}
window.addEventListener('pointermove', moveTimetableDragFromPointer)
window.addEventListener('pointerup', stopTimetableDrag) window.addEventListener('pointerup', stopTimetableDrag)
window.addEventListener('pointercancel', stopTimetableDrag)
window.addEventListener('touchmove', moveTimetableDragFromTouch, { passive: false })
window.addEventListener('touchend', stopTimetableDrag)
window.addEventListener('touchcancel', stopTimetableDrag)
window.addEventListener('pointermove', moveTaskSelectionFromPointer)
window.addEventListener('pointerup', stopTaskSelection)
window.addEventListener('keydown', clearSelectedTasks)
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('pointermove', moveTimetableDragFromPointer)
window.removeEventListener('pointerup', stopTimetableDrag) window.removeEventListener('pointerup', stopTimetableDrag)
window.removeEventListener('pointercancel', stopTimetableDrag)
window.removeEventListener('touchmove', moveTimetableDragFromTouch)
window.removeEventListener('touchend', stopTimetableDrag)
window.removeEventListener('touchcancel', stopTimetableDrag)
window.removeEventListener('pointermove', moveTaskSelectionFromPointer)
window.removeEventListener('pointerup', stopTaskSelection)
window.removeEventListener('keydown', clearSelectedTasks)
}) })
</script> </script>
<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="planner-sheet__meta flex flex-col gap-4 py-3 sm:py-[18px]">
<div class="flex gap-4"> <div class="planner-sheet__meta-top 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="planner-field min-h-[82px] 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> <div class="planner-field__header flex items-center gap-2 text-muted">
<p class="pt-6 text-xs tracking-[0.24em] text-ink sm:text-sm"> <span class="shrink-0">YEAR / MONTH / DAY</span>
<span class="h-px flex-1 bg-ink"></span>
</div>
<p class="pt-6 text-[11px] tracking-[0.2em] text-ink 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="planner-field min-h-[82px] w-full px-[10px] pt-[10px] sm:w-[210px]">
<span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">D-DAY</span> <div class="planner-field__header flex items-center gap-2 text-muted">
<p class="pt-6 text-xs tracking-[0.24em] text-ink sm:text-sm">{{ dday }}</p> <span class="shrink-0">D-DAY</span>
<span class="h-px flex-1 bg-ink"></span>
</div>
<p
class="pt-6 text-[11px] tracking-[0.14em] text-ink sm:text-sm"
style="
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
"
>
{{ dday }}
</p>
</div> </div>
</div> </div>
<div class="flex gap-4 border-b border-ink pb-[18px]"> <div class="planner-sheet__meta-bottom 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="planner-field min-h-[82px] w-full flex-1 px-[10px] pt-[10px] lg:w-[394px]">
<span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">COMMENT</span> <div class="planner-field__header flex items-center gap-2 text-muted">
<span class="shrink-0">COMMENT</span>
<span class="h-px flex-1 bg-ink"></span>
</div>
<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" :readonly="props.readonly"
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: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="planner-field min-h-[82px] w-full px-[10px] pt-[10px] lg:w-[210px]">
<span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">TOTAL TIME</span> <div class="planner-field__header flex items-center gap-2 text-muted">
<p class="pt-6 text-xs tracking-[0.24em] text-ink sm:text-sm">{{ totalTime }}</p> <span class="shrink-0">FOCUSED TIME</span>
<span class="h-px flex-1 bg-ink"></span>
</div>
<p class="pt-6 text-[11px] tracking-[0.2em] text-ink sm:text-sm">{{ totalTime }}</p>
</div> </div>
</div> </div>
</div> </div>
<div class="flex gap-4 py-[10px]"> <div class="planner-sheet__body 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="planner-sheet__lists flex w-full flex-1 flex-col gap-[26px] print:gap-[21px] lg:w-[394px]">
<section class="relative"> <section>
<div class="absolute -top-[9px] left-0 bg-paper px-[2px] text-muted">TASKS</div> <div class="flex items-center gap-2 text-muted">
<div class="border-t border-ink"> <span class="shrink-0">TASKS</span>
<span
v-if="selectedTaskIndexes.size > 0"
class="shrink-0 rounded-full border border-ink/30 px-2 py-[1px] text-[8px] tracking-[0.12em] text-ink"
>
선택 {{ selectedTaskIndexes.size }} · DEL 삭제 · ESC 취소
</span>
<span class="h-px flex-1 bg-ink"></span>
</div>
<div>
<div <div
v-for="(task, index) in tasks" v-for="(task, index) in tasks"
:key="task.id" :key="task.id ?? index"
class="flex h-[38px] items-center border-b" :data-task-index="index"
:class="index % 5 === 4 || index === tasks.length - 1 ? 'border-ink' : 'border-line'" class="flex h-[38px] select-none items-center border-b transition-colors"
:class="[
index % 5 === 4 || index === tasks.length - 1 ? 'border-ink' : 'border-line',
isTaskSelected(index) ? 'bg-amber-100/55' : '',
]"
@pointerdown="startTaskSelection(index, $event)"
@pointerenter="moveTaskSelection(index)"
> >
<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"
:readonly="props.readonly"
class="w-full bg-transparent text-center text-[9px] font-semibold tracking-[0.08em] text-stone-500 outline-none placeholder:text-stone-300" class="w-full bg-transparent text-center text-[9px] font-semibold tracking-[0.08em] text-stone-500 outline-none placeholder:text-stone-300"
@focus="clearTaskSelectionOnFocus"
@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 gap-2 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" :readonly="props.readonly"
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) ? '할 일을 입력해 주세요.' : ''"
@focus="clearTaskSelectionOnFocus"
@input="emit('update:task-title', { index, value: $event.target.value })" @input="emit('update:task-title', { index, value: $event.target.value })"
/> />
<GuideTooltip
v-if="task.carryoverFrom"
:title="'이월된 할 일'"
:description="`이 항목은 ${task.carryoverFrom}부터 이월된 할 일입니다.`"
:dismissible="false"
button-label="이월"
/>
</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" :disabled="props.readonly"
class="flex h-full w-full items-center justify-center border border-dashed transition focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
:class="task.checked ? 'border-ink bg-stone-100 text-ink' : 'border-ink/60 text-transparent'" :class="task.checked ? 'border-ink bg-stone-100 text-ink' : 'border-ink/60 text-transparent'"
@click="emit('toggle:task', index)" @click="emit('toggle:task', index)"
> >
@@ -195,28 +512,33 @@ onBeforeUnmount(() => {
</div> </div>
</section> </section>
<section class="relative"> <section>
<div class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">MEMO</div> <div class="flex items-center gap-2 text-muted">
<div class="border-t border-ink"> <span class="shrink-0">MEMO</span>
<span class="h-px flex-1 bg-ink"></span>
</div>
<div>
<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 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"
:readonly="props.readonly"
class="w-full bg-transparent text-center text-[9px] font-semibold tracking-[0.08em] text-stone-500 outline-none placeholder:text-stone-300" class="w-full bg-transparent text-center text-[9px] font-semibold tracking-[0.08em] text-stone-500 outline-none placeholder:text-stone-300"
@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" :readonly="props.readonly"
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 +547,55 @@ onBeforeUnmount(() => {
</section> </section>
</div> </div>
<section class="relative w-[210px] shrink-0"> <section class="planner-sheet__timetable w-full shrink-0 lg:w-[210px]">
<div class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">TIME TABLE</div> <div v-if="!props.readonly" class="flex items-center gap-2 text-muted">
<div class="border-t border-ink"> <button
<div type="button"
v-for="(hour, index) in hours" class="shrink-0 text-left transition hover:text-ink"
:key="`${hour}-${index}`" @click="emit('timetable-action')"
class="flex h-[30px] border-b" @contextmenu.prevent="emit('timetable-contextmenu', $event)"
:class="index === hours.length - 1 ? 'border-ink' : 'border-line'"
> >
<span class="shrink-0">TIME TABLE</span>
</button>
<span class="h-px flex-1 bg-ink"></span>
<GuideTooltip
title="Time Table"
description="먼저 원하는 날짜의 TIME TABLE 제목을 클릭해 타임테이블을 저장하세요. 그다음 붙여넣을 날짜로 이동해서 TIME TABLE 제목을 마우스 오른쪽 클릭하면 손쉽게 같은 내용을 붙여넣을 수 있습니다."
:dismissible="false"
/>
</div>
<div v-else class="flex items-center gap-2 text-muted">
<span class="shrink-0">TIME TABLE</span>
<span class="h-px flex-1 bg-ink"></span>
</div>
<div class="planner-sheet__timetable-scroll overflow-x-auto pb-1">
<div class="planner-sheet__timetable-grid min-w-[210px] w-full">
<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="grid h-[25px] grid-cols-[30px_repeat(6,minmax(0,1fr))] border-b sm:h-[30px] lg:grid-cols-[30px_repeat(6,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"
:data-timetable-index="index * 6 + quarter - 1"
:class="[
props.timetable[index * 6 + quarter - 1] ? 'bg-stone-800/90' : 'bg-transparent',
pendingTimetableTouchIndex === index * 6 + quarter - 1 ? 'ring-1 ring-inset ring-stone-500/70' : '',
]"
class="h-full cursor-crosshair touch-pan-y select-none border-r border-dashed border-line transition-colors last:border-r-0"
@contextmenu.prevent
@pointerdown="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>

View File

@@ -14,6 +14,14 @@ const props = defineProps({
type: Object, type: Object,
required: true, required: true,
}, },
accountDeleteForm: {
type: Object,
required: true,
},
authSessionInfo: {
type: Object,
required: true,
},
profileBusy: { profileBusy: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -30,6 +38,22 @@ const props = defineProps({
type: String, type: String,
default: '', default: '',
}, },
accountDeleteBusy: {
type: Boolean,
default: false,
},
accountDeleteMessage: {
type: String,
default: '',
},
guideTooltipResetMessage: {
type: String,
default: '',
},
carryoverCheckPolicy: {
type: String,
default: 'ask',
},
}) })
const emit = defineEmits([ const emit = defineEmits([
@@ -37,6 +61,10 @@ const emit = defineEmits([
'update:password-field', 'update:password-field',
'submit:profile', 'submit:profile',
'submit:password', 'submit:password',
'update:account-delete-field',
'submit:delete-account',
'reset-guide-tooltips',
'update:carryover-check-policy',
]) ])
const initials = computed(() => const initials = computed(() =>
@@ -56,6 +84,13 @@ function updatePasswordField(field, event) {
value: event.target.value, value: event.target.value,
}) })
} }
function updateAccountDeleteField(field, event) {
emit('update:account-delete-field', {
field,
value: event.target.value,
})
}
</script> </script>
<template> <template>
@@ -71,11 +106,76 @@ function updatePasswordField(field, event) {
<p class="mt-1 text-sm font-semibold text-stone-500">{{ user.email }}</p> <p class="mt-1 text-sm font-semibold text-stone-500">{{ user.email }}</p>
</div> </div>
</div> </div>
<div class="mt-6 space-y-3 rounded-[24px] border border-stone-200 bg-[#fbf7f0] p-4"> <!-- <div class="mt-6 space-y-3 rounded-[24px] border border-stone-200 bg-[#fbf7f0] p-4">
<p class="text-[10px] font-bold tracking-[0.18em] text-stone-500">PROFILE NOTE</p> <p class="text-[10px] font-bold tracking-[0.18em] text-stone-500">PROFILE NOTE</p>
<p class="text-sm font-semibold leading-6 text-stone-700"> <p class="text-sm font-semibold leading-6 text-stone-700">
썸네일 이미지는 다음 단계에서 붙이는 편이 자연스럽습니다. 이번 단계에서는 계정 정보 수정과 비밀번호 변경 흐름을 먼저 안정화합니다. ...
</p> </p>
</div> -->
<div class="mt-6 rounded-[24px] border border-stone-200 bg-white/80 p-4">
<p class="text-[10px] font-bold tracking-[0.18em] text-stone-500">GUIDE TOOLTIPS</p>
<p class="mt-3 text-sm font-semibold leading-6 text-stone-700">
숨긴 가이드 툴팁을 다시 표시합니다.
</p>
<button
type="button"
class="mt-4 rounded-full border border-stone-900 px-4 py-3 text-xs font-bold tracking-[0.14em] text-stone-900 transition hover:bg-stone-900 hover:text-white"
@click="emit('reset-guide-tooltips')"
>
가이드 다시 보기
</button>
<p
v-if="guideTooltipResetMessage"
class="mt-3 rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-3 text-xs font-semibold leading-5 text-stone-600"
>
{{ guideTooltipResetMessage }}
</p>
</div>
<div class="mt-4 rounded-[24px] border border-stone-200 bg-white/80 p-4">
<p class="text-[10px] font-bold tracking-[0.18em] text-stone-500">CARRYOVER CHECK</p>
<p class="mt-3 text-sm font-semibold leading-6 text-stone-700">
이월된 일을 완료할 이전 날짜 항목까지 함께 체크할지 정합니다.
</p>
<div class="mt-4 grid gap-2">
<button
v-for="option in [
{ value: 'ask', label: '항상 물어보기' },
{ value: 'all', label: '항상 이전까지 체크' },
{ value: 'current', label: '항상 오늘만 체크' },
]"
:key="option.value"
type="button"
class="rounded-2xl border px-4 py-3 text-left text-xs font-bold tracking-[0.12em] transition"
:class="carryoverCheckPolicy === option.value ? 'border-stone-900 bg-stone-900 text-white' : 'border-stone-200 bg-white text-stone-600 hover:border-stone-400 hover:text-stone-900'"
@click="emit('update:carryover-check-policy', option.value)"
>
{{ option.label }}
</button>
</div>
</div>
<div class="mt-4 rounded-[24px] border border-stone-200 bg-white/80 p-4">
<p class="text-[10px] font-bold tracking-[0.18em] text-stone-500">LOGIN STATUS</p>
<div class="mt-3 space-y-3">
<div class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-3">
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">현재 기기 저장 방식</p>
<p class="mt-2 text-sm font-semibold leading-6 text-stone-800">{{ authSessionInfo.storageLabel }}</p>
<p class="mt-1 text-xs font-semibold leading-5 text-stone-500">{{ authSessionInfo.storageDescription }}</p>
</div>
<div class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-3">
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">최근 로그인</p>
<p class="mt-2 text-sm font-semibold leading-6 text-stone-800">{{ authSessionInfo.lastLoginLabel }}</p>
</div>
<div
v-if="authSessionInfo.verificationLabel"
class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-3"
>
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">이메일 인증</p>
<p class="mt-2 text-sm font-semibold leading-6 text-stone-800">{{ authSessionInfo.verificationLabel }}</p>
</div>
</div>
</div> </div>
</aside> </aside>
@@ -166,6 +266,42 @@ function updatePasswordField(field, event) {
{{ passwordBusy ? '변경 중...' : '비밀번호 변경' }} {{ passwordBusy ? '변경 중...' : '비밀번호 변경' }}
</button> </button>
</form> </form>
<form
v-if="user.role !== 'admin'"
class="rounded-[28px] border border-rose-200 bg-white/75 p-6"
@submit.prevent="emit('submit:delete-account')"
>
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-rose-500">Delete Account</p>
<p class="mt-4 text-sm font-semibold leading-6 text-stone-700">
회원 탈퇴를 진행하면 플래너 기록, 목표, 계정 정보가 함께 삭제됩니다.
</p>
<div class="mt-5 space-y-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">현재 비밀번호 확인</label>
<input
:value="accountDeleteForm.currentPassword"
type="password"
class="w-full rounded-2xl border border-rose-200 bg-white px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-rose-400"
@input="updateAccountDeleteField('currentPassword', $event)"
/>
</div>
<p
v-if="accountDeleteMessage"
class="mt-4 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold leading-6 text-rose-700"
>
{{ accountDeleteMessage }}
</p>
<button
type="submit"
class="mt-5 rounded-full border border-rose-500 px-5 py-3 text-xs font-bold tracking-[0.18em] text-rose-600 transition hover:bg-rose-500 hover:text-white disabled:cursor-not-allowed disabled:border-rose-200 disabled:text-rose-300"
:disabled="accountDeleteBusy"
>
{{ accountDeleteBusy ? '처리 중...' : '회원 탈퇴' }}
</button>
</form>
</div> </div>
</section> </section>
</template> </template>

View File

@@ -1,5 +1,8 @@
<script setup> <script setup>
defineProps({ import { computed, ref } from 'vue'
import GuideTooltip from './GuideTooltip.vue'
const props = defineProps({
overviewCards: { overviewCards: {
type: Array, type: Array,
required: true, required: true,
@@ -30,18 +33,161 @@ defineProps({
}, },
}) })
const emit = defineEmits(['update:range-start', 'update:range-end']) const emit = defineEmits(['update:range-start', 'update:range-end', 'quick-range'])
const flowScrollerRef = ref(null)
const flowDragState = ref(null)
const hoveredFlowRecord = ref(null)
const flowTooltipPosition = ref({ x: 0, y: 0 })
const flowGapClass = computed(() => {
if (props.weeklyRecords.length > 14) {
return 'gap-1'
}
return 'gap-3'
})
const flowItemStyle = computed(() => {
const count = props.weeklyRecords.length
if (count <= 0) {
return {}
}
const gap = count > 14 ? 4 : 12
const minWidth = count <= 7 ? 76 : count <= 14 ? 46 : 18
if (count <= 31) {
return {
flex: '0 0 auto',
width: `max(${minWidth}px, calc((100% - ${(count - 1) * gap}px) / ${count}))`,
}
}
return {
flex: '0 0 auto',
width: '18px',
}
})
const flowBarWidth = computed(() => {
if (props.weeklyRecords.length <= 7) {
return '48px'
}
if (props.weeklyRecords.length <= 14) {
return '30px'
}
return '12px'
})
const shouldShowFlowTime = computed(() => props.weeklyRecords.length <= 14)
function shouldShowFlowLabel(index) {
const count = props.weeklyRecords.length
if (count <= 14) {
return true
}
const interval = Math.ceil(count / 8)
return index === 0 || index === count - 1 || index % interval === 0
}
function updateFlowTooltipPosition(event) {
const tooltipWidth = 176
const tooltipHeight = 86
const margin = 12
const viewportWidth = window.innerWidth || tooltipWidth
const safeX = Math.min(
Math.max(event.clientX, margin + tooltipWidth / 2),
viewportWidth - margin - tooltipWidth / 2,
)
const safeY = Math.max(event.clientY - 18, margin + tooltipHeight)
flowTooltipPosition.value = {
x: safeX,
y: safeY,
}
}
function showFlowTooltip(record, event) {
hoveredFlowRecord.value = record
updateFlowTooltipPosition(event)
}
function moveFlowTooltip(event) {
if (!hoveredFlowRecord.value) {
return
}
updateFlowTooltipPosition(event)
}
function hideFlowTooltip() {
hoveredFlowRecord.value = null
}
function startFlowDrag(event) {
if (!flowScrollerRef.value || event.button !== 0) {
return
}
flowDragState.value = {
pointerId: event.pointerId,
startX: event.clientX,
scrollLeft: flowScrollerRef.value.scrollLeft,
}
flowScrollerRef.value.setPointerCapture?.(event.pointerId)
}
function moveFlowDrag(event) {
if (!flowDragState.value || !flowScrollerRef.value) {
return
}
event.preventDefault()
flowScrollerRef.value.scrollLeft =
flowDragState.value.scrollLeft - (event.clientX - flowDragState.value.startX)
}
function stopFlowDrag(event) {
if (!flowDragState.value) {
return
}
flowScrollerRef.value?.releasePointerCapture?.(event.pointerId)
flowDragState.value = null
}
</script> </script>
<template> <template>
<section class="grid gap-6"> <section class="grid gap-6">
<article class="rounded-[28px] border border-white/60 bg-white/80 p-5 shadow-paper backdrop-blur"> <article class="rounded-[28px] border border-stone-200 bg-white/86 p-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between"> <div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div> <div>
<p class="text-[11px] font-bold tracking-[0.22em] text-stone-500">RANGE</p> <p class="text-[11px] font-bold tracking-[0.22em] text-stone-500">RANGE</p>
<h2 class="mt-2 text-2xl font-semibold tracking-[-0.05em] text-stone-900"> <h2 class="mt-2 text-2xl font-semibold tracking-[-0.05em] text-stone-900">
원하는 기간 기준으로 통계 보기 원하는 기간 기준으로 통계 보기
</h2> </h2>
<div class="mt-4 flex flex-wrap gap-2">
<button
type="button"
class="rounded-full border border-stone-200 bg-white px-4 py-2 text-[11px] font-bold tracking-[0.12em] text-stone-600 transition hover:border-stone-500 hover:text-stone-900"
@click="emit('quick-range', 7)"
>
최근 1
</button>
<button
type="button"
class="rounded-full border border-stone-200 bg-white px-4 py-2 text-[11px] font-bold tracking-[0.12em] text-stone-600 transition hover:border-stone-500 hover:text-stone-900"
@click="emit('quick-range', 30)"
>
최근 1
</button>
</div>
</div> </div>
<div class="grid gap-3 sm:grid-cols-2"> <div class="grid gap-3 sm:grid-cols-2">
<label class="flex flex-col gap-2 text-[11px] font-bold tracking-[0.14em] text-stone-500"> <label class="flex flex-col gap-2 text-[11px] font-bold tracking-[0.14em] text-stone-500">
@@ -70,7 +216,7 @@ const emit = defineEmits(['update:range-start', 'update:range-end'])
<article <article
v-for="card in overviewCards" v-for="card in overviewCards"
:key="card.label" :key="card.label"
class="rounded-[28px] border border-white/60 bg-white/80 p-5 shadow-paper backdrop-blur" class="rounded-[28px] border border-stone-200 bg-white/86 p-5"
> >
<p class="text-[11px] font-bold tracking-[0.22em] text-stone-500">{{ card.label }}</p> <p class="text-[11px] font-bold tracking-[0.22em] text-stone-500">{{ card.label }}</p>
<p class="mt-4 text-[34px] font-semibold tracking-[-0.06em] text-stone-900">{{ card.value }}</p> <p class="mt-4 text-[34px] font-semibold tracking-[-0.06em] text-stone-900">{{ card.value }}</p>
@@ -78,43 +224,95 @@ const emit = defineEmits(['update:range-start', 'update:range-end'])
</article> </article>
</div> </div>
<div class="grid gap-6 xl:grid-cols-[minmax(0,1.3fr)_minmax(320px,0.7fr)]"> <div class="grid items-start gap-6 xl:grid-cols-[minmax(0,1.3fr)_minmax(320px,0.7fr)]">
<article class="rounded-[28px] border border-white/60 bg-white/80 p-6 shadow-paper backdrop-blur"> <article class="rounded-[28px] border border-stone-200 bg-white/86 p-6">
<div class="flex items-end justify-between gap-4"> <div class="flex items-end justify-between gap-4">
<div> <div>
<p class="text-[11px] font-bold tracking-[0.22em] text-stone-500">WEEKLY FLOW</p> <p class="text-[11px] font-bold tracking-[0.22em] text-stone-500">RANGE FLOW</p>
<h2 class="mt-2 text-2xl font-semibold tracking-[-0.05em] text-stone-900"> <h2 class="mt-2 text-2xl font-semibold tracking-[-0.05em] text-stone-900">
선택 기간 기록 흐름 선택 기간 날짜별 집중 흐름
</h2> </h2>
<p class="mt-2 text-[12px] font-semibold leading-5 text-stone-500">
기록이 있는 날짜만 날짜순으로 표시합니다.
</p>
</div> </div>
<p class="text-[11px] font-semibold tracking-[0.06em] text-stone-500"> <p class="text-[11px] font-semibold tracking-[0.06em] text-stone-500">
기준 날짜: {{ selectedDateLabel }} 기준 날짜: {{ selectedDateLabel }}
</p> </p>
</div> </div>
<div class="mt-8 flex gap-3 overflow-x-auto pb-2"> <div
ref="flowScrollerRef"
class="mt-8 flex h-[268px] select-none items-stretch overflow-x-auto overscroll-x-contain border-b border-stone-200 pb-4 cursor-grab active:cursor-grabbing"
:class="flowGapClass"
@pointerdown="startFlowDrag"
@pointermove="moveFlowDrag"
@pointerup="stopFlowDrag"
@pointercancel="stopFlowDrag"
@pointerleave="stopFlowDrag"
>
<div <div
v-for="record in weeklyRecords" v-for="(record, index) in weeklyRecords"
:key="record.key" :key="record.key"
class="flex min-w-[56px] flex-col items-center gap-3" class="relative flex flex-col items-center justify-end gap-3"
:style="flowItemStyle"
@mouseenter="showFlowTooltip(record, $event)"
@mousemove="moveFlowTooltip"
@mouseleave="hideFlowTooltip"
> >
<div class="flex h-40 items-end"> <div class="flex h-44 items-end">
<div <div
class="w-10 rounded-full bg-stone-900/90 transition-all" class="rounded-t-full bg-stone-900/90 transition-all"
:style="{ height: `${record.barHeight}%` }" :style="{ width: flowBarWidth, height: `${record.barHeight}%` }"
/> />
</div> </div>
<div class="text-center"> <div class="h-14 text-center">
<p class="text-[11px] font-bold tracking-[0.12em] text-stone-500">{{ record.weekday }}</p> <template v-if="shouldShowFlowLabel(index)">
<p class="mt-1 text-[11px] font-semibold text-stone-800">{{ record.focusedTime }}</p> <p class="select-none text-[11px] font-bold tracking-[0.12em] text-stone-500">{{ record.dateLabel }}</p>
<p class="mt-1 select-none text-[10px] font-bold tracking-[0.12em] text-stone-400">{{ record.weekday }}</p>
</template>
<p
v-if="shouldShowFlowTime"
class="mt-1 select-none text-[11px] font-semibold text-stone-800"
>
{{ record.focusedTime }}
</p>
</div> </div>
</div> </div>
<p
v-if="weeklyRecords.length === 0"
class="flex min-h-[180px] w-full items-center justify-center rounded-2xl border border-dashed border-stone-300 text-sm font-semibold text-stone-500"
>
선택 기간에 기록이 없습니다.
</p>
</div>
<div
v-if="hoveredFlowRecord"
class="pointer-events-none fixed z-[80] w-44 -translate-x-1/2 -translate-y-full rounded-2xl border border-stone-200 bg-white px-3 py-2 text-left shadow-[0_16px_40px_rgba(28,25,23,0.14)]"
:style="{ left: `${flowTooltipPosition.x}px`, top: `${flowTooltipPosition.y}px` }"
>
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">
{{ hoveredFlowRecord.dateLabel }} {{ hoveredFlowRecord.weekday }}
</p>
<p class="mt-1 text-[11px] font-semibold text-stone-900">
FOCUSED {{ hoveredFlowRecord.focusedTime }}
</p>
<p class="mt-1 text-[10px] font-semibold tracking-[0.04em] text-stone-500">
TASKS {{ hoveredFlowRecord.completedTasks }} / {{ hoveredFlowRecord.totalTasks }}
</p>
</div> </div>
</article> </article>
<div class="grid gap-6"> <div class="grid gap-6">
<article class="rounded-[28px] border border-white/60 bg-white/80 p-6 shadow-paper backdrop-blur"> <article class="rounded-[28px] border border-stone-200 bg-white/86 p-6">
<p class="text-[11px] font-bold tracking-[0.22em] text-stone-500">BEST DAY</p> <div class="flex items-center gap-2">
<p class="text-[11px] font-bold tracking-[0.22em] text-stone-500">BEST DAY</p>
<GuideTooltip
title="Best Day"
description="선택 범위 안에서 FOCUSED TIME이 가장 큰 날짜를 보여줍니다."
:dismissible="false"
/>
</div>
<div v-if="bestDay" class="mt-4"> <div v-if="bestDay" class="mt-4">
<h2 class="text-2xl font-semibold tracking-[-0.05em] text-stone-900"> <h2 class="text-2xl font-semibold tracking-[-0.05em] text-stone-900">
{{ bestDay.dateLabel }} {{ bestDay.dateLabel }}
@@ -127,31 +325,44 @@ const emit = defineEmits(['update:range-start', 'update:range-end'])
아직 통계를 보여줄 기록이 충분하지 않습니다. 아직 통계를 보여줄 기록이 충분하지 않습니다.
</p> </p>
</article> </article>
<article class="rounded-[28px] border border-white/60 bg-white/80 p-6 shadow-paper backdrop-blur">
<p class="text-[11px] font-bold tracking-[0.22em] text-stone-500">RECENT RECORDS</p>
<div class="mt-4 space-y-4">
<div
v-for="record in recentRecords"
:key="record.key"
class="rounded-2xl border border-stone-200 bg-stone-50/80 p-4"
>
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-[12px] font-bold tracking-[0.08em] text-stone-900">{{ record.dateLabel }}</p>
<p class="mt-2 text-[11px] font-semibold leading-5 text-stone-600">
{{ record.comment || '코멘트 없음' }}
</p>
</div>
<div class="text-right">
<p class="text-sm font-semibold tracking-[-0.03em] text-stone-900">{{ record.focusedTime }}</p>
<p class="mt-1 text-[11px] font-semibold text-stone-500">{{ record.completionRate }}%</p>
</div>
</div>
</div>
</div>
</article>
</div> </div>
</div> </div>
<article class="rounded-[28px] border border-stone-200 bg-white/86 p-6">
<div class="flex items-center gap-2">
<p class="text-[11px] font-bold tracking-[0.22em] text-stone-500">RECENT RECORDS</p>
<GuideTooltip
title="Recent Records"
description="선택 범위 안의 기록을 날짜 내림차순으로 최대 5개까지 보여줍니다."
:dismissible="false"
/>
</div>
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<div
v-for="record in recentRecords"
:key="record.key"
class="rounded-2xl border border-stone-200 bg-stone-50/80 p-4"
>
<div class="flex h-full flex-col justify-between gap-4">
<div>
<p class="text-[12px] font-bold tracking-[0.08em] text-stone-900">{{ record.dateLabel }}</p>
<p class="mt-2 text-[11px] font-semibold leading-5 text-stone-600">
{{ record.comment || '코멘트 없음' }}
</p>
</div>
<div>
<p class="text-sm font-semibold tracking-[-0.03em] text-stone-900">{{ record.focusedTime }}</p>
<p class="mt-1 text-[11px] font-semibold text-stone-500">완료율 {{ record.completionRate }}%</p>
</div>
</div>
</div>
<p
v-if="recentRecords.length === 0"
class="rounded-2xl border border-dashed border-stone-300 px-4 py-8 text-sm font-semibold text-stone-500"
>
선택 기간에 최근 기록이 없습니다.
</p>
</div>
</article>
</section> </section>
</template> </template>

48
src/lib/adminApi.js Normal file
View File

@@ -0,0 +1,48 @@
import { buildApiUrl, toUserFacingApiError } from './apiBase'
async function request(path, token, { method = 'GET', body } = {}) {
const hasBody = body !== undefined
const response = await fetch(buildApiUrl(path), {
method,
headers: {
Authorization: `Bearer ${token}`,
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
},
body: hasBody ? JSON.stringify(body) : undefined,
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(toUserFacingApiError(data, '관리자 데이터를 불러오지 못했습니다.'))
}
return data
}
export async function fetchAdminOverview(token) {
return request('/api/admin/overview', token)
}
export async function fetchAdminUserDetail(token, userId) {
return request(`/api/admin/users/${userId}/detail`, token)
}
export async function updateAdminUserStatus(token, userId, disabled) {
return request(`/api/admin/users/${userId}/status`, token, {
method: 'PUT',
body: { disabled },
})
}
export async function revokeAdminUserSessions(token, userId) {
return request(`/api/admin/users/${userId}/revoke-sessions`, token, {
method: 'POST',
})
}
export async function deleteAdminUser(token, userId) {
return request(`/api/admin/users/${userId}`, token, {
method: 'DELETE',
})
}

View File

@@ -28,29 +28,39 @@ async function request(path, { method = 'GET', token, body } = {}) {
export function readAuthState() { export function readAuthState() {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return { token: '', user: null } return { token: '', user: null, persist: false }
} }
try { try {
return JSON.parse(window.localStorage.getItem(AUTH_STORAGE_KEY) ?? '{"token":"","user":null}') const localState = JSON.parse(window.localStorage.getItem(AUTH_STORAGE_KEY) ?? 'null')
if (localState?.token) {
return { ...localState, persist: true }
}
const sessionState = JSON.parse(window.sessionStorage.getItem(AUTH_STORAGE_KEY) ?? 'null')
if (sessionState?.token) {
return { ...sessionState, persist: false }
}
return { token: '', user: null, persist: false }
} catch (error) { } catch (error) {
console.warn('저장된 인증 상태를 불러오지 못했습니다.', error) console.warn('저장된 인증 상태를 불러오지 못했습니다.', error)
return { token: '', user: null } return { token: '', user: null, persist: false }
} }
} }
export function persistAuthState({ token, user }) { export function persistAuthState({ token, user, persist = false }) {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return return
} }
window.localStorage.setItem( const targetStorage = persist ? window.localStorage : window.sessionStorage
AUTH_STORAGE_KEY, const unusedStorage = persist ? window.sessionStorage : window.localStorage
JSON.stringify({
token, unusedStorage.removeItem(AUTH_STORAGE_KEY)
user, targetStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify({ token, user }))
}),
)
} }
export function clearAuthState() { export function clearAuthState() {
@@ -59,6 +69,7 @@ export function clearAuthState() {
} }
window.localStorage.removeItem(AUTH_STORAGE_KEY) window.localStorage.removeItem(AUTH_STORAGE_KEY)
window.sessionStorage.removeItem(AUTH_STORAGE_KEY)
} }
export async function signup({ email, password, nickname }) { export async function signup({ email, password, nickname }) {
@@ -81,6 +92,13 @@ export async function fetchCurrentUser(token) {
}) })
} }
export async function logout(token) {
return request('/api/auth/logout', {
method: 'POST',
token,
})
}
export async function updateProfile(token, { email, nickname }) { export async function updateProfile(token, { email, nickname }) {
return request('/api/auth/profile', { return request('/api/auth/profile', {
method: 'PUT', method: 'PUT',
@@ -96,3 +114,40 @@ export async function updatePassword(token, { currentPassword, newPassword }) {
body: { currentPassword, newPassword }, body: { currentPassword, newPassword },
}) })
} }
export async function deleteAccount(token, { currentPassword }) {
return request('/api/auth/account', {
method: 'DELETE',
token,
body: { currentPassword },
})
}
export async function requestPasswordReset({ email }) {
return request('/api/auth/password-reset/request', {
method: 'POST',
body: { email },
})
}
export async function confirmPasswordReset({ token, newPassword }) {
return request('/api/auth/password-reset/confirm', {
method: 'POST',
body: { token, newPassword },
})
}
export async function confirmVerification({ token }) {
return request('/api/auth/verification/confirm', {
method: 'POST',
body: { token },
})
}
export async function requestVerification({ email }, token) {
return request('/api/auth/verification/request', {
method: 'POST',
token,
body: email ? { email } : {},
})
}

View File

@@ -40,16 +40,8 @@ export function restorePlannerUiState({
toDateValue, toDateValue,
}) { }) {
const savedState = readStorageState() const savedState = readStorageState()
selectedDate.value = toDateValue(new Date(), selectedDate.value)
if (savedState.selectedDate) { calendarViewDate.value = new Date(selectedDate.value)
selectedDate.value = toDateValue(savedState.selectedDate, selectedDate.value)
}
if (savedState.calendarViewDate) {
calendarViewDate.value = toDateValue(savedState.calendarViewDate, selectedDate.value)
} else {
calendarViewDate.value = new Date(selectedDate.value)
}
if (savedState.statsRangeStart) { if (savedState.statsRangeStart) {
statsRangeStart.value = savedState.statsRangeStart statsRangeStart.value = savedState.statsRangeStart
@@ -75,12 +67,13 @@ export function persistPlannerState({
const previousState = readStorageState() const previousState = readStorageState()
const nextState = { const nextState = {
...previousState, ...previousState,
selectedDate: selectedDate.toISOString(),
calendarViewDate: calendarViewDate.toISOString(),
statsRangeStart, statsRangeStart,
statsRangeEnd, statsRangeEnd,
} }
delete nextState.selectedDate
delete nextState.calendarViewDate
if (includeRecords) { if (includeRecords) {
nextState.records = Object.fromEntries( nextState.records = Object.fromEntries(
Object.entries(plannerRecords).map(([key, record]) => [ Object.entries(plannerRecords).map(([key, record]) => [

View File

@@ -22,12 +22,16 @@
} }
@media print { @media print {
@page {
margin: 0;
}
html, html,
body { body {
background: #ffffff !important; background: #ffffff !important;
width: auto !important; width: auto !important;
height: auto !important; height: auto !important;
overflow: hidden !important; overflow: visible !important;
} }
body { body {
@@ -46,17 +50,11 @@
} }
.print-only { .print-only {
display: flex !important; display: block !important;
width: 100% !important; width: 100% !important;
height: 100% !important; height: auto !important;
margin: 0 !important; margin: 0 !important;
padding: 0 !important; padding: 0 !important;
align-items: center;
justify-content: center;
break-inside: avoid-page;
page-break-inside: avoid;
break-after: avoid-page;
page-break-after: avoid;
} }
.print-root { .print-root {
@@ -75,28 +73,37 @@
justify-content: center; justify-content: center;
background: #ffffff !important; background: #ffffff !important;
padding: 0; padding: 0;
margin: 0 auto;
overflow: hidden; overflow: hidden;
break-after: page;
page-break-after: always;
break-inside: avoid-page;
page-break-inside: avoid;
}
.print-paper:last-child {
break-after: avoid-page;
page-break-after: avoid;
} }
body[data-print-layout='single'] .print-paper { body[data-print-layout='single'] .print-paper {
width: 204mm; width: 210mm;
height: 291mm; height: 297mm;
align-items: flex-start; align-items: center;
justify-content: center; justify-content: center;
padding-top: 3mm;
} }
.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: 297mm;
height: 198mm; height: 210mm;
} }
body[data-print-layout='single'] .print-paper--single .print-sheet-frame { body[data-print-layout='single'] .print-paper--single .print-sheet-frame {
@@ -112,9 +119,13 @@
background: #ffffff !important; background: #ffffff !important;
} }
.print-sheet-frame--blank {
background: #ffffff !important;
}
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 {
@@ -125,7 +136,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.978); transform: scale(0.982);
box-shadow: none !important; box-shadow: none !important;
background: #ffffff !important; background: #ffffff !important;
} }
@@ -134,7 +145,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.6894);
box-shadow: none !important; box-shadow: none !important;
background: #ffffff !important; background: #ffffff !important;
} }
@@ -143,6 +154,89 @@
-webkit-print-color-adjust: exact; -webkit-print-color-adjust: exact;
print-color-adjust: exact; print-color-adjust: exact;
} }
.planner-sheet__meta-top,
.planner-sheet__meta-bottom,
.planner-sheet__body {
display: flex !important;
flex-direction: row !important;
gap: 16px !important;
}
.planner-sheet__meta {
gap: 14px !important;
padding-top: 10px !important;
padding-bottom: 10px !important;
}
.planner-sheet__meta-top > div,
.planner-sheet__meta-bottom > div {
min-height: 78px !important;
overflow: visible !important;
}
.planner-sheet__meta-bottom > div:first-child {
width: auto !important;
max-width: none !important;
flex: 1 1 auto !important;
min-width: 0 !important;
}
.planner-sheet__meta-bottom > div:last-child {
width: 210px !important;
max-width: 210px !important;
flex: 0 0 210px !important;
}
.planner-field__header {
width: 100% !important;
min-width: 0 !important;
}
.planner-field__header > span:first-child {
white-space: nowrap !important;
letter-spacing: 0.16em !important;
}
.planner-sheet__meta-bottom > div:last-child > p {
padding-top: 24px !important;
white-space: nowrap !important;
}
.planner-sheet__lists {
width: 394px !important;
flex: 1 1 auto !important;
}
.planner-sheet__timetable {
width: 210px !important;
flex-shrink: 0 !important;
}
.planner-sheet__timetable-scroll {
overflow: visible !important;
padding-bottom: 0 !important;
}
.planner-sheet__timetable-grid {
min-width: 210px !important;
}
.planner-sheet textarea {
height: 58px !important;
min-height: 58px !important;
max-height: 58px !important;
overflow: hidden !important;
padding-top: 4px !important;
line-height: 1.55 !important;
white-space: pre-wrap !important;
word-break: break-word !important;
}
.print-paper--double .print-sheet-frame {
align-items: flex-start !important;
justify-content: flex-start !important;
}
} }
@media screen { @media screen {
@@ -150,3 +244,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);
}