@naeil/ui 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/DESIGN.md +186 -0
  3. package/LICENSE +21 -0
  4. package/README.md +85 -22
  5. package/THIRD_PARTY_NOTICES.md +49 -0
  6. package/dist/chunk-JBTDRVZ7.js +46 -0
  7. package/dist/chunk-QKVN6Q6C.js +438 -0
  8. package/dist/components/index.d.ts +87 -0
  9. package/dist/components/index.js +832 -0
  10. package/dist/components/ui/index.d.ts +105 -0
  11. package/dist/components/ui/index.js +736 -0
  12. package/dist/lib/utils.d.ts +5 -0
  13. package/dist/lib/utils.js +7 -0
  14. package/dist/source/components/footer.d.ts +15 -0
  15. package/dist/source/components/footer.js +172 -0
  16. package/dist/source/components/index.d.ts +11 -0
  17. package/dist/source/components/index.js +26 -0
  18. package/dist/source/components/locale-switcher.d.ts +17 -0
  19. package/dist/source/components/locale-switcher.js +66 -0
  20. package/dist/source/components/logo.d.ts +9 -0
  21. package/dist/source/components/logo.js +52 -0
  22. package/dist/source/components/nav.d.ts +39 -0
  23. package/dist/source/components/nav.js +412 -0
  24. package/dist/source/components/theme-provider.d.ts +6 -0
  25. package/dist/source/components/theme-provider.js +45 -0
  26. package/dist/source/components/theme-toggle-icon.d.ts +5 -0
  27. package/dist/source/components/theme-toggle-icon.js +62 -0
  28. package/dist/source/components/theme-toggle.d.ts +5 -0
  29. package/dist/source/components/theme-toggle.js +34 -0
  30. package/dist/source/components/typography.d.ts +12 -0
  31. package/dist/source/components/typography.js +49 -0
  32. package/dist/source/components/ui/avatar.d.ts +14 -0
  33. package/dist/source/components/ui/avatar.js +137 -0
  34. package/dist/source/components/ui/badge.d.ts +13 -0
  35. package/dist/source/components/ui/badge.js +76 -0
  36. package/dist/source/components/ui/button.d.ts +14 -0
  37. package/dist/source/components/ui/button.js +88 -0
  38. package/dist/source/components/ui/card.d.ts +13 -0
  39. package/dist/source/components/ui/card.js +135 -0
  40. package/dist/source/components/ui/checkbox.d.ts +7 -0
  41. package/dist/source/components/ui/checkbox.js +58 -0
  42. package/dist/source/components/ui/dialog.d.ts +20 -0
  43. package/dist/source/components/ui/dialog.js +182 -0
  44. package/dist/source/components/ui/dropdown-menu.d.ts +28 -0
  45. package/dist/source/components/ui/dropdown-menu.js +282 -0
  46. package/dist/source/components/ui/index.d.ts +19 -0
  47. package/dist/source/components/ui/index.js +131 -0
  48. package/dist/source/components/ui/input.d.ts +6 -0
  49. package/dist/source/components/ui/input.js +49 -0
  50. package/dist/source/components/ui/select.d.ts +16 -0
  51. package/dist/source/components/ui/select.js +197 -0
  52. package/dist/source/components/ui/sonner.d.ts +6 -0
  53. package/dist/source/components/ui/sonner.js +80 -0
  54. package/dist/source/components/ui/switch.d.ts +7 -0
  55. package/dist/source/components/ui/switch.js +54 -0
  56. package/dist/source/components/ui/tabs.d.ts +10 -0
  57. package/dist/source/components/ui/tabs.js +57 -0
  58. package/dist/source/components/ui/textarea.d.ts +6 -0
  59. package/dist/source/components/ui/textarea.js +46 -0
  60. package/dist/source/i18n/config.d.ts +6 -0
  61. package/dist/source/i18n/config.js +12 -0
  62. package/dist/source/i18n/routing.d.ts +371 -0
  63. package/dist/source/i18n/routing.js +17 -0
  64. package/dist/source/lib/design/modal-inert.d.ts +6 -0
  65. package/dist/source/lib/design/modal-inert.js +73 -0
  66. package/dist/source/lib/utils.d.ts +5 -0
  67. package/dist/source/lib/utils.js +8 -0
  68. package/dist/tokens.ts +217 -0
  69. package/docs/components/README.md +21 -0
  70. package/docs/components/avatar.md +47 -0
  71. package/docs/components/badge.md +29 -0
  72. package/docs/components/button.md +39 -0
  73. package/docs/components/card.md +32 -0
  74. package/docs/components/checkbox.md +35 -0
  75. package/docs/components/common.md +31 -0
  76. package/docs/components/composition.md +68 -0
  77. package/docs/components/dialog.md +41 -0
  78. package/docs/components/dropdown-menu.md +38 -0
  79. package/docs/components/input.md +32 -0
  80. package/docs/components/select.md +39 -0
  81. package/docs/components/sonner.md +37 -0
  82. package/docs/components/switch.md +33 -0
  83. package/docs/components/tabs.md +40 -0
  84. package/docs/components/textarea.md +35 -0
  85. package/docs/design/component-demand.md +30 -0
  86. package/docs/design/public-ui-final-verification.md +96 -0
  87. package/docs/design/public-ui-release-validation.md +86 -0
  88. package/docs/design/public-ui-stage3-verification.md +98 -0
  89. package/docs/design/public-ui-stage4-verification.md +129 -0
  90. package/docs/design/public-ui-support.md +40 -0
  91. package/docs/design/v2-migration.md +199 -0
  92. package/docs/package-release.md +89 -0
  93. package/licenses/Noto-Sans-JP-OFL.txt +93 -0
  94. package/licenses/Nunito-Sans-OFL.txt +93 -0
  95. package/licenses/Pretendard-OFL.txt +94 -0
  96. package/licenses/React-MIT.txt +21 -0
  97. package/licenses/Storybook-MIT.txt +21 -0
  98. package/licenses/popper-MIT.txt +25 -0
  99. package/licenses/react-remove-scroll-bar-MIT.txt +21 -0
  100. package/licenses/store2-MIT.txt +22 -0
  101. package/licenses/toggle-selection-MIT.txt +21 -0
  102. package/package.json +215 -26
  103. package/src/styles/components.css +276 -0
  104. package/src/styles/globals.css +18 -0
  105. package/src/styles/theme.css +293 -0
  106. package/messages/en.json +0 -221
  107. package/messages/ja.json +0 -221
  108. package/messages/ko.json +0 -221
  109. package/postcss.config.mjs +0 -7
  110. package/src/app/[locale]/(content)/baby-agent/page.tsx +0 -30
  111. package/src/app/[locale]/(content)/blog/page.tsx +0 -68
  112. package/src/app/[locale]/(content)/cc/page.tsx +0 -216
  113. package/src/app/[locale]/(content)/design/page.tsx +0 -703
  114. package/src/app/[locale]/(content)/layout.tsx +0 -7
  115. package/src/app/[locale]/(content)/pkm/page.tsx +0 -31
  116. package/src/app/[locale]/(content)/projects/page.tsx +0 -100
  117. package/src/app/[locale]/(content)/sa/page.tsx +0 -179
  118. package/src/app/[locale]/(landing)/page.tsx +0 -157
  119. package/src/app/[locale]/layout.tsx +0 -72
  120. package/src/app/[locale]/login/actions.ts +0 -10
  121. package/src/app/[locale]/login/oauth-buttons.tsx +0 -78
  122. package/src/app/[locale]/login/page.tsx +0 -85
  123. package/src/app/auth/callback/route.ts +0 -78
  124. package/src/app/favicon.ico +0 -0
  125. package/src/app/globals.css +0 -32
  126. package/src/app/layout.tsx +0 -22
  127. package/src/app/sitemap.ts +0 -26
  128. package/src/components/accent-picker.tsx +0 -62
  129. package/src/components/auth-slot.tsx +0 -96
  130. package/src/components/footer-wrapper.tsx +0 -8
  131. package/src/components/footer.tsx +0 -193
  132. package/src/components/hero-scene.tsx +0 -948
  133. package/src/components/hero-section.tsx +0 -57
  134. package/src/components/locale-switcher.tsx +0 -74
  135. package/src/components/logo.tsx +0 -51
  136. package/src/components/nav-server-wrapper.tsx +0 -22
  137. package/src/components/nav-wrapper.tsx +0 -45
  138. package/src/components/nav.tsx +0 -550
  139. package/src/components/paraglider-cursor.tsx +0 -112
  140. package/src/components/project-layout.tsx +0 -194
  141. package/src/components/theme-provider.tsx +0 -10
  142. package/src/components/theme-toggle-icon.tsx +0 -62
  143. package/src/components/theme-toggle.tsx +0 -39
  144. package/src/components/typography.tsx +0 -27
  145. package/src/components/ui/avatar.tsx +0 -109
  146. package/src/components/ui/badge.tsx +0 -47
  147. package/src/components/ui/button.tsx +0 -65
  148. package/src/components/ui/card.tsx +0 -106
  149. package/src/components/ui/dialog.tsx +0 -158
  150. package/src/components/ui/dropdown-menu.tsx +0 -257
  151. package/src/components/ui/input.tsx +0 -21
  152. package/src/components/ui/sonner.tsx +0 -54
  153. package/src/components/workflow-diagram.tsx +0 -414
  154. package/src/i18n/config.ts +0 -8
  155. package/src/i18n/request.ts +0 -16
  156. package/src/i18n/routing.ts +0 -12
  157. package/src/lib/supabase/client.ts +0 -18
  158. package/src/lib/supabase/middleware.ts +0 -42
  159. package/src/lib/supabase/server.ts +0 -37
  160. package/src/lib/utils.ts +0 -6
  161. package/src/proxy.ts +0 -62
  162. package/src/tokens/.gitkeep +0 -0
  163. package/src/tokens/color.json +0 -56
  164. package/src/tokens/radius.json +0 -9
  165. package/src/tokens/semantic.json +0 -81
  166. package/src/tokens/shadow.json +0 -7
  167. package/src/tokens/spacing.json +0 -16
  168. package/src/tokens/typography.json +0 -34
  169. package/src/types/i18n.d.ts +0 -6
  170. package/src/utils/.gitkeep +0 -0
