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.
Files changed (180) hide show
  1. package/README.md +77 -0
  2. package/bin/arete.js +156 -0
  3. package/bin/create.js +111 -0
  4. package/lib/install-openclaw.js +50 -0
  5. package/lib/scaffold.js +213 -0
  6. package/lib/setup-wizard.js +88 -0
  7. package/lib/updater.js +130 -0
  8. package/package.json +34 -0
  9. package/packages/gatsaeng-os/README.md +36 -0
  10. package/packages/gatsaeng-os/components.json +23 -0
  11. package/packages/gatsaeng-os/eslint.config.mjs +18 -0
  12. package/packages/gatsaeng-os/next.config.ts +7 -0
  13. package/packages/gatsaeng-os/package.json +59 -0
  14. package/packages/gatsaeng-os/postcss.config.mjs +7 -0
  15. package/packages/gatsaeng-os/public/file.svg +1 -0
  16. package/packages/gatsaeng-os/public/globe.svg +1 -0
  17. package/packages/gatsaeng-os/public/next.svg +1 -0
  18. package/packages/gatsaeng-os/public/vercel.svg +1 -0
  19. package/packages/gatsaeng-os/public/window.svg +1 -0
  20. package/packages/gatsaeng-os/python/api_server.py +248 -0
  21. package/packages/gatsaeng-os/python/briefing.py +145 -0
  22. package/packages/gatsaeng-os/python/config.py +55 -0
  23. package/packages/gatsaeng-os/python/goal_context_agent.py +193 -0
  24. package/packages/gatsaeng-os/python/gyeokguk.py +171 -0
  25. package/packages/gatsaeng-os/python/proactive.py +158 -0
  26. package/packages/gatsaeng-os/python/requirements.txt +11 -0
  27. package/packages/gatsaeng-os/python/run.py +28 -0
  28. package/packages/gatsaeng-os/python/scoring.py +44 -0
  29. package/packages/gatsaeng-os/python/streak.py +70 -0
  30. package/packages/gatsaeng-os/python/telegram_bot.py +331 -0
  31. package/packages/gatsaeng-os/python/timing_engine.py +117 -0
  32. package/packages/gatsaeng-os/python/vault_io.py +423 -0
  33. package/packages/gatsaeng-os/src/app/(dashboard)/areas/[id]/page.tsx +215 -0
  34. package/packages/gatsaeng-os/src/app/(dashboard)/areas/page.tsx +161 -0
  35. package/packages/gatsaeng-os/src/app/(dashboard)/books/[id]/page.tsx +215 -0
  36. package/packages/gatsaeng-os/src/app/(dashboard)/books/page.tsx +268 -0
  37. package/packages/gatsaeng-os/src/app/(dashboard)/calendar/page.tsx +379 -0
  38. package/packages/gatsaeng-os/src/app/(dashboard)/error.tsx +30 -0
  39. package/packages/gatsaeng-os/src/app/(dashboard)/focus/page.tsx +293 -0
  40. package/packages/gatsaeng-os/src/app/(dashboard)/goals/[id]/page.tsx +426 -0
  41. package/packages/gatsaeng-os/src/app/(dashboard)/goals/page.tsx +178 -0
  42. package/packages/gatsaeng-os/src/app/(dashboard)/layout.tsx +29 -0
  43. package/packages/gatsaeng-os/src/app/(dashboard)/notes/[id]/page.tsx +147 -0
  44. package/packages/gatsaeng-os/src/app/(dashboard)/notes/page.tsx +254 -0
  45. package/packages/gatsaeng-os/src/app/(dashboard)/page.tsx +26 -0
  46. package/packages/gatsaeng-os/src/app/(dashboard)/projects/[id]/page.tsx +86 -0
  47. package/packages/gatsaeng-os/src/app/(dashboard)/projects/page.tsx +215 -0
  48. package/packages/gatsaeng-os/src/app/(dashboard)/review/page.tsx +475 -0
  49. package/packages/gatsaeng-os/src/app/(dashboard)/routines/page.tsx +436 -0
  50. package/packages/gatsaeng-os/src/app/(dashboard)/tasks/[id]/page.tsx +210 -0
  51. package/packages/gatsaeng-os/src/app/(dashboard)/tasks/page.tsx +307 -0
  52. package/packages/gatsaeng-os/src/app/(dashboard)/voice/page.tsx +212 -0
  53. package/packages/gatsaeng-os/src/app/api/areas/[id]/route.ts +26 -0
  54. package/packages/gatsaeng-os/src/app/api/areas/route.ts +22 -0
  55. package/packages/gatsaeng-os/src/app/api/auth/login/route.ts +52 -0
  56. package/packages/gatsaeng-os/src/app/api/auth/logout/route.ts +8 -0
  57. package/packages/gatsaeng-os/src/app/api/books/[id]/route.ts +27 -0
  58. package/packages/gatsaeng-os/src/app/api/books/route.ts +20 -0
  59. package/packages/gatsaeng-os/src/app/api/calendar/[id]/route.ts +24 -0
  60. package/packages/gatsaeng-os/src/app/api/calendar/import/route.ts +52 -0
  61. package/packages/gatsaeng-os/src/app/api/calendar/route.ts +37 -0
  62. package/packages/gatsaeng-os/src/app/api/daily/route.ts +51 -0
  63. package/packages/gatsaeng-os/src/app/api/goals/[id]/route.ts +34 -0
  64. package/packages/gatsaeng-os/src/app/api/goals/route.ts +30 -0
  65. package/packages/gatsaeng-os/src/app/api/logs/energy/route.ts +40 -0
  66. package/packages/gatsaeng-os/src/app/api/logs/focus/route.ts +22 -0
  67. package/packages/gatsaeng-os/src/app/api/logs/routine/route.ts +54 -0
  68. package/packages/gatsaeng-os/src/app/api/milestones/[id]/route.ts +26 -0
  69. package/packages/gatsaeng-os/src/app/api/milestones/route.ts +47 -0
  70. package/packages/gatsaeng-os/src/app/api/notes/[id]/route.ts +29 -0
  71. package/packages/gatsaeng-os/src/app/api/notes/route.ts +37 -0
  72. package/packages/gatsaeng-os/src/app/api/profile/route.ts +17 -0
  73. package/packages/gatsaeng-os/src/app/api/projects/[id]/route.ts +27 -0
  74. package/packages/gatsaeng-os/src/app/api/projects/route.ts +25 -0
  75. package/packages/gatsaeng-os/src/app/api/reviews/[id]/route.ts +26 -0
  76. package/packages/gatsaeng-os/src/app/api/reviews/route.ts +29 -0
  77. package/packages/gatsaeng-os/src/app/api/routines/[id]/route.ts +26 -0
  78. package/packages/gatsaeng-os/src/app/api/routines/route.ts +28 -0
  79. package/packages/gatsaeng-os/src/app/api/tasks/[id]/route.ts +28 -0
  80. package/packages/gatsaeng-os/src/app/api/tasks/route.ts +66 -0
  81. package/packages/gatsaeng-os/src/app/api/timing/current/route.ts +63 -0
  82. package/packages/gatsaeng-os/src/app/api/voice/chat/route.ts +50 -0
  83. package/packages/gatsaeng-os/src/app/api/voice/transcribe/route.ts +25 -0
  84. package/packages/gatsaeng-os/src/app/api/voice/tts/route.ts +36 -0
  85. package/packages/gatsaeng-os/src/app/error.tsx +30 -0
  86. package/packages/gatsaeng-os/src/app/favicon.ico +0 -0
  87. package/packages/gatsaeng-os/src/app/globals.css +208 -0
  88. package/packages/gatsaeng-os/src/app/layout.tsx +33 -0
  89. package/packages/gatsaeng-os/src/app/login/page.tsx +87 -0
  90. package/packages/gatsaeng-os/src/app/providers.tsx +27 -0
  91. package/packages/gatsaeng-os/src/components/ErrorBoundary.tsx +46 -0
  92. package/packages/gatsaeng-os/src/components/dashboard/DashboardGrid.tsx +86 -0
  93. package/packages/gatsaeng-os/src/components/dashboard/DdayWidget.tsx +88 -0
  94. package/packages/gatsaeng-os/src/components/dashboard/EnergyTracker.tsx +87 -0
  95. package/packages/gatsaeng-os/src/components/dashboard/FocusTimer.tsx +139 -0
  96. package/packages/gatsaeng-os/src/components/dashboard/GatsaengScore.tsx +30 -0
  97. package/packages/gatsaeng-os/src/components/dashboard/GoalRings.tsx +107 -0
  98. package/packages/gatsaeng-os/src/components/dashboard/ProactiveBar.tsx +98 -0
  99. package/packages/gatsaeng-os/src/components/dashboard/RoutineChecklist.tsx +81 -0
  100. package/packages/gatsaeng-os/src/components/dashboard/TimingWidget.tsx +86 -0
  101. package/packages/gatsaeng-os/src/components/dashboard/WidgetCustomizer.tsx +95 -0
  102. package/packages/gatsaeng-os/src/components/dashboard/WidgetWrapper.tsx +33 -0
  103. package/packages/gatsaeng-os/src/components/dashboard/ZeigarnikPanel.tsx +43 -0
  104. package/packages/gatsaeng-os/src/components/editor/EditorToolbar.tsx +186 -0
  105. package/packages/gatsaeng-os/src/components/editor/TiptapEditor.tsx +114 -0
  106. package/packages/gatsaeng-os/src/components/layout/Header.tsx +47 -0
  107. package/packages/gatsaeng-os/src/components/layout/MobileBottomNav.tsx +122 -0
  108. package/packages/gatsaeng-os/src/components/layout/MobileSidebar.tsx +29 -0
  109. package/packages/gatsaeng-os/src/components/layout/Sidebar.tsx +142 -0
  110. package/packages/gatsaeng-os/src/components/onboarding/OnboardingFlow.tsx +229 -0
  111. package/packages/gatsaeng-os/src/components/onboarding/OnboardingGate.tsx +78 -0
  112. package/packages/gatsaeng-os/src/components/projects/CalendarView.tsx +152 -0
  113. package/packages/gatsaeng-os/src/components/projects/KanbanView.tsx +180 -0
  114. package/packages/gatsaeng-os/src/components/projects/ListView.tsx +82 -0
  115. package/packages/gatsaeng-os/src/components/projects/TableView.tsx +206 -0
  116. package/packages/gatsaeng-os/src/components/projects/TaskCard.tsx +154 -0
  117. package/packages/gatsaeng-os/src/components/projects/TaskForm.tsx +128 -0
  118. package/packages/gatsaeng-os/src/components/projects/ViewSwitcher.tsx +40 -0
  119. package/packages/gatsaeng-os/src/components/search/GlobalSearch.tsx +179 -0
  120. package/packages/gatsaeng-os/src/components/shared/InlineEdit.tsx +77 -0
  121. package/packages/gatsaeng-os/src/components/shared/PinButton.tsx +42 -0
  122. package/packages/gatsaeng-os/src/components/tasks/DDayBadge.tsx +34 -0
  123. package/packages/gatsaeng-os/src/components/ui/badge.tsx +48 -0
  124. package/packages/gatsaeng-os/src/components/ui/button.tsx +64 -0
  125. package/packages/gatsaeng-os/src/components/ui/card.tsx +92 -0
  126. package/packages/gatsaeng-os/src/components/ui/checkbox.tsx +32 -0
  127. package/packages/gatsaeng-os/src/components/ui/command.tsx +184 -0
  128. package/packages/gatsaeng-os/src/components/ui/dialog.tsx +158 -0
  129. package/packages/gatsaeng-os/src/components/ui/input.tsx +21 -0
  130. package/packages/gatsaeng-os/src/components/ui/label.tsx +24 -0
  131. package/packages/gatsaeng-os/src/components/ui/popover.tsx +89 -0
  132. package/packages/gatsaeng-os/src/components/ui/progress.tsx +31 -0
  133. package/packages/gatsaeng-os/src/components/ui/select.tsx +190 -0
  134. package/packages/gatsaeng-os/src/components/ui/sheet.tsx +143 -0
  135. package/packages/gatsaeng-os/src/components/ui/tabs.tsx +91 -0
  136. package/packages/gatsaeng-os/src/components/ui/toggle-group.tsx +83 -0
  137. package/packages/gatsaeng-os/src/components/ui/toggle.tsx +47 -0
  138. package/packages/gatsaeng-os/src/components/ui/tooltip.tsx +57 -0
  139. package/packages/gatsaeng-os/src/hooks/useAreas.ts +53 -0
  140. package/packages/gatsaeng-os/src/hooks/useBooks.ts +62 -0
  141. package/packages/gatsaeng-os/src/hooks/useCalendar.ts +59 -0
  142. package/packages/gatsaeng-os/src/hooks/useDaily.ts +15 -0
  143. package/packages/gatsaeng-os/src/hooks/useGlobalTasks.ts +45 -0
  144. package/packages/gatsaeng-os/src/hooks/useGoals.ts +53 -0
  145. package/packages/gatsaeng-os/src/hooks/useMilestones.ts +75 -0
  146. package/packages/gatsaeng-os/src/hooks/useNotes.ts +65 -0
  147. package/packages/gatsaeng-os/src/hooks/useProjects.ts +102 -0
  148. package/packages/gatsaeng-os/src/hooks/useRoutines.ts +76 -0
  149. package/packages/gatsaeng-os/src/hooks/useTiming.ts +27 -0
  150. package/packages/gatsaeng-os/src/lib/apiFetch.ts +14 -0
  151. package/packages/gatsaeng-os/src/lib/auth.ts +32 -0
  152. package/packages/gatsaeng-os/src/lib/date.ts +7 -0
  153. package/packages/gatsaeng-os/src/lib/editor/markdown.ts +35 -0
  154. package/packages/gatsaeng-os/src/lib/llm-governor.ts +167 -0
  155. package/packages/gatsaeng-os/src/lib/neuroscience/energyCycle.ts +35 -0
  156. package/packages/gatsaeng-os/src/lib/neuroscience/habitStack.ts +22 -0
  157. package/packages/gatsaeng-os/src/lib/neuroscience/scoring.ts +32 -0
  158. package/packages/gatsaeng-os/src/lib/routes.ts +15 -0
  159. package/packages/gatsaeng-os/src/lib/utils.ts +6 -0
  160. package/packages/gatsaeng-os/src/lib/vault/config.ts +29 -0
  161. package/packages/gatsaeng-os/src/lib/vault/frontmatter.ts +84 -0
  162. package/packages/gatsaeng-os/src/lib/vault/index.ts +180 -0
  163. package/packages/gatsaeng-os/src/lib/vault/schemas.ts +274 -0
  164. package/packages/gatsaeng-os/src/middleware.ts +34 -0
  165. package/packages/gatsaeng-os/src/stores/dashboardStore.ts +26 -0
  166. package/packages/gatsaeng-os/src/stores/favoritesStore.ts +47 -0
  167. package/packages/gatsaeng-os/src/stores/timerStore.ts +65 -0
  168. package/packages/gatsaeng-os/src/types/index.ts +320 -0
  169. package/packages/gatsaeng-os/tsconfig.json +34 -0
  170. package/templates/scripts/forge_qa.sh.tmpl +237 -0
  171. package/templates/scripts/forge_ship.sh.tmpl +183 -0
  172. package/templates/scripts/session_indexer.py.tmpl +420 -0
  173. package/templates/scripts/tracer.py.tmpl +266 -0
  174. package/templates/workspace/AGENTS.md.tmpl +190 -0
  175. package/templates/workspace/BOOTSTRAP.md.tmpl +27 -0
  176. package/templates/workspace/HEARTBEAT.md.tmpl +23 -0
  177. package/templates/workspace/MEMORY.md.tmpl +35 -0
  178. package/templates/workspace/SOUL.md.tmpl +258 -0
  179. package/templates/workspace/TOOLS.md.tmpl +28 -0
  180. 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,8 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { COOKIE_NAME } from '@/lib/auth'
3
+
4
+ export async function POST() {
5
+ const res = NextResponse.json({ ok: true })
6
+ res.cookies.delete(COOKIE_NAME)
7
+ return res
8
+ }
@@ -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
+ }