app: 멀티유저 카테고리 보드 및 드래그 이동 추가
Made-with: Cursor
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
VITE_PUBLIC_APP_URL=https://todo.sori.studio
|
VITE_PUBLIC_APP_URL=https://todo.sori.studio
|
||||||
|
|
||||||
# PocketBase 공개 URL(끝 슬래시 없음). 브라우저가 직접 호출한다
|
# PocketBase 공개 URL(끝 슬래시 없음). 브라우저가 직접 호출한다
|
||||||
VITE_POCKETBASE_URL=https://api.todo.sori.studio
|
VITE_POCKETBASE_URL=https://todo-pb.sori.studio
|
||||||
|
|
||||||
# 로컬에서 PocketBase 컨테이너만 띄울 때 예시(필요 시 위 두 줄 대신 사용)
|
# 로컬에서 PocketBase 컨테이너만 띄울 때 예시(필요 시 위 두 줄 대신 사용)
|
||||||
# VITE_PUBLIC_APP_URL=http://127.0.0.1:5173
|
# VITE_PUBLIC_APP_URL=http://127.0.0.1:5173
|
||||||
@@ -17,3 +17,4 @@ VITE_POCKETBASE_URL=https://api.todo.sori.studio
|
|||||||
# docker compose 기본 호스트 포트로 NAS LAN에서 접속할 때(포트는 docker-compose.yaml과 맞출 것)
|
# docker compose 기본 호스트 포트로 NAS LAN에서 접속할 때(포트는 docker-compose.yaml과 맞출 것)
|
||||||
# VITE_PUBLIC_APP_URL=http://192.168.0.50:42881
|
# VITE_PUBLIC_APP_URL=http://192.168.0.50:42881
|
||||||
# VITE_POCKETBASE_URL=http://192.168.0.50:42917
|
# VITE_POCKETBASE_URL=http://192.168.0.50:42917
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 현재 버전
|
## 현재 버전
|
||||||
|
|
||||||
- `v0.0.8`
|
- `v0.0.9`
|
||||||
|
|
||||||
NAS에 SSH로 올리는 **전체 순서**는 `docs/nas-deploy-guide.md`에 따로 정리했다.
|
NAS에 SSH로 올리는 **전체 순서**는 `docs/nas-deploy-guide.md`에 따로 정리했다.
|
||||||
|
|
||||||
@@ -42,9 +42,10 @@ docker compose up -d --build
|
|||||||
|
|
||||||
### PocketBase 초기 설정 요약
|
### PocketBase 초기 설정 요약
|
||||||
|
|
||||||
1. 관리자 UI에서 컬렉션 `todos` 생성(`title` text, `done` bool).
|
1. `users`(auth) 컬렉션으로 사용자 계정을 만든다.
|
||||||
2. API 규칙을 배포 방식에 맞게 설정(인증 사용 시 로그인 사용자만 생성·수정 가능 등).
|
2. 컬렉션 `categories`, `todos`를 `docs/spec.md`의 스키마대로 만든다.
|
||||||
3. CORS 허용 출처에 웹 앱 출처를 추가한다. (LAN이면 `http://<IP>:42881`, 도메인이면 `https://todo.sori.studio` 등 **주소창과 동일**하게.)
|
3. API 규칙은 “본인 데이터만 접근”을 기본으로 적용한다. (예시는 `docs/spec.md` 참고)
|
||||||
|
4. CORS 허용 출처에 웹 앱 출처를 추가한다. (LAN이면 `http://<IP>:42881`, 도메인이면 `https://todo.sori.studio` 등 **주소창과 동일**하게.)
|
||||||
|
|
||||||
### 선택: 관리자 자동 생성
|
### 선택: 관리자 자동 생성
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-04-13 · v0.0.9 — 멀티유저 + 카테고리 보드 도입
|
||||||
|
|
||||||
|
Todo를 단일 리스트에서 **로그인 사용자 기준 멀티유저** 구조로 확장하고, 카테고리 기반 보드(카테고리·항목 드래그 이동/정렬)로 전환했다. 완료 시각(`completedAt`)과 완료 항목 표시 토글을 추가해 “미리 알림” 스타일의 흐름을 유지하면서 관리 기능을 확장한다.
|
||||||
|
|
||||||
## 2026-04-13 · v0.0.8 — PocketBase `user`와 `pb_data` 권한
|
## 2026-04-13 · v0.0.8 — PocketBase `user`와 `pb_data` 권한
|
||||||
|
|
||||||
호스트 바인드 `./pb_data`에 **`user: 1000:10`으로 기동**하면, 폴더가 root 소유이거나 비어 있을 때 SQLite가 **오류 14(unable to open database file)** 로 종료되는 경우가 잦다. 기본값에서는 `user`를 두지 않고 기동한 뒤, 필요 시 `chown` 후 `user`를 켜는 절차를 `docker-compose.yaml` 주석과 문서에 정리했다.
|
호스트 바인드 `./pb_data`에 **`user: 1000:10`으로 기동**하면, 폴더가 root 소유이거나 비어 있을 때 SQLite가 **오류 14(unable to open database file)** 로 종료되는 경우가 잦다. 기본값에서는 `user`를 두지 않고 기동한 뒤, 필요 시 `chown` 후 `user`를 켜는 절차를 `docker-compose.yaml` 주석과 문서에 정리했다.
|
||||||
|
|||||||
@@ -2,17 +2,20 @@
|
|||||||
|
|
||||||
## 현재 버전
|
## 현재 버전
|
||||||
|
|
||||||
- `v0.0.8`
|
- `v0.0.9`
|
||||||
|
|
||||||
| 경로 | 역할 |
|
| 경로 | 역할 |
|
||||||
| ------------------------- | ----------------------------------------- |
|
| ------------------------- | ----------------------------------------- |
|
||||||
| `src/App.vue` | 미리 알림 스타일 UI, 목록·입력 |
|
| `src/App.vue` | 로그인 + 카테고리 보드 UI, 드래그 이동/정렬 |
|
||||||
| `src/main.js` | 앱 부트스트랩, PWA 서비스 워커 등록 |
|
| `src/main.js` | 앱 부트스트랩, PWA 서비스 워커 등록 |
|
||||||
| `src/style.css` | Tailwind 베이스, 모션 감소 대응 |
|
| `src/style.css` | Tailwind 베이스, 모션 감소 대응 |
|
||||||
| `src/lib/apiUrl.js` | `toApiUrl()` URL 정규화 |
|
| `src/lib/apiUrl.js` | `toApiUrl()` URL 정규화 |
|
||||||
| `src/lib/pocketBase.js` | PocketBase 싱글톤 클라이언트 |
|
| `src/lib/pocketBase.js` | PocketBase 싱글톤 클라이언트 |
|
||||||
| `src/lib/todoSchema.js` | 할 일 제목 Zod 스키마 |
|
| `src/lib/todoSchema.js` | 할 일 제목 Zod 스키마 |
|
||||||
| `src/composables/useTodos.js` | 목록 로드·추가·완료 토글 |
|
| `src/composables/useTodos.js` | 목록 로드·추가·완료 토글 |
|
||||||
|
| `src/composables/useAuth.js` | 로그인/로그아웃, 세션 쿠키 유지 |
|
||||||
|
| `src/composables/useTodosBoard.js` | 카테고리/할일 로드·CRUD·완료시각·드래그 이동/정렬 |
|
||||||
|
| `src/composables/useCategories.js` | (레거시) 카테고리 로드·CRUD |
|
||||||
| `vite.config.js` | Vue 플러그인, PWA 매니페스트(`VITE_PUBLIC_APP_URL` 반영) |
|
| `vite.config.js` | Vue 플러그인, PWA 매니페스트(`VITE_PUBLIC_APP_URL` 반영) |
|
||||||
| `tailwind.config.js` | 테마 색·폰트 |
|
| `tailwind.config.js` | 테마 색·폰트 |
|
||||||
| `docker-compose.yaml` | PocketBase(`pocketbase-todo`, `./pb_data`, 선택 `user`) + 웹(`todo-web`), 호스트 포트 42881·42917 |
|
| `docker-compose.yaml` | PocketBase(`pocketbase-todo`, `./pb_data`, 선택 `user`) + 웹(`todo-web`), 호스트 포트 42881·42917 |
|
||||||
|
|||||||
42
docs/spec.md
42
docs/spec.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 현재 버전
|
## 현재 버전
|
||||||
|
|
||||||
- `v0.0.8`
|
- `v0.0.9`
|
||||||
|
|
||||||
## 스택
|
## 스택
|
||||||
|
|
||||||
@@ -14,12 +14,42 @@
|
|||||||
|
|
||||||
## PocketBase 컬렉션: `todos`
|
## PocketBase 컬렉션: `todos`
|
||||||
|
|
||||||
| 필드 | 타입 | 설명 |
|
이 앱은 **로그인 사용자 기반**으로 동작한다. 권한은 “본인 데이터만 접근”을 기본으로 한다.
|
||||||
| ------ | ------- | ----------- |
|
|
||||||
| `title` | text | 할 일 제목 |
|
|
||||||
| `done` | bool | 완료 여부 |
|
|
||||||
|
|
||||||
규칙(API 규칙)은 운영 환경에 맞게 설정한다. 로컬 개발 시에는 본인 계정에 맞는 생성·수정 권한이 있어야 한다.
|
### `users` (auth collection)
|
||||||
|
|
||||||
|
- PocketBase 기본 `users` 컬렉션을 사용한다.
|
||||||
|
- 프런트는 `authWithPassword` 방식으로 로그인한다.
|
||||||
|
|
||||||
|
### `categories`
|
||||||
|
|
||||||
|
| 필드 | 타입 | 설명 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `owner` | relation -> users | 카테고리 소유자 |
|
||||||
|
| `name` | text(noempty) | 카테고리 이름 |
|
||||||
|
| `order` | number | 카테고리 정렬 순서(작을수록 위) |
|
||||||
|
|
||||||
|
### `todos`
|
||||||
|
|
||||||
|
| 필드 | 타입 | 설명 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `owner` | relation -> users | 할 일 소유자 |
|
||||||
|
| `category` | relation -> categories | 소속 카테고리 |
|
||||||
|
| `title` | text(noempty) | 할 일 제목 |
|
||||||
|
| `done` | bool | 완료 여부 |
|
||||||
|
| `completedAt` | date(nullable) | 완료 시각(완료 시 set) |
|
||||||
|
| `order` | number | 카테고리 내 정렬 순서(작을수록 위) |
|
||||||
|
|
||||||
|
### API Rules(권장)
|
||||||
|
|
||||||
|
아래는 “로그인 사용자 본인 데이터만”을 기준으로 한 예시다. (컬렉션/필드 이름은 실제 설정과 동일해야 한다.)
|
||||||
|
|
||||||
|
- **categories**: list/view/create/update/delete
|
||||||
|
- `@request.auth.id != "" && owner = @request.auth.id`
|
||||||
|
- **todos**: list/view/create/update/delete
|
||||||
|
- `@request.auth.id != "" && owner = @request.auth.id`
|
||||||
|
|
||||||
|
`create` 규칙은 레코드에 `owner`를 포함해 저장하는 것을 전제로 한다. 프런트에서 `owner`를 항상 설정한다.
|
||||||
|
|
||||||
## 환경 변수
|
## 환경 변수
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## v0.0.9
|
||||||
|
|
||||||
|
- ~추가. 로그인 기반 멀티유저(`users` auth) + 카테고리 보드 UI.
|
||||||
|
- ~추가. 완료 시각(`completedAt`), 완료 항목 표시 토글.
|
||||||
|
- ~추가. 카테고리 CRUD + 카테고리/항목 드래그 이동·정렬(기본 HTML5 DnD).
|
||||||
|
- ~수정. `docs/spec.md`, `docs/deploy.md`, `docs/map.md` 스키마·가이드 동기화.
|
||||||
|
|
||||||
## v0.0.8
|
## v0.0.8
|
||||||
|
|
||||||
- ~수정. PocketBase `unable to open database file (14)` 대비: 기본 `user` 비활성화(주석), `pb_data` 권한 안내·13절 표 추가.
|
- ~수정. PocketBase `unable to open database file (14)` 대비: 기본 `user` 비활성화(주석), `pb_data` 권한 안내·13절 표 추가.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "todo",
|
"name": "todo",
|
||||||
"version": "0.0.1",
|
"version": "0.0.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "todo",
|
"name": "todo",
|
||||||
"version": "0.0.1",
|
"version": "0.0.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pocketbase": "^0.25.1",
|
"pocketbase": "^0.25.1",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "todo",
|
"name": "todo",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.8",
|
"version": "0.0.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
336
src/App.vue
336
src/App.vue
@@ -1,10 +1,84 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useTodos } from '@/composables/useTodos'
|
import { useAuth } from '@/composables/useAuth'
|
||||||
|
import { useTodosBoard } from '@/composables/useTodosBoard'
|
||||||
|
|
||||||
const { items, status, errorMessage, formError, newTitle, add, toggleDone } = useTodos()
|
const { isAuthed, user, status: authStatus, errorMessage: authError, email, password, login, logout } =
|
||||||
|
useAuth()
|
||||||
|
|
||||||
|
const {
|
||||||
|
categories,
|
||||||
|
todosByCategory,
|
||||||
|
status,
|
||||||
|
errorMessage,
|
||||||
|
formError,
|
||||||
|
showCompleted,
|
||||||
|
load,
|
||||||
|
addCategory,
|
||||||
|
renameCategory,
|
||||||
|
deleteCategory,
|
||||||
|
moveCategory,
|
||||||
|
addTodo,
|
||||||
|
toggleTodo,
|
||||||
|
moveTodo
|
||||||
|
} = useTodosBoard()
|
||||||
|
|
||||||
const isLoading = computed(() => status.value === 'loading')
|
const isLoading = computed(() => status.value === 'loading')
|
||||||
|
const isAuthLoading = computed(() => authStatus.value === 'loading')
|
||||||
|
|
||||||
|
function formatCompletedAt(value) {
|
||||||
|
if (!value) return ''
|
||||||
|
try {
|
||||||
|
return new Date(value).toLocaleString()
|
||||||
|
} catch {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRenameCategory(cat) {
|
||||||
|
const next = window.prompt('카테고리 이름', cat.name)
|
||||||
|
if (!next || next.trim() === cat.name) return
|
||||||
|
renameCategory(cat.id, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeleteCategory(cat) {
|
||||||
|
const ok = window.confirm(`카테고리 \"${cat.name}\"을(를) 삭제할까요?`)
|
||||||
|
if (!ok) return
|
||||||
|
deleteCategory(cat.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragStartCategory(event, fromIndex) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
event.dataTransfer.setData('application/x-todo-category', JSON.stringify({ fromIndex }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDropCategory(event, toIndex) {
|
||||||
|
event.preventDefault()
|
||||||
|
const raw = event.dataTransfer.getData('application/x-todo-category')
|
||||||
|
if (!raw) return
|
||||||
|
const payload = JSON.parse(raw)
|
||||||
|
moveCategory(payload.fromIndex, toIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
function allowDrop(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragStartTodo(event, todoId, fromCategoryId, fromIndex) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
event.dataTransfer.setData(
|
||||||
|
'application/x-todo-item',
|
||||||
|
JSON.stringify({ todoId, fromCategoryId, fromIndex })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDropTodo(event, targetCategoryId, targetIndex) {
|
||||||
|
event.preventDefault()
|
||||||
|
const raw = event.dataTransfer.getData('application/x-todo-item')
|
||||||
|
if (!raw) return
|
||||||
|
const payload = JSON.parse(raw)
|
||||||
|
moveTodo(payload.todoId, targetCategoryId, targetIndex)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -16,38 +90,93 @@ const isLoading = computed(() => status.value === 'loading')
|
|||||||
|
|
||||||
<main class="todo-app-main flex flex-1 flex-col gap-6">
|
<main class="todo-app-main flex flex-1 flex-col gap-6">
|
||||||
<section
|
<section
|
||||||
|
v-if="!isAuthed"
|
||||||
class="todo-app-card rounded-3xl bg-surface p-4 shadow-card ring-1 ring-line/60 sm:p-5"
|
class="todo-app-card rounded-3xl bg-surface p-4 shadow-card ring-1 ring-line/60 sm:p-5"
|
||||||
aria-label="새 할 일"
|
aria-label="로그인"
|
||||||
>
|
>
|
||||||
<form class="todo-app-form flex flex-col gap-3" @submit="add">
|
<form class="todo-auth-form flex flex-col gap-3" @submit="login">
|
||||||
<div class="flex gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<input
|
<input
|
||||||
v-model="newTitle"
|
v-model="email"
|
||||||
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"
|
class="todo-auth-input min-h-11 w-full 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"
|
type="email"
|
||||||
name="title"
|
name="email"
|
||||||
autocomplete="off"
|
autocomplete="email"
|
||||||
placeholder="새로운 미리 알림"
|
placeholder="이메일"
|
||||||
maxlength="500"
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
class="todo-auth-input min-h-11 w-full 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="password"
|
||||||
|
name="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
placeholder="비밀번호"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<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>
|
</div>
|
||||||
<p
|
<button
|
||||||
v-if="formError"
|
class="todo-auth-submit inline-flex min-h-11 items-center justify-center rounded-2xl bg-accent px-4 text-sm font-semibold text-white shadow-sm transition hover:brightness-105 disabled:opacity-60"
|
||||||
class="todo-app-form-error text-sm text-red-600"
|
type="submit"
|
||||||
role="status"
|
:disabled="isAuthLoading"
|
||||||
>
|
>
|
||||||
{{ formError }}
|
로그인
|
||||||
|
</button>
|
||||||
|
<p v-if="authError" class="todo-auth-error text-sm text-red-600" role="status">
|
||||||
|
{{ authError }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-ink-muted">
|
||||||
|
PocketBase의 `users`(auth) 컬렉션에 계정을 만든 뒤 로그인하세요.
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="todo-app-list-wrap" aria-live="polite">
|
<section
|
||||||
|
v-else
|
||||||
|
class="todo-app-card rounded-3xl bg-surface p-4 shadow-card ring-1 ring-line/60 sm:p-5"
|
||||||
|
aria-label="계정"
|
||||||
|
>
|
||||||
|
<div class="todo-auth-summary flex items-center justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-xs font-medium text-ink-muted">로그인됨</p>
|
||||||
|
<p class="truncate text-sm font-semibold text-ink">
|
||||||
|
{{ user?.email || '사용자' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="inline-flex min-h-10 items-center justify-center rounded-2xl border border-line bg-surface-subtle px-4 text-sm font-semibold text-ink transition hover:bg-surface-subtle/70"
|
||||||
|
type="button"
|
||||||
|
@click="logout"
|
||||||
|
>
|
||||||
|
로그아웃
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="isAuthed" class="todo-board-wrap" aria-live="polite">
|
||||||
|
<div class="todo-board-toolbar mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<label class="todo-board-toggle flex items-center gap-2 text-sm text-ink-muted">
|
||||||
|
<input v-model="showCompleted" class="h-4 w-4 accent-accent" type="checkbox" @change="load" />
|
||||||
|
완료 항목 보기
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<form class="todo-category-create flex gap-2" @submit="(e) => addCategory(e, $refs.categoryName?.value)">
|
||||||
|
<input
|
||||||
|
ref="categoryName"
|
||||||
|
class="min-h-10 w-44 rounded-2xl border border-line bg-surface-subtle px-3 text-sm text-ink outline-none ring-accent/30 transition focus:border-accent focus:ring-2"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="카테고리 추가"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="inline-flex min-h-10 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>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="isLoading"
|
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"
|
class="todo-app-status rounded-2xl bg-surface/80 px-4 py-3 text-sm text-ink-muted ring-1 ring-line/50"
|
||||||
@@ -55,61 +184,134 @@ const isLoading = computed(() => status.value === 'loading')
|
|||||||
불러오는 중…
|
불러오는 중…
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="errorMessage"
|
v-else-if="errorMessage || formError"
|
||||||
class="todo-app-status rounded-2xl bg-surface px-4 py-3 text-sm text-ink ring-1 ring-line/60"
|
class="todo-app-status rounded-2xl bg-surface px-4 py-3 text-sm text-ink ring-1 ring-line/60"
|
||||||
>
|
>
|
||||||
{{ errorMessage }}
|
{{ errorMessage || formError }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TransitionGroup
|
<TransitionGroup
|
||||||
v-else
|
v-else
|
||||||
name="todo-list"
|
name="todo-list"
|
||||||
tag="ul"
|
tag="div"
|
||||||
class="todo-app-list divide-y divide-line/70 overflow-hidden rounded-3xl bg-surface shadow-card ring-1 ring-line/60"
|
class="todo-board-categories flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
<li
|
<section
|
||||||
v-for="item in items"
|
v-for="(cat, catIndex) in categories"
|
||||||
:key="item.id"
|
:key="cat.id"
|
||||||
class="todo-app-row group flex items-center gap-3 px-4 py-3.5 sm:px-5"
|
class="todo-category-card rounded-3xl bg-surface shadow-card ring-1 ring-line/60"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="(e) => onDragStartCategory(e, catIndex)"
|
||||||
|
@dragover="allowDrop"
|
||||||
|
@drop="(e) => onDropCategory(e, catIndex)"
|
||||||
>
|
>
|
||||||
<button
|
<header class="todo-category-header flex items-center justify-between gap-2 px-4 py-3 sm:px-5">
|
||||||
type="button"
|
<div class="min-w-0">
|
||||||
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"
|
<h2 class="truncate text-base font-semibold text-ink">{{ cat.name }}</h2>
|
||||||
:class="item.done ? 'border-accent bg-accent' : ''"
|
<p class="text-xs text-ink-muted">드래그로 카테고리 순서 변경</p>
|
||||||
:aria-pressed="item.done"
|
</div>
|
||||||
:aria-label="item.done ? '완료 취소' : '완료로 표시'"
|
<div class="flex flex-none items-center gap-2">
|
||||||
@click="toggleDone(item)"
|
<button
|
||||||
>
|
class="rounded-xl border border-line bg-surface-subtle px-3 py-2 text-xs font-semibold text-ink transition hover:bg-surface-subtle/70"
|
||||||
<svg
|
type="button"
|
||||||
v-if="item.done"
|
@click="() => onRenameCategory(cat)"
|
||||||
class="h-3.5 w-3.5 text-white"
|
>
|
||||||
viewBox="0 0 16 16"
|
이름
|
||||||
fill="none"
|
</button>
|
||||||
aria-hidden="true"
|
<button
|
||||||
>
|
class="rounded-xl border border-line bg-surface-subtle px-3 py-2 text-xs font-semibold text-ink transition hover:bg-surface-subtle/70"
|
||||||
<path
|
type="button"
|
||||||
d="M3.5 8.5 6.5 11.5 12.5 4.5"
|
@click="() => onDeleteCategory(cat)"
|
||||||
stroke="currentColor"
|
>
|
||||||
stroke-width="2"
|
삭제
|
||||||
stroke-linecap="round"
|
</button>
|
||||||
stroke-linejoin="round"
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="todo-category-body px-4 pb-4 sm:px-5">
|
||||||
|
<form class="todo-item-create mb-3 flex gap-2" @submit="(e) => addTodo(e, cat.id, $refs[`todoTitle_${cat.id}`]?.value)">
|
||||||
|
<input
|
||||||
|
:ref="`todoTitle_${cat.id}`"
|
||||||
|
class="min-h-10 flex-1 rounded-2xl border border-line bg-surface-subtle px-3 text-sm text-ink outline-none ring-accent/30 transition focus:border-accent focus:ring-2"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="새로운 미리 알림"
|
||||||
|
maxlength="500"
|
||||||
/>
|
/>
|
||||||
</svg>
|
<button
|
||||||
</button>
|
class="inline-flex min-h-10 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]"
|
||||||
<span
|
type="submit"
|
||||||
class="todo-app-title flex-1 text-base transition"
|
>
|
||||||
:class="item.done ? 'text-ink-muted line-through decoration-line' : 'text-ink'"
|
추가
|
||||||
>
|
</button>
|
||||||
{{ item.title }}
|
</form>
|
||||||
</span>
|
|
||||||
</li>
|
<TransitionGroup
|
||||||
|
name="todo-list"
|
||||||
|
tag="ul"
|
||||||
|
class="todo-category-list divide-y divide-line/70 overflow-hidden rounded-3xl bg-surface ring-1 ring-line/60"
|
||||||
|
@dragover="allowDrop"
|
||||||
|
@drop="(e) => onDropTodo(e, cat.id, (todosByCategory[cat.id] || []).length)"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="(item, index) in (todosByCategory[cat.id] || [])"
|
||||||
|
:key="item.id"
|
||||||
|
class="todo-item-row group flex items-start gap-3 px-4 py-3.5"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="(e) => onDragStartTodo(e, item.id, cat.id, index)"
|
||||||
|
@dragover="allowDrop"
|
||||||
|
@drop="(e) => onDropTodo(e, cat.id, index)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="todo-item-check mt-0.5 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="toggleTodo(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>
|
||||||
|
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p
|
||||||
|
class="todo-item-title text-sm transition"
|
||||||
|
:class="item.done ? 'text-ink-muted line-through decoration-line' : 'text-ink'"
|
||||||
|
>
|
||||||
|
{{ item.title }}
|
||||||
|
</p>
|
||||||
|
<p v-if="item.completedAt" class="mt-0.5 text-xs text-ink-muted">
|
||||||
|
완료: {{ formatCompletedAt(item.completedAt) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="todo-item-drag text-xs font-semibold text-ink-muted">↕︎</span>
|
||||||
|
</li>
|
||||||
|
</TransitionGroup>
|
||||||
|
|
||||||
|
<p v-if="(todosByCategory[cat.id] || []).length === 0" class="mt-3 text-center text-xs text-ink-muted">
|
||||||
|
이 카테고리에 항목이 없습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
|
|
||||||
<p
|
<p v-if="categories.length === 0" class="todo-app-empty mt-4 text-center text-sm text-ink-muted">
|
||||||
v-if="!isLoading && !errorMessage && items.length === 0"
|
카테고리를 추가해 시작하세요.
|
||||||
class="todo-app-empty mt-4 text-center text-sm text-ink-muted"
|
|
||||||
>
|
|
||||||
표시할 항목이 없습니다. PocketBase에 `todos` 컬렉션을 만든 뒤 다시 시도하세요.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
56
src/composables/useAuth.js
Normal file
56
src/composables/useAuth.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { getPocketBase } from '@/lib/pocketBase'
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const status = ref('idle')
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
|
||||||
|
const pb = computed(() => getPocketBase())
|
||||||
|
const isAuthed = computed(() => pb.value.authStore.isValid)
|
||||||
|
const user = computed(() => pb.value.authStore.model)
|
||||||
|
|
||||||
|
function clearError() {
|
||||||
|
errorMessage.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(event) {
|
||||||
|
event?.preventDefault?.()
|
||||||
|
status.value = 'loading'
|
||||||
|
errorMessage.value = ''
|
||||||
|
try {
|
||||||
|
await pb.value.collection('users').authWithPassword(email.value, password.value)
|
||||||
|
status.value = 'idle'
|
||||||
|
password.value = ''
|
||||||
|
} catch (err) {
|
||||||
|
status.value = 'error'
|
||||||
|
errorMessage.value = err?.message || '로그인에 실패했습니다.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
pb.value.authStore.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
pb.value.authStore.loadFromCookie(document.cookie)
|
||||||
|
pb.value.authStore.onChange(() => {
|
||||||
|
document.cookie = pb.value.authStore.exportToCookie({ httpOnly: false })
|
||||||
|
}, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
errorMessage,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
isAuthed,
|
||||||
|
user,
|
||||||
|
clearError,
|
||||||
|
login,
|
||||||
|
logout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
87
src/composables/useCategories.js
Normal file
87
src/composables/useCategories.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { getPocketBase } from '@/lib/pocketBase'
|
||||||
|
|
||||||
|
export function useCategories() {
|
||||||
|
const items = ref([])
|
||||||
|
const status = ref('idle')
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const newName = ref('')
|
||||||
|
|
||||||
|
const pb = computed(() => getPocketBase())
|
||||||
|
const userId = computed(() => pb.value.authStore.model?.id || '')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
status.value = 'loading'
|
||||||
|
errorMessage.value = ''
|
||||||
|
try {
|
||||||
|
const list = await pb.value.collection('categories').getFullList({
|
||||||
|
sort: 'order,created'
|
||||||
|
})
|
||||||
|
items.value = list
|
||||||
|
status.value = 'idle'
|
||||||
|
} catch (err) {
|
||||||
|
status.value = 'error'
|
||||||
|
errorMessage.value = err?.message || '카테고리를 불러오지 못했습니다.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function add(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
errorMessage.value = ''
|
||||||
|
const name = String(newName.value || '').trim()
|
||||||
|
if (!name) {
|
||||||
|
errorMessage.value = '카테고리 이름을 입력하세요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const nextOrder = items.value.length ? Math.max(...items.value.map((c) => c.order ?? 0)) + 1 : 1
|
||||||
|
const created = await pb.value.collection('categories').create({
|
||||||
|
owner: userId.value,
|
||||||
|
name,
|
||||||
|
order: nextOrder
|
||||||
|
})
|
||||||
|
items.value = [...items.value, created]
|
||||||
|
newName.value = ''
|
||||||
|
} catch (err) {
|
||||||
|
errorMessage.value = err?.message || '카테고리를 추가하지 못했습니다.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rename(categoryId, name) {
|
||||||
|
const trimmed = String(name || '').trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
try {
|
||||||
|
const updated = await pb.value.collection('categories').update(categoryId, {
|
||||||
|
name: trimmed
|
||||||
|
})
|
||||||
|
items.value = items.value.map((row) => (row.id === updated.id ? updated : row))
|
||||||
|
} catch (err) {
|
||||||
|
errorMessage.value = err?.message || '카테고리 이름을 바꾸지 못했습니다.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(categoryId) {
|
||||||
|
try {
|
||||||
|
await pb.value.collection('categories').delete(categoryId)
|
||||||
|
items.value = items.value.filter((row) => row.id !== categoryId)
|
||||||
|
} catch (err) {
|
||||||
|
errorMessage.value = err?.message || '카테고리를 삭제하지 못했습니다.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
load()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
status,
|
||||||
|
errorMessage,
|
||||||
|
newName,
|
||||||
|
load,
|
||||||
|
add,
|
||||||
|
rename,
|
||||||
|
remove
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -11,15 +11,15 @@ export function useTodos() {
|
|||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const formError = ref('')
|
const formError = ref('')
|
||||||
const newTitle = ref('')
|
const newTitle = ref('')
|
||||||
|
const showCompleted = ref(true)
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
status.value = 'loading'
|
status.value = 'loading'
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
try {
|
try {
|
||||||
const pb = getPocketBase()
|
const pb = getPocketBase()
|
||||||
const list = await pb.collection('todos').getFullList({
|
const filter = showCompleted.value ? '' : 'done=false'
|
||||||
sort: 'created'
|
const list = await pb.collection('todos').getFullList({ sort: 'order,created', filter })
|
||||||
})
|
|
||||||
items.value = list
|
items.value = list
|
||||||
status.value = 'idle'
|
status.value = 'idle'
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -39,7 +39,8 @@ export function useTodos() {
|
|||||||
const pb = getPocketBase()
|
const pb = getPocketBase()
|
||||||
const created = await pb.collection('todos').create({
|
const created = await pb.collection('todos').create({
|
||||||
title,
|
title,
|
||||||
done: false
|
done: false,
|
||||||
|
completedAt: null
|
||||||
})
|
})
|
||||||
items.value = [...items.value, created]
|
items.value = [...items.value, created]
|
||||||
newTitle.value = ''
|
newTitle.value = ''
|
||||||
@@ -59,8 +60,10 @@ export function useTodos() {
|
|||||||
async function toggleDone(record) {
|
async function toggleDone(record) {
|
||||||
try {
|
try {
|
||||||
const pb = getPocketBase()
|
const pb = getPocketBase()
|
||||||
|
const nextDone = !record.done
|
||||||
const updated = await pb.collection('todos').update(record.id, {
|
const updated = await pb.collection('todos').update(record.id, {
|
||||||
done: !record.done
|
done: nextDone,
|
||||||
|
completedAt: nextDone ? new Date().toISOString() : null
|
||||||
})
|
})
|
||||||
items.value = items.value.map((row) => (row.id === updated.id ? updated : row))
|
items.value = items.value.map((row) => (row.id === updated.id ? updated : row))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -78,6 +81,7 @@ export function useTodos() {
|
|||||||
errorMessage,
|
errorMessage,
|
||||||
formError,
|
formError,
|
||||||
newTitle,
|
newTitle,
|
||||||
|
showCompleted,
|
||||||
load,
|
load,
|
||||||
add,
|
add,
|
||||||
toggleDone
|
toggleDone
|
||||||
|
|||||||
225
src/composables/useTodosBoard.js
Normal file
225
src/composables/useTodosBoard.js
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { getPocketBase } from '@/lib/pocketBase'
|
||||||
|
import { parseTodoTitle } from '@/lib/todoSchema'
|
||||||
|
|
||||||
|
function clampNumber(value, min, max) {
|
||||||
|
return Math.max(min, Math.min(max, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 멀티유저 + 카테고리 기반 Todo 보드
|
||||||
|
* - categories.order: 카테고리 순서
|
||||||
|
* - todos.order: 카테고리 내 아이템 순서
|
||||||
|
*/
|
||||||
|
export function useTodosBoard() {
|
||||||
|
const status = ref('idle')
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const formError = ref('')
|
||||||
|
|
||||||
|
const showCompleted = ref(true)
|
||||||
|
|
||||||
|
const categories = ref([])
|
||||||
|
const todos = ref([])
|
||||||
|
|
||||||
|
const pb = computed(() => getPocketBase())
|
||||||
|
const userId = computed(() => pb.value.authStore.model?.id || '')
|
||||||
|
|
||||||
|
const todosByCategory = computed(() => {
|
||||||
|
/** @type {Record<string, any[]>} */
|
||||||
|
const map = {}
|
||||||
|
for (const cat of categories.value) {
|
||||||
|
map[cat.id] = []
|
||||||
|
}
|
||||||
|
for (const row of todos.value) {
|
||||||
|
const catId = row.category
|
||||||
|
if (!catId) continue
|
||||||
|
if (!map[catId]) map[catId] = []
|
||||||
|
map[catId].push(row)
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(map)) {
|
||||||
|
map[key] = map[key].slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
status.value = 'loading'
|
||||||
|
errorMessage.value = ''
|
||||||
|
try {
|
||||||
|
const [cats, list] = await Promise.all([
|
||||||
|
pb.value.collection('categories').getFullList({ sort: 'order,created' }),
|
||||||
|
pb.value
|
||||||
|
.collection('todos')
|
||||||
|
.getFullList({ sort: 'category,order,created', filter: showCompleted.value ? '' : 'done=false' })
|
||||||
|
])
|
||||||
|
categories.value = cats
|
||||||
|
todos.value = list
|
||||||
|
status.value = 'idle'
|
||||||
|
} catch (err) {
|
||||||
|
status.value = 'error'
|
||||||
|
errorMessage.value = err?.message || '데이터를 불러오지 못했습니다.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addCategory(event, nameValue) {
|
||||||
|
event?.preventDefault?.()
|
||||||
|
const name = String(nameValue || '').trim()
|
||||||
|
if (!name) {
|
||||||
|
formError.value = '카테고리 이름을 입력하세요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const nextOrder = categories.value.length
|
||||||
|
? Math.max(...categories.value.map((c) => c.order ?? 0)) + 1
|
||||||
|
: 1
|
||||||
|
const created = await pb.value.collection('categories').create({
|
||||||
|
owner: userId.value,
|
||||||
|
name,
|
||||||
|
order: nextOrder
|
||||||
|
})
|
||||||
|
categories.value = [...categories.value, created].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||||
|
} catch (err) {
|
||||||
|
formError.value = err?.message || '카테고리를 추가하지 못했습니다.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameCategory(categoryId, name) {
|
||||||
|
const trimmed = String(name || '').trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
try {
|
||||||
|
const updated = await pb.value.collection('categories').update(categoryId, { name: trimmed })
|
||||||
|
categories.value = categories.value.map((row) => (row.id === updated.id ? updated : row))
|
||||||
|
} catch (err) {
|
||||||
|
formError.value = err?.message || '카테고리 이름을 바꾸지 못했습니다.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCategory(categoryId) {
|
||||||
|
try {
|
||||||
|
await pb.value.collection('categories').delete(categoryId)
|
||||||
|
categories.value = categories.value.filter((row) => row.id !== categoryId)
|
||||||
|
todos.value = todos.value.filter((row) => row.category !== categoryId)
|
||||||
|
} catch (err) {
|
||||||
|
formError.value = err?.message || '카테고리를 삭제하지 못했습니다.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveCategory(fromIndex, toIndex) {
|
||||||
|
const from = clampNumber(fromIndex, 0, categories.value.length - 1)
|
||||||
|
const to = clampNumber(toIndex, 0, categories.value.length - 1)
|
||||||
|
if (from === to) return
|
||||||
|
|
||||||
|
const next = categories.value.slice()
|
||||||
|
const [picked] = next.splice(from, 1)
|
||||||
|
next.splice(to, 0, picked)
|
||||||
|
|
||||||
|
categories.value = next.map((cat, index) => ({ ...cat, order: index + 1 }))
|
||||||
|
try {
|
||||||
|
for (const cat of categories.value) {
|
||||||
|
await pb.value.collection('categories').update(cat.id, { order: cat.order })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
formError.value = err?.message || '카테고리 순서를 저장하지 못했습니다.'
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTodo(event, categoryId, titleValue) {
|
||||||
|
event?.preventDefault?.()
|
||||||
|
formError.value = ''
|
||||||
|
try {
|
||||||
|
const { title } = parseTodoTitle({ title: titleValue })
|
||||||
|
const current = todosByCategory.value[categoryId] || []
|
||||||
|
const nextOrder = current.length ? Math.max(...current.map((t) => t.order ?? 0)) + 1 : 1
|
||||||
|
const created = await pb.value.collection('todos').create({
|
||||||
|
owner: userId.value,
|
||||||
|
category: categoryId,
|
||||||
|
title,
|
||||||
|
done: false,
|
||||||
|
completedAt: null,
|
||||||
|
order: nextOrder
|
||||||
|
})
|
||||||
|
todos.value = [...todos.value, created]
|
||||||
|
} catch (err) {
|
||||||
|
const zodMessage = Array.isArray(err?.issues) ? err.issues[0]?.message : ''
|
||||||
|
if (zodMessage) {
|
||||||
|
formError.value = zodMessage
|
||||||
|
return
|
||||||
|
}
|
||||||
|
formError.value = err?.message || '할 일을 추가하지 못했습니다.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleTodo(todoRecord) {
|
||||||
|
try {
|
||||||
|
const nextDone = !todoRecord.done
|
||||||
|
const updated = await pb.value.collection('todos').update(todoRecord.id, {
|
||||||
|
done: nextDone,
|
||||||
|
completedAt: nextDone ? new Date().toISOString() : null
|
||||||
|
})
|
||||||
|
todos.value = todos.value.map((row) => (row.id === updated.id ? updated : row))
|
||||||
|
} catch (err) {
|
||||||
|
formError.value = err?.message || '상태를 바꾸지 못했습니다.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveTodo(todoId, targetCategoryId, targetIndex) {
|
||||||
|
const current = todos.value.find((t) => t.id === todoId)
|
||||||
|
if (!current) return
|
||||||
|
|
||||||
|
const fromCategoryId = current.category
|
||||||
|
|
||||||
|
const fromList = (todosByCategory.value[fromCategoryId] || []).filter((t) => t.id !== todoId)
|
||||||
|
const toListBase = (todosByCategory.value[targetCategoryId] || []).filter((t) => t.id !== todoId)
|
||||||
|
const toIndex = clampNumber(targetIndex, 0, toListBase.length)
|
||||||
|
|
||||||
|
toListBase.splice(toIndex, 0, { ...current, category: targetCategoryId })
|
||||||
|
|
||||||
|
const nextTodos = todos.value
|
||||||
|
.map((t) => {
|
||||||
|
if (t.id === todoId) return { ...t, category: targetCategoryId }
|
||||||
|
return t
|
||||||
|
})
|
||||||
|
.filter((t) => t.category !== fromCategoryId && t.category !== targetCategoryId)
|
||||||
|
|
||||||
|
const renumberedFrom = fromList.map((t, idx) => ({ ...t, order: idx + 1 }))
|
||||||
|
const renumberedTo = toListBase.map((t, idx) => ({ ...t, order: idx + 1 }))
|
||||||
|
|
||||||
|
todos.value = [...nextTodos, ...renumberedFrom, ...renumberedTo]
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const row of renumberedFrom) {
|
||||||
|
await pb.value.collection('todos').update(row.id, { order: row.order })
|
||||||
|
}
|
||||||
|
for (const row of renumberedTo) {
|
||||||
|
const payload = row.id === todoId ? { category: targetCategoryId, order: row.order } : { order: row.order }
|
||||||
|
await pb.value.collection('todos').update(row.id, payload)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
formError.value = err?.message || '이동을 저장하지 못했습니다.'
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
load()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
errorMessage,
|
||||||
|
formError,
|
||||||
|
showCompleted,
|
||||||
|
categories,
|
||||||
|
todosByCategory,
|
||||||
|
load,
|
||||||
|
addCategory,
|
||||||
|
renameCategory,
|
||||||
|
deleteCategory,
|
||||||
|
moveCategory,
|
||||||
|
addTodo,
|
||||||
|
toggleTodo,
|
||||||
|
moveTodo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user