Compare commits

..

17 Commits

Author SHA1 Message Date
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
e221254c60 v0.1.25 - 빈 JSON 본문 요청 오류 수정 2026-04-22 11:20:22 +09:00
1738a7d3b6 v0.1.24 - 개발용과 배포용 실행 문서 추가 2026-04-22 11:14:29 +09:00
9760c9dc0e v0.1.23 - 개발용 자동 새로고침과 로그인 화면 정렬 2026-04-22 11:08:58 +09:00
b972209c2f v0.1.22 - 로그인 오류 문구와 비로그인 화면 정리 2026-04-22 11:05:46 +09:00
15 changed files with 1080 additions and 285 deletions

View File

@@ -4,7 +4,7 @@
- 프로젝트명: 10 Minute Planner 웹 UI
- 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript
- 현재 기준 버전: `v0.1.21` 준비 중
- 현재 기준 버전: `v0.1.37` 준비 중
- Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git`
## 기준 디자인
@@ -37,6 +37,7 @@
- 프론트 Dockerfile: `Dockerfile`
- 백엔드 Dockerfile: `backend/Dockerfile`
- nginx 프록시 설정: `deploy/nginx/default.conf`
- 실행 가이드 문서: `README.md`
- Tailwind 설정은 완료되어 있으며, 이 프로젝트의 스타일링 기준으로 유지한다.
- 현재 선택 날짜는 시스템 날짜 기준으로 시작한다.
- `COMMENT`, `TASKS`, `MEMO`는 화면에서 바로 편집할 수 있다.
@@ -155,6 +156,42 @@
- `planner_entries.payload`는 문자열이 아니라 PostgreSQL `JSONB`로 저장되도록 바뀌었다.
- `docker-compose.yml` 기준으로 `postgres`, `backend`, `frontend(nginx)` 3개 서비스 초안이 추가되었다.
- 프론트는 nginx에서 `/api`를 백엔드로 프록시하는 구조라서, 배포 시 브라우저가 별도 API 포트를 직접 알 필요가 없다.
- 프론트 API 클라이언트는 `VITE_API_BASE_URL` 끝에 `/api`가 포함되어 있어도 `/api/api/...` 중복 주소가 생기지 않도록 정규화한다.
- 로그인 실패 시에는 내부 라우트 문자열이나 서버 경로를 그대로 노출하지 않고, 사용자용 안내 문구만 보여준다.
- 비로그인 상태에서는 왼쪽 사이드 내비게이션을 숨기고, 중앙 로그인 안내 화면만 보여주도록 정리했다.
- 배포용 `docker-compose.yml`과 별도로 개발용 `docker-compose.dev.yml`을 추가했다.
- 개발용 compose는 프론트 `5173`, 백엔드 `3001`, PostgreSQL `5432`를 열고, 소스 마운트 + Vite HMR + Node watch 기반으로 자동 반영된다.
- 개발/배포 실행 방법은 루트 `README.md`에 먼저 적고, `HANDOFF.md`에는 변경 이유와 주의사항 위주로 남긴다.
- API 클라이언트는 body가 없는 `GET`, `DELETE` 요청에 `Content-Type: application/json`을 붙이지 않도록 수정했다. 날짜 선택 시 발생하던 `Body cannot be empty...` 오류는 이 문제였다.
- 날짜 이동 중 목표 자동 보정으로 `goalEnabled`만 꺼지는 경우에는, 실제 내용이 없는 기록이라면 클라우드 동기화 토스트를 띄우지 않도록 정리했다.
- 집중 보기에서 오른쪽 패널 폭을 넓혀 `1920x1080` 기준 활용도를 높였고, `TASK LABELS / D-DAY / CALENDAR`는 상단 고정 영역으로 올렸다.
- `STATS``NEXT DAY`는 반반 카드가 아니라 각각 한 줄씩 쓰도록 바꿔서 날짜 길이에 따라 높이가 흔들리는 문제를 줄였다.
- 미니 달력은 42칸 기준으로 렌더링하고, 요일 헤더를 `SUN ~ SAT` 순서로 고정해서 일요일 시작 달력 기준을 더 명확하게 맞췄다.
- 집중 보기 오른쪽 패널은 다시 정리해서 `왼쪽 컬럼: CALENDAR / TASK LABELS / D-DAY`, `오른쪽 컬럼: STATS / NEXT DAY`, `하단 전체 폭: READ NEXT / PREV SNAPSHOT` 구조로 맞췄다.
- 달력은 과하게 키우지 않고 컴팩트한 크기를 유지한 채, 우측 정보 패널 내부 배치만 다시 조정하는 쪽으로 방향을 잡았다.
- `PRINT 2-UP`은 브라우저/프린터 기본 여백 차이로 오른쪽 페이지가 잘릴 수 있어서, 프레임 폭과 스케일을 조금 더 보수적으로 줄여 안정성을 높였다.
- 플래너 집중 보기 반응형 기준은 `1620px 이상: 우측 패널 2열`, `1280px 이상 ~ 1619px 이하: 우측 패널 1열 + 최대 360px`, `1280px 미만: 우측 패널 오버레이` 구조로 다시 정리했다.
- 오버레이 구간에서는 본문 오른쪽 위의 `OPEN SIDE PANEL` 버튼으로 패널을 열고, 배경 클릭이나 `CLOSE` 버튼으로 닫는다.
- `2 PAGE SPREAD`는 화면 폭을 기준으로 배율을 자동 계산해서 오른쪽 페이지가 잘리는 현상을 줄이는 방향으로 조정했다.
- `1280px` 미만에서는 왼쪽 내비게이션도 본문 위에 쌓이지 않고 `MENU` 버튼으로 여는 드로어형 패널로 전환된다.
- 태블릿/모바일 구간에서는 `왼쪽 내비게이션 드로어 + 오른쪽 정보 패널 오버레이 + 본문 단일 컬럼` 조합으로 보는 흐름을 기본값으로 삼는다.
- 통계 화면과 우측 `FOCUSED TIME` 요약처럼 사용자에게 보여주는 집중 시간 표기는 `00H 00M` 대신 `00시간 00분` 한글 형식으로 바꿨다.
- 좌측 메뉴 드로어와 우측 정보 패널 오버레이는 이제 열고 닫힐 때 페이드 + 슬라이드 애니메이션이 적용된다.
- 모바일처럼 좁은 화면에서는 본문 래퍼 패딩을 조금 줄이고, 우측 패널 열기 버튼 문구를 `INFO`로 축약해 밀도를 낮췄다.
- 플래너 본문은 작은 화면에서 상단 정보 영역이 세로로 쌓이고, `TIME TABLE`이 아래로 내려가도록 조정했다.
- 모바일 구간에서는 TASKS / MEMO 행 높이와 좌우 패딩을 조금 줄여 입력 밀도를 낮췄고, 타임테이블은 필요할 때만 최소 가로 스크롤이 생기도록 바뀌었다.
- 미니 달력은 모바일 구간에서 패딩, 월 이동 버튼, 요일 헤더, 날짜 셀 크기를 한 단계 더 줄여서 카드 내부 밀도를 정리했다.
- 연도 선택 팝오버는 좁은 화면에서 카드 전체 폭을 활용하고, 넓은 화면에서는 기존 우측 드롭다운 폭을 유지한다.
- 플래너 본문 안의 `TOTAL TIME` 라벨도 `총 시간`으로 바꿔서 영어 라벨을 줄였다.
- 사용자 노출 메뉴 문구는 `보기 방식 / 날짜 이동 / 인쇄 / 1페이지 + 정보 / 2페이지 펼침 / 이전 날 / 다음 날 / 1장 인쇄 / 2장 인쇄`처럼 한글 중심으로 정리하기 시작했다.
- 2페이지 펼침 보기 배율 계산에서 데스크톱 여유 폭을 더 보수적으로 잡아, `1920px` 근처에서 우측 페이지가 잘려 가로 스크롤이 생기던 문제를 줄이는 방향으로 조정했다.
- 달력 날짜 버튼은 셀 안쪽에 고정 크기 원형 버튼으로 다시 배치해서 모바일에서 서로 겹쳐 보이는 현상을 줄였다.
- 좌측 상단 소개 영역은 개발 설명 문장을 빼고, 서비스 소개용 짧은 카피와 배지 스타일로 다시 구성했다.
- 달력 날짜 버튼은 의도상 원형이며, 현재는 `size` 고정 기준으로 다시 맞춰서 타원처럼 보이는 인상을 줄이는 방향으로 정리했다.
- 플래너 본문, 2페이지 펼침, 인쇄 전용 `PlannerPage` 모두 `총 시간` 값은 `00시간 00분` 한글 포맷으로 통일했다.
- 모바일 대응 이후 인쇄에서 `TIME TABLE`이 사라지던 문제를 막기 위해, print 시에는 `PlannerPage` 내부 레이아웃을 다시 가로 배치로 고정하고 타임테이블 오버플로를 해제하도록 보정했다.
- `TODO.md`는 중복 체크 항목을 정리했고, 인증 확장을 위해 `이메일 인증 / 비밀번호 재설정 / rate limit / 메일 인프라` 작업을 별도 항목으로 추가했다.
- 비로그인 랜딩 카드는 상단 고정이 아니라 화면 중앙에 오도록 정렬을 수정했다.
- 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다.
- 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다.
- Docker Compose는 프론트엔드와 백엔드를 함께 올리는 기준으로 설계하되, NAS 환경에 맞는 볼륨과 재시작 정책도 함께 고려한다.

57
README.md Normal file
View File

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

View File

@@ -46,7 +46,7 @@
- [x] 목표별로 D-DAY 표시 시작일과 종료일을 설정할 수 있게 한다.
- [x] 목표 표시 기간이 서로 겹치면 저장되지 않도록 막는다.
- [ ] 목표 완료 처리와 보관 상태를 구분한다.
- [ ] 목표 편집/삭제 UI를 추가한다.
- [x] 목표 편집/삭제 UI를 추가한다.
- [ ] 목표 목록 정렬 규칙과 검색 UX를 다듬는다.
## 4단계: 데이터 구조와 저장
@@ -56,9 +56,7 @@
- [x] 로컬 저장 또는 외부 저장 방식 중 우선 구현 방식을 정한다.
- [x] 입력 상태가 새로고침 후에도 유지되도록 만든다.
- [x] DB 전환 시점을 잡을 수 있도록 저장 레이어를 분리한다.
- [ ] 회원 가입 및 로그인 구조를 고려한 사용자별 데이터 모델을 설계한다.
- [x] 회원 가입 및 로그인 구조를 고려한 사용자별 데이터 모델을 설계한다.
- [ ] 사용자별 문서 저장/조회 흐름을 정리한다.
- [x] 사용자별 문서 저장/조회 흐름을 정리한다.
- [ ] 출력용 문서 포맷과 프린트 흐름을 고려한 데이터 구조를 정리한다.
@@ -77,7 +75,6 @@
## 6단계: 계정 및 서비스 확장
- [ ] 회원 가입 / 로그인 방식 후보를 정리한다.
- [x] 회원 가입 / 로그인 방식 후보를 정리한다.
- [x] 사용자 설정 화면에서 닉네임 / 이메일 / 비밀번호 수정 흐름을 분리한다.
- [x] 상단 헤더를 왼쪽 사이드 내비게이션 구조로 재배치한다.
@@ -92,6 +89,10 @@
- [x] UGREEN NAS 기준 `docker-compose.yml` 초안을 작성한다.
- [x] 백엔드 기본 스캐폴딩을 추가한다.
- [x] PostgreSQL 전환 초안을 적용한다.
- [ ] 이메일 인증 플로우를 설계하고 구현한다.
- [ ] 비밀번호 찾기 / 재설정 토큰 흐름을 추가한다.
- [ ] 로그인 및 인증 관련 rate limit / 잠금 정책을 추가한다.
- [ ] 메일 발송 인프라와 발신 도메인 정책을 확정한다.
## 메모

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

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

4
package-lock.json generated
View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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