@naeil/ui 0.1.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 +36 -0
- package/messages/en.json +221 -0
- package/messages/ja.json +221 -0
- package/messages/ko.json +221 -0
- package/package.json +73 -0
- package/postcss.config.mjs +7 -0
- package/src/app/[locale]/(content)/baby-agent/page.tsx +30 -0
- package/src/app/[locale]/(content)/blog/page.tsx +68 -0
- package/src/app/[locale]/(content)/cc/page.tsx +216 -0
- package/src/app/[locale]/(content)/design/page.tsx +703 -0
- package/src/app/[locale]/(content)/layout.tsx +7 -0
- package/src/app/[locale]/(content)/pkm/page.tsx +31 -0
- package/src/app/[locale]/(content)/projects/page.tsx +100 -0
- package/src/app/[locale]/(content)/sa/page.tsx +179 -0
- package/src/app/[locale]/(landing)/page.tsx +157 -0
- package/src/app/[locale]/layout.tsx +72 -0
- package/src/app/[locale]/login/actions.ts +10 -0
- package/src/app/[locale]/login/oauth-buttons.tsx +78 -0
- package/src/app/[locale]/login/page.tsx +85 -0
- package/src/app/auth/callback/route.ts +78 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +32 -0
- package/src/app/layout.tsx +22 -0
- package/src/app/sitemap.ts +26 -0
- package/src/components/accent-picker.tsx +62 -0
- package/src/components/auth-slot.tsx +96 -0
- package/src/components/footer-wrapper.tsx +8 -0
- package/src/components/footer.tsx +193 -0
- package/src/components/hero-scene.tsx +948 -0
- package/src/components/hero-section.tsx +57 -0
- package/src/components/locale-switcher.tsx +74 -0
- package/src/components/logo.tsx +51 -0
- package/src/components/nav-server-wrapper.tsx +22 -0
- package/src/components/nav-wrapper.tsx +45 -0
- package/src/components/nav.tsx +550 -0
- package/src/components/paraglider-cursor.tsx +112 -0
- package/src/components/project-layout.tsx +194 -0
- package/src/components/theme-provider.tsx +10 -0
- package/src/components/theme-toggle-icon.tsx +62 -0
- package/src/components/theme-toggle.tsx +39 -0
- package/src/components/typography.tsx +27 -0
- package/src/components/ui/avatar.tsx +109 -0
- package/src/components/ui/badge.tsx +47 -0
- package/src/components/ui/button.tsx +65 -0
- package/src/components/ui/card.tsx +106 -0
- package/src/components/ui/dialog.tsx +158 -0
- package/src/components/ui/dropdown-menu.tsx +257 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/sonner.tsx +54 -0
- package/src/components/workflow-diagram.tsx +414 -0
- package/src/i18n/config.ts +8 -0
- package/src/i18n/request.ts +16 -0
- package/src/i18n/routing.ts +12 -0
- package/src/lib/supabase/client.ts +18 -0
- package/src/lib/supabase/middleware.ts +42 -0
- package/src/lib/supabase/server.ts +37 -0
- package/src/lib/utils.ts +6 -0
- package/src/proxy.ts +62 -0
- package/src/tokens/.gitkeep +0 -0
- package/src/tokens/color.json +56 -0
- package/src/tokens/radius.json +9 -0
- package/src/tokens/semantic.json +81 -0
- package/src/tokens/shadow.json +7 -0
- package/src/tokens/spacing.json +16 -0
- package/src/tokens/typography.json +34 -0
- package/src/types/i18n.d.ts +6 -0
- package/src/utils/.gitkeep +0 -0
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { Badge } from "./ui/badge";
|
|
6
|
+
import Image from "next/image";
|
|
7
|
+
|
|
8
|
+
const PROJECTS = [
|
|
9
|
+
{
|
|
10
|
+
key: "cc",
|
|
11
|
+
href: "/cc",
|
|
12
|
+
accent: "oklch(0.704 0.140 181)",
|
|
13
|
+
icon: "/images/coral.png",
|
|
14
|
+
status: "active" as const,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
key: "pkm",
|
|
18
|
+
href: "/pkm",
|
|
19
|
+
accent: "oklch(0.702 0.183 293)",
|
|
20
|
+
icon: "/images/jellyfish.png",
|
|
21
|
+
status: "coming" as const,
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
key: "naeilUi",
|
|
25
|
+
href: "/design",
|
|
26
|
+
accent: "oklch(0.623 0.214 259)",
|
|
27
|
+
icon: "/images/whale.png",
|
|
28
|
+
status: "active" as const,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
key: "sa",
|
|
32
|
+
href: "/sa",
|
|
33
|
+
accent: "oklch(0.723 0.219 149)",
|
|
34
|
+
icon: "/images/fish.png",
|
|
35
|
+
status: "active" as const,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
key: "babyAgent",
|
|
39
|
+
href: "/baby-agent",
|
|
40
|
+
accent: "oklch(0.769 0.188 70.08)",
|
|
41
|
+
icon: "/images/turtle.png",
|
|
42
|
+
status: "coming" as const,
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
interface NavProps {
|
|
47
|
+
/** Internal Link component (from consumer's next-intl routing) */
|
|
48
|
+
Link: React.ComponentType<{
|
|
49
|
+
href: string;
|
|
50
|
+
children: React.ReactNode;
|
|
51
|
+
className?: string;
|
|
52
|
+
onClick?: () => void;
|
|
53
|
+
[key: string]: any;
|
|
54
|
+
}>;
|
|
55
|
+
/** Current pathname value (already resolved, not a hook) */
|
|
56
|
+
usePathname: () => string;
|
|
57
|
+
/** Where the .naeil logo links to. Default: "/" */
|
|
58
|
+
brandHref?: string;
|
|
59
|
+
/** Whether brandHref is external (renders <a>) or internal (renders Link). Default: false */
|
|
60
|
+
brandExternal?: boolean;
|
|
61
|
+
/** Custom brand content (replaces default Logo + ".naeil" text) */
|
|
62
|
+
brandSlot?: React.ReactNode;
|
|
63
|
+
/** When true, project links (except current) are rendered as external <a> to externalBaseUrl. Default: false */
|
|
64
|
+
projectsExternal?: boolean;
|
|
65
|
+
/** Base URL for external project links. Default: "https://naeil.dev" */
|
|
66
|
+
externalBaseUrl?: string;
|
|
67
|
+
/** Override href for the current project in the dropdown */
|
|
68
|
+
currentProjectKey?: string;
|
|
69
|
+
currentProjectHref?: string;
|
|
70
|
+
/** App-specific sub-navigation items after the project dropdown */
|
|
71
|
+
subNav?: Array<{ label: string; href: string }>;
|
|
72
|
+
/** React node for auth UI (rendered in desktop right side + mobile bottom) */
|
|
73
|
+
authSlot?: React.ReactNode;
|
|
74
|
+
/** Show Projects dropdown in nav. Default: true */
|
|
75
|
+
showProjects?: boolean;
|
|
76
|
+
/** Show blog link in nav. Default: true */
|
|
77
|
+
showBlog?: boolean;
|
|
78
|
+
/** Right-side toolbar (theme toggle, locale switcher, etc.) */
|
|
79
|
+
toolbarSlot?: React.ReactNode;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function Nav({
|
|
83
|
+
Link,
|
|
84
|
+
usePathname,
|
|
85
|
+
brandHref = "/",
|
|
86
|
+
brandExternal = false,
|
|
87
|
+
brandSlot,
|
|
88
|
+
projectsExternal = false,
|
|
89
|
+
externalBaseUrl = "https://naeil.dev",
|
|
90
|
+
currentProjectKey,
|
|
91
|
+
currentProjectHref,
|
|
92
|
+
subNav,
|
|
93
|
+
authSlot,
|
|
94
|
+
showProjects = true,
|
|
95
|
+
showBlog = true,
|
|
96
|
+
toolbarSlot,
|
|
97
|
+
}: NavProps) {
|
|
98
|
+
const t = useTranslations("nav");
|
|
99
|
+
const pt = useTranslations("projects");
|
|
100
|
+
const pathname = usePathname();
|
|
101
|
+
const [scrolled, setScrolled] = useState(false);
|
|
102
|
+
const [mobileOpen, setMobileOpen] = useState(false);
|
|
103
|
+
const [projectsOpen, setProjectsOpen] = useState(false);
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
const onScroll = () => setScrolled(window.scrollY > 16);
|
|
107
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
108
|
+
return () => window.removeEventListener("scroll", onScroll);
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
// Close mobile menu on route change
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
setMobileOpen(false);
|
|
114
|
+
setProjectsOpen(false);
|
|
115
|
+
}, [pathname]);
|
|
116
|
+
|
|
117
|
+
// Prevent body scroll when mobile menu open
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
document.body.style.overflow = mobileOpen ? "hidden" : "";
|
|
120
|
+
return () => {
|
|
121
|
+
document.body.style.overflow = "";
|
|
122
|
+
};
|
|
123
|
+
}, [mobileOpen]);
|
|
124
|
+
|
|
125
|
+
const linkClass = (href: string) => {
|
|
126
|
+
const isActive = pathname === href;
|
|
127
|
+
return [
|
|
128
|
+
"text-sm font-medium transition-colors",
|
|
129
|
+
isActive
|
|
130
|
+
? "text-foreground"
|
|
131
|
+
: "text-muted-foreground hover:text-foreground",
|
|
132
|
+
].join(" ");
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const isProjectActive =
|
|
136
|
+
pathname.startsWith("/projects") ||
|
|
137
|
+
pathname.startsWith("/cc") ||
|
|
138
|
+
pathname.startsWith("/pkm") ||
|
|
139
|
+
pathname.startsWith("/sa") ||
|
|
140
|
+
pathname.startsWith("/design");
|
|
141
|
+
|
|
142
|
+
// Resolve project href and whether it's external
|
|
143
|
+
function getProjectLink(project: (typeof PROJECTS)[number]) {
|
|
144
|
+
if (currentProjectKey && project.key === currentProjectKey) {
|
|
145
|
+
return { href: currentProjectHref || "/", external: false };
|
|
146
|
+
}
|
|
147
|
+
if (projectsExternal) {
|
|
148
|
+
return { href: `${externalBaseUrl}${project.href}`, external: true };
|
|
149
|
+
}
|
|
150
|
+
return { href: project.href, external: false };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Brand logo element
|
|
154
|
+
const brandContent = brandSlot ?? (
|
|
155
|
+
<span className="text-foreground flex items-center gap-3 text-xl font-semibold tracking-tight">
|
|
156
|
+
<Logo size={28} />
|
|
157
|
+
.naeil
|
|
158
|
+
</span>
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const brandElement = brandExternal ? (
|
|
162
|
+
<a href={brandHref}>{brandContent}</a>
|
|
163
|
+
) : (
|
|
164
|
+
<Link href={brandHref}>{brandContent}</Link>
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<>
|
|
169
|
+
<nav
|
|
170
|
+
className={[
|
|
171
|
+
"fixed top-0 z-50 w-full transition-[background-color,border-color] duration-300",
|
|
172
|
+
scrolled || mobileOpen
|
|
173
|
+
? "border-b border-border/40 bg-background/80 backdrop-blur-md"
|
|
174
|
+
: "border-b border-transparent bg-transparent",
|
|
175
|
+
].join(" ")}
|
|
176
|
+
>
|
|
177
|
+
<div className="mx-auto flex h-[58px] max-w-7xl items-center px-6">
|
|
178
|
+
{/* Logo */}
|
|
179
|
+
<div className="flex shrink-0 items-center md:w-[200px]">
|
|
180
|
+
{brandElement}
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
{/* Desktop: Nav links */}
|
|
184
|
+
<div className="hidden flex-1 items-center gap-6 md:flex">
|
|
185
|
+
{/* Projects dropdown */}
|
|
186
|
+
{showProjects && <div className="group relative">
|
|
187
|
+
<button
|
|
188
|
+
className={[
|
|
189
|
+
"text-sm font-medium transition-colors",
|
|
190
|
+
isProjectActive
|
|
191
|
+
? "text-foreground"
|
|
192
|
+
: "text-muted-foreground hover:text-foreground",
|
|
193
|
+
].join(" ")}
|
|
194
|
+
>
|
|
195
|
+
{t("projects")}
|
|
196
|
+
<svg
|
|
197
|
+
width="10"
|
|
198
|
+
height="10"
|
|
199
|
+
viewBox="0 0 24 24"
|
|
200
|
+
fill="none"
|
|
201
|
+
stroke="currentColor"
|
|
202
|
+
strokeWidth="2"
|
|
203
|
+
strokeLinecap="round"
|
|
204
|
+
strokeLinejoin="round"
|
|
205
|
+
className="ml-1 inline-block transition-transform group-hover:rotate-180"
|
|
206
|
+
>
|
|
207
|
+
<path d="M6 9l6 6 6-6" />
|
|
208
|
+
</svg>
|
|
209
|
+
</button>
|
|
210
|
+
|
|
211
|
+
{/* Hover mega-dropdown */}
|
|
212
|
+
<div className="pointer-events-none absolute left-0 top-full pt-3 opacity-0 transition-opacity duration-150 group-hover:pointer-events-auto group-hover:opacity-100">
|
|
213
|
+
<div className="border-border/40 bg-background/95 w-[520px] rounded-xl border p-4 shadow-xl backdrop-blur-md">
|
|
214
|
+
<div className="grid grid-cols-2 gap-1">
|
|
215
|
+
{PROJECTS.map((project) => {
|
|
216
|
+
const { href, external } = getProjectLink(project);
|
|
217
|
+
const inner = (
|
|
218
|
+
<div className="hover:bg-muted/50 flex items-start gap-3.5 rounded-lg px-3 py-3 transition-colors">
|
|
219
|
+
<div
|
|
220
|
+
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border"
|
|
221
|
+
style={{
|
|
222
|
+
borderColor: `color-mix(in oklch, ${project.accent} 30%, transparent)`,
|
|
223
|
+
backgroundColor: `color-mix(in oklch, ${project.accent} 8%, transparent)`,
|
|
224
|
+
}}
|
|
225
|
+
>
|
|
226
|
+
<Image
|
|
227
|
+
src={project.icon}
|
|
228
|
+
alt=""
|
|
229
|
+
width={24}
|
|
230
|
+
height={24}
|
|
231
|
+
className="object-contain"
|
|
232
|
+
/>
|
|
233
|
+
</div>
|
|
234
|
+
<div className="min-w-0 flex-1 pt-0.5">
|
|
235
|
+
<div className="flex items-center gap-2">
|
|
236
|
+
<span className="text-foreground text-sm font-medium">
|
|
237
|
+
{pt(`${project.key}.name`)}
|
|
238
|
+
</span>
|
|
239
|
+
{project.status === "coming" && (
|
|
240
|
+
<Badge
|
|
241
|
+
variant="outline"
|
|
242
|
+
className="text-muted-foreground px-1 py-0 text-[9px]"
|
|
243
|
+
>
|
|
244
|
+
{pt("status.coming")}
|
|
245
|
+
</Badge>
|
|
246
|
+
)}
|
|
247
|
+
</div>
|
|
248
|
+
<p className="text-muted-foreground mt-0.5 text-xs leading-snug">
|
|
249
|
+
{pt(`${project.key}.description`)}
|
|
250
|
+
</p>
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
if (external) {
|
|
256
|
+
return (
|
|
257
|
+
<a key={project.key} href={href}>
|
|
258
|
+
{inner}
|
|
259
|
+
</a>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
return (
|
|
263
|
+
<Link key={project.key} href={href}>
|
|
264
|
+
{inner}
|
|
265
|
+
</Link>
|
|
266
|
+
);
|
|
267
|
+
})}
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
</div>}
|
|
272
|
+
|
|
273
|
+
{/* Blog link (conditional) */}
|
|
274
|
+
{showBlog && (
|
|
275
|
+
<>
|
|
276
|
+
{projectsExternal ? (
|
|
277
|
+
<a
|
|
278
|
+
href={`${externalBaseUrl}/blog`}
|
|
279
|
+
className="text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
|
280
|
+
>
|
|
281
|
+
{t("blog")}
|
|
282
|
+
</a>
|
|
283
|
+
) : (
|
|
284
|
+
<Link href="/blog" className={linkClass("/blog")}>
|
|
285
|
+
{t("blog")}
|
|
286
|
+
</Link>
|
|
287
|
+
)}
|
|
288
|
+
</>
|
|
289
|
+
)}
|
|
290
|
+
|
|
291
|
+
{/* Divider before sub-nav */}
|
|
292
|
+
{subNav && subNav.length > 0 && (
|
|
293
|
+
<>
|
|
294
|
+
<div className="h-4 w-px bg-border/40" />
|
|
295
|
+
{subNav.map((item) => (
|
|
296
|
+
<Link
|
|
297
|
+
key={item.href}
|
|
298
|
+
href={item.href}
|
|
299
|
+
className={linkClass(item.href)}
|
|
300
|
+
>
|
|
301
|
+
{item.label}
|
|
302
|
+
</Link>
|
|
303
|
+
))}
|
|
304
|
+
</>
|
|
305
|
+
)}
|
|
306
|
+
</div>
|
|
307
|
+
|
|
308
|
+
{/* Desktop: Auth + Toolbar */}
|
|
309
|
+
<div className="hidden items-center gap-3 md:flex">
|
|
310
|
+
{authSlot}
|
|
311
|
+
{authSlot && toolbarSlot && (
|
|
312
|
+
<div className="h-4 w-px bg-border/40" />
|
|
313
|
+
)}
|
|
314
|
+
{toolbarSlot && (
|
|
315
|
+
<div className="flex items-center gap-4">{toolbarSlot}</div>
|
|
316
|
+
)}
|
|
317
|
+
</div>
|
|
318
|
+
|
|
319
|
+
{/* Mobile: Hamburger */}
|
|
320
|
+
<button
|
|
321
|
+
className="text-muted-foreground hover:text-foreground ml-auto transition-colors md:hidden"
|
|
322
|
+
onClick={() => setMobileOpen(!mobileOpen)}
|
|
323
|
+
aria-label="Menu"
|
|
324
|
+
>
|
|
325
|
+
{mobileOpen ? <CloseIcon /> : <MenuIcon />}
|
|
326
|
+
</button>
|
|
327
|
+
</div>
|
|
328
|
+
</nav>
|
|
329
|
+
|
|
330
|
+
{/* Mobile menu overlay */}
|
|
331
|
+
{mobileOpen && (
|
|
332
|
+
<div className="bg-background/95 fixed inset-0 z-40 overflow-y-auto pt-[58px] backdrop-blur-md md:hidden">
|
|
333
|
+
<div className="flex flex-col px-6 py-6">
|
|
334
|
+
{/* Projects accordion */}
|
|
335
|
+
{showProjects && (
|
|
336
|
+
<>
|
|
337
|
+
<button
|
|
338
|
+
className="text-foreground flex items-center justify-between py-3 text-base font-medium"
|
|
339
|
+
onClick={() => setProjectsOpen(!projectsOpen)}
|
|
340
|
+
>
|
|
341
|
+
{t("projects")}
|
|
342
|
+
<svg
|
|
343
|
+
width="14"
|
|
344
|
+
height="14"
|
|
345
|
+
viewBox="0 0 24 24"
|
|
346
|
+
fill="none"
|
|
347
|
+
stroke="currentColor"
|
|
348
|
+
strokeWidth="2"
|
|
349
|
+
strokeLinecap="round"
|
|
350
|
+
strokeLinejoin="round"
|
|
351
|
+
className={[
|
|
352
|
+
"transition-transform duration-200",
|
|
353
|
+
projectsOpen ? "rotate-180" : "",
|
|
354
|
+
].join(" ")}
|
|
355
|
+
>
|
|
356
|
+
<path d="M6 9l6 6 6-6" />
|
|
357
|
+
</svg>
|
|
358
|
+
</button>
|
|
359
|
+
|
|
360
|
+
{projectsOpen && (
|
|
361
|
+
<div className="mb-2 flex flex-col gap-1 pl-1">
|
|
362
|
+
{PROJECTS.map((project) => {
|
|
363
|
+
const { href, external } = getProjectLink(project);
|
|
364
|
+
const inner = (
|
|
365
|
+
<div className="hover:bg-muted/50 flex items-center gap-3 rounded-lg px-3 py-3 transition-colors">
|
|
366
|
+
<div
|
|
367
|
+
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border"
|
|
368
|
+
style={{
|
|
369
|
+
borderColor: `color-mix(in oklch, ${project.accent} 30%, transparent)`,
|
|
370
|
+
backgroundColor: `color-mix(in oklch, ${project.accent} 8%, transparent)`,
|
|
371
|
+
}}
|
|
372
|
+
>
|
|
373
|
+
<Image
|
|
374
|
+
src={project.icon}
|
|
375
|
+
alt=""
|
|
376
|
+
width={20}
|
|
377
|
+
height={20}
|
|
378
|
+
className="object-contain"
|
|
379
|
+
/>
|
|
380
|
+
</div>
|
|
381
|
+
<div>
|
|
382
|
+
<span className="text-foreground text-sm font-medium">
|
|
383
|
+
{pt(`${project.key}.name`)}
|
|
384
|
+
</span>
|
|
385
|
+
{project.status === "coming" && (
|
|
386
|
+
<Badge
|
|
387
|
+
variant="outline"
|
|
388
|
+
className="text-muted-foreground ml-2 px-1 py-0 text-[9px]"
|
|
389
|
+
>
|
|
390
|
+
{pt("status.coming")}
|
|
391
|
+
</Badge>
|
|
392
|
+
)}
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
if (external) {
|
|
398
|
+
return (
|
|
399
|
+
<a
|
|
400
|
+
key={project.key}
|
|
401
|
+
href={href}
|
|
402
|
+
onClick={() => setMobileOpen(false)}
|
|
403
|
+
>
|
|
404
|
+
{inner}
|
|
405
|
+
</a>
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
return (
|
|
409
|
+
<Link
|
|
410
|
+
key={project.key}
|
|
411
|
+
href={href}
|
|
412
|
+
onClick={() => setMobileOpen(false)}
|
|
413
|
+
>
|
|
414
|
+
{inner}
|
|
415
|
+
</Link>
|
|
416
|
+
);
|
|
417
|
+
})}
|
|
418
|
+
</div>
|
|
419
|
+
)}
|
|
420
|
+
</>
|
|
421
|
+
)}
|
|
422
|
+
|
|
423
|
+
{/* Blog link (conditional) */}
|
|
424
|
+
{showBlog && (
|
|
425
|
+
<>
|
|
426
|
+
{projectsExternal ? (
|
|
427
|
+
<a
|
|
428
|
+
href={`${externalBaseUrl}/blog`}
|
|
429
|
+
className="text-foreground border-border/40 border-t py-3 text-base font-medium"
|
|
430
|
+
onClick={() => setMobileOpen(false)}
|
|
431
|
+
>
|
|
432
|
+
{t("blog")}
|
|
433
|
+
</a>
|
|
434
|
+
) : (
|
|
435
|
+
<Link
|
|
436
|
+
href="/blog"
|
|
437
|
+
className="text-foreground border-border/40 border-t py-3 text-base font-medium"
|
|
438
|
+
onClick={() => setMobileOpen(false)}
|
|
439
|
+
>
|
|
440
|
+
{t("blog")}
|
|
441
|
+
</Link>
|
|
442
|
+
)}
|
|
443
|
+
</>
|
|
444
|
+
)}
|
|
445
|
+
|
|
446
|
+
{/* Sub-nav items (mobile) */}
|
|
447
|
+
{subNav && subNav.length > 0 && (
|
|
448
|
+
<div className="border-border/40 border-t">
|
|
449
|
+
{subNav.map((item) => (
|
|
450
|
+
<Link
|
|
451
|
+
key={item.href}
|
|
452
|
+
href={item.href}
|
|
453
|
+
className="text-foreground block py-3 text-base font-medium"
|
|
454
|
+
onClick={() => setMobileOpen(false)}
|
|
455
|
+
>
|
|
456
|
+
{item.label}
|
|
457
|
+
</Link>
|
|
458
|
+
))}
|
|
459
|
+
</div>
|
|
460
|
+
)}
|
|
461
|
+
|
|
462
|
+
{/* Auth section mobile */}
|
|
463
|
+
{authSlot && (
|
|
464
|
+
<div className="border-border/40 border-t pt-4">
|
|
465
|
+
{authSlot}
|
|
466
|
+
</div>
|
|
467
|
+
)}
|
|
468
|
+
|
|
469
|
+
{/* Toolbar (Theme + Locale) */}
|
|
470
|
+
{toolbarSlot && (
|
|
471
|
+
<div className="border-border/40 flex items-center gap-4 border-t pt-4 mt-2">
|
|
472
|
+
{toolbarSlot}
|
|
473
|
+
</div>
|
|
474
|
+
)}
|
|
475
|
+
</div>
|
|
476
|
+
</div>
|
|
477
|
+
)}
|
|
478
|
+
</>
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/* ── Inline Logo (pure SVG, no external deps) ── */
|
|
483
|
+
function Logo({
|
|
484
|
+
size = 20,
|
|
485
|
+
}: {
|
|
486
|
+
size?: number;
|
|
487
|
+
}) {
|
|
488
|
+
const id = `naeil-nav-logo-${size}`;
|
|
489
|
+
return (
|
|
490
|
+
<svg
|
|
491
|
+
width={size}
|
|
492
|
+
height={size}
|
|
493
|
+
viewBox="0 0 24 24"
|
|
494
|
+
fill="none"
|
|
495
|
+
aria-label="naeil"
|
|
496
|
+
>
|
|
497
|
+
<path
|
|
498
|
+
d="M6 4 L6 16 L20 16"
|
|
499
|
+
stroke="currentColor"
|
|
500
|
+
strokeWidth="2"
|
|
501
|
+
strokeLinecap="square"
|
|
502
|
+
strokeLinejoin="miter"
|
|
503
|
+
/>
|
|
504
|
+
<circle cx="15" cy="16" r="3.5" fill={`url(#${id}-grad)`} mask={`url(#${id})`} />
|
|
505
|
+
<defs>
|
|
506
|
+
<linearGradient id={`${id}-grad`} x1="15" y1="12.5" x2="15" y2="16">
|
|
507
|
+
<stop stopColor="#eab308" />
|
|
508
|
+
<stop offset="1" stopColor="#dc2626" />
|
|
509
|
+
</linearGradient>
|
|
510
|
+
<mask id={id}>
|
|
511
|
+
<rect x="0" y="0" width="24" height="16" fill="white" />
|
|
512
|
+
</mask>
|
|
513
|
+
</defs>
|
|
514
|
+
</svg>
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function MenuIcon() {
|
|
519
|
+
return (
|
|
520
|
+
<svg
|
|
521
|
+
width="22"
|
|
522
|
+
height="22"
|
|
523
|
+
viewBox="0 0 24 24"
|
|
524
|
+
fill="none"
|
|
525
|
+
stroke="currentColor"
|
|
526
|
+
strokeWidth="1.5"
|
|
527
|
+
strokeLinecap="round"
|
|
528
|
+
strokeLinejoin="round"
|
|
529
|
+
>
|
|
530
|
+
<path d="M4 6h16M4 12h16M4 18h16" />
|
|
531
|
+
</svg>
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function CloseIcon() {
|
|
536
|
+
return (
|
|
537
|
+
<svg
|
|
538
|
+
width="22"
|
|
539
|
+
height="22"
|
|
540
|
+
viewBox="0 0 24 24"
|
|
541
|
+
fill="none"
|
|
542
|
+
stroke="currentColor"
|
|
543
|
+
strokeWidth="1.5"
|
|
544
|
+
strokeLinecap="round"
|
|
545
|
+
strokeLinejoin="round"
|
|
546
|
+
>
|
|
547
|
+
<path d="M18 6L6 18M6 6l12 12" />
|
|
548
|
+
</svg>
|
|
549
|
+
);
|
|
550
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
export function ParagliderCursor({ containerRef }: { containerRef: React.RefObject<HTMLElement | null> }) {
|
|
6
|
+
const gliderRef = useRef<HTMLDivElement>(null);
|
|
7
|
+
const pos = useRef({ x: 0, y: 0 });
|
|
8
|
+
const target = useRef({ x: 0, y: 0 });
|
|
9
|
+
const visible = useRef(false);
|
|
10
|
+
const direction = useRef(1); // 1 = right, -1 = left
|
|
11
|
+
const [show, setShow] = useState(false);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const container = containerRef.current;
|
|
15
|
+
if (!container) return;
|
|
16
|
+
|
|
17
|
+
const onMove = (e: MouseEvent) => {
|
|
18
|
+
const rect = container.getBoundingClientRect();
|
|
19
|
+
target.current = {
|
|
20
|
+
x: e.clientX - rect.left,
|
|
21
|
+
y: e.clientY - rect.top,
|
|
22
|
+
};
|
|
23
|
+
// Track direction for tilt
|
|
24
|
+
const dx = target.current.x - pos.current.x;
|
|
25
|
+
if (Math.abs(dx) > 1) {
|
|
26
|
+
direction.current = dx > 0 ? 1 : -1;
|
|
27
|
+
}
|
|
28
|
+
if (!visible.current) {
|
|
29
|
+
visible.current = true;
|
|
30
|
+
setShow(true);
|
|
31
|
+
// Snap to initial position (no lag on first enter)
|
|
32
|
+
pos.current = { ...target.current };
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const onLeave = () => {
|
|
37
|
+
visible.current = false;
|
|
38
|
+
setShow(false);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
container.addEventListener("mousemove", onMove);
|
|
42
|
+
container.addEventListener("mouseleave", onLeave);
|
|
43
|
+
|
|
44
|
+
let raf: number;
|
|
45
|
+
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
|
46
|
+
|
|
47
|
+
const animate = () => {
|
|
48
|
+
pos.current.x = lerp(pos.current.x, target.current.x, 0.06);
|
|
49
|
+
pos.current.y = lerp(pos.current.y, target.current.y, 0.06);
|
|
50
|
+
|
|
51
|
+
if (gliderRef.current) {
|
|
52
|
+
const dx = target.current.x - pos.current.x;
|
|
53
|
+
const tilt = Math.max(-15, Math.min(15, dx * 0.3));
|
|
54
|
+
gliderRef.current.style.transform = `translate(${pos.current.x - 24}px, ${pos.current.y - 32}px) rotate(${tilt}deg)`;
|
|
55
|
+
}
|
|
56
|
+
raf = requestAnimationFrame(animate);
|
|
57
|
+
};
|
|
58
|
+
raf = requestAnimationFrame(animate);
|
|
59
|
+
|
|
60
|
+
return () => {
|
|
61
|
+
container.removeEventListener("mousemove", onMove);
|
|
62
|
+
container.removeEventListener("mouseleave", onLeave);
|
|
63
|
+
cancelAnimationFrame(raf);
|
|
64
|
+
};
|
|
65
|
+
}, [containerRef]);
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div
|
|
69
|
+
ref={gliderRef}
|
|
70
|
+
className="pointer-events-none absolute left-0 top-0 z-20 transition-opacity duration-300"
|
|
71
|
+
style={{ opacity: show ? 1 : 0 }}
|
|
72
|
+
>
|
|
73
|
+
<svg
|
|
74
|
+
width="48"
|
|
75
|
+
height="48"
|
|
76
|
+
viewBox="0 0 64 64"
|
|
77
|
+
fill="none"
|
|
78
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
79
|
+
>
|
|
80
|
+
{/* Canopy */}
|
|
81
|
+
<path
|
|
82
|
+
d="M8 22 Q12 6 32 4 Q52 6 56 22 Q44 18 32 20 Q20 18 8 22Z"
|
|
83
|
+
fill="white"
|
|
84
|
+
fillOpacity="0.7"
|
|
85
|
+
/>
|
|
86
|
+
{/* Lines */}
|
|
87
|
+
<line x1="12" y1="21" x2="30" y2="42" stroke="white" strokeWidth="0.6" opacity="0.5" />
|
|
88
|
+
<line x1="22" y1="19" x2="31" y2="42" stroke="white" strokeWidth="0.6" opacity="0.5" />
|
|
89
|
+
<line x1="32" y1="18" x2="32" y2="42" stroke="white" strokeWidth="0.6" opacity="0.5" />
|
|
90
|
+
<line x1="42" y1="19" x2="33" y2="42" stroke="white" strokeWidth="0.6" opacity="0.5" />
|
|
91
|
+
<line x1="52" y1="21" x2="34" y2="42" stroke="white" strokeWidth="0.6" opacity="0.5" />
|
|
92
|
+
{/* Person */}
|
|
93
|
+
<circle cx="32" cy="45" r="2.5" fill="white" fillOpacity="0.8" />
|
|
94
|
+
<path
|
|
95
|
+
d="M30 48 L32 56 L34 48"
|
|
96
|
+
stroke="white"
|
|
97
|
+
strokeWidth="1.2"
|
|
98
|
+
strokeLinecap="round"
|
|
99
|
+
strokeLinejoin="round"
|
|
100
|
+
opacity="0.7"
|
|
101
|
+
/>
|
|
102
|
+
<path
|
|
103
|
+
d="M28 51 L32 49 L36 51"
|
|
104
|
+
stroke="white"
|
|
105
|
+
strokeWidth="1"
|
|
106
|
+
strokeLinecap="round"
|
|
107
|
+
opacity="0.6"
|
|
108
|
+
/>
|
|
109
|
+
</svg>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|