Compare commits

..

3 Commits

10 changed files with 157 additions and 22 deletions

View File

@@ -4,7 +4,7 @@
- 프로젝트명: 10 Minute Planner 웹 UI
- 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript
- 현재 기준 버전: `v0.1.22` 준비 중
- 현재 기준 버전: `v0.1.25` 준비 중
- 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`는 화면에서 바로 편집할 수 있다.
@@ -158,6 +159,11 @@
- 프론트 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...` 오류는 이 문제였다.
- 비로그인 랜딩 카드는 상단 고정이 아니라 화면 중앙에 오도록 정렬을 수정했다.
- 현재 환경에서는 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 배포 예정

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.22",
"version": "0.1.25",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ten-minute-planner",
"version": "0.1.22",
"version": "0.1.25",
"dependencies": {
"vue": "^3.5.13"
},

View File

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

View File

@@ -1298,8 +1298,8 @@ onMounted(() => {
<template>
<main class="min-h-screen px-4 py-6 text-ink sm:px-6 lg:px-10 xl:h-screen xl:overflow-hidden">
<div
class="print-root mx-auto flex max-w-[1760px] flex-col gap-6 xl:h-[calc(100vh-3rem)] xl:items-start"
:class="isAuthenticated ? 'xl:grid xl:grid-cols-[300px_minmax(0,1fr)]' : ''"
class="print-root mx-auto flex max-w-[1760px] flex-col gap-6"
:class="isAuthenticated ? 'xl:h-[calc(100vh-3rem)] xl:grid xl:grid-cols-[300px_minmax(0,1fr)] xl:items-start' : 'min-h-[calc(100vh-3rem)] items-center justify-center'"
>
<aside
v-if="isAuthenticated"
@@ -1459,10 +1459,10 @@ onMounted(() => {
</div>
</aside>
<div class="min-w-0 space-y-6" :class="isAuthenticated ? 'xl:h-full xl:overflow-hidden' : ''">
<div class="min-w-0 space-y-6" :class="isAuthenticated ? 'xl:h-full xl:overflow-hidden' : 'w-full'">
<section
v-if="!isAuthenticated"
class="scrollbar-hide print-hidden rounded-[28px] border border-white/60 bg-white/65 p-6 shadow-[0_24px_80px_rgba(28,25,23,0.08)] sm:p-8 xl:h-full xl:overflow-y-auto"
class="scrollbar-hide print-hidden mx-auto w-full max-w-4xl rounded-[28px] border border-white/60 bg-white/65 p-6 shadow-[0_24px_80px_rgba(28,25,23,0.08)] sm:p-8"
>
<div class="mx-auto flex max-w-3xl flex-col gap-6 text-center">
<div class="space-y-3">

View File

@@ -1,19 +1,20 @@
const AUTH_STORAGE_KEY = 'ten-minute-planner-auth'
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 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(() => ({}))

View File

@@ -1,17 +1,18 @@
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 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(() => ({}))

View File

@@ -1,17 +1,18 @@
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 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(() => ({}))

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,
},
},
})