Compare commits

..

10 Commits

22 changed files with 2602 additions and 89 deletions

View File

@@ -4,7 +4,7 @@
- 프로젝트명: 10 Minute Planner 웹 UI
- 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript
- 현재 기준 버전: `v0.1.8`
- 현재 기준 버전: `v0.1.18`
- Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git`
## 기준 디자인
@@ -26,8 +26,13 @@
- 메인 화면 셸: `src/App.vue`
- 플래너 종이 레이아웃: `src/components/PlannerPage.vue`
- 우측 달력 컴포넌트: `src/components/MiniCalendar.vue`
- 프론트 인증 모달: `src/components/AuthDialog.vue`
- 프론트 인증 클라이언트: `src/lib/authClient.js`
- 백엔드 엔트리 포인트: `backend/src/server.js`
- 백엔드 DB 스키마: `backend/src/db/schema.js`
- 백엔드 인증 라우트: `backend/src/routes/auth.js`
- 백엔드 목표 라우트: `backend/src/routes/goals.js`
- 백엔드 비밀번호/세션 유틸: `backend/src/lib/password.js`
- Tailwind 설정은 완료되어 있으며, 이 프로젝트의 스타일링 기준으로 유지한다.
- 현재 선택 날짜는 시스템 날짜 기준으로 시작한다.
- `COMMENT`, `TASKS`, `MEMO`는 화면에서 바로 편집할 수 있다.
@@ -45,7 +50,12 @@
- 상단 날짜 표시에서는 토요일의 `(토)`만 파란색, 일요일의 `(일)`만 빨간색으로 표시한다.
- 플래너 상태는 `localStorage`에 저장되며, 날짜별 기록과 선택 날짜, 달력 보고 있던 월까지 복원된다.
- `localStorage` 접근 로직은 `src/lib/plannerStorage.js`로 분리하기 시작했고, 이후 API/DB adapter로 교체하기 쉬운 구조로 정리 중이다.
- 프론트는 헤더에서 `LOGIN` / `SIGN UP` 모달을 열 수 있고, 로그인 상태면 닉네임과 `LOGOUT` 버튼을 표시한다.
- 인증 토큰과 현재 사용자 정보는 프론트 로컬 저장소에 따로 유지하고, 앱 시작 시 `/api/auth/me`로 세션 복원을 시도한다.
- 프론트 플래너 API 클라이언트는 `src/lib/plannerApi.js`에 추가되었다.
- 프론트 목표 API 클라이언트는 `src/lib/goalsApi.js`에 추가되었다.
- 루트에서 `npm run dev:backend`, `npm run db:generate`, `npm run db:migrate`로 백엔드 명령을 호출할 수 있다.
- 화면 탭은 `PLANNER / STATS / GOALS / SETTINGS` 기준으로 확장되었다.
- 상단 전환 버튼으로 `PLANNER / STATS` 화면을 오갈 수 있다.
- 통계 화면에서는 전체 집중 시간, 평균 완료율, 기록 일수, 최근 7일 흐름, 최근 기록, 베스트 데이를 보여준다.
- 통계 화면은 시작일/종료일을 직접 선택해 그 기간 기준으로 지표를 다시 계산할 수 있다.
@@ -63,6 +73,11 @@
- `1-UP`은 세로 가운데 정렬을 없애고 상단 기준으로 붙여야 여백이 덜 커 보인다.
- 현재 `1-UP`은 프레임 자체를 A4 세로에 가깝게 키우고 배율을 크게 올려 빈 여백을 줄이는 방향으로 맞추고 있다.
- 백엔드 초안은 `Fastify + Drizzle + SQLite` 조합이며, 현재는 `/health`, `/api/meta` 정도의 기본 라우트만 있다.
- 백엔드에는 `/api/auth/signup`, `/api/auth/login`, `/api/auth/me`가 추가되었다.
- 백엔드에는 `/api/planner/:entryDate` 단건 조회/저장과 `/api/planner?from=...&to=...` 범위 조회가 추가되었다.
- 비밀번호는 Node 내장 `crypto.scrypt` 기반 해시로 저장하고, 세션 토큰은 `auth_sessions` 테이블에 해시 형태로 저장한다.
- 초기 실행 시 `backend/src/db/init.js`에서 테이블이 없으면 자동 생성하도록 맞춰두었다.
- 플래너 저장은 `planner_entries (user_id, entry_date)` 고유 키 기준으로 upsert 하도록 구성했다.
- 현재 샌드박스에서는 포트 바인딩 제한 때문에 백엔드 실제 리슨 확인이 막힐 수 있다. `listen EPERM 0.0.0.0:3001`은 코드 자체보다 실행 환경 제약에 가깝다.
## 확정된 결정사항
@@ -102,6 +117,23 @@
- DB는 기능 탐색 속도를 해치지 않는 선에서, 저장 레이어를 분리할 수 있는 적절한 시점에 붙이는 것이 좋다.
- 현재 기준 추천 백엔드 방향은 `Vue 프론트엔드 + Node.js API + SQLite 또는 PostgreSQL`이다.
- 현재는 SQLite로 시작하되, 확장 시 PostgreSQL로 옮길 수 있게 Drizzle 기반 스키마를 유지한다.
- 현재 인증 방식은 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가 추가되었다.
- 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다.
- Docker Compose는 프론트엔드와 백엔드를 함께 올리는 기준으로 설계하되, NAS 환경에 맞는 볼륨과 재시작 정책도 함께 고려한다.

29
TODO.md
View File

@@ -7,7 +7,7 @@
- 기본 레이아웃은 `1페이지 + 우측 정보 패널`을 유지한다.
- `2페이지 펼침 보기`는 비교용 보조 모드로 유지한다.
- 스타일은 Vue + TailwindCSS 기준으로 구현한다.
- D-DAY는 목표 관리 패널과 연결기능으로 추후 구현한다.
- D-DAY는 목표 관리 패널과 연결구조로 전환했고, 세부 확장은 계속 진행한다.
## 1단계: 플래너 핵심 상호작용
@@ -36,10 +36,17 @@
## 3단계: 목표와 회고 기능
- [ ] 목표 관리 패널 설계한다.
- [ ] 선택한 목표 기준으로 `D-DAY`가 자동 계산되게 한다.
- [x] 목표 관리 패널 기본 구조를 설계한다.
- [x] 선택한 목표 기준으로 `D-DAY`가 자동 계산되게 한다.
- [ ] 우측 요약 패널의 `PREV SNAPSHOT`, `READ NEXT`를 실제 데이터 기반으로 연결한다.
- [ ] 다음날 할 일 자동 제안 규칙을 정리한다.
- [x] 오른쪽 패널에 `D-DAY 사용` 토글과 목표 검색/선택 UI를 추가한다.
- [x] 목표를 여러 개 생성하고 날짜별 대표 목표를 선택할 수 있게 한다.
- [x] 목표 관리 화면을 별도로 분리하고, 플래너에서는 D-DAY 표시 ON/OFF만 제어한다.
- [x] 목표별로 D-DAY 표시 시작일과 종료일을 설정할 수 있게 한다.
- [ ] 목표 완료 처리와 보관 상태를 구분한다.
- [ ] 목표 편집/삭제 UI를 추가한다.
- [ ] 목표 목록 정렬 규칙과 검색 UX를 다듬는다.
## 4단계: 데이터 구조와 저장
@@ -49,7 +56,9 @@
- [x] 입력 상태가 새로고침 후에도 유지되도록 만든다.
- [x] DB 전환 시점을 잡을 수 있도록 저장 레이어를 분리한다.
- [ ] 회원 가입 및 로그인 구조를 고려한 사용자별 데이터 모델을 설계한다.
- [x] 회원 가입 및 로그인 구조를 고려한 사용자별 데이터 모델을 설계한다.
- [ ] 사용자별 문서 저장/조회 흐름을 정리한다.
- [x] 사용자별 문서 저장/조회 흐름을 정리한다.
- [ ] 출력용 문서 포맷과 프린트 흐름을 고려한 데이터 구조를 정리한다.
## 추가 반영 메모
@@ -68,6 +77,8 @@
## 6단계: 계정 및 서비스 확장
- [ ] 회원 가입 / 로그인 방식 후보를 정리한다.
- [x] 회원 가입 / 로그인 방식 후보를 정리한다.
- [x] 사용자 설정 화면에서 닉네임 / 이메일 / 비밀번호 수정 흐름을 분리한다.
- [ ] 사용자별 문서 분리 저장 구조를 설계한다.
- [ ] 공유가 아닌 개인 보관용 서비스 흐름으로 요구사항을 정리한다.
- [x] 향후 출력 기능을 위한 인쇄 레이아웃 요구사항을 정리한다.
@@ -81,6 +92,8 @@
## 메모
- D-DAY는 현재 보류 상태다. 목표 패널 설계 후 연결한다.
- D-DAY는 본문에 직접 입력하는 방식보다, 별도 목표 목록에서 선택한 대표 목표를 보여주는 구조가 더 적합하다.
- 목표가 없는 경우 본문 D-DAY 영역은 숨기고, 오른쪽 패널의 `D-DAY 사용` 메뉴에서 검색/선택하도록 유도한다.
- `TIME TABLE` 드래그는 단순 사각형 선택이 아니라 시간 셀 단위의 연속 선택으로 해석한다.
- 현재는 `localStorage`로 개발을 진행하지만, 적절한 시점에 DB를 붙여 사용자별 저장 구조로 확장해야 한다.
- 현재 `localStorage` 저장 로직은 분리 가능한 형태로 정리 중이며, 이후 API/DB adapter로 교체하기 쉽게 유지한다.
@@ -89,4 +102,14 @@
- 최종 배포는 UGREEN NAS에서 Docker 기반으로 동작할 예정이며, 포트와 실제 서비스 구성은 추후 확정한다.
- 백엔드는 빠른 목업이면 PocketBase도 가능하지만, 현재 방향상 커스텀 로직과 확장성을 생각하면 전용 Node.js API + DB 조합을 우선 검토한다.
- 현재 백엔드는 `backend/` 폴더에 `Fastify + Drizzle + SQLite` 기준 초안이 추가되었다.
- 현재 백엔드는 회원가입, 로그인, 현재 사용자 확인용 기본 인증 API까지 포함한다.
- 현재 백엔드는 사용자별 플래너 단건 저장/조회와 범위 조회 API까지 포함한다.
- 현재 백엔드는 사용자별 목표 목록 조회와 목표 생성 API까지 포함한다.
- 프론트에는 로그인/회원가입 모달과 현재 사용자 상태 표시가 추가되었다.
- 로그인 상태일 때는 서버 저장을 우선 사용하는 흐름으로 전환 중이다.
- 로그인 전에는 플래너 본문을 사용하지 못하도록 막고, 인증 후 사용 흐름으로 정리했다.
- 현재는 각 날짜 플래너가 대표 목표 하나를 선택해 `D-DAY`에 연결하는 구조다.
- 목표는 별도 GOALS 화면에서 검색/생성/기간 설정을 관리하고, 플래너에서는 표시 ON/OFF만 다룬다.
- 목표가 현재 날짜에 적용되지 않았거나 `D-DAY 사용`이 꺼져 있으면 본문 `D-DAY` 영역은 숨긴다.
- TASK LABELS는 버튼 묶음 대신 동일한 토글 패턴으로 단순화했다.
- 구현할 때마다 완료된 항목은 체크하고, 큰 결정사항은 `HANDOFF.md`에도 함께 반영한다.

View File

@@ -12,6 +12,7 @@ const envSchema = z.object({
PORT: z.coerce.number().default(3001),
DB_FILE: z.string().default('./data/planner.sqlite'),
CORS_ORIGIN: z.string().default('http://localhost:5173'),
SESSION_TTL_DAYS: z.coerce.number().default(30),
})
export const env = envSchema.parse(process.env)

63
backend/src/db/init.js Normal file
View File

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

View File

@@ -1,4 +1,4 @@
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
@@ -9,11 +9,39 @@ export const users = sqliteTable('users', {
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
})
export const plannerEntries = sqliteTable('planner_entries', {
export const authSessions = sqliteTable('auth_sessions', {
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
entryDate: text('entry_date').notNull(),
payload: text('payload').notNull(),
tokenHash: text('token_hash').notNull().unique(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
})
export const plannerEntries = sqliteTable(
'planner_entries',
{
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
entryDate: text('entry_date').notNull(),
payload: text('payload').notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
},
(table) => ({
userDateUnique: uniqueIndex('planner_entries_user_date_unique').on(table.userId, table.entryDate),
}),
)
export const goals = sqliteTable('goals', {
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
targetDate: text('target_date').notNull(),
activeFrom: text('active_from'),
activeUntil: text('active_until'),
status: text('status').notNull().default('active'),
color: text('color').notNull().default('#1c1917'),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
completedAt: integer('completed_at', { mode: 'timestamp_ms' }),
})

View File

@@ -0,0 +1,65 @@
import { eq } from 'drizzle-orm'
import { env } from '../config.js'
import { db } from '../db/client.js'
import { authSessions, users } from '../db/schema.js'
import { createSessionToken, hashSessionToken } from './password.js'
function getBearerToken(request) {
const authorization = request.headers.authorization
if (!authorization?.startsWith('Bearer ')) {
return null
}
return authorization.slice('Bearer '.length).trim()
}
export async function createSession(userId) {
const token = createSessionToken()
const tokenHash = hashSessionToken(token)
const now = Date.now()
const expiresAt = now + env.SESSION_TTL_DAYS * 24 * 60 * 60 * 1000
const [session] = await db
.insert(authSessions)
.values({
userId,
tokenHash,
expiresAt: new Date(expiresAt),
createdAt: new Date(now),
})
.returning()
return {
token,
session,
}
}
export async function findAuthenticatedUser(request) {
const token = getBearerToken(request)
if (!token) {
return null
}
const tokenHash = hashSessionToken(token)
const [session] = await db
.select()
.from(authSessions)
.where(eq(authSessions.tokenHash, tokenHash))
.limit(1)
if (!session || new Date(session.expiresAt).getTime() <= Date.now()) {
return null
}
const [user] = await db
.select()
.from(users)
.where(eq(users.id, session.userId))
.limit(1)
return user ?? null
}

View File

@@ -0,0 +1,47 @@
import crypto from 'node:crypto'
const SCRYPT_KEY_LENGTH = 64
function scryptAsync(password, salt) {
return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, SCRYPT_KEY_LENGTH, (error, derivedKey) => {
if (error) {
reject(error)
return
}
resolve(derivedKey)
})
})
}
export async function hashPassword(password) {
const salt = crypto.randomBytes(16).toString('hex')
const derivedKey = await scryptAsync(password, salt)
return `${salt}:${derivedKey.toString('hex')}`
}
export async function verifyPassword(password, storedHash) {
const [salt, originalHash] = storedHash.split(':')
if (!salt || !originalHash) {
return false
}
const derivedKey = await scryptAsync(password, salt)
const originalBuffer = Buffer.from(originalHash, 'hex')
if (originalBuffer.length !== derivedKey.length) {
return false
}
return crypto.timingSafeEqual(originalBuffer, derivedKey)
}
export function createSessionToken() {
return crypto.randomBytes(32).toString('hex')
}
export function hashSessionToken(token) {
return crypto.createHash('sha256').update(token).digest('hex')
}

231
backend/src/routes/auth.js Normal file
View File

@@ -0,0 +1,231 @@
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { db } from '../db/client.js'
import { users } from '../db/schema.js'
import { hashPassword, verifyPassword } from '../lib/password.js'
import { createSession, findAuthenticatedUser } from '../lib/authSession.js'
const signupSchema = z.object({
email: z.string().trim().email(),
password: z.string().min(8).max(72),
nickname: z.string().trim().min(2).max(30),
})
const loginSchema = z.object({
email: z.string().trim().email(),
password: z.string().min(1).max(72),
})
const profileSchema = z.object({
email: z.string().trim().email(),
nickname: z.string().trim().min(2).max(30),
})
const passwordSchema = z.object({
currentPassword: z.string().min(1).max(72),
newPassword: z.string().min(8).max(72),
})
function sanitizeUser(user) {
return {
id: user.id,
email: user.email,
nickname: user.nickname,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
}
}
export async function registerAuthRoutes(app) {
app.post('/api/auth/signup', async (request, reply) => {
const payload = signupSchema.safeParse(request.body)
if (!payload.success) {
return reply.code(400).send({
message: '회원가입 입력값이 올바르지 않습니다.',
issues: payload.error.flatten(),
})
}
const { email, password, nickname } = payload.data
const normalizedEmail = email.toLowerCase()
const [existingUser] = await db
.select()
.from(users)
.where(eq(users.email, normalizedEmail))
.limit(1)
if (existingUser) {
return reply.code(409).send({
message: '이미 사용 중인 이메일입니다.',
})
}
const now = new Date()
const passwordHash = await hashPassword(password)
const [user] = await db
.insert(users)
.values({
email: normalizedEmail,
passwordHash,
nickname,
createdAt: now,
updatedAt: now,
})
.returning()
const { token } = await createSession(user.id)
return reply.code(201).send({
message: '회원가입이 완료되었습니다.',
token,
user: sanitizeUser(user),
})
})
app.post('/api/auth/login', async (request, reply) => {
const payload = loginSchema.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 reply.code(401).send({
message: '이메일 또는 비밀번호가 올바르지 않습니다.',
})
}
const passwordMatches = await verifyPassword(payload.data.password, user.passwordHash)
if (!passwordMatches) {
return reply.code(401).send({
message: '이메일 또는 비밀번호가 올바르지 않습니다.',
})
}
const { token } = await createSession(user.id)
return {
message: '로그인에 성공했습니다.',
token,
user: sanitizeUser(user),
}
})
app.get('/api/auth/me', async (request, reply) => {
const user = await findAuthenticatedUser(request)
if (!user) {
return reply.code(401).send({
message: '인증이 필요합니다.',
})
}
return {
user: sanitizeUser(user),
}
})
app.put('/api/auth/profile', async (request, reply) => {
const user = await findAuthenticatedUser(request)
if (!user) {
return reply.code(401).send({
message: '인증이 필요합니다.',
})
}
const payload = profileSchema.safeParse(request.body)
if (!payload.success) {
return reply.code(400).send({
message: '프로필 입력값이 올바르지 않습니다.',
issues: payload.error.flatten(),
})
}
const normalizedEmail = payload.data.email.toLowerCase()
const [existingUser] = await db
.select()
.from(users)
.where(eq(users.email, normalizedEmail))
.limit(1)
if (existingUser && existingUser.id !== user.id) {
return reply.code(409).send({
message: '이미 사용 중인 이메일입니다.',
})
}
const [updatedUser] = await db
.update(users)
.set({
email: normalizedEmail,
nickname: payload.data.nickname,
updatedAt: new Date(),
})
.where(eq(users.id, user.id))
.returning()
return {
message: '프로필이 수정되었습니다.',
user: sanitizeUser(updatedUser),
}
})
app.put('/api/auth/password', async (request, reply) => {
const user = await findAuthenticatedUser(request)
if (!user) {
return reply.code(401).send({
message: '인증이 필요합니다.',
})
}
const payload = passwordSchema.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: '현재 비밀번호가 올바르지 않습니다.',
})
}
const passwordHash = await hashPassword(payload.data.newPassword)
await db
.update(users)
.set({
passwordHash,
updatedAt: new Date(),
})
.where(eq(users.id, user.id))
return {
message: '비밀번호가 변경되었습니다.',
}
})
}

204
backend/src/routes/goals.js Normal file
View File

@@ -0,0 +1,204 @@
import { and, asc, desc, eq, like } from 'drizzle-orm'
import { z } from 'zod'
import { db } from '../db/client.js'
import { goals } from '../db/schema.js'
import { findAuthenticatedUser } from '../lib/authSession.js'
const goalSchema = z.object({
title: z.string().trim().min(1).max(80),
targetDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
activeFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
activeUntil: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
status: z.enum(['active', 'done', 'archived']).optional(),
color: z.string().trim().min(4).max(32).optional(),
})
const goalUpdateSchema = z.object({
title: z.string().trim().min(1).max(80).optional(),
targetDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
activeFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
activeUntil: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
status: z.enum(['active', 'done', 'archived']).optional(),
color: z.string().trim().min(4).max(32).optional(),
})
const goalQuerySchema = z.object({
query: z.string().trim().optional(),
status: z.enum(['active', 'done', 'archived', 'all']).optional(),
})
async function requireAuthenticatedUser(request, reply) {
const user = await findAuthenticatedUser(request)
if (!user) {
reply.code(401).send({
message: '인증이 필요합니다.',
})
return null
}
return user
}
export async function registerGoalRoutes(app) {
app.get('/api/goals', async (request, reply) => {
const user = await requireAuthenticatedUser(request, reply)
if (!user) {
return
}
const query = goalQuerySchema.safeParse(request.query ?? {})
if (!query.success) {
return reply.code(400).send({
message: '목표 조회 조건이 올바르지 않습니다.',
issues: query.error.flatten(),
})
}
const filters = [eq(goals.userId, user.id)]
if (query.data.status && query.data.status !== 'all') {
filters.push(eq(goals.status, query.data.status))
}
if (query.data.query) {
filters.push(like(goals.title, `%${query.data.query}%`))
}
const items = await db
.select()
.from(goals)
.where(and(...filters))
.orderBy(desc(goals.updatedAt), asc(goals.targetDate), asc(goals.id))
return { goals: items }
})
app.post('/api/goals', async (request, reply) => {
const user = await requireAuthenticatedUser(request, reply)
if (!user) {
return
}
const payload = goalSchema.safeParse(request.body)
if (!payload.success) {
return reply.code(400).send({
message: '목표 입력값이 올바르지 않습니다.',
issues: payload.error.flatten(),
})
}
const now = new Date()
const [goal] = await db
.insert(goals)
.values({
userId: user.id,
title: payload.data.title,
targetDate: payload.data.targetDate,
activeFrom: payload.data.activeFrom ?? null,
activeUntil: payload.data.activeUntil ?? null,
color: payload.data.color ?? '#1c1917',
status: payload.data.status ?? 'active',
createdAt: now,
updatedAt: now,
completedAt: payload.data.status === 'done' ? now : null,
})
.returning()
return reply.code(201).send({
message: '목표가 추가되었습니다.',
goal,
})
})
app.patch('/api/goals/:goalId', async (request, reply) => {
const user = await requireAuthenticatedUser(request, reply)
if (!user) {
return
}
const params = z.object({
goalId: z.coerce.number().int().positive(),
}).safeParse(request.params)
if (!params.success) {
return reply.code(400).send({
message: '목표 식별자가 올바르지 않습니다.',
})
}
const payload = goalUpdateSchema.safeParse(request.body)
if (!payload.success) {
return reply.code(400).send({
message: '목표 수정값이 올바르지 않습니다.',
issues: payload.error.flatten(),
})
}
const [existingGoal] = await db
.select()
.from(goals)
.where(and(eq(goals.id, params.data.goalId), eq(goals.userId, user.id)))
.limit(1)
if (!existingGoal) {
return reply.code(404).send({
message: '목표를 찾을 수 없습니다.',
})
}
const nextValues = {
updatedAt: new Date(),
}
if (payload.data.title !== undefined) {
nextValues.title = payload.data.title
}
if (payload.data.targetDate !== undefined) {
nextValues.targetDate = payload.data.targetDate
}
if (payload.data.activeFrom !== undefined) {
nextValues.activeFrom = payload.data.activeFrom
}
if (payload.data.activeUntil !== undefined) {
nextValues.activeUntil = payload.data.activeUntil
}
if (payload.data.status !== undefined) {
nextValues.status = payload.data.status
}
if (payload.data.color !== undefined) {
nextValues.color = payload.data.color
}
if (payload.data.status === 'done' && !existingGoal.completedAt) {
nextValues.completedAt = new Date()
}
if (payload.data.status && payload.data.status !== 'done') {
nextValues.completedAt = null
}
const [goal] = await db
.update(goals)
.set(nextValues)
.where(and(eq(goals.id, params.data.goalId), eq(goals.userId, user.id)))
.returning()
return {
message: '목표가 수정되었습니다.',
goal,
}
})
}

View File

@@ -0,0 +1,196 @@
import { and, asc, eq, gte, lte } from 'drizzle-orm'
import { z } from 'zod'
import { db } from '../db/client.js'
import { plannerEntries } from '../db/schema.js'
import { findAuthenticatedUser } from '../lib/authSession.js'
const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
const plannerPayloadSchema = z.object({
payload: z.record(z.any()),
})
const plannerRangeQuerySchema = z.object({
from: dateSchema.optional(),
to: dateSchema.optional(),
})
async function requireAuthenticatedUser(request, reply) {
const user = await findAuthenticatedUser(request)
if (!user) {
reply.code(401).send({
message: '인증이 필요합니다.',
})
return null
}
return user
}
export async function registerPlannerRoutes(app) {
app.get('/api/planner', async (request, reply) => {
const user = await requireAuthenticatedUser(request, reply)
if (!user) {
return
}
const query = plannerRangeQuerySchema.safeParse(request.query ?? {})
if (!query.success) {
return reply.code(400).send({
message: '조회 범위가 올바르지 않습니다.',
issues: query.error.flatten(),
})
}
const filters = [eq(plannerEntries.userId, user.id)]
if (query.data.from) {
filters.push(gte(plannerEntries.entryDate, query.data.from))
}
if (query.data.to) {
filters.push(lte(plannerEntries.entryDate, query.data.to))
}
const entries = await db
.select({
entryDate: plannerEntries.entryDate,
payload: plannerEntries.payload,
createdAt: plannerEntries.createdAt,
updatedAt: plannerEntries.updatedAt,
})
.from(plannerEntries)
.where(and(...filters))
.orderBy(asc(plannerEntries.entryDate))
return {
entries: entries.map((entry) => ({
...entry,
payload: JSON.parse(entry.payload),
})),
}
})
app.get('/api/planner/:entryDate', async (request, reply) => {
const user = await requireAuthenticatedUser(request, reply)
if (!user) {
return
}
const dateResult = dateSchema.safeParse(request.params.entryDate)
if (!dateResult.success) {
return reply.code(400).send({
message: '날짜 형식이 올바르지 않습니다.',
})
}
const [entry] = await db
.select()
.from(plannerEntries)
.where(
and(
eq(plannerEntries.userId, user.id),
eq(plannerEntries.entryDate, dateResult.data),
),
)
.limit(1)
return {
entry: entry
? {
...entry,
payload: JSON.parse(entry.payload),
}
: null,
}
})
app.put('/api/planner/:entryDate', async (request, reply) => {
const user = await requireAuthenticatedUser(request, reply)
if (!user) {
return
}
const dateResult = dateSchema.safeParse(request.params.entryDate)
if (!dateResult.success) {
return reply.code(400).send({
message: '날짜 형식이 올바르지 않습니다.',
})
}
const payloadResult = plannerPayloadSchema.safeParse(request.body)
if (!payloadResult.success) {
return reply.code(400).send({
message: '플래너 저장 데이터가 올바르지 않습니다.',
issues: payloadResult.error.flatten(),
})
}
const now = new Date()
const [entry] = await db
.insert(plannerEntries)
.values({
userId: user.id,
entryDate: dateResult.data,
payload: JSON.stringify(payloadResult.data.payload),
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [plannerEntries.userId, plannerEntries.entryDate],
set: {
payload: JSON.stringify(payloadResult.data.payload),
updatedAt: now,
},
})
.returning()
return {
message: '플래너가 저장되었습니다.',
entry: {
...entry,
payload: JSON.parse(entry.payload),
},
}
})
app.delete('/api/planner/:entryDate', async (request, reply) => {
const user = await requireAuthenticatedUser(request, reply)
if (!user) {
return
}
const dateResult = dateSchema.safeParse(request.params.entryDate)
if (!dateResult.success) {
return reply.code(400).send({
message: '날짜 형식이 올바르지 않습니다.',
})
}
const deletedEntries = await db
.delete(plannerEntries)
.where(
and(
eq(plannerEntries.userId, user.id),
eq(plannerEntries.entryDate, dateResult.data),
),
)
.returning()
return {
message: deletedEntries.length > 0 ? '플래너가 삭제되었습니다.' : '삭제할 플래너가 없습니다.',
deleted: deletedEntries.length > 0,
}
})
}

View File

@@ -2,16 +2,26 @@ import Fastify from 'fastify'
import cors from '@fastify/cors'
import { env } from './config.js'
import { sqlite } from './db/client.js'
import { ensureDatabaseSchema } from './db/init.js'
import { registerAuthRoutes } from './routes/auth.js'
import { registerGoalRoutes } from './routes/goals.js'
import { registerPlannerRoutes } from './routes/planner.js'
const app = Fastify({
logger: true,
})
ensureDatabaseSchema()
await app.register(cors, {
origin: env.CORS_ORIGIN,
credentials: true,
})
await registerAuthRoutes(app)
await registerGoalRoutes(app)
await registerPlannerRoutes(app)
app.get('/health', async () => {
const version = sqlite.prepare('select sqlite_version() as version').get()
@@ -26,12 +36,13 @@ app.get('/health', async () => {
})
app.get('/api/meta', async () => ({
auth: 'planned',
auth: 'active',
storage: 'sqlite',
orm: 'drizzle',
notes: [
'회원가입 로그인 API는 다음 단계에서 추가 예정',
'플래너 저장 API는 로컬 저장 레이어 분리 이후 연결 예정',
'회원가입, 로그인, 현재 사용자 확인 API가 준비되어 있습니다.',
'사용자별 목표 목록과 생성 API가 준비되어 있습니다.',
'사용자별 플래너 저장 및 조회 API가 준비되어 있습니다.',
],
}))

4
package-lock.json generated
View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,129 @@
<script setup>
const props = defineProps({
open: {
type: Boolean,
default: false,
},
mode: {
type: String,
default: 'login',
},
form: {
type: Object,
required: true,
},
busy: {
type: Boolean,
default: false,
},
message: {
type: String,
default: '',
},
})
const emit = defineEmits([
'close',
'submit',
'switch-mode',
'update:field',
])
function updateField(field, event) {
emit('update:field', {
field,
value: event.target.value,
})
}
</script>
<template>
<div
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"
>
<div class="w-full max-w-md rounded-[28px] border border-white/60 bg-[#f6f1e8] p-6 shadow-2xl sm:p-7">
<div class="flex items-start justify-between gap-4">
<div class="space-y-2">
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">Account</p>
<h2 class="text-2xl font-semibold tracking-[-0.04em] text-stone-900">
{{ mode === 'login' ? '로그인' : '회원가입' }}
</h2>
<p class="text-sm leading-6 text-stone-600">
{{ mode === 'login' ? '저장된 플래너를 다시 이어서 볼 수 있습니다.' : '사용자별 기록과 통계를 연결하기 위한 계정을 만듭니다.' }}
</p>
</div>
<button
type="button"
class="rounded-full border border-stone-300 px-3 py-2 text-[10px] font-bold tracking-[0.16em] text-stone-500 transition hover:border-stone-500 hover:text-stone-900"
@click="emit('close')"
>
CLOSE
</button>
</div>
<form class="mt-6 space-y-4" @submit.prevent="emit('submit')">
<div v-if="mode === 'signup'" class="space-y-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">닉네임</label>
<input
:value="form.nickname"
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"
placeholder="닉네임을 입력해 주세요."
@input="updateField('nickname', $event)"
/>
</div>
<div class="space-y-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">이메일</label>
<input
:value="form.email"
type="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"
placeholder="zenn@example.com"
@input="updateField('email', $event)"
/>
</div>
<div class="space-y-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">비밀번호</label>
<input
:value="form.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"
placeholder="8자 이상 입력해 주세요."
@input="updateField('password', $event)"
/>
</div>
<p
v-if="message"
class="rounded-2xl border border-stone-300 bg-white/80 px-4 py-3 text-sm font-semibold leading-6 text-stone-700"
>
{{ message }}
</p>
<button
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"
:disabled="busy"
>
{{ busy ? '처리 중...' : mode === 'login' ? 'LOGIN' : 'SIGN UP' }}
</button>
</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">
<p class="text-sm font-semibold text-stone-600">
{{ mode === 'login' ? '아직 계정이 없나요?' : '이미 계정이 있나요?' }}
</p>
<button
type="button"
class="text-xs font-bold tracking-[0.16em] text-stone-900 underline underline-offset-4"
@click="emit('switch-mode', mode === 'login' ? 'signup' : 'login')"
>
{{ mode === 'login' ? '회원가입' : '로그인' }}
</button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,233 @@
<script setup>
const props = defineProps({
goals: {
type: Array,
default: () => [],
},
query: {
type: String,
default: '',
},
status: {
type: String,
default: 'all',
},
form: {
type: Object,
required: true,
},
editingGoalId: {
type: Number,
default: null,
},
busy: {
type: Boolean,
default: false,
},
message: {
type: String,
default: '',
},
selectedDateKey: {
type: String,
default: '',
},
})
const emit = defineEmits([
'update:query',
'update:status',
'update:form-field',
'submit:create',
'start-edit',
'cancel-edit',
'submit:update',
])
function updateField(field, event) {
emit('update:form-field', {
field,
value: event.target.value,
})
}
function isActiveOnSelectedDate(goal) {
if (!goal.activeFrom || !goal.activeUntil || !props.selectedDateKey) {
return false
}
return props.selectedDateKey >= goal.activeFrom && props.selectedDateKey <= goal.activeUntil
}
</script>
<template>
<section class="grid gap-6 xl:grid-cols-[380px_minmax(0,1fr)]">
<form class="rounded-[28px] border border-white/60 bg-white/75 p-6" @submit.prevent="emit(editingGoalId ? 'submit:update' : 'submit:create')">
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">
{{ editingGoalId ? 'Edit Goal' : 'Create Goal' }}
</p>
<div class="mt-5 space-y-4">
<div class="space-y-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">목표 이름</label>
<input
:value="form.title"
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"
placeholder="예: 자격증 시험 / 프로젝트 런칭 / 운동 루틴"
@input="updateField('title', $event)"
/>
</div>
<div class="space-y-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">목표일</label>
<input
:value="form.targetDate"
type="date"
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"
@input="updateField('targetDate', $event)"
/>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">표시 시작일</label>
<input
:value="form.activeFrom"
type="date"
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"
@input="updateField('activeFrom', $event)"
/>
</div>
<div class="space-y-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">표시 종료일</label>
<input
:value="form.activeUntil"
type="date"
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"
@input="updateField('activeUntil', $event)"
/>
</div>
</div>
<div class="space-y-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">상태</label>
<select
:value="form.status"
class="w-full rounded-2xl border border-stone-300 bg-white px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-500"
@change="updateField('status', $event)"
>
<option value="active">진행 </option>
<option value="done">완료</option>
<option value="archived">보관</option>
</select>
</div>
<p class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-3 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-600">
여기서 목표와 표시 기간을 설정해 두면, 플래너 작성 화면에서는 해당 날짜에 보여줄지 여부만 간단히 ON/OFF 있습니다.
</p>
<p
v-if="message"
class="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="flex flex-wrap gap-2">
<button
type="submit"
class="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"
>
{{ busy ? '저장 중...' : editingGoalId ? '목표 수정' : '목표 추가' }}
</button>
<button
v-if="editingGoalId"
type="button"
class="rounded-full border border-stone-300 px-5 py-3 text-xs font-bold tracking-[0.18em] text-stone-600 transition hover:border-stone-500 hover:text-stone-900"
@click="emit('cancel-edit')"
>
취소
</button>
</div>
</div>
</form>
<section class="rounded-[28px] border border-white/60 bg-white/75 p-6">
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">Goal Library</p>
<p class="mt-2 text-sm font-semibold leading-6 text-stone-600">
목표가 많아져도 플래너 작성 화면이 길어지지 않도록, 전체 관리는 화면에서 처리합니다.
</p>
</div>
<div class="grid gap-2 sm:grid-cols-[220px_140px]">
<input
:value="query"
type="text"
class="rounded-2xl border border-stone-300 bg-white px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-500"
placeholder="목표 검색"
@input="emit('update:query', $event.target.value)"
/>
<select
:value="status"
class="rounded-2xl border border-stone-300 bg-white px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-500"
@change="emit('update:status', $event.target.value)"
>
<option value="all">전체</option>
<option value="active">진행 </option>
<option value="done">완료</option>
<option value="archived">보관</option>
</select>
</div>
</div>
<div class="mt-5 grid gap-4">
<article
v-for="goal in goals"
:key="goal.id"
class="rounded-[24px] border border-stone-200 bg-white px-5 py-5"
>
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
<p class="text-lg font-semibold tracking-[-0.03em] text-stone-900">{{ goal.title }}</p>
<span
class="rounded-full px-3 py-1 text-[10px] font-bold tracking-[0.16em]"
:class="goal.status === 'done' ? 'bg-emerald-100 text-emerald-700' : goal.status === 'archived' ? 'bg-stone-200 text-stone-600' : 'bg-stone-900 text-white'"
>
{{ goal.status === 'done' ? '완료' : goal.status === 'archived' ? '보관' : '진행 중' }}
</span>
<span
v-if="isActiveOnSelectedDate(goal)"
class="rounded-full bg-amber-100 px-3 py-1 text-[10px] font-bold tracking-[0.16em] text-amber-700"
>
현재 날짜에 표시
</span>
</div>
<p class="text-sm font-semibold text-stone-600">목표일 {{ goal.targetDate }}</p>
<p class="text-[11px] font-semibold tracking-[0.06em] text-stone-500">
{{ goal.activeFrom && goal.activeUntil ? `표시 기간 ${goal.activeFrom} ~ ${goal.activeUntil}` : '표시 기간 미설정' }}
</p>
</div>
<button
type="button"
class="rounded-full border border-stone-300 px-4 py-2 text-[11px] font-bold tracking-[0.16em] text-stone-600 transition hover:border-stone-500 hover:text-stone-900"
@click="emit('start-edit', goal)"
>
수정
</button>
</div>
</article>
<div
v-if="goals.length === 0"
class="rounded-[24px] border border-dashed border-stone-300 bg-white px-5 py-8 text-center"
>
<p class="text-sm font-semibold text-stone-600">조건에 맞는 목표가 없습니다.</p>
</div>
</div>
</section>
</section>
</template>

View File

@@ -16,7 +16,11 @@ const props = defineProps({
},
dday: {
type: String,
required: true,
default: '',
},
showDday: {
type: Boolean,
default: true,
},
comment: {
type: String,
@@ -119,14 +123,14 @@ onBeforeUnmount(() => {
>
<div class="flex flex-col gap-4 py-[18px]">
<div class="flex gap-4">
<div class="relative h-[90px] w-[394px] flex-1 border-t border-ink px-[10px] pt-[10px]">
<div class="relative h-[90px] border-t border-ink px-[10px] pt-[10px]" :class="props.showDday ? 'w-[394px] 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">
<span>{{ dateMain }}</span>
<span class="ml-1" :class="dateWeekdayTone">{{ dateWeekday }}</span>
</p>
</div>
<div class="relative h-[90px] w-[210px] border-t border-ink px-[10px] pt-[10px]">
<div v-if="props.showDday" 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">D-DAY</span>
<p class="pt-6 text-xs tracking-[0.24em] text-ink sm:text-sm">{{ dday }}</p>
</div>

View File

@@ -0,0 +1,171 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
user: {
type: Object,
required: true,
},
profileForm: {
type: Object,
required: true,
},
passwordForm: {
type: Object,
required: true,
},
profileBusy: {
type: Boolean,
default: false,
},
passwordBusy: {
type: Boolean,
default: false,
},
profileMessage: {
type: String,
default: '',
},
passwordMessage: {
type: String,
default: '',
},
})
const emit = defineEmits([
'update:profile-field',
'update:password-field',
'submit:profile',
'submit:password',
])
const initials = computed(() =>
`${props.user.nickname?.slice(0, 1) ?? ''}${props.user.email?.slice(0, 1) ?? ''}`.toUpperCase(),
)
function updateProfileField(field, event) {
emit('update:profile-field', {
field,
value: event.target.value,
})
}
function updatePasswordField(field, event) {
emit('update:password-field', {
field,
value: event.target.value,
})
}
</script>
<template>
<section class="grid gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
<aside class="rounded-[28px] border border-white/60 bg-white/70 p-6">
<p class="text-[11px] font-bold uppercase tracking-[0.28em] text-stone-500">Settings</p>
<div class="mt-6 flex items-center gap-4">
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-stone-900 text-2xl font-bold tracking-[0.04em] text-white">
{{ initials || 'U' }}
</div>
<div>
<p class="text-xl font-semibold tracking-[-0.04em] text-stone-900">{{ user.nickname }}</p>
<p class="mt-1 text-sm font-semibold text-stone-500">{{ user.email }}</p>
</div>
</div>
<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-sm font-semibold leading-6 text-stone-700">
썸네일 이미지는 다음 단계에서 붙이는 편이 자연스럽습니다. 이번 단계에서는 계정 정보 수정과 비밀번호 변경 흐름을 먼저 안정화합니다.
</p>
</div>
</aside>
<div class="grid gap-6">
<form class="rounded-[28px] border border-white/60 bg-white/75 p-6" @submit.prevent="emit('submit:profile')">
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">Account Profile</p>
<div class="mt-5 grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">닉네임</label>
<input
:value="profileForm.nickname"
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"
@input="updateProfileField('nickname', $event)"
/>
</div>
<div class="space-y-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">이메일</label>
<input
:value="profileForm.email"
type="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"
@input="updateProfileField('email', $event)"
/>
</div>
</div>
<p
v-if="profileMessage"
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"
>
{{ profileMessage }}
</p>
<button
type="submit"
class="mt-5 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="profileBusy"
>
{{ profileBusy ? '저장 중...' : '프로필 저장' }}
</button>
</form>
<form class="rounded-[28px] border border-white/60 bg-white/75 p-6" @submit.prevent="emit('submit:password')">
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">Password</p>
<div class="mt-5 grid gap-4 md:grid-cols-2">
<div class="space-y-2 md:col-span-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">현재 비밀번호</label>
<input
:value="passwordForm.currentPassword"
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"
@input="updatePasswordField('currentPassword', $event)"
/>
</div>
<div class="space-y-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600"> 비밀번호</label>
<input
:value="passwordForm.newPassword"
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"
@input="updatePasswordField('newPassword', $event)"
/>
</div>
<div class="space-y-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600"> 비밀번호 확인</label>
<input
:value="passwordForm.confirmPassword"
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"
@input="updatePasswordField('confirmPassword', $event)"
/>
</div>
</div>
<p
v-if="passwordMessage"
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"
>
{{ passwordMessage }}
</p>
<button
type="submit"
class="mt-5 rounded-full border border-stone-900 px-5 py-3 text-xs font-bold tracking-[0.18em] text-stone-900 transition hover:bg-stone-900 hover:text-white disabled:cursor-not-allowed disabled:border-stone-300 disabled:text-stone-400"
:disabled="passwordBusy"
>
{{ passwordBusy ? '변경 중...' : '비밀번호 변경' }}
</button>
</form>
</div>
</section>
</template>

97
src/lib/authClient.js Normal file
View File

@@ -0,0 +1,97 @@
const AUTH_STORAGE_KEY = 'ten-minute-planner-auth'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001'
function buildHeaders(token, extraHeaders = {}) {
return {
'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}`, {
method,
headers: buildHeaders(token),
body: body ? JSON.stringify(body) : undefined,
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(data.message || '요청 처리 중 문제가 발생했습니다.')
}
return data
}
export function readAuthState() {
if (typeof window === 'undefined') {
return { token: '', user: null }
}
try {
return JSON.parse(window.localStorage.getItem(AUTH_STORAGE_KEY) ?? '{"token":"","user":null}')
} catch (error) {
console.warn('저장된 인증 상태를 불러오지 못했습니다.', error)
return { token: '', user: null }
}
}
export function persistAuthState({ token, user }) {
if (typeof window === 'undefined') {
return
}
window.localStorage.setItem(
AUTH_STORAGE_KEY,
JSON.stringify({
token,
user,
}),
)
}
export function clearAuthState() {
if (typeof window === 'undefined') {
return
}
window.localStorage.removeItem(AUTH_STORAGE_KEY)
}
export async function signup({ email, password, nickname }) {
return request('/api/auth/signup', {
method: 'POST',
body: { email, password, nickname },
})
}
export async function login({ email, password }) {
return request('/api/auth/login', {
method: 'POST',
body: { email, password },
})
}
export async function fetchCurrentUser(token) {
return request('/api/auth/me', {
token,
})
}
export async function updateProfile(token, { email, nickname }) {
return request('/api/auth/profile', {
method: 'PUT',
token,
body: { email, nickname },
})
}
export async function updatePassword(token, { currentPassword, newPassword }) {
return request('/api/auth/password', {
method: 'PUT',
token,
body: { currentPassword, newPassword },
})
}

