git: 원격 저장소 연결 및 초기 커밋

Made-with: Cursor
This commit is contained in:
2026-04-13 13:50:17 +09:00
commit 3286159154
38 changed files with 7830 additions and 0 deletions

15
.cursor/rules/01-base.mdc Normal file
View File

@@ -0,0 +1,15 @@
---
alwaysApply: true
---
- 모든 응답과 문서는 한국어로 작성한다.
- 답변은 짧고 명확하게 작성한다.
- 이모지 사용을 금지한다.
- 추측성 답변을 하지 않는다.
- 불확실한 내용은 반드시 명시한다.
- 기존 코드 스타일을 우선 따른다.
- 불필요한 리팩토링을 금지한다.
- 요청되지 않은 구조 변경을 금지한다.
- 파일 생성은 최소화한다.
- 기존 코드에서 해결 가능한지 먼저 검토한다.

View File

@@ -0,0 +1,14 @@
---
alwaysApply: true
---
모든 작업은 반드시 아래 순서를 따른다:
1. docs 문서를 먼저 확인한다.
2. 관련 코드 파일을 확인한다.
3. 기존 구현 방식과 패턴을 파악한다.
4. 최소 범위로 수정한다.
5. 검증을 수행한다.
6. 문서를 즉시 갱신한다.
7. 누락 여부를 확인한다.
이 순서를 생략하거나 변경하지 않는다.

15
.cursor/rules/03-docs.mdc Normal file
View File

@@ -0,0 +1,15 @@
---
alwaysApply: true
---
작업 후 반드시 문서를 갱신한다.
- update.md: 작업 내용을 항목형으로 기록 (~추가., ~수정.)
- todo.md: 현재 유효한 작업만 유지
- spec.md: API / 데이터 구조 변경 시 갱신
- history.md: 구조 변경 + 이유 기록
- map.md: 파일-기능 매핑 유지
- deploy.md: 실행 및 배포 방법 최신화
문서와 코드가 다르면:
1. 코드를 기준으로 판단
2. 문서를 수정한다

View File

@@ -0,0 +1,11 @@
---
alwaysApply: true
---
- 이 프로젝트는 Vite + Vue 3(SPA) 기반이다.
- 기존 구조, 네이밍, 패턴을 반드시 먼저 파악한다.
- 기존 composables, utils, components를 우선 재사용한다.
- 상태 관리는 기존 방식을 따른다.
- 동일 데이터를 중복 관리하지 않는다.
- Tailwind CSS를 사용한다.
- 전역 폰트는 Pretendard를 유지한다.

View File

@@ -0,0 +1,13 @@
---
alwaysApply: true
---
- API 호출은 기존 API 레이어를 사용한다.
- 정적 URL은 toApiUrl()로 처리한다.
- Composition API 패턴(ref, computed, onMounted)을 유지한다.
- UI는 "요약 + 필요 시 모달" 구조를 우선한다.
- 모달 활성화 시 body scroll lock을 적용한다.
- 리스트 변경 시 깜빡임 없이 즉시 반영한다.
- 애니메이션은 TransitionGroup을 사용한다.

View File

@@ -0,0 +1,11 @@
---
alwaysApply: true
---
- 입력 검증은 zod를 사용한다.
- 인증/권한은 PocketBase 클라이언트 래퍼 또는 라우터 가드 등으로 분리한다.
- 사용자 입력은 반드시 검증 후 저장한다.
- 환경 의존 값(URL 등)은 저장 전에 정규화한다.
- 업로드 파일명은 ASCII 안전 문자열을 사용한다.
- 관리자/사용자 업로드 경로를 분리한다.

View File

@@ -0,0 +1,12 @@
---
alwaysApply: true
---
- Tailwind CSS를 기본으로 사용한다.
- Tailwind만으로 구조를 구성하지 않는다.
- 주요 영역에는 고유 클래스명을 반드시 추가한다.
- 클래스명은 의미 기반으로 작성한다.
- 임시 이름 사용을 금지한다.
- JavaScript는 세미콜론 없이 작성한다.
- 주석은 필요한 경우에만 JSDoc 형식을 사용한다.

22
.cursor/rules/08-git.mdc Normal file
View 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 형식으로 증가시킨다.
- 푸시 전 민감 정보 포함 여부를 확인한다.

View File

@@ -0,0 +1,11 @@
---
alwaysApply: true
---
- 가능한 범위에서 기능 검증을 수행한다.
- 주요 사용자 흐름을 확인한다.
- null, undefined, 실패 응답을 점검한다.
- console.log는 디버깅용으로만 사용한다.
- 작업 완료 시 불필요한 로그는 제거한다.
- 검증을 수행하지 못한 경우 사유를 명시한다.

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
dist
.git
pb_data
.env
.env.*
*.md
.cursor

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
# PocketBase 공개 URL (끝 슬래시 없이). Docker 배포 시 브라우저에서 접근 가능한 주소로 설정
VITE_POCKETBASE_URL=http://127.0.0.1:8090

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules
dist
.DS_Store
.env
.env.*.local
pb_data
*.log

20
Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
# 할 일 및 이슈
- PocketBase `todos` 컬렉션·API 규칙을 실제 배포 환경에 맞게 정리한다.
- 미리 알림 스타일 마이크로 인터랙션(체크·삭제·섹션)을 다듬는다.
- HTTPS 리버스 프록시 예시를 NAS 환경에 맞게 보강한다.

9
docs/update.md Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View 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
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

4
public/favicon.svg Normal file
View 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
View 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>

View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />

27
tailwind.config.js Normal file
View 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
View 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))
}
}
})