create-spine 1.0.2 → 1.0.4
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/final-test/AGENTS.md +5 -0
- package/final-test/CLAUDE.md +1 -0
- package/final-test/README.md +37 -0
- package/final-test/app/Refund/page.tsx +340 -0
- package/final-test/app/api/auth/[...nextauth]/route.ts +6 -0
- package/final-test/app/api/generate/route.ts +57 -0
- package/final-test/app/dashboard/page.tsx +713 -0
- package/final-test/app/faq/page.tsx +343 -0
- package/final-test/app/globals.css +130 -0
- package/final-test/app/layout.tsx +33 -0
- package/final-test/app/page.tsx +650 -0
- package/final-test/app/pricing/page.tsx +23 -0
- package/final-test/app/privacy/page.tsx +328 -0
- package/final-test/app/terms/page.tsx +12 -0
- package/final-test/components/ui/badge.tsx +52 -0
- package/final-test/components/ui/button.tsx +58 -0
- package/final-test/components/ui/card.tsx +103 -0
- package/final-test/components/ui/separator.tsx +25 -0
- package/final-test/components/ui/textarea.tsx +18 -0
- package/final-test/components/ui/ui/footer/page.tsx +103 -0
- package/final-test/components/ui/ui/navbar/navbar.tsx +0 -0
- package/final-test/components.json +25 -0
- package/final-test/desktop.ini +6 -0
- package/final-test/eslint.config.mjs +18 -0
- package/final-test/lib/auth.ts +19 -0
- package/final-test/lib/prisma.ts +7 -0
- package/final-test/lib/utils.ts +6 -0
- package/final-test/next.config.ts +7 -0
- package/final-test/package-lock.json +9794 -0
- package/final-test/package.json +35 -0
- package/final-test/postcss.config.mjs +7 -0
- package/final-test/public/X.svg +1 -0
- package/final-test/public/github.svg +2 -0
- package/final-test/public/google.svg +2 -0
- package/final-test/src/components/ui/badge.tsx +58 -0
- package/final-test/src/components/ui/button.tsx +90 -0
- package/final-test/src/components/ui/card.tsx +103 -0
- package/final-test/src/components/ui/textarea.tsx +29 -0
- package/final-test/src/lib/utils.ts +6 -0
- package/final-test/tsconfig.json +34 -0
- package/index.js +3 -6
- package/package.json +1 -1
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Spine — Dashboard
|
|
5
|
+
* Self-contained demo with realistic mock data wired to Spine's actual product
|
|
6
|
+
* mechanics (daily AI-generation quota, stacks, scaffolds, files).
|
|
7
|
+
*
|
|
8
|
+
* To connect real data:
|
|
9
|
+
* - Replace STATS / RECENT_PROJECTS / USAGE_DATA / NOTIFICATIONS with API/DB reads
|
|
10
|
+
* - Replace "Alex Chen" with session.user.name (NextAuth) in Sidebar + Topbar
|
|
11
|
+
* - Wire "Generate new scaffold" to the same /api/generate flow as the landing page
|
|
12
|
+
* - Wire each row's Download button to the real scaffold ZIP endpoint
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useState, useEffect, useMemo } from "react"
|
|
16
|
+
import {
|
|
17
|
+
Zap, Package, FileCode, FolderOpen, LayoutDashboard, FolderGit2, Layers,
|
|
18
|
+
KeyRound, CreditCard, Settings, Bell, Search, Menu, X, ChevronDown, ChevronRight,
|
|
19
|
+
LogOut, User, TrendingUp, Plus, Download, Copy, Check, Circle, Terminal,
|
|
20
|
+
} from "lucide-react"
|
|
21
|
+
import { Button } from "@/components/ui/button"
|
|
22
|
+
import { Badge } from "@/components/ui/badge"
|
|
23
|
+
import { Card, CardContent } from "@/components/ui/card"
|
|
24
|
+
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
|
25
|
+
import { Separator } from "@/components/ui/separator"
|
|
26
|
+
import { Input } from "@/components/ui/input"
|
|
27
|
+
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
|
|
28
|
+
|
|
29
|
+
/* Tiny className joiner so this file doesn't depend on "@/lib/utils" being present */
|
|
30
|
+
const cx = (...c) => c.filter(Boolean).join(" ")
|
|
31
|
+
|
|
32
|
+
/* ════════════════════════════════════════════════════════════════
|
|
33
|
+
MOCK DATA
|
|
34
|
+
════════════════════════════════════════════════════════════════ */
|
|
35
|
+
const NAV_ITEMS = [
|
|
36
|
+
{ id: "overview", label: "Overview", icon: LayoutDashboard },
|
|
37
|
+
{ id: "projects", label: "Projects", icon: FolderGit2 },
|
|
38
|
+
{ id: "templates", label: "Templates", icon: Layers },
|
|
39
|
+
{ id: "api-keys", label: "API Keys", icon: KeyRound },
|
|
40
|
+
{ id: "billing", label: "Billing", icon: CreditCard },
|
|
41
|
+
{ id: "settings", label: "Settings", icon: Settings },
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
const STACK_META = {
|
|
45
|
+
nextjs: { label: "Next.js Fullstack", text: "text-blue-400", bg: "bg-blue-950", border: "border-blue-800" },
|
|
46
|
+
mern: { label: "MERN Stack", text: "text-violet-400", bg: "bg-violet-950", border: "border-violet-800" },
|
|
47
|
+
micro: { label: "Microservice", text: "text-amber-400", bg: "bg-amber-950", border: "border-amber-800" },
|
|
48
|
+
hono: { label: "Hono Edge API", text: "text-orange-400", bg: "bg-orange-950", border: "border-orange-800" },
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const STATS = {
|
|
52
|
+
generationsToday: 4,
|
|
53
|
+
dailyLimit: 5,
|
|
54
|
+
scaffoldsTotal: 128,
|
|
55
|
+
scaffoldsDelta: 12,
|
|
56
|
+
filesTotal: 1024,
|
|
57
|
+
filesDelta: 86,
|
|
58
|
+
activeProjects: 18,
|
|
59
|
+
stacksInUse: 4,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const RECENT_PROJECTS = [
|
|
63
|
+
{ id: 1, name: "task-manager-api", stack: "nextjs", files: 11, created: "2 hours ago" },
|
|
64
|
+
{ id: 2, name: "blog-cms-backend", stack: "mern", files: 9, created: "Yesterday" },
|
|
65
|
+
{ id: 3, name: "shop-checkout-service", stack: "hono", files: 8, created: "3 days ago" },
|
|
66
|
+
{ id: 4, name: "internal-tools-api", stack: "micro", files: 6, created: "5 days ago" },
|
|
67
|
+
{ id: 5, name: "saas-billing-api", stack: "nextjs", files: 13, created: "1 week ago" },
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
const QUICKSTART = [
|
|
71
|
+
{ id: 1, label: "Install the Spine CLI", done: true },
|
|
72
|
+
{ id: 2, label: "Generate your first scaffold", done: true },
|
|
73
|
+
{ id: 3, label: "Connect a GitHub repo", done: false },
|
|
74
|
+
{ id: 4, label: "Upgrade to Pro for unlimited runs", done: false },
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
const NOTIFICATIONS = [
|
|
78
|
+
{ id: 1, title: "Your task-manager-api scaffold finished generating.", time: "2 hours ago", unread: true },
|
|
79
|
+
{ id: 2, title: "New template available: Hono Edge API v2.", time: "1 day ago", unread: true },
|
|
80
|
+
{ id: 3, title: "Your weekly usage summary is ready.", time: "3 days ago", unread: false },
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
const RAW_USAGE = [2,1,3,2,4,3,2,3,4,2,5,3,4,3,4,5,3,6,4,5,4,5,6,4,7,5,6,5,7,6]
|
|
84
|
+
const USAGE_DATA = RAW_USAGE.map((value, i) => {
|
|
85
|
+
const d = new Date()
|
|
86
|
+
d.setDate(d.getDate() - (RAW_USAGE.length - 1 - i))
|
|
87
|
+
return { label: d.toLocaleDateString("en-US", { month: "short", day: "numeric" }), value }
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
/* ════════════════════════════════════════════════════════════════
|
|
91
|
+
HOOKS
|
|
92
|
+
════════════════════════════════════════════════════════════════ */
|
|
93
|
+
function useCountUp(target, duration = 900) {
|
|
94
|
+
const [value, setValue] = useState(0)
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
let raf
|
|
97
|
+
const start = performance.now()
|
|
98
|
+
function tick(now) {
|
|
99
|
+
const progress = Math.min((now - start) / duration, 1)
|
|
100
|
+
const eased = 1 - Math.pow(1 - progress, 3)
|
|
101
|
+
setValue(Math.round(target * eased))
|
|
102
|
+
if (progress < 1) raf = requestAnimationFrame(tick)
|
|
103
|
+
}
|
|
104
|
+
raf = requestAnimationFrame(tick)
|
|
105
|
+
return () => cancelAnimationFrame(raf)
|
|
106
|
+
}, [target, duration])
|
|
107
|
+
return value
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* ════════════════════════════════════════════════════════════════
|
|
111
|
+
SMALL PIECES
|
|
112
|
+
════════════════════════════════════════════════════════════════ */
|
|
113
|
+
function Logo() {
|
|
114
|
+
return (
|
|
115
|
+
<div className="flex items-center gap-2.5">
|
|
116
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
|
117
|
+
<line x1="12" y1="1" x2="12" y2="23" stroke="#34d399" strokeWidth="2" strokeLinecap="round" />
|
|
118
|
+
<line x1="12" y1="5" x2="18" y2="3" stroke="#34d399" strokeWidth="1.4" strokeLinecap="round" />
|
|
119
|
+
<line x1="12" y1="5" x2="6" y2="3" stroke="#34d399" strokeWidth="1.4" strokeLinecap="round" opacity="0.45" />
|
|
120
|
+
<line x1="12" y1="11" x2="18" y2="9" stroke="#34d399" strokeWidth="1.4" strokeLinecap="round" />
|
|
121
|
+
<line x1="12" y1="11" x2="6" y2="9" stroke="#34d399" strokeWidth="1.4" strokeLinecap="round" opacity="0.45" />
|
|
122
|
+
<line x1="12" y1="17" x2="18" y2="15" stroke="#34d399" strokeWidth="1.4" strokeLinecap="round" />
|
|
123
|
+
<line x1="12" y1="17" x2="6" y2="15" stroke="#34d399" strokeWidth="1.4" strokeLinecap="round" opacity="0.45" />
|
|
124
|
+
</svg>
|
|
125
|
+
<span className="font-bold text-base tracking-tight text-zinc-50">Spine</span>
|
|
126
|
+
</div>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function StackBadge({ stackId }) {
|
|
131
|
+
const meta = STACK_META[stackId]
|
|
132
|
+
if (!meta) return null
|
|
133
|
+
return (
|
|
134
|
+
<Badge variant="outline" className={cx("font-mono text-xs whitespace-nowrap", meta.bg, meta.border, meta.text)}>
|
|
135
|
+
{meta.label}
|
|
136
|
+
</Badge>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function UsageTooltip({ active, payload, label }) {
|
|
141
|
+
if (!active || !payload || !payload.length) return null
|
|
142
|
+
return (
|
|
143
|
+
<div className="rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2 shadow-2xl">
|
|
144
|
+
<p className="text-xs text-zinc-500 font-mono mb-0.5">{label}</p>
|
|
145
|
+
<p className="text-sm font-semibold text-emerald-400">{payload[0].value} scaffolds</p>
|
|
146
|
+
</div>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* ════════════════════════════════════════════════════════════════
|
|
151
|
+
SIDEBAR
|
|
152
|
+
════════════════════════════════════════════════════════════════ */
|
|
153
|
+
function SidebarContent({ activeNav, onNavigate, onClose }) {
|
|
154
|
+
return (
|
|
155
|
+
<div className="flex flex-col h-full">
|
|
156
|
+
<div className="h-14 flex items-center justify-between px-5 border-b border-zinc-800 shrink-0">
|
|
157
|
+
<Logo />
|
|
158
|
+
{onClose && (
|
|
159
|
+
<button onClick={onClose} className="text-zinc-500 hover:text-zinc-200 transition-colors lg:hidden" aria-label="Close menu">
|
|
160
|
+
<X className="w-4 h-4" />
|
|
161
|
+
</button>
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<nav className="flex-1 overflow-y-auto py-4 px-3">
|
|
166
|
+
<p className="px-2.5 text-xs font-semibold text-zinc-600 tracking-widest uppercase mb-2">Workspace</p>
|
|
167
|
+
<div className="flex flex-col gap-0.5">
|
|
168
|
+
{NAV_ITEMS.map((item) => {
|
|
169
|
+
const active = activeNav === item.id
|
|
170
|
+
const Icon = item.icon
|
|
171
|
+
return (
|
|
172
|
+
<button
|
|
173
|
+
key={item.id}
|
|
174
|
+
onClick={() => onNavigate(item.id)}
|
|
175
|
+
className={cx(
|
|
176
|
+
"flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm transition-colors text-left border",
|
|
177
|
+
active
|
|
178
|
+
? "bg-emerald-950 text-emerald-400 border-emerald-800"
|
|
179
|
+
: "text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800/60 border-transparent"
|
|
180
|
+
)}
|
|
181
|
+
>
|
|
182
|
+
<Icon className="w-4 h-4 shrink-0" />
|
|
183
|
+
{item.label}
|
|
184
|
+
</button>
|
|
185
|
+
)
|
|
186
|
+
})}
|
|
187
|
+
</div>
|
|
188
|
+
</nav>
|
|
189
|
+
|
|
190
|
+
<div className="p-3 border-t border-zinc-800 shrink-0">
|
|
191
|
+
<div className="flex items-center gap-2 px-2 py-2 mb-2">
|
|
192
|
+
<span className="relative flex h-2 w-2 shrink-0">
|
|
193
|
+
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-60" />
|
|
194
|
+
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-400" />
|
|
195
|
+
</span>
|
|
196
|
+
<span className="text-xs text-zinc-500">All systems operational</span>
|
|
197
|
+
</div>
|
|
198
|
+
<Separator className="bg-zinc-800 mb-2" />
|
|
199
|
+
<div className="flex items-center gap-2.5 px-2 py-1.5">
|
|
200
|
+
<Avatar className="h-8 w-8 border border-zinc-700 shrink-0">
|
|
201
|
+
<AvatarFallback className="bg-emerald-950 text-emerald-400 text-xs font-semibold">AC</AvatarFallback>
|
|
202
|
+
</Avatar>
|
|
203
|
+
<div className="min-w-0 flex-1">
|
|
204
|
+
<p className="text-xs font-medium text-zinc-200 truncate">Alex Chen</p>
|
|
205
|
+
<p className="text-xs text-zinc-600 truncate">Free plan</p>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* ════════════════════════════════════════════════════════════════
|
|
214
|
+
TOPBAR
|
|
215
|
+
════════════════════════════════════════════════════════════════ */
|
|
216
|
+
function Topbar({ onMenuClick, notifOpen, setNotifOpen, userMenuOpen, setUserMenuOpen }) {
|
|
217
|
+
return (
|
|
218
|
+
<header className="sticky top-0 z-30 h-14 flex items-center gap-2 sm:gap-3 px-4 md:px-6 bg-black/80 backdrop-blur-md border-b border-zinc-800 shrink-0">
|
|
219
|
+
<button onClick={onMenuClick} className="text-zinc-400 hover:text-zinc-100 transition-colors lg:hidden shrink-0" aria-label="Open menu">
|
|
220
|
+
<Menu className="w-5 h-5" />
|
|
221
|
+
</button>
|
|
222
|
+
|
|
223
|
+
<h1 className="text-sm font-semibold text-zinc-100">Overview</h1>
|
|
224
|
+
|
|
225
|
+
<div className="flex-1" />
|
|
226
|
+
|
|
227
|
+
<div className="relative hidden sm:block">
|
|
228
|
+
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-500 pointer-events-none" />
|
|
229
|
+
<Input
|
|
230
|
+
placeholder="Search projects..."
|
|
231
|
+
className="h-9 w-40 lg:w-64 pl-8 bg-zinc-900 border-zinc-800 text-sm placeholder:text-zinc-600 focus-visible:ring-emerald-600"
|
|
232
|
+
/>
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
<Button size="sm" className="hidden sm:inline-flex bg-emerald-500 hover:bg-emerald-400 text-black font-semibold text-xs h-8 gap-1.5">
|
|
236
|
+
<Plus className="w-3.5 h-3.5" /> New scaffold
|
|
237
|
+
</Button>
|
|
238
|
+
|
|
239
|
+
<div className="relative">
|
|
240
|
+
<button
|
|
241
|
+
onClick={() => { setNotifOpen((v) => !v); setUserMenuOpen(false) }}
|
|
242
|
+
className="relative text-zinc-400 hover:text-zinc-100 transition-colors w-8 h-8 flex items-center justify-center rounded-lg hover:bg-zinc-800/60"
|
|
243
|
+
aria-label="Notifications"
|
|
244
|
+
>
|
|
245
|
+
<Bell className="w-4 h-4" />
|
|
246
|
+
<span className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
|
247
|
+
</button>
|
|
248
|
+
{notifOpen && (
|
|
249
|
+
<>
|
|
250
|
+
<div className="fixed inset-0 z-40" onClick={() => setNotifOpen(false)} />
|
|
251
|
+
<div className="absolute right-0 mt-2 w-72 bg-zinc-900 border border-zinc-800 rounded-lg shadow-2xl z-50 overflow-hidden">
|
|
252
|
+
<div className="px-4 py-3 border-b border-zinc-800">
|
|
253
|
+
<p className="text-xs font-semibold text-zinc-300">Notifications</p>
|
|
254
|
+
</div>
|
|
255
|
+
<div className="max-h-64 overflow-y-auto">
|
|
256
|
+
{NOTIFICATIONS.map((n) => (
|
|
257
|
+
<div key={n.id} className="px-4 py-3 border-b border-zinc-800 last:border-0 hover:bg-zinc-800/40 transition-colors">
|
|
258
|
+
<div className="flex items-start gap-2">
|
|
259
|
+
{n.unread && <span className="w-1.5 h-1.5 rounded-full bg-emerald-400 mt-1.5 shrink-0" />}
|
|
260
|
+
<div className={cx(!n.unread && "pl-3.5")}>
|
|
261
|
+
<p className="text-xs text-zinc-300 leading-relaxed">{n.title}</p>
|
|
262
|
+
<p className="text-xs text-zinc-600 mt-0.5">{n.time}</p>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
))}
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
</>
|
|
270
|
+
)}
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
<div className="relative">
|
|
274
|
+
<button onClick={() => { setUserMenuOpen((v) => !v); setNotifOpen(false) }} className="flex items-center gap-1.5" aria-label="Account menu">
|
|
275
|
+
<Avatar className="h-8 w-8 border border-zinc-700">
|
|
276
|
+
<AvatarFallback className="bg-emerald-950 text-emerald-400 text-xs font-semibold">AC</AvatarFallback>
|
|
277
|
+
</Avatar>
|
|
278
|
+
<ChevronDown className="w-3.5 h-3.5 text-zinc-500 hidden sm:block" />
|
|
279
|
+
</button>
|
|
280
|
+
{userMenuOpen && (
|
|
281
|
+
<>
|
|
282
|
+
<div className="fixed inset-0 z-40" onClick={() => setUserMenuOpen(false)} />
|
|
283
|
+
<div className="absolute right-0 mt-2 w-52 bg-zinc-900 border border-zinc-800 rounded-lg shadow-2xl z-50 overflow-hidden py-1.5">
|
|
284
|
+
<div className="px-3.5 py-2 mb-1 border-b border-zinc-800">
|
|
285
|
+
<p className="text-xs font-medium text-zinc-200">Alex Chen</p>
|
|
286
|
+
<p className="text-xs text-zinc-600">alex@example.com</p>
|
|
287
|
+
</div>
|
|
288
|
+
{[{ icon: User, label: "Profile" }, { icon: Settings, label: "Settings" }, { icon: CreditCard, label: "Billing" }].map((item) => {
|
|
289
|
+
const ItemIcon = item.icon
|
|
290
|
+
return (
|
|
291
|
+
<button
|
|
292
|
+
key={item.label}
|
|
293
|
+
className="w-full flex items-center gap-2.5 px-3.5 py-2 text-xs text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800/60 transition-colors text-left"
|
|
294
|
+
>
|
|
295
|
+
<ItemIcon className="w-3.5 h-3.5" /> {item.label}
|
|
296
|
+
</button>
|
|
297
|
+
)
|
|
298
|
+
})}
|
|
299
|
+
<Separator className="bg-zinc-800 my-1.5" />
|
|
300
|
+
<button className="w-full flex items-center gap-2.5 px-3.5 py-2 text-xs text-red-400 hover:bg-red-950/40 transition-colors text-left">
|
|
301
|
+
<LogOut className="w-3.5 h-3.5" /> Log out
|
|
302
|
+
</button>
|
|
303
|
+
</div>
|
|
304
|
+
</>
|
|
305
|
+
)}
|
|
306
|
+
</div>
|
|
307
|
+
</header>
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/* ════════════════════════════════════════════════════════════════
|
|
312
|
+
STAT CARD
|
|
313
|
+
════════════════════════════════════════════════════════════════ */
|
|
314
|
+
function StatCard({ icon: Icon, label, value, tag, tone = "positive" }) {
|
|
315
|
+
const count = useCountUp(value)
|
|
316
|
+
return (
|
|
317
|
+
<Card className="bg-zinc-900 border-zinc-800 hover:border-zinc-700 transition-colors">
|
|
318
|
+
<CardContent className="p-5">
|
|
319
|
+
<div className="flex items-center justify-between mb-4">
|
|
320
|
+
<div className="w-9 h-9 rounded-lg bg-emerald-950 border border-emerald-800 flex items-center justify-center">
|
|
321
|
+
<Icon className="w-4 h-4 text-emerald-400" />
|
|
322
|
+
</div>
|
|
323
|
+
{tag && (
|
|
324
|
+
<span
|
|
325
|
+
className={cx(
|
|
326
|
+
"inline-flex items-center gap-1 text-xs font-medium px-1.5 py-0.5 rounded-md font-mono",
|
|
327
|
+
tone === "positive" ? "text-emerald-400 bg-emerald-950/60" : "text-zinc-500 bg-zinc-800/60"
|
|
328
|
+
)}
|
|
329
|
+
>
|
|
330
|
+
{tone === "positive" && <TrendingUp className="w-3 h-3" />}
|
|
331
|
+
{tag}
|
|
332
|
+
</span>
|
|
333
|
+
)}
|
|
334
|
+
</div>
|
|
335
|
+
<p className="text-2xl font-extrabold tracking-tight tabular-nums">{count.toLocaleString()}</p>
|
|
336
|
+
<p className="text-xs text-zinc-500 mt-1">{label}</p>
|
|
337
|
+
</CardContent>
|
|
338
|
+
</Card>
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/* ════════════════════════════════════════════════════════════════
|
|
343
|
+
USAGE CARD — signature terminal-chrome panel (mirrors the landing
|
|
344
|
+
page's generator output window)
|
|
345
|
+
════════════════════════════════════════════════════════════════ */
|
|
346
|
+
function UsageCard({ period, setPeriod, data }) {
|
|
347
|
+
const total = data.reduce((a, b) => a + b.value, 0)
|
|
348
|
+
const avg = (total / data.length).toFixed(1)
|
|
349
|
+
const peak = Math.max(...data.map((d) => d.value))
|
|
350
|
+
|
|
351
|
+
return (
|
|
352
|
+
<Card className="bg-zinc-950 border-zinc-800 overflow-hidden h-full">
|
|
353
|
+
<div className="flex items-center justify-between px-4 py-2.5 border-b border-zinc-800 bg-zinc-900">
|
|
354
|
+
<div className="flex items-center gap-2">
|
|
355
|
+
<span className="w-2.5 h-2.5 rounded-full bg-red-500/70" />
|
|
356
|
+
<span className="w-2.5 h-2.5 rounded-full bg-yellow-500/70" />
|
|
357
|
+
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500/70" />
|
|
358
|
+
<span className="text-xs text-zinc-500 font-mono ml-2">usage — scaffolds/day</span>
|
|
359
|
+
</div>
|
|
360
|
+
<div className="flex items-center gap-1 bg-zinc-950 border border-zinc-800 rounded-md p-0.5">
|
|
361
|
+
{["7", "14", "30"].map((p) => (
|
|
362
|
+
<button
|
|
363
|
+
key={p}
|
|
364
|
+
onClick={() => setPeriod(p)}
|
|
365
|
+
className={cx(
|
|
366
|
+
"px-2 py-1 rounded text-xs font-mono transition-colors",
|
|
367
|
+
period === p ? "bg-zinc-800 text-emerald-400" : "text-zinc-500 hover:text-zinc-300"
|
|
368
|
+
)}
|
|
369
|
+
>
|
|
370
|
+
{p}D
|
|
371
|
+
</button>
|
|
372
|
+
))}
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
<CardContent className="p-5 pt-6">
|
|
376
|
+
<div style={{ width: "100%", height: 220 }}>
|
|
377
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
378
|
+
<AreaChart data={data} margin={{ top: 8, right: 8, left: -20, bottom: 0 }}>
|
|
379
|
+
<defs>
|
|
380
|
+
<linearGradient id="usageFill" x1="0" y1="0" x2="0" y2="1">
|
|
381
|
+
<stop offset="0%" stopColor="#34d399" stopOpacity={0.35} />
|
|
382
|
+
<stop offset="100%" stopColor="#34d399" stopOpacity={0} />
|
|
383
|
+
</linearGradient>
|
|
384
|
+
</defs>
|
|
385
|
+
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" vertical={false} />
|
|
386
|
+
<XAxis dataKey="label" stroke="#52525b" fontSize={10} tickLine={false} axisLine={false} interval="preserveStartEnd" />
|
|
387
|
+
<YAxis stroke="#52525b" fontSize={10} tickLine={false} axisLine={false} width={24} allowDecimals={false} />
|
|
388
|
+
<Tooltip content={<UsageTooltip />} cursor={{ stroke: "#3f3f46", strokeWidth: 1 }} />
|
|
389
|
+
<Area type="monotone" dataKey="value" stroke="#34d399" strokeWidth={2} fill="url(#usageFill)" />
|
|
390
|
+
</AreaChart>
|
|
391
|
+
</ResponsiveContainer>
|
|
392
|
+
</div>
|
|
393
|
+
<div className="flex items-center gap-5 mt-2 pt-4 border-t border-zinc-900">
|
|
394
|
+
<div>
|
|
395
|
+
<p className="text-xs text-zinc-600">Total in range</p>
|
|
396
|
+
<p className="text-sm font-mono font-semibold text-zinc-200">{total}</p>
|
|
397
|
+
</div>
|
|
398
|
+
<div>
|
|
399
|
+
<p className="text-xs text-zinc-600">Daily average</p>
|
|
400
|
+
<p className="text-sm font-mono font-semibold text-zinc-200">{avg}</p>
|
|
401
|
+
</div>
|
|
402
|
+
<div>
|
|
403
|
+
<p className="text-xs text-zinc-600">Peak day</p>
|
|
404
|
+
<p className="text-sm font-mono font-semibold text-emerald-400">{peak}</p>
|
|
405
|
+
</div>
|
|
406
|
+
</div>
|
|
407
|
+
</CardContent>
|
|
408
|
+
</Card>
|
|
409
|
+
)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/* ════════════════════════════════════════════════════════════════
|
|
413
|
+
PLAN CARD
|
|
414
|
+
════════════════════════════════════════════════════════════════ */
|
|
415
|
+
function PlanCard({ used, limit }) {
|
|
416
|
+
const pct = Math.min(100, Math.round((used / limit) * 100))
|
|
417
|
+
const nearLimit = pct >= 80
|
|
418
|
+
const left = Math.max(0, limit - used)
|
|
419
|
+
|
|
420
|
+
return (
|
|
421
|
+
<Card className="bg-zinc-900 border-zinc-800 h-full">
|
|
422
|
+
<CardContent className="p-6 flex flex-col h-full">
|
|
423
|
+
<div className="flex items-center justify-between mb-1">
|
|
424
|
+
<p className="text-xs font-semibold text-zinc-500 tracking-widest uppercase">Current plan</p>
|
|
425
|
+
<Badge variant="outline" className="bg-zinc-950 border-zinc-700 text-zinc-400 text-xs">Free</Badge>
|
|
426
|
+
</div>
|
|
427
|
+
<p className="text-lg font-bold tracking-tight mb-5">Free Forever</p>
|
|
428
|
+
|
|
429
|
+
<div className="mb-1.5 flex items-baseline justify-between">
|
|
430
|
+
<span className="text-xs text-zinc-500">Daily generations</span>
|
|
431
|
+
<span className={cx("text-xs font-mono", nearLimit ? "text-amber-400" : "text-zinc-400")}>{used} / {limit}</span>
|
|
432
|
+
</div>
|
|
433
|
+
<div className="h-1.5 w-full rounded-full bg-zinc-800 overflow-hidden mb-1.5">
|
|
434
|
+
<div
|
|
435
|
+
className={cx("h-full rounded-full transition-all duration-500", nearLimit ? "bg-amber-400" : "bg-emerald-400")}
|
|
436
|
+
style={{ width: `${pct}%` }}
|
|
437
|
+
/>
|
|
438
|
+
</div>
|
|
439
|
+
<p className="text-xs text-zinc-600 mb-6">{left} left today · resets at midnight UTC</p>
|
|
440
|
+
|
|
441
|
+
<div className="flex flex-col gap-2 mb-6 flex-1">
|
|
442
|
+
{["Unlimited AI generations", "Private saved templates", "Custom stack presets", "Team sharing"].map((f) => (
|
|
443
|
+
<div key={f} className="flex items-center gap-2">
|
|
444
|
+
<Check className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
|
445
|
+
<span className="text-xs text-zinc-400">{f}</span>
|
|
446
|
+
</div>
|
|
447
|
+
))}
|
|
448
|
+
</div>
|
|
449
|
+
|
|
450
|
+
<Button className="w-full bg-emerald-500 hover:bg-emerald-400 text-black font-semibold">Upgrade to Pro — $9/mo</Button>
|
|
451
|
+
</CardContent>
|
|
452
|
+
</Card>
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/* ════════════════════════════════════════════════════════════════
|
|
457
|
+
PROJECTS TABLE
|
|
458
|
+
════════════════════════════════════════════════════════════════ */
|
|
459
|
+
function ProjectsTable({ projects }) {
|
|
460
|
+
return (
|
|
461
|
+
<Card className="bg-zinc-900 border-zinc-800 h-full">
|
|
462
|
+
<CardContent className="p-6">
|
|
463
|
+
<div className="flex items-center justify-between mb-5">
|
|
464
|
+
<div>
|
|
465
|
+
<p className="text-xs text-zinc-500 tracking-widest uppercase mb-1">Recent activity</p>
|
|
466
|
+
<h3 className="text-base font-bold tracking-tight">Your projects</h3>
|
|
467
|
+
</div>
|
|
468
|
+
<button className="text-xs text-zinc-500 hover:text-zinc-200 transition-colors inline-flex items-center gap-1">
|
|
469
|
+
View all <ChevronRight className="w-3.5 h-3.5" />
|
|
470
|
+
</button>
|
|
471
|
+
</div>
|
|
472
|
+
|
|
473
|
+
{projects.length === 0 ? (
|
|
474
|
+
<div className="flex flex-col items-center justify-center text-center py-16 px-6 border border-dashed border-zinc-800 rounded-lg">
|
|
475
|
+
<div className="w-10 h-10 rounded-lg bg-emerald-950 border border-emerald-800 flex items-center justify-center mb-3">
|
|
476
|
+
<Terminal className="w-4 h-4 text-emerald-400" />
|
|
477
|
+
</div>
|
|
478
|
+
<p className="text-sm text-zinc-400 mb-1">No scaffolds yet</p>
|
|
479
|
+
<p className="text-xs text-zinc-600 max-w-xs">
|
|
480
|
+
Run <span className="text-emerald-400 font-mono">npx create-spine@latest</span> or generate one from this dashboard.
|
|
481
|
+
</p>
|
|
482
|
+
</div>
|
|
483
|
+
) : (
|
|
484
|
+
<div className="overflow-x-auto">
|
|
485
|
+
<table className="w-full text-sm">
|
|
486
|
+
<thead>
|
|
487
|
+
<tr className="border-b border-zinc-800 text-left">
|
|
488
|
+
<th className="px-2 pb-2.5 font-medium text-zinc-500 text-xs whitespace-nowrap">Project</th>
|
|
489
|
+
<th className="px-2 pb-2.5 font-medium text-zinc-500 text-xs whitespace-nowrap">Stack</th>
|
|
490
|
+
<th className="px-2 pb-2.5 font-medium text-zinc-500 text-xs text-right whitespace-nowrap">Files</th>
|
|
491
|
+
<th className="px-2 pb-2.5 font-medium text-zinc-500 text-xs whitespace-nowrap">Created</th>
|
|
492
|
+
<th className="px-2 pb-2.5 font-medium text-zinc-500 text-xs text-right whitespace-nowrap">Download</th>
|
|
493
|
+
</tr>
|
|
494
|
+
</thead>
|
|
495
|
+
<tbody>
|
|
496
|
+
{projects.map((p) => (
|
|
497
|
+
<tr key={p.id} className="border-b border-zinc-900 last:border-0 hover:bg-zinc-800/30 transition-colors">
|
|
498
|
+
<td className="px-2 py-3">
|
|
499
|
+
<div className="flex items-center gap-2.5">
|
|
500
|
+
<div className="w-7 h-7 rounded-md bg-zinc-800 flex items-center justify-center shrink-0">
|
|
501
|
+
<FolderGit2 className="w-3.5 h-3.5 text-zinc-400" />
|
|
502
|
+
</div>
|
|
503
|
+
<span className="font-medium text-zinc-200 whitespace-nowrap">{p.name}</span>
|
|
504
|
+
</div>
|
|
505
|
+
</td>
|
|
506
|
+
<td className="px-2 py-3">
|
|
507
|
+
<StackBadge stackId={p.stack} />
|
|
508
|
+
</td>
|
|
509
|
+
<td className="px-2 py-3 text-right font-mono text-zinc-400 text-xs">{p.files}</td>
|
|
510
|
+
<td className="px-2 py-3 text-zinc-500 text-xs whitespace-nowrap">{p.created}</td>
|
|
511
|
+
<td className="px-2 py-3 text-right">
|
|
512
|
+
<button
|
|
513
|
+
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-zinc-500 hover:text-emerald-400 hover:bg-emerald-950/50 transition-colors"
|
|
514
|
+
title="Download ZIP"
|
|
515
|
+
>
|
|
516
|
+
<Download className="w-3.5 h-3.5" />
|
|
517
|
+
</button>
|
|
518
|
+
</td>
|
|
519
|
+
</tr>
|
|
520
|
+
))}
|
|
521
|
+
</tbody>
|
|
522
|
+
</table>
|
|
523
|
+
</div>
|
|
524
|
+
)}
|
|
525
|
+
</CardContent>
|
|
526
|
+
</Card>
|
|
527
|
+
)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/* ════════════════════════════════════════════════════════════════
|
|
531
|
+
QUICK START
|
|
532
|
+
════════════════════════════════════════════════════════════════ */
|
|
533
|
+
function QuickStartCard({ items }) {
|
|
534
|
+
const [copied, setCopied] = useState(false)
|
|
535
|
+
const doneCount = items.filter((i) => i.done).length
|
|
536
|
+
|
|
537
|
+
const copy = () => {
|
|
538
|
+
navigator.clipboard?.writeText("npx create-spine@latest").catch(() => {})
|
|
539
|
+
setCopied(true)
|
|
540
|
+
setTimeout(() => setCopied(false), 1800)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return (
|
|
544
|
+
<Card className="bg-zinc-900 border-zinc-800 h-full">
|
|
545
|
+
<CardContent className="p-6 flex flex-col h-full">
|
|
546
|
+
<div className="flex items-center justify-between mb-1">
|
|
547
|
+
<p className="text-xs text-zinc-500 tracking-widest uppercase">Getting started</p>
|
|
548
|
+
<span className="text-xs font-mono text-zinc-500">{doneCount}/{items.length}</span>
|
|
549
|
+
</div>
|
|
550
|
+
<h3 className="text-base font-bold tracking-tight mb-5">Quick start</h3>
|
|
551
|
+
|
|
552
|
+
<div className="flex flex-col gap-3.5 mb-5">
|
|
553
|
+
{items.map((item) => (
|
|
554
|
+
<div key={item.id} className="flex items-start gap-2.5">
|
|
555
|
+
{item.done ? (
|
|
556
|
+
<div className="w-4 h-4 rounded-full bg-emerald-500 flex items-center justify-center mt-0.5 shrink-0">
|
|
557
|
+
<Check className="w-2.5 h-2.5 text-black" strokeWidth={3} />
|
|
558
|
+
</div>
|
|
559
|
+
) : (
|
|
560
|
+
<Circle className="w-4 h-4 text-zinc-700 mt-0.5 shrink-0" />
|
|
561
|
+
)}
|
|
562
|
+
<span className={cx("text-sm", item.done ? "text-zinc-500 line-through decoration-zinc-700" : "text-zinc-300")}>
|
|
563
|
+
{item.label}
|
|
564
|
+
</span>
|
|
565
|
+
</div>
|
|
566
|
+
))}
|
|
567
|
+
</div>
|
|
568
|
+
|
|
569
|
+
<div className="mt-auto flex items-center gap-2 bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2">
|
|
570
|
+
<span className="text-emerald-400 font-mono text-xs">$</span>
|
|
571
|
+
<span className="text-zinc-400 font-mono text-xs flex-1 truncate">npx create-spine@latest</span>
|
|
572
|
+
<button onClick={copy} className="text-zinc-500 hover:text-zinc-200 transition-colors shrink-0" title="Copy command" aria-label="Copy command">
|
|
573
|
+
{copied ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
|
|
574
|
+
</button>
|
|
575
|
+
</div>
|
|
576
|
+
</CardContent>
|
|
577
|
+
</Card>
|
|
578
|
+
)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/* ════════════════════════════════════════════════════════════════
|
|
582
|
+
MAIN DASHBOARD
|
|
583
|
+
════════════════════════════════════════════════════════════════ */
|
|
584
|
+
export default function Dashboard() {
|
|
585
|
+
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
|
586
|
+
const [notifOpen, setNotifOpen] = useState(false)
|
|
587
|
+
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
|
588
|
+
const [activeNav, setActiveNav] = useState("overview")
|
|
589
|
+
const [period, setPeriod] = useState("14")
|
|
590
|
+
|
|
591
|
+
useEffect(() => {
|
|
592
|
+
function onKey(e) {
|
|
593
|
+
if (e.key === "Escape") {
|
|
594
|
+
setMobileNavOpen(false)
|
|
595
|
+
setNotifOpen(false)
|
|
596
|
+
setUserMenuOpen(false)
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
window.addEventListener("keydown", onKey)
|
|
600
|
+
return () => window.removeEventListener("keydown", onKey)
|
|
601
|
+
}, [])
|
|
602
|
+
|
|
603
|
+
useEffect(() => {
|
|
604
|
+
document.body.style.overflow = mobileNavOpen ? "hidden" : ""
|
|
605
|
+
return () => { document.body.style.overflow = "" }
|
|
606
|
+
}, [mobileNavOpen])
|
|
607
|
+
|
|
608
|
+
const chartData = useMemo(() => USAGE_DATA.slice(-Number(period)), [period])
|
|
609
|
+
|
|
610
|
+
const greeting = useMemo(() => {
|
|
611
|
+
const h = new Date().getHours()
|
|
612
|
+
if (h < 12) return "Good morning"
|
|
613
|
+
if (h < 18) return "Good afternoon"
|
|
614
|
+
return "Good evening"
|
|
615
|
+
}, [])
|
|
616
|
+
|
|
617
|
+
const dateLabel = useMemo(
|
|
618
|
+
() => new Date().toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" }),
|
|
619
|
+
[]
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
return (
|
|
623
|
+
<div className="bg-black min-h-screen text-zinc-50 font-sans antialiased flex">
|
|
624
|
+
<style>{`
|
|
625
|
+
@media (prefers-reduced-motion: reduce) {
|
|
626
|
+
*, *::before, *::after {
|
|
627
|
+
animation-duration: 0.01ms !important;
|
|
628
|
+
animation-iteration-count: 1 !important;
|
|
629
|
+
transition-duration: 0.01ms !important;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
`}</style>
|
|
633
|
+
|
|
634
|
+
<div
|
|
635
|
+
onClick={() => setMobileNavOpen(false)}
|
|
636
|
+
className={cx(
|
|
637
|
+
"fixed inset-0 bg-black/60 backdrop-blur-sm z-40 transition-opacity duration-300 lg:hidden",
|
|
638
|
+
mobileNavOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
|
|
639
|
+
)}
|
|
640
|
+
/>
|
|
641
|
+
|
|
642
|
+
<aside
|
|
643
|
+
className={cx(
|
|
644
|
+
"fixed inset-y-0 left-0 z-50 w-72 bg-zinc-950 border-r border-zinc-800 transform transition-transform duration-300 ease-out lg:hidden",
|
|
645
|
+
mobileNavOpen ? "translate-x-0" : "-translate-x-full"
|
|
646
|
+
)}
|
|
647
|
+
>
|
|
648
|
+
<SidebarContent
|
|
649
|
+
activeNav={activeNav}
|
|
650
|
+
onNavigate={(id) => { setActiveNav(id); setMobileNavOpen(false) }}
|
|
651
|
+
onClose={() => setMobileNavOpen(false)}
|
|
652
|
+
/>
|
|
653
|
+
</aside>
|
|
654
|
+
|
|
655
|
+
<aside className="hidden lg:flex lg:flex-col w-64 shrink-0 border-r border-zinc-800 bg-zinc-950/40 sticky top-0 h-screen">
|
|
656
|
+
<SidebarContent activeNav={activeNav} onNavigate={setActiveNav} />
|
|
657
|
+
</aside>
|
|
658
|
+
|
|
659
|
+
<div className="flex-1 min-w-0 flex flex-col">
|
|
660
|
+
<Topbar
|
|
661
|
+
onMenuClick={() => setMobileNavOpen(true)}
|
|
662
|
+
notifOpen={notifOpen}
|
|
663
|
+
setNotifOpen={setNotifOpen}
|
|
664
|
+
userMenuOpen={userMenuOpen}
|
|
665
|
+
setUserMenuOpen={setUserMenuOpen}
|
|
666
|
+
/>
|
|
667
|
+
|
|
668
|
+
<main className="flex-1 px-4 md:px-6 lg:px-8 py-6 md:py-8 max-w-7xl w-full mx-auto">
|
|
669
|
+
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-7">
|
|
670
|
+
<div
|
|
671
|
+
className="absolute -top-10 left-0 pointer-events-none -z-10"
|
|
672
|
+
style={{ width: 420, height: 220, background: "radial-gradient(ellipse at center, rgba(16,185,129,0.07) 0%, transparent 70%)" }}
|
|
673
|
+
/>
|
|
674
|
+
<div>
|
|
675
|
+
<h2 className="text-xl md:text-2xl font-extrabold tracking-tight">{greeting}, Alex</h2>
|
|
676
|
+
<p className="text-sm text-zinc-500 mt-1">{dateLabel}</p>
|
|
677
|
+
</div>
|
|
678
|
+
<Button className="bg-emerald-500 hover:bg-emerald-400 text-black font-semibold gap-2 self-start sm:self-auto">
|
|
679
|
+
<Plus className="w-4 h-4" /> Generate new scaffold
|
|
680
|
+
</Button>
|
|
681
|
+
</div>
|
|
682
|
+
|
|
683
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
|
684
|
+
<StatCard icon={Zap} label="Generations today" value={STATS.generationsToday} tag={`of ${STATS.dailyLimit} daily`} tone="neutral" />
|
|
685
|
+
<StatCard icon={Package} label="Scaffolds generated" value={STATS.scaffoldsTotal} tag={`+${STATS.scaffoldsDelta} this week`} />
|
|
686
|
+
<StatCard icon={FileCode} label="Files generated" value={STATS.filesTotal} tag={`+${STATS.filesDelta} this week`} />
|
|
687
|
+
<StatCard icon={FolderOpen} label="Active projects" value={STATS.activeProjects} tag={`${STATS.stacksInUse} stacks`} tone="neutral" />
|
|
688
|
+
</div>
|
|
689
|
+
|
|
690
|
+
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 mb-4">
|
|
691
|
+
<div className="xl:col-span-2">
|
|
692
|
+
<UsageCard period={period} setPeriod={setPeriod} data={chartData} />
|
|
693
|
+
</div>
|
|
694
|
+
<div className="xl:col-span-1">
|
|
695
|
+
<PlanCard used={STATS.generationsToday} limit={STATS.dailyLimit} />
|
|
696
|
+
</div>
|
|
697
|
+
</div>
|
|
698
|
+
|
|
699
|
+
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 mb-6">
|
|
700
|
+
<div className="xl:col-span-2">
|
|
701
|
+
<ProjectsTable projects={RECENT_PROJECTS} />
|
|
702
|
+
</div>
|
|
703
|
+
<div className="xl:col-span-1">
|
|
704
|
+
<QuickStartCard items={QUICKSTART} />
|
|
705
|
+
</div>
|
|
706
|
+
</div>
|
|
707
|
+
|
|
708
|
+
<p className="text-center text-xs text-zinc-700 mb-2">Spine · spine.io © {new Date().getFullYear()}</p>
|
|
709
|
+
</main>
|
|
710
|
+
</div>
|
|
711
|
+
</div>
|
|
712
|
+
)
|
|
713
|
+
}
|