diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..2183fe4 --- /dev/null +++ b/.env.template @@ -0,0 +1,20 @@ +# Analytics and feedback +SLACK_WEBHOOK_URL= +LOOPS_API_KEY= +NEXT_PUBLIC_POSTHOG_HOST= +NEXT_PUBLIC_POSTHOG_KEY= + +# Crisp +EXT_PUBLIC_CRISP_WEBSITE_ID= +NEXT_PUBLIC_CRISP_WEBSITE_ID= + +# For qa chatbot +OPENAI_API_KEY= +SUPABASE_URL= +SUPABASE_SERVICE_ROLE_KEY= +NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY= +LANGFUSE_PUBLIC_KEY= +LANGFUSE_SECRET_KEY= + +# Newsletter Subscribers +MONGODB_URI= \ No newline at end of file diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..f86e55c --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,241 @@ +const TAILWIND_CONFIG = { + extends: ['plugin:tailwindcss/recommended'], + rules: { + 'tailwindcss/classnames-order': 'off', // conflicts with prettier-plugin-tailwindcss + 'tailwindcss/enforces-negative-arbitrary-values': 'error', + 'tailwindcss/enforces-shorthand': 'error', + 'tailwindcss/migration-from-tailwind-2': 'error', + 'tailwindcss/no-custom-classname': 'error', + }, +} + +/** @type {import('eslint').Linter.Config} */ +module.exports = { + root: true, + reportUnusedDisableDirectives: true, + ignorePatterns: ['next-env.d.ts'], + overrides: [ + // Rules for all files + { + files: '**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts}', + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:import/typescript', + 'prettier', + ], + plugins: ['import', 'unicorn'], + rules: { + 'prefer-object-has-own': 'error', + 'logical-assignment-operators': ['error', 'always', { enforceForIfStatements: true }], + 'no-else-return': ['error', { allowElseIf: false }], + 'no-lonely-if': 'error', + 'prefer-destructuring': ['error', { VariableDeclarator: { object: true } }], + 'import/no-duplicates': 'error', + 'no-negated-condition': 'off', + 'unicorn/no-negated-condition': 'error', + 'prefer-regex-literals': ['error', { disallowRedundantWrapping: true }], + 'object-shorthand': ['error', 'always'], + 'unicorn/prefer-regexp-test': 'error', + 'unicorn/no-array-for-each': 'error', + 'unicorn/prefer-string-replace-all': 'error', + '@typescript-eslint/prefer-for-of': 'error', + "no-sharp-comments": "off", + "markdown/no-sharp-comments": "off", + // todo: enable + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-var-requires': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + }, + }, + // Rules for React files + { + files: '{packages,examples,docs}/**', + extends: [ + 'plugin:react/recommended', + 'plugin:react/jsx-runtime', + 'plugin:react-hooks/recommended', + 'plugin:@next/next/recommended', + ], + rules: { + 'react/prop-types': 'off', + 'react/no-unknown-property': ['error', { ignore: ['jsx'] }], + 'react-hooks/exhaustive-deps': 'error', + 'react/self-closing-comp': 'error', + 'no-restricted-syntax': [ + 'error', + { + // ❌ useMemo(…, []) + selector: + 'CallExpression[callee.name=useMemo][arguments.1.type=ArrayExpression][arguments.1.elements.length=0]', + message: + "`useMemo` with an empty dependency array can't provide a stable reference, use `useRef` instead.", + }, + { + // ❌ z.object(…) + selector: 'MemberExpression[object.name=z] > .property[name=object]', + message: 'Use z.strictObject is more safe.', + }, + ], + 'react/jsx-filename-extension': [ + 'error', + { extensions: ['.tsx', '.jsx'], allow: 'as-needed' }, + ], + 'react/jsx-curly-brace-presence': 'error', + 'react/jsx-boolean-value': 'error', + }, + settings: { + react: { version: 'detect' }, + }, + }, + // Rules for TypeScript files + { + files: '**/*.{ts,tsx,cts,mts}', + extends: [ + // TODO: fix errors + // 'plugin:@typescript-eslint/recommended-requiring-type-checking' + ], + parserOptions: { + project: ['tsconfig.json'], + tsconfigRootDir: './' + }, + rules: { + '@typescript-eslint/no-unnecessary-type-assertion': 'error', + // '@typescript-eslint/consistent-type-imports': 'error', + '@typescript-eslint/non-nullable-type-assertion-style': 'error', + '@typescript-eslint/prefer-optional-chain': 'error', + }, + }, + // ⚙️ nextra-theme-docs + { + ...TAILWIND_CONFIG, + files: 'packages/nextra-theme-docs/**', + plugins: ['typescript-sort-keys'], + settings: { + tailwindcss: { + config: 'packages/nextra-theme-docs/tailwind.config.js', + callees: ['cn'], + whitelist: [ + 'nextra-breadcrumb', + 'nextra-bleed', + 'nextra-menu-desktop', + 'nextra-menu-mobile', + ], + }, + }, + rules: { + ...TAILWIND_CONFIG.rules, + 'no-restricted-imports': [ + 'error', + { + name: 'next/link', + message: 'Use local instead', + }, + ], + }, + }, + // ⚙️ nextra-theme-blog + { + ...TAILWIND_CONFIG, + files: 'packages/nextra-theme-blog/**', + settings: { + tailwindcss: { + config: 'packages/nextra-theme-blog/tailwind.config.js', + whitelist: ['subheading-', 'post-item', 'post-item-more'], + }, + }, + }, + // ⚙️ nextra + { + ...TAILWIND_CONFIG, + files: 'packages/nextra/**', + settings: { + tailwindcss: { + config: 'packages/nextra-theme-docs/tailwind.config.js', + callees: ['cn'], + whitelist: ['nextra-code-block', 'nextra-filetree'], + }, + }, + }, + // ⚙️ Docs + { + ...TAILWIND_CONFIG, + files: 'docs/**', + settings: { + tailwindcss: { + config: 'docs/tailwind.config.js', + callees: ['cn'], + whitelist: ['dash-ring', 'theme-1', 'theme-2', 'theme-3', 'theme-4'], + }, + next: { rootDir: 'docs' }, + }, + }, + // ⚙️ SWR-site example + { + ...TAILWIND_CONFIG, + files: 'examples/swr-site/**', + settings: { + tailwindcss: { + config: 'examples/swr-site/tailwind.config.js', + }, + next: { rootDir: 'examples/swr-site' }, + }, + }, + // ⚙️ blog example + { + files: 'examples/blog/**', + settings: { + next: { rootDir: 'examples/blog' }, + }, + }, + // ⚙️ docs example + { + files: 'examples/docs/**', + settings: { + next: { rootDir: 'examples/docs' }, + }, + }, + { + files: [ + 'prettier.config.js', + 'postcss.config.js', + 'tailwind.config.js', + 'next.config.js', + '.eslintrc.cjs', + ], + env: { + node: true, + }, + }, + { + files: 'packages/{nextra,nextra-theme-docs,nextra-theme-blog}/**', + rules: { + // disable rule because we don't have pagesDir in above folders + '@next/next/no-html-link-for-pages': 'off', + }, + }, + { + files: 'packages/nextra/src/**', + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['fs', 'node:fs'], + message: 'Use `graceful-fs` instead', + }, + ], + }, + ], + }, + }, + { + files: ['**/*.d.ts'], + rules: { + 'no-var': 'off', + }, + }, + ], +} diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..bd8b79f --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,67 @@ +# EXAMPLE +# Source: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners +# This is a comment. +# Each line is a file pattern followed by one or more owners. + +# These owners will be the default owners for everything in +# the repo. Unless a later match takes precedence, +# @global-owner1 and @global-owner2 will be requested for +# review when someone opens a pull request. +* @global-owner1 @global-owner2 + +# Order is important; the last matching pattern takes the most +# precedence. When someone opens a pull request that only +# modifies JS files, only @js-owner and not the global +# owner(s) will be requested for a review. +*.js @js-owner #This is an inline comment. + +# You can also use email addresses if you prefer. They'll be +# used to look up users just like we do for commit author +# emails. +*.go docs@example.com + +# Teams can be specified as code owners as well. Teams should +# be identified in the format @org/team-name. Teams must have +# explicit write access to the repository. In this example, +# the octocats team in the octo-org organization owns all .txt files. +*.txt @octo-org/octocats + +# In this example, @doctocat owns any files in the build/logs +# directory at the root of the repository and any of its +# subdirectories. +/build/logs/ @doctocat + +# The `docs/*` pattern will match files like +# `docs/getting-started.md` but not further nested files like +# `docs/build-app/troubleshooting.md`. +docs/* docs@example.com + +# In this example, @octocat owns any file in an apps directory +# anywhere in your repository. +apps/ @octocat + +# In this example, @doctocat owns any file in the `/docs` +# directory in the root of your repository and any of its +# subdirectories. +/docs/ @doctocat + +# In this example, any change inside the `/scripts` directory +# will require approval from @doctocat or @octocat. +/scripts/ @doctocat @octocat + +# In this example, @octocat owns any file in a `/logs` directory such as +# `/build/logs`, `/scripts/logs`, and `/deeply/nested/logs`. Any changes +# in a `/logs` directory will require approval from @octocat. +**/logs @octocat + +# In this example, @octocat owns any file in the `/apps` +# directory in the root of your repository except for the `/apps/github` +# subdirectory, as its owners are left empty. +/apps/ @octocat +/apps/github + +# In this example, @octocat owns any file in the `/apps` +# directory in the root of your repository except for the `/apps/github` +# subdirectory, as this subdirectory has its own owner @doctocat +/apps/ @octocat +/apps/github @doctocat \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d252f8b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,29 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: npm + directory: '/' # Location of package manifests + rebase-strategy: 'disabled' # use dependabot-rebase-stale + schedule: + interval: 'daily' + commit-message: + prefix: chore + prefix-development: chore + include: scope + ignore: + - dependency-name: '@types/node' + groups: + patches: + update-types: + - 'patch' + low-risk: + patterns: + - '@calcom/embed-react' + - '@splinetool/react-spline' + - 'lucide-react' + - 'posthog-js' + - 'react-icons' diff --git a/.github/workflows/nextjs_bundle_analysis.yml b/.github/workflows/nextjs_bundle_analysis.yml new file mode 100644 index 0000000..9eb5e8b --- /dev/null +++ b/.github/workflows/nextjs_bundle_analysis.yml @@ -0,0 +1,130 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +name: 'Next.js Bundle Analysis' + +on: + pull_request: + push: + branches: + - main # change this if your default branch is named differently + workflow_dispatch: + +defaults: + run: + # change this if your nextjs app does not live at the root of the repo + working-directory: ./ + +permissions: + contents: read # for checkout repository + actions: read # for fetching base branch bundle stats + pull-requests: write # for comments + +jobs: + analyze: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Install Node.js + uses: actions/setup-node@v3 + with: + node-version: 18 + + # - name: Install dependencies + # uses: bahmutov/npm-install@v1 + + # If pnpm is used, you need to switch the previous step with the following one. pnpm does not create a package-lock.json + # so the step above will fail to pull dependencies + - uses: pnpm/action-setup@v2 + name: Install pnpm + id: pnpm-install + with: + version: 8 + run_install: true + + # DO NOT CACHE DUE TO NEXTRA CACHING ISSUES + # - name: Restore next build + # uses: actions/cache@v3 + # id: restore-build-cache + # env: + # cache-name: cache-next-build + # with: + # # if you use a custom build directory, replace all instances of `.next` in this file with your build directory + # # ex: if your app builds to `dist`, replace `.next` with `dist` + # path: .next/cache + # # change this if you prefer a more strict cache + # key: ${{ runner.os }}-build-${{ env.cache-name }} + + - name: Build next.js app + # change this if your site requires a custom build command + run: ./node_modules/.bin/next build + + # Here's the first place where next-bundle-analysis' own script is used + # This step pulls the raw bundle stats for the current bundle + - name: Analyze bundle + run: npx -p nextjs-bundle-analysis report + + - name: Upload bundle + uses: actions/upload-artifact@v3 + with: + name: bundle + path: .next/analyze/__bundle_analysis.json + + - name: Download base branch bundle stats + uses: dawidd6/action-download-artifact@v2 + if: success() && github.event.number + with: + workflow: nextjs_bundle_analysis.yml + branch: ${{ github.event.pull_request.base.ref }} + path: .next/analyze/base + + # And here's the second place - this runs after we have both the current and + # base branch bundle stats, and will compare them to determine what changed. + # There are two configurable arguments that come from package.json: + # + # - budget: optional, set a budget (bytes) against which size changes are measured + # it's set to 350kb here by default, as informed by the following piece: + # https://infrequently.org/2021/03/the-performance-inequality-gap/ + # + # - red-status-percentage: sets the percent size increase where you get a red + # status indicator, defaults to 20% + # + # Either of these arguments can be changed or removed by editing the `nextBundleAnalysis` + # entry in your package.json file. + - name: Compare with base branch bundle + if: success() && github.event.number + run: ls -laR .next/analyze/base && npx -p nextjs-bundle-analysis compare + + - name: Get Comment Body + id: get-comment-body + if: success() && github.event.number + # https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#multiline-strings + run: | + echo "body<> $GITHUB_OUTPUT + echo "$(cat .next/analyze/__bundle_analysis_comment.txt)" >> $GITHUB_OUTPUT + echo EOF >> $GITHUB_OUTPUT + + - name: Find Comment + uses: peter-evans/find-comment@v2 + if: success() && github.event.number + id: fc + with: + issue-number: ${{ github.event.number }} + body-includes: '' + + - name: Create Comment + uses: peter-evans/create-or-update-comment@v2 + if: success() && github.event.number && steps.fc.outputs.comment-id == 0 + with: + issue-number: ${{ github.event.number }} + body: ${{ steps.get-comment-body.outputs.body }} + + - name: Update Comment + uses: peter-evans/create-or-update-comment@v2 + if: success() && github.event.number && steps.fc.outputs.comment-id != 0 + with: + issue-number: ${{ github.event.number }} + body: ${{ steps.get-comment-body.outputs.body }} + comment-id: ${{ steps.fc.outputs.comment-id }} + edit-mode: replace diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7265064 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +.next +node_modules +.vercel + +.env.local +.env +.env*.local + +# next sitemap +public/robots.txt +public/sitemap*.xml + +# we use pnpm +package-lock.json +yarn.lock + + +# Mac +.DS_Store + +# eslint +.eslintcache \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..cb2c84d --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm lint-staged diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..6c59086 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +enable-pre-post-scripts=true diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2fb4f8e --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v18.18 \ No newline at end of file diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..0dbc1fb --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,7 @@ +module.exports = { + trailingComma: 'all', + tabWidth: 2, + semi: false, + singleQuote: true, + printWidth: 100, +} diff --git a/LICENSE b/LICENSE index 2ad6eda..49a2249 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 noreplylibrechatai +Copyright (c) 2024 LibreChat Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md new file mode 100644 index 0000000..652ee0a --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# LibreChat Docs + +Based on [Nextra](https://nextra.site/) + +## Local Development + +Pre-requisites: Node.js 18+, pnpm 9+ + +1. Optional: Create env based on [.env.template](./.env.template) +2. Run `pnpm i` to install the dependencies. +3. Run `pnpm dev` to start the development server on localhost:3333 +4. Run `pnpm build` to build... +5. Run `pnpm start` to start the production server on localhost:3333 + +⚠️ **Note: try building prod before making a PR** + +## Bundle analysis + +Run `pnpm run analyze` to analyze the bundle size of the production build using `@next/bundle-analyzer`. diff --git a/components.json b/components.json new file mode 100644 index 0000000..008ff52 --- /dev/null +++ b/components.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "style.css", + "baseColor": "slate", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/components/Author/AuthorPage.tsx b/components/Author/AuthorPage.tsx new file mode 100644 index 0000000..eb8956c --- /dev/null +++ b/components/Author/AuthorPage.tsx @@ -0,0 +1,88 @@ +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?: Record +} + +const AuthorCard: React.FC<{ author: AuthorMetadata }> = ({ author }) => { + const [isClient, setIsClient] = useState(false) + + useEffect(() => { + setIsClient(true) + }, []) + + const socialsEntries = Object.entries(author.socials ?? {}).filter(([, value]) => !!value) + + return ( + +
+
+ {author.name} +
+

