mirror of
https://github.com/LibreChat-AI/librechat.ai.git
synced 2026-03-27 02:38:32 +07:00
refactor: remove Nextra, migrate to App Router, redesign Toolkit (#531)
* refactor: remove Nextra shims, migrate pages/ to app router, upgrade ESLint to v9 - Remove all nextra-shims and legacy pages/ directory - Migrate subscribe, unsubscribe, and toolkit pages to app router - Restructure config docs with guide-first setup steps and Tabs components - Rewrite Docker install, OpenRouter, and custom endpoints docs - Add Quick Start guide, Google Search docs, and image generation cross-links - Update .gitignore * feat: redesign toolkit and integrate into docs sidebar Move credentials generator and YAML validator into the docs under a new "Tools > Toolkit" sidebar section. Old /toolkit routes redirect to /docs/toolkit. Credentials generator: 2-column field grid, per-field copy buttons, copy-all as .env block, empty state placeholder, design system tokens. YAML validator: full-width theme-aware Ace Editor (chrome/twilight), drag-and-drop overlay, result banners with icons, clear button, no print margin. Remove unused .error-marker and .custom-btn from style.css. --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
9
app/subscribe/client.tsx
Normal file
9
app/subscribe/client.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const SubscribeForm = dynamic(() => import('@/components/Newsletter/SubscribeForm'), { ssr: false })
|
||||
|
||||
export default function SubscribeClient() {
|
||||
return <SubscribeForm />
|
||||
}
|
||||
19
app/subscribe/page.tsx
Normal file
19
app/subscribe/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { HomeLayout } from 'fumadocs-ui/layouts/home'
|
||||
import { baseOptions } from '@/app/layout.config'
|
||||
import type { Metadata } from 'next'
|
||||
import SubscribeClient from './client'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Subscribe',
|
||||
description: 'Subscribe to the LibreChat newsletter',
|
||||
}
|
||||
|
||||
export default function SubscribePage() {
|
||||
return (
|
||||
<HomeLayout {...baseOptions}>
|
||||
<main className="mx-auto max-w-xl px-4 py-16">
|
||||
<SubscribeClient />
|
||||
</main>
|
||||
</HomeLayout>
|
||||
)
|
||||
}
|
||||
5
app/toolkit/creds-generator/page.ts
Normal file
5
app/toolkit/creds-generator/page.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function CredsGeneratorPage() {
|
||||
redirect('/docs/toolkit/credentials-generator')
|
||||
}
|
||||
5
app/toolkit/page.ts
Normal file
5
app/toolkit/page.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function ToolkitPage() {
|
||||
redirect('/docs/toolkit')
|
||||
}
|
||||
5
app/toolkit/yaml-checker/page.ts
Normal file
5
app/toolkit/yaml-checker/page.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function YamlCheckerPage() {
|
||||
redirect('/docs/toolkit/yaml-validator')
|
||||
}
|
||||
11
app/unsubscribe/client.tsx
Normal file
11
app/unsubscribe/client.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const UnsubscribeForm = dynamic(() => import('@/components/Newsletter/UnsubscribeForm'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export default function UnsubscribeClient() {
|
||||
return <UnsubscribeForm />
|
||||
}
|
||||
19
app/unsubscribe/page.tsx
Normal file
19
app/unsubscribe/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { HomeLayout } from 'fumadocs-ui/layouts/home'
|
||||
import { baseOptions } from '@/app/layout.config'
|
||||
import type { Metadata } from 'next'
|
||||
import UnsubscribeClient from './client'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Unsubscribe',
|
||||
description: 'Unsubscribe from the LibreChat newsletter',
|
||||
}
|
||||
|
||||
export default function UnsubscribePage() {
|
||||
return (
|
||||
<HomeLayout {...baseOptions}>
|
||||
<main className="mx-auto max-w-xl px-4 py-16">
|
||||
<UnsubscribeClient />
|
||||
</main>
|
||||
</HomeLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { getPagesUnderRoute } from 'nextra/context'
|
||||
import { type Page } from 'nextra'
|
||||
import { SocialIcon } from 'react-social-icons'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface AuthorMetadata {
|
||||
authorid: string
|
||||
subtitle: string
|
||||
name: string
|
||||
bio: string
|
||||
ogImage: string
|
||||
socials?: {
|
||||
[key: string]: string
|
||||
}
|
||||
}
|
||||
|
||||
const AuthorCard: React.FC<{ author: AuthorMetadata }> = ({ author }) => {
|
||||
const [isClient, setIsClient] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true)
|
||||
}, [])
|
||||
|
||||
const socialsEntries = Object.entries(author.socials ?? {}).filter(
|
||||
([, value]) => !!value,
|
||||
) as unknown as [string, string][]
|
||||
|
||||
return (
|
||||
<Link href={`/authors/${author.authorid}`}>
|
||||
<div className="flex flex-col items-center gap-4 bg-gray-200/20 rounded-lg p-6 h-full group">
|
||||
<div className="relative overflow-hidden rounded-full">
|
||||
<Image
|
||||
width={200}
|
||||
height={200}
|
||||
src={author.ogImage}
|
||||
alt={author.name}
|
||||
className="rounded-full transition-transform duration-300 ease-in-out group-hover:scale-110"
|
||||
style={{ objectFit: 'cover' }}
|
||||
/>
|
||||
</div>
|
||||
<h2 className="font-bold text-xl">{author.name}</h2>
|
||||
<p className="text-sm text-center text-gray-600 grow">{author.subtitle}</p>
|
||||
<div className="flex flex-wrap gap-4 justify-center mt-2">
|
||||
{isClient &&
|
||||
socialsEntries.map(([key, value]) => (
|
||||
<a
|
||||
key={key}
|
||||
href={value}
|
||||
className="btn btn-square relative overflow-hidden"
|
||||
title={`See ${author.name}'s ${key}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ transition: 'transform 0.3s ease' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<SocialIcon
|
||||
url={value}
|
||||
className="absolute inset-0 size-full scale-100 transition-transform opacity-100 hover:scale-90"
|
||||
bgColor="#9B9B9B80"
|
||||
fgColor="background"
|
||||
style={{ width: '2em', height: '2em' }}
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const AuthorPage: React.FC = () => {
|
||||
const allAuthors = getPagesUnderRoute('/authors') as Array<Page & { frontMatter: AuthorMetadata }>
|
||||
|
||||
const authors = allAuthors.filter((author) => !!author.frontMatter.authorid)
|
||||
|
||||
return (
|
||||
<section className="max-w-4xl mx-auto mt-12 mb-24 md:mb-32">
|
||||
<h1 className="font-extrabold text-3xl lg:text-5xl tracking-tight mb-8 text-center">
|
||||
Our Authors
|
||||
</h1>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{authors.map((author) => (
|
||||
<AuthorCard key={author.frontMatter.authorid} author={author.frontMatter} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuthorPage
|
||||
@@ -1,131 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getPagesUnderRoute } from 'nextra/context'
|
||||
import { type Page } from 'nextra'
|
||||
import { SocialIcon } from 'react-social-icons'
|
||||
import BlogCard from '../blog/BlogCard'
|
||||
import Image from 'next/image'
|
||||
import { Cards } from 'nextra/components'
|
||||
import { OurAuthors, Blog } from '@/components/CardIcons'
|
||||
|
||||
// Known issues:
|
||||
// - Mobile: social icons overflow when author has more than 4 social links
|
||||
// - SocialIcon uses the generic "share" icon when the platform is unrecognized
|
||||
// - "Recent Posts by" section does not support filtering by tag
|
||||
// - Profile image positioning is off when the author has no bio text
|
||||
|
||||
interface AuthorMetadata {
|
||||
authorid: string
|
||||
subtitle: string
|
||||
name: string
|
||||
bio: string
|
||||
ogImage: string
|
||||
socials?: Record<string, string | undefined> // Dynamically match social media platforms
|
||||
date: string | number | Date
|
||||
}
|
||||
|
||||
interface AuthorProfileProps {
|
||||
authorId: string
|
||||
}
|
||||
|
||||
const AuthorProfile: React.FC<AuthorProfileProps> = ({ authorId }) => {
|
||||
const authors = getPagesUnderRoute('/authors') as Array<Page & { frontMatter: AuthorMetadata }>
|
||||
const author = authors.find((a) => a.frontMatter.authorid === authorId)?.frontMatter
|
||||
const blogPosts = getPagesUnderRoute('/blog') as Array<Page & { frontMatter: AuthorMetadata }>
|
||||
|
||||
// Filter posts by the current authorId
|
||||
const authorPosts = blogPosts.filter((post) => post.frontMatter.authorid === authorId)
|
||||
const sortedAuthorPosts = authorPosts.sort(
|
||||
(a, b) => new Date(b.frontMatter.date).getTime() - new Date(a.frontMatter.date).getTime(),
|
||||
)
|
||||
|
||||
// State to track whether the component is rendered on the client side
|
||||
const [isClient, setIsClient] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true)
|
||||
}, [])
|
||||
|
||||
if (!author) {
|
||||
return <div>Author not found!</div>
|
||||
}
|
||||
|
||||
const socialsEntries = Object.entries(author.socials ?? {}).filter(([, value]) => !!value)
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="max-w-4xl mx-auto flex flex-col md:flex-row gap-8 mt-12 mb-24 md:mb-32">
|
||||
<div>
|
||||
<h1 className="font-extrabold text-3xl lg:text-5xl tracking-tight mb-2">{author.name}</h1>
|
||||
<p
|
||||
className="md:text-lg mb-6 md:mb-10 font-medium"
|
||||
style={{ fontSize: '1.3rem', fontWeight: 'bold' }}
|
||||
>
|
||||
{author.subtitle}
|
||||
</p>
|
||||
{author.bio && <p className="md:text-lg text-base-content/80">{author.bio}</p>}
|
||||
</div>
|
||||
|
||||
<div className="max-md:order-first flex md:flex-col gap-4 shrink-0">
|
||||
<Image
|
||||
width={512}
|
||||
height={512}
|
||||
src={author.ogImage}
|
||||
alt={author.name}
|
||||
className="rounded-box size-[12rem] md:size-[16rem] rounded-square"
|
||||
style={{ borderRadius: '20px', objectFit: 'cover' }}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-4 max-w-[4rem] md:max-w-[16rem]">
|
||||
{isClient &&
|
||||
socialsEntries.map(([key, value]) => (
|
||||
<a
|
||||
key={key}
|
||||
href={value}
|
||||
className="btn btn-square relative overflow-hidden"
|
||||
aria-label={`Visit ${author.name}'s ${key}`}
|
||||
title={`See ${author.name}'s ${key}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ transition: 'transform 0.3s ease' }} // Add transition here
|
||||
>
|
||||
<SocialIcon
|
||||
url={value}
|
||||
className="absolute inset-0 size-full scale-100 transition-transform opacity-100 hover:scale-90"
|
||||
bgColor="#9B9B9B80"
|
||||
fgColor="background"
|
||||
// fallback={{ path: 'M32 2 A30 30 0 0 1 62 32 A30 30 0 0 1 32 62 A30 30 0 0 1 2 32 A30 30 0 0 1 32 2 Z' }}
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="max-w-4xl mx-auto mt-8">
|
||||
<h2 className="font-bold text-2xl mb-6 text-center">Recent Posts by {author.name}</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-7">
|
||||
{sortedAuthorPosts.map((post) => (
|
||||
<BlogCard
|
||||
key={post.route}
|
||||
page={post}
|
||||
handleTagClick={() => {}}
|
||||
selectedTags={undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: '75px' }} />
|
||||
<div>
|
||||
<Cards num={3}>
|
||||
<Cards.Card title="Blog" href="/blog" icon={<Blog />} image>
|
||||
{null}
|
||||
</Cards.Card>
|
||||
<Cards.Card title="Our Authors" href="/authors" icon={<OurAuthors />} image>
|
||||
{null}
|
||||
</Cards.Card>
|
||||
</Cards>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuthorProfile
|
||||
@@ -1,38 +0,0 @@
|
||||
import Image from 'next/image'
|
||||
import { getPagesUnderRoute } from 'nextra/context'
|
||||
|
||||
export const Author = ({ authorid }: { authorid: string }) => {
|
||||
const authorPages = getPagesUnderRoute('/authors')
|
||||
const page = authorPages?.find((page) => page.frontMatter.authorid === authorid)
|
||||
|
||||
if (!page) {
|
||||
// Handle the case when the author page is not found
|
||||
console.error('Author page not found for authorid:', authorid)
|
||||
return null
|
||||
}
|
||||
|
||||
const { name, ogImage } = page.frontMatter
|
||||
|
||||
return (
|
||||
<div>
|
||||
<a href={`/authors/${authorid}`} className="group shrink-0" rel="noopener noreferrer">
|
||||
<div className="flex justify-end gap-2" key={name}>
|
||||
<div className="flex items-center gap-2">
|
||||
{ogImage ? (
|
||||
<Image
|
||||
src={ogImage}
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded-full"
|
||||
alt={`Picture ${name}`}
|
||||
/>
|
||||
) : null}
|
||||
<span className="text-primary/60 group-hover:text-primary whitespace-nowrap">
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import Image from 'next/image'
|
||||
import { getPagesUnderRoute } from 'nextra/context'
|
||||
|
||||
export const AuthorSmall = ({ authorid }: { authorid: string }) => {
|
||||
const authorPages = getPagesUnderRoute('/authors')
|
||||
const page = authorPages?.find((page) => page.frontMatter.authorid === authorid)
|
||||
|
||||
if (!page) {
|
||||
// Handle the case when the author page is not found
|
||||
console.error('Author page not found for authorid:', authorid)
|
||||
return null
|
||||
}
|
||||
|
||||
const { name, ogImage } = page.frontMatter
|
||||
|
||||
return (
|
||||
<div className="group shrink-0" key={name}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Image
|
||||
src={ogImage}
|
||||
width={20}
|
||||
height={20}
|
||||
className="rounded-full"
|
||||
alt={`Picture ${name}`}
|
||||
/>
|
||||
<span className="text-primary/60 whitespace-nowrap">{name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { useRouter } from 'next/router'
|
||||
import type { Page, MdxFile } from 'nextra'
|
||||
import { Author } from '../Author/Authors'
|
||||
import { Video } from '../Video'
|
||||
|
||||
const BlogCard = ({
|
||||
page,
|
||||
handleTagClick,
|
||||
selectedTags = [],
|
||||
}: {
|
||||
page: MdxFile & Page
|
||||
handleTagClick: (tag: string) => void
|
||||
selectedTags?: string[]
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const [cardWidth, setCardWidth] = useState(0)
|
||||
const [maxDescriptionLength, setMaxDescriptionLength] = useState(160)
|
||||
const handleCardClick = () => {
|
||||
router.push(page.route)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setMaxDescriptionLength(cardWidth > 260 ? 145 : 46) // Adjust maxLength based on card width
|
||||
}, [cardWidth])
|
||||
|
||||
useEffect(() => {
|
||||
const updateCardWidth = () => {
|
||||
setCardWidth(document.querySelector('.blog-card')?.clientWidth ?? 0)
|
||||
}
|
||||
window.addEventListener('resize', updateCardWidth)
|
||||
updateCardWidth()
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateCardWidth)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const truncateDescription = (text) => {
|
||||
if (text.length > maxDescriptionLength) {
|
||||
return text.slice(0, maxDescriptionLength) + '...'
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-popover rounded-lg shadow-md overflow-hidden blog-card">
|
||||
<div
|
||||
className="relative h-52 md:h-64 mb-1 overflow-hidden scale-100 transition-transform hover:scale-105 cursor-pointer"
|
||||
onClick={handleCardClick}
|
||||
style={{ transformOrigin: 'bottom center' }}
|
||||
>
|
||||
{page.frontMatter?.ogVideo ? (
|
||||
<Video src={page.frontMatter.ogVideo} gifStyle className="object-cover size-full mt-0" />
|
||||
) : page.frontMatter?.ogImage ? (
|
||||
<Image
|
||||
src={page.frontMatter.gif ?? page.frontMatter.ogImage}
|
||||
width={1200}
|
||||
height={675}
|
||||
className="object-cover absolute top-0 left-0 size-full"
|
||||
alt={page.frontMatter?.title ?? 'Blog post image'}
|
||||
unoptimized={
|
||||
page.frontMatter.gif !== undefined || page.frontMatter.ogImage?.endsWith('.gif')
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="p-4 pt-2 h-56 overflow-hidden relative">
|
||||
<div className="items-center justify-between mb-2">
|
||||
{page.frontMatter?.tags?.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={`cursor-pointer text-xs py-1 px-2 bg-background/80 shadow-md rounded-md mx-1 ${
|
||||
selectedTags.includes(tag) ? 'bg-gray-700/20' : ''
|
||||
}`}
|
||||
onClick={() => handleTagClick(tag)}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Modified title and description to be clickable */}
|
||||
<div className="mb-2 mx-1 cursor-pointer" onClick={handleCardClick}>
|
||||
<h2 className="font-mono text-xl mb-2 font-bold">
|
||||
{page.meta?.title || page.frontMatter?.title || page.name}
|
||||
</h2>
|
||||
<div>{truncateDescription(page.frontMatter?.description || '')}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between absolute bottom-4 inset-x-4">
|
||||
<Author authorid={page.frontMatter?.authorid} />
|
||||
<span className="text-sm opacity-60">{page.frontMatter?.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BlogCard
|
||||
@@ -1,61 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { getPagesUnderRoute } from 'nextra/context'
|
||||
import { Author } from '../Author/Authors'
|
||||
import { Video } from '../Video'
|
||||
|
||||
export const BlogHeader = () => {
|
||||
const pathname = usePathname()
|
||||
const changelogPages = getPagesUnderRoute('/blog')
|
||||
const page = changelogPages.find((page) => page.route === pathname)
|
||||
|
||||
const { title, description, ogImage, ogVideo, gif, date, authorid } = page.frontMatter
|
||||
|
||||
return (
|
||||
<div className="md:mt-10 flex flex-col gap-10">
|
||||
<div>
|
||||
<div className="text-lg text-primary/60 mb-3">
|
||||
{new Date(date).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-col gap-5 md:gap-10 md:flex-row justify-between md:items-center">
|
||||
<div>
|
||||
<h1 className="font-extrabold text-3xl lg:text-5xl tracking-tight mb-2">{title}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className="md:text-lg md:mb-5 font-medium"
|
||||
style={{ fontSize: '1.3rem', fontWeight: 'bold' }}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<Author authorid={authorid} />
|
||||
</div>
|
||||
<br />
|
||||
{ogVideo ? (
|
||||
<Video src={ogVideo} gifStyle />
|
||||
) : ogImage ? (
|
||||
<Image
|
||||
src={gif ?? ogImage}
|
||||
alt={title}
|
||||
width={1200}
|
||||
height={630}
|
||||
className="border"
|
||||
style={{ borderRadius: 20 }}
|
||||
unoptimized={
|
||||
page.frontMatter.gif !== undefined || page.frontMatter.ogImage?.endsWith('.gif')
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<div className="mt-6 md:mt-4" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import Link from 'next/link'
|
||||
import { Page } from 'nextra'
|
||||
import { getPagesUnderRoute } from 'nextra/context'
|
||||
|
||||
const changelogItems = getPagesUnderRoute('/changelog') as Array<Page & { frontMatter: any }>
|
||||
|
||||
export default function Changelog({ className }: { className?: string }) {
|
||||
const changelog = changelogItems
|
||||
.filter(
|
||||
(page) =>
|
||||
page.route &&
|
||||
!page.route.includes('content') &&
|
||||
page.frontMatter.title &&
|
||||
page.frontMatter.date,
|
||||
)
|
||||
.sort((a, b) => new Date(b.frontMatter.date).getTime() - new Date(a.frontMatter.date).getTime())
|
||||
.slice(0, 20)
|
||||
.map(({ route, frontMatter }) => ({
|
||||
route,
|
||||
title: frontMatter.title ?? null,
|
||||
author: frontMatter.author ?? null,
|
||||
date: new Date(frontMatter.date),
|
||||
}))
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('rounded border p-5 max-w-lg mx-5 sm:mx-auto', className)}
|
||||
role="region" // Added role for the container
|
||||
aria-labelledby="changelog-heading" // Added aria-labelledby to reference the heading
|
||||
>
|
||||
<div className="px-5 py-2 text-center -mt-5 -mx-5 mb-5 border-b font-medium">
|
||||
<h3 id="changelog-heading">Changelog</h3> {/* Added id to the heading */}
|
||||
</div>
|
||||
<div
|
||||
role="list"
|
||||
className="space-y-6 max-h-52 lg:max-h-96 overflow-y-scroll"
|
||||
aria-label="Recent changelog entries" // Added aria-label for the list
|
||||
>
|
||||
{changelog.map((activityItem) => (
|
||||
<Link
|
||||
href={activityItem.route}
|
||||
className="relative flex gap-x-4 group"
|
||||
key={activityItem.route}
|
||||
role="listitem" // Added role for each list item
|
||||
aria-label={`Changelog entry for ${activityItem.title}`} // Added aria-label for each list item
|
||||
>
|
||||
<div className="-bottom-6 absolute left-0 top-0 flex w-6 justify-center">
|
||||
<div className="w-px bg-secondary" />
|
||||
</div>
|
||||
|
||||
<div className="relative flex size-6 flex-none items-center justify-center bg-background">
|
||||
<div className="size-1.5 rounded-full bg-secondary ring-1 ring-primary/80 opacity-60 group-hover:opacity-100" />
|
||||
</div>
|
||||
<p className="flex-auto py-0.5 text-sm leading-5 text-primary/70 opacity-80 group-hover:opacity-100">
|
||||
<span className="font-medium text-primary">{activityItem.title}</span>{' '}
|
||||
{activityItem.author ? `by ${activityItem.author}` : null}
|
||||
</p>
|
||||
{activityItem.date ? (
|
||||
<time
|
||||
dateTime={activityItem.date.toISOString()}
|
||||
className="flex-none py-0.5 text-sm leading-5 text-primary/70 opacity-80 group-hover:opacity-100"
|
||||
aria-label={`Date: ${formatDate(activityItem.date)}`} // Added aria-label for the date
|
||||
>
|
||||
{formatDate(activityItem.date)}
|
||||
</time>
|
||||
) : null}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<Link
|
||||
key="root"
|
||||
href="/changelog"
|
||||
className="relative flex gap-x-4 group"
|
||||
role="button" // Added role for the link acting as a button
|
||||
aria-label="Read the full changelog" // Added aria-label for the link
|
||||
>
|
||||
<div className="size-6 absolute left-0 top-0 flex justify-center">
|
||||
<div className="w-px bg-secondary" />
|
||||
</div>
|
||||
|
||||
<div className="relative flex size-6 flex-none items-center justify-center bg-background">
|
||||
<div className="size-1.5 rounded-full bg-secondary ring-1 ring-primary/80 opacity-60 group-hover:opacity-100" />
|
||||
</div>
|
||||
<p className="flex-auto py-0.5 text-sm leading-5 text-primary/60 opacity-80 group-hover:opacity-100">
|
||||
<span className="font-medium text-primary">Read the full changelog ...</span> {null}
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(date: Date) {
|
||||
const dayDiff = (Date.now() - date.getTime()) / (1000 * 60 * 60 * 24)
|
||||
|
||||
if (dayDiff < 1) return 'today'
|
||||
if (dayDiff < 14) return `${Math.round(dayDiff)} days ago`
|
||||
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
import { Link } from 'nextra-theme-docs'
|
||||
import { Button } from 'nextra/components'
|
||||
|
||||
const PrivacyPolicy = () => {
|
||||
const currentDate = new Date().toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
|
||||
return (
|
||||
<main className="max-w-xl mx-auto">
|
||||
<div className="p-5">
|
||||
<h1 className="text-3xl font-extrabold pb-6">Privacy Policy for LibreChat Documentation</h1>
|
||||
|
||||
<pre className="leading-relaxed whitespace-pre-wrap" style={{ fontFamily: 'sans-serif' }}>
|
||||
{`Effective Date: ${currentDate}
|
||||
|
||||
This Privacy Policy outlines how the LibreChat documentation website (`}{' '}
|
||||
<Link href="https://librechat.ai">https://librechat.ai</Link>
|
||||
{`) operates with respect to user privacy.
|
||||
|
||||
1. No Data Collection
|
||||
|
||||
We do not collect, store, or process any personal information when you visit our documentation site. This includes:
|
||||
- No collection of names, email addresses, or contact information
|
||||
- No use of cookies or tracking technologies
|
||||
- No analytics or usage tracking
|
||||
- No user accounts or authentication
|
||||
|
||||
2. Purpose of This Site
|
||||
|
||||
This website serves solely as documentation for the open-source LibreChat project. It provides:
|
||||
- Installation guides
|
||||
- Configuration documentation
|
||||
- API references
|
||||
- User guides
|
||||
- Contributing guidelines
|
||||
|
||||
3. External Links
|
||||
|
||||
Our documentation may contain links to external websites, including:
|
||||
- The LibreChat GitHub repository
|
||||
- Third-party service documentation
|
||||
- Community resources
|
||||
|
||||
We are not responsible for the privacy practices of these external sites.
|
||||
|
||||
4. Changes to This Policy
|
||||
|
||||
We may update this privacy policy to reflect changes in our practices or for clarity. Any updates will be posted on this page with an updated effective date.
|
||||
|
||||
5. Contact Information
|
||||
|
||||
For questions about this privacy policy or the LibreChat project, please visit our GitHub repository at`}{' '}
|
||||
<Link href="https://github.com/danny-avila/LibreChat">
|
||||
https://github.com/danny-avila/LibreChat
|
||||
</Link>
|
||||
{`.
|
||||
|
||||
By using this documentation site, you acknowledge that no personal data is collected or processed.`}
|
||||
</pre>
|
||||
</div>
|
||||
<Button onClick={() => window.open('/', '_self')}>← Go Back</Button>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const TermsOfServices = () => {
|
||||
const currentDate = new Date().toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
|
||||
return (
|
||||
<main className="max-w-xl mx-auto">
|
||||
<div className="p-5">
|
||||
<h1 className="text-3xl font-extrabold pb-6">
|
||||
Terms of Service for LibreChat Documentation
|
||||
</h1>
|
||||
|
||||
<pre className="leading-relaxed whitespace-pre-wrap" style={{ fontFamily: 'sans-serif' }}>
|
||||
{`Effective Date: ${currentDate}
|
||||
|
||||
Welcome to the LibreChat documentation website, available at `}{' '}
|
||||
<Link href="https://librechat.ai">https://librechat.ai</Link>
|
||||
{`. These Terms of Service ("Terms") govern your use of our documentation website.
|
||||
|
||||
1. Purpose and Scope
|
||||
|
||||
This website provides documentation for LibreChat, an open-source AI chat platform. The site is purely informational and includes:
|
||||
- Technical documentation
|
||||
- Installation and setup guides
|
||||
- Configuration references
|
||||
- API documentation
|
||||
- Contributing guidelines
|
||||
- Blog posts related to LibreChat
|
||||
|
||||
2. No Commercial Services
|
||||
|
||||
This documentation site:
|
||||
- Does not sell any products or services
|
||||
- Does not require payment for access
|
||||
- Does not collect user data or personal information
|
||||
- Does not require user registration or accounts
|
||||
|
||||
3. Open Source Project
|
||||
|
||||
LibreChat is an open-source project licensed under the MIT License. The source code is available at:`}{' '}
|
||||
<Link href="https://github.com/danny-avila/LibreChat">
|
||||
https://github.com/danny-avila/LibreChat
|
||||
</Link>
|
||||
{`
|
||||
|
||||
4. Use of Documentation
|
||||
|
||||
You may freely:
|
||||
- Access and read all documentation
|
||||
- Share links to the documentation
|
||||
- Use the documentation to implement and configure LibreChat
|
||||
- Contribute improvements to the documentation via GitHub
|
||||
|
||||
5. No Warranty
|
||||
|
||||
This documentation is provided "as is" without warranty of any kind. While we strive for accuracy, we make no guarantees about:
|
||||
- The completeness or accuracy of the documentation
|
||||
- The suitability of LibreChat for any particular purpose
|
||||
- The availability of this documentation site
|
||||
|
||||
6. External Resources
|
||||
|
||||
Our documentation may reference or link to third-party services, tools, or resources. We are not responsible for:
|
||||
- The content or practices of external sites
|
||||
- The availability of external resources
|
||||
- Any issues arising from the use of third-party services
|
||||
|
||||
7. Intellectual Property
|
||||
|
||||
The documentation content is licensed under the MIT License, consistent with the LibreChat project. You are free to use, modify, and distribute the documentation in accordance with this license.
|
||||
|
||||
8. Changes to Terms
|
||||
|
||||
We may update these terms to reflect changes in the project or for clarity. Updates will be posted on this page with a new effective date.
|
||||
|
||||
9. Contact
|
||||
|
||||
For questions about these terms or to contribute to the project, please visit:`}{' '}
|
||||
<Link href="https://github.com/danny-avila/LibreChat">
|
||||
https://github.com/danny-avila/LibreChat
|
||||
</Link>
|
||||
{`
|
||||
|
||||
By using this documentation site, you agree to these Terms of Service.`}
|
||||
</pre>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const CookiePolicy = () => {
|
||||
const currentDate = new Date().toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
|
||||
return (
|
||||
<main className="max-w-xl mx-auto">
|
||||
<div className="p-5">
|
||||
<h1 className="text-3xl font-extrabold pb-6">Cookie Policy for LibreChat Documentation</h1>
|
||||
|
||||
<pre className="leading-relaxed whitespace-pre-wrap" style={{ fontFamily: 'sans-serif' }}>
|
||||
{`Effective Date: ${currentDate}
|
||||
|
||||
1. No Cookies Used
|
||||
|
||||
The LibreChat documentation website (`}{' '}
|
||||
<Link href="https://librechat.ai">https://librechat.ai</Link>
|
||||
{`) does not use cookies or any similar tracking technologies.
|
||||
|
||||
2. No Data Collection
|
||||
|
||||
We do not:
|
||||
- Set or read cookies
|
||||
- Use web beacons or tracking pixels
|
||||
- Employ analytics or tracking scripts
|
||||
- Collect any personal information
|
||||
- Track user behavior or preferences
|
||||
|
||||
3. Third-Party Services
|
||||
|
||||
While our site does not use cookies, please note:
|
||||
- External links may lead to sites that use cookies
|
||||
- Your browser may have its own cookie settings
|
||||
- We are not responsible for cookies set by external sites
|
||||
|
||||
4. Browser Storage
|
||||
|
||||
This documentation site may use browser features like:
|
||||
- Local storage for theme preferences (light/dark mode)
|
||||
- Session storage for navigation state
|
||||
|
||||
This data is stored only in your browser and is never transmitted to any server.
|
||||
|
||||
5. Your Privacy
|
||||
|
||||
Since we don't use cookies or collect data:
|
||||
- There's nothing to opt out of
|
||||
- No personal information is at risk
|
||||
- Your browsing is completely private
|
||||
|
||||
6. Changes to This Policy
|
||||
|
||||
Any updates to this policy will be posted here with a new effective date. However, we do not anticipate adding cookies or tracking to this documentation site.
|
||||
|
||||
7. Contact
|
||||
|
||||
For questions about this policy or the LibreChat project, visit:`}{' '}
|
||||
<Link href="https://github.com/danny-avila/LibreChat">
|
||||
https://github.com/danny-avila/LibreChat
|
||||
</Link>
|
||||
{`
|
||||
|
||||
By using this site, you acknowledge that no cookies or tracking technologies are employed.`}
|
||||
</pre>
|
||||
</div>
|
||||
<Button onClick={() => window.open('/', '_self')}>← Go Back</Button>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export { TermsOfServices }
|
||||
export { PrivacyPolicy }
|
||||
export { CookiePolicy }
|
||||
@@ -1,215 +1,150 @@
|
||||
import { useState } from 'react'
|
||||
import useCredentialsGenerator from './credentialsGenerator' // Adjust the path based on your project structure
|
||||
'use client'
|
||||
|
||||
const CredsGenerator = () => {
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Copy, Check, RefreshCw, ClipboardList } from 'lucide-react'
|
||||
import useCredentialsGenerator from './credentialsGenerator'
|
||||
|
||||
const CREDENTIAL_FIELDS = [
|
||||
{ key: 'CREDS_KEY', label: 'CREDS_KEY', hint: 'Encryption key for stored credentials' },
|
||||
{ key: 'CREDS_IV', label: 'CREDS_IV', hint: 'Initialization vector for encryption' },
|
||||
{ key: 'JWT_SECRET', label: 'JWT_SECRET', hint: 'Secret for signing access tokens' },
|
||||
{
|
||||
key: 'JWT_REFRESH_SECRET',
|
||||
label: 'JWT_REFRESH_SECRET',
|
||||
hint: 'Secret for signing refresh tokens',
|
||||
},
|
||||
{ key: 'MEILI_KEY', label: 'MEILI_MASTER_KEY', hint: 'MeiliSearch master key' },
|
||||
] as const
|
||||
|
||||
type CredentialKey = (typeof CREDENTIAL_FIELDS)[number]['key']
|
||||
type Credentials = Record<CredentialKey, string>
|
||||
|
||||
export default function CredentialsGenerator() {
|
||||
const { generateCredentials } = useCredentialsGenerator()
|
||||
const [credentials, setCredentials] = useState<{
|
||||
CREDS_KEY: string
|
||||
CREDS_IV: string
|
||||
JWT_SECRET: string
|
||||
JWT_REFRESH_SECRET: string
|
||||
MEILI_KEY: string
|
||||
} | null>(null)
|
||||
const [copyEnabled, setCopyEnabled] = useState(false) // State to track whether copy is enabled
|
||||
const [showTooltip, setShowTooltip] = useState(false) // State to track tooltip visibility
|
||||
const [credentials, setCredentials] = useState<Credentials | null>(null)
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null)
|
||||
const [generated, setGenerated] = useState(false)
|
||||
|
||||
const handleGenerate = () => {
|
||||
try {
|
||||
const newCredentials = generateCredentials()
|
||||
setCredentials(newCredentials)
|
||||
setCopyEnabled(true) // Enable copy after generating credentials
|
||||
setCredentials(generateCredentials())
|
||||
setGenerated(true)
|
||||
setCopiedKey(null)
|
||||
} catch (error) {
|
||||
console.error(error.message)
|
||||
console.error((error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = (value) => {
|
||||
navigator.clipboard
|
||||
.writeText(value)
|
||||
.then(() => {
|
||||
setShowTooltip(true) // Show tooltip on successful copy
|
||||
setTimeout(() => setShowTooltip(false), 2000) // Hide tooltip after 2 seconds
|
||||
})
|
||||
.catch((err) => console.error('Copy failed:', err))
|
||||
}
|
||||
const copyToClipboard = useCallback(async (text: string, key: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopiedKey(key)
|
||||
setTimeout(() => setCopiedKey(null), 2000)
|
||||
} catch (err) {
|
||||
console.error('Copy failed:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleCopyAll = useCallback(() => {
|
||||
if (!credentials) return
|
||||
const block = CREDENTIAL_FIELDS.map((f) => `${f.label}=${credentials[f.key]}`).join('\n')
|
||||
copyToClipboard(block, 'all')
|
||||
}, [credentials, copyToClipboard])
|
||||
|
||||
return (
|
||||
<div className="credentials-box">
|
||||
<h2 style={{ textAlign: 'center', fontSize: '1.5rem', marginBottom: '15px' }}>
|
||||
Generate Credentials
|
||||
</h2>
|
||||
<div className="credentials-container">
|
||||
<div>
|
||||
<p style={{ fontSize: '0.8rem', marginBottom: '5px', marginTop: '10px' }}>CREDS_KEY</p>
|
||||
<div className="input-container">
|
||||
<input
|
||||
type="text"
|
||||
value={credentials?.CREDS_KEY || ''}
|
||||
placeholder=""
|
||||
readOnly
|
||||
aria-label="CREDS_KEY value"
|
||||
/>
|
||||
<button
|
||||
className="copy-button"
|
||||
onClick={() => handleCopy(credentials?.CREDS_KEY)}
|
||||
disabled={!copyEnabled}
|
||||
aria-label="Copy CREDS_KEY"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p style={{ fontSize: '0.8rem', marginBottom: '5px', marginTop: '10px' }}>CREDS_IV</p>
|
||||
<div className="input-container">
|
||||
<input
|
||||
type="text"
|
||||
value={credentials?.CREDS_IV || ''}
|
||||
placeholder=""
|
||||
readOnly
|
||||
aria-label="CREDS_IV value"
|
||||
/>
|
||||
<button
|
||||
className="copy-button"
|
||||
onClick={() => handleCopy(credentials?.CREDS_IV)}
|
||||
disabled={!copyEnabled}
|
||||
aria-label="Copy CREDS_IV"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p style={{ fontSize: '0.8rem', marginBottom: '5px', marginTop: '10px' }}>JWT_SECRET</p>
|
||||
<div className="input-container">
|
||||
<input
|
||||
type="text"
|
||||
value={credentials?.JWT_SECRET || ''}
|
||||
placeholder=""
|
||||
readOnly
|
||||
aria-label="JWT_SECRET value"
|
||||
/>
|
||||
<button
|
||||
className="copy-button"
|
||||
onClick={() => handleCopy(credentials?.JWT_SECRET)}
|
||||
disabled={!copyEnabled}
|
||||
aria-label="Copy JWT_SECRET"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p style={{ fontSize: '0.8rem', marginBottom: '5px', marginTop: '10px' }}>
|
||||
JWT_REFRESH_SECRET
|
||||
</p>
|
||||
<div className="input-container">
|
||||
<input
|
||||
type="text"
|
||||
value={credentials?.JWT_REFRESH_SECRET || ''}
|
||||
placeholder=""
|
||||
readOnly
|
||||
aria-label="JWT_REFRESH_SECRET value"
|
||||
/>
|
||||
<button
|
||||
className="copy-button"
|
||||
onClick={() => handleCopy(credentials?.JWT_REFRESH_SECRET)}
|
||||
disabled={!copyEnabled}
|
||||
aria-label="Copy JWT_REFRESH_SECRET"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p style={{ fontSize: '0.8rem', marginBottom: '5px', marginTop: '10px' }}>
|
||||
MEILI_MASTER_KEY
|
||||
</p>
|
||||
<div className="input-container">
|
||||
<input
|
||||
type="text"
|
||||
value={credentials?.MEILI_KEY || ''}
|
||||
placeholder=""
|
||||
readOnly
|
||||
aria-label="MEILI_MASTER_KEY value"
|
||||
/>
|
||||
<button
|
||||
className="copy-button"
|
||||
onClick={() => handleCopy(credentials?.MEILI_KEY)}
|
||||
disabled={!copyEnabled}
|
||||
aria-label="Copy MEILI_MASTER_KEY"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showTooltip && <div className="tooltip">Copied to Clipboard</div>}
|
||||
<div className="space-y-6">
|
||||
<button
|
||||
className="generate-button"
|
||||
style={{ display: 'block', margin: '0 auto', marginTop: '15px' }}
|
||||
onClick={handleGenerate}
|
||||
className="group flex w-full items-center justify-center gap-2 rounded-lg bg-fd-primary px-5 py-3 text-sm font-medium text-fd-primary-foreground transition-all hover:opacity-90 active:scale-[0.99]"
|
||||
aria-label="Generate new credentials"
|
||||
>
|
||||
Generate
|
||||
<RefreshCw
|
||||
className={`size-4 transition-transform ${generated ? 'group-hover:rotate-180' : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{generated ? 'Regenerate Credentials' : 'Generate Credentials'}
|
||||
</button>
|
||||
<style jsx>{`
|
||||
.credentials-box {
|
||||
position: relative;
|
||||
padding: 10px;
|
||||
// border: 1px solid #ccc;
|
||||
border-radius: 20px;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.credentials-container {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.input-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.input-container input {
|
||||
width: calc(100% - 70px);
|
||||
margin-right: 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
padding: 5px;
|
||||
}
|
||||
.copy-button {
|
||||
padding: 6px 12px; /* Adjust as needed */
|
||||
background: rgb(30, 163, 128);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
width: auto;
|
||||
font-size: 0.8rem; /* Adjusted size */
|
||||
}
|
||||
.generate-button {
|
||||
padding: 6px 12px; /* Adjust as needed */
|
||||
background: rgb(30, 163, 128);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
width: auto;
|
||||
font-size: 1rem; /* Adjusted size */
|
||||
}
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background-color: rgba(30, 163, 128, 0.5);
|
||||
color: #fff;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
z-index: 999; /* Ensure a higher z-index */
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{credentials && (
|
||||
<>
|
||||
<div
|
||||
className="grid grid-cols-1 gap-4 lg:grid-cols-2"
|
||||
role="region"
|
||||
aria-label="Generated credentials"
|
||||
>
|
||||
{CREDENTIAL_FIELDS.map((field) => {
|
||||
const id = `cred-${field.key}`
|
||||
const isCopied = copiedKey === field.key
|
||||
return (
|
||||
<div
|
||||
key={field.key}
|
||||
className="group/field rounded-lg border border-fd-border bg-fd-card p-4 transition-colors hover:border-fd-primary/20"
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="font-mono text-xs font-semibold text-fd-foreground"
|
||||
>
|
||||
{field.label}
|
||||
</label>
|
||||
<button
|
||||
onClick={() => copyToClipboard(credentials[field.key], field.key)}
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-fd-muted-foreground transition-colors hover:bg-fd-accent hover:text-fd-foreground"
|
||||
aria-label={`Copy ${field.label}`}
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<Check className="size-3 text-emerald-500" aria-hidden="true" />
|
||||
<span className="text-emerald-500">Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="size-3" aria-hidden="true" />
|
||||
<span>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mb-2 text-xs text-fd-muted-foreground">{field.hint}</p>
|
||||
<input
|
||||
id={id}
|
||||
readOnly
|
||||
value={credentials[field.key]}
|
||||
className="w-full truncate rounded border border-fd-border bg-fd-muted px-3 py-2 font-mono text-xs text-fd-foreground outline-none focus-visible:ring-2 focus-visible:ring-fd-ring"
|
||||
aria-label={`${field.label} value`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 border-t border-fd-border pt-4">
|
||||
<button
|
||||
onClick={handleCopyAll}
|
||||
className="flex items-center gap-2 rounded-lg border border-fd-border bg-fd-secondary px-4 py-2 text-sm font-medium text-fd-secondary-foreground transition-colors hover:bg-fd-accent"
|
||||
aria-label="Copy all credentials as .env block"
|
||||
>
|
||||
<ClipboardList className="size-4" aria-hidden="true" />
|
||||
{copiedKey === 'all' ? 'Copied to clipboard!' : 'Copy All as .env'}
|
||||
</button>
|
||||
<span aria-live="polite" className="text-xs text-fd-muted-foreground">
|
||||
{copiedKey === 'all' && 'All 5 credentials copied as KEY=value format'}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!credentials && (
|
||||
<div className="rounded-lg border border-dashed border-fd-border bg-fd-muted/50 px-6 py-12 text-center">
|
||||
<p className="text-sm text-fd-muted-foreground">
|
||||
Click the button above to generate secure random credentials for your{' '}
|
||||
<code className="rounded bg-fd-muted px-1.5 py-0.5 font-mono text-xs text-fd-foreground">
|
||||
.env
|
||||
</code>{' '}
|
||||
file.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CredsGenerator
|
||||
|
||||
12
components/tools/CredentialsGeneratorMDX.tsx
Normal file
12
components/tools/CredentialsGeneratorMDX.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const CredentialsGenerator = dynamic(() => import('@/components/tools/CredentialsGeneratorBox'), {
|
||||
ssr: false,
|
||||
loading: () => <div className="h-64 animate-pulse rounded-lg bg-fd-muted" />,
|
||||
})
|
||||
|
||||
export function CredentialsGeneratorMDX() {
|
||||
return <CredentialsGenerator />
|
||||
}
|
||||
12
components/tools/YAMLValidatorMDX.tsx
Normal file
12
components/tools/YAMLValidatorMDX.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const YAMLValidator = dynamic(() => import('@/components/tools/yamlChecker'), {
|
||||
ssr: false,
|
||||
loading: () => <div className="h-[500px] animate-pulse rounded-lg bg-fd-muted" />,
|
||||
})
|
||||
|
||||
export function YAMLValidatorMDX() {
|
||||
return <YAMLValidator />
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import AceEditor, { IMarker } from 'react-ace'
|
||||
import 'ace-builds/src-noconflict/mode-yaml'
|
||||
import 'ace-builds/src-noconflict/theme-twilight'
|
||||
import 'ace-builds/src-noconflict/theme-chrome'
|
||||
import jsYaml from 'js-yaml'
|
||||
import { CheckCircle, XCircle, Trash2, Upload } from 'lucide-react'
|
||||
|
||||
function YAMLChecker() {
|
||||
export default function YAMLValidator() {
|
||||
const [yaml, setYaml] = useState('')
|
||||
const [validationResult, setValidationResult] = useState<{
|
||||
valid: boolean
|
||||
@@ -12,49 +16,55 @@ function YAMLChecker() {
|
||||
error?: string
|
||||
} | null>(null)
|
||||
const [errorLine, setErrorLine] = useState<number | null>(null)
|
||||
const editorRef = useRef<any>(null)
|
||||
const [isDark, setIsDark] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const editorRef = useRef<AceEditor>(null)
|
||||
|
||||
const validateYAML = (yamlContent: string) => {
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
setIsDark(root.classList.contains('dark'))
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDark(root.classList.contains('dark'))
|
||||
})
|
||||
observer.observe(root, { attributes: true, attributeFilter: ['class'] })
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const validateYAML = useCallback((yamlContent: string) => {
|
||||
try {
|
||||
const result = jsYaml.load(yamlContent)
|
||||
setErrorLine(null) // No error
|
||||
setErrorLine(null)
|
||||
return { valid: true, result: JSON.stringify(result, null, 2) }
|
||||
} catch (error) {
|
||||
let errorMessage = ''
|
||||
const line = error.mark?.line
|
||||
setErrorLine(line)
|
||||
if (error.reason === 'bad indentation of a mapping entry') {
|
||||
errorMessage = ` Incorrect indentation at line ${line + 1}. Each entry in YAML should be properly indented.`
|
||||
} else {
|
||||
errorMessage = ` ${error.reason} at line ${line + 1}`
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const yamlError = error as { reason?: string; mark?: { line?: number } }
|
||||
const line = yamlError.mark?.line
|
||||
setErrorLine(line ?? null)
|
||||
const errorMessage =
|
||||
yamlError.reason === 'bad indentation of a mapping entry'
|
||||
? `Incorrect indentation at line ${(line ?? 0) + 1}. Each entry in YAML should be properly indented.`
|
||||
: `${yamlError.reason} at line ${(line ?? 0) + 1}`
|
||||
return { valid: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
|
||||
const handleYamlChange = (newYaml: string) => {
|
||||
setYaml(newYaml)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleFileDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const fileContent = reader.result as string
|
||||
setYaml(fileContent)
|
||||
}
|
||||
reader.onload = () => setYaml(reader.result as string)
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (yaml.trim() === '') {
|
||||
setValidationResult(null) // Clear validation result if YAML is empty
|
||||
setValidationResult(null)
|
||||
setErrorLine(null)
|
||||
} else {
|
||||
const result = validateYAML(yaml)
|
||||
setValidationResult(result)
|
||||
setValidationResult(validateYAML(yaml))
|
||||
}
|
||||
}, [yaml]) // Trigger validation whenever `yaml` changes
|
||||
}, [yaml, validateYAML])
|
||||
|
||||
const errorMarkers: IMarker[] =
|
||||
errorLine === null
|
||||
@@ -66,65 +76,91 @@ function YAMLChecker() {
|
||||
startCol: 0,
|
||||
endCol: Number.MAX_VALUE,
|
||||
type: 'text',
|
||||
className: 'error-marker', // Use className instead of style
|
||||
className: 'ace-error-marker',
|
||||
},
|
||||
]
|
||||
|
||||
const textAreaStyle = {
|
||||
width: '100%',
|
||||
minHeight: '50px',
|
||||
padding: '10px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '5px',
|
||||
fontSize: '1rem',
|
||||
backgroundColor: validationResult
|
||||
? validationResult.valid
|
||||
? 'rgba(0,255,0,0.2)' // Green background for valid YAML
|
||||
: 'rgba(255,0,0,0.2)' // Red background for invalid YAML
|
||||
: 'transparent', // Transparent background by default
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', maxWidth: '800px', margin: '0 auto' }}>
|
||||
<h2 style={{ textAlign: 'left', fontSize: '1.5rem', margin: '10px 0' }}>
|
||||
YAML Validator (beta)
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
onDrop={handleFileDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
style={{ width: '100%', marginBottom: '10px' }}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
className={`relative overflow-hidden rounded-lg border transition-colors ${
|
||||
isDragging ? 'border-fd-primary bg-fd-primary/5' : 'border-fd-border'
|
||||
}`}
|
||||
>
|
||||
{isDragging && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-fd-background/80 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-fd-primary">
|
||||
<Upload className="size-5" aria-hidden="true" />
|
||||
Drop YAML file here
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<AceEditor
|
||||
mode="yaml"
|
||||
theme="twilight"
|
||||
onChange={handleYamlChange}
|
||||
theme={isDark ? 'twilight' : 'chrome'}
|
||||
onChange={setYaml}
|
||||
value={yaml}
|
||||
name="YAML_EDITOR"
|
||||
editorProps={{ $blockScrolling: true }}
|
||||
setOptions={{
|
||||
showLineNumbers: true,
|
||||
highlightActiveLine: false,
|
||||
showPrintMargin: false,
|
||||
tabSize: 2,
|
||||
fontSize: 14,
|
||||
}}
|
||||
markers={errorMarkers}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="Paste the content of the YAML file here or drop a file here..."
|
||||
width="100%"
|
||||
height="500px"
|
||||
placeholder="Paste your librechat.yaml content here, or drag & drop a file..."
|
||||
ref={editorRef}
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
style={textAreaStyle}
|
||||
placeholder="Validation Result will be displayed here"
|
||||
value={
|
||||
validationResult
|
||||
? validationResult.valid
|
||||
? 'YAML is valid!'
|
||||
: validationResult.error || ''
|
||||
: ''
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1" role="status" aria-live="polite">
|
||||
{validationResult === null ? (
|
||||
<p className="rounded-lg border border-dashed border-fd-border px-4 py-3 text-sm text-fd-muted-foreground">
|
||||
Validation results will appear here once you paste or drop YAML content.
|
||||
</p>
|
||||
) : validationResult.valid ? (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-emerald-500/20 bg-emerald-500/10 px-4 py-3 text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||
<CheckCircle className="size-4 shrink-0" aria-hidden="true" />
|
||||
YAML is valid!
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-600 dark:text-red-400">
|
||||
<XCircle className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
<span>{validationResult.error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{yaml.trim() !== '' && (
|
||||
<button
|
||||
onClick={() => setYaml('')}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-fd-border px-3 py-2.5 text-sm text-fd-muted-foreground transition-colors hover:bg-fd-accent hover:text-fd-foreground"
|
||||
aria-label="Clear editor"
|
||||
>
|
||||
<Trash2 className="size-3.5" aria-hidden="true" />
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.ace-error-marker {
|
||||
position: absolute;
|
||||
background-color: rgba(255, 0, 0, 0.3);
|
||||
z-index: 20;
|
||||
}
|
||||
/>
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default YAMLChecker
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"features",
|
||||
"user_guides",
|
||||
"translation",
|
||||
"---Tools---",
|
||||
"toolkit",
|
||||
"---Contributing---",
|
||||
"development",
|
||||
"documentation"
|
||||
|
||||
10
content/docs/toolkit/credentials-generator.mdx
Normal file
10
content/docs/toolkit/credentials-generator.mdx
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: Credentials Generator
|
||||
description: Generate secure random credentials for your LibreChat .env file.
|
||||
---
|
||||
|
||||
import { CredentialsGeneratorMDX } from '@/components/tools/CredentialsGeneratorMDX'
|
||||
|
||||
Generate cryptographically secure random values for the required secrets in your LibreChat `.env` configuration file. Click **Generate** and then copy individual values or all of them at once.
|
||||
|
||||
<CredentialsGeneratorMDX />
|
||||
13
content/docs/toolkit/index.mdx
Normal file
13
content/docs/toolkit/index.mdx
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
title: Toolkit
|
||||
description: Development tools and utilities for configuring LibreChat.
|
||||
---
|
||||
|
||||
<Cards num={2}>
|
||||
<Card title="Credentials Generator" href="/docs/toolkit/credentials-generator" arrow>
|
||||
Generate secure random values for CREDS_KEY, JWT_SECRET, and other .env variables.
|
||||
</Card>
|
||||
<Card title="YAML Validator" href="/docs/toolkit/yaml-validator" arrow>
|
||||
Paste or drop your librechat.yaml to check for syntax errors and formatting issues.
|
||||
</Card>
|
||||
</Cards>
|
||||
9
content/docs/toolkit/meta.json
Normal file
9
content/docs/toolkit/meta.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "Toolkit",
|
||||
"icon": "Wrench",
|
||||
"pages": [
|
||||
"index",
|
||||
"credentials-generator",
|
||||
"yaml-validator"
|
||||
]
|
||||
}
|
||||
10
content/docs/toolkit/yaml-validator.mdx
Normal file
10
content/docs/toolkit/yaml-validator.mdx
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: YAML Validator
|
||||
description: Validate your LibreChat YAML configuration files for syntax errors.
|
||||
---
|
||||
|
||||
import { YAMLValidatorMDX } from '@/components/tools/YAMLValidatorMDX'
|
||||
|
||||
Paste your `librechat.yaml` content below or drag and drop a file to check for syntax errors. The validator highlights the exact line where issues occur.
|
||||
|
||||
<YAMLValidatorMDX />
|
||||
@@ -12,6 +12,8 @@ import { QuickStartHub } from '@/components/QuickStartHub'
|
||||
import { FeaturesHub } from '@/components/FeaturesHub'
|
||||
import Carousel from '@/components/carousel/Carousel'
|
||||
import { TrackedLink, TrackedAnchor } from '@/components/TrackedLink'
|
||||
import { CredentialsGeneratorMDX } from '@/components/tools/CredentialsGeneratorMDX'
|
||||
import { YAMLValidatorMDX } from '@/components/tools/YAMLValidatorMDX'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
function mapCalloutType(type?: string): 'info' | 'warn' | 'error' {
|
||||
@@ -251,4 +253,6 @@ export const mdxComponents = {
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
CredentialsGeneratorMDX,
|
||||
YAMLValidatorMDX,
|
||||
}
|
||||
|
||||
9
lib/mdx-provider.ts
Normal file
9
lib/mdx-provider.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Minimal MDX provider for components/ directory MDX files.
|
||||
* Provides component mapping without depending on Nextra.
|
||||
*/
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
export function useMDXComponents(components: Record<string, ComponentType<any>> = {}) {
|
||||
return components
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface ButtonProps {
|
||||
children?: ReactNode
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export function ClientButton({ children, ...props }: ButtonProps) {
|
||||
return <button {...props}>{children}</button>
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* Nextra compatibility shim for `nextra/components`.
|
||||
* Provides minimal component stubs so existing pages/ components
|
||||
* can resolve their imports during the migration to Fumadocs.
|
||||
*
|
||||
* Nextra's components use compound patterns (e.g., Cards.Card, Tabs.Tab,
|
||||
* FileTree.File) which are replicated here using Object.assign.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface ChildrenProps {
|
||||
children?: ReactNode
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export function Steps({ children }: ChildrenProps) {
|
||||
return <div className="nextra-steps">{children}</div>
|
||||
}
|
||||
|
||||
function TabComponent({ children }: ChildrenProps) {
|
||||
return <div>{children}</div>
|
||||
}
|
||||
|
||||
function TabsBase({ children }: ChildrenProps) {
|
||||
return <div>{children}</div>
|
||||
}
|
||||
|
||||
export const Tabs = Object.assign(TabsBase, { Tab: TabComponent })
|
||||
|
||||
function CardComponent({
|
||||
children,
|
||||
title,
|
||||
href,
|
||||
icon,
|
||||
...props
|
||||
}: ChildrenProps & { title?: string; href?: string; icon?: ReactNode }) {
|
||||
const content = (
|
||||
<div
|
||||
className="nextra-card flex items-center gap-3 rounded-lg border border-gray-200 dark:border-gray-800 p-4 transition-colors hover:bg-gray-100 dark:hover:bg-gray-800/50"
|
||||
{...props}
|
||||
>
|
||||
{icon && <span className="shrink-0 [&>svg]:size-6">{icon}</span>}
|
||||
{title && <h3 className="m-0 text-base font-medium">{title}</h3>}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
if (href)
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="no-underline"
|
||||
{...(props.target ? { target: props.target, rel: 'noopener noreferrer' } : {})}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
)
|
||||
return content
|
||||
}
|
||||
|
||||
function CardsBase({ children }: ChildrenProps) {
|
||||
return <div className="nextra-cards mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">{children}</div>
|
||||
}
|
||||
|
||||
export const Cards = Object.assign(CardsBase, { Card: CardComponent })
|
||||
|
||||
function FileComponent({
|
||||
children,
|
||||
name,
|
||||
...props
|
||||
}: ChildrenProps & { name?: string; active?: boolean }) {
|
||||
return (
|
||||
<div className="file" {...props}>
|
||||
{name && <span>{name}</span>}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FolderComponent({
|
||||
children,
|
||||
name,
|
||||
...props
|
||||
}: ChildrenProps & { name?: string; defaultOpen?: boolean }) {
|
||||
return (
|
||||
<div className="folder" {...props}>
|
||||
{name && <span>{name}/</span>}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FileTreeBase({ children }: ChildrenProps) {
|
||||
return <div>{children}</div>
|
||||
}
|
||||
|
||||
export const FileTree = Object.assign(FileTreeBase, {
|
||||
File: FileComponent,
|
||||
Folder: FolderComponent,
|
||||
})
|
||||
|
||||
export function Button({ children, ...props }: ChildrenProps) {
|
||||
return <button {...props}>{children}</button>
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* Nextra compatibility shim for `nextra/context`.
|
||||
* Provides stub implementations so existing pages/ components
|
||||
* can resolve their imports during the migration to Fumadocs.
|
||||
*
|
||||
* Components like ChangelogHeader use:
|
||||
* const pages = getPagesUnderRoute('/changelog');
|
||||
* const page = pages.find(p => p.route === router.pathname);
|
||||
* const { title } = page.frontMatter;
|
||||
*
|
||||
* We return a Proxy-based array that always matches .find() calls,
|
||||
* returning a safe stub page with empty frontMatter defaults.
|
||||
*/
|
||||
|
||||
const safeFrontMatter: Record<string, any> = new Proxy(
|
||||
{
|
||||
title: '',
|
||||
description: '',
|
||||
date: new Date().toISOString(),
|
||||
ogImage: '',
|
||||
ogVideo: '',
|
||||
gif: undefined,
|
||||
authorid: '',
|
||||
author: '',
|
||||
version: '',
|
||||
tags: [],
|
||||
category: '',
|
||||
featured: false,
|
||||
},
|
||||
{
|
||||
get(target, prop) {
|
||||
if (prop in target) return target[prop as string]
|
||||
return ''
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function createStubPage(route: string): any {
|
||||
return {
|
||||
name: route.split('/').pop() || 'stub',
|
||||
route,
|
||||
kind: 'MdxPage',
|
||||
frontMatter: safeFrontMatter,
|
||||
meta: { title: '' },
|
||||
children: [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a proxy array that intercepts .find() calls to always return
|
||||
* a valid stub page, preventing undefined access errors.
|
||||
*/
|
||||
export function getPagesUnderRoute(route: string): any[] {
|
||||
const stubArray: any[] = []
|
||||
|
||||
return new Proxy(stubArray, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === 'find') {
|
||||
return (_predicate: any) => createStubPage(route)
|
||||
}
|
||||
if (prop === 'filter') {
|
||||
return (_predicate: any) => []
|
||||
}
|
||||
if (prop === 'sort') {
|
||||
return (_compareFn: any) => receiver
|
||||
}
|
||||
if (prop === 'slice') {
|
||||
return () => []
|
||||
}
|
||||
if (prop === 'map') {
|
||||
return (_mapFn: any) => []
|
||||
}
|
||||
if (prop === 'flatMap') {
|
||||
return (_mapFn: any) => []
|
||||
}
|
||||
if (prop === 'length') {
|
||||
return 0
|
||||
}
|
||||
if (prop === Symbol.iterator) {
|
||||
return function* () {}
|
||||
}
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub for useConfig - returns a config object with empty frontMatter.
|
||||
*/
|
||||
export function useConfig(): any {
|
||||
return {
|
||||
title: '',
|
||||
frontMatter: safeFrontMatter,
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Nextra compatibility shim for `nextra` (main package).
|
||||
* Provides stub type exports so existing pages/ components
|
||||
* can resolve their imports during the migration to Fumadocs.
|
||||
*/
|
||||
|
||||
export interface Page {
|
||||
name: string
|
||||
route: string
|
||||
children?: Page[]
|
||||
meta?: Record<string, any>
|
||||
frontMatter?: Record<string, any>
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface MdxFile {
|
||||
name: string
|
||||
route: string
|
||||
frontMatter?: Record<string, any>
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export type { Page as default }
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* MDX components provider for the Nextra compatibility layer.
|
||||
* This module is used as the `providerImportSource` for @mdx-js/loader
|
||||
* to automatically provide components that Nextra used to inject
|
||||
* via theme.config.tsx's `components` property.
|
||||
*/
|
||||
import type { ReactNode, ComponentType } from 'react'
|
||||
|
||||
interface ChildrenProps {
|
||||
children?: ReactNode
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
function Callout({ children, type = 'info', ...props }: ChildrenProps & { type?: string }) {
|
||||
return (
|
||||
<div data-callout={type} className="callout" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Steps({ children }: ChildrenProps) {
|
||||
return <div className="steps">{children}</div>
|
||||
}
|
||||
|
||||
function Tab({ children }: ChildrenProps) {
|
||||
return <div>{children}</div>
|
||||
}
|
||||
|
||||
function TabsComponent({ children }: ChildrenProps) {
|
||||
return <div>{children}</div>
|
||||
}
|
||||
|
||||
/** Nextra's Tabs component uses compound pattern: `<Tabs.Tab>` */
|
||||
const Tabs = Object.assign(TabsComponent, { Tab })
|
||||
|
||||
function Card({
|
||||
children,
|
||||
title,
|
||||
href,
|
||||
...props
|
||||
}: ChildrenProps & { title?: string; href?: string }) {
|
||||
const content = (
|
||||
<div className="card" {...props}>
|
||||
{title && <h3>{title}</h3>}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
if (href) return <a href={href}>{content}</a>
|
||||
return content
|
||||
}
|
||||
|
||||
function CardsComponent({ children }: ChildrenProps) {
|
||||
return <div className="cards">{children}</div>
|
||||
}
|
||||
|
||||
/** Nextra's Cards component uses compound pattern: `<Cards.Card>` */
|
||||
const Cards = Object.assign(CardsComponent, { Card })
|
||||
|
||||
function FileComponent({ children, name, ...props }: ChildrenProps & { name?: string }) {
|
||||
return (
|
||||
<div className="file" {...props}>
|
||||
{name && <span>{name}</span>}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FolderComponent({ children, name, ...props }: ChildrenProps & { name?: string }) {
|
||||
return (
|
||||
<div className="folder" {...props}>
|
||||
{name && <span>{name}/</span>}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FileTreeBase({ children }: ChildrenProps) {
|
||||
return <div className="file-tree">{children}</div>
|
||||
}
|
||||
|
||||
/** Nextra's FileTree uses compound pattern: `<FileTree.File>`, `<FileTree.Folder>` */
|
||||
const FileTree = Object.assign(FileTreeBase, {
|
||||
File: FileComponent,
|
||||
Folder: FolderComponent,
|
||||
})
|
||||
|
||||
function OptionTable({ options, ...props }: { options: any[]; [key: string]: any }) {
|
||||
if (!options || !Array.isArray(options)) return null
|
||||
return (
|
||||
<table {...props}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Option</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{options.map((opt: any, i: number) => (
|
||||
<tr key={i}>
|
||||
<td>{opt[0]}</td>
|
||||
<td>{opt[1]}</td>
|
||||
<td>{opt[2]}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
function Button({ children, ...props }: ChildrenProps) {
|
||||
return <button {...props}>{children}</button>
|
||||
}
|
||||
|
||||
function Carousel({ children }: ChildrenProps) {
|
||||
return <div className="carousel">{children}</div>
|
||||
}
|
||||
|
||||
function Frame({ children }: ChildrenProps) {
|
||||
return <div className="frame">{children}</div>
|
||||
}
|
||||
|
||||
const components: Record<string, ComponentType<any>> = {
|
||||
Callout,
|
||||
Steps,
|
||||
Tabs,
|
||||
Tab,
|
||||
Cards,
|
||||
Card,
|
||||
FileTree,
|
||||
OptionTable,
|
||||
Button,
|
||||
Carousel,
|
||||
Frame,
|
||||
}
|
||||
|
||||
export function useMDXComponents(existing: Record<string, ComponentType<any>> = {}) {
|
||||
return { ...components, ...existing }
|
||||
}
|
||||
|
||||
export function MDXProvider({ children }: { children: ReactNode }) {
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Webpack loader that replaces Nextra _meta.ts file content with a
|
||||
* dummy React component default export. This prevents Next.js from
|
||||
* failing during build optimization when it expects all files in
|
||||
* pages/ to export a React component.
|
||||
*
|
||||
* This shim is temporary and will be removed once all pages/ content
|
||||
* is migrated to the Fumadocs app/ directory.
|
||||
*/
|
||||
module.exports = function metaLoader() {
|
||||
return `
|
||||
export default function MetaPlaceholder() { return null; }
|
||||
export const getStaticProps = () => ({ notFound: true });
|
||||
`;
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Nextra compatibility shim for `nextra-theme-docs`.
|
||||
* Provides stub implementations so existing pages/ components
|
||||
* can resolve their imports during the migration to Fumadocs.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface ChildrenProps {
|
||||
children?: ReactNode
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export type DocsThemeConfig = Record<string, any>
|
||||
|
||||
export function useConfig(): any {
|
||||
return {
|
||||
title: '',
|
||||
frontMatter: {},
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function ThemeSwitch(props: any) {
|
||||
return null
|
||||
}
|
||||
|
||||
export function Link({ children, href, ...props }: ChildrenProps & { href?: string }) {
|
||||
return (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -59,15 +59,10 @@ const nonPermanentRedirects = [
|
||||
['/docs/user_guides/plugins', '/docs/features/agents'],
|
||||
['/docs/features/plugins', '/docs/features/agents'],
|
||||
['/docs/configuration/librechat_yaml/setup', '/docs/configuration/librechat_yaml'],
|
||||
['/toolkit/yaml_checker', '/toolkit/yaml-checker'],
|
||||
['/toolkit/creds_generator', '/toolkit/creds-generator'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Nextra compatibility shims - redirect nextra imports to local stubs
|
||||
* so the existing pages/ directory can build during the Fumadocs migration.
|
||||
* These will be removed once all pages/ content is migrated to app/.
|
||||
*/
|
||||
const nextraShims = resolve(process.cwd(), 'lib/nextra-shims');
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const config = {
|
||||
typescript: {
|
||||
@@ -76,15 +71,6 @@ const config = {
|
||||
turbopack: {},
|
||||
pageExtensions: ['mdx', 'md', 'jsx', 'js', 'tsx', 'ts'],
|
||||
webpack(webpackConfig, options) {
|
||||
// Nextra compatibility: redirect nextra imports to local shims
|
||||
webpackConfig.resolve.alias = {
|
||||
...webpackConfig.resolve.alias,
|
||||
'nextra/context': resolve(nextraShims, 'context.tsx'),
|
||||
'nextra/components': resolve(nextraShims, 'components.tsx'),
|
||||
nextra: resolve(nextraShims, 'index.ts'),
|
||||
'nextra-theme-docs': resolve(nextraShims, 'theme-docs.tsx'),
|
||||
};
|
||||
|
||||
/**
|
||||
* Fumadocs MDX loader: only applied to content/ directory files.
|
||||
* These are processed by fumadocs-mdx for the app/ router docs.
|
||||
@@ -105,37 +91,24 @@ const config = {
|
||||
});
|
||||
|
||||
/**
|
||||
* Basic MDX loader for pages/ directory and components/ directory files.
|
||||
* Provides minimal MDX compilation so existing pages/ content
|
||||
* can compile during the migration period.
|
||||
* Uses a custom providerImportSource that provides the same
|
||||
* components Nextra used to auto-inject (Callout, Steps, etc.).
|
||||
* MDX loader for components/ directory files.
|
||||
* These are MDX files imported directly as React components
|
||||
* (e.g. changelog content, repeated sections).
|
||||
*/
|
||||
webpackConfig.module.rules.push({
|
||||
test: /\.mdx?$/,
|
||||
include: [resolve(process.cwd(), 'pages'), resolve(process.cwd(), 'components')],
|
||||
include: [resolve(process.cwd(), 'components')],
|
||||
use: [
|
||||
options.defaultLoaders.babel,
|
||||
{
|
||||
loader: '@mdx-js/loader',
|
||||
options: {
|
||||
providerImportSource: resolve(process.cwd(), 'lib/nextra-shims/mdx-components.tsx'),
|
||||
providerImportSource: resolve(process.cwd(), 'lib/mdx-provider.ts'),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* Replace Nextra _meta files with a dummy React component export
|
||||
* so Next.js doesn't fail when encountering them as pages.
|
||||
*/
|
||||
webpackConfig.module.rules.push({
|
||||
test: /pages[\\/].*_meta\.(ts|js|tsx|jsx)$/,
|
||||
use: {
|
||||
loader: resolve(process.cwd(), 'lib/nextra-shims/meta-loader.cjs'),
|
||||
},
|
||||
});
|
||||
|
||||
return webpackConfig;
|
||||
},
|
||||
transpilePackages: ['react-tweet', 'geist'],
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
"homepage": "https://www.librechat.ai",
|
||||
"dependencies": {
|
||||
"@glidejs/glide": "^3.6.0",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@mdx-js/loader": "^3.1.1",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
@@ -114,7 +114,7 @@
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,jsx}": [
|
||||
"prettier --write",
|
||||
"eslint --fix"
|
||||
"cross-env ESLINT_USE_FLAT_CONFIG=false eslint --fix"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<div className="text-center py-20">
|
||||
|
||||
# 404: Page Not Found
|
||||
|
||||
# 🤷
|
||||
|
||||
[Submit an issue about broken links ↗](https://github.com/LibreChat-AI/librechat.ai/issues/new?title=Found%20broken%20%60%2Fdocs%60%20link.%20Please%20fix!&labels=bug)
|
||||
|
||||
</div>
|
||||
@@ -1,26 +0,0 @@
|
||||
### Author profiles
|
||||
|
||||
- Profiles located in `pages\authors`
|
||||
- create a mdx file named with your authorid
|
||||
- look at the other profiles for examples
|
||||
- Authors Profile pics in: `public\images\people`
|
||||
- Supported socials for authors (react-social-icons):
|
||||

|
||||
|
||||
### Changelogs/Blog Headers example
|
||||
|
||||
⚠️ Title, Screenshot and author is automatically populated in the changelog/blog
|
||||
|
||||
```markdown
|
||||
---
|
||||
date: 2024-04-01
|
||||
title: LibreChat v0.7.0
|
||||
description: The v0.7.0 release of LibreChat
|
||||
authorid: danny
|
||||
ogImage: /images/changelog/2024-04-01-v0.7.0.png
|
||||
---
|
||||
|
||||
import { ChangelogHeader } from "@/components/changelog/ChangelogHeader";
|
||||
|
||||
<ChangelogHeader />
|
||||
```
|
||||
@@ -1,55 +0,0 @@
|
||||
import '../style.css'
|
||||
import '../src/overrides.css'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { GeistSans } from 'geist/font/sans'
|
||||
import { GeistMono } from 'geist/font/mono'
|
||||
import { Analytics } from '@vercel/analytics/react'
|
||||
import { SpeedInsights } from '@vercel/speed-insights/next'
|
||||
import { Hubspot, hsPageView } from '@/components/analytics/hubspot'
|
||||
import { CrispWidget } from '@/components/supportChat'
|
||||
import { Banner } from '@/components/Banner'
|
||||
import type { AppProps } from 'next/app'
|
||||
import type PostHogType from 'posthog-js'
|
||||
type PostHog = typeof PostHogType
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
const router = useRouter()
|
||||
const posthogRef = useRef<PostHog | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
|
||||
import('posthog-js').then(({ default: ph }) => {
|
||||
ph.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
|
||||
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://eu.posthog.com',
|
||||
loaded: (posthog) => {
|
||||
if (process.env.NODE_ENV === 'development') posthog.debug()
|
||||
},
|
||||
})
|
||||
posthogRef.current = ph
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleRouteChange = (path: string) => {
|
||||
posthogRef.current?.capture('$pageview')
|
||||
hsPageView(path)
|
||||
}
|
||||
router.events.on('routeChangeComplete', handleRouteChange)
|
||||
return () => {
|
||||
router.events.off('routeChangeComplete', handleRouteChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={`${GeistSans.variable} font-sans ${GeistMono.variable} font-mono`}>
|
||||
<Banner storageKey="clickhouse-announcement" />
|
||||
<Component {...pageProps} />
|
||||
<Analytics />
|
||||
<SpeedInsights />
|
||||
{process.env.NEXT_PUBLIC_CRISP_WEBSITE_ID ? <CrispWidget /> : null}
|
||||
<Hubspot />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Html, Head, Main, NextScript } from 'next/document'
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head />
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
export default {
|
||||
index: {
|
||||
type: 'page',
|
||||
title: 'LibreChat',
|
||||
display: 'hidden',
|
||||
theme: {
|
||||
layout: 'raw',
|
||||
},
|
||||
},
|
||||
demo: {
|
||||
type: 'page',
|
||||
title: 'Demo',
|
||||
href: 'https://chat.librechat.ai/',
|
||||
},
|
||||
tos: {
|
||||
title: 'Terms of Service',
|
||||
type: 'page',
|
||||
display: 'hidden',
|
||||
},
|
||||
privacy: {
|
||||
title: 'Privacy Policy',
|
||||
type: 'page',
|
||||
display: 'hidden',
|
||||
},
|
||||
cookie: {
|
||||
title: 'Cookie Policy',
|
||||
type: 'page',
|
||||
display: 'hidden',
|
||||
},
|
||||
about: {
|
||||
title: 'About us',
|
||||
type: 'page',
|
||||
display: 'hidden',
|
||||
theme: {
|
||||
timestamp: false,
|
||||
},
|
||||
},
|
||||
features: {
|
||||
title: 'Features',
|
||||
type: 'page',
|
||||
display: 'hidden',
|
||||
theme: {
|
||||
timestamp: false,
|
||||
},
|
||||
},
|
||||
subscribe: {
|
||||
title: 'Subscribe',
|
||||
type: 'page',
|
||||
display: 'hidden',
|
||||
},
|
||||
unsubscribe: {
|
||||
title: 'Unsubscribe',
|
||||
type: 'page',
|
||||
display: 'hidden',
|
||||
},
|
||||
'404': {
|
||||
type: 'page',
|
||||
theme: {
|
||||
typesetting: 'article',
|
||||
timestamp: false,
|
||||
},
|
||||
},
|
||||
toolkit: 'ToolKit',
|
||||
README: {
|
||||
title: 'readme.md',
|
||||
type: 'page',
|
||||
display: 'hidden',
|
||||
},
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
https://chat.librechat.ai/
|
||||
|
||||
place holder document used so the demo entry in the top menu doesn't look selected at all time
|
||||
@@ -1,7 +0,0 @@
|
||||
<div className="text-center py-20">
|
||||
|
||||
import SubscribeForm from '@/components/Newsletter/SubscribeForm'
|
||||
|
||||
<SubscribeForm />
|
||||
|
||||
</div>
|
||||
@@ -1,5 +0,0 @@
|
||||
export default {
|
||||
index: 'Intro',
|
||||
creds_generator: 'Credentials Generator',
|
||||
yaml_checker: 'YAML Validator',
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import CredsGenerator from '@/components/tools/CredentialsGeneratorBox'
|
||||
|
||||
<CredsGenerator />
|
||||
@@ -1,18 +0,0 @@
|
||||
# ToolKit
|
||||
|
||||
## 🔐 Credentials Generator
|
||||
First up is the 'Credentials Generator,' a handy tool that effortlessly generates secure crypto keys and JWT secrets.
|
||||
These keys are crucial for securing sensitive environment variables like `CREDS_KEY`, `CREDS_IV`, `JWT_SECRET`, and `JWT_REFRESH_SECRET`.
|
||||
|
||||
<Cards>
|
||||
<Cards.Card icon="🔐  " title="Credentials Generator" href="/toolkit/creds_generator" />
|
||||
</Cards>
|
||||
|
||||
|
||||
## 🔍 YAML Validator (beta)
|
||||
Additionally, we present the beta version of our YAML Validator, designed to validate both your librechat.yaml and docker-compose.override.yml files, ensuring smooth sailing as you configure your LibreChat environment.
|
||||
|
||||
<Cards>
|
||||
<Cards.Card icon="🔍  " title="YAML Validator" href="/toolkit/yaml_checker" />
|
||||
</Cards>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import YAMLChecker from '@/components/tools/yamlChecker'
|
||||
|
||||
This tool is currently in its beta testing phase. If you encounter any issues or have any doubts, please validate your results using https://yamlchecker.com/.
|
||||
|
||||
---
|
||||
|
||||
<YAMLChecker />
|
||||
@@ -1,7 +0,0 @@
|
||||
<div className="text-center py-20">
|
||||
|
||||
import UnsubscribeForm from '@/components/Newsletter/UnsubscribeForm'
|
||||
|
||||
<UnsubscribeForm />
|
||||
|
||||
</div>
|
||||
18
style.css
18
style.css
@@ -80,21 +80,3 @@
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
|
||||
|
||||
.error-marker {
|
||||
position: absolute;
|
||||
background-color: rgba(255, 0, 0, 0.3);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.custom-btn {
|
||||
background-color: rgba(150, 150, 150, 0.2); /* transparent gray background */
|
||||
border-radius: 10px; /* rounded corners */
|
||||
padding: 8px 16px; /* optional: adjust padding as needed */
|
||||
border: none; /* optional: remove border */
|
||||
cursor: pointer; /* optional: change cursor on hover */
|
||||
}
|
||||
|
||||
.custom-btn:hover {
|
||||
background-color: rgba(150, 150, 150, 0.4); /* darker background on hover */
|
||||
}
|
||||
|
||||
118
theme.config.tsx
118
theme.config.tsx
@@ -1,118 +0,0 @@
|
||||
import React from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { GeistSans } from 'geist/font/sans'
|
||||
import { DocsThemeConfig, useConfig, ThemeSwitch } from 'nextra-theme-docs'
|
||||
import { Steps, Tabs, Cards, FileTree, Button } from 'nextra/components'
|
||||
import { Callout } from '@/components/callouts/callout'
|
||||
import Carousel from '@/components/carousel/Carousel'
|
||||
import { Logo } from '@/components/logo'
|
||||
import { Frame } from './components/Frame'
|
||||
import { OptionTable } from 'components/table'
|
||||
import FooterMenu from './components/FooterMenu'
|
||||
|
||||
/** Nextra docs theme configuration. Social card images are chosen based on the current page path (docs, blog, changelog). */
|
||||
const config: DocsThemeConfig = {
|
||||
logo: <Logo />,
|
||||
logoLink: '/',
|
||||
project: {
|
||||
link: 'https://github.com/danny-avila/LibreChat',
|
||||
},
|
||||
chat: {
|
||||
link: 'https://discord.librechat.ai',
|
||||
},
|
||||
search: {
|
||||
placeholder: 'Search...',
|
||||
},
|
||||
navbar: {
|
||||
extraContent: () => {
|
||||
return <>{ThemeSwitch({ lite: true, className: 'button-switch theme-switch' })}</>
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
defaultMenuCollapseLevel: 1,
|
||||
toggleButton: true,
|
||||
},
|
||||
|
||||
editLink: {
|
||||
content: 'Edit this page on GitHub',
|
||||
},
|
||||
toc: {
|
||||
backToTop: true,
|
||||
},
|
||||
docsRepositoryBase: 'https://github.com/LibreChat-AI/librechat.ai/tree/main',
|
||||
footer: {
|
||||
content: <FooterMenu />,
|
||||
},
|
||||
|
||||
head: () => {
|
||||
const { asPath, defaultLocale, locale } = useRouter()
|
||||
const { frontMatter, title: pageTitle } = useConfig()
|
||||
const url = 'https://librechat.ai' + (defaultLocale === locale ? asPath : `/${locale}${asPath}`)
|
||||
|
||||
const description = frontMatter.description ?? ''
|
||||
|
||||
const title = frontMatter.title ?? pageTitle
|
||||
|
||||
// Default frontmatter image based on path
|
||||
const defaultImage = asPath.startsWith('/docs')
|
||||
? '/images/socialcards/default-docs-image.png'
|
||||
: asPath.startsWith('/blog')
|
||||
? '/images/socialcards/default-blog-image.png'
|
||||
: asPath.startsWith('/changelog')
|
||||
? '/images/socialcards/default-changelog-image.png'
|
||||
: '/images/socialcards/default-image.png'
|
||||
|
||||
const image = frontMatter.ogImage
|
||||
? 'https://www.librechat.ai' + frontMatter.ogImage // Use frontmatter image if available
|
||||
: defaultImage // Use default image based on path if frontmatter image is not available
|
||||
|
||||
const video = frontMatter.ogVideo ? 'https://www.librechat.ai' + frontMatter.ogVideo : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>{title}</title>
|
||||
<link rel="canonical" href={url} />
|
||||
<meta property="og:url" content={url} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:site_name" content="LibreChat" />
|
||||
<meta httpEquiv="Content-Language" content="en" />
|
||||
<meta name="title" content={title} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta name="description" content={description} />
|
||||
<meta property="og:description" content={description} />
|
||||
{video && <meta property="og:video" content={video} />}
|
||||
<meta property="og:image" content={image} />
|
||||
<meta property="twitter:image" content={image} />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:site:domain" content="librechat.ai" />
|
||||
<meta name="twitter:url" content="https://librechat.ai" />
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `html { --font-geist-sans: ${GeistSans.style.fontFamily}; }`,
|
||||
}}
|
||||
/>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
|
||||
<meta name="msapplication-TileColor" content="#da532c" />
|
||||
<meta name="theme-color" content="#080808" />
|
||||
</>
|
||||
)
|
||||
},
|
||||
|
||||
components: {
|
||||
Frame,
|
||||
Tabs,
|
||||
Steps,
|
||||
Cards,
|
||||
FileTree,
|
||||
Callout,
|
||||
Button,
|
||||
Carousel,
|
||||
OptionTable,
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
Reference in New Issue
Block a user