diff --git a/app/subscribe/client.tsx b/app/subscribe/client.tsx new file mode 100644 index 0000000..d84cd16 --- /dev/null +++ b/app/subscribe/client.tsx @@ -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 +} diff --git a/app/subscribe/page.tsx b/app/subscribe/page.tsx new file mode 100644 index 0000000..505cc44 --- /dev/null +++ b/app/subscribe/page.tsx @@ -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 ( + +
+ +
+
+ ) +} diff --git a/app/toolkit/creds-generator/page.ts b/app/toolkit/creds-generator/page.ts new file mode 100644 index 0000000..84db8a8 --- /dev/null +++ b/app/toolkit/creds-generator/page.ts @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation' + +export default function CredsGeneratorPage() { + redirect('/docs/toolkit/credentials-generator') +} diff --git a/app/toolkit/page.ts b/app/toolkit/page.ts new file mode 100644 index 0000000..39f112a --- /dev/null +++ b/app/toolkit/page.ts @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation' + +export default function ToolkitPage() { + redirect('/docs/toolkit') +} diff --git a/app/toolkit/yaml-checker/page.ts b/app/toolkit/yaml-checker/page.ts new file mode 100644 index 0000000..9e618fb --- /dev/null +++ b/app/toolkit/yaml-checker/page.ts @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation' + +export default function YamlCheckerPage() { + redirect('/docs/toolkit/yaml-validator') +} diff --git a/app/unsubscribe/client.tsx b/app/unsubscribe/client.tsx new file mode 100644 index 0000000..15ea712 --- /dev/null +++ b/app/unsubscribe/client.tsx @@ -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 +} diff --git a/app/unsubscribe/page.tsx b/app/unsubscribe/page.tsx new file mode 100644 index 0000000..3c465b2 --- /dev/null +++ b/app/unsubscribe/page.tsx @@ -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 ( + +
+ +
+
+ ) +} diff --git a/components/Author/AuthorPage.tsx b/components/Author/AuthorPage.tsx deleted file mode 100644 index adb0862..0000000 --- a/components/Author/AuthorPage.tsx +++ /dev/null @@ -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 ( - -
-
- {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 deleted file mode 100644 index 8c5fb3a..0000000 --- a/components/Author/AuthorProfile.tsx +++ /dev/null @@ -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 // Dynamically match social media platforms - date: string | number | Date -} - -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(), - ) - - // State to track whether the component is rendered on the client side - const [isClient, setIsClient] = useState(false) - - useEffect(() => { - setIsClient(true) - }, []) - - if (!author) { - return
Author not found!
- } - - const socialsEntries = Object.entries(author.socials ?? {}).filter(([, value]) => !!value) - - return ( - <> -
-
-

{author.name}

-

- {author.subtitle} -

- {author.bio &&

{author.bio}

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

Recent Posts by {author.name}

-
- {sortedAuthorPosts.map((post) => ( - {}} - selectedTags={undefined} - /> - ))} -
-
-
- - } image> - {null} - - } image> - {null} - - -
-
- - ) -} - -export default AuthorProfile diff --git a/components/Author/Authors.tsx b/components/Author/Authors.tsx deleted file mode 100644 index 08f7186..0000000 --- a/components/Author/Authors.tsx +++ /dev/null @@ -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 ( - - ) -} diff --git a/components/Author/AuthorsSmall.tsx b/components/Author/AuthorsSmall.tsx deleted file mode 100644 index 8114fad..0000000 --- a/components/Author/AuthorsSmall.tsx +++ /dev/null @@ -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 ( -
-
- {`Picture - {name} -
-
- ) -} diff --git a/components/blog/BlogCard.tsx b/components/blog/BlogCard.tsx deleted file mode 100644 index d14dd86..0000000 --- a/components/blog/BlogCard.tsx +++ /dev/null @@ -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 ( -
-
- {page.frontMatter?.ogVideo ? ( -
-
-
- {page.frontMatter?.tags?.map((tag) => ( - handleTagClick(tag)} - > - {tag} - - ))} -
- {/* Modified title and description to be clickable */} -
-

- {page.meta?.title || page.frontMatter?.title || page.name} -

-
{truncateDescription(page.frontMatter?.description || '')}
-
-
- - {page.frontMatter?.date} -
-
-
- ) -} - -export default BlogCard diff --git a/components/blog/BlogHeader.tsx b/components/blog/BlogHeader.tsx deleted file mode 100644 index b35950a..0000000 --- a/components/blog/BlogHeader.tsx +++ /dev/null @@ -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 ( -
-
-
- {new Date(date).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - timeZone: 'UTC', - })} -
-
-
-

{title}

-
-
-

- {description} -

-
- -
-
- {ogVideo ? ( -
- ) -} diff --git a/components/home/Changelog.tsx b/components/home/Changelog.tsx deleted file mode 100644 index 5618574..0000000 --- a/components/home/Changelog.tsx +++ /dev/null @@ -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 - -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 ( -
-
-

Changelog

{/* Added id to the heading */} -
-
- {changelog.map((activityItem) => ( - -
-
-
- -
-
-
-

- {activityItem.title}{' '} - {activityItem.author ? `by ${activityItem.author}` : null} -

- {activityItem.date ? ( - - ) : null} - - ))} -
- -
-
-
- -
-
-
-

- Read the full changelog ... {null} -

- -
- ) -} - -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', - }) -} diff --git a/components/policies.tsx b/components/policies.tsx deleted file mode 100644 index cc4a733..0000000 --- a/components/policies.tsx +++ /dev/null @@ -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 ( -
-
-

Privacy Policy for LibreChat Documentation

- -
-          {`Effective Date: ${currentDate}
-
-This Privacy Policy outlines how the LibreChat documentation website (`}{' '}
-          https://librechat.ai
-          {`) 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`}{' '}
-          
-            https://github.com/danny-avila/LibreChat
-          
-          {`.
-
-By using this documentation site, you acknowledge that no personal data is collected or processed.`}
-        
-
- -
- ) -} - -const TermsOfServices = () => { - const currentDate = new Date().toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - }) - - return ( -
-
-

- Terms of Service for LibreChat Documentation -

- -
-          {`Effective Date: ${currentDate}
-
-Welcome to the LibreChat documentation website, available at `}{' '}
-          https://librechat.ai
-          {`. 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:`}{' '}
-          
-            https://github.com/danny-avila/LibreChat
-          
-          {`
-
-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:`}{' '}
-          
-            https://github.com/danny-avila/LibreChat
-          
-          {`
-
-By using this documentation site, you agree to these Terms of Service.`}
-        
-
-
- ) -} - -const CookiePolicy = () => { - const currentDate = new Date().toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - }) - - return ( -
-
-

Cookie Policy for LibreChat Documentation

- -
-          {`Effective Date: ${currentDate}
-
-1. No Cookies Used
-
-The LibreChat documentation website (`}{' '}
-          https://librechat.ai
-          {`) 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:`}{' '}
-          
-            https://github.com/danny-avila/LibreChat
-          
-          {`
-
-By using this site, you acknowledge that no cookies or tracking technologies are employed.`}
-        
-
- -
- ) -} - -export { TermsOfServices } -export { PrivacyPolicy } -export { CookiePolicy } diff --git a/components/tools/CredentialsGeneratorBox.tsx b/components/tools/CredentialsGeneratorBox.tsx index 747b0a6..7321f87 100644 --- a/components/tools/CredentialsGeneratorBox.tsx +++ b/components/tools/CredentialsGeneratorBox.tsx @@ -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 + +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(null) + const [copiedKey, setCopiedKey] = useState(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 ( -
-

- Generate Credentials -

-
-
-

CREDS_KEY

-
- - -
-
-
-

CREDS_IV

-
- - -
-
-
-

JWT_SECRET

-
- - -
-
-
-

- JWT_REFRESH_SECRET -

-
- - -
-
-
-

- MEILI_MASTER_KEY -

-
- - -
-
-
- {showTooltip &&
Copied to Clipboard
} +
- + + {credentials && ( + <> +
+ {CREDENTIAL_FIELDS.map((field) => { + const id = `cred-${field.key}` + const isCopied = copiedKey === field.key + return ( +
+
+ + +
+

{field.hint}

+ +
+ ) + })} +
+ +
+ + + {copiedKey === 'all' && 'All 5 credentials copied as KEY=value format'} + +
+ + )} + + {!credentials && ( +
+

+ Click the button above to generate secure random credentials for your{' '} + + .env + {' '} + file. +

+
+ )}
) } - -export default CredsGenerator diff --git a/components/tools/CredentialsGeneratorMDX.tsx b/components/tools/CredentialsGeneratorMDX.tsx new file mode 100644 index 0000000..ac18be6 --- /dev/null +++ b/components/tools/CredentialsGeneratorMDX.tsx @@ -0,0 +1,12 @@ +'use client' + +import dynamic from 'next/dynamic' + +const CredentialsGenerator = dynamic(() => import('@/components/tools/CredentialsGeneratorBox'), { + ssr: false, + loading: () =>
, +}) + +export function CredentialsGeneratorMDX() { + return +} diff --git a/components/tools/YAMLValidatorMDX.tsx b/components/tools/YAMLValidatorMDX.tsx new file mode 100644 index 0000000..9a1f316 --- /dev/null +++ b/components/tools/YAMLValidatorMDX.tsx @@ -0,0 +1,12 @@ +'use client' + +import dynamic from 'next/dynamic' + +const YAMLValidator = dynamic(() => import('@/components/tools/yamlChecker'), { + ssr: false, + loading: () =>
, +}) + +export function YAMLValidatorMDX() { + return +} diff --git a/components/tools/yamlChecker.tsx b/components/tools/yamlChecker.tsx index 34e17fd..5d2592a 100644 --- a/components/tools/yamlChecker.tsx +++ b/components/tools/yamlChecker.tsx @@ -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(null) - const editorRef = useRef(null) + const [isDark, setIsDark] = useState(false) + const [isDragging, setIsDragging] = useState(false) + const editorRef = useRef(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) => { 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 ( -
-

- YAML Validator (beta) -

+
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 && ( +
+
+
+
+ )}
-