git: 원격 저장소 연결 및 초기 커밋
Made-with: Cursor
This commit is contained in:
15
.cursor/rules/01-base.mdc
Normal file
15
.cursor/rules/01-base.mdc
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
- 모든 응답과 문서는 한국어로 작성한다.
|
||||
- 답변은 짧고 명확하게 작성한다.
|
||||
- 이모지 사용을 금지한다.
|
||||
- 추측성 답변을 하지 않는다.
|
||||
- 불확실한 내용은 반드시 명시한다.
|
||||
|
||||
- 기존 코드 스타일을 우선 따른다.
|
||||
- 불필요한 리팩토링을 금지한다.
|
||||
- 요청되지 않은 구조 변경을 금지한다.
|
||||
|
||||
- 파일 생성은 최소화한다.
|
||||
- 기존 코드에서 해결 가능한지 먼저 검토한다.
|
||||
14
.cursor/rules/02-workflow.mdc
Normal file
14
.cursor/rules/02-workflow.mdc
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
모든 작업은 반드시 아래 순서를 따른다:
|
||||
|
||||
1. docs 문서를 먼저 확인한다.
|
||||
2. 관련 코드 파일을 확인한다.
|
||||
3. 기존 구현 방식과 패턴을 파악한다.
|
||||
4. 최소 범위로 수정한다.
|
||||
5. 검증을 수행한다.
|
||||
6. 문서를 즉시 갱신한다.
|
||||
7. 누락 여부를 확인한다.
|
||||
|
||||
이 순서를 생략하거나 변경하지 않는다.
|
||||
15
.cursor/rules/03-docs.mdc
Normal file
15
.cursor/rules/03-docs.mdc
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
작업 후 반드시 문서를 갱신한다.
|
||||
|
||||
- update.md: 작업 내용을 항목형으로 기록 (~추가., ~수정.)
|
||||
- todo.md: 현재 유효한 작업만 유지
|
||||
- spec.md: API / 데이터 구조 변경 시 갱신
|
||||
- history.md: 구조 변경 + 이유 기록
|
||||
- map.md: 파일-기능 매핑 유지
|
||||
- deploy.md: 실행 및 배포 방법 최신화
|
||||
|
||||
문서와 코드가 다르면:
|
||||
1. 코드를 기준으로 판단
|
||||
2. 문서를 수정한다
|
||||
11
.cursor/rules/04-project.mdc
Normal file
11
.cursor/rules/04-project.mdc
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
- 이 프로젝트는 Vite + Vue 3(SPA) 기반이다.
|
||||
- 기존 구조, 네이밍, 패턴을 반드시 먼저 파악한다.
|
||||
- 기존 composables, utils, components를 우선 재사용한다.
|
||||
- 상태 관리는 기존 방식을 따른다.
|
||||
- 동일 데이터를 중복 관리하지 않는다.
|
||||
|
||||
- Tailwind CSS를 사용한다.
|
||||
- 전역 폰트는 Pretendard를 유지한다.
|
||||
13
.cursor/rules/05-frontend.mdc
Normal file
13
.cursor/rules/05-frontend.mdc
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
- API 호출은 기존 API 레이어를 사용한다.
|
||||
- 정적 URL은 toApiUrl()로 처리한다.
|
||||
|
||||
- Composition API 패턴(ref, computed, onMounted)을 유지한다.
|
||||
|
||||
- UI는 "요약 + 필요 시 모달" 구조를 우선한다.
|
||||
- 모달 활성화 시 body scroll lock을 적용한다.
|
||||
|
||||
- 리스트 변경 시 깜빡임 없이 즉시 반영한다.
|
||||
- 애니메이션은 TransitionGroup을 사용한다.
|
||||
11
.cursor/rules/06-backend.mdc
Normal file
11
.cursor/rules/06-backend.mdc
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
- 입력 검증은 zod를 사용한다.
|
||||
- 인증/권한은 PocketBase 클라이언트 래퍼 또는 라우터 가드 등으로 분리한다.
|
||||
|
||||
- 사용자 입력은 반드시 검증 후 저장한다.
|
||||
- 환경 의존 값(URL 등)은 저장 전에 정규화한다.
|
||||
|
||||
- 업로드 파일명은 ASCII 안전 문자열을 사용한다.
|
||||
- 관리자/사용자 업로드 경로를 분리한다.
|
||||
12
.cursor/rules/07-style.mdc
Normal file
12
.cursor/rules/07-style.mdc
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
- Tailwind CSS를 기본으로 사용한다.
|
||||
- Tailwind만으로 구조를 구성하지 않는다.
|
||||
- 주요 영역에는 고유 클래스명을 반드시 추가한다.
|
||||
|
||||
- 클래스명은 의미 기반으로 작성한다.
|
||||
- 임시 이름 사용을 금지한다.
|
||||
|
||||
- JavaScript는 세미콜론 없이 작성한다.
|
||||
- 주석은 필요한 경우에만 JSDoc 형식을 사용한다.
|
||||
22
.cursor/rules/08-git.mdc
Normal file
22
.cursor/rules/08-git.mdc
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
- 커밋 메시지는 한국어로 작성한다.
|
||||
- 형식: "영역: 변경 내용"
|
||||
|
||||
예:
|
||||
docs: 규칙 정리
|
||||
blog: 레이아웃 구현
|
||||
|
||||
- 작성자 정보:
|
||||
name: zenn
|
||||
email: zenn.message@gmail.com
|
||||
|
||||
- 작업 후 반드시:
|
||||
git add -A
|
||||
git commit
|
||||
git push
|
||||
|
||||
- 버전은 v0.0.1 형식으로 증가시킨다.
|
||||
|
||||
- 푸시 전 민감 정보 포함 여부를 확인한다.
|
||||
11
.cursor/rules/09-validation.mdc
Normal file
11
.cursor/rules/09-validation.mdc
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
- 가능한 범위에서 기능 검증을 수행한다.
|
||||
- 주요 사용자 흐름을 확인한다.
|
||||
- null, undefined, 실패 응답을 점검한다.
|
||||
|
||||
- console.log는 디버깅용으로만 사용한다.
|
||||
- 작업 완료 시 불필요한 로그는 제거한다.
|
||||
|
||||
- 검증을 수행하지 못한 경우 사유를 명시한다.
|
||||
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
pb_data
|
||||
.env
|
||||
.env.*
|
||||
*.md
|
||||
.cursor
|
||||
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
# PocketBase 공개 URL (끝 슬래시 없이). Docker 배포 시 브라우저에서 접근 가능한 주소로 설정
|
||||
VITE_POCKETBASE_URL=http://127.0.0.1:8090
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*.local
|
||||
pb_data
|
||||
*.log
|
||||
20
Dockerfile
Normal file
20
Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG VITE_POCKETBASE_URL
|
||||
ENV VITE_POCKETBASE_URL=${VITE_POCKETBASE_URL}
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
25
docker-compose.yml
Normal file
25
docker-compose.yml
Normal file
@@ -0,0 +1,25 @@
|
||||
services:
|
||||
pocketbase:
|
||||
image: ghcr.io/muchobien/pocketbase:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PB_HOST: 0.0.0.0
|
||||
PB_PORT: 8090
|
||||
ports:
|
||||
- "8090:8090"
|
||||
volumes:
|
||||
- pocketbase_data:/pb_data
|
||||
|
||||
todo-web:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
VITE_POCKETBASE_URL: ${VITE_POCKETBASE_URL:-http://127.0.0.1:8090}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:80"
|
||||
depends_on:
|
||||
- pocketbase
|
||||
|
||||
volumes:
|
||||
pocketbase_data:
|
||||
141
docs/convention.md
Normal file
141
docs/convention.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# 코딩 컨벤션
|
||||
|
||||
## 공통
|
||||
- 시작 버전은 `v0.0.1`이다.
|
||||
- 문서와 사용자 응답은 한국어로 작성한다.
|
||||
- 작업 보고, 릴리스 메모, 주요 문서 업데이트에는 항상 버전명을 함께 표시한다.
|
||||
- `docs/update.md`는 짧은 항목형으로 작성한다.
|
||||
- `docs/update.md`는 서술형 문장보다 `~추가.`, `~수정.`, `~정리.`, `~진행.` 형식을 우선 사용한다.
|
||||
- `docs/update.md`는 날짜가 아니라 버전 기준 섹션으로 구분한다.
|
||||
- `docs/update.md`는 가장 최신 버전 섹션이 항상 맨 위에 오도록 유지한다.
|
||||
- `docs/history.md`는 이유를 포함한 서술형 기록을 허용한다.
|
||||
- `docs/history.md`는 가장 최신 항목이 항상 맨 위에 오도록 유지한다.
|
||||
- `docs/history.md` 제목에는 날짜와 버전을 함께 적는다.
|
||||
- JavaScript는 세미콜론 없이 현재 코드 스타일을 유지한다.
|
||||
- 새 주석이 필요할 경우 JSDoc 형식을 사용한다.
|
||||
- 경로, 주소, 운영 설정은 하드코딩보다 환경변수 기반 구성을 우선한다.
|
||||
- 사이트 전역 기본 폰트는 항상 `Pretendard`를 우선 사용한다.
|
||||
- Git 커밋 메시지는 한국어로 작성한다.
|
||||
- 로컬 Git 작성자 정보는 항상 `zenn` / `zenn.message@gmail.com` 조합을 사용한다.
|
||||
- 버전 릴리스가 포함된 작업은 `docs/update.md`의 버전 표기와 Git 태그를 함께 맞춘다.
|
||||
- Git 푸시 전에는 민감 정보(실명, 개인 이메일, 비밀키, 로컬 절대 경로) 포함 여부를 다시 확인한다.
|
||||
- 작업이 끝난 뒤에는 문서 업데이트까지 포함한 변경사항을 커밋하고 원격 저장소에 푸시한다.
|
||||
|
||||
### Git 업로드 양식
|
||||
- 작성자 정보 확인: `git config user.name` / `git config user.email`
|
||||
- 작성자 정보 고정: `git config user.name "zenn"` / `git config user.email "zenn.message@gmail.com"`
|
||||
- 작업 확인: `git status`
|
||||
- 변경 스테이징: `git add -A`
|
||||
- 커밋: `git commit -m "작업 목적을 한 줄로 설명하는 한국어 메시지"`
|
||||
- 원격 푸시: `git push origin main`
|
||||
- 푸시 후 확인: `git status`, `git log -1 --oneline`
|
||||
|
||||
### Git 커밋 메시지 규칙
|
||||
- 한국어 한 줄 요약으로 작성한다.
|
||||
- 가능하면 `영역: 변경 내용` 형식을 사용한다.
|
||||
- 예시: `docs: Git 업로드 규칙 정리`
|
||||
- 예시: `app: 기본 Vite 레이아웃 초기 구현`
|
||||
|
||||
### 버전 표기 규칙
|
||||
- 작업이 끝나 커밋할 때마다 버전을 증가시킨다.
|
||||
- 새 기능 시작, 구조 변경, 배포 준비, 작업 결과 보고 시 현재 버전을 함께 적는다.
|
||||
- 형식은 항상 `v0.0.1`처럼 `v` 접두사를 포함한다.
|
||||
- 버전이 변경되면 `docs/update.md`, `docs/spec.md`, `docs/history.md`, `docs/deploy.md`에 함께 반영한다.
|
||||
|
||||
---
|
||||
|
||||
## 수정 범위 규칙
|
||||
- 요청된 기능과 직접 관련 없는 코드는 수정하지 않는다.
|
||||
- 기존에 정상 동작하는 로직은 리팩토링하지 않는다.
|
||||
- 스타일 변경만으로 해결 가능한 경우 로직 수정 금지.
|
||||
- 기존 API 구조를 임의로 변경하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 환경 변수 및 설정 규칙
|
||||
- API 주소, 파일 경로, 외부 서비스 URL은 반드시 환경변수로 관리한다.
|
||||
- 코드 내에 직접 문자열로 작성하지 않는다.
|
||||
- 예외적으로 테스트용 임시 값은 주석으로 명시한다.
|
||||
|
||||
---
|
||||
|
||||
## 로그 및 디버깅 규칙
|
||||
- console.log는 디버깅 용도로만 사용한다.
|
||||
- 작업 완료 시 불필요한 로그는 반드시 제거한다.
|
||||
- 운영 환경에 영향을 줄 수 있는 로그는 추가하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 프런트엔드
|
||||
|
||||
### 기본 구조
|
||||
- API 호출은 `src/lib/pocketBase.js` 및 관련 composable을 통해 통합한다.
|
||||
- 정적 파일 URL 조합은 `toApiUrl()`로 처리한다.
|
||||
- 화면 상태는 `ref`, `computed`, `onMounted` 중심의 단순한 Composition API 패턴을 유지한다.
|
||||
|
||||
### UI/UX 패턴
|
||||
- 설정/계정과 같이 자주 변경되지 않는 정보는 `현재 상태 요약 + 필요 시 모달 편집` 구조를 우선한다.
|
||||
- 모달이 활성화된 동안에는 `body` 스크롤 잠금을 적용한다.
|
||||
- 배경 화면이 움직이지 않도록 한다.
|
||||
- 정렬 가능한 리스트는 즉시 재렌더링으로 깜빡이지 않도록 한다.
|
||||
- `TransitionGroup` 등을 사용해 이동 애니메이션을 적용한다.
|
||||
|
||||
### 스타일 (Tailwind + 구조 클래스)
|
||||
- 기본 스타일링은 Tailwind CSS를 사용한다.
|
||||
- Tailwind 유틸리티 클래스만으로 구조를 구성하지 않는다.
|
||||
- 주요 영역에는 반드시 의미 있는 고유 클래스명을 함께 작성한다.
|
||||
- 고유 클래스명은 개발자 도구에서 구조를 빠르게 파악하기 위한 용도로 사용한다.
|
||||
- 클래스명은 컴포넌트 또는 역할 기준으로 명확하게 작성한다.
|
||||
- 임시 이름 사용을 금지한다.
|
||||
|
||||
---
|
||||
|
||||
## 백엔드
|
||||
|
||||
### 기본 규칙
|
||||
- 라우트 검증은 `zod`로 처리한다.
|
||||
- 인증/권한 분기는 PocketBase 세션·가드(`requireAuth` 등)로 분리한다.
|
||||
|
||||
### 데이터 처리
|
||||
- DB 저장 전 배포 환경에 종속되는 값(예: 로컬 절대 URL)을 제거하거나 정규화한다.
|
||||
- 사용자 입력 데이터는 반드시 검증 후 저장한다.
|
||||
|
||||
### 파일 업로드
|
||||
- 업로드 파일명은 ASCII 안전 문자열을 사용한다.
|
||||
- 관리자 데이터와 사용자 데이터는 업로드 경로를 분리한다.
|
||||
|
||||
### 기능 흐름
|
||||
- 사용자 프로필과 같이 “선택 후 저장” 흐름이 필요한 기능은
|
||||
파일 선택과 실제 저장 요청을 분리한다.
|
||||
|
||||
---
|
||||
|
||||
## 네이밍 규칙
|
||||
- 변수명, 함수명, 클래스명은 의미가 명확하게 드러나야 한다.
|
||||
- 축약어 남용을 금지한다.
|
||||
- 임시 이름(`temp`, `data2`, `testFn`, `aaa`) 사용을 금지한다.
|
||||
- 컴포넌트 이름은 역할 기반으로 작성한다.
|
||||
|
||||
---
|
||||
|
||||
## 파일 및 구조 규칙
|
||||
- 파일 생성은 최소화한다.
|
||||
- 기존 파일에서 해결 가능한지 먼저 검토한다.
|
||||
- 사용하지 않는 코드(import, 함수, 스타일)는 반드시 제거한다.
|
||||
- 파일은 하나의 책임을 가지도록 유지한다.
|
||||
|
||||
---
|
||||
|
||||
## 주석 규칙
|
||||
- 주석은 필요한 경우에만 작성한다.
|
||||
- 함수, 복잡한 로직에는 JSDoc 형식을 사용한다.
|
||||
- 코드로 설명 가능한 내용은 주석으로 반복하지 않는다.
|
||||
- 오래되면 잘못될 수 있는 주석은 작성하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 검증 규칙
|
||||
- 가능한 범위에서 기능 검증을 수행한다.
|
||||
- 주요 사용자 흐름을 확인한다.
|
||||
- 에러 발생 가능성이 있는 조건(null, undefined, 실패 응답 등)을 점검한다.
|
||||
- 검증을 수행하지 못한 경우 그 사유를 명확히 남긴다.
|
||||
49
docs/deploy.md
Normal file
49
docs/deploy.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# 배포 가이드
|
||||
|
||||
## 현재 버전
|
||||
|
||||
- `v0.0.1`
|
||||
|
||||
## 로컬 개발
|
||||
|
||||
1. `cp .env.example .env` 후 `VITE_POCKETBASE_URL`을 실제 PocketBase 주소로 맞춘다.
|
||||
2. `npm install`
|
||||
3. `npm run dev`
|
||||
|
||||
PocketBase는 Docker로 띄우려면 `docker compose up pocketbase`만 실행해도 된다(기본 `8090`).
|
||||
|
||||
## Docker Compose(웹 + PocketBase)
|
||||
|
||||
프로젝트 루트에서:
|
||||
|
||||
```bash
|
||||
export VITE_POCKETBASE_URL="http://<이_기기_IP>:8090"
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
- 웹: `http://<이_기기_IP>:8080`
|
||||
- PocketBase 관리자: `http://<이_기기_IP>:8090/_/`
|
||||
|
||||
**중요:** `VITE_POCKETBASE_URL`은 **컨테이너가 아니라 브라우저**에서 접근한다. 같은 PC에서만 쓸 때는 `http://127.0.0.1:8090`이 가능하지만, 휴대폰 등 다른 기기에서 접속할 때는 NAS/PC의 LAN IP 또는 공인 도메인을 사용해야 한다.
|
||||
|
||||
### PocketBase 초기 설정 요약
|
||||
|
||||
1. 관리자 UI에서 컬렉션 `todos` 생성(`title` text, `done` bool).
|
||||
2. API 규칙을 배포 방식에 맞게 설정(인증 사용 시 로그인 사용자만 생성·수정 가능 등).
|
||||
3. CORS 허용 출처에 웹 앱 출처(예: `http://<IP>:8080`)를 추가한다.
|
||||
|
||||
### 선택: 관리자 자동 생성
|
||||
|
||||
`docker-compose.yml`의 `pocketbase` 서비스에 `PB_ADMIN_EMAIL`, `PB_ADMIN_PASSWORD` 환경 변수를 추가할 수 있다. 값은 저장소에 넣지 말고 NAS 비밀 관리 방식으로 주입한다.
|
||||
|
||||
## 단일 이미지(웹만)
|
||||
|
||||
`Dockerfile`은 정적 빌드 결과를 nginx로 제공한다. 빌드 시점에 `VITE_POCKETBASE_URL`이 번들에 포함된다.
|
||||
|
||||
```bash
|
||||
docker build --build-arg VITE_POCKETBASE_URL="https://pb.example.com" -t todo-web .
|
||||
```
|
||||
|
||||
## PWA·HTTPS
|
||||
|
||||
홈 화면 추가·서비스 워커 동작은 브라우저마다 다르며, **HTTPS**를 쓰는 것이 안정적이다. NAS 리버스 프록시에서 TLS를 종료하는 구성을 권장한다.
|
||||
5
docs/history.md
Normal file
5
docs/history.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-13 · v0.0.1 — Vite SPA 스택 확정
|
||||
|
||||
SEO 요구가 없고 NAS·PWA·PocketBase 중심 배포가 목표이므로 Nuxt 대신 **Vite + Vue 3 SPA**로 시작한다. 빌드 산출물이 단순해지고 nginx 정적 호스팅과 Docker 구성이 단순해진다.
|
||||
20
docs/map.md
Normal file
20
docs/map.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# 파일-기능 매핑
|
||||
|
||||
## 현재 버전
|
||||
|
||||
- `v0.0.1`
|
||||
|
||||
| 경로 | 역할 |
|
||||
| ------------------------- | ----------------------------------------- |
|
||||
| `src/App.vue` | 미리 알림 스타일 UI, 목록·입력 |
|
||||
| `src/main.js` | 앱 부트스트랩, PWA 서비스 워커 등록 |
|
||||
| `src/style.css` | Tailwind 베이스, 모션 감소 대응 |
|
||||
| `src/lib/apiUrl.js` | `toApiUrl()` URL 정규화 |
|
||||
| `src/lib/pocketBase.js` | PocketBase 싱글톤 클라이언트 |
|
||||
| `src/lib/todoSchema.js` | 할 일 제목 Zod 스키마 |
|
||||
| `src/composables/useTodos.js` | 목록 로드·추가·완료 토글 |
|
||||
| `vite.config.js` | Vue 플러그인, PWA 매니페스트 |
|
||||
| `tailwind.config.js` | 테마 색·폰트 |
|
||||
| `docker-compose.yml` | PocketBase + 정적 웹(nginx) 구성 |
|
||||
| `Dockerfile` | Vite 빌드 후 nginx 이미지 |
|
||||
| `nginx.conf` | SPA 폴백 라우팅 |
|
||||
32
docs/spec.md
Normal file
32
docs/spec.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# 기술 명세
|
||||
|
||||
## 현재 버전
|
||||
|
||||
- `v0.0.1`
|
||||
|
||||
## 스택
|
||||
|
||||
- Vue 3 + Vite(SPA)
|
||||
- Tailwind CSS, Pretendard(변수 폰트, CDN)
|
||||
- PWA: `vite-plugin-pwa`(자동 업데이트 등록)
|
||||
- 데이터: PocketBase(공식 JS SDK)
|
||||
- 입력 검증: Zod(`src/lib/todoSchema.js`)
|
||||
|
||||
## PocketBase 컬렉션: `todos`
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
| ------ | ------- | ----------- |
|
||||
| `title` | text | 할 일 제목 |
|
||||
| `done` | bool | 완료 여부 |
|
||||
|
||||
규칙(API 규칙)은 운영 환경에 맞게 설정한다. 로컬 개발 시에는 본인 계정에 맞는 생성·수정 권한이 있어야 한다.
|
||||
|
||||
## 환경 변수
|
||||
|
||||
| 이름 | 설명 |
|
||||
| ------------------------ | ------------------------------------------------------------ |
|
||||
| `VITE_POCKETBASE_URL` | PocketBase 루트 URL(끝 슬래시 없음). **브라우저가 접근 가능한 주소**여야 한다. |
|
||||
|
||||
## 버전 정책
|
||||
|
||||
- 앱 버전은 `package.json`의 `version`과 문서의 `v0.0.1` 형식을 맞춘다.
|
||||
5
docs/todo.md
Normal file
5
docs/todo.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
- PocketBase `todos` 컬렉션·API 규칙을 실제 배포 환경에 맞게 정리한다.
|
||||
- 미리 알림 스타일 마이크로 인터랙션(체크·삭제·섹션)을 다듬는다.
|
||||
- HTTPS 리버스 프록시 예시를 NAS 환경에 맞게 보강한다.
|
||||
9
docs/update.md
Normal file
9
docs/update.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## v0.0.1
|
||||
|
||||
- ~추가. Vite + Vue 3 SPA, Tailwind, PWA, PocketBase 연동 골격.
|
||||
- ~추가. Docker Compose(PocketBase + nginx 정적 웹).
|
||||
- ~수정. 프로젝트 규칙을 Vite SPA 기준으로 정리.
|
||||
- ~수정. 완료 표시는 SVG 아이콘으로 정리.
|
||||
- ~추가. Git 저장소 초기화 및 원격 `origin`(git.sori.studio) 연결.
|
||||
21
index.html
Normal file
21
index.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#f2f2f7" />
|
||||
<meta name="description" content="NAS용 Todo" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
as="style"
|
||||
crossorigin
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
|
||||
/>
|
||||
<title>Todo</title>
|
||||
</head>
|
||||
<body class="bg-surface-subtle text-ink antialiased">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
11
jsconfig.json
Normal file
11
jsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.js", "src/**/*.vue", "src/vite-env.d.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
10
nginx.conf
Normal file
10
nginx.conf
Normal file
@@ -0,0 +1,10 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
6943
package-lock.json
generated
Normal file
6943
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "todo",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"pocketbase": "^0.25.1",
|
||||
"vue": "^3.5.13",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^5.4.14",
|
||||
"vite-plugin-pwa": "^0.21.1"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
4
public/favicon.svg
Normal file
4
public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<rect width="64" height="64" rx="14" fill="#007AFF"/>
|
||||
<path stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" d="M20 33l8 8 16-20"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 245 B |
135
src/App.vue
Normal file
135
src/App.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useTodos } from '@/composables/useTodos'
|
||||
|
||||
const { items, status, errorMessage, formError, newTitle, add, toggleDone } = useTodos()
|
||||
|
||||
const isLoading = computed(() => status.value === 'loading')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="todo-app-shell mx-auto flex min-h-full max-w-lg flex-col px-4 pb-10 pt-8 sm:px-6">
|
||||
<header class="todo-app-header mb-8">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-ink-muted">내 목록</p>
|
||||
<h1 class="mt-1 text-3xl font-semibold tracking-tight text-ink">할 일</h1>
|
||||
</header>
|
||||
|
||||
<main class="todo-app-main flex flex-1 flex-col gap-6">
|
||||
<section
|
||||
class="todo-app-card rounded-3xl bg-surface p-4 shadow-card ring-1 ring-line/60 sm:p-5"
|
||||
aria-label="새 할 일"
|
||||
>
|
||||
<form class="todo-app-form flex flex-col gap-3" @submit="add">
|
||||
<div class="flex gap-3">
|
||||
<input
|
||||
v-model="newTitle"
|
||||
class="todo-app-input min-h-11 flex-1 rounded-2xl border border-line bg-surface-subtle px-4 text-base text-ink outline-none ring-accent/30 transition focus:border-accent focus:ring-2"
|
||||
type="text"
|
||||
name="title"
|
||||
autocomplete="off"
|
||||
placeholder="새로운 미리 알림"
|
||||
maxlength="500"
|
||||
/>
|
||||
<button
|
||||
class="todo-app-submit inline-flex min-h-11 flex-none items-center justify-center rounded-2xl bg-accent px-4 text-sm font-semibold text-white shadow-sm transition hover:brightness-105 active:scale-[0.98]"
|
||||
type="submit"
|
||||
>
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-if="formError"
|
||||
class="todo-app-form-error text-sm text-red-600"
|
||||
role="status"
|
||||
>
|
||||
{{ formError }}
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="todo-app-list-wrap" aria-live="polite">
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="todo-app-status rounded-2xl bg-surface/80 px-4 py-3 text-sm text-ink-muted ring-1 ring-line/50"
|
||||
>
|
||||
불러오는 중…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="errorMessage"
|
||||
class="todo-app-status rounded-2xl bg-surface px-4 py-3 text-sm text-ink ring-1 ring-line/60"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<TransitionGroup
|
||||
v-else
|
||||
name="todo-list"
|
||||
tag="ul"
|
||||
class="todo-app-list divide-y divide-line/70 overflow-hidden rounded-3xl bg-surface shadow-card ring-1 ring-line/60"
|
||||
>
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="todo-app-row group flex items-center gap-3 px-4 py-3.5 sm:px-5"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="todo-app-check flex h-6 w-6 flex-none items-center justify-center rounded-full border border-line bg-surface transition group-hover:border-accent/50"
|
||||
:class="item.done ? 'border-accent bg-accent' : ''"
|
||||
:aria-pressed="item.done"
|
||||
:aria-label="item.done ? '완료 취소' : '완료로 표시'"
|
||||
@click="toggleDone(item)"
|
||||
>
|
||||
<svg
|
||||
v-if="item.done"
|
||||
class="h-3.5 w-3.5 text-white"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M3.5 8.5 6.5 11.5 12.5 4.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
class="todo-app-title flex-1 text-base transition"
|
||||
:class="item.done ? 'text-ink-muted line-through decoration-line' : 'text-ink'"
|
||||
>
|
||||
{{ item.title }}
|
||||
</span>
|
||||
</li>
|
||||
</TransitionGroup>
|
||||
|
||||
<p
|
||||
v-if="!isLoading && !errorMessage && items.length === 0"
|
||||
class="todo-app-empty mt-4 text-center text-sm text-ink-muted"
|
||||
>
|
||||
표시할 항목이 없습니다. PocketBase에 `todos` 컬렉션을 만든 뒤 다시 시도하세요.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.todo-list-move,
|
||||
.todo-list-enter-active,
|
||||
.todo-list-leave-active {
|
||||
transition: opacity 0.22s ease, transform 0.22s ease;
|
||||
}
|
||||
|
||||
.todo-list-enter-from,
|
||||
.todo-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
.todo-app-list {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
85
src/composables/useTodos.js
Normal file
85
src/composables/useTodos.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getPocketBase } from '@/lib/pocketBase'
|
||||
import { parseTodoTitle } from '@/lib/todoSchema'
|
||||
|
||||
/**
|
||||
* PocketBase `todos` 컬렉션과 연동한다. 스키마는 `docs/spec.md` 참고.
|
||||
*/
|
||||
export function useTodos() {
|
||||
const items = ref([])
|
||||
const status = ref('idle')
|
||||
const errorMessage = ref('')
|
||||
const formError = ref('')
|
||||
const newTitle = ref('')
|
||||
|
||||
async function load() {
|
||||
status.value = 'loading'
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const pb = getPocketBase()
|
||||
const list = await pb.collection('todos').getFullList({
|
||||
sort: 'created'
|
||||
})
|
||||
items.value = list
|
||||
status.value = 'idle'
|
||||
} catch (err) {
|
||||
status.value = 'error'
|
||||
errorMessage.value = err?.message || '목록을 불러오지 못했습니다.'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SubmitEvent} event
|
||||
*/
|
||||
async function add(event) {
|
||||
event.preventDefault()
|
||||
formError.value = ''
|
||||
try {
|
||||
const { title } = parseTodoTitle({ title: newTitle.value })
|
||||
const pb = getPocketBase()
|
||||
const created = await pb.collection('todos').create({
|
||||
title,
|
||||
done: false
|
||||
})
|
||||
items.value = [...items.value, created]
|
||||
newTitle.value = ''
|
||||
} catch (err) {
|
||||
const zodMessage = Array.isArray(err?.issues) ? err.issues[0]?.message : ''
|
||||
if (zodMessage) {
|
||||
formError.value = zodMessage
|
||||
return
|
||||
}
|
||||
formError.value = err?.message || '추가하지 못했습니다.'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('pocketbase').RecordModel} record
|
||||
*/
|
||||
async function toggleDone(record) {
|
||||
try {
|
||||
const pb = getPocketBase()
|
||||
const updated = await pb.collection('todos').update(record.id, {
|
||||
done: !record.done
|
||||
})
|
||||
items.value = items.value.map((row) => (row.id === updated.id ? updated : row))
|
||||
} catch (err) {
|
||||
formError.value = err?.message || '상태를 바꾸지 못했습니다.'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
})
|
||||
|
||||
return {
|
||||
items,
|
||||
status,
|
||||
errorMessage,
|
||||
formError,
|
||||
newTitle,
|
||||
load,
|
||||
add,
|
||||
toggleDone
|
||||
}
|
||||
}
|
||||
13
src/lib/apiUrl.js
Normal file
13
src/lib/apiUrl.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* API·정적 자원용 베이스 URL과 경로를 결합한다. 끝 슬래시는 정규화한다.
|
||||
* @param {string | undefined} base
|
||||
* @param {string} [path]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function toApiUrl(base, path = '') {
|
||||
if (!base) return ''
|
||||
const trimmed = base.replace(/\/+$/, '')
|
||||
if (!path) return trimmed
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`
|
||||
return `${trimmed}${normalized}`
|
||||
}
|
||||
19
src/lib/pocketBase.js
Normal file
19
src/lib/pocketBase.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import PocketBase from 'pocketbase'
|
||||
import { toApiUrl } from '@/lib/apiUrl'
|
||||
|
||||
/** @type {PocketBase | null} */
|
||||
let client = null
|
||||
|
||||
/**
|
||||
* @returns {PocketBase}
|
||||
*/
|
||||
export function getPocketBase() {
|
||||
const base = import.meta.env.VITE_POCKETBASE_URL
|
||||
if (!base) {
|
||||
throw new Error('VITE_POCKETBASE_URL이 설정되지 않았습니다.')
|
||||
}
|
||||
if (!client) {
|
||||
client = new PocketBase(toApiUrl(base))
|
||||
}
|
||||
return client
|
||||
}
|
||||
13
src/lib/todoSchema.js
Normal file
13
src/lib/todoSchema.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const todoTitleSchema = z.object({
|
||||
title: z.string().trim().min(1, '제목을 입력하세요.').max(500, '제목이 너무 깁니다.')
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {unknown} input
|
||||
* @returns {{ title: string }}
|
||||
*/
|
||||
export function parseTodoTitle(input) {
|
||||
return todoTitleSchema.parse(input)
|
||||
}
|
||||
8
src/main.js
Normal file
8
src/main.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createApp } from 'vue'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
registerSW({ immediate: true })
|
||||
|
||||
createApp(App).mount('#app')
|
||||
22
src/style.css
Normal file
22
src/style.css
Normal file
@@ -0,0 +1,22 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
src/vite-env.d.ts
vendored
Normal file
2
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
27
tailwind.config.js
Normal file
27
tailwind.config.js
Normal file
@@ -0,0 +1,27 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{vue,js}'],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Pretendard Variable', 'Pretendard', 'system-ui', 'sans-serif']
|
||||
},
|
||||
colors: {
|
||||
ink: {
|
||||
DEFAULT: '#1c1c1e',
|
||||
muted: '#636366'
|
||||
},
|
||||
surface: {
|
||||
DEFAULT: '#ffffff',
|
||||
subtle: '#f2f2f7'
|
||||
},
|
||||
line: '#d1d1d6',
|
||||
accent: '#007aff'
|
||||
},
|
||||
boxShadow: {
|
||||
card: '0 1px 2px rgba(0, 0, 0, 0.06), 0 4px 12px rgba(0, 0, 0, 0.04)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
}
|
||||
40
vite.config.js
Normal file
40
vite.config.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.svg'],
|
||||
manifest: {
|
||||
name: 'Todo',
|
||||
short_name: 'Todo',
|
||||
description: 'NAS용 Todo 웹앱',
|
||||
theme_color: '#f2f2f7',
|
||||
background_color: '#f2f2f7',
|
||||
display: 'standalone',
|
||||
start_url: '/',
|
||||
lang: 'ko',
|
||||
icons: [
|
||||
{
|
||||
src: '/favicon.svg',
|
||||
sizes: 'any',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'any'
|
||||
}
|
||||
]
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,svg,woff2}']
|
||||
}
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user