Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e221254c60 | |||
| 1738a7d3b6 | |||
| 9760c9dc0e | |||
| b972209c2f | |||
| bc2e981577 |
13
HANDOFF.md
13
HANDOFF.md
@@ -4,7 +4,7 @@
|
||||
|
||||
- 프로젝트명: 10 Minute Planner 웹 UI
|
||||
- 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript
|
||||
- 현재 기준 버전: `v0.1.20`
|
||||
- 현재 기준 버전: `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`는 화면에서 바로 편집할 수 있다.
|
||||
@@ -149,10 +150,20 @@
|
||||
- 목표 상태 개념은 제거했고, 목표는 기간이 있으면 곧바로 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...` 오류는 이 문제였다.
|
||||
- 비로그인 랜딩 카드는 상단 고정이 아니라 화면 중앙에 오도록 정렬을 수정했다.
|
||||
- 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다.
|
||||
- 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다.
|
||||
- Docker Compose는 프론트엔드와 백엔드를 함께 올리는 기준으로 설계하되, NAS 환경에 맞는 볼륨과 재시작 정책도 함께 고려한다.
|
||||
|
||||
57
README.md
Normal file
57
README.md
Normal 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 배포 예정
|
||||
2
TODO.md
2
TODO.md
@@ -38,7 +38,7 @@
|
||||
|
||||
- [x] 목표 관리 패널 기본 구조를 설계한다.
|
||||
- [x] 선택한 목표 기준으로 `D-DAY`가 자동 계산되게 한다.
|
||||
- [ ] 우측 요약 패널의 `PREV SNAPSHOT`, `READ NEXT`를 실제 데이터 기반으로 연결한다.
|
||||
- [x] 우측 요약 패널의 `PREV SNAPSHOT`, `READ NEXT`를 실제 데이터 기반으로 연결한다.
|
||||
- [ ] 다음날 할 일 자동 제안 규칙을 정리한다.
|
||||
- [x] 오른쪽 패널에 `D-DAY 사용` 토글과 목표 검색/선택 UI를 추가한다.
|
||||
- [x] 목표를 여러 개 생성하고 날짜별 대표 목표를 선택할 수 있게 한다.
|
||||
|
||||
61
docker-compose.dev.yml
Normal file
61
docker-compose.dev.yml
Normal 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
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ten-minute-planner",
|
||||
"version": "0.1.20",
|
||||
"version": "0.1.25",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ten-minute-planner",
|
||||
"version": "0.1.20",
|
||||
"version": "0.1.25",
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ten-minute-planner",
|
||||
"private": true,
|
||||
"version": "0.1.20",
|
||||
"version": "0.1.25",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
71
src/App.vue
71
src/App.vue
@@ -16,6 +16,7 @@ import {
|
||||
updatePassword,
|
||||
updateProfile,
|
||||
} from './lib/authClient'
|
||||
import { toUserFacingApiError } from './lib/apiBase'
|
||||
import { createGoal, deleteGoal, fetchGoals, updateGoal } from './lib/goalsApi'
|
||||
import { deletePlannerEntry, fetchPlannerEntries, savePlannerEntry } from './lib/plannerApi'
|
||||
import {
|
||||
@@ -636,6 +637,56 @@ const bestDay = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const previousRecordedEntry = computed(() => {
|
||||
const selectedKey = toKey(selectedDate.value)
|
||||
|
||||
return [...plannerEntries.value]
|
||||
.filter(([key]) => key < selectedKey)
|
||||
.sort(([leftKey], [rightKey]) => rightKey.localeCompare(leftKey))[0] ?? null
|
||||
})
|
||||
|
||||
const prevSnapshotItems = computed(() => {
|
||||
if (!previousRecordedEntry.value) {
|
||||
return [
|
||||
'이전 기록 없음',
|
||||
'첫 기록을 쌓아보세요.',
|
||||
'오늘부터 흐름을 만들 수 있습니다.',
|
||||
]
|
||||
}
|
||||
|
||||
const [entryKey, entryRecord] = previousRecordedEntry.value
|
||||
const completedCount = entryRecord.tasks.filter((task) => task.title.trim() && task.checked).length
|
||||
const previousComment = entryRecord.comment.trim()
|
||||
const previousTopTask = entryRecord.tasks.find((task) => task.title.trim())
|
||||
|
||||
return [
|
||||
`${createDateLabel(entryKey)} 기록`,
|
||||
`${formatTotalTime(entryRecord)} 집중 / 완료 ${completedCount}개`,
|
||||
previousComment || (previousTopTask ? `주요 작업: ${previousTopTask.title}` : '남겨진 코멘트 없음'),
|
||||
]
|
||||
})
|
||||
|
||||
const readNextItems = computed(() => {
|
||||
const nextDayFirstTask = secondaryPlanner.value.tasks.find((task) => task.title.trim())
|
||||
const incompleteTasks = planner.value.tasks.filter((task) => task.title.trim() && !task.checked)
|
||||
const carryTask = incompleteTasks[0]?.title
|
||||
const todayComment = planner.value.comment.trim()
|
||||
|
||||
return [
|
||||
nextDayFirstTask
|
||||
? `내일 첫 작업: ${nextDayFirstTask.title}`
|
||||
: carryTask
|
||||
? `내일 이어갈 첫 작업: ${carryTask}`
|
||||
: '내일 첫 작업은 아직 비어 있습니다.',
|
||||
incompleteTasks.length > 0
|
||||
? `미완료 ${incompleteTasks.length}개를 이어서 볼 수 있습니다.`
|
||||
: '오늘 미완료 작업은 없습니다.',
|
||||
todayComment
|
||||
? `오늘 코멘트 이어보기: ${todayComment}`
|
||||
: '오늘 코멘트는 아직 비어 있습니다.',
|
||||
]
|
||||
})
|
||||
|
||||
watch(
|
||||
[plannerRecords, selectedDate, calendarViewDate, statsRangeStart, statsRangeEnd],
|
||||
() => {
|
||||
@@ -799,7 +850,7 @@ async function submitAuthForm() {
|
||||
|
||||
await applyAuthSuccess(result)
|
||||
} catch (error) {
|
||||
authMessage.value = error.message || '인증 처리 중 문제가 발생했습니다.'
|
||||
authMessage.value = toUserFacingApiError(error, '인증 처리 중 문제가 발생했습니다.')
|
||||
} finally {
|
||||
authBusy.value = false
|
||||
}
|
||||
@@ -1246,8 +1297,14 @@ 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:grid xl:grid-cols-[300px_minmax(0,1fr)] xl:items-start">
|
||||
<aside class="scrollbar-hide print-hidden rounded-[28px] border border-white/60 bg-white/70 p-5 backdrop-blur sm:p-6 xl:h-full xl:overflow-y-auto">
|
||||
<div
|
||||
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"
|
||||
class="scrollbar-hide print-hidden rounded-[28px] border border-white/60 bg-white/70 p-5 backdrop-blur sm:p-6 xl:h-full xl:overflow-y-auto"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<p class="text-[11px] font-bold uppercase tracking-[0.28em] text-stone-500">10 Minute Planner</p>
|
||||
@@ -1402,10 +1459,10 @@ onMounted(() => {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="min-w-0 space-y-6 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">
|
||||
@@ -1493,7 +1550,7 @@ onMounted(() => {
|
||||
<p class="mb-4 text-[11px] font-bold tracking-[0.22em] text-ink">PREV SNAPSHOT</p>
|
||||
<div class="space-y-3">
|
||||
<p
|
||||
v-for="item in planner.prevSummary"
|
||||
v-for="item in prevSnapshotItems"
|
||||
:key="item"
|
||||
class="border-b border-stone-200 pb-3 text-[11px] font-semibold tracking-[0.08em] text-stone-700 last:border-b-0 last:pb-0"
|
||||
>
|
||||
@@ -1528,7 +1585,7 @@ onMounted(() => {
|
||||
<p class="mb-4 text-[11px] font-bold tracking-[0.22em] text-ink">READ NEXT</p>
|
||||
<div class="space-y-3">
|
||||
<p
|
||||
v-for="item in planner.nextFocus"
|
||||
v-for="item in readNextItems"
|
||||
:key="item"
|
||||
class="border-b border-stone-200 pb-3 text-[11px] font-semibold tracking-[0.08em] text-stone-700 last:border-b-0 last:pb-0"
|
||||
>
|
||||
|
||||
46
src/lib/apiBase.js
Normal file
46
src/lib/apiBase.js
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user