app: 멀티유저 카테고리 보드 및 드래그 이동 추가
Made-with: Cursor
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
VITE_PUBLIC_APP_URL=https://todo.sori.studio
|
||||
|
||||
# PocketBase 공개 URL(끝 슬래시 없음). 브라우저가 직접 호출한다
|
||||
VITE_POCKETBASE_URL=https://api.todo.sori.studio
|
||||
VITE_POCKETBASE_URL=https://todo-pb.sori.studio
|
||||
|
||||
# 로컬에서 PocketBase 컨테이너만 띄울 때 예시(필요 시 위 두 줄 대신 사용)
|
||||
# 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과 맞출 것)
|
||||
# VITE_PUBLIC_APP_URL=http://192.168.0.50:42881
|
||||
# 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`에 따로 정리했다.
|
||||
|
||||
@@ -42,9 +42,10 @@ docker compose up -d --build
|
||||
|
||||
### PocketBase 초기 설정 요약
|
||||
|
||||
1. 관리자 UI에서 컬렉션 `todos` 생성(`title` text, `done` bool).
|
||||
2. API 규칙을 배포 방식에 맞게 설정(인증 사용 시 로그인 사용자만 생성·수정 가능 등).
|
||||
3. CORS 허용 출처에 웹 앱 출처를 추가한다. (LAN이면 `http://<IP>:42881`, 도메인이면 `https://todo.sori.studio` 등 **주소창과 동일**하게.)
|
||||
1. `users`(auth) 컬렉션으로 사용자 계정을 만든다.
|
||||
2. 컬렉션 `categories`, `todos`를 `docs/spec.md`의 스키마대로 만든다.
|
||||
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` 권한
|
||||
|
||||
호스트 바인드 `./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/style.css` | Tailwind 베이스, 모션 감소 대응 |
|
||||
| `src/lib/apiUrl.js` | `toApiUrl()` URL 정규화 |
|
||||
| `src/lib/pocketBase.js` | PocketBase 싱글톤 클라이언트 |
|
||||
| `src/lib/todoSchema.js` | 할 일 제목 Zod 스키마 |
|
||||
| `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` 반영) |
|
||||
| `tailwind.config.js` | 테마 색·폰트 |
|
||||
| `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`
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
| ------ | ------- | ----------- |
|
||||
| `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
|
||||
|
||||
- ~수정. 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",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "todo",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.8",
|
||||
"dependencies": {
|
||||
"pocketbase": "^0.25.1",
|
||||
"vue": "^3.5.13",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "todo",
|
||||
"private": true,
|
||||
"version": "0.0.8",
|
||||
"version": "0.0.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
280
src/App.vue
280
src/App.vue
@@ -1,10 +1,84 @@
|
||||
<script setup>
|
||||
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 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>
|
||||
|
||||
<template>
|
||||
@@ -16,38 +90,93 @@ const isLoading = computed(() => status.value === 'loading')
|
||||
|
||||
<main class="todo-app-main flex flex-1 flex-col gap-6">
|
||||
<section
|
||||
v-if="!isAuthed"
|
||||
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">
|
||||
<div class="flex gap-3">
|
||||
<form class="todo-auth-form flex flex-col gap-3" @submit="login">
|
||||
<div class="flex flex-col 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"
|
||||
v-model="email"
|
||||
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="email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
placeholder="이메일"
|
||||
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>
|
||||
<p
|
||||
v-if="formError"
|
||||
class="todo-app-form-error text-sm text-red-600"
|
||||
role="status"
|
||||
<button
|
||||
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"
|
||||
type="submit"
|
||||
: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>
|
||||
</form>
|
||||
</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
|
||||
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"
|
||||
@@ -55,30 +184,91 @@ const isLoading = computed(() => status.value === 'loading')
|
||||
불러오는 중…
|
||||
</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"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
{{ errorMessage || formError }}
|
||||
</div>
|
||||
|
||||
<TransitionGroup
|
||||
v-else
|
||||
name="todo-list"
|
||||
tag="div"
|
||||
class="todo-board-categories flex flex-col gap-4"
|
||||
>
|
||||
<section
|
||||
v-for="(cat, catIndex) in categories"
|
||||
:key="cat.id"
|
||||
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)"
|
||||
>
|
||||
<header class="todo-category-header flex items-center justify-between gap-2 px-4 py-3 sm:px-5">
|
||||
<div class="min-w-0">
|
||||
<h2 class="truncate text-base font-semibold text-ink">{{ cat.name }}</h2>
|
||||
<p class="text-xs text-ink-muted">드래그로 카테고리 순서 변경</p>
|
||||
</div>
|
||||
<div class="flex flex-none items-center gap-2">
|
||||
<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"
|
||||
type="button"
|
||||
@click="() => onRenameCategory(cat)"
|
||||
>
|
||||
이름
|
||||
</button>
|
||||
<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"
|
||||
type="button"
|
||||
@click="() => onDeleteCategory(cat)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</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"
|
||||
/>
|
||||
<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]"
|
||||
type="submit"
|
||||
>
|
||||
추가
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<TransitionGroup
|
||||
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"
|
||||
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 in items"
|
||||
v-for="(item, index) in (todosByCategory[cat.id] || [])"
|
||||
:key="item.id"
|
||||
class="todo-app-row group flex items-center gap-3 px-4 py-3.5 sm:px-5"
|
||||
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-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="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="toggleDone(item)"
|
||||
@click="toggleTodo(item)"
|
||||
>
|
||||
<svg
|
||||
v-if="item.done"
|
||||
@@ -96,20 +286,32 @@ const isLoading = computed(() => status.value === 'loading')
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
class="todo-app-title flex-1 text-base transition"
|
||||
|
||||
<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 }}
|
||||
</span>
|
||||
</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="!isLoading && !errorMessage && items.length === 0"
|
||||
class="todo-app-empty mt-4 text-center text-sm text-ink-muted"
|
||||
>
|
||||
표시할 항목이 없습니다. PocketBase에 `todos` 컬렉션을 만든 뒤 다시 시도하세요.
|
||||
<p v-if="(todosByCategory[cat.id] || []).length === 0" class="mt-3 text-center text-xs text-ink-muted">
|
||||
이 카테고리에 항목이 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</TransitionGroup>
|
||||
|
||||
<p v-if="categories.length === 0" class="todo-app-empty mt-4 text-center text-sm text-ink-muted">
|
||||
카테고리를 추가해 시작하세요.
|
||||
</p>
|
||||
</section>
|
||||
</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 formError = ref('')
|
||||
const newTitle = ref('')
|
||||
const showCompleted = ref(true)
|
||||
|
||||
async function load() {
|
||||
status.value = 'loading'
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const pb = getPocketBase()
|
||||
const list = await pb.collection('todos').getFullList({
|
||||
sort: 'created'
|
||||
})
|
||||
const filter = showCompleted.value ? '' : 'done=false'
|
||||
const list = await pb.collection('todos').getFullList({ sort: 'order,created', filter })
|
||||
items.value = list
|
||||
status.value = 'idle'
|
||||
} catch (err) {
|
||||
@@ -39,7 +39,8 @@ export function useTodos() {
|
||||
const pb = getPocketBase()
|
||||
const created = await pb.collection('todos').create({
|
||||
title,
|
||||
done: false
|
||||
done: false,
|
||||
completedAt: null
|
||||
})
|
||||
items.value = [...items.value, created]
|
||||
newTitle.value = ''
|
||||
@@ -59,8 +60,10 @@ export function useTodos() {
|
||||
async function toggleDone(record) {
|
||||
try {
|
||||
const pb = getPocketBase()
|
||||
const nextDone = !record.done
|
||||
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))
|
||||
} catch (err) {
|
||||
@@ -78,6 +81,7 @@ export function useTodos() {
|
||||
errorMessage,
|
||||
formError,
|
||||
newTitle,
|
||||
showCompleted,
|
||||
load,
|
||||
add,
|
||||
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