57
src/lib/goalsApi.js Normal file
View File

@@ -0,0 +1,57 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001'
function buildHeaders(token) {
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
}
}
async function request(path, { method = 'GET', token, body } = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, {
method,
headers: buildHeaders(token),
body: body ? JSON.stringify(body) : undefined,
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(data.message || '목표 데이터를 처리하지 못했습니다.')
}
return data
}
export async function fetchGoals(token, { query = '', status = 'active' } = {}) {
const searchParams = new URLSearchParams()
if (query) {
searchParams.set('query', query)
}
if (status) {
searchParams.set('status', status)
}
const queryString = searchParams.toString()
return request(`/api/goals${queryString ? `?${queryString}` : ''}`, {
token,
})
}
export async function createGoal(token, payload) {
return request('/api/goals', {
method: 'POST',
token,
body: payload,
})
}
export async function updateGoal(token, goalId, payload) {
return request(`/api/goals/${goalId}`, {
method: 'PATCH',
token,
body: payload,
})
}

58
src/lib/plannerApi.js Normal file
View File

@@ -0,0 +1,58 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001'
function buildHeaders(token) {
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
}
}
async function request(path, { method = 'GET', token, body } = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, {
method,
headers: buildHeaders(token),
body: body ? JSON.stringify(body) : undefined,
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(data.message || '플래너 데이터를 처리하지 못했습니다.')
}
return data
}
export async function fetchPlannerEntries(token, range = {}) {
const searchParams = new URLSearchParams()
if (range.from) {
searchParams.set('from', range.from)
}
if (range.to) {
searchParams.set('to', range.to)
}
const query = searchParams.toString()
return request(`/api/planner${query ? `?${query}` : ''}`, {
token,
})
}
export async function savePlannerEntry(token, entryDate, payload) {
return request(`/api/planner/${entryDate}`, {
method: 'PUT',
token,
body: {
payload,
},
})
}
export async function deletePlannerEntry(token, entryDate) {
return request(`/api/planner/${entryDate}`, {
method: 'DELETE',
token,
})
}