@@ -1,85 +0,0 @@
1
- import { getTranslations } from "next-intl/server";
2
- import { redirect } from "@/i18n/routing";
3
- import { createClient } from "@/lib/supabase/server";
4
- import { OAuthButtons } from "./oauth-buttons";
5
-
6
- export default async function LoginPage({
7
- params,
8
- searchParams,
9
- }: {
10
- params: Promise<{ locale: string }>;
11
- searchParams: Promise<{ next?: string }>;
12
- }) {
13
- const { locale } = await params;
14
- const { next } = await searchParams;
15
-
16
- const supabase = await createClient();
17
- const {
18
- data: { user },
19
- } = await supabase.auth.getUser();
20
-
21
- if (user) {
22
- redirect({ href: next || "/", locale });
23
- }
24
-
25
- const t = await getTranslations("auth");
26
-
27
- return (
28
- <div className="flex min-h-svh items-center justify-center px-4">
29
- <div className="w-full max-w-sm space-y-8">
30
- {/* Branding */}
31
- <div className="text-center">
32
- <div className="mb-4 flex justify-center">
33
- <Logo size={40} />
34
- </div>
35
- <h1 className="text-foreground text-2xl font-semibold tracking-tight">
36
- {t("login")}
37
- </h1>
38
- <p className="text-muted-foreground mt-2 text-sm">
39
- {t("loginDescription")}
40
- </p>
41
- </div>
42
-
43
- {/* OAuth buttons */}
44
- <OAuthButtons next={next} />
45
- </div>
46
- </div>
47
- );
48
- }
49
-
50
- function Logo({ size = 40 }: { size?: number }) {
51
- const id = "login-logo";
52
- return (
53
- <svg
54
- width={size}
55
- height={size}
56
- viewBox="0 0 24 24"
57
- fill="none"
58
- aria-label="naeil"
59
- >
60
- <path
61
- d="M6 4 L6 16 L20 16"
62
- stroke="currentColor"
63
- strokeWidth="2"
64
- strokeLinecap="square"
65
- strokeLinejoin="miter"
66
- />
67
- <circle
68
- cx="15"
69
- cy="16"
70
- r="3.5"
71
- fill={`url(#${id}-grad)`}
72
- mask={`url(#${id})`}
73
- />
74
- <defs>
75
- <linearGradient id={`${id}-grad`} x1="15" y1="12.5" x2="15" y2="16">
76
- <stop stopColor="#eab308" />
77
- <stop offset="1" stopColor="#dc2626" />
78
- </linearGradient>
79
- <mask id={id}>
80
- <rect x="0" y="0" width="24" height="16" fill="white" />
81
- </mask>
82
- </defs>
83
- </svg>
84
- );
85
- }
@@ -1,78 +0,0 @@
1
- import { NextResponse } from "next/server";
2
- import { createServerClient } from "@supabase/ssr";
3
- import { cookies, headers } from "next/headers";
4
-
5
- function getCookieDomain(headerStore: Headers): string | undefined {
6
- const host = headerStore.get("host") || "";
7
- return host.endsWith(".naeil.dev") || host === "naeil.dev"
8
- ? ".naeil.dev"
9
- : undefined;
10
- }
11
-
12
- function isAllowedRedirect(url: string): boolean {
13
- if (url.startsWith("/")) return true;
14
- try {
15
- const parsed = new URL(url);
16
- return (
17
- parsed.hostname === "naeil.dev" ||
18
- parsed.hostname.endsWith(".naeil.dev")
19
- );
20
- } catch {
21
- return false;
22
- }
23
- }
24
-
25
- export async function GET(request: Request) {
26
- const { searchParams, origin } = new URL(request.url);
27
- const code = searchParams.get("code");
28
-
29
- if (code) {
30
- const cookieStore = await cookies();
31
- const headerStore = await headers();
32
- const domain = getCookieDomain(headerStore);
33
-
34
- const supabase = createServerClient(
35
- process.env.NEXT_PUBLIC_SUPABASE_URL!,
36
- process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
37
- {
38
- cookies: {
39
- getAll() {
40
- return cookieStore.getAll();
41
- },
42
- setAll(cookiesToSet) {
43
- try {
44
- cookiesToSet.forEach(({ name, value, options }) =>
45
- cookieStore.set(name, value, { ...options, domain }),
46
- );
47
- } catch {
48
- // Ignore — cookies can only be set in Server Actions or Route Handlers
49
- }
50
- },
51
- },
52
- },
53
- );
54
-
55
- const { error } = await supabase.auth.exchangeCodeForSession(code);
56
-
57
- const redirectCookie = cookieStore.get("auth_redirect_to");
58
-
59
- if (!error) {
60
- // decodeURIComponent required: Next.js cookies.set() URL-encodes values,
61
- // but cookies.get() returns them raw (still encoded)
62
- const next = decodeURIComponent(redirectCookie?.value || "/");
63
-
64
- // Clear the redirect cookie
65
- cookieStore.set("auth_redirect_to", "", { path: "/", maxAge: 0, domain });
66
-
67
- // Redirect to original page
68
- if (isAllowedRedirect(next)) {
69
- const redirectUrl = next.startsWith("http") ? next : `${origin}${next}`;
70
- return NextResponse.redirect(redirectUrl);
71
- }
72
- return NextResponse.redirect(origin);
73
- }
74
- }
75
-
76
- // Auth error — redirect to login
77
- return NextResponse.redirect(`${origin}/login`);
78
- }
Binary file
@@ -1,32 +0,0 @@
1
- @import "tailwindcss";
2
- @import "tw-animate-css";
3
- @import "shadcn/tailwind.css";
4
- @import "../../dist/theme.css";
5
-
6
- @custom-variant dark (&:is(.dark *));
7
-
8
- /* Register float animation as Tailwind utility */
9
- @theme inline {
10
- --animate-float: float 4s ease-in-out infinite;
11
- }
12
-
13
- @layer base {
14
- * {
15
- @apply border-border outline-ring/50;
16
- }
17
- body {
18
- @apply bg-background text-foreground font-sans;
19
- }
20
- }
21
-
22
- /* Float keyframes */
23
- @keyframes float {
24
- 0%, 100% { transform: translateY(0); }
25
- 50% { transform: translateY(-12px); }
26
- }
27
-
28
- @media (prefers-reduced-motion: reduce) {
29
- .animate-float {
30
- animation: none;
31
- }
32
- }
@@ -1,22 +0,0 @@
1
- import { JetBrains_Mono } from "next/font/google";
2
- import "pretendard/dist/web/variable/pretendardvariable-dynamic-subset.css";
3
- import "./globals.css";
4
-
5
- const jetbrainsMono = JetBrains_Mono({
6
- variable: "--font-jetbrains-mono",
7
- subsets: ["latin"],
8
- });
9
-
10
- export default function RootLayout({
11
- children,
12
- }: {
13
- children: React.ReactNode;
14
- }) {
15
- return (
16
- <html suppressHydrationWarning>
17
- <body className={`${jetbrainsMono.variable} antialiased`}>
18
- {children}
19
- </body>
20
- </html>
21
- );
22
- }
@@ -1,26 +0,0 @@
1
- import type { MetadataRoute } from "next";
2
- import { locales } from "@/i18n/config";
3
-
4
- const APP_URL = process.env.NEXT_PUBLIC_APP_URL || "https://naeil.dev";
5
- const staticPages = ["", "/projects", "/design", "/cc", "/pkm"];
6
-
7
- export default function sitemap(): MetadataRoute.Sitemap {
8
- const entries: MetadataRoute.Sitemap = [];
9
-
10
- for (const locale of locales) {
11
- for (const page of staticPages) {
12
- const languages: Record<string, string> = {};
13
- for (const alt of locales) {
14
- languages[alt] = `${APP_URL}/${alt}${page}`;
15
- }
16
- languages["x-default"] = `${APP_URL}/en${page}`;
17
-
18
- entries.push({
19
- url: `${APP_URL}/${locale}${page}`,
20
- lastModified: new Date(),
21
- alternates: { languages },
22
- });
23
- }
24
- }
25
- return entries;
26
- }
@@ -1,62 +0,0 @@
1
- "use client";
2
-
3
- import { useState } from "react";
4
- import { Button } from "@/components/ui/button";
5
-
6
- const ACCENT_PRESETS = [
7
- { name: "블루 (기본)", hue: 259, chroma: 0.214 },
8
- { name: "퍼플", hue: 303, chroma: 0.22 },
9
- { name: "그린", hue: 162, chroma: 0.17 },
10
- { name: "오렌지", hue: 55, chroma: 0.22 },
11
- { name: "레드", hue: 25, chroma: 0.24 },
12
- ] as const;
13
-
14
- function generateAccentScale(hue: number, chroma: number) {
15
- // Generate a 50-950 OKLCH scale for the given hue/chroma
16
- const steps = [
17
- { name: "50", l: 0.97, c: chroma * 0.065 },
18
- { name: "100", l: 0.932, c: chroma * 0.15 },
19
- { name: "200", l: 0.882, c: chroma * 0.275 },
20
- { name: "300", l: 0.809, c: chroma * 0.49 },
21
- { name: "400", l: 0.707, c: chroma * 0.77 },
22
- { name: "500", l: 0.623, c: chroma },
23
- { name: "600", l: 0.546, c: chroma * 1.14 },
24
- { name: "700", l: 0.488, c: chroma * 1.13 },
25
- { name: "800", l: 0.424, c: chroma * 0.93 },
26
- { name: "900", l: 0.379, c: chroma * 0.68 },
27
- { name: "950", l: 0.282, c: chroma * 0.42 },
28
- ];
29
- return steps.map((s) => ({
30
- name: s.name,
31
- value: `oklch(${s.l} ${Number(s.c.toFixed(3))} ${hue})`,
32
- }));
33
- }
34
-
35
- export function AccentPicker() {
36
- const [active, setActive] = useState(0);
37
-
38
- function applyAccent(index: number) {
39
- setActive(index);
40
- const preset = ACCENT_PRESETS[index];
41
- const scale = generateAccentScale(preset.hue, preset.chroma);
42
- const root = document.documentElement;
43
- for (const step of scale) {
44
- root.style.setProperty(`--accent-${step.name}`, step.value);
45
- }
46
- }
47
-
48
- return (
49
- <div className="flex flex-wrap gap-2">
50
- {ACCENT_PRESETS.map((preset, i) => (
51
- <Button
52
- key={preset.name}
53
- variant={active === i ? "default" : "outline"}
54
- size="sm"
55
- onClick={() => applyAccent(i)}
56
- >
57
- {preset.name}
58
- </Button>
59
- ))}
60
- </div>
61
- );
62
- }
@@ -1,96 +0,0 @@
1
- "use client";
2
-
3
- import { useLocale, useTranslations } from "next-intl";
4
- import { Link, usePathname } from "@/i18n/routing";
5
- import Image from "next/image";
6
- import { Avatar, AvatarImage, AvatarFallback } from "./ui/avatar";
7
- import {
8
- DropdownMenu,
9
- DropdownMenuTrigger,
10
- DropdownMenuContent,
11
- DropdownMenuLabel,
12
- DropdownMenuSeparator,
13
- DropdownMenuItem,
14
- } from "./ui/dropdown-menu";
15
- import { logout } from "@/app/[locale]/login/actions";
16
-
17
- const ANIMALS = [
18
- "/images/coral.png",
19
- "/images/fish.png",
20
- "/images/jellyfish.png",
21
- "/images/turtle.png",
22
- "/images/whale.png",
23
- "/images/diver.png",
24
- ];
25
-
26
- function hashUserId(id: string): number {
27
- let hash = 0;
28
- for (let i = 0; i < id.length; i++) {
29
- hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0;
30
- }
31
- return Math.abs(hash) % ANIMALS.length;
32
- }
33
-
34
- interface AuthUser {
35
- id: string;
36
- name?: string;
37
- email?: string;
38
- }
39
-
40
- export function AuthSlot({ user }: { user: AuthUser | null }) {
41
- const t = useTranslations("auth");
42
- const locale = useLocale();
43
- const pathname = usePathname();
44
-
45
- if (!user) {
46
- return (
47
- <Link
48
- href={`/login?next=${encodeURIComponent(pathname)}`}
49
- className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors"
50
- >
51
- {t("login")}
52
- </Link>
53
- );
54
- }
55
-
56
- const animalSrc = ANIMALS[hashUserId(user.id)];
57
- const displayName = user.name || user.email?.split("@")[0] || "User";
58
-
59
- return (
60
- <DropdownMenu>
61
- <DropdownMenuTrigger asChild>
62
- <button className="cursor-pointer focus:outline-none">
63
- <Avatar>
64
- <AvatarImage src={animalSrc} alt={displayName} />
65
- <AvatarFallback>
66
- {displayName.charAt(0).toUpperCase()}
67
- </AvatarFallback>
68
- </Avatar>
69
- </button>
70
- </DropdownMenuTrigger>
71
- <DropdownMenuContent align="end" className="w-56">
72
- <DropdownMenuLabel className="flex items-center gap-3 font-normal">
73
- <Image
74
- src={animalSrc}
75
- alt=""
76
- width={32}
77
- height={32}
78
- className="shrink-0 rounded-full"
79
- />
80
- <div className="flex min-w-0 flex-col">
81
- <span className="truncate text-sm font-medium">{displayName}</span>
82
- {user.email && (
83
- <span className="text-muted-foreground truncate text-xs">
84
- {user.email}
85
- </span>
86
- )}
87
- </div>
88
- </DropdownMenuLabel>
89
- <DropdownMenuSeparator />
90
- <DropdownMenuItem onClick={() => logout(locale)}>
91
- {t("logout")}
92
- </DropdownMenuItem>
93
- </DropdownMenuContent>
94
- </DropdownMenu>
95
- );
96
- }
@@ -1,8 +0,0 @@
1
- "use client";
2
-
3
- import { Footer } from "./footer";
4
- import { Link } from "@/i18n/routing";
5
-
6
- export function FooterWrapper() {
7
- return <Footer Link={Link} linksExternal={false} />;
8
- }
@@ -1,193 +0,0 @@
1
- "use client";
2
-
3
- import { useTranslations } from "next-intl";
4
-
5
- interface FooterProps {
6
- /** Link component from consumer */
7
- Link?: React.ComponentType<{
8
- href: string;
9
- children: React.ReactNode;
10
- className?: string;
11
- }>;
12
- /** Whether links go to naeil.dev (external) or are internal. Default: false */
13
- linksExternal?: boolean;
14
- }
15
-
16
- export function Footer({ Link, linksExternal = false }: FooterProps) {
17
- const t = useTranslations("footer");
18
-
19
- // Render a navigation link — external <a> or internal Link
20
- const NavLink = ({
21
- href,
22
- children,
23
- className,
24
- }: {
25
- href: string;
26
- children: React.ReactNode;
27
- className?: string;
28
- }) => {
29
- if (linksExternal) {
30
- return (
31
- <a
32
- href={`https://naeil.dev${href}`}
33
- target="_blank"
34
- rel="noopener noreferrer"
35
- className={className}
36
- >
37
- {children}
38
- </a>
39
- );
40
- }
41
- if (Link) {
42
- return (
43
- <Link href={href} className={className}>
44
- {children}
45
- </Link>
46
- );
47
- }
48
- return (
49
- <a href={href} className={className}>
50
- {children}
51
- </a>
52
- );
53
- };
54
-
55
- return (
56
- <footer className="border-border/40 border-t">
57
- <div className="mx-auto flex max-w-7xl flex-col gap-10 px-6 py-16 sm:flex-row sm:justify-between">
58
- {/* Left: Brand */}
59
- <div className="flex flex-col gap-4">
60
- <div className="flex items-center gap-3">
61
- <Logo size={20} />
62
- <span className="text-foreground text-sm font-semibold tracking-tight">
63
- .naeil
64
- </span>
65
- </div>
66
- <p className="text-muted-foreground max-w-[240px] text-sm leading-relaxed">
67
- {t("tagline")}
68
- </p>
69
- <div className="flex items-center gap-3">
70
- <a
71
- href="https://github.com/naeil-dev"
72
- target="_blank"
73
- rel="noopener noreferrer"
74
- className="text-muted-foreground hover:text-foreground transition-colors"
75
- aria-label="GitHub"
76
- >
77
- <GitHubIcon />
78
- </a>
79
- <a
80
- href="https://x.com"
81
- target="_blank"
82
- rel="noopener noreferrer"
83
- className="text-muted-foreground hover:text-foreground transition-colors"
84
- aria-label="X"
85
- >
86
- <XIcon />
87
- </a>
88
- </div>
89
- </div>
90
-
91
- {/* Right: Links */}
92
- <div className="flex gap-10 sm:gap-16">
93
- <div className="flex flex-col gap-3">
94
- <span className="text-foreground text-xs font-medium uppercase tracking-wider">
95
- {t("navigation")}
96
- </span>
97
- <NavLink
98
- href="/projects"
99
- className="text-muted-foreground hover:text-foreground text-sm transition-colors"
100
- >
101
- {t("projects")}
102
- </NavLink>
103
- <NavLink
104
- href="/blog"
105
- className="text-muted-foreground hover:text-foreground text-sm transition-colors"
106
- >
107
- {t("blog")}
108
- </NavLink>
109
- </div>
110
- <div className="flex flex-col gap-3">
111
- <span className="text-foreground text-xs font-medium uppercase tracking-wider">
112
- {t("social")}
113
- </span>
114
- <a
115
- href="https://github.com/naeil-dev"
116
- target="_blank"
117
- rel="noopener noreferrer"
118
- className="text-muted-foreground hover:text-foreground text-sm transition-colors"
119
- >
120
- GitHub ↗
121
- </a>
122
- </div>
123
- </div>
124
- </div>
125
- </footer>
126
- );
127
- }
128
-
129
- /* ── Inline Logo (pure SVG) ── */
130
- function Logo({ size = 20 }: { size?: number }) {
131
- const id = `naeil-footer-logo-${size}`;
132
- return (
133
- <svg
134
- width={size}
135
- height={size}
136
- viewBox="0 0 24 24"
137
- fill="none"
138
- aria-label="naeil"
139
- >
140
- <path
141
- d="M6 4 L6 16 L20 16"
142
- stroke="currentColor"
143
- strokeWidth="2"
144
- strokeLinecap="square"
145
- strokeLinejoin="miter"
146
- />
147
- <circle
148
- cx="15"
149
- cy="16"
150
- r="3.5"
151
- fill={`url(#${id}-grad)`}
152
- mask={`url(#${id})`}
153
- />
154
- <defs>
155
- <linearGradient id={`${id}-grad`} x1="15" y1="12.5" x2="15" y2="16">
156
- <stop stopColor="#eab308" />
157
- <stop offset="1" stopColor="#dc2626" />
158
- </linearGradient>
159
- <mask id={id}>
160
- <rect x="0" y="0" width="24" height="16" fill="white" />
161
- </mask>
162
- </defs>
163
- </svg>
164
- );
165
- }
166
-
167
- function GitHubIcon() {
168
- return (
169
- <svg
170
- width="16"
171
- height="16"
172
- viewBox="0 0 24 24"
173
- fill="currentColor"
174
- aria-hidden="true"
175
- >
176
- <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
177
- </svg>
178
- );
179
- }
180
-
181
- function XIcon() {
182
- return (
183
- <svg
184
- width="14"
185
- height="14"
186
- viewBox="0 0 24 24"
187
- fill="currentColor"
188
- aria-hidden="true"
189
- >
190
- <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
191
- </svg>
192
- );
193
- }