create-arete-workspace 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -0
- package/bin/arete.js +156 -0
- package/bin/create.js +111 -0
- package/lib/install-openclaw.js +50 -0
- package/lib/scaffold.js +213 -0
- package/lib/setup-wizard.js +88 -0
- package/lib/updater.js +130 -0
- package/package.json +34 -0
- package/packages/gatsaeng-os/README.md +36 -0
- package/packages/gatsaeng-os/components.json +23 -0
- package/packages/gatsaeng-os/eslint.config.mjs +18 -0
- package/packages/gatsaeng-os/next.config.ts +7 -0
- package/packages/gatsaeng-os/package.json +59 -0
- package/packages/gatsaeng-os/postcss.config.mjs +7 -0
- package/packages/gatsaeng-os/public/file.svg +1 -0
- package/packages/gatsaeng-os/public/globe.svg +1 -0
- package/packages/gatsaeng-os/public/next.svg +1 -0
- package/packages/gatsaeng-os/public/vercel.svg +1 -0
- package/packages/gatsaeng-os/public/window.svg +1 -0
- package/packages/gatsaeng-os/python/api_server.py +248 -0
- package/packages/gatsaeng-os/python/briefing.py +145 -0
- package/packages/gatsaeng-os/python/config.py +55 -0
- package/packages/gatsaeng-os/python/goal_context_agent.py +193 -0
- package/packages/gatsaeng-os/python/gyeokguk.py +171 -0
- package/packages/gatsaeng-os/python/proactive.py +158 -0
- package/packages/gatsaeng-os/python/requirements.txt +11 -0
- package/packages/gatsaeng-os/python/run.py +28 -0
- package/packages/gatsaeng-os/python/scoring.py +44 -0
- package/packages/gatsaeng-os/python/streak.py +70 -0
- package/packages/gatsaeng-os/python/telegram_bot.py +331 -0
- package/packages/gatsaeng-os/python/timing_engine.py +117 -0
- package/packages/gatsaeng-os/python/vault_io.py +423 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/areas/[id]/page.tsx +215 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/areas/page.tsx +161 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/books/[id]/page.tsx +215 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/books/page.tsx +268 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/calendar/page.tsx +379 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/error.tsx +30 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/focus/page.tsx +293 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/goals/[id]/page.tsx +426 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/goals/page.tsx +178 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/layout.tsx +29 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/notes/[id]/page.tsx +147 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/notes/page.tsx +254 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/page.tsx +26 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/projects/[id]/page.tsx +86 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/projects/page.tsx +215 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/review/page.tsx +475 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/routines/page.tsx +436 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/tasks/[id]/page.tsx +210 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/tasks/page.tsx +307 -0
- package/packages/gatsaeng-os/src/app/(dashboard)/voice/page.tsx +212 -0
- package/packages/gatsaeng-os/src/app/api/areas/[id]/route.ts +26 -0
- package/packages/gatsaeng-os/src/app/api/areas/route.ts +22 -0
- package/packages/gatsaeng-os/src/app/api/auth/login/route.ts +52 -0
- package/packages/gatsaeng-os/src/app/api/auth/logout/route.ts +8 -0
- package/packages/gatsaeng-os/src/app/api/books/[id]/route.ts +27 -0
- package/packages/gatsaeng-os/src/app/api/books/route.ts +20 -0
- package/packages/gatsaeng-os/src/app/api/calendar/[id]/route.ts +24 -0
- package/packages/gatsaeng-os/src/app/api/calendar/import/route.ts +52 -0
- package/packages/gatsaeng-os/src/app/api/calendar/route.ts +37 -0
- package/packages/gatsaeng-os/src/app/api/daily/route.ts +51 -0
- package/packages/gatsaeng-os/src/app/api/goals/[id]/route.ts +34 -0
- package/packages/gatsaeng-os/src/app/api/goals/route.ts +30 -0
- package/packages/gatsaeng-os/src/app/api/logs/energy/route.ts +40 -0
- package/packages/gatsaeng-os/src/app/api/logs/focus/route.ts +22 -0
- package/packages/gatsaeng-os/src/app/api/logs/routine/route.ts +54 -0
- package/packages/gatsaeng-os/src/app/api/milestones/[id]/route.ts +26 -0
- package/packages/gatsaeng-os/src/app/api/milestones/route.ts +47 -0
- package/packages/gatsaeng-os/src/app/api/notes/[id]/route.ts +29 -0
- package/packages/gatsaeng-os/src/app/api/notes/route.ts +37 -0
- package/packages/gatsaeng-os/src/app/api/profile/route.ts +17 -0
- package/packages/gatsaeng-os/src/app/api/projects/[id]/route.ts +27 -0
- package/packages/gatsaeng-os/src/app/api/projects/route.ts +25 -0
- package/packages/gatsaeng-os/src/app/api/reviews/[id]/route.ts +26 -0
- package/packages/gatsaeng-os/src/app/api/reviews/route.ts +29 -0
- package/packages/gatsaeng-os/src/app/api/routines/[id]/route.ts +26 -0
- package/packages/gatsaeng-os/src/app/api/routines/route.ts +28 -0
- package/packages/gatsaeng-os/src/app/api/tasks/[id]/route.ts +28 -0
- package/packages/gatsaeng-os/src/app/api/tasks/route.ts +66 -0
- package/packages/gatsaeng-os/src/app/api/timing/current/route.ts +63 -0
- package/packages/gatsaeng-os/src/app/api/voice/chat/route.ts +50 -0
- package/packages/gatsaeng-os/src/app/api/voice/transcribe/route.ts +25 -0
- package/packages/gatsaeng-os/src/app/api/voice/tts/route.ts +36 -0
- package/packages/gatsaeng-os/src/app/error.tsx +30 -0
- package/packages/gatsaeng-os/src/app/favicon.ico +0 -0
- package/packages/gatsaeng-os/src/app/globals.css +208 -0
- package/packages/gatsaeng-os/src/app/layout.tsx +33 -0
- package/packages/gatsaeng-os/src/app/login/page.tsx +87 -0
- package/packages/gatsaeng-os/src/app/providers.tsx +27 -0
- package/packages/gatsaeng-os/src/components/ErrorBoundary.tsx +46 -0
- package/packages/gatsaeng-os/src/components/dashboard/DashboardGrid.tsx +86 -0
- package/packages/gatsaeng-os/src/components/dashboard/DdayWidget.tsx +88 -0
- package/packages/gatsaeng-os/src/components/dashboard/EnergyTracker.tsx +87 -0
- package/packages/gatsaeng-os/src/components/dashboard/FocusTimer.tsx +139 -0
- package/packages/gatsaeng-os/src/components/dashboard/GatsaengScore.tsx +30 -0
- package/packages/gatsaeng-os/src/components/dashboard/GoalRings.tsx +107 -0
- package/packages/gatsaeng-os/src/components/dashboard/ProactiveBar.tsx +98 -0
- package/packages/gatsaeng-os/src/components/dashboard/RoutineChecklist.tsx +81 -0
- package/packages/gatsaeng-os/src/components/dashboard/TimingWidget.tsx +86 -0
- package/packages/gatsaeng-os/src/components/dashboard/WidgetCustomizer.tsx +95 -0
- package/packages/gatsaeng-os/src/components/dashboard/WidgetWrapper.tsx +33 -0
- package/packages/gatsaeng-os/src/components/dashboard/ZeigarnikPanel.tsx +43 -0
- package/packages/gatsaeng-os/src/components/editor/EditorToolbar.tsx +186 -0
- package/packages/gatsaeng-os/src/components/editor/TiptapEditor.tsx +114 -0
- package/packages/gatsaeng-os/src/components/layout/Header.tsx +47 -0
- package/packages/gatsaeng-os/src/components/layout/MobileBottomNav.tsx +122 -0
- package/packages/gatsaeng-os/src/components/layout/MobileSidebar.tsx +29 -0
- package/packages/gatsaeng-os/src/components/layout/Sidebar.tsx +142 -0
- package/packages/gatsaeng-os/src/components/onboarding/OnboardingFlow.tsx +229 -0
- package/packages/gatsaeng-os/src/components/onboarding/OnboardingGate.tsx +78 -0
- package/packages/gatsaeng-os/src/components/projects/CalendarView.tsx +152 -0
- package/packages/gatsaeng-os/src/components/projects/KanbanView.tsx +180 -0
- package/packages/gatsaeng-os/src/components/projects/ListView.tsx +82 -0
- package/packages/gatsaeng-os/src/components/projects/TableView.tsx +206 -0
- package/packages/gatsaeng-os/src/components/projects/TaskCard.tsx +154 -0
- package/packages/gatsaeng-os/src/components/projects/TaskForm.tsx +128 -0
- package/packages/gatsaeng-os/src/components/projects/ViewSwitcher.tsx +40 -0
- package/packages/gatsaeng-os/src/components/search/GlobalSearch.tsx +179 -0
- package/packages/gatsaeng-os/src/components/shared/InlineEdit.tsx +77 -0
- package/packages/gatsaeng-os/src/components/shared/PinButton.tsx +42 -0
- package/packages/gatsaeng-os/src/components/tasks/DDayBadge.tsx +34 -0
- package/packages/gatsaeng-os/src/components/ui/badge.tsx +48 -0
- package/packages/gatsaeng-os/src/components/ui/button.tsx +64 -0
- package/packages/gatsaeng-os/src/components/ui/card.tsx +92 -0
- package/packages/gatsaeng-os/src/components/ui/checkbox.tsx +32 -0
- package/packages/gatsaeng-os/src/components/ui/command.tsx +184 -0
- package/packages/gatsaeng-os/src/components/ui/dialog.tsx +158 -0
- package/packages/gatsaeng-os/src/components/ui/input.tsx +21 -0
- package/packages/gatsaeng-os/src/components/ui/label.tsx +24 -0
- package/packages/gatsaeng-os/src/components/ui/popover.tsx +89 -0
- package/packages/gatsaeng-os/src/components/ui/progress.tsx +31 -0
- package/packages/gatsaeng-os/src/components/ui/select.tsx +190 -0
- package/packages/gatsaeng-os/src/components/ui/sheet.tsx +143 -0
- package/packages/gatsaeng-os/src/components/ui/tabs.tsx +91 -0
- package/packages/gatsaeng-os/src/components/ui/toggle-group.tsx +83 -0
- package/packages/gatsaeng-os/src/components/ui/toggle.tsx +47 -0
- package/packages/gatsaeng-os/src/components/ui/tooltip.tsx +57 -0
- package/packages/gatsaeng-os/src/hooks/useAreas.ts +53 -0
- package/packages/gatsaeng-os/src/hooks/useBooks.ts +62 -0
- package/packages/gatsaeng-os/src/hooks/useCalendar.ts +59 -0
- package/packages/gatsaeng-os/src/hooks/useDaily.ts +15 -0
- package/packages/gatsaeng-os/src/hooks/useGlobalTasks.ts +45 -0
- package/packages/gatsaeng-os/src/hooks/useGoals.ts +53 -0
- package/packages/gatsaeng-os/src/hooks/useMilestones.ts +75 -0
- package/packages/gatsaeng-os/src/hooks/useNotes.ts +65 -0
- package/packages/gatsaeng-os/src/hooks/useProjects.ts +102 -0
- package/packages/gatsaeng-os/src/hooks/useRoutines.ts +76 -0
- package/packages/gatsaeng-os/src/hooks/useTiming.ts +27 -0
- package/packages/gatsaeng-os/src/lib/apiFetch.ts +14 -0
- package/packages/gatsaeng-os/src/lib/auth.ts +32 -0
- package/packages/gatsaeng-os/src/lib/date.ts +7 -0
- package/packages/gatsaeng-os/src/lib/editor/markdown.ts +35 -0
- package/packages/gatsaeng-os/src/lib/llm-governor.ts +167 -0
- package/packages/gatsaeng-os/src/lib/neuroscience/energyCycle.ts +35 -0
- package/packages/gatsaeng-os/src/lib/neuroscience/habitStack.ts +22 -0
- package/packages/gatsaeng-os/src/lib/neuroscience/scoring.ts +32 -0
- package/packages/gatsaeng-os/src/lib/routes.ts +15 -0
- package/packages/gatsaeng-os/src/lib/utils.ts +6 -0
- package/packages/gatsaeng-os/src/lib/vault/config.ts +29 -0
- package/packages/gatsaeng-os/src/lib/vault/frontmatter.ts +84 -0
- package/packages/gatsaeng-os/src/lib/vault/index.ts +180 -0
- package/packages/gatsaeng-os/src/lib/vault/schemas.ts +274 -0
- package/packages/gatsaeng-os/src/middleware.ts +34 -0
- package/packages/gatsaeng-os/src/stores/dashboardStore.ts +26 -0
- package/packages/gatsaeng-os/src/stores/favoritesStore.ts +47 -0
- package/packages/gatsaeng-os/src/stores/timerStore.ts +65 -0
- package/packages/gatsaeng-os/src/types/index.ts +320 -0
- package/packages/gatsaeng-os/tsconfig.json +34 -0
- package/templates/scripts/forge_qa.sh.tmpl +237 -0
- package/templates/scripts/forge_ship.sh.tmpl +183 -0
- package/templates/scripts/session_indexer.py.tmpl +420 -0
- package/templates/scripts/tracer.py.tmpl +266 -0
- package/templates/workspace/AGENTS.md.tmpl +190 -0
- package/templates/workspace/BOOTSTRAP.md.tmpl +27 -0
- package/templates/workspace/HEARTBEAT.md.tmpl +23 -0
- package/templates/workspace/MEMORY.md.tmpl +35 -0
- package/templates/workspace/SOUL.md.tmpl +258 -0
- package/templates/workspace/TOOLS.md.tmpl +28 -0
- package/templates/workspace/USER.md.tmpl +43 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useMemo } from 'react'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
import { useGlobalTasks, useQuickCreateTask, useToggleTaskDone } from '@/hooks/useGlobalTasks'
|
|
6
|
+
import { useProjects } from '@/hooks/useProjects'
|
|
7
|
+
import { DDayBadge } from '@/components/tasks/DDayBadge'
|
|
8
|
+
import { Button } from '@/components/ui/button'
|
|
9
|
+
import { Card, CardContent } from '@/components/ui/card'
|
|
10
|
+
import { Input } from '@/components/ui/input'
|
|
11
|
+
import { Badge } from '@/components/ui/badge'
|
|
12
|
+
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
13
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
14
|
+
import {
|
|
15
|
+
Inbox,
|
|
16
|
+
CalendarCheck,
|
|
17
|
+
CalendarClock,
|
|
18
|
+
ListTodo,
|
|
19
|
+
CheckCircle2,
|
|
20
|
+
Circle,
|
|
21
|
+
Plus,
|
|
22
|
+
ArrowUpDown,
|
|
23
|
+
} from 'lucide-react'
|
|
24
|
+
import { cn } from '@/lib/utils'
|
|
25
|
+
import type { Task, TaskPriority } from '@/types'
|
|
26
|
+
|
|
27
|
+
const VIEW_TABS = [
|
|
28
|
+
{ value: 'inbox', label: 'Inbox', icon: Inbox },
|
|
29
|
+
{ value: 'today', label: '오늘', icon: CalendarCheck },
|
|
30
|
+
{ value: 'upcoming', label: '예정', icon: CalendarClock },
|
|
31
|
+
{ value: 'incomplete', label: '미완료', icon: ListTodo },
|
|
32
|
+
{ value: 'done', label: '완료', icon: CheckCircle2 },
|
|
33
|
+
] as const
|
|
34
|
+
|
|
35
|
+
const PRIORITY_COLORS: Record<TaskPriority, string> = {
|
|
36
|
+
urgent: 'bg-gatsaeng-red text-white',
|
|
37
|
+
high: 'bg-gatsaeng-red/70 text-white',
|
|
38
|
+
medium: 'bg-gatsaeng-amber/80 text-black',
|
|
39
|
+
low: 'bg-muted text-muted-foreground',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const PRIORITY_LABELS: Record<TaskPriority, string> = {
|
|
43
|
+
urgent: '긴급',
|
|
44
|
+
high: '높음',
|
|
45
|
+
medium: '보통',
|
|
46
|
+
low: '낮음',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default function TasksPage() {
|
|
50
|
+
const [view, setView] = useState('inbox')
|
|
51
|
+
const [sort, setSort] = useState<string>('')
|
|
52
|
+
const [showQuickAdd, setShowQuickAdd] = useState(false)
|
|
53
|
+
const [quickTitle, setQuickTitle] = useState('')
|
|
54
|
+
const [quickDueDate, setQuickDueDate] = useState('')
|
|
55
|
+
const [quickProjectId, setQuickProjectId] = useState('')
|
|
56
|
+
|
|
57
|
+
const { data: tasks = [], isLoading } = useGlobalTasks(view, sort || undefined)
|
|
58
|
+
const { data: projects = [] } = useProjects()
|
|
59
|
+
const quickCreate = useQuickCreateTask()
|
|
60
|
+
const toggleDone = useToggleTaskDone()
|
|
61
|
+
|
|
62
|
+
const handleQuickAdd = async (e: React.FormEvent) => {
|
|
63
|
+
e.preventDefault()
|
|
64
|
+
if (!quickTitle.trim()) return
|
|
65
|
+
await quickCreate.mutateAsync({
|
|
66
|
+
title: quickTitle.trim(),
|
|
67
|
+
due_date: quickDueDate || undefined,
|
|
68
|
+
project_id: quickProjectId || undefined,
|
|
69
|
+
})
|
|
70
|
+
setQuickTitle('')
|
|
71
|
+
setQuickDueDate('')
|
|
72
|
+
setQuickProjectId('')
|
|
73
|
+
setShowQuickAdd(false)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const getProjectName = (projectId: string) => {
|
|
77
|
+
return projects.find(p => p.id === projectId)?.title ?? ''
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const groupedTasks = useMemo(() => {
|
|
81
|
+
if (view === 'inbox' || view === 'done') return null // no date grouping for these
|
|
82
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
83
|
+
const weekLater = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10)
|
|
84
|
+
|
|
85
|
+
const groups: { label: string; tasks: Task[] }[] = []
|
|
86
|
+
const overdue: Task[] = []
|
|
87
|
+
const todayTasks: Task[] = []
|
|
88
|
+
const thisWeek: Task[] = []
|
|
89
|
+
const later: Task[] = []
|
|
90
|
+
const noDue: Task[] = []
|
|
91
|
+
|
|
92
|
+
for (const t of tasks) {
|
|
93
|
+
const d = t.due_date?.slice(0, 10)
|
|
94
|
+
if (!d) { noDue.push(t); continue }
|
|
95
|
+
if (d < today) overdue.push(t)
|
|
96
|
+
else if (d === today) todayTasks.push(t)
|
|
97
|
+
else if (d <= weekLater) thisWeek.push(t)
|
|
98
|
+
else later.push(t)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (overdue.length) groups.push({ label: `지난 할일 ${overdue.length}`, tasks: overdue })
|
|
102
|
+
if (todayTasks.length) groups.push({ label: `오늘 ${todayTasks.length}`, tasks: todayTasks })
|
|
103
|
+
if (thisWeek.length) groups.push({ label: `다음 7일 ${thisWeek.length}`, tasks: thisWeek })
|
|
104
|
+
if (later.length) groups.push({ label: `이후 ${later.length}`, tasks: later })
|
|
105
|
+
if (noDue.length) groups.push({ label: `마감일 없음 ${noDue.length}`, tasks: noDue })
|
|
106
|
+
|
|
107
|
+
return groups.length > 0 ? groups : null
|
|
108
|
+
}, [tasks, view])
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className="max-w-4xl mx-auto">
|
|
112
|
+
<div className="flex items-center justify-between mb-6">
|
|
113
|
+
<div>
|
|
114
|
+
<h1 className="text-2xl font-bold text-foreground">할일</h1>
|
|
115
|
+
<p className="text-sm text-muted-foreground mt-1">스마트 뷰로 태스크 관리</p>
|
|
116
|
+
</div>
|
|
117
|
+
<div className="flex items-center gap-2">
|
|
118
|
+
<Select value={sort} onValueChange={setSort}>
|
|
119
|
+
<SelectTrigger className="w-[130px] h-8 text-xs">
|
|
120
|
+
<ArrowUpDown className="w-3 h-3 mr-1" />
|
|
121
|
+
<SelectValue placeholder="정렬" />
|
|
122
|
+
</SelectTrigger>
|
|
123
|
+
<SelectContent>
|
|
124
|
+
<SelectItem value="position">기본</SelectItem>
|
|
125
|
+
<SelectItem value="due_date">마감일순</SelectItem>
|
|
126
|
+
<SelectItem value="priority">우선순위순</SelectItem>
|
|
127
|
+
</SelectContent>
|
|
128
|
+
</Select>
|
|
129
|
+
<Button
|
|
130
|
+
size="sm"
|
|
131
|
+
className="bg-primary hover:bg-primary/80"
|
|
132
|
+
onClick={() => setShowQuickAdd(v => !v)}
|
|
133
|
+
>
|
|
134
|
+
<Plus className="w-4 h-4 mr-1" /> 추가
|
|
135
|
+
</Button>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{showQuickAdd && (
|
|
140
|
+
<Card className="mb-4 border-primary/30">
|
|
141
|
+
<CardContent className="py-3 px-4">
|
|
142
|
+
<form onSubmit={handleQuickAdd} className="flex items-center gap-2">
|
|
143
|
+
<Input
|
|
144
|
+
value={quickTitle}
|
|
145
|
+
onChange={e => setQuickTitle(e.target.value)}
|
|
146
|
+
placeholder="새 할일 입력..."
|
|
147
|
+
className="flex-1 h-8 text-sm"
|
|
148
|
+
autoFocus
|
|
149
|
+
/>
|
|
150
|
+
<Input
|
|
151
|
+
type="date"
|
|
152
|
+
value={quickDueDate}
|
|
153
|
+
onChange={e => setQuickDueDate(e.target.value)}
|
|
154
|
+
className="w-[140px] h-8 text-xs"
|
|
155
|
+
/>
|
|
156
|
+
<Select value={quickProjectId} onValueChange={setQuickProjectId}>
|
|
157
|
+
<SelectTrigger className="w-[130px] h-8 text-xs">
|
|
158
|
+
<SelectValue placeholder="프로젝트" />
|
|
159
|
+
</SelectTrigger>
|
|
160
|
+
<SelectContent>
|
|
161
|
+
<SelectItem value="">없음</SelectItem>
|
|
162
|
+
{projects.map(p => (
|
|
163
|
+
<SelectItem key={p.id} value={p.id}>{p.title}</SelectItem>
|
|
164
|
+
))}
|
|
165
|
+
</SelectContent>
|
|
166
|
+
</Select>
|
|
167
|
+
<Button type="submit" size="sm" disabled={quickCreate.isPending}>
|
|
168
|
+
추가
|
|
169
|
+
</Button>
|
|
170
|
+
</form>
|
|
171
|
+
</CardContent>
|
|
172
|
+
</Card>
|
|
173
|
+
)}
|
|
174
|
+
|
|
175
|
+
<Tabs value={view} onValueChange={setView} className="mb-6">
|
|
176
|
+
<TabsList>
|
|
177
|
+
{VIEW_TABS.map(tab => {
|
|
178
|
+
const Icon = tab.icon
|
|
179
|
+
return (
|
|
180
|
+
<TabsTrigger key={tab.value} value={tab.value} className="gap-1.5">
|
|
181
|
+
<Icon className="w-3.5 h-3.5" />
|
|
182
|
+
{tab.label}
|
|
183
|
+
</TabsTrigger>
|
|
184
|
+
)
|
|
185
|
+
})}
|
|
186
|
+
</TabsList>
|
|
187
|
+
</Tabs>
|
|
188
|
+
|
|
189
|
+
{isLoading ? (
|
|
190
|
+
<div className="space-y-2">
|
|
191
|
+
{[1, 2, 3, 4].map(i => (
|
|
192
|
+
<div key={i} className="h-14 bg-card rounded-lg animate-pulse" />
|
|
193
|
+
))}
|
|
194
|
+
</div>
|
|
195
|
+
) : tasks.length === 0 ? (
|
|
196
|
+
<Card>
|
|
197
|
+
<CardContent className="py-12 text-center text-muted-foreground">
|
|
198
|
+
{view === 'inbox' && '프로젝트에 배정되지 않은 할일이 없습니다'}
|
|
199
|
+
{view === 'today' && '오늘 마감인 할일이 없습니다'}
|
|
200
|
+
{view === 'upcoming' && '예정된 할일이 없습니다'}
|
|
201
|
+
{view === 'incomplete' && '미완료 할일이 없습니다'}
|
|
202
|
+
{view === 'done' && '완료된 할일이 없습니다'}
|
|
203
|
+
</CardContent>
|
|
204
|
+
</Card>
|
|
205
|
+
) : groupedTasks ? (
|
|
206
|
+
<div className="space-y-4">
|
|
207
|
+
{groupedTasks.map(group => (
|
|
208
|
+
<div key={group.label}>
|
|
209
|
+
<div className="flex items-center gap-2 mb-2">
|
|
210
|
+
<span className="text-xs font-medium text-muted-foreground">▼ {group.label}</span>
|
|
211
|
+
</div>
|
|
212
|
+
<div className="space-y-1.5">
|
|
213
|
+
{group.tasks.map(task => (
|
|
214
|
+
<TaskListItem
|
|
215
|
+
key={task.id}
|
|
216
|
+
task={task}
|
|
217
|
+
projectName={getProjectName(task.project_id)}
|
|
218
|
+
onToggle={() => toggleDone.mutate({ id: task.id, currentStatus: task.status })}
|
|
219
|
+
/>
|
|
220
|
+
))}
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
))}
|
|
224
|
+
</div>
|
|
225
|
+
) : (
|
|
226
|
+
<div className="space-y-1.5">
|
|
227
|
+
{tasks.map(task => (
|
|
228
|
+
<TaskListItem
|
|
229
|
+
key={task.id}
|
|
230
|
+
task={task}
|
|
231
|
+
projectName={getProjectName(task.project_id)}
|
|
232
|
+
onToggle={() => toggleDone.mutate({ id: task.id, currentStatus: task.status })}
|
|
233
|
+
/>
|
|
234
|
+
))}
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
|
|
238
|
+
{!isLoading && tasks.length > 0 && (
|
|
239
|
+
<div className="mt-4 text-xs text-muted-foreground text-right">
|
|
240
|
+
{tasks.length}개 태스크
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function TaskListItem({ task, projectName, onToggle }: {
|
|
248
|
+
task: Task
|
|
249
|
+
projectName: string
|
|
250
|
+
onToggle: () => void
|
|
251
|
+
}) {
|
|
252
|
+
const router = useRouter()
|
|
253
|
+
const isDone = task.status === 'done'
|
|
254
|
+
|
|
255
|
+
return (
|
|
256
|
+
<Card
|
|
257
|
+
className={cn(
|
|
258
|
+
'group hover:border-primary/30 transition-colors cursor-pointer',
|
|
259
|
+
isDone && 'opacity-60'
|
|
260
|
+
)}
|
|
261
|
+
onClick={() => router.push(`/tasks/${task.id}`)}
|
|
262
|
+
>
|
|
263
|
+
<CardContent className="py-2.5 px-4">
|
|
264
|
+
<div className="flex items-center gap-3">
|
|
265
|
+
<button
|
|
266
|
+
onClick={(e) => { e.stopPropagation(); onToggle() }}
|
|
267
|
+
className="flex-shrink-0 transition-colors"
|
|
268
|
+
>
|
|
269
|
+
{isDone ? (
|
|
270
|
+
<CheckCircle2 className="w-5 h-5 text-primary" />
|
|
271
|
+
) : (
|
|
272
|
+
<Circle className="w-5 h-5 text-muted-foreground hover:text-primary" />
|
|
273
|
+
)}
|
|
274
|
+
</button>
|
|
275
|
+
|
|
276
|
+
<div className="flex-1 min-w-0">
|
|
277
|
+
<div className="flex items-center gap-2">
|
|
278
|
+
<span className={cn(
|
|
279
|
+
'text-sm font-medium truncate',
|
|
280
|
+
isDone && 'line-through text-muted-foreground'
|
|
281
|
+
)}>
|
|
282
|
+
{task.title}
|
|
283
|
+
</span>
|
|
284
|
+
</div>
|
|
285
|
+
{(projectName || task.tag) && (
|
|
286
|
+
<div className="flex items-center gap-2 mt-0.5">
|
|
287
|
+
{projectName && (
|
|
288
|
+
<span className="text-[10px] text-muted-foreground">{projectName}</span>
|
|
289
|
+
)}
|
|
290
|
+
{task.tag && (
|
|
291
|
+
<Badge variant="outline" className="text-[9px] px-1 py-0">{task.tag}</Badge>
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
)}
|
|
295
|
+
</div>
|
|
296
|
+
|
|
297
|
+
<div className="flex items-center gap-2 flex-shrink-0">
|
|
298
|
+
<Badge className={cn('text-[10px] px-1.5 py-0', PRIORITY_COLORS[task.priority])}>
|
|
299
|
+
{PRIORITY_LABELS[task.priority]}
|
|
300
|
+
</Badge>
|
|
301
|
+
{task.due_date && <DDayBadge dueDate={task.due_date} />}
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
</CardContent>
|
|
305
|
+
</Card>
|
|
306
|
+
)
|
|
307
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useCallback } from 'react'
|
|
4
|
+
import { Mic, Loader2, Volume2, Play } from 'lucide-react'
|
|
5
|
+
import { cn } from '@/lib/utils'
|
|
6
|
+
|
|
7
|
+
type Message = { role: 'user' | 'assistant'; content: string; audioUrl?: string }
|
|
8
|
+
type VoiceState = 'idle' | 'recording' | 'processing' | 'playing'
|
|
9
|
+
|
|
10
|
+
export default function VoicePage() {
|
|
11
|
+
const [state, setState] = useState<VoiceState>('idle')
|
|
12
|
+
const [messages, setMessages] = useState<Message[]>([])
|
|
13
|
+
const [error, setError] = useState<string | null>(null)
|
|
14
|
+
|
|
15
|
+
const recorderRef = useRef<MediaRecorder | null>(null)
|
|
16
|
+
const chunksRef = useRef<Blob[]>([])
|
|
17
|
+
const streamRef = useRef<MediaStream | null>(null)
|
|
18
|
+
const historyRef = useRef<Message[]>([])
|
|
19
|
+
|
|
20
|
+
// Keep historyRef in sync
|
|
21
|
+
const pushMessage = useCallback((msg: Message) => {
|
|
22
|
+
setMessages(prev => {
|
|
23
|
+
const next = [...prev, msg].slice(-20) // keep UI messages
|
|
24
|
+
historyRef.current = next.slice(-10) // keep 10 for API context
|
|
25
|
+
return next
|
|
26
|
+
})
|
|
27
|
+
}, [])
|
|
28
|
+
|
|
29
|
+
const startRecording = useCallback(async () => {
|
|
30
|
+
setError(null)
|
|
31
|
+
try {
|
|
32
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
33
|
+
streamRef.current = stream
|
|
34
|
+
chunksRef.current = []
|
|
35
|
+
|
|
36
|
+
const recorder = new MediaRecorder(stream, { mimeType: 'audio/webm' })
|
|
37
|
+
recorder.ondataavailable = (e) => {
|
|
38
|
+
if (e.data.size > 0) chunksRef.current.push(e.data)
|
|
39
|
+
}
|
|
40
|
+
recorderRef.current = recorder
|
|
41
|
+
recorder.start()
|
|
42
|
+
setState('recording')
|
|
43
|
+
} catch {
|
|
44
|
+
setError('마이크 접근 권한이 필요합니다.')
|
|
45
|
+
}
|
|
46
|
+
}, [])
|
|
47
|
+
|
|
48
|
+
const stopAndSend = useCallback(async () => {
|
|
49
|
+
const recorder = recorderRef.current
|
|
50
|
+
if (!recorder || recorder.state !== 'recording') return
|
|
51
|
+
|
|
52
|
+
setState('processing')
|
|
53
|
+
|
|
54
|
+
// Stop recording and collect blob
|
|
55
|
+
const blob = await new Promise<Blob>((resolve) => {
|
|
56
|
+
recorder.onstop = () => {
|
|
57
|
+
resolve(new Blob(chunksRef.current, { type: 'audio/webm' }))
|
|
58
|
+
}
|
|
59
|
+
recorder.stop()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// Release mic
|
|
63
|
+
streamRef.current?.getTracks().forEach(t => t.stop())
|
|
64
|
+
streamRef.current = null
|
|
65
|
+
|
|
66
|
+
if (blob.size < 1000) {
|
|
67
|
+
setState('idle')
|
|
68
|
+
return // too short, ignore
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
// 1. Transcribe
|
|
73
|
+
const form = new FormData()
|
|
74
|
+
form.append('audio', blob, 'recording.webm')
|
|
75
|
+
const sttRes = await fetch('/api/voice/transcribe', { method: 'POST', body: form })
|
|
76
|
+
if (!sttRes.ok) throw new Error('STT 실패')
|
|
77
|
+
const { text } = await sttRes.json()
|
|
78
|
+
if (!text?.trim()) { setState('idle'); return }
|
|
79
|
+
|
|
80
|
+
pushMessage({ role: 'user', content: text })
|
|
81
|
+
|
|
82
|
+
// 2. Chat
|
|
83
|
+
const chatRes = await fetch('/api/voice/chat', {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: { 'Content-Type': 'application/json' },
|
|
86
|
+
body: JSON.stringify({ message: text, history: historyRef.current }),
|
|
87
|
+
})
|
|
88
|
+
if (!chatRes.ok) throw new Error('Chat 실패')
|
|
89
|
+
const { reply } = await chatRes.json()
|
|
90
|
+
|
|
91
|
+
pushMessage({ role: 'assistant', content: reply })
|
|
92
|
+
|
|
93
|
+
// 3. TTS — fetch audio, store URL for user-gesture play (iOS safe)
|
|
94
|
+
setState('playing')
|
|
95
|
+
const ttsRes = await fetch('/api/voice/tts', {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: { 'Content-Type': 'application/json' },
|
|
98
|
+
body: JSON.stringify({ text: reply }),
|
|
99
|
+
})
|
|
100
|
+
if (!ttsRes.ok) throw new Error('TTS 실패')
|
|
101
|
+
|
|
102
|
+
const audioBlob = await ttsRes.blob()
|
|
103
|
+
const audioUrl = URL.createObjectURL(audioBlob)
|
|
104
|
+
|
|
105
|
+
// Store audioUrl on the last message so user can tap ▶ to play (iOS autoplay policy)
|
|
106
|
+
setMessages(prev => {
|
|
107
|
+
const next = [...prev]
|
|
108
|
+
const last = next[next.length - 1]
|
|
109
|
+
if (last?.role === 'assistant') next[next.length - 1] = { ...last, audioUrl }
|
|
110
|
+
return next
|
|
111
|
+
})
|
|
112
|
+
setState('idle')
|
|
113
|
+
} catch (e) {
|
|
114
|
+
setError(e instanceof Error ? e.message : '오류가 발생했습니다.')
|
|
115
|
+
setState('idle')
|
|
116
|
+
}
|
|
117
|
+
}, [pushMessage])
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<div className="flex flex-col h-full max-w-lg mx-auto">
|
|
121
|
+
{/* Header */}
|
|
122
|
+
<div className="text-center py-4 border-b border-border">
|
|
123
|
+
<h1 className="text-lg font-bold">Eve Voice</h1>
|
|
124
|
+
<p className="text-xs text-muted-foreground mt-0.5">
|
|
125
|
+
버튼을 누르고 말하세요
|
|
126
|
+
</p>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
{/* Messages */}
|
|
130
|
+
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
|
131
|
+
{messages.length === 0 && (
|
|
132
|
+
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
|
133
|
+
Eve에게 무엇이든 물어보세요
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
{messages.map((msg, i) => (
|
|
137
|
+
<div
|
|
138
|
+
key={i}
|
|
139
|
+
className={cn(
|
|
140
|
+
'flex',
|
|
141
|
+
msg.role === 'user' ? 'justify-end' : 'justify-start'
|
|
142
|
+
)}
|
|
143
|
+
>
|
|
144
|
+
<div
|
|
145
|
+
className={cn(
|
|
146
|
+
'max-w-[80%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed',
|
|
147
|
+
msg.role === 'user'
|
|
148
|
+
? 'bg-muted text-foreground rounded-br-md'
|
|
149
|
+
: 'bg-primary/10 text-foreground rounded-bl-md'
|
|
150
|
+
)}
|
|
151
|
+
>
|
|
152
|
+
{msg.role === 'assistant' && (
|
|
153
|
+
<span className="text-xs font-medium text-primary block mb-1">Eve</span>
|
|
154
|
+
)}
|
|
155
|
+
{msg.content}
|
|
156
|
+
{msg.role === 'assistant' && msg.audioUrl && (
|
|
157
|
+
<button
|
|
158
|
+
onClick={() => {
|
|
159
|
+
const audio = new Audio(msg.audioUrl!)
|
|
160
|
+
audio.onended = () => setState('idle')
|
|
161
|
+
setState('playing')
|
|
162
|
+
audio.play().catch(() => setState('idle'))
|
|
163
|
+
}}
|
|
164
|
+
className="mt-2 flex items-center gap-1 text-xs text-primary/70 hover:text-primary"
|
|
165
|
+
>
|
|
166
|
+
<Play className="w-3 h-3" /> 재생
|
|
167
|
+
</button>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
))}
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
{/* Error */}
|
|
175
|
+
{error && (
|
|
176
|
+
<div className="mx-4 mb-2 text-center text-xs text-destructive bg-destructive/10 rounded-lg px-3 py-2">
|
|
177
|
+
{error}
|
|
178
|
+
</div>
|
|
179
|
+
)}
|
|
180
|
+
|
|
181
|
+
{/* Mic Button */}
|
|
182
|
+
<div className="flex flex-col items-center gap-2 py-6 border-t border-border">
|
|
183
|
+
<button
|
|
184
|
+
onPointerDown={startRecording}
|
|
185
|
+
onPointerUp={stopAndSend}
|
|
186
|
+
onPointerLeave={() => {
|
|
187
|
+
if (state === 'recording') stopAndSend()
|
|
188
|
+
}}
|
|
189
|
+
disabled={state === 'processing' || state === 'playing'}
|
|
190
|
+
className={cn(
|
|
191
|
+
'w-20 h-20 rounded-full flex items-center justify-center transition-all select-none touch-none',
|
|
192
|
+
state === 'idle' && 'bg-primary text-primary-foreground hover:bg-primary/90 active:scale-95',
|
|
193
|
+
state === 'recording' && 'bg-red-500 text-white animate-pulse scale-110',
|
|
194
|
+
state === 'processing' && 'bg-muted text-muted-foreground cursor-wait',
|
|
195
|
+
state === 'playing' && 'bg-green-500 text-white cursor-default'
|
|
196
|
+
)}
|
|
197
|
+
>
|
|
198
|
+
{state === 'idle' && <Mic className="w-8 h-8" />}
|
|
199
|
+
{state === 'recording' && <Mic className="w-8 h-8" />}
|
|
200
|
+
{state === 'processing' && <Loader2 className="w-8 h-8 animate-spin" />}
|
|
201
|
+
{state === 'playing' && <Volume2 className="w-8 h-8" />}
|
|
202
|
+
</button>
|
|
203
|
+
<span className="text-xs text-muted-foreground">
|
|
204
|
+
{state === 'idle' && '꾹 눌러서 말하기'}
|
|
205
|
+
{state === 'recording' && '듣고 있어요...'}
|
|
206
|
+
{state === 'processing' && '처리 중...'}
|
|
207
|
+
{state === 'playing' && 'Eve가 말하는 중...'}
|
|
208
|
+
</span>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
)
|
|
212
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { getEntity, updateEntity, deleteEntity } from '@/lib/vault'
|
|
3
|
+
import { areaSchema } from '@/lib/vault/schemas'
|
|
4
|
+
import type { Area } from '@/types'
|
|
5
|
+
|
|
6
|
+
export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
7
|
+
const { id } = await params
|
|
8
|
+
const item = await getEntity<Area>('areas', id, areaSchema)
|
|
9
|
+
if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
10
|
+
return NextResponse.json(item.data)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
14
|
+
const { id } = await params
|
|
15
|
+
const body = await req.json()
|
|
16
|
+
const updated = await updateEntity<Area>('areas', id, body)
|
|
17
|
+
if (!updated) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
18
|
+
return NextResponse.json(updated)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
22
|
+
const { id } = await params
|
|
23
|
+
const ok = await deleteEntity('areas', id)
|
|
24
|
+
if (!ok) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
25
|
+
return NextResponse.json({ ok: true })
|
|
26
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { listEntities, createEntity } from '@/lib/vault'
|
|
3
|
+
import { areaSchema } from '@/lib/vault/schemas'
|
|
4
|
+
import type { Area } from '@/types'
|
|
5
|
+
import { LIMITS } from '@/types'
|
|
6
|
+
|
|
7
|
+
export async function GET() {
|
|
8
|
+
const items = await listEntities<Area>('areas', areaSchema)
|
|
9
|
+
return NextResponse.json(items.map(i => i.data))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function POST(req: Request) {
|
|
13
|
+
const body = await req.json()
|
|
14
|
+
const now = new Date().toISOString()
|
|
15
|
+
const data = await createEntity<Area>('areas', {
|
|
16
|
+
...body,
|
|
17
|
+
status: 'active',
|
|
18
|
+
linked_goals: body.linked_goals || [],
|
|
19
|
+
created: now,
|
|
20
|
+
}, body.body_content)
|
|
21
|
+
return NextResponse.json(data, { status: 201 })
|
|
22
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
import { signToken, checkCredentials, COOKIE_NAME, COOKIE_MAX_AGE } from '@/lib/auth'
|
|
3
|
+
|
|
4
|
+
// Rate limiting (in-memory, resets on restart)
|
|
5
|
+
const attempts = new Map<string, { count: number; resetAt: number }>()
|
|
6
|
+
|
|
7
|
+
function getRateLimit(ip: string) {
|
|
8
|
+
const now = Date.now()
|
|
9
|
+
const entry = attempts.get(ip)
|
|
10
|
+
if (!entry || entry.resetAt < now) {
|
|
11
|
+
return { count: 0, resetAt: now + 15 * 60 * 1000 }
|
|
12
|
+
}
|
|
13
|
+
return entry
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function POST(req: NextRequest) {
|
|
17
|
+
if (!process.env.SESSION_SECRET) {
|
|
18
|
+
return NextResponse.json({ error: 'Server misconfigured' }, { status: 500 })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ip = req.headers.get('x-forwarded-for') ?? req.headers.get('x-real-ip') ?? 'unknown'
|
|
22
|
+
const limit = getRateLimit(ip)
|
|
23
|
+
|
|
24
|
+
if (limit.count >= 10) {
|
|
25
|
+
attempts.set(ip, limit)
|
|
26
|
+
return NextResponse.json(
|
|
27
|
+
{ error: 'Too many attempts. Try again in 15 minutes.' },
|
|
28
|
+
{ status: 429, headers: { 'Retry-After': '900' } }
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { username, password } = await req.json()
|
|
33
|
+
|
|
34
|
+
if (!checkCredentials(username, password)) {
|
|
35
|
+
attempts.set(ip, { count: limit.count + 1, resetAt: limit.resetAt })
|
|
36
|
+
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Reset on success
|
|
40
|
+
attempts.delete(ip)
|
|
41
|
+
|
|
42
|
+
const token = await signToken({ username })
|
|
43
|
+
const res = NextResponse.json({ ok: true })
|
|
44
|
+
res.cookies.set(COOKIE_NAME, token, {
|
|
45
|
+
httpOnly: true,
|
|
46
|
+
secure: true,
|
|
47
|
+
sameSite: 'lax',
|
|
48
|
+
maxAge: COOKIE_MAX_AGE,
|
|
49
|
+
path: '/',
|
|
50
|
+
})
|
|
51
|
+
return res
|
|
52
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { getEntity, updateEntity, deleteEntity } from '@/lib/vault'
|
|
3
|
+
import type { Book } from '@/types'
|
|
4
|
+
|
|
5
|
+
export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
6
|
+
const { id } = await params
|
|
7
|
+
const result = await getEntity<Book>('books', id)
|
|
8
|
+
if (!result) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
9
|
+
return NextResponse.json({ ...result.data, _content: result.content ?? '' })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
13
|
+
const { id } = await params
|
|
14
|
+
const body = await request.json()
|
|
15
|
+
const { content: bodyContent, ...updates } = body
|
|
16
|
+
const finalUpdates = { ...updates, updated_at: new Date().toISOString() }
|
|
17
|
+
const result = await updateEntity<Book>('books', id, finalUpdates, bodyContent)
|
|
18
|
+
if (!result) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
19
|
+
return NextResponse.json(result)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
23
|
+
const { id } = await params
|
|
24
|
+
const deleted = await deleteEntity('books', id)
|
|
25
|
+
if (!deleted) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
26
|
+
return NextResponse.json({ success: true })
|
|
27
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { listEntities, createEntity } from '@/lib/vault'
|
|
3
|
+
import { bookSchema } from '@/lib/vault/schemas'
|
|
4
|
+
import type { Book } from '@/types'
|
|
5
|
+
|
|
6
|
+
export async function GET() {
|
|
7
|
+
const books = await listEntities<Book>('books', bookSchema)
|
|
8
|
+
return NextResponse.json(books.map(b => ({ ...b.data, _content: b.content ?? '' })))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function POST(request: Request) {
|
|
12
|
+
const body = await request.json()
|
|
13
|
+
const data = {
|
|
14
|
+
...body,
|
|
15
|
+
status: body.status || 'want_to_read',
|
|
16
|
+
created_at: new Date().toISOString(),
|
|
17
|
+
}
|
|
18
|
+
const result = await createEntity<Book>('books', data)
|
|
19
|
+
return NextResponse.json(result, { status: 201 })
|
|
20
|
+
}
|