{author.name}

+

{author.subtitle}

+
+ {isClient && + socialsEntries.map(([key, value]) => ( + e.stopPropagation()} + > + + + ))} +
+
+ + ) +} + +const AuthorPage: React.FC = () => { + const allAuthors = getPagesUnderRoute('/authors') as Array + + const authors = allAuthors.filter((author) => !!author.frontMatter.authorid) + + return ( +
+

+ Our Authors +

+
+ {authors.map((author) => ( + + ))} +
+
+ ) +} + +export default AuthorPage diff --git a/components/Author/AuthorProfile.tsx b/components/Author/AuthorProfile.tsx new file mode 100644 index 0000000..9a3d429 --- /dev/null +++ b/components/Author/AuthorProfile.tsx @@ -0,0 +1,129 @@ +import React, { 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 { Blog } from '@/components/CardIcons/Blog' +import { OurAuthors } from '@/components/CardIcons/OurAuthors' + +//TODO: Fix Mobile view to better handle more than 4 socials; +//TODO: Better fallback social icon (the default one is the "share" icon) +//TODO: Tag selection on "Recent Posts by" (author pages) +//TODO: fix profile pic position when no bio + +interface AuthorMetadata { + authorid: string + subtitle: string + name: string + bio: string + ogImage: string + socials?: Record // Dynamically match social media platforms +} + +interface AuthorProfileProps { + authorId: string +} + +const AuthorProfile: React.FC = ({ authorId }) => { + const authors = getPagesUnderRoute('/authors') as Array + const author = authors.find((a) => a.frontMatter.authorid === authorId)?.frontMatter + const blogPosts = getPagesUnderRoute('/blog') as Array + + // 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(), + ) + + if (!author) { + return
Author not found!
+ } + + const socialsEntries = Object.entries(author.socials ?? {}).filter(([, value]) => !!value) + + // State to track whether the component is rendered on the client side + const [isClient, setIsClient] = useState(false) + + useEffect(() => { + setIsClient(true) + }, []) + + return ( + <> +
+
+

