mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-22 15:59:56 -05:00
Merge branch 'canary' into tunis
This commit is contained in:
@@ -27,3 +27,4 @@ beka
|
||||
guilhermejansen
|
||||
iamjasonkendrick
|
||||
ejirocodes
|
||||
0-Sandy
|
||||
|
||||
@@ -159,10 +159,15 @@ const authClient = createAuthClient({
|
||||
|
||||
When you call `auth.api.signInEmail` on the server, and the user has 2FA enabled, it will return an object where `twoFactorRedirect` is set to `true`. This behavior isn’t inferred in TypeScript, which can be misleading. You can check using `in` instead to check if `twoFactorRedirect` is set to `true`.
|
||||
|
||||
`authClient.twoFactor.*` handles cookies automatically in the browser. If you continue the 2FA flow with `auth.api.*` on the server, you must pass the relevant headers so Better Auth can read the current 2FA state and set the resulting 2FA/session cookies. The generated examples below use `await headers()` for this, but in other frameworks you should pass the equivalent incoming request headers.
|
||||
|
||||
If you chain multiple `auth.api.*` calls in the same server flow, make sure you forward the cookies from the previous auth response into the next call.
|
||||
|
||||
```ts title="sign-in.ts"
|
||||
import { auth } from "@/lib/auth"
|
||||
|
||||
const response = await auth.api.signInEmail({
|
||||
const { headers: responseHeaders, response } = await auth.api.signInEmail({
|
||||
returnHeaders: true,
|
||||
body: {
|
||||
email: "test@test.com",
|
||||
password: "test",
|
||||
@@ -170,6 +175,7 @@ const response = await auth.api.signInEmail({
|
||||
});
|
||||
|
||||
if ("twoFactorRedirect" in response) {
|
||||
// Forward the cookies from responseHeaders into the next auth.api 2FA call.
|
||||
// Handle the 2FA verification in place
|
||||
}
|
||||
```
|
||||
@@ -252,7 +258,12 @@ By default the issuer for TOTP is set to the app name provided in the auth confi
|
||||
|
||||
After the user has entered their 2FA code, you can verify it using `twoFactor.verifyTotp` method. `Better Auth` follows standard practice by accepting TOTP codes from one period before and one after the current code, ensuring users can authenticate even with minor time delays on their end.
|
||||
|
||||
<APIMethod path="/two-factor/verify-totp" method="POST">
|
||||
<APIMethod
|
||||
path="/two-factor/verify-totp"
|
||||
method="POST"
|
||||
requireHeaders
|
||||
headersComment="Pass the current request headers so Better Auth can read and set the 2FA/session cookies."
|
||||
>
|
||||
```ts
|
||||
type verifyTOTP = {
|
||||
/**
|
||||
@@ -292,11 +303,16 @@ export const auth = betterAuth({
|
||||
|
||||
#### Sending OTP
|
||||
|
||||
Sending an OTP is done by calling the `twoFactor.sendOtp` function. This function will trigger your sendOTP implementation that you provided in the Better Auth configuration.
|
||||
Sending an OTP is done by calling the `authClient.twoFactor.sendOtp` function on the client or `auth.api.sendTwoFactorOTP` on the server. This function will trigger your sendOTP implementation that you provided in the Better Auth configuration.
|
||||
|
||||
<APIMethod path="/two-factor/send-otp" method="POST">
|
||||
<APIMethod
|
||||
path="/two-factor/send-otp"
|
||||
method="POST"
|
||||
requireHeaders
|
||||
headersComment="Pass the current request headers so Better Auth can read and set the 2FA/session cookies."
|
||||
>
|
||||
```ts
|
||||
type send2FaOTP = {
|
||||
type sendTwoFactorOTP = {
|
||||
/**
|
||||
* If true, the device will be trusted for 30 days. It'll be refreshed on every sign in request within this time.
|
||||
*/
|
||||
@@ -311,11 +327,16 @@ if (data) {
|
||||
|
||||
#### Verifying OTP
|
||||
|
||||
After the user has entered their OTP code, you can verify it
|
||||
After the user has entered their OTP code, you can verify it using `authClient.twoFactor.verifyOtp` on the client or `auth.api.verifyTwoFactorOTP` on the server.
|
||||
|
||||
<APIMethod path="/two-factor/verify-otp" method="POST">
|
||||
<APIMethod
|
||||
path="/two-factor/verify-otp"
|
||||
method="POST"
|
||||
requireHeaders
|
||||
headersComment="Pass the current request headers so Better Auth can read and set the 2FA/session cookies."
|
||||
>
|
||||
```ts
|
||||
type verifyOTP = {
|
||||
type verifyTwoFactorOTP = {
|
||||
/**
|
||||
* The otp code to verify.
|
||||
*/
|
||||
@@ -364,7 +385,12 @@ When you generate backup codes, the old backup codes will be deleted and new one
|
||||
You can now allow users to provide a backup code as an account recovery method.
|
||||
|
||||
|
||||
<APIMethod path="/two-factor/verify-backup-code" method="POST">
|
||||
<APIMethod
|
||||
path="/two-factor/verify-backup-code"
|
||||
method="POST"
|
||||
requireHeaders
|
||||
headersComment="Pass the current request headers so Better Auth can read and set the 2FA/session cookies."
|
||||
>
|
||||
```ts
|
||||
type verifyBackupCode = {
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
# GitHub API - community stats
|
||||
GITHUB_TOKEN=
|
||||
|
||||
# Inkeep API - docs search & AI chat
|
||||
# Inkeep API - AI chat
|
||||
INKEEP_API_KEY=
|
||||
|
||||
# Typesense - Docs search
|
||||
NEXT_PUBLIC_TYPESENSE_SERVER_URL=
|
||||
NEXT_PUBLIC_TYPESENSE_SEARCH_API_KEY=
|
||||
TYPESENSE_ADMIN_API_KEY=
|
||||
|
||||
# Next.js - public URL
|
||||
NEXT_PUBLIC_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_URL=http://localhost:3000
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
url: string;
|
||||
type: "page" | "heading" | "text";
|
||||
content: string;
|
||||
pageName?: string;
|
||||
}
|
||||
|
||||
interface InkeepDocument {
|
||||
type: string;
|
||||
record_type: string;
|
||||
url: string;
|
||||
title: string;
|
||||
source?: {
|
||||
type: string;
|
||||
content: { type: string; text?: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
function toSlug(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function toRelativePath(fullUrl: string): string {
|
||||
try {
|
||||
const parsed = new URL(fullUrl);
|
||||
return parsed.pathname;
|
||||
} catch {
|
||||
return fullUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function extractHeadings(text: string): { level: number; title: string }[] {
|
||||
const headings: { level: number; title: string }[] = [];
|
||||
const regex = /^(#{1,6})\s+(.+)$/gm;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
headings.push({
|
||||
level: match[1].length,
|
||||
title: match[2].trim(),
|
||||
});
|
||||
}
|
||||
return headings;
|
||||
}
|
||||
|
||||
function extractMatchingSnippet(text: string, query: string): string | null {
|
||||
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
|
||||
// Find the first paragraph-like block that contains a query term
|
||||
const blocks = text.split(/\n{2,}/);
|
||||
for (const block of blocks) {
|
||||
const blockLower = block.toLowerCase();
|
||||
// Skip headings and very short blocks
|
||||
if (block.startsWith("#") || block.trim().length < 20) continue;
|
||||
if (terms.some((t) => blockLower.includes(t))) {
|
||||
// Return a trimmed snippet
|
||||
const clean = block.replace(/[#*_`>\[\]]/g, "").trim();
|
||||
if (clean.length > 120) return `${clean.slice(0, 120)}...`;
|
||||
return clean;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const query = url.searchParams.get("query");
|
||||
if (!query) return Response.json([]);
|
||||
|
||||
try {
|
||||
const response = await fetch("https://api.inkeep.com/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${process.env.INKEEP_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "inkeep-rag",
|
||||
messages: [{ role: "user", content: query }],
|
||||
response_format: { type: "json_object" },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Inkeep RAG API error:", response.status);
|
||||
return Response.json([]);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
if (!content) return Response.json([]);
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
const documents: InkeepDocument[] = parsed.content ?? [];
|
||||
|
||||
const results: SearchResult[] = [];
|
||||
const seenUrls = new Set<string>();
|
||||
|
||||
for (const doc of documents) {
|
||||
const docUrl = toRelativePath(doc.url);
|
||||
|
||||
// Add page-level result (dedupe by base URL)
|
||||
const baseUrl = docUrl.split("#")[0];
|
||||
if (!seenUrls.has(baseUrl)) {
|
||||
seenUrls.add(baseUrl);
|
||||
results.push({
|
||||
id: baseUrl,
|
||||
url: baseUrl,
|
||||
type: "page",
|
||||
content: doc.title,
|
||||
});
|
||||
}
|
||||
|
||||
// Extract full text from source content blocks
|
||||
const fullText = (doc.source?.content ?? [])
|
||||
.filter((c) => c.type === "text" && c.text)
|
||||
.map((c) => c.text!)
|
||||
.join("\n\n");
|
||||
|
||||
if (!fullText) continue;
|
||||
|
||||
// Extract headings and add as section results
|
||||
const headings = extractHeadings(fullText);
|
||||
const queryTerms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
|
||||
for (const heading of headings) {
|
||||
const headingLower = heading.title.toLowerCase();
|
||||
const matches = queryTerms.some((t) => headingLower.includes(t));
|
||||
if (!matches) continue;
|
||||
|
||||
const slug = toSlug(heading.title);
|
||||
const headingUrl = `${baseUrl}#${slug}`;
|
||||
if (seenUrls.has(headingUrl)) continue;
|
||||
seenUrls.add(headingUrl);
|
||||
|
||||
results.push({
|
||||
id: headingUrl,
|
||||
url: headingUrl,
|
||||
type: "heading",
|
||||
content: heading.title,
|
||||
pageName: doc.title,
|
||||
});
|
||||
}
|
||||
|
||||
// Add a text snippet match if we find relevant content
|
||||
const snippet = extractMatchingSnippet(fullText, query);
|
||||
if (snippet) {
|
||||
const snippetId = `${baseUrl}:snippet`;
|
||||
if (!seenUrls.has(snippetId)) {
|
||||
seenUrls.add(snippetId);
|
||||
|
||||
// Link to the closest heading above the snippet, or the page
|
||||
let bestAnchor = baseUrl;
|
||||
for (const heading of headings) {
|
||||
const slug = toSlug(heading.title);
|
||||
bestAnchor = `${baseUrl}#${slug}`;
|
||||
}
|
||||
|
||||
results.push({
|
||||
id: snippetId,
|
||||
url: bestAnchor,
|
||||
type: "text",
|
||||
content: snippet,
|
||||
pageName: doc.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json(results.slice(0, 20));
|
||||
} catch (error) {
|
||||
console.error("Search error:", error);
|
||||
return Response.json([]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { exportSearchIndexes } from "@/lib/export-search-indexes";
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export async function GET() {
|
||||
return Response.json(await exportSearchIndexes());
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { getRSS } from "@/lib/rss";
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export function GET() {
|
||||
return new Response(getRSS(), {
|
||||
headers: {
|
||||
"Content-Type": "application/rss+xml; charset=utf-8",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -54,7 +54,7 @@ function ApplyDialog({
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm" />
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-md dark:bg-black/72" />
|
||||
|
||||
{/* Dialog */}
|
||||
<motion.div
|
||||
@@ -63,19 +63,19 @@ function ApplyDialog({
|
||||
exit={{ opacity: 0, y: 10, scale: 0.98 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="relative w-full max-w-md border border-dashed border-foreground/[0.08] bg-background overflow-hidden"
|
||||
className="relative w-full max-w-md overflow-hidden border border-foreground/[0.14] bg-background/95 shadow-2xl shadow-black/12 ring-1 ring-black/5 backdrop-blur dark:border-white/[0.08] dark:bg-[#050505]/95 dark:shadow-black/65 dark:ring-white/[0.04]"
|
||||
>
|
||||
{/* Corner marks */}
|
||||
<span className="absolute top-2 left-2 text-[9px] font-mono text-foreground/15 select-none leading-none">
|
||||
<span className="absolute top-2 left-2 text-[9px] font-mono text-foreground/25 dark:text-foreground/18 select-none leading-none">
|
||||
+
|
||||
</span>
|
||||
<span className="absolute top-2 right-2 text-[9px] font-mono text-foreground/15 select-none leading-none">
|
||||
<span className="absolute top-2 right-2 text-[9px] font-mono text-foreground/25 dark:text-foreground/18 select-none leading-none">
|
||||
+
|
||||
</span>
|
||||
<span className="absolute bottom-2 left-2 text-[9px] font-mono text-foreground/15 select-none leading-none">
|
||||
<span className="absolute bottom-2 left-2 text-[9px] font-mono text-foreground/25 dark:text-foreground/18 select-none leading-none">
|
||||
+
|
||||
</span>
|
||||
<span className="absolute bottom-2 right-2 text-[9px] font-mono text-foreground/15 select-none leading-none">
|
||||
<span className="absolute bottom-2 right-2 text-[9px] font-mono text-foreground/25 dark:text-foreground/18 select-none leading-none">
|
||||
+
|
||||
</span>
|
||||
|
||||
@@ -84,13 +84,15 @@ function ApplyDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-foreground/30 hover:text-foreground/60 transition-colors"
|
||||
aria-label="Close dialog"
|
||||
className="absolute top-4 right-4 text-foreground/50 hover:text-foreground/80 transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
@@ -106,15 +108,17 @@ function ApplyDialog({
|
||||
transition={{ duration: 0.3 }}
|
||||
className="text-center py-6 space-y-3"
|
||||
>
|
||||
<p className="text-sm text-foreground/80">Application sent</p>
|
||||
<p className="text-[11px] text-foreground/40 leading-relaxed max-w-xs mx-auto">
|
||||
<p className="text-sm text-foreground/92 dark:text-foreground/88">
|
||||
Application sent
|
||||
</p>
|
||||
<p className="text-[11px] text-foreground/60 dark:text-foreground/52 leading-relaxed max-w-xs mx-auto">
|
||||
Thanks for applying for {role.title}. We'll review your
|
||||
application and get back to you soon.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="mt-4 inline-flex items-center px-4 py-2 border border-foreground/[0.12] text-foreground/40 hover:text-foreground/70 hover:border-foreground/25 transition-all font-mono text-[10px] uppercase tracking-widest"
|
||||
className="mt-4 inline-flex items-center px-4 py-2 border border-foreground/[0.18] bg-foreground/[0.03] text-foreground/72 hover:text-foreground/90 hover:border-foreground/28 hover:bg-foreground/[0.06] transition-all font-mono text-[10px] uppercase tracking-widest dark:border-white/[0.1] dark:bg-white/[0.03] dark:text-foreground/68 dark:hover:text-foreground/84 dark:hover:bg-white/[0.05]"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
@@ -122,15 +126,17 @@ function ApplyDialog({
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-6 space-y-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-foreground/30 font-mono">
|
||||
<p className="text-[10px] uppercase tracking-widest text-foreground/55 dark:text-foreground/50 font-mono">
|
||||
# Apply
|
||||
</p>
|
||||
<h3 className="text-sm text-foreground/80">{role.title}</h3>
|
||||
<h3 className="text-sm text-foreground/92 dark:text-foreground/86">
|
||||
{role.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<span className="text-[8px] font-mono uppercase tracking-widest text-foreground/40 border border-dashed border-foreground/[0.1] px-1.5 py-0.5 leading-none">
|
||||
<span className="text-[8px] font-mono uppercase tracking-widest text-foreground/60 dark:text-foreground/50 border border-foreground/[0.15] bg-foreground/[0.03] px-1.5 py-0.5 leading-none dark:border-white/[0.08] dark:bg-white/[0.03]">
|
||||
{role.type}
|
||||
</span>
|
||||
<span className="text-[8px] font-mono uppercase tracking-widest text-foreground/40 border border-dashed border-foreground/[0.1] px-1.5 py-0.5 leading-none">
|
||||
<span className="text-[8px] font-mono uppercase tracking-widest text-foreground/60 dark:text-foreground/50 border border-foreground/[0.15] bg-foreground/[0.03] px-1.5 py-0.5 leading-none dark:border-white/[0.08] dark:bg-white/[0.03]">
|
||||
{role.location}
|
||||
</span>
|
||||
</div>
|
||||
@@ -146,7 +152,7 @@ function ApplyDialog({
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="careers-name"
|
||||
className="text-[9px] text-foreground/30 uppercase tracking-widest font-mono"
|
||||
className="text-[9px] text-foreground/58 dark:text-foreground/48 uppercase tracking-widest font-mono"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
@@ -154,7 +160,7 @@ function ApplyDialog({
|
||||
id="careers-name"
|
||||
type="text"
|
||||
required
|
||||
className="w-full bg-transparent border border-dashed border-foreground/[0.1] px-3 py-2 text-[12px] text-foreground/70 placeholder:text-foreground/20 focus:outline-none focus:border-foreground/25 transition-colors"
|
||||
className="w-full border border-foreground/[0.15] bg-foreground/[0.025] px-3 py-2 text-[12px] text-foreground/88 placeholder:text-foreground/38 focus:outline-none focus:border-foreground/38 dark:border-white/[0.1] dark:bg-white/[0.03] dark:text-foreground/78 dark:placeholder:text-foreground/28 dark:focus:border-white/[0.2] transition-colors"
|
||||
placeholder="Your full name"
|
||||
/>
|
||||
</div>
|
||||
@@ -162,7 +168,7 @@ function ApplyDialog({
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="careers-email"
|
||||
className="text-[9px] text-foreground/30 uppercase tracking-widest font-mono"
|
||||
className="text-[9px] text-foreground/58 dark:text-foreground/48 uppercase tracking-widest font-mono"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
@@ -170,7 +176,7 @@ function ApplyDialog({
|
||||
id="careers-email"
|
||||
type="email"
|
||||
required
|
||||
className="w-full bg-transparent border border-dashed border-foreground/[0.1] px-3 py-2 text-[12px] text-foreground/70 placeholder:text-foreground/20 focus:outline-none focus:border-foreground/25 transition-colors"
|
||||
className="w-full border border-foreground/[0.15] bg-foreground/[0.025] px-3 py-2 text-[12px] text-foreground/88 placeholder:text-foreground/38 focus:outline-none focus:border-foreground/38 dark:border-white/[0.1] dark:bg-white/[0.03] dark:text-foreground/78 dark:placeholder:text-foreground/28 dark:focus:border-white/[0.2] transition-colors"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
@@ -178,7 +184,7 @@ function ApplyDialog({
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="careers-portfolio"
|
||||
className="text-[9px] text-foreground/30 uppercase tracking-widest font-mono"
|
||||
className="text-[9px] text-foreground/58 dark:text-foreground/48 uppercase tracking-widest font-mono"
|
||||
>
|
||||
Portfolio / GitHub
|
||||
</label>
|
||||
@@ -186,7 +192,7 @@ function ApplyDialog({
|
||||
id="careers-portfolio"
|
||||
type="url"
|
||||
required
|
||||
className="w-full bg-transparent border border-dashed border-foreground/[0.1] px-3 py-2 text-[12px] text-foreground/70 placeholder:text-foreground/20 focus:outline-none focus:border-foreground/25 transition-colors"
|
||||
className="w-full border border-foreground/[0.15] bg-foreground/[0.025] px-3 py-2 text-[12px] text-foreground/88 placeholder:text-foreground/38 focus:outline-none focus:border-foreground/38 dark:border-white/[0.1] dark:bg-white/[0.03] dark:text-foreground/78 dark:placeholder:text-foreground/28 dark:focus:border-white/[0.2] transition-colors"
|
||||
placeholder="https://"
|
||||
/>
|
||||
</div>
|
||||
@@ -194,7 +200,7 @@ function ApplyDialog({
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="careers-why"
|
||||
className="text-[9px] text-foreground/30 uppercase tracking-widest font-mono"
|
||||
className="text-[9px] text-foreground/58 dark:text-foreground/48 uppercase tracking-widest font-mono"
|
||||
>
|
||||
Why Better Auth?
|
||||
</label>
|
||||
@@ -202,7 +208,7 @@ function ApplyDialog({
|
||||
id="careers-why"
|
||||
required
|
||||
rows={3}
|
||||
className="w-full bg-transparent border border-dashed border-foreground/[0.1] px-3 py-2 text-[12px] text-foreground/70 placeholder:text-foreground/20 focus:outline-none focus:border-foreground/25 transition-colors resize-none"
|
||||
className="w-full border border-foreground/[0.15] bg-foreground/[0.025] px-3 py-2 text-[12px] text-foreground/88 placeholder:text-foreground/38 focus:outline-none focus:border-foreground/38 dark:border-white/[0.1] dark:bg-white/[0.03] dark:text-foreground/78 dark:placeholder:text-foreground/28 dark:focus:border-white/[0.2] transition-colors resize-none"
|
||||
placeholder="Tell us why you want to join..."
|
||||
/>
|
||||
</div>
|
||||
@@ -210,13 +216,13 @@ function ApplyDialog({
|
||||
<div className="pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 border border-foreground/[0.12] text-foreground/40 hover:text-foreground/70 hover:border-foreground/25 hover:bg-foreground/[0.02] transition-all"
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 bg-foreground text-background hover:opacity-90 transition-all"
|
||||
>
|
||||
<span className="font-mono text-[10px] uppercase tracking-widest">
|
||||
Submit Application
|
||||
</span>
|
||||
<svg
|
||||
className="h-2.5 w-2.5 opacity-50"
|
||||
className="h-2.5 w-2.5 opacity-80"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
>
|
||||
@@ -326,58 +332,60 @@ function RoleCard({
|
||||
delay: 0.2 + index * 0.08,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
className="relative border border-dashed border-foreground/[0.08] hover:border-foreground/[0.14] transition-all duration-300 group"
|
||||
className="group relative overflow-hidden border border-foreground/[0.1] bg-foreground/[0.018] shadow-sm shadow-black/[0.03] transition-all duration-300 hover:z-10 hover:-translate-y-px hover:border-foreground/[0.18] hover:bg-foreground/[0.03] hover:shadow-lg hover:shadow-black/[0.06] dark:border-white/[0.07] dark:bg-white/[0.015] dark:shadow-black/[0.2] dark:hover:border-white/[0.12] dark:hover:bg-white/[0.03] dark:hover:shadow-black/[0.35]"
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-foreground/18 to-transparent dark:via-white/[0.14]" />
|
||||
|
||||
{/* Corner marks */}
|
||||
<span className="absolute top-2 left-2 text-[9px] font-mono text-foreground/15 select-none leading-none">
|
||||
<span className="absolute top-2 left-2 text-[9px] font-mono text-foreground/22 dark:text-foreground/16 select-none leading-none">
|
||||
+
|
||||
</span>
|
||||
<span className="absolute top-2 right-2 text-[9px] font-mono text-foreground/15 select-none leading-none">
|
||||
<span className="absolute top-2 right-2 text-[9px] font-mono text-foreground/22 dark:text-foreground/16 select-none leading-none">
|
||||
+
|
||||
</span>
|
||||
<span className="absolute bottom-2 left-2 text-[9px] font-mono text-foreground/15 select-none leading-none">
|
||||
<span className="absolute bottom-2 left-2 text-[9px] font-mono text-foreground/22 dark:text-foreground/16 select-none leading-none">
|
||||
+
|
||||
</span>
|
||||
<span className="absolute bottom-2 right-2 text-[9px] font-mono text-foreground/15 select-none leading-none">
|
||||
<span className="absolute bottom-2 right-2 text-[9px] font-mono text-foreground/22 dark:text-foreground/16 select-none leading-none">
|
||||
+
|
||||
</span>
|
||||
|
||||
<div className="p-5 sm:p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm sm:text-base text-foreground/90 tracking-tight">
|
||||
<h3 className="text-sm sm:text-base text-foreground/92 dark:text-foreground/86 tracking-tight">
|
||||
{role.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[8px] font-mono uppercase tracking-widest text-foreground/40 border border-dashed border-foreground/[0.1] px-1.5 py-0.5 leading-none">
|
||||
<span className="border border-foreground/[0.14] bg-foreground/[0.03] px-1.5 py-0.5 text-[8px] font-mono uppercase tracking-widest text-foreground/58 dark:border-white/[0.08] dark:bg-white/[0.03] dark:text-foreground/48 leading-none">
|
||||
{role.type}
|
||||
</span>
|
||||
<span className="text-[8px] font-mono uppercase tracking-widest text-foreground/40 border border-dashed border-foreground/[0.1] px-1.5 py-0.5 leading-none">
|
||||
<span className="border border-foreground/[0.14] bg-foreground/[0.03] px-1.5 py-0.5 text-[8px] font-mono uppercase tracking-widest text-foreground/58 dark:border-white/[0.08] dark:bg-white/[0.03] dark:text-foreground/48 leading-none">
|
||||
{role.location}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-[11px] text-foreground/50 leading-relaxed max-w-lg">
|
||||
<p className="max-w-lg text-[12px] leading-relaxed text-foreground/66 dark:text-foreground/56">
|
||||
{role.description}
|
||||
</p>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-dashed border-foreground/[0.06]" />
|
||||
<div className="border-t border-foreground/[0.08] dark:border-white/[0.06]" />
|
||||
|
||||
{/* Requirements */}
|
||||
<div>
|
||||
<p className="text-[9px] text-foreground/30 uppercase tracking-widest font-mono mb-3">
|
||||
<p className="mb-3 text-[9px] font-mono uppercase tracking-[0.18em] text-foreground/52 dark:text-foreground/44">
|
||||
Requirements
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{role.requirements.map((req) => (
|
||||
<li
|
||||
key={req}
|
||||
className="flex items-start gap-2 text-[11px] text-foreground/45 group-hover:text-foreground/55 transition-colors duration-300"
|
||||
className="flex items-start gap-2 text-[11px] leading-relaxed text-foreground/56 transition-colors duration-300 group-hover:text-foreground/68 dark:text-foreground/48 dark:group-hover:text-foreground/60"
|
||||
>
|
||||
<span className="text-foreground/50 mt-px font-mono text-[9px] leading-none select-none shrink-0">
|
||||
<span className="mt-px shrink-0 select-none font-mono text-[9px] leading-none text-foreground/60 dark:text-foreground/46">
|
||||
+
|
||||
</span>
|
||||
<span>{req}</span>
|
||||
@@ -391,13 +399,13 @@ function RoleCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onApply}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 border border-foreground/[0.12] text-foreground/40 hover:text-foreground/70 hover:border-foreground/25 hover:bg-foreground/[0.02] transition-all cursor-pointer"
|
||||
className="inline-flex items-center gap-1.5 bg-foreground px-3.5 py-2 text-background transition-all hover:opacity-90 cursor-pointer"
|
||||
>
|
||||
<span className="font-mono text-[10px] uppercase tracking-widest">
|
||||
Apply
|
||||
</span>
|
||||
<svg
|
||||
className="h-2.5 w-2.5 opacity-50"
|
||||
className="h-2.5 w-2.5 opacity-80"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
>
|
||||
@@ -503,7 +511,7 @@ export function CareersPageClient() {
|
||||
# Open positions
|
||||
</p>
|
||||
|
||||
<div className="space-y-0">
|
||||
<div className="space-y-4">
|
||||
{roles.map((role, i) => (
|
||||
<RoleCard
|
||||
key={role.title}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
||||
import { RootProvider } from "fumadocs-ui/provider/next";
|
||||
import type { ReactNode } from "react";
|
||||
import { Suspense } from "react";
|
||||
import { AIChat, AIChatPanel, AIChatTrigger } from "@/components/ai-chat";
|
||||
import { DocsSidebar } from "@/components/docs/docs-sidebar";
|
||||
import { FloatingToolbar } from "@/components/floating-toolbar";
|
||||
import { source } from "@/lib/source";
|
||||
import type { PageEntry } from "./provider";
|
||||
import { DocsProvider } from "./provider";
|
||||
|
||||
const allPages: PageEntry[] = source.getPages().map((page) => ({
|
||||
name: page.data.title,
|
||||
url: page.url,
|
||||
}));
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<RootProvider
|
||||
search={{
|
||||
enabled: false,
|
||||
}}
|
||||
>
|
||||
<DocsProvider pages={allPages}>
|
||||
<Suspense>
|
||||
<DocsSidebar />
|
||||
</Suspense>
|
||||
@@ -27,8 +29,17 @@ export default function Layout({ children }: { children: ReactNode }) {
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<AIChat>
|
||||
<AIChatPanel />
|
||||
<AIChatTrigger>
|
||||
<span className="text-sm text-muted-foreground">Ask AI</span>
|
||||
<span className="h-5 w-px bg-foreground/10" />
|
||||
<kbd className="inline-flex items-center gap-0.5 text-[10px] font-mono text-muted-foreground">
|
||||
<span className="text-[11px]">⌘</span>I
|
||||
</kbd>
|
||||
</AIChatTrigger>
|
||||
</AIChat>
|
||||
</DocsLayout>
|
||||
<FloatingToolbar />
|
||||
</RootProvider>
|
||||
</DocsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { RootProvider } from "fumadocs-ui/provider/next";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { ReactNode } from "react";
|
||||
import { createContext, use } from "react";
|
||||
|
||||
const SearchDialog = dynamic(() => import("@/components/search-dialog"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export interface PageEntry {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const PagesContext = createContext<PageEntry[]>([]);
|
||||
|
||||
export function usePages() {
|
||||
return use(PagesContext);
|
||||
}
|
||||
|
||||
export function DocsProvider({
|
||||
pages,
|
||||
children,
|
||||
}: {
|
||||
pages: PageEntry[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PagesContext value={pages}>
|
||||
<RootProvider
|
||||
search={{
|
||||
SearchDialog,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</RootProvider>
|
||||
</PagesContext>
|
||||
);
|
||||
}
|
||||
@@ -674,6 +674,14 @@ html:not([data-anchor-scrolling]) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide scrollbar in AI Chat panel */
|
||||
.ai-chat-messages {
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Prevent scroll-chaining and hide scrollbars in TOC areas */
|
||||
.docs-layout #nd-toc > div {
|
||||
overscroll-behavior-y: contain;
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Analytics } from "@vercel/analytics/next";
|
||||
import type { Metadata } from "next";
|
||||
import Script from "next/script";
|
||||
import type { ReactNode } from "react";
|
||||
import { CommandMenuProvider } from "@/components/command-menu";
|
||||
import { StaggeredNavFiles } from "@/components/landing/staggered-nav-files";
|
||||
import { Providers } from "@/components/providers";
|
||||
|
||||
@@ -97,12 +96,10 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<Providers>
|
||||
<CommandMenuProvider>
|
||||
<div className="relative min-h-dvh">
|
||||
<StaggeredNavFiles />
|
||||
{children}
|
||||
</div>
|
||||
</CommandMenuProvider>
|
||||
<div className="relative min-h-dvh">
|
||||
<StaggeredNavFiles />
|
||||
{children}
|
||||
</div>
|
||||
</Providers>
|
||||
<Analytics />
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
"use client";
|
||||
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { Loader2, MessageCircleIcon, RefreshCw, Send, X } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode, SyntheticEvent } from "react";
|
||||
import {
|
||||
createContext,
|
||||
use,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Markdown } from "./markdown";
|
||||
import { MessageFeedback } from "./message-feedback";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "./ui/drawer";
|
||||
|
||||
// ─── Context ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const AIChatContext = createContext<{
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
chat: ReturnType<typeof useChat>;
|
||||
} | null>(null);
|
||||
|
||||
export function useAIChat() {
|
||||
const ctx = use(AIChatContext);
|
||||
if (!ctx) throw new Error("Missing <AIChat />");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function useChatContext() {
|
||||
return useAIChat().chat;
|
||||
}
|
||||
|
||||
// ─── Root ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_PANEL_WIDTH = 400;
|
||||
const MIN_PANEL_WIDTH = 320;
|
||||
const MAX_PANEL_WIDTH = 640;
|
||||
|
||||
const chatTransport = new DefaultChatTransport({
|
||||
api: "/api/docs/chat",
|
||||
});
|
||||
|
||||
export function AIChat({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const chat = useChat({
|
||||
id: "ai-chat",
|
||||
transport: chatTransport,
|
||||
});
|
||||
|
||||
// Support ?askai= URL param
|
||||
const handleAskAiParam = useEffectEvent(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const aiQuery = params.get("askai");
|
||||
if (aiQuery) {
|
||||
setOpen(true);
|
||||
void chat.sendMessage({ text: aiQuery });
|
||||
const newParams = new URLSearchParams(window.location.search);
|
||||
newParams.delete("askai");
|
||||
const newUrl = newParams.toString()
|
||||
? `${window.location.pathname}?${newParams.toString()}`
|
||||
: window.location.pathname;
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
handleAskAiParam();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AIChatContext
|
||||
value={useMemo(() => ({ chat, open, setOpen }), [chat, open])}
|
||||
>
|
||||
{children}
|
||||
</AIChatContext>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Trigger ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function AIChatTrigger({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<"button">) {
|
||||
const { open, setOpen } = useAIChat();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-state={open ? "open" : "closed"}
|
||||
className={cn(
|
||||
"fixed bottom-5 end-5 z-20 flex items-center gap-2.5 px-3.5 py-1.5 border bg-secondary/70 backdrop-blur-md shadow-lg transition-[translate,opacity]",
|
||||
open && "translate-y-10 opacity-0",
|
||||
className,
|
||||
)}
|
||||
onClick={() => setOpen(!open)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Panel ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const LG_BREAKPOINT = 1024;
|
||||
|
||||
function useIsDesktop() {
|
||||
const [isDesktop, setIsDesktop] = useState<boolean | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(`(min-width: ${LG_BREAKPOINT}px)`);
|
||||
const onChange = () => setIsDesktop(mql.matches);
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsDesktop(mql.matches);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return isDesktop;
|
||||
}
|
||||
|
||||
export function AIChatPanel() {
|
||||
const isDesktop = useIsDesktop();
|
||||
useAIChatHotKey();
|
||||
|
||||
// SSR / hydration: render nothing until we know the viewport
|
||||
if (isDesktop === undefined) return null;
|
||||
|
||||
return isDesktop ? <DesktopPanel /> : <MobileDrawerPanel />;
|
||||
}
|
||||
|
||||
function useVisualViewportHeight() {
|
||||
const [height, setHeight] = useState<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return;
|
||||
const update = () => setHeight(vv.height);
|
||||
update();
|
||||
vv.addEventListener("resize", update);
|
||||
return () => vv.removeEventListener("resize", update);
|
||||
}, []);
|
||||
|
||||
return height;
|
||||
}
|
||||
|
||||
function MobileDrawerPanel() {
|
||||
const { open, setOpen } = useAIChat();
|
||||
const vvHeight = useVisualViewportHeight();
|
||||
|
||||
// 85% of the visual viewport (shrinks when keyboard opens)
|
||||
const drawerHeight = vvHeight ? vvHeight * 0.85 : undefined;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
repositionInputs={false}
|
||||
handleOnly
|
||||
>
|
||||
<DrawerContent
|
||||
style={
|
||||
drawerHeight
|
||||
? { height: drawerHeight, maxHeight: drawerHeight }
|
||||
: undefined
|
||||
}
|
||||
className={drawerHeight ? undefined : "h-[85dvh] max-h-[85dvh]"}
|
||||
>
|
||||
<DrawerHeader className="flex flex-row items-center gap-2 border-b">
|
||||
<div className="flex-1 text-left">
|
||||
<DrawerTitle className="text-xs font-medium">AI Chat</DrawerTitle>
|
||||
<DrawerDescription className="text-[10px]">
|
||||
Powered by{" "}
|
||||
<a
|
||||
href="https://inkeep.com"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="underline hover:text-foreground transition-colors"
|
||||
>
|
||||
Inkeep AI
|
||||
</a>
|
||||
</DrawerDescription>
|
||||
</div>
|
||||
<DrawerClose
|
||||
aria-label="Close"
|
||||
className="p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</DrawerClose>
|
||||
</DrawerHeader>
|
||||
<div className="flex flex-col flex-1 w-full min-h-0 overflow-hidden px-2 pb-3">
|
||||
<PanelMessages className="flex-1" />
|
||||
<PanelInput autoFocus={false} />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopPanel() {
|
||||
const { open } = useAIChat();
|
||||
const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const startX = useRef(0);
|
||||
const startWidth = useRef(panelWidth);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) return;
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
const delta = startX.current - e.clientX;
|
||||
setPanelWidth(
|
||||
Math.min(
|
||||
MAX_PANEL_WIDTH,
|
||||
Math.max(MIN_PANEL_WIDTH, startWidth.current + delta),
|
||||
),
|
||||
);
|
||||
};
|
||||
const onMouseUp = () => setDragging(false);
|
||||
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
}, [dragging]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden overscroll-contain z-200 bg-background text-foreground",
|
||||
"fixed top-0 inset-e-0 h-dvh border-s",
|
||||
"shadow-[-8px_0_24px_-4px_rgba(0,0,0,0.1)] dark:shadow-[-8px_0_30px_-2px_rgba(0,0,0,0.7)]",
|
||||
)}
|
||||
style={{ width: panelWidth }}
|
||||
>
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 left-0 w-1 cursor-col-resize hover:bg-foreground/10 active:bg-foreground/15 transition-colors z-10"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
startX.current = e.clientX;
|
||||
startWidth.current = panelWidth;
|
||||
setDragging(true);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="flex flex-col size-full min-h-0 overflow-hidden p-3"
|
||||
style={{ width: panelWidth }}
|
||||
>
|
||||
<PanelHeader />
|
||||
<PanelMessages className="flex-1" />
|
||||
<PanelInput autoFocus />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full-screen overlay during drag */}
|
||||
{dragging && <div className="fixed inset-0 z-[201] cursor-col-resize" />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Panel Header ────────────────────────────────────────────────────────────
|
||||
|
||||
function PanelHeader() {
|
||||
const { setOpen } = useAIChat();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-b px-3 pb-3">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">AI Chat</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Powered by{" "}
|
||||
<a
|
||||
href="https://inkeep.com"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="underline hover:text-foreground transition-colors"
|
||||
>
|
||||
Inkeep AI
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
className="p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Panel Messages ──────────────────────────────────────────────────────────
|
||||
|
||||
function PanelMessages({ className, ...props }: ComponentProps<"div">) {
|
||||
const { messages } = useChatContext();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const filtered = messages.filter((msg) => msg.role !== "system");
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const container = containerRef.current;
|
||||
|
||||
const scrollToBottom = () => {
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: "instant",
|
||||
});
|
||||
};
|
||||
|
||||
// Observe size changes on all current and future children
|
||||
const resizeObserver = new ResizeObserver(scrollToBottom);
|
||||
const observeChildren = () => {
|
||||
resizeObserver.disconnect();
|
||||
for (const child of container.children) {
|
||||
resizeObserver.observe(child);
|
||||
}
|
||||
};
|
||||
|
||||
observeChildren();
|
||||
|
||||
// Re-attach when children are added/removed
|
||||
const mutationObserver = new MutationObserver(observeChildren);
|
||||
mutationObserver.observe(container, { childList: true });
|
||||
|
||||
// Prevent scroll chaining to body on desktop
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const atTop = scrollTop <= 0 && e.deltaY < 0;
|
||||
const atBottom = scrollTop + clientHeight >= scrollHeight && e.deltaY > 0;
|
||||
if (atTop || atBottom) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
container.addEventListener("wheel", onWheel, { passive: false });
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
mutationObserver.disconnect();
|
||||
container.removeEventListener("wheel", onWheel);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"ai-chat-messages overflow-y-auto overscroll-contain min-w-0 min-h-0 flex flex-col py-4 select-text",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
maskImage:
|
||||
"linear-gradient(to bottom, transparent, white 1rem, white calc(100% - 1rem), transparent 100%)",
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="size-full flex flex-col items-center justify-center text-center gap-2 text-sm text-muted-foreground/80 py-6">
|
||||
<MessageCircleIcon
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
className="size-5"
|
||||
/>
|
||||
<p>Start a new chat below.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col px-1 gap-4">
|
||||
{filtered.map((item, index) => (
|
||||
<Message
|
||||
key={item.id}
|
||||
message={item}
|
||||
messages={filtered}
|
||||
messageIndex={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Panel Input ─────────────────────────────────────────────────────────────
|
||||
|
||||
function PanelInput({ autoFocus = false }: { autoFocus?: boolean }) {
|
||||
const { status, sendMessage, stop, setMessages, messages, regenerate } =
|
||||
useChatContext();
|
||||
const [input, setInput] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const isLoading = status === "streaming" || status === "submitted";
|
||||
const inputDragRef = useRef<{ startY: number; startHeight: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [inputMinHeight, setInputMinHeight] = useState(38);
|
||||
|
||||
const adjustHeight = useCallback(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
const maxH = window.innerHeight * 0.35;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(maxH, Math.max(el.scrollHeight, inputMinHeight))}px`;
|
||||
}, [inputMinHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
adjustHeight();
|
||||
}, [adjustHeight]);
|
||||
|
||||
const onSubmit = (e?: SyntheticEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!input.trim() || isLoading) return;
|
||||
void sendMessage({ text: input });
|
||||
setInput("");
|
||||
requestAnimationFrame(adjustHeight);
|
||||
};
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
inputDragRef.current = { startY: e.clientY, startHeight: inputMinHeight };
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (!inputDragRef.current) return;
|
||||
const delta = inputDragRef.current.startY - ev.clientY;
|
||||
setInputMinHeight(
|
||||
Math.max(38, Math.min(400, inputDragRef.current.startHeight + delta)),
|
||||
);
|
||||
};
|
||||
const onUp = () => {
|
||||
inputDragRef.current = null;
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.cursor = "";
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
document.body.style.userSelect = "none";
|
||||
document.body.style.cursor = "row-resize";
|
||||
},
|
||||
[inputMinHeight],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Drag handle to resize input */}
|
||||
<div
|
||||
onMouseDown={handleDragStart}
|
||||
className="absolute -top-1.5 left-1/2 -translate-x-1/2 z-10 hidden lg:flex items-center justify-center w-8 h-3 cursor-row-resize group/drag"
|
||||
>
|
||||
<div className="w-8 h-[2px] rounded-full bg-foreground/20 dark:bg-white/20 group-hover/drag:bg-foreground/40 transition-colors" />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border transition-all duration-200",
|
||||
"border-border/60 dark:border-white/8",
|
||||
"bg-card/50 dark:bg-white/2",
|
||||
"focus-within:border-foreground/15 dark:focus-within:border-white/12",
|
||||
"focus-within:bg-background dark:focus-within:bg-white/3",
|
||||
"focus-within:shadow-[0_0_0_1px_rgba(0,0,0,0.04)] dark:focus-within:shadow-[0_0_0_1px_rgba(255,255,255,0.03)]",
|
||||
)}
|
||||
>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="flex items-end">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
id="nd-ai-input"
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
adjustHeight();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (!e.shiftKey && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
placeholder={isLoading ? "AI is answering..." : "Ask a question"}
|
||||
autoFocus={autoFocus}
|
||||
rows={1}
|
||||
style={{
|
||||
height: Math.max(inputMinHeight, 38),
|
||||
}}
|
||||
className="flex-1 resize-none text-base lg:text-[13px] bg-transparent pl-3.5 pr-1.5 py-2.5 placeholder:text-muted-foreground focus:outline-none overflow-y-auto"
|
||||
/>
|
||||
<div className="shrink-0 pb-1.5 pr-1.5">
|
||||
{isLoading ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={stop}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-border text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-all duration-150 cursor-pointer"
|
||||
title="Stop generating"
|
||||
>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={input.trim().length === 0}
|
||||
className={cn(
|
||||
"inline-flex h-7 w-7 items-center justify-center rounded-full transition-all duration-150",
|
||||
input.trim()
|
||||
? "bg-foreground text-background hover:bg-foreground/90 cursor-pointer"
|
||||
: "bg-muted/50 dark:bg-white/5 text-muted-foreground cursor-default",
|
||||
)}
|
||||
title="Send (Enter)"
|
||||
>
|
||||
<Send className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 p-1 empty:hidden">
|
||||
{!isLoading &&
|
||||
messages.length > 0 &&
|
||||
messages.at(-1)?.role === "assistant" && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 text-xs border rounded-full text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => regenerate()}
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
{messages.length > 0 && !isLoading && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 text-xs border rounded-full text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setMessages([])}
|
||||
>
|
||||
Clear Chat
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Message ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const roleName: Record<string, string> = {
|
||||
user: "you",
|
||||
assistant: "BA bot",
|
||||
};
|
||||
|
||||
function Message({
|
||||
message,
|
||||
messages,
|
||||
messageIndex,
|
||||
}: {
|
||||
message: UIMessage;
|
||||
messages: UIMessage[];
|
||||
messageIndex: number;
|
||||
}) {
|
||||
const { status } = useChatContext();
|
||||
const isLastAssistant =
|
||||
message.role === "assistant" && messageIndex === messages.length - 1;
|
||||
const isStreaming =
|
||||
(status === "streaming" || status === "submitted") && isLastAssistant;
|
||||
|
||||
let markdown = "";
|
||||
for (const part of message.parts ?? []) {
|
||||
if (part.type === "text") {
|
||||
markdown += part.text;
|
||||
}
|
||||
}
|
||||
|
||||
if (message.role === "assistant") {
|
||||
// Remove Inkeep citation markers like (1), (2) etc.
|
||||
markdown = markdown.replace(/\(\d+\)/g, "");
|
||||
// Remove stray backslashes used as line breaks by Inkeep
|
||||
markdown = markdown.replace(/^\s*\\\s*$/gm, "");
|
||||
}
|
||||
|
||||
// Fix incomplete code blocks
|
||||
const codeBlockCount = (markdown.match(/```/g) || []).length;
|
||||
if (codeBlockCount % 2 !== 0) {
|
||||
markdown += "\n```";
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
"mb-1 text-xs font-medium text-muted-foreground",
|
||||
message.role === "assistant" && "text-foreground",
|
||||
)}
|
||||
>
|
||||
{roleName[message.role] ?? "unknown"}
|
||||
</p>
|
||||
<div className="text-sm prose prose-sm max-w-none [&_pre]:!text-[11px]">
|
||||
<Markdown text={markdown} />
|
||||
</div>
|
||||
{message.role === "assistant" && message.id && !isStreaming && (
|
||||
<MessageFeedback
|
||||
messageId={message.id}
|
||||
userMessageId={
|
||||
messageIndex > 0 ? messages[messageIndex - 1]?.id : undefined
|
||||
}
|
||||
content={markdown}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Hot Key ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function useAIChatHotKey() {
|
||||
const { open, setOpen } = useAIChat();
|
||||
|
||||
const onKeyPress = useEffectEvent((e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && open) {
|
||||
setOpen(false);
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.key === "i" && (e.metaKey || e.ctrlKey)) {
|
||||
setOpen(!open);
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", onKeyPress);
|
||||
return () => window.removeEventListener("keydown", onKeyPress);
|
||||
}, []);
|
||||
}
|
||||
@@ -48,7 +48,9 @@ export const APIMethod = ({
|
||||
children,
|
||||
noResult,
|
||||
requireSession,
|
||||
requireHeaders,
|
||||
requireBearerToken,
|
||||
headersComment,
|
||||
note,
|
||||
clientOnlyNote,
|
||||
serverOnlyNote,
|
||||
@@ -73,6 +75,16 @@ export const APIMethod = ({
|
||||
* @default false
|
||||
*/
|
||||
requireBearerToken?: boolean;
|
||||
/**
|
||||
* If enabled, we will add `headers` to the fetch options without assuming the request is backed by a session.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
requireHeaders?: boolean;
|
||||
/**
|
||||
* A custom comment to display above the generated `headers` line when `requireHeaders` is enabled.
|
||||
*/
|
||||
headersComment?: string;
|
||||
/**
|
||||
* The HTTP method to the endpoint
|
||||
*
|
||||
@@ -152,7 +164,9 @@ export const APIMethod = ({
|
||||
props,
|
||||
method: method ?? "GET",
|
||||
requireSession: requireSession ?? false,
|
||||
requireHeaders: requireHeaders ?? false,
|
||||
requireBearerToken: requireBearerToken ?? false,
|
||||
headersComment,
|
||||
forceAsQuery,
|
||||
forceAsParam,
|
||||
forceAsBody,
|
||||
@@ -768,7 +782,9 @@ function shouldServerUseQueryParams(
|
||||
function createServerBody({
|
||||
props,
|
||||
requireSession,
|
||||
requireHeaders,
|
||||
requireBearerToken,
|
||||
headersComment,
|
||||
method,
|
||||
forceAsBody,
|
||||
forceAsParam,
|
||||
@@ -776,7 +792,9 @@ function createServerBody({
|
||||
}: {
|
||||
props: Property[];
|
||||
requireSession: boolean;
|
||||
requireHeaders: boolean;
|
||||
requireBearerToken: boolean;
|
||||
headersComment: string | undefined;
|
||||
method: string;
|
||||
forceAsQuery: boolean | undefined;
|
||||
forceAsParam: boolean | undefined;
|
||||
@@ -840,6 +858,11 @@ function createServerBody({
|
||||
if (requireSession) {
|
||||
fetchOptions +=
|
||||
"\n // This endpoint requires session cookies.\n headers: await headers(),";
|
||||
} else if (requireHeaders) {
|
||||
fetchOptions += `\n // ${
|
||||
headersComment ||
|
||||
"Pass the current request headers so Better Auth can read and set cookies."
|
||||
}\n headers: await headers(),`;
|
||||
}
|
||||
|
||||
if (requireBearerToken) {
|
||||
|
||||
@@ -181,7 +181,7 @@ export function BlogLeftPanel({ postCount, post }: BlogLeftPanelProps) {
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="/rss.xml"
|
||||
href="/blog/rss.xml"
|
||||
className="flex items-center gap-1.5 text-foreground/30 hover:text-foreground/70 transition-colors"
|
||||
aria-label="RSS Feed"
|
||||
>
|
||||
|
||||
@@ -290,6 +290,17 @@ export const communityPlugins: CommunityPlugin[] = [
|
||||
avatar: "https://github.com/iamjasonkendrick.png",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "better-auth-invite-plugin",
|
||||
url: "https://github.com/0-Sandy/better-auth-invite-plugin",
|
||||
description:
|
||||
"Easily create and manage user invitations, allowing you to invite users with customizable settings and track usage.",
|
||||
author: {
|
||||
name: "Sandy",
|
||||
github: "0-Sandy",
|
||||
avatar: "https://github.com/0-Sandy.png",
|
||||
},
|
||||
},
|
||||
];
|
||||
export function CommunityPluginsTable() {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
@@ -5,11 +5,11 @@ import type {
|
||||
Item as PageTreeItem,
|
||||
Separator as PageTreeSeparator,
|
||||
} from "fumadocs-core/page-tree";
|
||||
import { useSearchContext } from "fumadocs-ui/contexts/search";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { useCommandMenu } from "@/components/command-menu";
|
||||
|
||||
// ─── Item ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -111,7 +111,7 @@ export const CustomSeparator: FC<{ item: PageTreeSeparator }> = ({ item }) => {
|
||||
// ─── Search Button (sidebar banner) ─────────────────────────────────────────
|
||||
|
||||
export const SearchButton: FC = () => {
|
||||
const { setOpen } = useCommandMenu();
|
||||
const { setOpenSearch: setOpen } = useSearchContext();
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, MotionConfig, motion } from "framer-motion";
|
||||
import { ChevronDownIcon, GitBranch, Search, Sparkles } from "lucide-react";
|
||||
import { useSearchContext } from "fumadocs-ui/contexts/search";
|
||||
import { ChevronDownIcon, GitBranch, Search } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCommandMenu } from "@/components/command-menu";
|
||||
import type { ListItem } from "@/components/sidebar-content";
|
||||
import { contents } from "@/components/sidebar-content";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -18,7 +19,7 @@ export function DocsSidebar() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const branch = searchParams.get("branch");
|
||||
const { setOpen, openAI } = useCommandMenu();
|
||||
const { setOpenSearch } = useSearchContext();
|
||||
const [currentOpen, setCurrentOpen] = useState(0);
|
||||
const navRef = useRef<HTMLElement>(null);
|
||||
|
||||
@@ -77,30 +78,24 @@ export function DocsSidebar() {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-4 lg:px-7 py-2.5 border-b border-foreground/5 text-[12px] uppercase tracking-wider text-foreground/55 hover:text-foreground/80 transition-colors"
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex w-full items-center gap-2 px-4 py-2.5 border-b border-foreground/5 text-sm text-foreground/55 hover:text-foreground/80 hover:bg-foreground/3 transition-colors"
|
||||
onClick={() => setOpenSearch(true)}
|
||||
>
|
||||
<Search className="size-3.5 shrink-0" />
|
||||
<Search className="size-4 shrink-0" />
|
||||
<span className="truncate">Search</span>
|
||||
<span
|
||||
className="ml-auto flex items-center gap-1 shrink-0 text-foreground/55 hover:text-foreground/70 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openAI();
|
||||
}}
|
||||
onKeyDown={() => {}}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Sparkles className="size-3" />
|
||||
<span className="text-[10px] normal-case tracking-normal">AI</span>
|
||||
</span>
|
||||
<kbd className="ml-auto inline-flex items-center gap-0.5 shrink-0 text-[10px] font-mono text-foreground/40 border border-foreground/10 rounded-md px-1.5 py-0.5">
|
||||
<span className="text-[11px]">⌘</span>K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
{/* Scrollable navigation area */}
|
||||
<nav
|
||||
ref={navRef}
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden pb-3 sidebar-scroll"
|
||||
style={{
|
||||
maskImage:
|
||||
"linear-gradient(to bottom, transparent, white 1rem, white calc(100% - 2rem), transparent 100%)",
|
||||
}}
|
||||
>
|
||||
<MotionConfig
|
||||
transition={{ duration: 0.35, type: "spring", bounce: 0 }}
|
||||
@@ -112,7 +107,6 @@ export function DocsSidebar() {
|
||||
type="button"
|
||||
className={cn(
|
||||
"border-b border-foreground/6 w-full text-left flex gap-2 items-center px-4 py-2.5 transition-colors",
|
||||
"lg:px-7",
|
||||
"font-medium text-sm tracking-wider",
|
||||
currentOpen === index
|
||||
? "text-foreground bg-foreground/3"
|
||||
@@ -154,6 +148,29 @@ export function DocsSidebar() {
|
||||
</div>
|
||||
</MotionConfig>
|
||||
</nav>
|
||||
|
||||
{/* Footer: GitHub + Theme Toggle */}
|
||||
<div className="flex items-center gap-1 p-2 border-t border-foreground/5 text-foreground/40">
|
||||
<a
|
||||
href="https://github.com/better-auth/better-auth"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center justify-center size-8 hover:text-foreground/70 hover:bg-foreground/5 transition-colors"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="size-4"
|
||||
>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
</a>
|
||||
<div className="ms-auto [&_button]:text-foreground/40 [&_button:hover]:text-foreground/70">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</motion.aside>
|
||||
);
|
||||
}
|
||||
@@ -195,7 +212,7 @@ function BranchSwitcher() {
|
||||
<div ref={ref} className="relative border-b border-foreground/10">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-4 lg:px-7 py-3 hover:bg-foreground/[0.02] transition-colors"
|
||||
className="flex w-full items-center gap-2 px-4 py-3 hover:bg-foreground/[0.02] transition-colors"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<GitBranch className="size-3.5 text-foreground/60 shrink-0" />
|
||||
@@ -376,13 +393,13 @@ function SidebarItemWithSubpages({
|
||||
transition={{ duration: 0.35, type: "spring", bounce: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="relative before:absolute before:left-[calc(1.75rem+0.75rem)] lg:before:left-[calc(2.75rem+0.75rem)] before:top-0 before:bottom-0 before:w-px before:bg-foreground/20">
|
||||
<div className="relative before:absolute before:left-[calc(1.75rem+0.75rem)] before:top-0 before:bottom-0 before:w-px before:bg-foreground/20">
|
||||
{item.subpages.map((subpage, i) => {
|
||||
if (subpage.group) {
|
||||
return (
|
||||
<div
|
||||
key={`subgroup-${subpage.title}-${i}`}
|
||||
className="flex flex-row items-center gap-2 pl-[calc(1.75rem+0.75rem+0.75rem)] lg:pl-[calc(2.75rem+0.75rem+0.75rem)] pr-4 lg:pr-7 py-1.5 mt-1 first:mt-0"
|
||||
className="flex flex-row items-center gap-2 pl-[calc(1.75rem+0.75rem+0.75rem)] pr-4 py-1.5 mt-1 first:mt-0"
|
||||
>
|
||||
<p className="text-[10px] text-foreground/45 uppercase tracking-wider">
|
||||
{subpage.title}
|
||||
@@ -433,7 +450,7 @@ function SubpageLink({
|
||||
href={href}
|
||||
data-active={active || undefined}
|
||||
className={cn(
|
||||
"relative flex items-center gap-1 pl-[calc(1.75rem+0.75rem+0.75rem)] lg:pl-[calc(2.75rem+0.75rem+0.75rem)] pr-4 lg:pr-7 py-1 text-[13px] transition-all duration-150",
|
||||
"relative flex items-center gap-1 pl-[calc(1.75rem+0.75rem+0.75rem)] pr-4 py-1 text-[13px] transition-all duration-150",
|
||||
active
|
||||
? "text-foreground bg-foreground/6"
|
||||
: "text-foreground/55 hover:text-foreground/80 hover:bg-foreground/3",
|
||||
@@ -474,7 +491,7 @@ function SidebarLink({
|
||||
href={href}
|
||||
data-active={active || undefined}
|
||||
className={`
|
||||
relative flex items-center gap-2.5 px-4 lg:px-7 py-1 text-[14px] transition-all duration-150
|
||||
relative flex items-center gap-2.5 px-4 py-1 text-[14px] transition-all duration-150
|
||||
${
|
||||
active
|
||||
? "text-foreground bg-foreground/6"
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { useCommandMenu } from "@/components/command-menu";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
|
||||
export function FloatingToolbar() {
|
||||
const { setOpen } = useCommandMenu();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.8, ease: "easeOut" }}
|
||||
className="fixed bottom-5 right-5 z-[100] flex items-center border border-foreground/[0.06] bg-background/60 backdrop-blur-md"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="group flex items-center justify-center size-9 text-foreground/40 hover:text-foreground/70 transition-colors"
|
||||
>
|
||||
<kbd className="flex items-center gap-0.5 text-[10px] font-mono">
|
||||
<span className="text-[11px]">⌘</span>K
|
||||
</kbd>
|
||||
<span className="sr-only">Command menu</span>
|
||||
</button>
|
||||
<span className="w-px h-3 bg-foreground/[0.08]" />
|
||||
<div className="flex items-center justify-center size-9 [&_button]:text-foreground/40 [&_button:hover]:text-foreground/70">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, MotionConfig, motion } from "framer-motion";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ChevronDownIcon, Search } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
import DarkPng from "../../public/branding/better-auth-logo-dark.png";
|
||||
import WhitePng from "../../public/branding/better-auth-logo-light.png";
|
||||
import { Logo } from "../icons/logo";
|
||||
import { contents } from "../sidebar-content";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "../ui/accordion";
|
||||
import { Badge } from "../ui/badge";
|
||||
import LogoContextMenu from "./logo-context-menu";
|
||||
|
||||
@@ -37,10 +44,22 @@ const resourceFiles: NavFileItem[] = [
|
||||
{ name: "careers", href: "/careers" },
|
||||
];
|
||||
|
||||
interface MobileMenuSection {
|
||||
name: string;
|
||||
href?: string;
|
||||
children?: NavFileItem[];
|
||||
}
|
||||
|
||||
const mobileMenuSections: MobileMenuSection[] = [
|
||||
{ name: "products", children: productFiles },
|
||||
{ name: "resources", children: resourceFiles },
|
||||
{ name: "enterprise", href: "/enterprise" },
|
||||
];
|
||||
|
||||
function DropdownItem({ item }: { item: NavFileItem }) {
|
||||
return (
|
||||
<div className="group/item flex items-center gap-1.5 px-3 py-1.5 hover:bg-foreground/[0.06] transition-colors duration-150 cursor-pointer">
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider text-foreground/75 dark:text-foreground/60 group-hover/item:text-foreground transition-colors duration-150 whitespace-nowrap">
|
||||
<div className="group/item flex w-full min-w-0 items-center gap-1.5 px-3 py-1.5 hover:bg-foreground/[0.06] transition-colors duration-150 cursor-pointer">
|
||||
<span className="block min-w-0 truncate font-mono text-[10px] uppercase tracking-wider text-foreground/75 dark:text-foreground/60 group-hover/item:text-foreground transition-colors duration-150">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
@@ -104,6 +123,24 @@ export function StaggeredNavFiles() {
|
||||
const productsTimeout = useRef<NodeJS.Timeout>(undefined);
|
||||
const resourcesTimeout = useRef<NodeJS.Timeout>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = mobileMenuOpen ? "hidden" : "";
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [mobileMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia("(min-width: 1024px)");
|
||||
const handler = () => {
|
||||
if (mql.matches) {
|
||||
setMobileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
mql.addEventListener("change", handler);
|
||||
return () => mql.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
const openProducts = () => {
|
||||
clearTimeout(productsTimeout.current);
|
||||
setProductsOpen(true);
|
||||
@@ -119,6 +156,10 @@ export function StaggeredNavFiles() {
|
||||
resourcesTimeout.current = setTimeout(() => setResourcesOpen(false), 150);
|
||||
};
|
||||
const isActive = useCallback((href: string) => pathname === href, [pathname]);
|
||||
const isActivePrefix = useCallback(
|
||||
(href: string) => pathname === href || pathname.startsWith(`${href}/`),
|
||||
[pathname],
|
||||
);
|
||||
const isDocs = pathname.startsWith("/docs");
|
||||
const isProductPage =
|
||||
pathname === "/products" || pathname.startsWith("/products/");
|
||||
@@ -161,7 +202,7 @@ export function StaggeredNavFiles() {
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-1 px-4 lg:px-7 py-3 transition-colors duration-150"
|
||||
className="flex items-center gap-1 px-4 py-3 transition-colors duration-150"
|
||||
>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<LogoContextMenu
|
||||
@@ -184,18 +225,39 @@ export function StaggeredNavFiles() {
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.28, ease: "easeOut" }}
|
||||
className="lg:hidden flex items-center justify-between w-full pointer-events-auto bg-background border-b border-foreground/[0.06]"
|
||||
className="lg:hidden flex items-center justify-between w-full h-[52px] pointer-events-auto bg-background border-b border-foreground/[0.06]"
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-1 px-4 py-3 transition-colors duration-150"
|
||||
>
|
||||
<Logo />
|
||||
<p className="select-none font-mono text-sm uppercase tracking-wider">
|
||||
<p className="select-none font-mono text-base uppercase">
|
||||
BETTER-AUTH.
|
||||
</p>
|
||||
</Link>
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center gap-1 pr-2">
|
||||
{isDocs && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "k",
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
className="flex items-center justify-center size-8 text-foreground/50 hover:text-foreground/80 transition-colors"
|
||||
aria-label="Search"
|
||||
>
|
||||
<Search className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center justify-center size-8 text-foreground/50 [&_button]:text-foreground/50 [&_button:hover]:text-foreground/80">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -216,7 +278,7 @@ export function StaggeredNavFiles() {
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-center px-4 py-3 text-foreground/75 dark:text-foreground/60 hover:text-foreground/85 transition-colors"
|
||||
className="flex items-center justify-center size-8 text-foreground/75 dark:text-foreground/60 hover:text-foreground/85 transition-colors"
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<svg
|
||||
@@ -490,12 +552,12 @@ export function StaggeredNavFiles() {
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
<div className="border-t border-foreground/[0.06] mt-1 pt-1 flex items-center gap-0.5 px-2.5">
|
||||
<div className="mt-1 grid w-full grid-cols-[repeat(auto-fit,minmax(1.75rem,1fr))] items-center justify-items-center gap-y-0.5 border-t border-foreground/[0.06] px-2 pt-1">
|
||||
<a
|
||||
href="https://github.com/better-auth/better-auth"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="p-1.5 text-foreground/55 dark:text-foreground/40 hover:text-foreground/75 transition-colors"
|
||||
className="flex items-center justify-center p-1 text-foreground/55 dark:text-foreground/40 hover:text-foreground/75 transition-colors"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<svg
|
||||
@@ -514,7 +576,7 @@ export function StaggeredNavFiles() {
|
||||
href="https://discord.gg/better-auth"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="p-1.5 text-foreground/55 dark:text-foreground/40 hover:text-foreground/75 transition-colors"
|
||||
className="flex items-center justify-center p-1 text-foreground/55 dark:text-foreground/40 hover:text-foreground/75 transition-colors"
|
||||
aria-label="Discord"
|
||||
>
|
||||
<svg
|
||||
@@ -533,7 +595,7 @@ export function StaggeredNavFiles() {
|
||||
href="https://reddit.com/r/better_auth"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="p-1.5 text-foreground/55 dark:text-foreground/40 hover:text-foreground/75 transition-colors"
|
||||
className="flex items-center justify-center p-1 text-foreground/55 dark:text-foreground/40 hover:text-foreground/75 transition-colors"
|
||||
aria-label="Reddit"
|
||||
>
|
||||
<svg
|
||||
@@ -554,7 +616,7 @@ export function StaggeredNavFiles() {
|
||||
href="https://x.com/better_auth"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="p-1.5 text-foreground/55 dark:text-foreground/40 hover:text-foreground/75 transition-colors"
|
||||
className="flex items-center justify-center p-1 text-foreground/55 dark:text-foreground/40 hover:text-foreground/75 transition-colors"
|
||||
aria-label="X"
|
||||
>
|
||||
<svg
|
||||
@@ -573,7 +635,7 @@ export function StaggeredNavFiles() {
|
||||
href="https://www.npmjs.com/package/better-auth"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="p-1.5 text-foreground/55 dark:text-foreground/40 hover:text-foreground/75 transition-colors"
|
||||
className="flex items-center justify-center p-1 text-foreground/55 dark:text-foreground/40 hover:text-foreground/75 transition-colors"
|
||||
aria-label="npm"
|
||||
>
|
||||
<svg
|
||||
@@ -633,37 +695,36 @@ export function StaggeredNavFiles() {
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="lg:hidden fixed inset-0 z-[98] bg-background/95 backdrop-blur-sm pointer-events-auto"
|
||||
className="lg:hidden fixed inset-0 z-[98] w-full bg-background/95 backdrop-blur-sm pointer-events-auto"
|
||||
>
|
||||
<div className="pt-[52px] flex flex-col h-full overflow-y-auto">
|
||||
{isDocs && mobileView === "docs" ? (
|
||||
<>
|
||||
{/* Subtle back to nav button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileView("nav")}
|
||||
className="flex items-center gap-2 px-5 py-2.5 text-foreground/65 dark:text-foreground/45 hover:text-foreground/70 transition-colors border-b border-foreground/[0.06]"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
<div className="pt-[52px] flex flex-col h-full">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{isDocs && mobileView === "docs" ? (
|
||||
<>
|
||||
{/* Subtle back to nav button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileView("nav")}
|
||||
className="flex items-center gap-2 w-full px-5 py-2.5 text-foreground/65 dark:text-foreground/45 hover:text-foreground/70 transition-colors border-b border-foreground/6"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M3 18h18v-2H3zm0-5h18v-2H3zm0-7v2h18V6z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider">
|
||||
Menu
|
||||
</span>
|
||||
</button>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M3 18h18v-2H3zm0-5h18v-2H3zm0-7v2h18V6z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider">
|
||||
Menu
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Doc sidebar sections */}
|
||||
|
||||
{/* Doc sidebar sections */}
|
||||
<MotionConfig
|
||||
transition={{ duration: 0.35, type: "spring", bounce: 0 }}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{contents.map((section, index) => (
|
||||
<div key={section.title}>
|
||||
@@ -691,207 +752,231 @@ export function StaggeredNavFiles() {
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{mobileDocSection === index && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="relative overflow-hidden"
|
||||
>
|
||||
<div className="text-sm pt-0 pb-1">
|
||||
{section.href && (
|
||||
{mobileDocSection === index && (
|
||||
<div className="relative overflow-hidden">
|
||||
<div className="text-sm pt-0 pb-1">
|
||||
{section.href && (
|
||||
<Link
|
||||
href={section.href}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
data-active={
|
||||
pathname === section.href || undefined
|
||||
}
|
||||
className={cn(
|
||||
"relative flex items-center gap-2.5 px-5 py-1.5 text-[14px] transition-all duration-150",
|
||||
pathname === section.href
|
||||
? "text-foreground bg-foreground/6"
|
||||
: "text-foreground/75 dark:text-foreground/60 hover:text-foreground/90 hover:bg-foreground/3",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">Overview</span>
|
||||
</Link>
|
||||
)}
|
||||
{section.list.map((item, i) => {
|
||||
if (item.separator || item.group) {
|
||||
return (
|
||||
<div
|
||||
key={`sep-${item.title}-${i}`}
|
||||
className="flex flex-row items-center gap-2 mx-5 my-2"
|
||||
>
|
||||
<p className="text-[10px] text-foreground/65 dark:text-foreground/45 uppercase tracking-wider">
|
||||
{item.title}
|
||||
</p>
|
||||
<div className="grow h-px bg-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!item.href) return null;
|
||||
const active =
|
||||
pathname === item.href ||
|
||||
(!!item.subpages?.length &&
|
||||
pathname.startsWith(`${item.href}/`));
|
||||
return (
|
||||
<Link
|
||||
href={section.href}
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
data-active={
|
||||
pathname === section.href || undefined
|
||||
}
|
||||
data-active={active || undefined}
|
||||
className={cn(
|
||||
"relative flex items-center gap-2.5 px-5 py-1.5 text-[14px] transition-all duration-150",
|
||||
pathname === section.href
|
||||
active
|
||||
? "text-foreground bg-foreground/6"
|
||||
: "text-foreground/75 dark:text-foreground/60 hover:text-foreground/90 hover:bg-foreground/3",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">Overview</span>
|
||||
</Link>
|
||||
)}
|
||||
{section.list.map((item, i) => {
|
||||
if (item.separator || item.group) {
|
||||
return (
|
||||
<div
|
||||
key={`sep-${item.title}-${i}`}
|
||||
className="flex flex-row items-center gap-2 mx-5 my-2"
|
||||
>
|
||||
<p className="text-[10px] text-foreground/65 dark:text-foreground/45 uppercase tracking-wider">
|
||||
{item.title}
|
||||
</p>
|
||||
<div className="grow h-px bg-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!item.href) return null;
|
||||
const active =
|
||||
pathname === item.href ||
|
||||
(!!item.subpages?.length &&
|
||||
pathname.startsWith(`${item.href}/`));
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
data-active={active || undefined}
|
||||
<span
|
||||
className={cn(
|
||||
"relative flex items-center gap-2.5 px-5 py-1.5 text-[14px] transition-all duration-150",
|
||||
"min-w-5 [&>svg]:size-[14px] transition-colors duration-150",
|
||||
active
|
||||
? "text-foreground bg-foreground/6"
|
||||
: "text-foreground/75 dark:text-foreground/60 hover:text-foreground/90 hover:bg-foreground/3",
|
||||
? "text-foreground"
|
||||
: "text-foreground/75 dark:text-foreground/60",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
<item.icon className="text-foreground/75" />
|
||||
</span>
|
||||
<span className="truncate grow">
|
||||
{item.title}
|
||||
</span>
|
||||
{item.isNew && (
|
||||
<Badge
|
||||
className={cn(
|
||||
"min-w-5 [&>svg]:size-[14px] transition-colors duration-150",
|
||||
"pointer-events-none border-dashed rounded-none px-1.5 py-0 text-[9px] uppercase tracking-wider",
|
||||
active
|
||||
? "text-foreground"
|
||||
: "text-foreground/75 dark:text-foreground/60",
|
||||
? "border-solid bg-foreground/10 text-foreground"
|
||||
: "text-foreground/70 dark:text-foreground/55 border-foreground/25",
|
||||
)}
|
||||
variant="outline"
|
||||
>
|
||||
<item.icon className="text-foreground/75" />
|
||||
</span>
|
||||
<span className="truncate grow">
|
||||
{item.title}
|
||||
</span>
|
||||
{item.isNew && (
|
||||
<Badge
|
||||
className={cn(
|
||||
"pointer-events-none border-dashed rounded-none px-1.5 py-0 text-[9px] uppercase tracking-wider",
|
||||
active
|
||||
? "border-solid bg-foreground/10 text-foreground"
|
||||
: "text-foreground/70 dark:text-foreground/55 border-foreground/25",
|
||||
)}
|
||||
variant="outline"
|
||||
>
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</MotionConfig>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Back to docs button (when on docs page and switched to nav view) */}
|
||||
{isDocs && mobileView === "nav" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileView("docs")}
|
||||
className="flex items-center gap-2 px-5 py-2.5 text-foreground/65 dark:text-foreground/45 hover:text-foreground/70 transition-colors border-b border-foreground/[0.06]"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Back to docs button (when on docs page and switched to nav view) */}
|
||||
{isDocs && mobileView === "nav" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileView("docs")}
|
||||
className="flex items-center gap-2 w-full px-5 py-2.5 text-foreground/65 dark:text-foreground/45 hover:text-foreground/70 transition-colors border-b border-foreground/6"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M20 11H7.83l5.59-5.59L12 4l-8 8l8 8l1.41-1.41L7.83 13H20z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider">
|
||||
Docs
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Default nav items */}
|
||||
{[...navFiles, ...productFiles, ...resourceFiles].map(
|
||||
(item, i) => (
|
||||
<motion.div
|
||||
key={item.name}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.15, delay: i * 0.03 }}
|
||||
>
|
||||
<Link
|
||||
href={item.href}
|
||||
target={item.external ? "_blank" : undefined}
|
||||
rel={item.external ? "noreferrer" : undefined}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={`flex items-center gap-2.5 px-5 py-3.5 border-b border-foreground/[0.06] transition-colors ${
|
||||
isActive(item.path || item.href) ||
|
||||
(item.href === "/docs" && isDocs)
|
||||
? "bg-foreground/[0.04]"
|
||||
: "hover:bg-foreground/[0.03]"
|
||||
}`}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<span
|
||||
className={`font-mono text-sm uppercase tracking-wider ${
|
||||
isActive(item.path || item.href) ||
|
||||
(item.href === "/docs" && isDocs)
|
||||
? "text-foreground"
|
||||
: "text-foreground/75 dark:text-foreground/60"
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
{item.external && (
|
||||
<svg
|
||||
className="h-2.5 w-2.5 text-foreground/45 dark:text-foreground/30 ml-auto"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M1 9L9 1M9 1H3M9 1V7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</Link>
|
||||
</motion.div>
|
||||
),
|
||||
)}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{
|
||||
duration: 0.15,
|
||||
delay:
|
||||
[...navFiles, ...productFiles, ...resourceFiles]
|
||||
.length * 0.03,
|
||||
}}
|
||||
className="px-5 pt-4"
|
||||
>
|
||||
<a
|
||||
href="https://dash.better-auth.com/sign-in"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center justify-center gap-1.5 w-full py-3 bg-foreground text-background font-mono text-sm uppercase tracking-wider transition-opacity hover:opacity-90"
|
||||
>
|
||||
get-started
|
||||
<svg
|
||||
className="h-2.5 w-2.5 opacity-50"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M20 11H7.83l5.59-5.59L12 4l-8 8l8 8l1.41-1.41L7.83 13H20z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider">
|
||||
Docs
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Nav items */}
|
||||
{navFiles.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-5 py-3.5 border-b border-foreground/6 transition-colors font-mono text-base uppercase tracking-wider",
|
||||
isActive(item.path || item.href) ||
|
||||
(item.href === "/docs" && isDocs)
|
||||
? "text-foreground bg-foreground/4"
|
||||
: "text-foreground/75 dark:text-foreground/60 hover:bg-foreground/3",
|
||||
)}
|
||||
>
|
||||
<path
|
||||
d="M1 9L9 1M9 1H3M9 1V7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</motion.div>
|
||||
</>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Accordion groups */}
|
||||
<Accordion
|
||||
type="multiple"
|
||||
defaultValue={[
|
||||
"products",
|
||||
...mobileMenuSections
|
||||
.filter((s) =>
|
||||
s.children?.some((item) =>
|
||||
isActivePrefix(item.path || item.href),
|
||||
),
|
||||
)
|
||||
.map((s) => s.name),
|
||||
]}
|
||||
className="w-full"
|
||||
>
|
||||
{mobileMenuSections.map((section) => (
|
||||
<AccordionItem
|
||||
key={section.name}
|
||||
value={section.name}
|
||||
className="border-foreground/6"
|
||||
>
|
||||
{section.children ? (
|
||||
<>
|
||||
<AccordionTrigger className="px-5 py-3.5 font-mono text-base uppercase tracking-wider text-foreground/75 dark:text-foreground/60 hover:text-foreground hover:no-underline">
|
||||
{section.name}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-0">
|
||||
{section.children.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
target={
|
||||
item.external ? "_blank" : undefined
|
||||
}
|
||||
rel={
|
||||
item.external ? "noreferrer" : undefined
|
||||
}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 pl-9 pr-5 py-2.5 transition-colors font-mono text-sm uppercase tracking-wider",
|
||||
isActivePrefix(item.path || item.href)
|
||||
? "text-foreground bg-foreground/4"
|
||||
: "text-foreground/60 dark:text-foreground/45 hover:text-foreground hover:bg-foreground/3",
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
href={section.href!}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-5 py-3.5 transition-colors font-mono text-base uppercase tracking-wider",
|
||||
isActive(section.href!)
|
||||
? "text-foreground bg-foreground/4"
|
||||
: "text-foreground/75 dark:text-foreground/60 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{section.name}
|
||||
</Link>
|
||||
)}
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sticky footer with sign-in CTA */}
|
||||
{!(isDocs && mobileView === "docs") && (
|
||||
<div className="shrink-0 border-t border-foreground/[0.06] bg-background px-5 py-4">
|
||||
<a
|
||||
href="https://dash.better-auth.com/sign-in"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center justify-center gap-1.5 w-full py-3 bg-foreground text-background font-mono text-sm uppercase tracking-wider transition-opacity hover:opacity-90"
|
||||
>
|
||||
sign-in
|
||||
<svg
|
||||
className="h-2.5 w-2.5 opacity-50"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M1 9L9 1M9 1H3M9 1V7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import type {
|
||||
SearchItemType,
|
||||
SharedProps,
|
||||
} from "fumadocs-ui/components/dialog/search";
|
||||
import {
|
||||
SearchDialog,
|
||||
SearchDialogClose,
|
||||
SearchDialogContent,
|
||||
SearchDialogFooter,
|
||||
SearchDialogHeader,
|
||||
SearchDialogInput,
|
||||
SearchDialogList,
|
||||
SearchDialogListItem,
|
||||
SearchDialogOverlay,
|
||||
useSearch,
|
||||
} from "fumadocs-ui/components/dialog/search";
|
||||
import { ArrowRight, Search } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import { Client } from "typesense";
|
||||
import { useTypesenseSearch } from "typesense-fumadocs-adapter/client";
|
||||
import { usePages } from "@/app/docs/provider";
|
||||
|
||||
const typesenseClient = (() => {
|
||||
const url = process.env.NEXT_PUBLIC_TYPESENSE_SERVER_URL;
|
||||
const apiKey = process.env.NEXT_PUBLIC_TYPESENSE_SEARCH_API_KEY;
|
||||
if (!url || !apiKey) {
|
||||
// Return a dummy client so the app works without Typesense configured
|
||||
return new Client({
|
||||
nodes: [{ host: "localhost", port: 8108, protocol: "http" }],
|
||||
apiKey: "dummy",
|
||||
});
|
||||
}
|
||||
let serverUrl: URL;
|
||||
try {
|
||||
serverUrl = new URL(url);
|
||||
} catch {
|
||||
return new Client({
|
||||
nodes: [{ host: "localhost", port: 8108, protocol: "http" }],
|
||||
apiKey: "dummy",
|
||||
});
|
||||
}
|
||||
return new Client({
|
||||
nodes: [
|
||||
{
|
||||
host: serverUrl.hostname,
|
||||
port:
|
||||
Number(serverUrl.port) ||
|
||||
(serverUrl.protocol === "https:" ? 443 : 80),
|
||||
protocol: serverUrl.protocol.replace(":", ""),
|
||||
},
|
||||
],
|
||||
apiKey,
|
||||
});
|
||||
})();
|
||||
|
||||
export default function CustomSearchDialog(props: SharedProps) {
|
||||
const { search, setSearch, query } = useTypesenseSearch({
|
||||
typesenseCollectionName: "better-auth-docs",
|
||||
client: typesenseClient!,
|
||||
/**
|
||||
* Non-legacy mode leaves raw <mark> tags in content at the moment,
|
||||
* which renders as plain text in fumadocs-ui
|
||||
*/
|
||||
legacy: true,
|
||||
});
|
||||
const pages = usePages();
|
||||
const router = useRouter();
|
||||
|
||||
const pageTreeAction = useMemo<SearchItemType | undefined>(() => {
|
||||
if (search.length === 0) return;
|
||||
|
||||
const normalized = search.toLowerCase();
|
||||
for (const page of pages) {
|
||||
if (!page.name.toLowerCase().includes(normalized)) continue;
|
||||
|
||||
return {
|
||||
id: "quick-action",
|
||||
type: "action",
|
||||
node: (
|
||||
<div className="inline-flex items-center gap-2 text-fd-muted-foreground">
|
||||
<ArrowRight className="size-4" />
|
||||
<p>
|
||||
Jump to{" "}
|
||||
<span className="font-medium text-fd-foreground">
|
||||
{page.name}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
onSelect: () => router.push(page.url),
|
||||
};
|
||||
}
|
||||
}, [router, search, pages]);
|
||||
|
||||
return (
|
||||
<SearchDialog
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
isLoading={query.isLoading}
|
||||
{...props}
|
||||
>
|
||||
<SearchDialogOverlay className="z-200" />
|
||||
<SearchDialogContent className="z-200">
|
||||
<SearchDialogHeader>
|
||||
<LoadingSearchIcon />
|
||||
<SearchDialogInput />
|
||||
<SearchDialogClose />
|
||||
</SearchDialogHeader>
|
||||
<SearchDialogList
|
||||
items={
|
||||
query.data !== "empty" || pageTreeAction
|
||||
? [
|
||||
...(pageTreeAction ? [pageTreeAction] : []),
|
||||
...(Array.isArray(query.data) ? query.data : []),
|
||||
]
|
||||
: null
|
||||
}
|
||||
Item={({ item, onClick }) => (
|
||||
<SearchDialogListItem
|
||||
item={item}
|
||||
onClick={onClick}
|
||||
className={
|
||||
item.type !== "action"
|
||||
? "max-h-24 [&>div:last-child]:line-clamp-2"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SearchDialogFooter>
|
||||
<span className="text-xs text-fd-muted-foreground">
|
||||
Search powered by{" "}
|
||||
<a
|
||||
href="https://typesense.org"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="underline hover:text-fd-foreground transition-colors"
|
||||
>
|
||||
Typesense
|
||||
</a>
|
||||
</span>
|
||||
</SearchDialogFooter>
|
||||
</SearchDialogContent>
|
||||
</SearchDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSearchIcon() {
|
||||
const { isLoading } = useSearch();
|
||||
|
||||
return (
|
||||
<Search
|
||||
className={
|
||||
isLoading
|
||||
? "size-5 animate-pulse text-foreground duration-400"
|
||||
: "size-5 text-fd-muted-foreground"
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import type * as React from "react";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-200 bg-background/50 backdrop-blur-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
"group/drawer-content fixed z-200 flex h-auto flex-col bg-background",
|
||||
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn("font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
@@ -0,0 +1,45 @@
|
||||
import { findPath } from "fumadocs-core/page-tree";
|
||||
import type { DocumentRecord } from "typesense-fumadocs-adapter";
|
||||
import { source } from "@/lib/source";
|
||||
|
||||
export async function exportSearchIndexes() {
|
||||
const results: DocumentRecord[] = [];
|
||||
|
||||
function isBreadcrumbItem(item: unknown): item is string {
|
||||
return typeof item === "string" && item.length > 0;
|
||||
}
|
||||
|
||||
for (const page of source.getPages()) {
|
||||
let breadcrumbs: string[] | undefined;
|
||||
const pageTree = source.getPageTree(page.locale);
|
||||
const path = findPath(
|
||||
pageTree.children,
|
||||
(node) => node.type === "page" && node.url === page.url,
|
||||
);
|
||||
|
||||
if (path) {
|
||||
breadcrumbs = [];
|
||||
path.pop();
|
||||
if (isBreadcrumbItem(pageTree.name)) {
|
||||
breadcrumbs.push(pageTree.name);
|
||||
}
|
||||
for (const segment of path) {
|
||||
if (!isBreadcrumbItem(segment.name)) continue;
|
||||
breadcrumbs.push(segment.name);
|
||||
}
|
||||
}
|
||||
|
||||
const loaded = await page.data.load();
|
||||
|
||||
results.push({
|
||||
_id: page.url,
|
||||
structured: loaded.structuredData,
|
||||
url: page.url,
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
breadcrumbs,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Feed } from "feed";
|
||||
import { blogs } from "./source";
|
||||
import { baseUrl } from "./utils";
|
||||
|
||||
export function getRSS() {
|
||||
const feed = new Feed({
|
||||
title: "Better Auth Blog",
|
||||
description: "Latest updates, articles, and insights about Better Auth",
|
||||
generator: "better-auth",
|
||||
id: `${baseUrl}blog`,
|
||||
link: `${baseUrl}blog`,
|
||||
language: "en",
|
||||
image: `${baseUrl}release-og/blogs.png`,
|
||||
favicon: `${baseUrl}favicon/favicon-32x32.png`,
|
||||
copyright: `All rights reserved ${new Date().getFullYear()}, Better Auth Inc.`,
|
||||
});
|
||||
|
||||
for (const page of blogs.getPages().sort((a, b) => {
|
||||
return new Date(b.data.date).getTime() - new Date(a.data.date).getTime();
|
||||
})) {
|
||||
const url = page.url.replace("blogs/", "blog/");
|
||||
|
||||
feed.addItem({
|
||||
id: page.url,
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
image: page.data.image
|
||||
? `${baseUrl}${page.data.image.startsWith("/") ? page.data.image.slice(1) : page.data.image}`
|
||||
: undefined,
|
||||
link: `${baseUrl}${url.startsWith("/") ? url.slice(1) : url}`,
|
||||
date: new Date(page.data.lastModified ?? page.data.date),
|
||||
author: page.data.author
|
||||
? [
|
||||
{
|
||||
name: page.data.author.name,
|
||||
avatar: page.data.author.avatar,
|
||||
link: page.data.author.twitter,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
}
|
||||
|
||||
return feed.rss2();
|
||||
}
|
||||
@@ -19,3 +19,11 @@ export function mergeRefs<T>(
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const baseUrl =
|
||||
process.env.NODE_ENV === "development" ||
|
||||
(!process.env.VERCEL_PROJECT_PRODUCTION_URL && !process.env.VERCEL_URL)
|
||||
? new URL("http://localhost:3000")
|
||||
: new URL(
|
||||
`https://${process.env.VERCEL_PROJECT_PRODUCTION_URL || process.env.VERCEL_URL}`,
|
||||
);
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
"postinstall": "fumadocs-mdx",
|
||||
"dev": "next dev --turbopack --port 3000",
|
||||
"build": "next build",
|
||||
"postbuild": "pnpm sync-typesense",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "biome check . --fix --unsafe"
|
||||
"lint:fix": "biome check . --fix --unsafe",
|
||||
"sync-typesense": "node scripts/sync-typesense.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.44",
|
||||
@@ -42,6 +44,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"feed": "^5.2.0",
|
||||
"foxact": "^0.2.52",
|
||||
"framer-motion": "^12.23.12",
|
||||
"fumadocs-core": "16.6.7",
|
||||
@@ -71,7 +74,10 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"three": "^0.183.1",
|
||||
"typesense": "^3.0.2",
|
||||
"typesense-fumadocs-adapter": "^0.3.0",
|
||||
"unist-util-visit": "^5.1.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -81,6 +87,7 @@
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/three": "^0.183.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.3.7",
|
||||
"typescript": "5.9.3"
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import "dotenv/config";
|
||||
import * as fs from "node:fs";
|
||||
import { Client } from "typesense";
|
||||
import type { DocumentRecord } from "typesense-fumadocs-adapter";
|
||||
import { sync } from "typesense-fumadocs-adapter";
|
||||
|
||||
const url = process.env.NEXT_PUBLIC_TYPESENSE_SERVER_URL;
|
||||
const adminKey = process.env.TYPESENSE_ADMIN_API_KEY;
|
||||
|
||||
if (!url || !adminKey) {
|
||||
console.log("Typesense env vars not set, skipping sync.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!process.env.VERCEL) {
|
||||
console.log("Not running on Production build, skipping sync.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (process.env.VERCEL_GIT_COMMIT_REF !== "main") {
|
||||
console.log(
|
||||
`Branch is "${process.env.VERCEL_GIT_COMMIT_REF}", skipping sync.`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const filePath = ".next/server/app/api/docs/static.json.body";
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.log("Build output not found, run 'pnpm build' first. Skipping sync.");
|
||||
process.exit(0);
|
||||
}
|
||||
const content = fs.readFileSync(filePath);
|
||||
const records = JSON.parse(content.toString()) as DocumentRecord[];
|
||||
|
||||
const serverUrl = new URL(url);
|
||||
|
||||
const client = new Client({
|
||||
nodes: [
|
||||
{
|
||||
host: serverUrl.hostname,
|
||||
port:
|
||||
Number(serverUrl.port) || (serverUrl.protocol === "https:" ? 443 : 80),
|
||||
protocol: serverUrl.protocol.replace(":", ""),
|
||||
},
|
||||
],
|
||||
apiKey: adminKey,
|
||||
connectionTimeoutSeconds: 30,
|
||||
});
|
||||
|
||||
void sync(client, {
|
||||
typesenseCollectionName: "better-auth-docs",
|
||||
documents: records,
|
||||
})
|
||||
.then(() => {
|
||||
console.log(`search updated: ${records.length} records`);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to sync Typesense index:", error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -622,6 +622,109 @@ describe("session-refresh", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
/**
|
||||
* https://github.com/better-auth/better-auth/issues/8325
|
||||
*/
|
||||
it("should preserve custom session fields on poll refresh", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const sessionAtom: SessionAtom = atom({
|
||||
data: {
|
||||
user: { id: "1", email: "test@test.com" },
|
||||
session: { id: "session-1" },
|
||||
},
|
||||
error: null,
|
||||
isPending: false,
|
||||
});
|
||||
const sessionSignal = atom(false);
|
||||
|
||||
const customSessionData = {
|
||||
user: { id: "1", email: "test@test.com" },
|
||||
session: { id: "session-1" },
|
||||
activationInfo: { isActivated: true, activatedAt: "2024-01-01" },
|
||||
role: "admin",
|
||||
};
|
||||
|
||||
const mockFetch = vi.fn(async () => ({
|
||||
data: customSessionData,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
const manager = createSessionRefreshManager({
|
||||
sessionAtom,
|
||||
sessionSignal,
|
||||
$fetch: mockFetch as any,
|
||||
options: {
|
||||
sessionOptions: {
|
||||
refetchInterval: 5,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
manager.init();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
const updatedSession = sessionAtom.get();
|
||||
expect(updatedSession.data).toEqual(customSessionData);
|
||||
expect((updatedSession.data as any)?.activationInfo).toEqual({
|
||||
isActivated: true,
|
||||
activatedAt: "2024-01-01",
|
||||
});
|
||||
expect((updatedSession.data as any)?.role).toBe("admin");
|
||||
|
||||
manager.cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should preserve custom session fields on visibilitychange refresh", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const sessionAtom: SessionAtom = atom({
|
||||
data: {
|
||||
user: { id: "1", email: "test@test.com" },
|
||||
session: { id: "session-1" },
|
||||
},
|
||||
error: null,
|
||||
isPending: false,
|
||||
});
|
||||
const sessionSignal = atom(false);
|
||||
|
||||
const customSessionData = {
|
||||
user: { id: "1", email: "test@test.com" },
|
||||
session: { id: "session-1" },
|
||||
customField: "custom value",
|
||||
};
|
||||
|
||||
const mockFetch = vi.fn(async () => ({
|
||||
data: customSessionData,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
const manager = createSessionRefreshManager({
|
||||
sessionAtom,
|
||||
sessionSignal,
|
||||
$fetch: mockFetch as any,
|
||||
options: {
|
||||
sessionOptions: {
|
||||
refetchOnWindowFocus: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
manager.init();
|
||||
|
||||
manager.triggerRefetch({ event: "visibilitychange" });
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const updatedSession = sessionAtom.get();
|
||||
expect(updatedSession.data).toEqual(customSessionData);
|
||||
expect((updatedSession.data as any)?.customField).toBe("custom value");
|
||||
|
||||
manager.cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should broadcast session update when broadcastSessionUpdate is called with signout", () => {
|
||||
const channel = getGlobalBroadcastChannel();
|
||||
const postSpy = vi.spyOn(channel, "post");
|
||||
|
||||
@@ -15,10 +15,12 @@ const now = () => Math.floor(Date.now() / 1000);
|
||||
const FOCUS_REFETCH_RATE_LIMIT_SECONDS = 5;
|
||||
|
||||
export interface SessionRefreshOptions {
|
||||
sessionAtom: AuthQueryAtom<{
|
||||
user: User;
|
||||
session: Session;
|
||||
}>;
|
||||
sessionAtom: AuthQueryAtom<
|
||||
{
|
||||
user: User;
|
||||
session: Session;
|
||||
} & Record<string, any>
|
||||
>;
|
||||
sessionSignal: WritableAtom<boolean>;
|
||||
$fetch: BetterFetch;
|
||||
options?: BetterAuthClientOptions | undefined;
|
||||
@@ -34,11 +36,19 @@ interface SessionRefreshState {
|
||||
unsubscribeOnline?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
interface SessionResponse {
|
||||
session: Session | null;
|
||||
user: User | null;
|
||||
needsRefresh?: boolean;
|
||||
}
|
||||
export type SessionResponse = (
|
||||
| {
|
||||
session: null;
|
||||
user: null;
|
||||
needsRefresh?: boolean;
|
||||
}
|
||||
| {
|
||||
session: Session;
|
||||
user: User;
|
||||
needsRefresh?: boolean;
|
||||
}
|
||||
) &
|
||||
Record<string, any>;
|
||||
|
||||
export function createSessionRefreshManager(opts: SessionRefreshOptions) {
|
||||
const { sessionAtom, sessionSignal, $fetch, options = {} } = opts;
|
||||
@@ -93,10 +103,7 @@ export function createSessionRefreshManager(opts: SessionRefreshOptions) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const sessionData =
|
||||
data?.session && data?.user
|
||||
? { session: data.session, user: data.user }
|
||||
: null;
|
||||
const sessionData = data?.session && data?.user ? data : null;
|
||||
|
||||
sessionAtom.set({
|
||||
...currentSession,
|
||||
|
||||
@@ -395,7 +395,7 @@ export const otp2fa = (options?: OTPOptions | undefined) => {
|
||||
* ### API Methods
|
||||
*
|
||||
* **server:**
|
||||
* `auth.api.send2FaOTP`
|
||||
* `auth.api.sendTwoFactorOTP`
|
||||
*
|
||||
* **client:**
|
||||
* `authClient.twoFactor.sendOtp`
|
||||
@@ -411,7 +411,7 @@ export const otp2fa = (options?: OTPOptions | undefined) => {
|
||||
* ### API Methods
|
||||
*
|
||||
* **server:**
|
||||
* `auth.api.verifyOTP`
|
||||
* `auth.api.verifyTwoFactorOTP`
|
||||
*
|
||||
* **client:**
|
||||
* `authClient.twoFactor.verifyOtp`
|
||||
|
||||
Generated
+82
-27
@@ -441,7 +441,7 @@ importers:
|
||||
version: 0.30.6
|
||||
drizzle-orm:
|
||||
specifier: ^0.45.1
|
||||
version: 0.45.1(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0(encoding@0.1.13))(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))
|
||||
version: 0.45.1(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))
|
||||
mongodb:
|
||||
specifier: ^7.1.0
|
||||
version: 7.1.0(socks@2.8.7)
|
||||
@@ -654,7 +654,7 @@ importers:
|
||||
version: link:../../../../../packages/better-auth
|
||||
drizzle-orm:
|
||||
specifier: ^0.45.1
|
||||
version: 0.45.1(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0(encoding@0.1.13))(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))
|
||||
version: 0.45.1(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))
|
||||
hono:
|
||||
specifier: ^4.12.4
|
||||
version: 4.12.5
|
||||
@@ -844,6 +844,9 @@ importers:
|
||||
cmdk:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
feed:
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0
|
||||
foxact:
|
||||
specifier: ^0.2.52
|
||||
version: 0.2.53(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -931,9 +934,18 @@ importers:
|
||||
three:
|
||||
specifier: ^0.183.1
|
||||
version: 0.183.2
|
||||
typesense:
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2(@babel/runtime@7.28.6)
|
||||
typesense-fumadocs-adapter:
|
||||
specifier: ^0.3.0
|
||||
version: 0.3.0(315bbd73201b0530b183e3c1250d4415)
|
||||
unist-util-visit:
|
||||
specifier: ^5.1.0
|
||||
version: 5.1.0
|
||||
vaul:
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
zod:
|
||||
specifier: ^4.1.5
|
||||
version: 4.3.6
|
||||
@@ -956,6 +968,9 @@ importers:
|
||||
'@types/three':
|
||||
specifier: ^0.183.1
|
||||
version: 0.183.1
|
||||
dotenv:
|
||||
specifier: ^17.3.1
|
||||
version: 17.3.1
|
||||
tailwindcss:
|
||||
specifier: ^4
|
||||
version: 4.2.1
|
||||
@@ -1034,7 +1049,7 @@ importers:
|
||||
version: 0.31.9
|
||||
drizzle-orm:
|
||||
specifier: '>=0.41.0'
|
||||
version: 0.45.1(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0(encoding@0.1.13))(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))
|
||||
version: 0.45.1(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))
|
||||
jose:
|
||||
specifier: ^6.1.3
|
||||
version: 6.1.3
|
||||
@@ -1200,7 +1215,7 @@ importers:
|
||||
version: 17.3.1
|
||||
drizzle-orm:
|
||||
specifier: ^0.41.0
|
||||
version: 0.41.0(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0(encoding@0.1.13))(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.5.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.5.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))
|
||||
version: 0.41.0(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.5.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.5.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))
|
||||
open:
|
||||
specifier: ^10.2.0
|
||||
version: 10.2.0
|
||||
@@ -1313,7 +1328,7 @@ importers:
|
||||
version: 0.3.1
|
||||
drizzle-orm:
|
||||
specifier: ^0.45.1
|
||||
version: 0.45.1(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0(encoding@0.1.13))(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))
|
||||
version: 0.45.1(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))
|
||||
tsdown:
|
||||
specifier: 'catalog:'
|
||||
version: 0.21.0-beta.2(@arethetypeswrong/core@0.18.2)(oxc-resolver@11.19.0)(publint@0.3.17)(synckit@0.11.11)(typescript@5.9.3)
|
||||
@@ -12482,6 +12497,10 @@ packages:
|
||||
resolution: {integrity: sha512-/3ER20CTTbahrCrpYfPn7Xavv9diBROZpoXGVZDWMw4b/X4uuUwAC0ki85tgsdMRONURyIJbcOvS94QsUBYPbQ==}
|
||||
hasBin: true
|
||||
|
||||
loglevel@1.9.2:
|
||||
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
long@2.4.0:
|
||||
resolution: {integrity: sha512-ijUtjmO/n2A5PaosNG9ZGDsQ3vxJg7ZW8vsY8Kp0f2yIZWhSJvjmegV7t+9RPQKxKrvj8yKGehhS+po14hPLGQ==}
|
||||
engines: {node: '>=0.6'}
|
||||
@@ -16037,6 +16056,22 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
typesense-fumadocs-adapter@0.3.0:
|
||||
resolution: {integrity: sha512-IfvGUp4p8nmVa+jVE90C7OIGejGijByenaMl5aAe78iUEyxru2Kqv8sqV5BE8lL3YbUxPaIbKGOI7zUBB+dhfA==}
|
||||
peerDependencies:
|
||||
fumadocs-core: ^16.0.0
|
||||
fumadocs-ui: ^16.0.0
|
||||
react: '>=19.0.0'
|
||||
react-dom: '>=19.0.0'
|
||||
typescript: '>=5'
|
||||
typesense: '>=2.1.0'
|
||||
|
||||
typesense@3.0.2:
|
||||
resolution: {integrity: sha512-MNspMeIdhcyH0lDVwJX2rVBB3DSXbE1t3k318JC1e1AL1/sRyovi44Aze7n9JzRxnAoFnlhkIB3kWgWGIEozbQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@babel/runtime': ^7.23.2
|
||||
|
||||
ua-parser-js@1.0.41:
|
||||
resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==}
|
||||
hasBin: true
|
||||
@@ -24887,14 +24922,14 @@ snapshots:
|
||||
msw: 2.12.10(@types/node@25.3.2)(typescript@5.9.3)
|
||||
vite: 7.3.1(@types/node@25.3.2)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
'@vitest/mocker@4.0.18(msw@2.12.10(@types/node@25.3.3)(typescript@5.9.3))(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
'@vitest/mocker@4.0.18(msw@2.12.10(@types/node@25.3.3)(typescript@5.9.3))(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.0.18
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
msw: 2.12.10(@types/node@25.3.3)(typescript@5.9.3)
|
||||
vite: 7.3.1(@types/node@25.3.2)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
'@vitest/pretty-format@4.0.18':
|
||||
dependencies:
|
||||
@@ -26836,7 +26871,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
drizzle-orm@0.41.0(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0(encoding@0.1.13))(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.5.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.5.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)):
|
||||
drizzle-orm@0.41.0(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.5.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.5.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)):
|
||||
optionalDependencies:
|
||||
'@cloudflare/workers-types': 4.20260226.1
|
||||
'@electric-sql/pglite': 0.3.15
|
||||
@@ -26872,24 +26907,6 @@ snapshots:
|
||||
postgres: 3.4.8
|
||||
prisma: 7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
|
||||
|
||||
drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0(encoding@0.1.13))(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)):
|
||||
optionalDependencies:
|
||||
'@cloudflare/workers-types': 4.20260226.1
|
||||
'@electric-sql/pglite': 0.3.15
|
||||
'@libsql/client': 0.17.0(encoding@0.1.13)
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@prisma/client': 7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3)
|
||||
'@types/better-sqlite3': 7.6.13
|
||||
'@types/pg': 8.16.0
|
||||
better-sqlite3: 12.6.2
|
||||
bun-types: 1.3.9
|
||||
gel: 2.2.0
|
||||
kysely: 0.28.11
|
||||
mysql2: 3.18.2(@types/node@25.3.3)
|
||||
pg: 8.19.0
|
||||
postgres: 3.4.8
|
||||
prisma: 7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
|
||||
|
||||
drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0(encoding@0.1.13))(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.5.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.5.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)):
|
||||
optionalDependencies:
|
||||
'@cloudflare/workers-types': 4.20260226.1
|
||||
@@ -26909,6 +26926,24 @@ snapshots:
|
||||
prisma: 7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.5.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
|
||||
optional: true
|
||||
|
||||
drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)):
|
||||
optionalDependencies:
|
||||
'@cloudflare/workers-types': 4.20260226.1
|
||||
'@electric-sql/pglite': 0.3.15
|
||||
'@libsql/client': 0.17.0(encoding@0.1.13)
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@prisma/client': 7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3)
|
||||
'@types/better-sqlite3': 7.6.13
|
||||
'@types/pg': 8.16.0
|
||||
better-sqlite3: 12.6.2
|
||||
bun-types: 1.3.9
|
||||
gel: 2.2.0
|
||||
kysely: 0.28.11
|
||||
mysql2: 3.18.2(@types/node@25.3.3)
|
||||
pg: 8.19.0
|
||||
postgres: 3.4.8
|
||||
prisma: 7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
|
||||
|
||||
drizzle-zod@0.8.3(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0(encoding@0.1.13))(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.2))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)))(zod@4.3.6):
|
||||
dependencies:
|
||||
drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0(encoding@0.1.13))(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.2))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))
|
||||
@@ -29614,6 +29649,8 @@ snapshots:
|
||||
yargs: 15.4.1
|
||||
optional: true
|
||||
|
||||
loglevel@1.9.2: {}
|
||||
|
||||
long@2.4.0:
|
||||
optional: true
|
||||
|
||||
@@ -35071,6 +35108,24 @@ snapshots:
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
typesense-fumadocs-adapter@0.3.0(315bbd73201b0530b183e3c1250d4415):
|
||||
dependencies:
|
||||
fumadocs-core: 16.6.7(@mdx-js/mdx@3.1.1)(@oramacloud/client@2.1.4)(@tanstack/react-router@1.163.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(algoliasearch@5.46.2)(lucide-react@0.575.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6)
|
||||
fumadocs-ui: 16.6.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.7(@mdx-js/mdx@3.1.1)(@oramacloud/client@2.1.4)(@tanstack/react-router@1.163.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(algoliasearch@5.46.2)(lucide-react@0.575.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
typescript: 5.9.3
|
||||
typesense: 3.0.2(@babel/runtime@7.28.6)
|
||||
|
||||
typesense@3.0.2(@babel/runtime@7.28.6):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
axios: 1.13.5
|
||||
loglevel: 1.9.2
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
ua-parser-js@1.0.41:
|
||||
optional: true
|
||||
|
||||
@@ -35697,7 +35752,7 @@ snapshots:
|
||||
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(@vitest/ui@4.0.18)(happy-dom@20.7.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(msw@2.12.10(@types/node@25.3.3)(typescript@5.9.3))(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.0.18
|
||||
'@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@25.3.3)(typescript@5.9.3))(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@25.3.3)(typescript@5.9.3))(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@vitest/pretty-format': 4.0.18
|
||||
'@vitest/runner': 4.0.18
|
||||
'@vitest/snapshot': 4.0.18
|
||||
|
||||
+10
@@ -16,6 +16,16 @@
|
||||
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
||||
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
|
||||
},
|
||||
"docs#build": {
|
||||
"dependsOn": ["^build"],
|
||||
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
||||
"outputs": [".next/**", "!.next/cache/**"],
|
||||
"passThroughEnv": [
|
||||
"TYPESENSE_ADMIN_API_KEY",
|
||||
"INKEEP_API_KEY",
|
||||
"GITHUB_TOKEN"
|
||||
]
|
||||
},
|
||||
"clean": {},
|
||||
"format": {
|
||||
"dependsOn": ["//#format"]
|
||||
|
||||
Reference in New Issue
Block a user