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,268 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
import { useBooks, useCreateBook, useUpdateBook, useDeleteBook } from '@/hooks/useBooks'
|
|
6
|
+
import { useGoals } from '@/hooks/useGoals'
|
|
7
|
+
import { Card, CardContent } from '@/components/ui/card'
|
|
8
|
+
import { Badge } from '@/components/ui/badge'
|
|
9
|
+
import { Button } from '@/components/ui/button'
|
|
10
|
+
import { Input } from '@/components/ui/input'
|
|
11
|
+
import { Label } from '@/components/ui/label'
|
|
12
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
13
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
|
14
|
+
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
15
|
+
import { BookOpen, Plus, Star, Trash2, Minus } from 'lucide-react'
|
|
16
|
+
import type { Book, BookStatus } from '@/types'
|
|
17
|
+
|
|
18
|
+
const STATUS_LABELS: Record<BookStatus, string> = {
|
|
19
|
+
reading: '읽는 중',
|
|
20
|
+
want_to_read: '읽고 싶은',
|
|
21
|
+
completed: '완료',
|
|
22
|
+
dropped: '중단',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const STATUS_COLORS: Record<BookStatus, string> = {
|
|
26
|
+
reading: 'border-gatsaeng-teal text-gatsaeng-teal',
|
|
27
|
+
want_to_read: 'border-primary text-primary',
|
|
28
|
+
completed: 'border-gatsaeng-amber text-gatsaeng-amber',
|
|
29
|
+
dropped: 'border-muted-foreground text-muted-foreground',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default function BooksPage() {
|
|
33
|
+
const router = useRouter()
|
|
34
|
+
const { data: books, isLoading } = useBooks()
|
|
35
|
+
const { data: goals } = useGoals()
|
|
36
|
+
const createBook = useCreateBook()
|
|
37
|
+
const updateBook = useUpdateBook()
|
|
38
|
+
const deleteBook = useDeleteBook()
|
|
39
|
+
const [open, setOpen] = useState(false)
|
|
40
|
+
const [tab, setTab] = useState<string>('all')
|
|
41
|
+
|
|
42
|
+
const filtered = (books ?? []).filter(b => tab === 'all' || b.status === tab)
|
|
43
|
+
const counts = {
|
|
44
|
+
all: (books ?? []).length,
|
|
45
|
+
reading: (books ?? []).filter(b => b.status === 'reading').length,
|
|
46
|
+
want_to_read: (books ?? []).filter(b => b.status === 'want_to_read').length,
|
|
47
|
+
completed: (books ?? []).filter(b => b.status === 'completed').length,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
51
|
+
e.preventDefault()
|
|
52
|
+
const form = new FormData(e.currentTarget)
|
|
53
|
+
await createBook.mutateAsync({
|
|
54
|
+
title: form.get('title') as string,
|
|
55
|
+
author: form.get('author') as string,
|
|
56
|
+
total_pages: Number(form.get('total_pages')) || undefined,
|
|
57
|
+
goal_id: (form.get('goal_id') as string) || undefined,
|
|
58
|
+
status: 'want_to_read',
|
|
59
|
+
})
|
|
60
|
+
setOpen(false)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const handleStatusChange = (book: Book, status: BookStatus) => {
|
|
64
|
+
const updates: Partial<Book> & { id: string } = { id: book.id, status }
|
|
65
|
+
if (status === 'reading' && !book.started_at) {
|
|
66
|
+
updates.started_at = new Date().toISOString()
|
|
67
|
+
}
|
|
68
|
+
if (status === 'completed') {
|
|
69
|
+
updates.finished_at = new Date().toISOString()
|
|
70
|
+
if (book.total_pages) updates.current_page = book.total_pages
|
|
71
|
+
}
|
|
72
|
+
updateBook.mutate(updates)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const handlePageUpdate = (book: Book, delta: number) => {
|
|
76
|
+
const newPage = Math.max(0, Math.min(book.total_pages ?? 9999, (book.current_page ?? 0) + delta))
|
|
77
|
+
updateBook.mutate({ id: book.id, current_page: newPage })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const handleRating = (book: Book, rating: number) => {
|
|
81
|
+
updateBook.mutate({ id: book.id, rating })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const handleDelete = (id: string) => {
|
|
85
|
+
if (!confirm('이 책을 삭제하시겠습니까?')) return
|
|
86
|
+
deleteBook.mutate(id)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div className="max-w-4xl mx-auto">
|
|
91
|
+
<div className="flex items-center justify-between mb-6">
|
|
92
|
+
<div>
|
|
93
|
+
<h1 className="text-2xl font-bold text-foreground">독서</h1>
|
|
94
|
+
<p className="text-sm text-muted-foreground mt-1">읽고 있는 책과 독서 기록</p>
|
|
95
|
+
</div>
|
|
96
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
97
|
+
<DialogTrigger asChild>
|
|
98
|
+
<Button className="bg-gatsaeng-amber hover:bg-gatsaeng-amber/80 text-black">
|
|
99
|
+
<Plus className="w-4 h-4 mr-2" /> 책 추가
|
|
100
|
+
</Button>
|
|
101
|
+
</DialogTrigger>
|
|
102
|
+
<DialogContent className="max-w-lg">
|
|
103
|
+
<DialogHeader>
|
|
104
|
+
<DialogTitle>책 추가</DialogTitle>
|
|
105
|
+
</DialogHeader>
|
|
106
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
107
|
+
<div>
|
|
108
|
+
<Label>제목</Label>
|
|
109
|
+
<Input name="title" required placeholder="책 제목" />
|
|
110
|
+
</div>
|
|
111
|
+
<div>
|
|
112
|
+
<Label>저자</Label>
|
|
113
|
+
<Input name="author" required placeholder="저자 이름" />
|
|
114
|
+
</div>
|
|
115
|
+
<div>
|
|
116
|
+
<Label>전체 페이지</Label>
|
|
117
|
+
<Input name="total_pages" type="number" placeholder="300" />
|
|
118
|
+
</div>
|
|
119
|
+
{(goals ?? []).length > 0 && (
|
|
120
|
+
<div>
|
|
121
|
+
<Label>연결 목표</Label>
|
|
122
|
+
<Select name="goal_id">
|
|
123
|
+
<SelectTrigger><SelectValue placeholder="목표 선택 (선택)" /></SelectTrigger>
|
|
124
|
+
<SelectContent>
|
|
125
|
+
{(goals ?? []).map(goal => (
|
|
126
|
+
<SelectItem key={goal.id} value={goal.id}>{goal.title}</SelectItem>
|
|
127
|
+
))}
|
|
128
|
+
</SelectContent>
|
|
129
|
+
</Select>
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
<Button type="submit" className="w-full bg-gatsaeng-amber hover:bg-gatsaeng-amber/80 text-black">
|
|
133
|
+
추가
|
|
134
|
+
</Button>
|
|
135
|
+
</form>
|
|
136
|
+
</DialogContent>
|
|
137
|
+
</Dialog>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<Tabs value={tab} onValueChange={setTab} className="mb-6">
|
|
141
|
+
<TabsList>
|
|
142
|
+
<TabsTrigger value="all">전체 ({counts.all})</TabsTrigger>
|
|
143
|
+
<TabsTrigger value="reading">읽는 중 ({counts.reading})</TabsTrigger>
|
|
144
|
+
<TabsTrigger value="want_to_read">읽고 싶은 ({counts.want_to_read})</TabsTrigger>
|
|
145
|
+
<TabsTrigger value="completed">완료 ({counts.completed})</TabsTrigger>
|
|
146
|
+
</TabsList>
|
|
147
|
+
</Tabs>
|
|
148
|
+
|
|
149
|
+
{isLoading ? (
|
|
150
|
+
<div className="space-y-3">
|
|
151
|
+
{[1, 2, 3].map(i => <div key={i} className="h-24 bg-card rounded-lg animate-pulse" />)}
|
|
152
|
+
</div>
|
|
153
|
+
) : filtered.length === 0 ? (
|
|
154
|
+
<Card>
|
|
155
|
+
<CardContent className="py-12 text-center text-muted-foreground">
|
|
156
|
+
{tab === 'all' ? '아직 등록된 책이 없습니다' : `${STATUS_LABELS[tab as BookStatus]} 책이 없습니다`}
|
|
157
|
+
</CardContent>
|
|
158
|
+
</Card>
|
|
159
|
+
) : (
|
|
160
|
+
<div className="space-y-3">
|
|
161
|
+
{filtered.map(book => {
|
|
162
|
+
const progress = book.total_pages
|
|
163
|
+
? Math.min(100, Math.round(((book.current_page ?? 0) / book.total_pages) * 100))
|
|
164
|
+
: null
|
|
165
|
+
return (
|
|
166
|
+
<Card
|
|
167
|
+
key={book.id}
|
|
168
|
+
className="hover:border-primary/30 transition-colors cursor-pointer"
|
|
169
|
+
onClick={() => router.push(`/books/${book.id}`)}
|
|
170
|
+
>
|
|
171
|
+
<CardContent className="py-4">
|
|
172
|
+
<div className="flex items-start gap-4">
|
|
173
|
+
<div className="w-10 h-14 rounded bg-card border border-border flex items-center justify-center flex-shrink-0">
|
|
174
|
+
<BookOpen className="w-5 h-5 text-muted-foreground" />
|
|
175
|
+
</div>
|
|
176
|
+
<div className="flex-1 min-w-0">
|
|
177
|
+
<div className="flex items-center gap-2 mb-1">
|
|
178
|
+
<h3 className="text-sm font-semibold text-foreground truncate">{book.title}</h3>
|
|
179
|
+
<Badge variant="outline" className={STATUS_COLORS[book.status]}>
|
|
180
|
+
{STATUS_LABELS[book.status]}
|
|
181
|
+
</Badge>
|
|
182
|
+
</div>
|
|
183
|
+
<div className="text-xs text-muted-foreground mb-2">{book.author}</div>
|
|
184
|
+
|
|
185
|
+
{/* Progress bar for reading books */}
|
|
186
|
+
{book.status === 'reading' && book.total_pages && (
|
|
187
|
+
<div className="flex items-center gap-2 mb-2">
|
|
188
|
+
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
|
189
|
+
<div
|
|
190
|
+
className="h-full bg-gatsaeng-teal rounded-full transition-all"
|
|
191
|
+
style={{ width: `${progress}%` }}
|
|
192
|
+
/>
|
|
193
|
+
</div>
|
|
194
|
+
<span className="text-xs text-muted-foreground min-w-[60px] text-right">
|
|
195
|
+
{book.current_page ?? 0}/{book.total_pages}p
|
|
196
|
+
</span>
|
|
197
|
+
<div className="flex items-center gap-1">
|
|
198
|
+
<Button
|
|
199
|
+
variant="ghost" size="icon"
|
|
200
|
+
className="h-6 w-6"
|
|
201
|
+
onClick={(e) => { e.stopPropagation(); handlePageUpdate(book, -10) }}
|
|
202
|
+
>
|
|
203
|
+
<Minus className="w-3 h-3" />
|
|
204
|
+
</Button>
|
|
205
|
+
<Button
|
|
206
|
+
variant="ghost" size="icon"
|
|
207
|
+
className="h-6 w-6"
|
|
208
|
+
onClick={(e) => { e.stopPropagation(); handlePageUpdate(book, 10) }}
|
|
209
|
+
>
|
|
210
|
+
<Plus className="w-3 h-3" />
|
|
211
|
+
</Button>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
)}
|
|
215
|
+
|
|
216
|
+
{/* Rating for completed books */}
|
|
217
|
+
{book.status === 'completed' && (
|
|
218
|
+
<div className="flex items-center gap-1 mb-2" onClick={e => e.stopPropagation()}>
|
|
219
|
+
{[1, 2, 3, 4, 5].map(s => (
|
|
220
|
+
<button
|
|
221
|
+
key={s}
|
|
222
|
+
onClick={() => handleRating(book, s)}
|
|
223
|
+
className="p-0"
|
|
224
|
+
>
|
|
225
|
+
<Star
|
|
226
|
+
className={`w-4 h-4 ${s <= (book.rating ?? 0)
|
|
227
|
+
? 'fill-gatsaeng-amber text-gatsaeng-amber'
|
|
228
|
+
: 'text-muted-foreground'}`}
|
|
229
|
+
/>
|
|
230
|
+
</button>
|
|
231
|
+
))}
|
|
232
|
+
</div>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
<div className="flex items-center gap-1 flex-shrink-0" onClick={e => e.stopPropagation()}>
|
|
237
|
+
<Select
|
|
238
|
+
value={book.status}
|
|
239
|
+
onValueChange={(v) => handleStatusChange(book, v as BookStatus)}
|
|
240
|
+
>
|
|
241
|
+
<SelectTrigger className="h-7 text-xs w-24">
|
|
242
|
+
<SelectValue />
|
|
243
|
+
</SelectTrigger>
|
|
244
|
+
<SelectContent>
|
|
245
|
+
<SelectItem value="want_to_read">읽고 싶은</SelectItem>
|
|
246
|
+
<SelectItem value="reading">읽는 중</SelectItem>
|
|
247
|
+
<SelectItem value="completed">완료</SelectItem>
|
|
248
|
+
<SelectItem value="dropped">중단</SelectItem>
|
|
249
|
+
</SelectContent>
|
|
250
|
+
</Select>
|
|
251
|
+
<Button
|
|
252
|
+
variant="ghost" size="icon"
|
|
253
|
+
className="h-7 w-7 text-muted-foreground hover:text-gatsaeng-red"
|
|
254
|
+
onClick={() => handleDelete(book.id)}
|
|
255
|
+
>
|
|
256
|
+
<Trash2 className="w-3.5 h-3.5" />
|
|
257
|
+
</Button>
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
</CardContent>
|
|
261
|
+
</Card>
|
|
262
|
+
)
|
|
263
|
+
})}
|
|
264
|
+
</div>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
)
|
|
268
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useMemo } from 'react'
|
|
4
|
+
import { useCalendarEvents, useCreateCalendarEvent, useDeleteCalendarEvent } from '@/hooks/useCalendar'
|
|
5
|
+
import { Button } from '@/components/ui/button'
|
|
6
|
+
import { Input } from '@/components/ui/input'
|
|
7
|
+
import { Label } from '@/components/ui/label'
|
|
8
|
+
import { Card, CardContent } from '@/components/ui/card'
|
|
9
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
10
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
|
11
|
+
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
12
|
+
import { CalendarDays, Plus, ChevronLeft, ChevronRight, Trash2, Clock, MapPin } from 'lucide-react'
|
|
13
|
+
import type { CalendarEvent, CalendarCategory } from '@/types'
|
|
14
|
+
|
|
15
|
+
const DAY_NAMES = ['일', '월', '화', '수', '목', '금', '토']
|
|
16
|
+
|
|
17
|
+
const CATEGORY_COLORS: Record<CalendarCategory, string> = {
|
|
18
|
+
work: 'bg-primary/20 text-primary border-primary/30',
|
|
19
|
+
personal: 'bg-gatsaeng-amber/20 text-gatsaeng-amber border-gatsaeng-amber/30',
|
|
20
|
+
health: 'bg-gatsaeng-teal/20 text-gatsaeng-teal border-gatsaeng-teal/30',
|
|
21
|
+
study: 'bg-gatsaeng-purple/20 text-gatsaeng-purple border-gatsaeng-purple/30',
|
|
22
|
+
social: 'bg-pink-500/20 text-pink-400 border-pink-500/30',
|
|
23
|
+
other: 'bg-muted text-muted-foreground border-border',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const CATEGORY_LABELS: Record<CalendarCategory, string> = {
|
|
27
|
+
work: '업무',
|
|
28
|
+
personal: '개인',
|
|
29
|
+
health: '건강',
|
|
30
|
+
study: '학습',
|
|
31
|
+
social: '사교',
|
|
32
|
+
other: '기타',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function formatDate(d: Date) {
|
|
36
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getWeekDates(baseDate: Date) {
|
|
40
|
+
const day = baseDate.getDay()
|
|
41
|
+
const monday = new Date(baseDate)
|
|
42
|
+
monday.setDate(baseDate.getDate() - ((day + 6) % 7))
|
|
43
|
+
return Array.from({ length: 7 }, (_, i) => {
|
|
44
|
+
const d = new Date(monday)
|
|
45
|
+
d.setDate(monday.getDate() + i)
|
|
46
|
+
return d
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getMonthGrid(year: number, month: number) {
|
|
51
|
+
const firstDay = new Date(year, month, 1)
|
|
52
|
+
const lastDay = new Date(year, month + 1, 0)
|
|
53
|
+
const startOffset = (firstDay.getDay() + 6) % 7 // Monday=0
|
|
54
|
+
const dates: (Date | null)[] = []
|
|
55
|
+
for (let i = 0; i < startOffset; i++) dates.push(null)
|
|
56
|
+
for (let d = 1; d <= lastDay.getDate(); d++) dates.push(new Date(year, month, d))
|
|
57
|
+
while (dates.length % 7 !== 0) dates.push(null)
|
|
58
|
+
return dates
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function EventCard({ event, onDelete }: { event: CalendarEvent; onDelete: (id: string) => void }) {
|
|
62
|
+
return (
|
|
63
|
+
<div
|
|
64
|
+
className={`rounded px-1.5 py-1 text-[10px] leading-tight border group relative ${
|
|
65
|
+
CATEGORY_COLORS[event.category ?? 'other']
|
|
66
|
+
}`}
|
|
67
|
+
onClick={(e) => e.stopPropagation()}
|
|
68
|
+
>
|
|
69
|
+
<div className="font-medium truncate">{event.title}</div>
|
|
70
|
+
{event.time_start && (
|
|
71
|
+
<div className="flex items-center gap-0.5 opacity-70">
|
|
72
|
+
<Clock className="w-2.5 h-2.5" />
|
|
73
|
+
{event.time_start}{event.time_end ? `–${event.time_end}` : ''}
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
{event.location && (
|
|
77
|
+
<div className="flex items-center gap-0.5 opacity-70">
|
|
78
|
+
<MapPin className="w-2.5 h-2.5" />
|
|
79
|
+
<span className="truncate">{event.location}</span>
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
<button
|
|
83
|
+
className="absolute top-0.5 right-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
84
|
+
onClick={() => onDelete(event.id)}
|
|
85
|
+
>
|
|
86
|
+
<Trash2 className="w-3 h-3 text-gatsaeng-red" />
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export default function CalendarPage() {
|
|
93
|
+
const [viewMode, setViewMode] = useState<'week' | 'month'>('week')
|
|
94
|
+
const [weekOffset, setWeekOffset] = useState(0)
|
|
95
|
+
const [monthOffset, setMonthOffset] = useState(0)
|
|
96
|
+
const [open, setOpen] = useState(false)
|
|
97
|
+
const [selectedDate, setSelectedDate] = useState<string | null>(null)
|
|
98
|
+
|
|
99
|
+
const createEvent = useCreateCalendarEvent()
|
|
100
|
+
const deleteEvent = useDeleteCalendarEvent()
|
|
101
|
+
const today = formatDate(new Date())
|
|
102
|
+
|
|
103
|
+
// Week view dates
|
|
104
|
+
const weekBase = useMemo(() => {
|
|
105
|
+
const d = new Date()
|
|
106
|
+
d.setDate(d.getDate() + weekOffset * 7)
|
|
107
|
+
return d
|
|
108
|
+
}, [weekOffset])
|
|
109
|
+
const weekDates = useMemo(() => getWeekDates(weekBase), [weekBase])
|
|
110
|
+
|
|
111
|
+
// Month view dates
|
|
112
|
+
const monthDate = useMemo(() => {
|
|
113
|
+
const d = new Date()
|
|
114
|
+
d.setMonth(d.getMonth() + monthOffset)
|
|
115
|
+
return d
|
|
116
|
+
}, [monthOffset])
|
|
117
|
+
const monthGrid = useMemo(() => getMonthGrid(monthDate.getFullYear(), monthDate.getMonth()), [monthDate])
|
|
118
|
+
|
|
119
|
+
// Calculate range for query
|
|
120
|
+
const rangeStart = useMemo(() => {
|
|
121
|
+
if (viewMode === 'week') return formatDate(weekDates[0])
|
|
122
|
+
const first = monthGrid.find(d => d !== null)
|
|
123
|
+
return first ? formatDate(first) : formatDate(new Date(monthDate.getFullYear(), monthDate.getMonth(), 1))
|
|
124
|
+
}, [viewMode, weekDates, monthGrid, monthDate])
|
|
125
|
+
|
|
126
|
+
const rangeEnd = useMemo(() => {
|
|
127
|
+
if (viewMode === 'week') return formatDate(weekDates[6])
|
|
128
|
+
const nonNull = monthGrid.filter((d): d is Date => d !== null)
|
|
129
|
+
const last = nonNull[nonNull.length - 1]
|
|
130
|
+
return last ? formatDate(last) : formatDate(new Date(monthDate.getFullYear(), monthDate.getMonth() + 1, 0))
|
|
131
|
+
}, [viewMode, weekDates, monthGrid, monthDate])
|
|
132
|
+
|
|
133
|
+
const { data: events = [], isLoading } = useCalendarEvents(rangeStart, rangeEnd)
|
|
134
|
+
|
|
135
|
+
const eventsByDate = useMemo(() => {
|
|
136
|
+
const map: Record<string, CalendarEvent[]> = {}
|
|
137
|
+
for (const e of events) {
|
|
138
|
+
if (!map[e.date]) map[e.date] = []
|
|
139
|
+
map[e.date].push(e)
|
|
140
|
+
}
|
|
141
|
+
return map
|
|
142
|
+
}, [events])
|
|
143
|
+
|
|
144
|
+
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
145
|
+
e.preventDefault()
|
|
146
|
+
const form = new FormData(e.currentTarget)
|
|
147
|
+
await createEvent.mutateAsync({
|
|
148
|
+
title: form.get('title') as string,
|
|
149
|
+
date: (form.get('date') as string) || selectedDate || today,
|
|
150
|
+
time_start: (form.get('time_start') as string) || undefined,
|
|
151
|
+
time_end: (form.get('time_end') as string) || undefined,
|
|
152
|
+
category: (form.get('category') as CalendarCategory) || 'other',
|
|
153
|
+
location: (form.get('location') as string) || undefined,
|
|
154
|
+
description: (form.get('description') as string) || undefined,
|
|
155
|
+
all_day: !(form.get('time_start') as string),
|
|
156
|
+
created_by: 'user',
|
|
157
|
+
})
|
|
158
|
+
setOpen(false)
|
|
159
|
+
setSelectedDate(null)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const handleDelete = (id: string) => {
|
|
163
|
+
if (!confirm('이 일정을 삭제하시겠습니까?')) return
|
|
164
|
+
deleteEvent.mutate(id)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const handlePrev = () => viewMode === 'week' ? setWeekOffset(w => w - 1) : setMonthOffset(m => m - 1)
|
|
168
|
+
const handleNext = () => viewMode === 'week' ? setWeekOffset(w => w + 1) : setMonthOffset(m => m + 1)
|
|
169
|
+
const handleToday = () => { setWeekOffset(0); setMonthOffset(0) }
|
|
170
|
+
|
|
171
|
+
const navLabel = viewMode === 'week'
|
|
172
|
+
? `${weekDates[0].getMonth() + 1}/${weekDates[0].getDate()} — ${weekDates[6].getMonth() + 1}/${weekDates[6].getDate()}`
|
|
173
|
+
: `${monthDate.getFullYear()}년 ${monthDate.getMonth() + 1}월`
|
|
174
|
+
|
|
175
|
+
const showTodayBtn = viewMode === 'week' ? weekOffset !== 0 : monthOffset !== 0
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<div className="max-w-5xl mx-auto">
|
|
179
|
+
<div className="flex items-center justify-between mb-6">
|
|
180
|
+
<div>
|
|
181
|
+
<h1 className="text-2xl font-bold text-foreground">캘린더</h1>
|
|
182
|
+
<p className="text-sm text-muted-foreground mt-1">일정 관리</p>
|
|
183
|
+
</div>
|
|
184
|
+
<div className="flex items-center gap-2">
|
|
185
|
+
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'week' | 'month')}>
|
|
186
|
+
<TabsList>
|
|
187
|
+
<TabsTrigger value="week">주간</TabsTrigger>
|
|
188
|
+
<TabsTrigger value="month">월간</TabsTrigger>
|
|
189
|
+
</TabsList>
|
|
190
|
+
</Tabs>
|
|
191
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
192
|
+
<DialogTrigger asChild>
|
|
193
|
+
<Button className="bg-gatsaeng-amber hover:bg-gatsaeng-amber/80 text-black">
|
|
194
|
+
<Plus className="w-4 h-4 mr-2" /> 일정 추가
|
|
195
|
+
</Button>
|
|
196
|
+
</DialogTrigger>
|
|
197
|
+
<DialogContent className="max-w-lg">
|
|
198
|
+
<DialogHeader>
|
|
199
|
+
<DialogTitle>일정 추가</DialogTitle>
|
|
200
|
+
</DialogHeader>
|
|
201
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
202
|
+
<div>
|
|
203
|
+
<Label>제목</Label>
|
|
204
|
+
<Input name="title" required placeholder="일정 제목" />
|
|
205
|
+
</div>
|
|
206
|
+
<div className="grid grid-cols-2 gap-4">
|
|
207
|
+
<div>
|
|
208
|
+
<Label>날짜</Label>
|
|
209
|
+
<Input name="date" type="date" defaultValue={selectedDate || today} />
|
|
210
|
+
</div>
|
|
211
|
+
<div>
|
|
212
|
+
<Label>카테고리</Label>
|
|
213
|
+
<Select name="category" defaultValue="other">
|
|
214
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
215
|
+
<SelectContent>
|
|
216
|
+
{(Object.keys(CATEGORY_LABELS) as CalendarCategory[]).map(cat => (
|
|
217
|
+
<SelectItem key={cat} value={cat}>{CATEGORY_LABELS[cat]}</SelectItem>
|
|
218
|
+
))}
|
|
219
|
+
</SelectContent>
|
|
220
|
+
</Select>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
<div className="grid grid-cols-2 gap-4">
|
|
224
|
+
<div>
|
|
225
|
+
<Label>시작 시간</Label>
|
|
226
|
+
<Input name="time_start" type="time" />
|
|
227
|
+
</div>
|
|
228
|
+
<div>
|
|
229
|
+
<Label>종료 시간</Label>
|
|
230
|
+
<Input name="time_end" type="time" />
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
<div>
|
|
234
|
+
<Label>장소</Label>
|
|
235
|
+
<Input name="location" placeholder="장소 (선택)" />
|
|
236
|
+
</div>
|
|
237
|
+
<div>
|
|
238
|
+
<Label>메모</Label>
|
|
239
|
+
<Input name="description" placeholder="메모 (선택)" />
|
|
240
|
+
</div>
|
|
241
|
+
<Button type="submit" className="w-full bg-gatsaeng-amber hover:bg-gatsaeng-amber/80 text-black">
|
|
242
|
+
추가
|
|
243
|
+
</Button>
|
|
244
|
+
</form>
|
|
245
|
+
</DialogContent>
|
|
246
|
+
</Dialog>
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
{/* Navigation */}
|
|
251
|
+
<div className="flex items-center justify-between mb-4">
|
|
252
|
+
<Button variant="ghost" size="icon" onClick={handlePrev}>
|
|
253
|
+
<ChevronLeft className="w-4 h-4" />
|
|
254
|
+
</Button>
|
|
255
|
+
<div className="flex items-center gap-2">
|
|
256
|
+
<CalendarDays className="w-4 h-4 text-muted-foreground" />
|
|
257
|
+
<span className="text-sm font-medium">{navLabel}</span>
|
|
258
|
+
{showTodayBtn && (
|
|
259
|
+
<Button variant="ghost" size="sm" className="text-xs h-6" onClick={handleToday}>
|
|
260
|
+
오늘
|
|
261
|
+
</Button>
|
|
262
|
+
)}
|
|
263
|
+
</div>
|
|
264
|
+
<Button variant="ghost" size="icon" onClick={handleNext}>
|
|
265
|
+
<ChevronRight className="w-4 h-4" />
|
|
266
|
+
</Button>
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
{/* Calendar grid */}
|
|
270
|
+
{isLoading ? (
|
|
271
|
+
<div className="grid grid-cols-7 gap-2">
|
|
272
|
+
{Array.from({ length: viewMode === 'week' ? 7 : 35 }, (_, i) => (
|
|
273
|
+
<div key={i} className={`${viewMode === 'week' ? 'h-40' : 'h-24'} bg-card rounded-lg animate-pulse`} />
|
|
274
|
+
))}
|
|
275
|
+
</div>
|
|
276
|
+
) : viewMode === 'week' ? (
|
|
277
|
+
/* Weekly view */
|
|
278
|
+
<div className="grid grid-cols-7 gap-2">
|
|
279
|
+
{weekDates.map((date, i) => {
|
|
280
|
+
const dateStr = formatDate(date)
|
|
281
|
+
const dayEvents = eventsByDate[dateStr] || []
|
|
282
|
+
const isToday = dateStr === today
|
|
283
|
+
const isWeekend = i >= 5
|
|
284
|
+
|
|
285
|
+
return (
|
|
286
|
+
<div
|
|
287
|
+
key={dateStr}
|
|
288
|
+
className={`min-h-[160px] rounded-lg border p-2 transition-colors cursor-pointer hover:border-primary/50 ${
|
|
289
|
+
isToday ? 'border-gatsaeng-amber/50 bg-gatsaeng-amber/5' : 'border-border bg-card'
|
|
290
|
+
}`}
|
|
291
|
+
onClick={() => { setSelectedDate(dateStr); setOpen(true) }}
|
|
292
|
+
>
|
|
293
|
+
<div className="flex items-center justify-between mb-2">
|
|
294
|
+
<span className={`text-xs font-medium ${isWeekend ? 'text-gatsaeng-red' : 'text-muted-foreground'}`}>
|
|
295
|
+
{DAY_NAMES[date.getDay()]}
|
|
296
|
+
</span>
|
|
297
|
+
<span className={`text-sm font-bold ${isToday ? 'text-gatsaeng-amber' : 'text-foreground'}`}>
|
|
298
|
+
{date.getDate()}
|
|
299
|
+
</span>
|
|
300
|
+
</div>
|
|
301
|
+
<div className="space-y-1">
|
|
302
|
+
{dayEvents.map(event => (
|
|
303
|
+
<EventCard key={event.id} event={event} onDelete={handleDelete} />
|
|
304
|
+
))}
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
)
|
|
308
|
+
})}
|
|
309
|
+
</div>
|
|
310
|
+
) : (
|
|
311
|
+
/* Monthly view */
|
|
312
|
+
<>
|
|
313
|
+
<div className="grid grid-cols-7 gap-px mb-1">
|
|
314
|
+
{['월', '화', '수', '목', '금', '토', '일'].map(d => (
|
|
315
|
+
<div key={d} className={`text-center text-[10px] font-medium py-1 ${
|
|
316
|
+
d === '토' || d === '일' ? 'text-gatsaeng-red' : 'text-muted-foreground'
|
|
317
|
+
}`}>
|
|
318
|
+
{d}
|
|
319
|
+
</div>
|
|
320
|
+
))}
|
|
321
|
+
</div>
|
|
322
|
+
<div className="grid grid-cols-7 gap-1">
|
|
323
|
+
{monthGrid.map((date, i) => {
|
|
324
|
+
if (!date) return <div key={`empty-${i}`} className="min-h-[90px]" />
|
|
325
|
+
const dateStr = formatDate(date)
|
|
326
|
+
const dayEvents = eventsByDate[dateStr] || []
|
|
327
|
+
const isToday = dateStr === today
|
|
328
|
+
const dayOfWeek = (i % 7)
|
|
329
|
+
const isWeekend = dayOfWeek >= 5
|
|
330
|
+
|
|
331
|
+
return (
|
|
332
|
+
<div
|
|
333
|
+
key={dateStr}
|
|
334
|
+
className={`min-h-[90px] rounded-md border p-1 transition-colors cursor-pointer hover:border-primary/50 ${
|
|
335
|
+
isToday ? 'border-gatsaeng-amber/50 bg-gatsaeng-amber/5' : 'border-border/50 bg-card'
|
|
336
|
+
}`}
|
|
337
|
+
onClick={() => { setSelectedDate(dateStr); setOpen(true) }}
|
|
338
|
+
>
|
|
339
|
+
<div className={`text-xs font-bold mb-1 ${
|
|
340
|
+
isToday ? 'text-gatsaeng-amber' : isWeekend ? 'text-gatsaeng-red/70' : 'text-foreground'
|
|
341
|
+
}`}>
|
|
342
|
+
{date.getDate()}
|
|
343
|
+
</div>
|
|
344
|
+
<div className="space-y-0.5">
|
|
345
|
+
{dayEvents.slice(0, 3).map(event => (
|
|
346
|
+
<div
|
|
347
|
+
key={event.id}
|
|
348
|
+
className={`rounded px-1 py-0.5 text-[9px] leading-tight truncate border ${
|
|
349
|
+
CATEGORY_COLORS[event.category ?? 'other']
|
|
350
|
+
}`}
|
|
351
|
+
onClick={(e) => e.stopPropagation()}
|
|
352
|
+
title={`${event.title}${event.time_start ? ` ${event.time_start}` : ''}`}
|
|
353
|
+
>
|
|
354
|
+
{event.title}
|
|
355
|
+
</div>
|
|
356
|
+
))}
|
|
357
|
+
{dayEvents.length > 3 && (
|
|
358
|
+
<div className="text-[9px] text-muted-foreground pl-1">+{dayEvents.length - 3}</div>
|
|
359
|
+
)}
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
)
|
|
363
|
+
})}
|
|
364
|
+
</div>
|
|
365
|
+
</>
|
|
366
|
+
)}
|
|
367
|
+
|
|
368
|
+
{/* Legend */}
|
|
369
|
+
<div className="flex gap-3 mt-4 flex-wrap">
|
|
370
|
+
{(Object.keys(CATEGORY_LABELS) as CalendarCategory[]).map(cat => (
|
|
371
|
+
<div key={cat} className="flex items-center gap-1.5">
|
|
372
|
+
<div className={`w-2.5 h-2.5 rounded-sm ${CATEGORY_COLORS[cat].split(' ')[0]}`} />
|
|
373
|
+
<span className="text-[10px] text-muted-foreground">{CATEGORY_LABELS[cat]}</span>
|
|
374
|
+
</div>
|
|
375
|
+
))}
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
)
|
|
379
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
|
+
|
|
5
|
+
export default function DashboardError({
|
|
6
|
+
error,
|
|
7
|
+
reset,
|
|
8
|
+
}: {
|
|
9
|
+
error: Error & { digest?: string }
|
|
10
|
+
reset: () => void
|
|
11
|
+
}) {
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
console.error('[DashboardError]', error)
|
|
14
|
+
}, [error])
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className="flex flex-col items-center justify-center h-full gap-4 p-8">
|
|
18
|
+
<h2 className="text-lg font-bold text-red-500">Dashboard Error</h2>
|
|
19
|
+
<p className="text-xs text-red-400 bg-red-950/30 p-4 rounded-lg max-w-lg overflow-auto">
|
|
20
|
+
{error.message}
|
|
21
|
+
</p>
|
|
22
|
+
<button
|
|
23
|
+
onClick={reset}
|
|
24
|
+
className="px-4 py-2 rounded bg-primary text-primary-foreground text-sm"
|
|
25
|
+
>
|
|
26
|
+
다시 시도
|
|
27
|
+
</button>
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|