@useatlas/react 0.0.1
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 +95 -0
- package/dist/chunk-2WFDP7G5.js +231 -0
- package/dist/chunk-2WFDP7G5.js.map +1 -0
- package/dist/chunk-44HBZYKP.js +224 -0
- package/dist/chunk-44HBZYKP.js.map +1 -0
- package/dist/chunk-5SEVKHS5.cjs +229 -0
- package/dist/chunk-5SEVKHS5.cjs.map +1 -0
- package/dist/chunk-UIRB6L36.cjs +249 -0
- package/dist/chunk-UIRB6L36.cjs.map +1 -0
- package/dist/hooks.cjs +251 -0
- package/dist/hooks.cjs.map +1 -0
- package/dist/hooks.d.cts +132 -0
- package/dist/hooks.d.ts +132 -0
- package/dist/hooks.js +237 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.cjs +2976 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +69 -0
- package/dist/index.d.ts +69 -0
- package/dist/index.js +2926 -0
- package/dist/index.js.map +1 -0
- package/dist/result-chart-NFAJ4IQ5.js +398 -0
- package/dist/result-chart-NFAJ4IQ5.js.map +1 -0
- package/dist/result-chart-YLCKBNV4.cjs +400 -0
- package/dist/result-chart-YLCKBNV4.cjs.map +1 -0
- package/dist/styles.css +59 -0
- package/dist/use-dark-mode-rFxawUv1.d.cts +123 -0
- package/dist/use-dark-mode-rFxawUv1.d.ts +123 -0
- package/dist/widget.css +2 -0
- package/dist/widget.js +445 -0
- package/package.json +113 -0
- package/src/components/__tests__/tool-renderers.test.tsx +239 -0
- package/src/components/actions/action-approval-card.tsx +296 -0
- package/src/components/actions/action-status-badge.tsx +50 -0
- package/src/components/admin/change-password-dialog.tsx +128 -0
- package/src/components/atlas-chat.tsx +656 -0
- package/src/components/chart/chart-detection.ts +318 -0
- package/src/components/chart/result-chart.tsx +590 -0
- package/src/components/chat/api-key-bar.tsx +66 -0
- package/src/components/chat/copy-button.tsx +25 -0
- package/src/components/chat/data-table.tsx +104 -0
- package/src/components/chat/error-banner.tsx +32 -0
- package/src/components/chat/explore-card.tsx +41 -0
- package/src/components/chat/follow-up-chips.tsx +29 -0
- package/src/components/chat/loading-card.tsx +10 -0
- package/src/components/chat/managed-auth-card.tsx +116 -0
- package/src/components/chat/markdown.tsx +146 -0
- package/src/components/chat/python-result-card.tsx +245 -0
- package/src/components/chat/sql-block.tsx +54 -0
- package/src/components/chat/sql-result-card.tsx +163 -0
- package/src/components/chat/starter-prompts.ts +6 -0
- package/src/components/chat/tool-part.tsx +106 -0
- package/src/components/chat/typing-indicator.tsx +22 -0
- package/src/components/conversations/conversation-item.tsx +135 -0
- package/src/components/conversations/conversation-list.tsx +69 -0
- package/src/components/conversations/conversation-sidebar.tsx +113 -0
- package/src/components/conversations/delete-confirmation.tsx +27 -0
- package/src/components/schema-explorer/schema-explorer.tsx +517 -0
- package/src/components/ui/alert-dialog.tsx +196 -0
- package/src/components/ui/badge.tsx +48 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/card.tsx +92 -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/label.tsx +24 -0
- package/src/components/ui/scroll-area.tsx +62 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +143 -0
- package/src/components/ui/table.tsx +116 -0
- package/src/components/ui/toggle-group.tsx +83 -0
- package/src/components/ui/toggle.tsx +47 -0
- package/src/context.tsx +85 -0
- package/src/env.d.ts +9 -0
- package/src/hooks/__tests__/provider.test.tsx +83 -0
- package/src/hooks/__tests__/use-atlas-auth.test.tsx +283 -0
- package/src/hooks/__tests__/use-atlas-chat.test.tsx +157 -0
- package/src/hooks/__tests__/use-atlas-conversations.test.tsx +159 -0
- package/src/hooks/__tests__/use-atlas-theme.test.tsx +56 -0
- package/src/hooks/index.ts +47 -0
- package/src/hooks/provider.tsx +77 -0
- package/src/hooks/theme-init-script.ts +17 -0
- package/src/hooks/use-atlas-auth.ts +131 -0
- package/src/hooks/use-atlas-chat.ts +102 -0
- package/src/hooks/use-atlas-conversations.ts +61 -0
- package/src/hooks/use-atlas-theme.ts +34 -0
- package/src/hooks/use-conversations.ts +189 -0
- package/src/hooks/use-dark-mode.ts +150 -0
- package/src/index.ts +36 -0
- package/src/lib/action-types.ts +11 -0
- package/src/lib/helpers.ts +198 -0
- package/src/lib/tool-renderer-types.ts +76 -0
- package/src/lib/types.ts +29 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles.css +59 -0
- package/src/test-setup.ts +55 -0
- package/src/widget-entry.ts +20 -0
- package/src/widget.css +12 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { formatCell } from "../../lib/helpers";
|
|
5
|
+
|
|
6
|
+
export function DataTable({
|
|
7
|
+
columns,
|
|
8
|
+
rows,
|
|
9
|
+
maxRows = 10,
|
|
10
|
+
}: {
|
|
11
|
+
columns: string[];
|
|
12
|
+
rows: (Record<string, unknown> | unknown[])[];
|
|
13
|
+
maxRows?: number;
|
|
14
|
+
}) {
|
|
15
|
+
const [sortCol, setSortCol] = useState<number | null>(null);
|
|
16
|
+
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
|
17
|
+
|
|
18
|
+
const hasMore = rows.length > maxRows;
|
|
19
|
+
|
|
20
|
+
const cell = (row: Record<string, unknown> | unknown[], colIdx: number): unknown => {
|
|
21
|
+
if (Array.isArray(row)) return row[colIdx];
|
|
22
|
+
return (row as Record<string, unknown>)[columns[colIdx]];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const handleSort = (colIdx: number) => {
|
|
26
|
+
if (sortCol === colIdx) {
|
|
27
|
+
if (sortDir === "asc") {
|
|
28
|
+
setSortDir("desc");
|
|
29
|
+
} else {
|
|
30
|
+
setSortCol(null);
|
|
31
|
+
setSortDir("asc");
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
setSortCol(colIdx);
|
|
35
|
+
setSortDir("asc");
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const sorted = sortCol !== null
|
|
40
|
+
? [...rows].sort((a, b) => {
|
|
41
|
+
const av = cell(a, sortCol);
|
|
42
|
+
const bv = cell(b, sortCol);
|
|
43
|
+
if (av == null && bv == null) return 0;
|
|
44
|
+
if (av == null) return 1;
|
|
45
|
+
if (bv == null) return -1;
|
|
46
|
+
const aStr = String(av).trim();
|
|
47
|
+
const bStr = String(bv).trim();
|
|
48
|
+
if (aStr === "" && bStr === "") return 0;
|
|
49
|
+
if (aStr === "") return 1;
|
|
50
|
+
if (bStr === "") return -1;
|
|
51
|
+
const an = Number(aStr), bn = Number(bStr);
|
|
52
|
+
if (!isNaN(an) && !isNaN(bn)) {
|
|
53
|
+
return sortDir === "asc" ? an - bn : bn - an;
|
|
54
|
+
}
|
|
55
|
+
const cmp = aStr.localeCompare(bStr);
|
|
56
|
+
return sortDir === "asc" ? cmp : -cmp;
|
|
57
|
+
})
|
|
58
|
+
: rows;
|
|
59
|
+
const display = sorted.slice(0, maxRows);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="relative rounded-lg border border-zinc-200 dark:border-zinc-700">
|
|
63
|
+
<div className="overflow-x-auto [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-zinc-300 dark:[&::-webkit-scrollbar-thumb]:bg-zinc-600">
|
|
64
|
+
<table className="min-w-full text-xs">
|
|
65
|
+
<thead>
|
|
66
|
+
<tr className="border-b border-zinc-200 bg-zinc-100/80 dark:border-zinc-700 dark:bg-zinc-800/80">
|
|
67
|
+
{columns.map((col, i) => (
|
|
68
|
+
<th
|
|
69
|
+
key={i}
|
|
70
|
+
onClick={() => handleSort(i)}
|
|
71
|
+
className="group cursor-pointer select-none whitespace-nowrap px-3 py-2 text-left font-medium text-zinc-500 transition-colors hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
|
|
72
|
+
>
|
|
73
|
+
{col}
|
|
74
|
+
{sortCol === i
|
|
75
|
+
? sortDir === "asc" ? " \u25B2" : " \u25BC"
|
|
76
|
+
: ""}
|
|
77
|
+
</th>
|
|
78
|
+
))}
|
|
79
|
+
</tr>
|
|
80
|
+
</thead>
|
|
81
|
+
<tbody>
|
|
82
|
+
{display.map((row, i) => (
|
|
83
|
+
<tr
|
|
84
|
+
key={i}
|
|
85
|
+
className={i % 2 === 0 ? "bg-zinc-100/60 dark:bg-zinc-900/60" : "bg-zinc-50/30 dark:bg-zinc-900/30"}
|
|
86
|
+
>
|
|
87
|
+
{columns.map((_, j) => (
|
|
88
|
+
<td key={j} className="whitespace-nowrap px-3 py-1.5 text-zinc-700 dark:text-zinc-300">
|
|
89
|
+
{formatCell(cell(row, j))}
|
|
90
|
+
</td>
|
|
91
|
+
))}
|
|
92
|
+
</tr>
|
|
93
|
+
))}
|
|
94
|
+
</tbody>
|
|
95
|
+
</table>
|
|
96
|
+
</div>
|
|
97
|
+
{hasMore && (
|
|
98
|
+
<div className="border-t border-zinc-200 px-3 py-1.5 text-xs text-zinc-500 dark:border-zinc-700">
|
|
99
|
+
Showing {maxRows} of {rows.length} rows
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useMemo } from "react";
|
|
4
|
+
import { parseChatError, type AuthMode } from "../../lib/types";
|
|
5
|
+
|
|
6
|
+
export function ErrorBanner({ error, authMode }: { error: Error; authMode: AuthMode }) {
|
|
7
|
+
const info = useMemo(() => parseChatError(error, authMode), [error, authMode]);
|
|
8
|
+
const [countdown, setCountdown] = useState(info.retryAfterSeconds ?? 0);
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (!info.retryAfterSeconds) return;
|
|
12
|
+
setCountdown(info.retryAfterSeconds);
|
|
13
|
+
const interval = setInterval(() => {
|
|
14
|
+
setCountdown((prev) => {
|
|
15
|
+
if (prev <= 1) { clearInterval(interval); return 0; }
|
|
16
|
+
return prev - 1;
|
|
17
|
+
});
|
|
18
|
+
}, 1000);
|
|
19
|
+
return () => clearInterval(interval);
|
|
20
|
+
}, [info.retryAfterSeconds]);
|
|
21
|
+
|
|
22
|
+
const detail = info.retryAfterSeconds && countdown > 0
|
|
23
|
+
? `Try again in ${countdown} second${countdown !== 1 ? "s" : ""}.`
|
|
24
|
+
: info.detail;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="mb-2 rounded-lg border border-red-300 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-950/20 dark:text-red-400 px-4 py-3 text-sm">
|
|
28
|
+
<p className="font-medium">{info.title}</p>
|
|
29
|
+
{detail && <p className="mt-1 text-xs opacity-80">{detail}</p>}
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { getToolArgs, getToolResult, isToolComplete } from "../../lib/helpers";
|
|
5
|
+
|
|
6
|
+
export function ExploreCard({ part }: { part: unknown }) {
|
|
7
|
+
const args = getToolArgs(part);
|
|
8
|
+
const result = getToolResult(part);
|
|
9
|
+
const done = isToolComplete(part);
|
|
10
|
+
const [open, setOpen] = useState(false);
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<div className="my-2 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
|
|
14
|
+
<button
|
|
15
|
+
onClick={() => done && setOpen(!open)}
|
|
16
|
+
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-zinc-100/60 dark:hover:bg-zinc-800/60"
|
|
17
|
+
>
|
|
18
|
+
<span className="font-mono text-green-400">$</span>
|
|
19
|
+
<span className="flex-1 truncate font-mono text-zinc-700 dark:text-zinc-300">
|
|
20
|
+
{String(args.command ?? "")}
|
|
21
|
+
</span>
|
|
22
|
+
{done ? (
|
|
23
|
+
<span className="text-zinc-400 dark:text-zinc-600">{open ? "\u25BE" : "\u25B8"}</span>
|
|
24
|
+
) : (
|
|
25
|
+
<span className="animate-pulse text-zinc-500">running...</span>
|
|
26
|
+
)}
|
|
27
|
+
</button>
|
|
28
|
+
{open && done && (
|
|
29
|
+
<div className="border-t border-zinc-100 bg-white px-3 py-2 dark:border-zinc-800 dark:bg-zinc-950">
|
|
30
|
+
<pre className="max-h-60 overflow-auto whitespace-pre-wrap font-mono text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
|
|
31
|
+
{result != null
|
|
32
|
+
? typeof result === "string"
|
|
33
|
+
? result
|
|
34
|
+
: JSON.stringify(result, null, 2)
|
|
35
|
+
: "(no output received)"}
|
|
36
|
+
</pre>
|
|
37
|
+
</div>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "../ui/button";
|
|
4
|
+
|
|
5
|
+
export function FollowUpChips({
|
|
6
|
+
suggestions,
|
|
7
|
+
onSelect,
|
|
8
|
+
}: {
|
|
9
|
+
suggestions: string[];
|
|
10
|
+
onSelect: (text: string) => void;
|
|
11
|
+
}) {
|
|
12
|
+
if (suggestions.length === 0) return null;
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div className="flex flex-wrap gap-2 pt-1">
|
|
16
|
+
{suggestions.map((s, i) => (
|
|
17
|
+
<Button
|
|
18
|
+
key={`${i}-${s}`}
|
|
19
|
+
variant="outline"
|
|
20
|
+
size="sm"
|
|
21
|
+
className="h-auto rounded-full px-3 py-1.5 text-xs font-normal text-zinc-600 dark:text-zinc-400"
|
|
22
|
+
onClick={() => onSelect(s)}
|
|
23
|
+
>
|
|
24
|
+
{s}
|
|
25
|
+
</Button>
|
|
26
|
+
))}
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
export function LoadingCard({ label }: { label: string }) {
|
|
4
|
+
return (
|
|
5
|
+
<div className="my-2 flex items-center gap-2 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs text-zinc-500 dark:border-zinc-700 dark:bg-zinc-900">
|
|
6
|
+
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-zinc-300 border-t-zinc-600 dark:border-zinc-600 dark:border-t-zinc-300" />
|
|
7
|
+
{label}
|
|
8
|
+
</div>
|
|
9
|
+
);
|
|
10
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useAtlasConfig } from "../../context";
|
|
5
|
+
|
|
6
|
+
export function ManagedAuthCard() {
|
|
7
|
+
const { authClient } = useAtlasConfig();
|
|
8
|
+
const [view, setView] = useState<"login" | "signup">("login");
|
|
9
|
+
const [email, setEmail] = useState("");
|
|
10
|
+
const [password, setPassword] = useState("");
|
|
11
|
+
const [name, setName] = useState("");
|
|
12
|
+
const [error, setError] = useState("");
|
|
13
|
+
const [loading, setLoading] = useState(false);
|
|
14
|
+
|
|
15
|
+
async function handleLogin(e: React.FormEvent) {
|
|
16
|
+
e.preventDefault();
|
|
17
|
+
setError("");
|
|
18
|
+
setLoading(true);
|
|
19
|
+
try {
|
|
20
|
+
const res = await authClient.signIn.email({ email, password });
|
|
21
|
+
if (res.error) setError(res.error.message ?? "Sign in failed");
|
|
22
|
+
} catch (err) {
|
|
23
|
+
console.error("Sign in error:", err);
|
|
24
|
+
setError(err instanceof TypeError ? "Unable to reach the server" : "Sign in failed");
|
|
25
|
+
} finally {
|
|
26
|
+
setLoading(false);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function handleSignup(e: React.FormEvent) {
|
|
31
|
+
e.preventDefault();
|
|
32
|
+
setError("");
|
|
33
|
+
setLoading(true);
|
|
34
|
+
try {
|
|
35
|
+
const res = await authClient.signUp.email({ email, password, name: name || email.split("@")[0] });
|
|
36
|
+
if (res.error) setError(res.error.message ?? "Sign up failed");
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.error("Sign up error:", err);
|
|
39
|
+
setError(err instanceof TypeError ? "Unable to reach the server" : "Sign up failed");
|
|
40
|
+
} finally {
|
|
41
|
+
setLoading(false);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="flex h-full items-center justify-center">
|
|
47
|
+
<div className="w-full max-w-sm space-y-4 rounded-lg border border-zinc-200 bg-zinc-50 p-6 dark:border-zinc-700 dark:bg-zinc-900">
|
|
48
|
+
<div className="text-center">
|
|
49
|
+
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
|
50
|
+
{view === "login" ? "Sign in to Atlas" : "Create an account"}
|
|
51
|
+
</h2>
|
|
52
|
+
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
|
53
|
+
{view === "login" ? "Enter your credentials to continue" : "Set up your Atlas account"}
|
|
54
|
+
</p>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<form onSubmit={view === "login" ? handleLogin : handleSignup} className="space-y-3">
|
|
58
|
+
{view === "signup" && (
|
|
59
|
+
<input
|
|
60
|
+
type="text"
|
|
61
|
+
value={name}
|
|
62
|
+
onChange={(e) => setName(e.target.value)}
|
|
63
|
+
placeholder="Name (optional)"
|
|
64
|
+
className="w-full rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 outline-none focus:border-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-600"
|
|
65
|
+
/>
|
|
66
|
+
)}
|
|
67
|
+
<input
|
|
68
|
+
type="email"
|
|
69
|
+
value={email}
|
|
70
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
71
|
+
placeholder="Email"
|
|
72
|
+
required
|
|
73
|
+
className="w-full rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 outline-none focus:border-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-600"
|
|
74
|
+
/>
|
|
75
|
+
<input
|
|
76
|
+
type="password"
|
|
77
|
+
value={password}
|
|
78
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
79
|
+
placeholder="Password"
|
|
80
|
+
required
|
|
81
|
+
minLength={8}
|
|
82
|
+
className="w-full rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 outline-none focus:border-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-600"
|
|
83
|
+
/>
|
|
84
|
+
{error && (
|
|
85
|
+
<p className="text-xs text-red-600 dark:text-red-400">{error}</p>
|
|
86
|
+
)}
|
|
87
|
+
<button
|
|
88
|
+
type="submit"
|
|
89
|
+
disabled={loading}
|
|
90
|
+
className="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-500 disabled:opacity-40"
|
|
91
|
+
>
|
|
92
|
+
{loading ? "..." : view === "login" ? "Sign in" : "Create account"}
|
|
93
|
+
</button>
|
|
94
|
+
</form>
|
|
95
|
+
|
|
96
|
+
<p className="text-center text-xs text-zinc-500 dark:text-zinc-400">
|
|
97
|
+
{view === "login" ? (
|
|
98
|
+
<>
|
|
99
|
+
No account?{" "}
|
|
100
|
+
<button onClick={() => { setView("signup"); setError(""); }} className="text-blue-600 hover:underline dark:text-blue-400">
|
|
101
|
+
Create one
|
|
102
|
+
</button>
|
|
103
|
+
</>
|
|
104
|
+
) : (
|
|
105
|
+
<>
|
|
106
|
+
Already have an account?{" "}
|
|
107
|
+
<button onClick={() => { setView("login"); setError(""); }} className="text-blue-600 hover:underline dark:text-blue-400">
|
|
108
|
+
Sign in
|
|
109
|
+
</button>
|
|
110
|
+
</>
|
|
111
|
+
)}
|
|
112
|
+
</p>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { memo, useContext, useState, useEffect, type ReactNode } from "react";
|
|
4
|
+
import ReactMarkdown from "react-markdown";
|
|
5
|
+
import remarkGfm from "remark-gfm";
|
|
6
|
+
import { DarkModeContext } from "../../hooks/use-dark-mode";
|
|
7
|
+
|
|
8
|
+
/* ------------------------------------------------------------------ */
|
|
9
|
+
/* Lazy-loaded syntax highlighter (~300KB) */
|
|
10
|
+
/* ------------------------------------------------------------------ */
|
|
11
|
+
|
|
12
|
+
type SyntaxHighlighterModule = typeof import("react-syntax-highlighter");
|
|
13
|
+
type StyleModule = typeof import("react-syntax-highlighter/dist/esm/styles/prism");
|
|
14
|
+
|
|
15
|
+
let _highlighterCache: { Prism: SyntaxHighlighterModule["Prism"]; oneDark: StyleModule["oneDark"]; oneLight: StyleModule["oneLight"] } | null = null;
|
|
16
|
+
let _loadFailed = false;
|
|
17
|
+
|
|
18
|
+
function LazyCodeBlock({ language, dark, children }: { language: string; dark: boolean; children: string }) {
|
|
19
|
+
const [mod, setMod] = useState(_highlighterCache);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (_highlighterCache || _loadFailed) return;
|
|
23
|
+
Promise.all([
|
|
24
|
+
import("react-syntax-highlighter"),
|
|
25
|
+
import("react-syntax-highlighter/dist/esm/styles/prism"),
|
|
26
|
+
]).then(([sh, styles]) => {
|
|
27
|
+
_highlighterCache = { Prism: sh.Prism, oneDark: styles.oneDark, oneLight: styles.oneLight };
|
|
28
|
+
setMod(_highlighterCache);
|
|
29
|
+
}).catch((err) => {
|
|
30
|
+
console.warn("Syntax highlighter failed to load — code blocks will use plain text:", err);
|
|
31
|
+
_loadFailed = true;
|
|
32
|
+
});
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
if (!mod) {
|
|
36
|
+
return (
|
|
37
|
+
<pre className="my-2 overflow-x-auto rounded-lg bg-zinc-100 p-3 text-xs dark:bg-zinc-800">
|
|
38
|
+
<code>{children}</code>
|
|
39
|
+
</pre>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<mod.Prism
|
|
45
|
+
language={language}
|
|
46
|
+
style={dark ? mod.oneDark : mod.oneLight}
|
|
47
|
+
customStyle={CODE_BLOCK_STYLE}
|
|
48
|
+
>
|
|
49
|
+
{children}
|
|
50
|
+
</mod.Prism>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const CODE_BLOCK_STYLE = {
|
|
55
|
+
margin: "0.5rem 0",
|
|
56
|
+
borderRadius: "0.5rem",
|
|
57
|
+
fontSize: "0.75rem",
|
|
58
|
+
} as const;
|
|
59
|
+
|
|
60
|
+
/* ------------------------------------------------------------------ */
|
|
61
|
+
/* Static markdown renderers — hoisted outside component */
|
|
62
|
+
/* ------------------------------------------------------------------ */
|
|
63
|
+
|
|
64
|
+
const mdComponents = {
|
|
65
|
+
p: ({ children }: { children?: ReactNode }) => (
|
|
66
|
+
<p className="mb-3 leading-relaxed last:mb-0">{children}</p>
|
|
67
|
+
),
|
|
68
|
+
h1: ({ children }: { children?: ReactNode }) => (
|
|
69
|
+
<h1 className="mb-2 mt-4 text-lg font-bold first:mt-0">{children}</h1>
|
|
70
|
+
),
|
|
71
|
+
h2: ({ children }: { children?: ReactNode }) => (
|
|
72
|
+
<h2 className="mb-2 mt-3 text-base font-semibold first:mt-0">{children}</h2>
|
|
73
|
+
),
|
|
74
|
+
h3: ({ children }: { children?: ReactNode }) => (
|
|
75
|
+
<h3 className="mb-1 mt-2 font-semibold first:mt-0">{children}</h3>
|
|
76
|
+
),
|
|
77
|
+
ul: ({ children }: { children?: ReactNode }) => (
|
|
78
|
+
<ul className="mb-3 list-disc space-y-1 pl-4">{children}</ul>
|
|
79
|
+
),
|
|
80
|
+
ol: ({ children }: { children?: ReactNode }) => (
|
|
81
|
+
<ol className="mb-3 list-decimal space-y-1 pl-4">{children}</ol>
|
|
82
|
+
),
|
|
83
|
+
strong: ({ children }: { children?: ReactNode }) => (
|
|
84
|
+
<strong className="font-semibold text-zinc-900 dark:text-zinc-50">{children}</strong>
|
|
85
|
+
),
|
|
86
|
+
blockquote: ({ children }: { children?: ReactNode }) => (
|
|
87
|
+
<blockquote className="my-2 border-l-2 border-zinc-300 pl-3 text-zinc-500 dark:border-zinc-600 dark:text-zinc-400">
|
|
88
|
+
{children}
|
|
89
|
+
</blockquote>
|
|
90
|
+
),
|
|
91
|
+
table: ({ children }: { children?: ReactNode }) => (
|
|
92
|
+
<div className="my-3 overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-700">
|
|
93
|
+
<table className="min-w-full text-sm">{children}</table>
|
|
94
|
+
</div>
|
|
95
|
+
),
|
|
96
|
+
thead: ({ children }: { children?: ReactNode }) => (
|
|
97
|
+
<thead className="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800/50">
|
|
98
|
+
{children}
|
|
99
|
+
</thead>
|
|
100
|
+
),
|
|
101
|
+
tbody: ({ children }: { children?: ReactNode }) => (
|
|
102
|
+
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">{children}</tbody>
|
|
103
|
+
),
|
|
104
|
+
tr: ({ children }: { children?: ReactNode }) => <tr>{children}</tr>,
|
|
105
|
+
th: ({ children }: { children?: ReactNode }) => (
|
|
106
|
+
<th className="px-3 py-2 text-left text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
|
107
|
+
{children}
|
|
108
|
+
</th>
|
|
109
|
+
),
|
|
110
|
+
td: ({ children }: { children?: ReactNode }) => (
|
|
111
|
+
<td className="px-3 py-2 text-zinc-700 dark:text-zinc-300">{children}</td>
|
|
112
|
+
),
|
|
113
|
+
pre: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export const Markdown = memo(function Markdown({ content }: { content: string }) {
|
|
117
|
+
const dark = useContext(DarkModeContext);
|
|
118
|
+
return (
|
|
119
|
+
<ReactMarkdown
|
|
120
|
+
remarkPlugins={[remarkGfm]}
|
|
121
|
+
components={{
|
|
122
|
+
...mdComponents,
|
|
123
|
+
code({ className, children, ...props }) {
|
|
124
|
+
const match = /language-(\w+)/.exec(className || "");
|
|
125
|
+
if (match) {
|
|
126
|
+
return (
|
|
127
|
+
<LazyCodeBlock language={match[1]} dark={dark}>
|
|
128
|
+
{String(children).replace(/\n$/, "")}
|
|
129
|
+
</LazyCodeBlock>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return (
|
|
133
|
+
<code
|
|
134
|
+
className="rounded bg-zinc-200/50 px-1.5 py-0.5 text-xs text-zinc-800 dark:bg-zinc-700/50 dark:text-zinc-200"
|
|
135
|
+
{...props}
|
|
136
|
+
>
|
|
137
|
+
{children}
|
|
138
|
+
</code>
|
|
139
|
+
);
|
|
140
|
+
},
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
{content}
|
|
144
|
+
</ReactMarkdown>
|
|
145
|
+
);
|
|
146
|
+
});
|