@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,414 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useCallback, useEffect } from "react";
|
|
4
|
+
|
|
5
|
+
/* ------------------------------------------------------------------ */
|
|
6
|
+
/* Types */
|
|
7
|
+
/* ------------------------------------------------------------------ */
|
|
8
|
+
|
|
9
|
+
export interface WorkflowNode {
|
|
10
|
+
id: string;
|
|
11
|
+
label: string;
|
|
12
|
+
type: "source" | "process" | "storage" | "output" | "ai";
|
|
13
|
+
/** Position in % (0–100) relative to container */
|
|
14
|
+
position: { x: number; y: number };
|
|
15
|
+
detail: {
|
|
16
|
+
description: string;
|
|
17
|
+
tech: string[];
|
|
18
|
+
meta?: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface WorkflowEdge {
|
|
23
|
+
from: string;
|
|
24
|
+
to: string;
|
|
25
|
+
label?: string;
|
|
26
|
+
animated?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface WorkflowDiagramProps {
|
|
30
|
+
nodes: WorkflowNode[];
|
|
31
|
+
edges: WorkflowEdge[];
|
|
32
|
+
accent: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* ------------------------------------------------------------------ */
|
|
36
|
+
/* Helpers */
|
|
37
|
+
/* ------------------------------------------------------------------ */
|
|
38
|
+
|
|
39
|
+
/** Nodes connected to the given node (any direction). */
|
|
40
|
+
function connectedIds(nodeId: string, edges: WorkflowEdge[]): Set<string> {
|
|
41
|
+
const ids = new Set<string>();
|
|
42
|
+
ids.add(nodeId);
|
|
43
|
+
for (const e of edges) {
|
|
44
|
+
if (e.from === nodeId) ids.add(e.to);
|
|
45
|
+
if (e.to === nodeId) ids.add(e.from);
|
|
46
|
+
}
|
|
47
|
+
return ids;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Build a cubic‑bezier `d` attribute (vertical flow). */
|
|
51
|
+
function edgePath(
|
|
52
|
+
x1: number,
|
|
53
|
+
y1: number,
|
|
54
|
+
x2: number,
|
|
55
|
+
y2: number,
|
|
56
|
+
): string {
|
|
57
|
+
const midY = (y1 + y2) / 2;
|
|
58
|
+
return `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* ------------------------------------------------------------------ */
|
|
62
|
+
/* Sub‑components */
|
|
63
|
+
/* ------------------------------------------------------------------ */
|
|
64
|
+
|
|
65
|
+
function Tooltip({
|
|
66
|
+
node,
|
|
67
|
+
accent,
|
|
68
|
+
containerRect,
|
|
69
|
+
nodeRect,
|
|
70
|
+
}: {
|
|
71
|
+
node: WorkflowNode;
|
|
72
|
+
accent: string;
|
|
73
|
+
containerRect: DOMRect | null;
|
|
74
|
+
nodeRect: DOMRect | null;
|
|
75
|
+
}) {
|
|
76
|
+
if (!containerRect || !nodeRect) return null;
|
|
77
|
+
|
|
78
|
+
const tooltipWidth = 240;
|
|
79
|
+
const gap = 12;
|
|
80
|
+
|
|
81
|
+
// Position relative to container
|
|
82
|
+
const nodeCenter = nodeRect.left + nodeRect.width / 2 - containerRect.left;
|
|
83
|
+
const nodeBottom = nodeRect.bottom - containerRect.top + gap;
|
|
84
|
+
|
|
85
|
+
// Keep within container bounds
|
|
86
|
+
let left = nodeCenter - tooltipWidth / 2;
|
|
87
|
+
if (left < 8) left = 8;
|
|
88
|
+
if (left + tooltipWidth > containerRect.width - 8)
|
|
89
|
+
left = containerRect.width - tooltipWidth - 8;
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div
|
|
93
|
+
className="pointer-events-none absolute z-20 w-[240px] rounded-lg border border-border bg-popover p-3 shadow-md transition-all duration-150"
|
|
94
|
+
style={{ left, top: nodeBottom }}
|
|
95
|
+
>
|
|
96
|
+
<p className="mb-2 text-[13px] leading-snug text-foreground">
|
|
97
|
+
{node.detail.description}
|
|
98
|
+
</p>
|
|
99
|
+
<div className="flex flex-wrap gap-1">
|
|
100
|
+
{node.detail.tech.map((t) => (
|
|
101
|
+
<span
|
|
102
|
+
key={t}
|
|
103
|
+
className="rounded px-1.5 py-0.5 font-mono text-[10px]"
|
|
104
|
+
style={{
|
|
105
|
+
background: `color-mix(in oklch, ${accent} 10%, transparent)`,
|
|
106
|
+
color: accent,
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
{t}
|
|
110
|
+
</span>
|
|
111
|
+
))}
|
|
112
|
+
</div>
|
|
113
|
+
{node.detail.meta && (
|
|
114
|
+
<p className="mt-2 font-mono text-[10px] text-muted-foreground">
|
|
115
|
+
{node.detail.meta}
|
|
116
|
+
</p>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* ------------------------------------------------------------------ */
|
|
123
|
+
/* Mobile fallback — vertical pipeline */
|
|
124
|
+
/* ------------------------------------------------------------------ */
|
|
125
|
+
|
|
126
|
+
function MobilePipeline({
|
|
127
|
+
nodes,
|
|
128
|
+
edges,
|
|
129
|
+
accent,
|
|
130
|
+
}: WorkflowDiagramProps) {
|
|
131
|
+
const [expanded, setExpanded] = useState<string | null>(null);
|
|
132
|
+
|
|
133
|
+
// Topological-ish order: sort by y position
|
|
134
|
+
const sorted = [...nodes].sort((a, b) => a.position.y - b.position.y);
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div className="flex flex-col gap-0">
|
|
138
|
+
{sorted.map((node, i) => {
|
|
139
|
+
const isOpen = expanded === node.id;
|
|
140
|
+
return (
|
|
141
|
+
<div key={node.id} className="flex items-start gap-4">
|
|
142
|
+
{/* Marker */}
|
|
143
|
+
<div className="flex flex-col items-center">
|
|
144
|
+
<div
|
|
145
|
+
className="h-2.5 w-2.5 shrink-0 rounded-full border-2"
|
|
146
|
+
style={{
|
|
147
|
+
borderColor: accent,
|
|
148
|
+
background:
|
|
149
|
+
node.type === "ai" || node.type === "output"
|
|
150
|
+
? accent
|
|
151
|
+
: "transparent",
|
|
152
|
+
}}
|
|
153
|
+
/>
|
|
154
|
+
{i < sorted.length - 1 && (
|
|
155
|
+
<div className="w-px flex-1 min-h-[32px] bg-border" />
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{/* Content */}
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
className="mb-4 flex-1 text-left"
|
|
163
|
+
onClick={() => setExpanded(isOpen ? null : node.id)}
|
|
164
|
+
>
|
|
165
|
+
<p className="text-[13px] font-medium text-foreground">
|
|
166
|
+
{node.label}
|
|
167
|
+
</p>
|
|
168
|
+
{isOpen && (
|
|
169
|
+
<div className="mt-2 rounded-lg border border-border bg-popover p-3">
|
|
170
|
+
<p className="mb-2 text-[12px] leading-relaxed text-muted-foreground">
|
|
171
|
+
{node.detail.description}
|
|
172
|
+
</p>
|
|
173
|
+
<div className="flex flex-wrap gap-1">
|
|
174
|
+
{node.detail.tech.map((t) => (
|
|
175
|
+
<span
|
|
176
|
+
key={t}
|
|
177
|
+
className="rounded px-1.5 py-0.5 font-mono text-[10px]"
|
|
178
|
+
style={{
|
|
179
|
+
background: `color-mix(in oklch, ${accent} 10%, transparent)`,
|
|
180
|
+
color: accent,
|
|
181
|
+
}}
|
|
182
|
+
>
|
|
183
|
+
{t}
|
|
184
|
+
</span>
|
|
185
|
+
))}
|
|
186
|
+
</div>
|
|
187
|
+
{node.detail.meta && (
|
|
188
|
+
<p className="mt-1.5 font-mono text-[10px] text-muted-foreground">
|
|
189
|
+
{node.detail.meta}
|
|
190
|
+
</p>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
</button>
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
})}
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* ------------------------------------------------------------------ */
|
|
203
|
+
/* Main diagram (desktop) */
|
|
204
|
+
/* ------------------------------------------------------------------ */
|
|
205
|
+
|
|
206
|
+
export function WorkflowDiagram({
|
|
207
|
+
nodes,
|
|
208
|
+
edges,
|
|
209
|
+
accent,
|
|
210
|
+
}: WorkflowDiagramProps) {
|
|
211
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
212
|
+
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
|
213
|
+
const [hovered, setHovered] = useState<string | null>(null);
|
|
214
|
+
const [positions, setPositions] = useState<
|
|
215
|
+
Map<string, { cx: number; cy: number }>
|
|
216
|
+
>(new Map());
|
|
217
|
+
const [containerRect, setContainerRect] = useState<DOMRect | null>(null);
|
|
218
|
+
const [hoveredRect, setHoveredRect] = useState<DOMRect | null>(null);
|
|
219
|
+
|
|
220
|
+
// Compute pixel centres after layout
|
|
221
|
+
const measure = useCallback(() => {
|
|
222
|
+
const cEl = containerRef.current;
|
|
223
|
+
if (!cEl) return;
|
|
224
|
+
const cRect = cEl.getBoundingClientRect();
|
|
225
|
+
setContainerRect(cRect);
|
|
226
|
+
const map = new Map<string, { cx: number; cy: number }>();
|
|
227
|
+
nodeRefs.current.forEach((el, id) => {
|
|
228
|
+
const r = el.getBoundingClientRect();
|
|
229
|
+
map.set(id, {
|
|
230
|
+
cx: r.left + r.width / 2 - cRect.left,
|
|
231
|
+
cy: r.top + r.height / 2 - cRect.top,
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
setPositions(map);
|
|
235
|
+
}, []);
|
|
236
|
+
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
measure();
|
|
239
|
+
window.addEventListener("resize", measure);
|
|
240
|
+
return () => window.removeEventListener("resize", measure);
|
|
241
|
+
}, [measure]);
|
|
242
|
+
|
|
243
|
+
// Re‑measure once after first paint (node refs populated)
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
const t = setTimeout(measure, 50);
|
|
246
|
+
return () => clearTimeout(t);
|
|
247
|
+
}, [measure]);
|
|
248
|
+
|
|
249
|
+
const connected = hovered ? connectedIds(hovered, edges) : null;
|
|
250
|
+
|
|
251
|
+
const handleHover = (id: string | null) => {
|
|
252
|
+
setHovered(id);
|
|
253
|
+
if (id) {
|
|
254
|
+
const el = nodeRefs.current.get(id);
|
|
255
|
+
if (el) setHoveredRect(el.getBoundingClientRect());
|
|
256
|
+
} else {
|
|
257
|
+
setHoveredRect(null);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const hoveredNode = hovered
|
|
262
|
+
? nodes.find((n) => n.id === hovered) ?? null
|
|
263
|
+
: null;
|
|
264
|
+
|
|
265
|
+
// Container height based on node Y spread
|
|
266
|
+
const maxY = Math.max(...nodes.map((n) => n.position.y));
|
|
267
|
+
const containerHeight = Math.max(400, (maxY / 100) * 600 + 60);
|
|
268
|
+
|
|
269
|
+
return (
|
|
270
|
+
<>
|
|
271
|
+
{/* Desktop */}
|
|
272
|
+
<div
|
|
273
|
+
ref={containerRef}
|
|
274
|
+
className="relative hidden md:block"
|
|
275
|
+
style={{ height: containerHeight }}
|
|
276
|
+
onMouseLeave={() => handleHover(null)}
|
|
277
|
+
>
|
|
278
|
+
{/* SVG edges */}
|
|
279
|
+
<svg
|
|
280
|
+
className="pointer-events-none absolute inset-0 h-full w-full"
|
|
281
|
+
aria-hidden="true"
|
|
282
|
+
>
|
|
283
|
+
<defs>
|
|
284
|
+
<marker
|
|
285
|
+
id="arrowhead"
|
|
286
|
+
markerWidth="6"
|
|
287
|
+
markerHeight="5"
|
|
288
|
+
refX="5"
|
|
289
|
+
refY="2.5"
|
|
290
|
+
orient="auto"
|
|
291
|
+
>
|
|
292
|
+
<polygon
|
|
293
|
+
points="0 0, 6 2.5, 0 5"
|
|
294
|
+
fill={`color-mix(in oklch, ${accent} 30%, transparent)`}
|
|
295
|
+
/>
|
|
296
|
+
</marker>
|
|
297
|
+
</defs>
|
|
298
|
+
{edges.map((edge) => {
|
|
299
|
+
const from = positions.get(edge.from);
|
|
300
|
+
const to = positions.get(edge.to);
|
|
301
|
+
if (!from || !to) return null;
|
|
302
|
+
|
|
303
|
+
const isConnected =
|
|
304
|
+
!connected ||
|
|
305
|
+
(connected.has(edge.from) && connected.has(edge.to));
|
|
306
|
+
const isDirectlyConnected =
|
|
307
|
+
hovered === edge.from || hovered === edge.to;
|
|
308
|
+
|
|
309
|
+
return (
|
|
310
|
+
<g key={`${edge.from}-${edge.to}`}>
|
|
311
|
+
<path
|
|
312
|
+
d={edgePath(from.cx, from.cy, to.cx, to.cy)}
|
|
313
|
+
fill="none"
|
|
314
|
+
stroke={accent}
|
|
315
|
+
strokeWidth={isDirectlyConnected ? 2 : 1.5}
|
|
316
|
+
strokeOpacity={
|
|
317
|
+
!connected ? 0.15 : isConnected ? 0.4 : 0.05
|
|
318
|
+
}
|
|
319
|
+
markerEnd="url(#arrowhead)"
|
|
320
|
+
className="transition-all duration-200"
|
|
321
|
+
strokeDasharray={edge.animated ? "6 4" : undefined}
|
|
322
|
+
>
|
|
323
|
+
{edge.animated && (
|
|
324
|
+
<animate
|
|
325
|
+
attributeName="stroke-dashoffset"
|
|
326
|
+
from="0"
|
|
327
|
+
to="-20"
|
|
328
|
+
dur="1.5s"
|
|
329
|
+
repeatCount="indefinite"
|
|
330
|
+
/>
|
|
331
|
+
)}
|
|
332
|
+
</path>
|
|
333
|
+
{edge.label && (
|
|
334
|
+
<text
|
|
335
|
+
x={(from.cx + to.cx) / 2}
|
|
336
|
+
y={(from.cy + to.cy) / 2 - 6}
|
|
337
|
+
textAnchor="middle"
|
|
338
|
+
className="fill-muted-foreground text-[9px] font-mono"
|
|
339
|
+
opacity={!connected ? 0.4 : isConnected ? 0.7 : 0.1}
|
|
340
|
+
>
|
|
341
|
+
{edge.label}
|
|
342
|
+
</text>
|
|
343
|
+
)}
|
|
344
|
+
</g>
|
|
345
|
+
);
|
|
346
|
+
})}
|
|
347
|
+
</svg>
|
|
348
|
+
|
|
349
|
+
{/* Nodes */}
|
|
350
|
+
{nodes.map((node) => {
|
|
351
|
+
const isDim = connected && !connected.has(node.id);
|
|
352
|
+
const isActive = hovered === node.id;
|
|
353
|
+
|
|
354
|
+
return (
|
|
355
|
+
<div
|
|
356
|
+
key={node.id}
|
|
357
|
+
ref={(el) => {
|
|
358
|
+
if (el) nodeRefs.current.set(node.id, el);
|
|
359
|
+
}}
|
|
360
|
+
className="absolute -translate-x-1/2 -translate-y-1/2 cursor-default select-none rounded-lg border px-4 py-2.5 font-mono text-[13px] font-medium transition-all duration-200"
|
|
361
|
+
style={{
|
|
362
|
+
left: `${node.position.x}%`,
|
|
363
|
+
top: `${node.position.y}%`,
|
|
364
|
+
borderColor: isDim
|
|
365
|
+
? "var(--border)"
|
|
366
|
+
: node.type === "ai"
|
|
367
|
+
? `color-mix(in oklch, ${accent} ${isActive ? "50%" : "25%"}, transparent)`
|
|
368
|
+
: node.type === "output"
|
|
369
|
+
? `color-mix(in oklch, ${accent} ${isActive ? "40%" : "18%"}, transparent)`
|
|
370
|
+
: "var(--border)",
|
|
371
|
+
background: isDim
|
|
372
|
+
? "var(--background)"
|
|
373
|
+
: node.type === "ai"
|
|
374
|
+
? `color-mix(in oklch, ${accent} ${isActive ? "12%" : "6%"}, transparent)`
|
|
375
|
+
: "var(--background)",
|
|
376
|
+
opacity: isDim ? 0.3 : 1,
|
|
377
|
+
transform: `translate(-50%, -50%) ${isActive ? "scale(1.04)" : "scale(1)"}`,
|
|
378
|
+
zIndex: isActive ? 10 : 1,
|
|
379
|
+
}}
|
|
380
|
+
onMouseEnter={() => handleHover(node.id)}
|
|
381
|
+
role="button"
|
|
382
|
+
tabIndex={0}
|
|
383
|
+
aria-label={`${node.label}: ${node.detail.description}`}
|
|
384
|
+
onFocus={() => handleHover(node.id)}
|
|
385
|
+
onBlur={() => handleHover(null)}
|
|
386
|
+
onKeyDown={(e) => {
|
|
387
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
388
|
+
handleHover(hovered === node.id ? null : node.id);
|
|
389
|
+
}
|
|
390
|
+
}}
|
|
391
|
+
>
|
|
392
|
+
{node.label}
|
|
393
|
+
</div>
|
|
394
|
+
);
|
|
395
|
+
})}
|
|
396
|
+
|
|
397
|
+
{/* Tooltip */}
|
|
398
|
+
{hoveredNode && (
|
|
399
|
+
<Tooltip
|
|
400
|
+
node={hoveredNode}
|
|
401
|
+
accent={accent}
|
|
402
|
+
containerRect={containerRect}
|
|
403
|
+
nodeRect={hoveredRect}
|
|
404
|
+
/>
|
|
405
|
+
)}
|
|
406
|
+
</div>
|
|
407
|
+
|
|
408
|
+
{/* Mobile */}
|
|
409
|
+
<div className="md:hidden">
|
|
410
|
+
<MobilePipeline nodes={nodes} edges={edges} accent={accent} />
|
|
411
|
+
</div>
|
|
412
|
+
</>
|
|
413
|
+
);
|
|
414
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { getRequestConfig } from "next-intl/server";
|
|
2
|
+
import { routing } from "./routing";
|
|
3
|
+
|
|
4
|
+
export default getRequestConfig(async ({ requestLocale }) => {
|
|
5
|
+
let locale = await requestLocale;
|
|
6
|
+
if (
|
|
7
|
+
!locale ||
|
|
8
|
+
!routing.locales.includes(locale as (typeof routing.locales)[number])
|
|
9
|
+
) {
|
|
10
|
+
locale = routing.defaultLocale;
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
locale,
|
|
14
|
+
messages: (await import(`../../messages/${locale}.json`)).default,
|
|
15
|
+
};
|
|
16
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineRouting } from "next-intl/routing";
|
|
2
|
+
import { createNavigation } from "next-intl/navigation";
|
|
3
|
+
import { locales, defaultLocale } from "./config";
|
|
4
|
+
|
|
5
|
+
export const routing = defineRouting({
|
|
6
|
+
locales,
|
|
7
|
+
defaultLocale,
|
|
8
|
+
localePrefix: "as-needed",
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const { Link, redirect, usePathname, useRouter, getPathname } =
|
|
12
|
+
createNavigation(routing);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createBrowserClient } from "@supabase/ssr";
|
|
2
|
+
|
|
3
|
+
const COOKIE_DOMAIN =
|
|
4
|
+
typeof window !== "undefined" && window.location.hostname.endsWith(".naeil.dev")
|
|
5
|
+
? ".naeil.dev"
|
|
6
|
+
: undefined;
|
|
7
|
+
|
|
8
|
+
export function createClient() {
|
|
9
|
+
return createBrowserClient(
|
|
10
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
11
|
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
12
|
+
{
|
|
13
|
+
cookieOptions: {
|
|
14
|
+
domain: COOKIE_DOMAIN,
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createServerClient } from "@supabase/ssr";
|
|
2
|
+
import { type NextRequest, NextResponse } from "next/server";
|
|
3
|
+
|
|
4
|
+
function getCookieDomain(request: NextRequest): string | undefined {
|
|
5
|
+
const host = request.headers.get("host") || "";
|
|
6
|
+
return host.endsWith(".naeil.dev") || host === "naeil.dev"
|
|
7
|
+
? ".naeil.dev"
|
|
8
|
+
: undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function updateSession(request: NextRequest) {
|
|
12
|
+
let supabaseResponse = NextResponse.next({ request });
|
|
13
|
+
const domain = getCookieDomain(request);
|
|
14
|
+
|
|
15
|
+
const supabase = createServerClient(
|
|
16
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
17
|
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
18
|
+
{
|
|
19
|
+
cookies: {
|
|
20
|
+
getAll() {
|
|
21
|
+
return request.cookies.getAll();
|
|
22
|
+
},
|
|
23
|
+
setAll(cookiesToSet) {
|
|
24
|
+
cookiesToSet.forEach(({ name, value }) =>
|
|
25
|
+
request.cookies.set(name, value),
|
|
26
|
+
);
|
|
27
|
+
supabaseResponse = NextResponse.next({ request });
|
|
28
|
+
cookiesToSet.forEach(({ name, value, options }) =>
|
|
29
|
+
supabaseResponse.cookies.set(name, value, { ...options, domain }),
|
|
30
|
+
);
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// Refresh the session — this calls Supabase Auth to validate/refresh tokens
|
|
37
|
+
const {
|
|
38
|
+
data: { user },
|
|
39
|
+
} = await supabase.auth.getUser();
|
|
40
|
+
|
|
41
|
+
return { response: supabaseResponse, user };
|
|
42
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createServerClient } from "@supabase/ssr";
|
|
2
|
+
import { cookies, headers } from "next/headers";
|
|
3
|
+
|
|
4
|
+
function getCookieDomain(headerStore: Headers): string | undefined {
|
|
5
|
+
const host = headerStore.get("host") || "";
|
|
6
|
+
return host.endsWith(".naeil.dev") || host === "naeil.dev"
|
|
7
|
+
? ".naeil.dev"
|
|
8
|
+
: undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function createClient() {
|
|
12
|
+
const cookieStore = await cookies();
|
|
13
|
+
const headerStore = await headers();
|
|
14
|
+
const domain = getCookieDomain(headerStore);
|
|
15
|
+
|
|
16
|
+
return createServerClient(
|
|
17
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
18
|
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
19
|
+
{
|
|
20
|
+
cookies: {
|
|
21
|
+
getAll() {
|
|
22
|
+
return cookieStore.getAll();
|
|
23
|
+
},
|
|
24
|
+
setAll(cookiesToSet) {
|
|
25
|
+
try {
|
|
26
|
+
cookiesToSet.forEach(({ name, value, options }) =>
|
|
27
|
+
cookieStore.set(name, value, { ...options, domain }),
|
|
28
|
+
);
|
|
29
|
+
} catch {
|
|
30
|
+
// The `setAll` method was called from a Server Component.
|
|
31
|
+
// This can be ignored if you have middleware refreshing sessions.
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
}
|
package/src/lib/utils.ts
ADDED
package/src/proxy.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import createIntlMiddleware from "next-intl/middleware";
|
|
3
|
+
import { routing } from "./i18n/routing";
|
|
4
|
+
import { updateSession } from "./lib/supabase/middleware";
|
|
5
|
+
|
|
6
|
+
const intlMiddleware = createIntlMiddleware(routing);
|
|
7
|
+
|
|
8
|
+
// Matches /sa/reports/<id> with optional locale prefix, but NOT /sa/reports
|
|
9
|
+
const PROTECTED_ROUTE = /^\/(?:(?:en|ko|ja)\/)?sa\/reports\/[^/]+/;
|
|
10
|
+
|
|
11
|
+
export default async function proxy(request: NextRequest) {
|
|
12
|
+
// 1. Refresh Supabase session (updates auth cookies)
|
|
13
|
+
const { response: supabaseResponse, user } = await updateSession(request);
|
|
14
|
+
|
|
15
|
+
// 2. Capture URL info BEFORE intl middleware (which may mutate request.nextUrl)
|
|
16
|
+
const { pathname } = request.nextUrl;
|
|
17
|
+
const nextParam = request.nextUrl.searchParams.get("next");
|
|
18
|
+
|
|
19
|
+
// 3. Check protected routes — redirect to login if unauthenticated
|
|
20
|
+
if (PROTECTED_ROUTE.test(pathname) && !user) {
|
|
21
|
+
const loginUrl = request.nextUrl.clone();
|
|
22
|
+
loginUrl.pathname = "/login";
|
|
23
|
+
loginUrl.searchParams.set("next", pathname);
|
|
24
|
+
return NextResponse.redirect(loginUrl);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 4. Run next-intl middleware for locale routing
|
|
28
|
+
const intlResponse = intlMiddleware(request);
|
|
29
|
+
|
|
30
|
+
// 5. If visiting /login with a ?next param, store it in a cookie for post-OAuth redirect
|
|
31
|
+
// Skip if next points back to /login itself (RSC refetch sends next=/login which overwrites the real value)
|
|
32
|
+
const loginMatch = pathname.match(/^\/(?:(?:en|ko|ja)\/)?login$/);
|
|
33
|
+
const isLoginSelf = nextParam?.replace(/^\/(?:en|ko|ja)\//, '/') === '/login';
|
|
34
|
+
if (loginMatch && nextParam && !isLoginSelf) {
|
|
35
|
+
intlResponse.cookies.set("auth_redirect_to", nextParam, {
|
|
36
|
+
path: "/",
|
|
37
|
+
maxAge: 600,
|
|
38
|
+
httpOnly: true,
|
|
39
|
+
sameSite: "lax",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 6. Copy Supabase auth cookies onto the intl response
|
|
44
|
+
supabaseResponse.cookies.getAll().forEach((cookie) => {
|
|
45
|
+
intlResponse.cookies.set(cookie.name, cookie.value);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return intlResponse;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const config = {
|
|
52
|
+
matcher: [
|
|
53
|
+
/*
|
|
54
|
+
* Match all paths except:
|
|
55
|
+
* - _next (Next.js internals)
|
|
56
|
+
* - Static files (images, fonts, etc.)
|
|
57
|
+
* - favicon.ico
|
|
58
|
+
* - auth/callback (handled by route handler, not locale-prefixed)
|
|
59
|
+
*/
|
|
60
|
+
"/((?!_next|auth/|.*\\..*|favicon\\.ico).*)",
|
|
61
|
+
],
|
|
62
|
+
};
|
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"color": {
|
|
3
|
+
"$description": "Primitive color palette — OKLCH color space",
|
|
4
|
+
"gray": {
|
|
5
|
+
"$description": "Pure neutral gray scale (chroma 0). Dark-first: 950=background, 50=text direction.",
|
|
6
|
+
"50": { "$type": "color", "$value": "oklch(0.985 0 0)" },
|
|
7
|
+
"100": { "$type": "color", "$value": "oklch(0.970 0 0)" },
|
|
8
|
+
"200": { "$type": "color", "$value": "oklch(0.922 0 0)" },
|
|
9
|
+
"300": { "$type": "color", "$value": "oklch(0.870 0 0)" },
|
|
10
|
+
"400": { "$type": "color", "$value": "oklch(0.708 0 0)" },
|
|
11
|
+
"500": { "$type": "color", "$value": "oklch(0.556 0 0)" },
|
|
12
|
+
"600": { "$type": "color", "$value": "oklch(0.439 0 0)" },
|
|
13
|
+
"700": { "$type": "color", "$value": "oklch(0.371 0 0)" },
|
|
14
|
+
"800": { "$type": "color", "$value": "oklch(0.269 0 0)" },
|
|
15
|
+
"900": { "$type": "color", "$value": "oklch(0.205 0 0)" },
|
|
16
|
+
"950": { "$type": "color", "$value": "oklch(0.145 0 0)" }
|
|
17
|
+
},
|
|
18
|
+
"accent": {
|
|
19
|
+
"$description": "Neutral blue accent scale. Default values — consumer projects may override.",
|
|
20
|
+
"50": { "$type": "color", "$value": "oklch(0.970 0.014 254)" },
|
|
21
|
+
"100": { "$type": "color", "$value": "oklch(0.932 0.032 255)" },
|
|
22
|
+
"200": { "$type": "color", "$value": "oklch(0.882 0.059 254)" },
|
|
23
|
+
"300": { "$type": "color", "$value": "oklch(0.809 0.105 254)" },
|
|
24
|
+
"400": { "$type": "color", "$value": "oklch(0.707 0.165 254)" },
|
|
25
|
+
"500": { "$type": "color", "$value": "oklch(0.623 0.214 259)" },
|
|
26
|
+
"600": { "$type": "color", "$value": "oklch(0.546 0.245 263)" },
|
|
27
|
+
"700": { "$type": "color", "$value": "oklch(0.488 0.243 264.376)" },
|
|
28
|
+
"800": { "$type": "color", "$value": "oklch(0.424 0.199 265)" },
|
|
29
|
+
"900": { "$type": "color", "$value": "oklch(0.379 0.146 265)" },
|
|
30
|
+
"950": { "$type": "color", "$value": "oklch(0.282 0.091 267)" }
|
|
31
|
+
},
|
|
32
|
+
"destructive": {
|
|
33
|
+
"$description": "Red scale for destructive/error states.",
|
|
34
|
+
"50": { "$type": "color", "$value": "oklch(0.971 0.013 17)" },
|
|
35
|
+
"100": { "$type": "color", "$value": "oklch(0.936 0.032 17)" },
|
|
36
|
+
"200": { "$type": "color", "$value": "oklch(0.885 0.062 18)" },
|
|
37
|
+
"300": { "$type": "color", "$value": "oklch(0.808 0.114 19)" },
|
|
38
|
+
"400": { "$type": "color", "$value": "oklch(0.704 0.191 22.216)" },
|
|
39
|
+
"500": { "$type": "color", "$value": "oklch(0.637 0.237 25)" },
|
|
40
|
+
"600": { "$type": "color", "$value": "oklch(0.577 0.245 27.325)" },
|
|
41
|
+
"700": { "$type": "color", "$value": "oklch(0.505 0.213 27)" },
|
|
42
|
+
"800": { "$type": "color", "$value": "oklch(0.444 0.177 26)" },
|
|
43
|
+
"900": { "$type": "color", "$value": "oklch(0.396 0.141 25)" },
|
|
44
|
+
"950": { "$type": "color", "$value": "oklch(0.258 0.092 26)" }
|
|
45
|
+
},
|
|
46
|
+
"project": {
|
|
47
|
+
"$description": "Per-project accent colors — theme-independent, used across light/dark.",
|
|
48
|
+
"cc": { "$type": "color", "$value": "oklch(0.723 0.219 149)" },
|
|
49
|
+
"pkm": { "$type": "color", "$value": "oklch(0.627 0.265 303)" },
|
|
50
|
+
"naeilUi": { "$type": "color", "$value": "oklch(0.623 0.214 259)" },
|
|
51
|
+
"baby": { "$type": "color", "$value": "oklch(0.769 0.188 70.08)" }
|
|
52
|
+
},
|
|
53
|
+
"white": { "$type": "color", "$value": "oklch(1 0 0)" },
|
|
54
|
+
"black": { "$type": "color", "$value": "oklch(0 0 0)" }
|
|
55
|
+
}
|
|
56
|
+
}
|