app: 멀티유저 카테고리 보드 및 드래그 이동 추가

Made-with: Cursor
This commit is contained in:
2026-04-13 15:16:52 +09:00
parent 5b83789450
commit 7bb96ef19c
13 changed files with 708 additions and 88 deletions

View File

@@ -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

View File

@@ -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` 등 **주소창과 동일**하게.)
### 선택: 관리자 자동 생성 ### 선택: 관리자 자동 생성

View File

@@ -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` 주석과 문서에 정리했다.

View File

@@ -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 |

View File

@@ -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`를 항상 설정한다.
## 환경 변수 ## 환경 변수

View File

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

@@ -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",

View File

@@ -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",

View File

@@ -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>

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

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

View File

@@ -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

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