View File

@@ -13,6 +13,10 @@ function readStorageState() {
}
}
export function readPlannerStorageState() {
return readStorageState()
}
export function createInitialPlannerRecords(seedRecords, normalizeRecord) {
const baseRecords = Object.fromEntries(
Object.entries(seedRecords).map(([key, record]) => [key, normalizeRecord(record)]),
@@ -62,31 +66,37 @@ export function persistPlannerState({
calendarViewDate,
statsRangeStart,
statsRangeEnd,
includeRecords = true,
}) {
if (typeof window === 'undefined') {
return
}
const serializableRecords = Object.fromEntries(
Object.entries(plannerRecords).map(([key, record]) => [
key,
{
...record,
tasks: record.tasks.map((task) => ({ ...task })),
memo: record.memo.map((item) => ({ ...item })),
timetable: [...record.timetable],
},
]),
)
const previousState = readStorageState()
const nextState = {
...previousState,
selectedDate: selectedDate.toISOString(),
calendarViewDate: calendarViewDate.toISOString(),
statsRangeStart,
statsRangeEnd,
}
if (includeRecords) {
nextState.records = Object.fromEntries(
Object.entries(plannerRecords).map(([key, record]) => [
key,
{
...record,
tasks: record.tasks.map((task) => ({ ...task })),
memo: record.memo.map((item) => ({ ...item })),
timetable: [...record.timetable],
},
]),
)
}
window.localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
selectedDate: selectedDate.toISOString(),
calendarViewDate: calendarViewDate.toISOString(),
statsRangeStart,
statsRangeEnd,
records: serializableRecords,
}),
JSON.stringify(nextState),
)
}