create-frootkit 1.2.8 → 1.2.10
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/dist/addons/database.d.ts +13 -5
- package/dist/addons/database.d.ts.map +1 -1
- package/dist/addons/database.js +496 -94
- package/dist/addons/database.js.map +1 -1
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +5 -1
- package/dist/scaffold.js.map +1 -1
- package/package.json +1 -1
- package/templates/auth/src/app/api/auth/route.ts +17 -12
- package/templates/auth/src/app/dashboard/layout.tsx +14 -9
- package/templates/auth/src/app/dashboard/page.tsx +55 -24
- package/templates/auth/src/app/layout.tsx +3 -3
- package/templates/auth/src/app/login/page.tsx +5 -1
- package/templates/auth/src/app/page.tsx +103 -45
- package/templates/auth/src/app/signup/page.tsx +5 -1
- package/templates/auth/src/components/AuthForm.tsx +34 -19
- package/templates/auth/src/components/DashboardNav.tsx +97 -56
- package/templates/auth/src/components/MetricCard.tsx +13 -6
- package/templates/auth/src/components/Navbar.tsx +13 -12
- package/templates/auth/src/lib/auth.ts +123 -0
- package/templates/auth/src/styles.css +27 -5
- package/templates/blog/src/app/api/posts/route.ts +10 -34
- package/templates/blog/src/app/blog/[slug]/page.tsx +22 -21
- package/templates/blog/src/app/layout.tsx +4 -4
- package/templates/blog/src/app/page.tsx +19 -17
- package/templates/blog/src/components/Footer.tsx +6 -6
- package/templates/blog/src/components/Navbar.tsx +17 -15
- package/templates/blog/src/components/Newsletter.tsx +11 -9
- package/templates/blog/src/components/PostCard.tsx +29 -27
- package/templates/blog/src/data/posts.ts +48 -0
- package/templates/blog/src/styles.css +14 -5
- package/templates/ecommerce/src/app/api/products/route.ts +10 -26
- package/templates/ecommerce/src/app/cart/page.tsx +3 -3
- package/templates/ecommerce/src/app/layout.tsx +2 -2
- package/templates/ecommerce/src/app/page.tsx +41 -18
- package/templates/ecommerce/src/app/products/[id]/page.tsx +37 -28
- package/templates/ecommerce/src/components/CartDrawer.tsx +20 -17
- package/templates/ecommerce/src/components/Footer.tsx +6 -6
- package/templates/ecommerce/src/components/Navbar.tsx +21 -14
- package/templates/ecommerce/src/components/ProductCard.tsx +22 -15
- package/templates/ecommerce/src/data/products.ts +55 -11
- package/templates/ecommerce/src/styles.css +29 -5
- package/templates/empty/src/app/layout.tsx +5 -5
- package/templates/empty/src/app/page.tsx +10 -9
- package/templates/empty/src/styles.css +13 -15
- package/templates/starter/src/app/layout.tsx +4 -4
- package/templates/starter/src/app/page.tsx +41 -33
- package/templates/starter/src/components/FeatureCard.tsx +5 -5
- package/templates/starter/src/components/Footer.tsx +6 -6
- package/templates/starter/src/components/Header.tsx +18 -14
- package/templates/starter/src/components/TerminalPreview.tsx +30 -30
- package/templates/starter/src/styles.css +14 -5
|
@@ -6,24 +6,32 @@ export function AuthForm({ mode }: AuthFormProps) {
|
|
|
6
6
|
const isLogin = mode === 'login';
|
|
7
7
|
|
|
8
8
|
return (
|
|
9
|
-
<div class="rounded-xl border border-[
|
|
9
|
+
<div class="rounded-xl border border-white/[0.08] bg-[#0f1117] p-7 sm:p-8 shadow-2xl shadow-black/50 relative overflow-hidden">
|
|
10
|
+
{/* Top ambient highlight */}
|
|
11
|
+
<div class="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-indigo-500/40 to-transparent" />
|
|
12
|
+
|
|
10
13
|
<div class="mb-6">
|
|
11
|
-
<h1 class="text-
|
|
12
|
-
{isLogin ? 'Sign in to workspace' : 'Create
|
|
14
|
+
<h1 class="text-xl font-semibold text-white tracking-tight mb-1">
|
|
15
|
+
{isLogin ? 'Sign in to workspace' : 'Create your account'}
|
|
13
16
|
</h1>
|
|
14
|
-
<p class="text-
|
|
17
|
+
<p class="text-zinc-400 text-xs">
|
|
15
18
|
{isLogin ? 'Need an account? ' : 'Already registered? '}
|
|
16
|
-
<a href={isLogin ? '/signup' : '/login'} class="text-
|
|
19
|
+
<a href={isLogin ? '/signup' : '/login'} class="text-indigo-400 font-medium hover:text-indigo-300 transition-colors">
|
|
17
20
|
{isLogin ? 'Sign up' : 'Sign in'}
|
|
18
21
|
</a>
|
|
19
22
|
</p>
|
|
20
23
|
</div>
|
|
21
24
|
|
|
22
25
|
{isLogin && (
|
|
23
|
-
<div class="mb-
|
|
24
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
<div class="mb-5 p-3 rounded-lg bg-white/[0.02] border border-white/[0.06] text-xs">
|
|
27
|
+
<div class="flex items-center gap-1.5 text-zinc-400 text-[11px] mb-1">
|
|
28
|
+
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
|
29
|
+
<span class="font-medium text-zinc-300">Quick Demo Credentials:</span>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="font-mono text-zinc-300 text-[11px] bg-white/[0.03] px-2 py-1 rounded flex items-center justify-between">
|
|
32
|
+
<span>admin@frootkit.dev</span>
|
|
33
|
+
<span class="text-zinc-600">•</span>
|
|
34
|
+
<span>password123</span>
|
|
27
35
|
</div>
|
|
28
36
|
</div>
|
|
29
37
|
)}
|
|
@@ -31,7 +39,7 @@ export function AuthForm({ mode }: AuthFormProps) {
|
|
|
31
39
|
<form action={`/api/auth?action=${mode}`} method="POST" class="space-y-4">
|
|
32
40
|
{!isLogin && (
|
|
33
41
|
<div>
|
|
34
|
-
<label class="block text-xs font-medium text-
|
|
42
|
+
<label class="block text-xs font-medium text-zinc-300 mb-1.5" for="name">
|
|
35
43
|
Full Name
|
|
36
44
|
</label>
|
|
37
45
|
<input
|
|
@@ -40,13 +48,13 @@ export function AuthForm({ mode }: AuthFormProps) {
|
|
|
40
48
|
name="name"
|
|
41
49
|
placeholder="Developer Name"
|
|
42
50
|
required
|
|
43
|
-
class="w-full px-3
|
|
51
|
+
class="w-full px-3 py-2 rounded-lg bg-[#08090a] border border-white/[0.08] text-white text-xs placeholder:text-zinc-600 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-colors"
|
|
44
52
|
/>
|
|
45
53
|
</div>
|
|
46
54
|
)}
|
|
47
55
|
|
|
48
56
|
<div>
|
|
49
|
-
<label class="block text-xs font-medium text-
|
|
57
|
+
<label class="block text-xs font-medium text-zinc-300 mb-1.5" for="email">
|
|
50
58
|
Email Address
|
|
51
59
|
</label>
|
|
52
60
|
<input
|
|
@@ -56,14 +64,21 @@ export function AuthForm({ mode }: AuthFormProps) {
|
|
|
56
64
|
placeholder="admin@frootkit.dev"
|
|
57
65
|
defaultValue={isLogin ? 'admin@frootkit.dev' : ''}
|
|
58
66
|
required
|
|
59
|
-
class="w-full px-3
|
|
67
|
+
class="w-full px-3 py-2 rounded-lg bg-[#08090a] border border-white/[0.08] text-white text-xs placeholder:text-zinc-600 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-colors"
|
|
60
68
|
/>
|
|
61
69
|
</div>
|
|
62
70
|
|
|
63
71
|
<div>
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
72
|
+
<div class="flex items-center justify-between mb-1.5">
|
|
73
|
+
<label class="block text-xs font-medium text-zinc-300" for="password">
|
|
74
|
+
Password
|
|
75
|
+
</label>
|
|
76
|
+
{isLogin && (
|
|
77
|
+
<span class="text-[11px] text-zinc-500">
|
|
78
|
+
min 8 characters
|
|
79
|
+
</span>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
67
82
|
<input
|
|
68
83
|
id="password"
|
|
69
84
|
type="password"
|
|
@@ -71,16 +86,16 @@ export function AuthForm({ mode }: AuthFormProps) {
|
|
|
71
86
|
placeholder="••••••••••••"
|
|
72
87
|
defaultValue={isLogin ? 'password123' : ''}
|
|
73
88
|
required
|
|
74
|
-
class="w-full px-3
|
|
89
|
+
class="w-full px-3 py-2 rounded-lg bg-[#08090a] border border-white/[0.08] text-white text-xs placeholder:text-zinc-600 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-colors"
|
|
75
90
|
/>
|
|
76
91
|
</div>
|
|
77
92
|
|
|
78
93
|
<button
|
|
79
94
|
type="submit"
|
|
80
|
-
class="w-full py-2.5 rounded-lg bg-
|
|
95
|
+
class="w-full py-2.5 rounded-lg bg-white text-zinc-950 font-medium text-xs hover:bg-zinc-200 transition-all mt-3 flex items-center justify-center gap-2 group shadow-sm"
|
|
81
96
|
>
|
|
82
97
|
<span>{isLogin ? 'Sign In' : 'Create Account'}</span>
|
|
83
|
-
<span>→</span>
|
|
98
|
+
<span class="text-zinc-500 group-hover:translate-x-0.5 transition-transform">→</span>
|
|
84
99
|
</button>
|
|
85
100
|
</form>
|
|
86
101
|
</div>
|
|
@@ -1,80 +1,121 @@
|
|
|
1
1
|
export function DashboardNav() {
|
|
2
2
|
return (
|
|
3
|
-
<aside class="w-
|
|
4
|
-
<div>
|
|
5
|
-
{/* Brand
|
|
6
|
-
<div class="
|
|
7
|
-
<div class="
|
|
8
|
-
<
|
|
9
|
-
<
|
|
3
|
+
<aside class="w-60 border-r border-white/[0.08] bg-[#0d0e12] p-3 flex flex-col justify-between shrink-0 min-h-screen">
|
|
4
|
+
<div class="space-y-6">
|
|
5
|
+
{/* Workspace Brand / Org Selector */}
|
|
6
|
+
<div class="px-2 pt-1">
|
|
7
|
+
<div class="flex items-center justify-between p-1.5 rounded-lg hover:bg-white/[0.04] transition-colors cursor-pointer border border-transparent hover:border-white/[0.06]">
|
|
8
|
+
<div class="flex items-center gap-2.5 min-w-0">
|
|
9
|
+
<div class="w-6 h-6 rounded-md bg-indigo-500/15 border border-indigo-500/30 flex items-center justify-center text-indigo-400 shrink-0">
|
|
10
|
+
<svg style="width: 13px; height: 13px; min-width: 13px; min-height: 13px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
11
|
+
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
|
12
|
+
</svg>
|
|
13
|
+
</div>
|
|
14
|
+
<div class="truncate">
|
|
15
|
+
<div class="text-xs font-semibold text-zinc-200 truncate">Orbit Workspace</div>
|
|
16
|
+
<div class="text-[10px] text-zinc-500 truncate">Production Cluster</div>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
<svg style="width: 12px; height: 12px; min-width: 12px; min-height: 12px;" class="text-zinc-500 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
20
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
|
10
21
|
</svg>
|
|
11
22
|
</div>
|
|
12
|
-
<span class="tracking-tight">Workspace</span>
|
|
13
23
|
</div>
|
|
14
24
|
|
|
15
|
-
{/* Navigation
|
|
16
|
-
<
|
|
17
|
-
<
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
<
|
|
22
|
-
<
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
{/* Navigation Sections */}
|
|
26
|
+
<div class="space-y-4">
|
|
27
|
+
<div>
|
|
28
|
+
<div class="px-2.5 mb-1.5 text-[10px] font-semibold text-zinc-500 uppercase tracking-wider">
|
|
29
|
+
Platform
|
|
30
|
+
</div>
|
|
31
|
+
<nav class="space-y-0.5 text-xs font-medium">
|
|
32
|
+
<a
|
|
33
|
+
href="/dashboard"
|
|
34
|
+
class="flex items-center gap-2.5 px-2.5 py-1.5 rounded-md bg-white/[0.08] text-white border border-white/[0.08] shadow-[inset_0_1px_0_0_rgba(255,255,255,0.06)]"
|
|
35
|
+
>
|
|
36
|
+
<span class="w-4 h-4 flex items-center justify-center shrink-0 text-indigo-400">
|
|
37
|
+
<svg style="width: 14px; height: 14px; min-width: 14px; min-height: 14px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
38
|
+
<rect x="3" y="3" width="7" height="7" />
|
|
39
|
+
<rect x="14" y="3" width="7" height="7" />
|
|
40
|
+
<rect x="14" y="14" width="7" height="7" />
|
|
41
|
+
<rect x="3" y="14" width="7" height="7" />
|
|
42
|
+
</svg>
|
|
43
|
+
</span>
|
|
44
|
+
<span>Overview</span>
|
|
45
|
+
</a>
|
|
26
46
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
47
|
+
<a
|
|
48
|
+
href="/dashboard#analytics"
|
|
49
|
+
class="flex items-center gap-2.5 px-2.5 py-1.5 rounded-md text-zinc-400 hover:text-zinc-200 hover:bg-white/[0.04] transition-colors"
|
|
50
|
+
>
|
|
51
|
+
<span class="w-4 h-4 flex items-center justify-center shrink-0 text-zinc-500">
|
|
52
|
+
<svg style="width: 14px; height: 14px; min-width: 14px; min-height: 14px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
53
|
+
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
|
54
|
+
</svg>
|
|
55
|
+
</span>
|
|
56
|
+
<span>Telemetry</span>
|
|
57
|
+
</a>
|
|
36
58
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
59
|
+
<a
|
|
60
|
+
href="/dashboard#team"
|
|
61
|
+
class="flex items-center gap-2.5 px-2.5 py-1.5 rounded-md text-zinc-400 hover:text-zinc-200 hover:bg-white/[0.04] transition-colors"
|
|
62
|
+
>
|
|
63
|
+
<span class="w-4 h-4 flex items-center justify-center shrink-0 text-zinc-500">
|
|
64
|
+
<svg style="width: 14px; height: 14px; min-width: 14px; min-height: 14px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
65
|
+
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
|
66
|
+
<circle cx="9" cy="7" r="4" />
|
|
67
|
+
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
|
68
|
+
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
|
69
|
+
</svg>
|
|
70
|
+
</span>
|
|
71
|
+
<span>Members</span>
|
|
72
|
+
</a>
|
|
73
|
+
</nav>
|
|
74
|
+
</div>
|
|
46
75
|
|
|
47
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
<
|
|
52
|
-
<
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
76
|
+
<div>
|
|
77
|
+
<div class="px-2.5 mb-1.5 text-[10px] font-semibold text-zinc-500 uppercase tracking-wider">
|
|
78
|
+
Developer
|
|
79
|
+
</div>
|
|
80
|
+
<nav class="space-y-0.5 text-xs font-medium">
|
|
81
|
+
<a
|
|
82
|
+
href="/api/auth"
|
|
83
|
+
class="flex items-center gap-2.5 px-2.5 py-1.5 rounded-md text-zinc-400 hover:text-zinc-200 hover:bg-white/[0.04] transition-colors"
|
|
84
|
+
>
|
|
85
|
+
<span class="w-4 h-4 flex items-center justify-center shrink-0 text-zinc-500">
|
|
86
|
+
<svg style="width: 14px; height: 14px; min-width: 14px; min-height: 14px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
87
|
+
<polyline points="16 18 22 12 16 6" />
|
|
88
|
+
<polyline points="8 6 2 12 8 18" />
|
|
89
|
+
</svg>
|
|
90
|
+
</span>
|
|
91
|
+
<span>Session API</span>
|
|
92
|
+
</a>
|
|
93
|
+
</nav>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
57
96
|
</div>
|
|
58
97
|
|
|
59
98
|
{/* User profile & Sign out */}
|
|
60
|
-
<div class="
|
|
99
|
+
<div class="p-2 border-t border-white/[0.06] flex items-center justify-between">
|
|
61
100
|
<div class="flex items-center gap-2.5 min-w-0">
|
|
62
|
-
<div class="w-
|
|
101
|
+
<div class="w-7 h-7 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center font-medium text-[11px] text-white shrink-0">
|
|
63
102
|
AD
|
|
64
103
|
</div>
|
|
65
|
-
<div class="text-xs truncate">
|
|
66
|
-
<div class="font-
|
|
67
|
-
<div class="text-
|
|
104
|
+
<div class="text-xs min-w-0 truncate">
|
|
105
|
+
<div class="font-medium text-zinc-200 truncate">admin@frootkit.dev</div>
|
|
106
|
+
<div class="text-zinc-500 text-[10px] truncate">Owner</div>
|
|
68
107
|
</div>
|
|
69
108
|
</div>
|
|
70
|
-
<form action="/api/auth?action=logout" method="POST" class="shrink-0 ml-
|
|
109
|
+
<form action="/api/auth?action=logout" method="POST" class="shrink-0 ml-1">
|
|
71
110
|
<button
|
|
72
111
|
type="submit"
|
|
73
|
-
class="p-1.5 rounded-md text-
|
|
112
|
+
class="p-1.5 rounded-md text-zinc-500 hover:text-zinc-200 hover:bg-white/[0.06] transition-colors"
|
|
74
113
|
title="Sign out"
|
|
75
114
|
>
|
|
76
|
-
<svg
|
|
77
|
-
<path
|
|
115
|
+
<svg style="width: 14px; height: 14px; min-width: 14px; min-height: 14px;" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
116
|
+
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
|
117
|
+
<polyline points="16 17 21 12 16 7" />
|
|
118
|
+
<line x1="21" y1="12" x2="9" y2="12" />
|
|
78
119
|
</svg>
|
|
79
120
|
</button>
|
|
80
121
|
</form>
|
|
@@ -3,26 +3,33 @@ export interface MetricCardProps {
|
|
|
3
3
|
value: string;
|
|
4
4
|
change: string;
|
|
5
5
|
isPositive: boolean;
|
|
6
|
+
context?: string;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
|
-
export function MetricCard({ label, value, change, isPositive }: MetricCardProps) {
|
|
9
|
+
export function MetricCard({ label, value, change, isPositive, context = 'vs last 30d' }: MetricCardProps) {
|
|
9
10
|
return (
|
|
10
|
-
<div class="rounded-xl border border-[
|
|
11
|
-
<div class="text-xs font-medium text-
|
|
11
|
+
<div class="rounded-xl border border-white/[0.08] bg-[#0f1117] p-4 sm:p-5 flex flex-col justify-between shadow-[inset_0_1px_0_0_rgba(255,255,255,0.05)] transition-all hover:border-white/[0.14]">
|
|
12
|
+
<div class="text-xs font-medium text-zinc-400 mb-3 tracking-normal">
|
|
12
13
|
{label}
|
|
13
14
|
</div>
|
|
14
15
|
<div class="flex items-baseline justify-between gap-3">
|
|
15
|
-
<div class="text-2xl font-
|
|
16
|
+
<div class="text-2xl sm:text-3xl font-semibold text-white tracking-tight tabular-nums">
|
|
17
|
+
{value}
|
|
18
|
+
</div>
|
|
16
19
|
<span
|
|
17
|
-
class={`text-
|
|
20
|
+
class={`inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full border shrink-0 ${
|
|
18
21
|
isPositive
|
|
19
22
|
? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20'
|
|
20
23
|
: 'bg-rose-500/10 text-rose-400 border-rose-500/20'
|
|
21
24
|
}`}
|
|
22
25
|
>
|
|
23
|
-
{
|
|
26
|
+
<span>{isPositive ? '↑' : '↓'}</span>
|
|
27
|
+
<span>{change}</span>
|
|
24
28
|
</span>
|
|
25
29
|
</div>
|
|
30
|
+
<div class="mt-2 text-[11px] text-zinc-500">
|
|
31
|
+
{context}
|
|
32
|
+
</div>
|
|
26
33
|
</div>
|
|
27
34
|
);
|
|
28
35
|
}
|
|
@@ -1,29 +1,30 @@
|
|
|
1
1
|
export function Navbar() {
|
|
2
2
|
return (
|
|
3
|
-
<header class="border-b border-[
|
|
4
|
-
<div class="max-w-6xl mx-auto px-6 h-
|
|
5
|
-
<a href="/" class="flex items-center gap-2.5 font-
|
|
6
|
-
<div class="w-
|
|
7
|
-
<svg
|
|
8
|
-
<
|
|
3
|
+
<header class="border-b border-white/[0.08] bg-[#08090a]/80 backdrop-blur-md sticky top-0 z-50">
|
|
4
|
+
<div class="max-w-6xl mx-auto px-6 h-14 flex items-center justify-between">
|
|
5
|
+
<a href="/" class="flex items-center gap-2.5 font-semibold text-zinc-100 text-sm tracking-tight group">
|
|
6
|
+
<div class="w-7 h-7 rounded-md bg-indigo-500/10 border border-indigo-500/25 flex items-center justify-center text-indigo-400 group-hover:border-indigo-400/50 transition-colors shrink-0">
|
|
7
|
+
<svg style="width: 14px; height: 14px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
8
|
+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
|
9
|
+
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
9
10
|
</svg>
|
|
10
11
|
</div>
|
|
11
|
-
<span>Froot<span class="text-
|
|
12
|
+
<span>Froot<span class="text-indigo-400">Auth</span></span>
|
|
12
13
|
</a>
|
|
13
14
|
|
|
14
|
-
<nav class="flex items-center gap-
|
|
15
|
-
<a href="/dashboard" class="text-
|
|
15
|
+
<nav class="flex items-center gap-5 text-xs font-medium">
|
|
16
|
+
<a href="/dashboard" class="text-zinc-400 hover:text-zinc-200 transition-colors">
|
|
16
17
|
Dashboard
|
|
17
18
|
</a>
|
|
18
|
-
<a href="/login" class="text-
|
|
19
|
+
<a href="/login" class="text-zinc-400 hover:text-zinc-200 transition-colors">
|
|
19
20
|
Sign In
|
|
20
21
|
</a>
|
|
21
22
|
<a
|
|
22
23
|
href="/signup"
|
|
23
|
-
class="px-
|
|
24
|
+
class="px-3 py-1.5 rounded-md bg-white text-zinc-950 font-medium hover:bg-zinc-200 transition-all text-xs shadow-sm flex items-center gap-1.5"
|
|
24
25
|
>
|
|
25
26
|
<span>Get Started</span>
|
|
26
|
-
<span>→</span>
|
|
27
|
+
<span class="text-zinc-500">→</span>
|
|
27
28
|
</a>
|
|
28
29
|
</nav>
|
|
29
30
|
</div>
|
|
@@ -129,6 +129,129 @@ export function clearSessionCookie(): string {
|
|
|
129
129
|
return 'froot_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0';
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Finds user record by email from DB (with fallback to in-memory).
|
|
134
|
+
*/
|
|
135
|
+
export async function findUserByEmail(email: string): Promise<{ user: User; passwordHash: string } | null> {
|
|
136
|
+
try {
|
|
137
|
+
const { db } = await import('../db/index.js');
|
|
138
|
+
const { users } = await import('../db/schema.js');
|
|
139
|
+
const { eq } = await import('drizzle-orm');
|
|
140
|
+
const rows = await db.select().from(users).where(eq(users.email, email));
|
|
141
|
+
if (rows && rows.length > 0) {
|
|
142
|
+
const u = rows[0];
|
|
143
|
+
return {
|
|
144
|
+
user: {
|
|
145
|
+
id: u.id,
|
|
146
|
+
email: u.email,
|
|
147
|
+
name: u.name,
|
|
148
|
+
role: u.role as 'user' | 'admin',
|
|
149
|
+
createdAt: u.createdAt,
|
|
150
|
+
},
|
|
151
|
+
passwordHash: u.passwordHash,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
} catch {}
|
|
155
|
+
return USERS_STORE.get(email) || null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Finds user record by ID.
|
|
160
|
+
*/
|
|
161
|
+
export async function findUserById(id: string): Promise<User | null> {
|
|
162
|
+
try {
|
|
163
|
+
const { db } = await import('../db/index.js');
|
|
164
|
+
const { users } = await import('../db/schema.js');
|
|
165
|
+
const { eq } = await import('drizzle-orm');
|
|
166
|
+
const rows = await db.select().from(users).where(eq(users.id, id));
|
|
167
|
+
if (rows && rows.length > 0) {
|
|
168
|
+
const u = rows[0];
|
|
169
|
+
return {
|
|
170
|
+
id: u.id,
|
|
171
|
+
email: u.email,
|
|
172
|
+
name: u.name,
|
|
173
|
+
role: u.role as 'user' | 'admin',
|
|
174
|
+
createdAt: u.createdAt,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
} catch {}
|
|
178
|
+
for (const entry of USERS_STORE.values()) {
|
|
179
|
+
if (entry.user.id === id) return entry.user;
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Saves a new user to DB and in-memory store.
|
|
186
|
+
*/
|
|
187
|
+
export async function saveUser(user: User, passwordHash: string): Promise<void> {
|
|
188
|
+
try {
|
|
189
|
+
const { db } = await import('../db/index.js');
|
|
190
|
+
const { users } = await import('../db/schema.js');
|
|
191
|
+
await db.insert(users).values({
|
|
192
|
+
id: user.id,
|
|
193
|
+
email: user.email,
|
|
194
|
+
name: user.name,
|
|
195
|
+
role: user.role,
|
|
196
|
+
passwordHash,
|
|
197
|
+
createdAt: user.createdAt,
|
|
198
|
+
});
|
|
199
|
+
} catch {}
|
|
200
|
+
USERS_STORE.set(user.email, { user, passwordHash });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Saves a session token to DB and in-memory store.
|
|
205
|
+
*/
|
|
206
|
+
export async function saveSession(session: Session): Promise<void> {
|
|
207
|
+
try {
|
|
208
|
+
const { db } = await import('../db/index.js');
|
|
209
|
+
const { sessions } = await import('../db/schema.js');
|
|
210
|
+
await db.insert(sessions).values({
|
|
211
|
+
id: session.id,
|
|
212
|
+
token: session.token,
|
|
213
|
+
userId: session.userId,
|
|
214
|
+
expiresAt: session.expiresAt,
|
|
215
|
+
});
|
|
216
|
+
} catch {}
|
|
217
|
+
SESSIONS_STORE.set(session.token, session);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Finds session by token.
|
|
222
|
+
*/
|
|
223
|
+
export async function findSession(token: string): Promise<Session | null> {
|
|
224
|
+
try {
|
|
225
|
+
const { db } = await import('../db/index.js');
|
|
226
|
+
const { sessions } = await import('../db/schema.js');
|
|
227
|
+
const { eq } = await import('drizzle-orm');
|
|
228
|
+
const rows = await db.select().from(sessions).where(eq(sessions.token, token));
|
|
229
|
+
if (rows && rows.length > 0) {
|
|
230
|
+
const s = rows[0];
|
|
231
|
+
return {
|
|
232
|
+
id: s.id,
|
|
233
|
+
token: s.token,
|
|
234
|
+
userId: s.userId,
|
|
235
|
+
expiresAt: s.expiresAt,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
} catch {}
|
|
239
|
+
return SESSIONS_STORE.get(token) || null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Deletes a session by token.
|
|
244
|
+
*/
|
|
245
|
+
export async function deleteSession(token: string): Promise<void> {
|
|
246
|
+
try {
|
|
247
|
+
const { db } = await import('../db/index.js');
|
|
248
|
+
const { sessions } = await import('../db/schema.js');
|
|
249
|
+
const { eq } = await import('drizzle-orm');
|
|
250
|
+
await db.delete(sessions).where(eq(sessions.token, token));
|
|
251
|
+
} catch {}
|
|
252
|
+
SESSIONS_STORE.delete(token);
|
|
253
|
+
}
|
|
254
|
+
|
|
132
255
|
// Pre-populate demo user: admin@frootkit.dev / password123
|
|
133
256
|
(async () => {
|
|
134
257
|
const demoHash = await hashPassword('password123');
|
|
@@ -4,28 +4,50 @@
|
|
|
4
4
|
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
5
5
|
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
6
6
|
|
|
7
|
-
--color-brand-primary: #
|
|
8
|
-
--color-brand-hover: #
|
|
7
|
+
--color-brand-primary: #6366f1;
|
|
8
|
+
--color-brand-hover: #4f46e5;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
:root {
|
|
12
12
|
color-scheme: dark;
|
|
13
|
+
--bg-canvas: #08090a;
|
|
14
|
+
--bg-surface: #0f1117;
|
|
15
|
+
--bg-elevated: #151822;
|
|
16
|
+
--border-subtle: rgba(255, 255, 255, 0.08);
|
|
17
|
+
--border-strong: rgba(255, 255, 255, 0.16);
|
|
18
|
+
--text-primary: #f4f4f5;
|
|
19
|
+
--text-secondary: #9496a1;
|
|
20
|
+
--text-muted: #5c6370;
|
|
13
21
|
}
|
|
14
22
|
|
|
15
23
|
html {
|
|
16
|
-
background-color:
|
|
17
|
-
color:
|
|
24
|
+
background-color: var(--bg-canvas);
|
|
25
|
+
color: var(--text-primary);
|
|
18
26
|
font-family: var(--font-sans);
|
|
19
27
|
scroll-behavior: smooth;
|
|
20
28
|
-webkit-font-smoothing: antialiased;
|
|
29
|
+
-moz-osx-font-smoothing: grayscale;
|
|
21
30
|
}
|
|
22
31
|
|
|
23
|
-
/* SVG safety
|
|
32
|
+
/* Strict SVG safety to prevent flex ballooning */
|
|
24
33
|
svg {
|
|
25
34
|
flex-shrink: 0;
|
|
26
35
|
max-width: 100%;
|
|
27
36
|
}
|
|
28
37
|
|
|
38
|
+
/* Linear-style subtle card highlight */
|
|
39
|
+
.linear-card {
|
|
40
|
+
background-color: var(--bg-surface);
|
|
41
|
+
border: 1px solid var(--border-subtle);
|
|
42
|
+
box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.06);
|
|
43
|
+
border-radius: 0.75rem;
|
|
44
|
+
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.linear-card:hover {
|
|
48
|
+
border-color: var(--border-strong);
|
|
49
|
+
}
|
|
50
|
+
|
|
29
51
|
/* Accessibility: Mandatory Reduced Motion */
|
|
30
52
|
@media (prefers-reduced-motion: reduce) {
|
|
31
53
|
*, *::before, *::after {
|
|
@@ -1,36 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
export async function GET(
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
if (tag) {
|
|
11
|
-
results = results.filter((p) => p.tags.some((t) => t.toLowerCase() === tag.toLowerCase()));
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
if (q) {
|
|
15
|
-
results = results.filter(
|
|
16
|
-
(p) =>
|
|
17
|
-
p.title.toLowerCase().includes(q) ||
|
|
18
|
-
p.summary.toLowerCase().includes(q) ||
|
|
19
|
-
p.category.toLowerCase().includes(q)
|
|
20
|
-
);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
return Response.json(
|
|
24
|
-
{
|
|
25
|
-
posts: results,
|
|
26
|
-
total: results.length,
|
|
27
|
-
timestamp: new Date().toISOString(),
|
|
1
|
+
import { getPosts } from '../../../data/posts.js';
|
|
2
|
+
|
|
3
|
+
export async function GET(): Promise<Response> {
|
|
4
|
+
const posts = await getPosts();
|
|
5
|
+
return Response.json(posts, {
|
|
6
|
+
status: 200,
|
|
7
|
+
headers: {
|
|
8
|
+
'content-type': 'application/json',
|
|
9
|
+
'cache-control': 'public, max-age=60',
|
|
28
10
|
},
|
|
29
|
-
|
|
30
|
-
status: 200,
|
|
31
|
-
headers: {
|
|
32
|
-
'content-type': 'application/json',
|
|
33
|
-
},
|
|
34
|
-
}
|
|
35
|
-
);
|
|
11
|
+
});
|
|
36
12
|
}
|