{author.name}

+

+ {author.subtitle} +

+ {author.bio &&

{author.bio}

} +
+ +
+ {author.name} + +
+ {isClient && + socialsEntries.map(([key, value]) => ( + + + + ))} +
+
+
+
+

Recent Posts by {author.name}

+
+ {sortedAuthorPosts.map((post) => ( + console.log('Tag clicked:', tag)} + selectedTags={undefined} + /> + ))} +
+
+
+ + } image> + {null} + + } image> + {null} + + +
+
+ + ) +} + +export default AuthorProfile diff --git a/components/Author/Authors.tsx b/components/Author/Authors.tsx new file mode 100644 index 0000000..24d2449 --- /dev/null +++ b/components/Author/Authors.tsx @@ -0,0 +1,47 @@ +import Image from 'next/image' +import { getPagesUnderRoute } from 'nextra/context' +import { Page } from 'nextra' + +type AuthorPage = Page & { + frontMatter: { + name: string + ogImage: string + authorid: string + } +} + +export const Author = ({ authorid }: { authorid: string }) => { + const authorPages = getPagesUnderRoute('/authors') + const page = authorPages?.find( + (page) => (page as AuthorPage).frontMatter.authorid === authorid, + ) as AuthorPage + + 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 ( + + ) +} diff --git a/components/Author/AuthorsSmall.tsx b/components/Author/AuthorsSmall.tsx new file mode 100644 index 0000000..2322814 --- /dev/null +++ b/components/Author/AuthorsSmall.tsx @@ -0,0 +1,41 @@ +import Image from 'next/image' +import { getPagesUnderRoute } from 'nextra/context' +import { Page } from 'nextra' + +type AuthorPage = Page & { + frontMatter: { + name: string + ogImage: string + authorid: string + } +} + +export const AuthorSmall = ({ authorid }: { authorid: string }) => { + const authorPages = getPagesUnderRoute('/authors') + const page = authorPages?.find( + (page) => (page as AuthorPage).frontMatter.authorid === authorid, + ) as AuthorPage + + 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 ( +
+
+ {`Picture + {name} +
+
+ ) +} diff --git a/components/CardIcons/AboutLogo.tsx b/components/CardIcons/AboutLogo.tsx new file mode 100644 index 0000000..33dccfa --- /dev/null +++ b/components/CardIcons/AboutLogo.tsx @@ -0,0 +1,36 @@ +import Image from 'next/image' + +export function Logo() { + return ( + <> +
+ {/* Image */} + LibreChat Logo + {/* Text */} + + About LibreChat... + + {/* CSS for hover effect */} + +
+ + ) +} diff --git a/components/CardIcons/Blog.tsx b/components/CardIcons/Blog.tsx new file mode 100644 index 0000000..edbc4fa --- /dev/null +++ b/components/CardIcons/Blog.tsx @@ -0,0 +1,34 @@ +import Image from 'next/image' + +export function Blog() { + return ( + <> +
+ {/* Image */} + Blog Logo + {/* Text */} + Blog + {/* CSS for hover effect */} + +
+ + ) +} diff --git a/components/CardIcons/Changelog.tsx b/components/CardIcons/Changelog.tsx new file mode 100644 index 0000000..d905fcc --- /dev/null +++ b/components/CardIcons/Changelog.tsx @@ -0,0 +1,34 @@ +import Image from 'next/image' + +export function Changelog() { + return ( + <> +
+ {/* Image */} + Changelog Logo + {/* Text */} + Changelog + {/* CSS for hover effect */} + +
+ + ) +} diff --git a/components/CardIcons/OurAuthors.tsx b/components/CardIcons/OurAuthors.tsx new file mode 100644 index 0000000..b169fac --- /dev/null +++ b/components/CardIcons/OurAuthors.tsx @@ -0,0 +1,34 @@ +import Image from 'next/image' + +export function OurAuthors() { + return ( + <> +
+ {/* Image */} + OurAuthors Logo + {/* Text */} + Our Authors + {/* CSS for hover effect */} + +
+ + ) +} diff --git a/components/CardIcons/Roadmap.tsx b/components/CardIcons/Roadmap.tsx new file mode 100644 index 0000000..0c73856 --- /dev/null +++ b/components/CardIcons/Roadmap.tsx @@ -0,0 +1,36 @@ +import Image from 'next/image' + +export function Roadmap() { + return ( + <> +
+ {/* Image */} + Roadmap Logo + {/* Text */} + + 2024 Roadmap + + {/* CSS for hover effect */} + +
+ + ) +} diff --git a/components/CardIcons/ToolKit.tsx b/components/CardIcons/ToolKit.tsx new file mode 100644 index 0000000..a65aed3 --- /dev/null +++ b/components/CardIcons/ToolKit.tsx @@ -0,0 +1,34 @@ +import Image from 'next/image' + +export function ToolKit() { + return ( + <> +
+ {/* Image */} + ToolKit Logo + {/* Text */} + ToolKit + {/* CSS for hover effect */} + +
+ + ) +} diff --git a/components/FooterMenu.tsx b/components/FooterMenu.tsx new file mode 100644 index 0000000..5bcbb4b --- /dev/null +++ b/components/FooterMenu.tsx @@ -0,0 +1,165 @@ +import Link from 'next/link' +import { SocialIcon } from 'react-social-icons' + +const menuItems: { + heading: string + items: { name: string; href: string }[] +}[] = [ + { + heading: 'About', + items: [ + { + name: 'About', + href: '/about', + }, + { name: 'Contact Us', href: '/about#contact-us' }, + ], + }, + { + heading: 'Resources', + items: [ + { + name: 'Changelog', + href: '/changelog', + }, + { + name: 'Roadmap', + href: '/blog/2024-02-19_2024_roadmap', + }, + { + name: 'Demo', + href: 'https://demo.librechat.cfd/', + }, + { + name: 'Status', + href: 'https://status.librechat.ai/', + }, + ], + }, + { + heading: 'Documentation', + items: [ + { + name: 'Get Started', + href: '/docs', + }, + { + name: 'Local Install', + href: '/docs/local', + }, + { + name: 'Remote Install', + href: '/docs/remote', + }, + ], + }, + { + heading: 'Blog', + items: [ + { name: 'Blog', href: '/blog' }, + { name: 'Blog Authors', href: '/authors' }, + ], + }, + { + heading: 'Newsletter', + items: [ + { + name: 'Subscribe', + href: '/subscribe', + }, + { + name: 'Unsubscribe', + href: '/unsubscribe', + }, + ], + }, + { + heading: 'Legal', + items: [ + { + name: 'Terms of services', + href: '/tos', + }, + { + name: 'Privacy policy', + href: '/privacy', + }, + { + name: 'Cookie policy', + href: '/cookie', + }, + ], + }, +] + +const FooterMenu = () => { + return ( +
+
+ {menuItems.map((menu) => ( +
+

{menu.heading}

+
    + {menu.items.map((item) => ( +
  • + + {item.name} + +
  • + ))} +
+
+ ))} +
+
© {new Date().getFullYear()} LibreChat
+
+ + + + + + +
+
+
+
+ ) +} + +export default FooterMenu diff --git a/components/Frame.tsx b/components/Frame.tsx new file mode 100644 index 0000000..2e2f63b --- /dev/null +++ b/components/Frame.tsx @@ -0,0 +1,35 @@ +import { cn } from '@/lib/utils' + +export const Frame = ({ + children, + className, + border = false, + fullWidth = false, + transparent = false, +}: { + children: React.ReactNode + className?: string + border?: boolean + fullWidth?: boolean + transparent?: boolean +}) => ( +
+
*]:mt-0', + fullWidth && 'max-w-full', + transparent && 'bg-transparent', + border && '[&>*]:-mb-1', + )} + > + {children} +
+
+) diff --git a/components/Header.tsx b/components/Header.tsx new file mode 100644 index 0000000..f394873 --- /dev/null +++ b/components/Header.tsx @@ -0,0 +1,42 @@ +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { ArrowRight } from 'lucide-react' +import Link from 'next/link' +import React from 'react' + +export const Header = ({ + title, + description, + className, + button, + h = 'h2', +}: { + title?: React.ReactNode + description?: React.ReactNode + button?: { href: string; text: string } + h?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' + className?: string +}) => { + const TitleTag: React.ElementType = h + return ( +
+ {title && ( + + {title} + + )} + {description && ( +

+ {description} +

+ )} + {button && ( + + )} +
+ ) +} diff --git a/components/LogoContextMenu.tsx b/components/LogoContextMenu.tsx new file mode 100644 index 0000000..94b7f41 --- /dev/null +++ b/components/LogoContextMenu.tsx @@ -0,0 +1,70 @@ +import { + DropdownMenuContent, + DropdownMenuTrigger, + DropdownMenu, + DropdownMenuItem, + DropdownMenuSeparator, +} from './ui/dropdown-menu' +import { Download, ExternalLink } from 'lucide-react' + +const LogoContextMenu: React.FC<{ + open: boolean + setOpen: (open: boolean) => void +}> = ({ open, setOpen }) => { + return ( + + + + { + e.preventDefault() + window.open('/', '_blank') + }} + > + + Open in new tab + + + { + e.preventDefault() + window.open('/librechat.png', '_blank') + }} + > + + Logo (png) + + { + e.preventDefault() + window.open('/librechat.svg', '_blank') + }} + > + + Logo (svg) + + + { + e.preventDefault() + window.open('/librechat_alt.png', '_blank') + }} + > + + Docs Logo (png) + + { + e.preventDefault() + window.open('/librechat_alt.svg', '_blank') + }} + > + + Docs Logo (svg) + + + + ) +} + +export default LogoContextMenu diff --git a/components/Newsletter/SubscribeForm.tsx b/components/Newsletter/SubscribeForm.tsx new file mode 100644 index 0000000..1409702 --- /dev/null +++ b/components/Newsletter/SubscribeForm.tsx @@ -0,0 +1,66 @@ +import React, { useState } from 'react' +import toast, { Toaster } from 'react-hot-toast' +import validator from 'validator' +import style from './newsletterform.module.css' + +const SubscribeForm = () => { + const [email, setEmail] = useState('') + const [isLoading, setIsLoading] = useState(false) + + const handleSubmit = async (e) => { + e.preventDefault() + + if (!validator.isEmail(email)) { + toast.error('Valid email is required') + return + } + + setIsLoading(true) + + try { + const response = await fetch('/api/subscribe', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email }), + }) + + if (response.status === 201) { + toast.success('Subscription successful') + setEmail('') + } else if (response.status === 409) { + toast.error('Email already subscribed') + } else { + toast.error('Subscription failed') + } + } catch (error) { + toast.error('Subscription failed') + } finally { + setIsLoading(false) + } + } + + return ( +
+ +
+

Subscribe to Our Newsletter

+
+ setEmail(e.target.value)} + className={style[`email-input`]} + /> + +
+
+
+ ) +} + +export default SubscribeForm diff --git a/components/Newsletter/UnsubscribeForm.tsx b/components/Newsletter/UnsubscribeForm.tsx new file mode 100644 index 0000000..d8539f7 --- /dev/null +++ b/components/Newsletter/UnsubscribeForm.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react' +import toast, { Toaster } from 'react-hot-toast' +import validator from 'validator' +import style from './newsletterform.module.css' + +const UnsubscribeForm = () => { + const [email, setEmail] = useState('') + const [isLoading, setIsLoading] = useState(false) + + const handleSubmit = async (e) => { + e.preventDefault() + + if (!validator.isEmail(email)) { + toast.error('Invalid email format') + return + } + + setIsLoading(true) + + try { + const response = await fetch('/api/unsubscribe', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email }), + }) + + if (response.status === 200) { + toast.success('Unsubscription successful') + setEmail('') + } else if (response.status === 404) { + toast.error('Subscriber not found') + } else { + toast.error('Unsubscription failed') + } + } catch { + toast.error('Unsubscription failed') + } finally { + setIsLoading(false) + } + } + + return ( +
+ +
+

Unsubscribe From Our Newsletter

+
+ setEmail(e.target.value)} + className={style[`email-input`]} + /> + +
+
+
+ ) +} + +export default UnsubscribeForm diff --git a/components/Newsletter/newsletterform.module.css b/components/Newsletter/newsletterform.module.css new file mode 100644 index 0000000..9548f0e --- /dev/null +++ b/components/Newsletter/newsletterform.module.css @@ -0,0 +1,74 @@ +.container { + text-align: center; +} + +.form-wrapper { + max-width: 400px; + margin: 0 auto; + border-radius: 10px; + overflow: hidden; + background-color: #f1f1f1; +} +:global(.dark) .form-wrapper { + background-color: #171717; +} +.form-title { + padding: 15px; + color:#454545; +} + +:global(.dark) .form-title { + color:#9b9b9b; +} + +.form-container { + padding: 20px; +} + +.email-input { + padding: 8px; + /* border: 1px solid #3e3e3e; */ + border-radius: 5px; + margin-bottom: 10px; + width: 100%; + background-color: #fff; +} + +.email-input::placeholder { + color: #5a5a5a; +} +.email-input::placeholder { + color: #5a5a5a; +} + +:global(.dark) .email-input { + background-color: #29292a; +} + +.subscribe-button { + padding: 8px 16px; + background: linear-gradient(-45deg, #ffa63d, #ff3d77, #338aff, #3cf0c5); + background-size: 600%; + animation: anime 16s linear infinite; + color: rgba(255, 255, 255, 0.9); + border: none; + border-radius: 5px; + cursor: pointer; + width: 100%; +} + +.subscribe-button:hover { + filter: brightness(110%); +} + +@keyframes anime { + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } +} \ No newline at end of file diff --git a/components/Tweet.tsx b/components/Tweet.tsx new file mode 100644 index 0000000..a200ab4 --- /dev/null +++ b/components/Tweet.tsx @@ -0,0 +1,8 @@ +import { cn } from '@/lib/utils' +import { Tweet as ReactTweet } from 'react-tweet' + +export const Tweet = ({ id, className }: { id: string; className?: string }) => ( +
+ +
+) diff --git a/components/Video.tsx b/components/Video.tsx new file mode 100644 index 0000000..e607993 --- /dev/null +++ b/components/Video.tsx @@ -0,0 +1,77 @@ +import { cn } from '@/lib/utils' +import { MediaPlayer, MediaOutlet, useMediaRemote, useMediaStore } from '@vidstack/react' +import { Play } from 'lucide-react' +import { useState, useRef } from 'react' + +export const Video = ({ + src, + poster, + aspectRatio, + className, + gifStyle = false, + title, +}: { + src: string + poster?: string + aspectRatio?: number + gifStyle?: boolean + className?: string + title?: string +}) => { + const [panelDismissed, setPanelDismissed] = useState(false) + const mediaPlayerRef = useRef(null) + const remote = useMediaRemote(mediaPlayerRef) + const { duration } = useMediaStore(mediaPlayerRef) + const durationString = duration + ? `${Math.floor(duration / 60)}:${Math.floor(duration % 60)} min` + : null + + return ( + + {gifStyle ? ( + // Capture mouse events, they broke scrolling on iOS +
+ ) : panelDismissed ? null : ( + // Overlay with play button and poster image +
{ + remote.startLoading() + }} + onClick={() => { + remote.play() + setPanelDismissed(true) + }} + > +
+ +
+
+ + {title && {title}} + {durationString && {durationString}} + +
+
+ )} + + + ) +} diff --git a/components/analytics/hubspot.tsx b/components/analytics/hubspot.tsx new file mode 100644 index 0000000..b007688 --- /dev/null +++ b/components/analytics/hubspot.tsx @@ -0,0 +1,11 @@ +import Script from 'next/script' + +export const Hubspot = () => { + return