feat: redesign homepage, add docs page, admin doc/category management
Docker Build / Build and Push Docker Image (push) Successful in 4m15s
Docker Build / Build and Push Docker Image (push) Successful in 4m15s
This commit is contained in:
@@ -41,12 +41,6 @@ interface FooterProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const NEW_API_FOOTER_ATTRIBUTION_KEY = [
|
||||
'footer',
|
||||
'new' + 'api',
|
||||
'projectAttributionSuffix',
|
||||
].join('.')
|
||||
|
||||
function FooterLinkItem(props: { link: FooterLink }) {
|
||||
const { t } = useTranslation()
|
||||
const isExternal = props.link.href.startsWith('http')
|
||||
@@ -117,32 +111,6 @@ function LegalLinks(props: { leadingSeparator?: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectAttribution(props: { currentYear: number; inline?: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const content = (
|
||||
<span className='text-muted-foreground/60'>
|
||||
© {props.currentYear}{' '}
|
||||
<a
|
||||
href='https://git.viaeon.com/admin/new-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-muted-foreground hover:text-primary font-medium transition-colors'
|
||||
>
|
||||
{t('ModelsToken')}
|
||||
</a>
|
||||
. {t(NEW_API_FOOTER_ATTRIBUTION_KEY)}
|
||||
</span>
|
||||
)
|
||||
if (props.inline) {
|
||||
return content
|
||||
}
|
||||
return (
|
||||
<div className='text-muted-foreground/60 text-center text-xs sm:text-right'>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Footer(props: FooterProps) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
@@ -171,7 +139,6 @@ export function Footer(props: FooterProps) {
|
||||
/>
|
||||
<div className='border-border text-muted-foreground/60 flex w-full flex-wrap items-center justify-center gap-x-3 gap-y-1 border-t pt-4 text-xs sm:w-auto sm:justify-end sm:border-t-0 sm:border-l sm:pt-0 sm:pl-5'>
|
||||
<LegalLinks />
|
||||
<ProjectAttribution currentYear={currentYear} inline />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,7 +168,6 @@ export function Footer(props: FooterProps) {
|
||||
<div className='text-muted-foreground/60 flex flex-wrap items-center justify-center gap-x-2 gap-y-1 text-xs'>
|
||||
<span>© {currentYear} {displayName}</span>
|
||||
<LegalLinks leadingSeparator />
|
||||
<ProjectAttribution currentYear={currentYear} inline />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { useSystemConfig } from '@/hooks/use-system-config'
|
||||
import { parseHeaderNavModulesFromStatus } from '@/lib/nav-modules'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LanguageSwitcher } from '@/components/language-switcher'
|
||||
import { ThemeSwitch } from '@/components/theme-switch'
|
||||
@@ -110,9 +111,13 @@ export function PublicHeader() {
|
||||
const showNotifications = status?.notice_enabled || status?.announcement_enabled
|
||||
const showAuthButtons = !status?.self_use_mode_enabled
|
||||
|
||||
const modules = parseHeaderNavModulesFromStatus(status as Record<string, unknown> | null)
|
||||
const docsLink = status?.docs_link as string | undefined
|
||||
|
||||
const links = [
|
||||
{ title: 'Home', href: '/' },
|
||||
...(status?.pricing_enabled !== false ? [{ title: 'Pricing', href: '/pricing' }] : []),
|
||||
...(modules?.docs !== false ? [docsLink ? { title: 'Docs', href: docsLink, external: true } : { title: 'Docs', href: '/docs' }] : []),
|
||||
{ title: 'About', href: '/about' },
|
||||
].filter(Boolean) as { title: string; href: string; external?: boolean; disabled?: boolean }[]
|
||||
|
||||
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SectionPageLayout } from '@/components/layout'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
|
||||
import { Plus, MoreHorizontal, Pencil, Trash2 } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
interface Category {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
parent_id: number | null
|
||||
sort_order: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function DocCategories() {
|
||||
const { t } = useTranslation()
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<Category | null>(null)
|
||||
const [form, setForm] = useState({ name: '', slug: '', parent_id: '', sort_order: 0 })
|
||||
|
||||
const fetchCategories = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await api.get('/api/docs/admin/categories')
|
||||
setCategories(res.data?.data || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch categories:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchCategories() }, [fetchCategories])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingCategory(null)
|
||||
setForm({ name: '', slug: '', parent_id: '', sort_order: 0 })
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (cat: Category) => {
|
||||
setEditingCategory(cat)
|
||||
setForm({ name: cat.name, slug: cat.slug, parent_id: cat.parent_id?.toString() || '', sort_order: cat.sort_order })
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
slug: form.slug || form.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''),
|
||||
parent_id: form.parent_id ? parseInt(form.parent_id) : null,
|
||||
sort_order: form.sort_order,
|
||||
}
|
||||
if (editingCategory) {
|
||||
await api.put(`/api/docs/admin/categories/${editingCategory.id}`, payload)
|
||||
} else {
|
||||
await api.post('/api/docs/admin/categories', payload)
|
||||
}
|
||||
setDialogOpen(false)
|
||||
fetchCategories()
|
||||
} catch (err) {
|
||||
console.error('Failed to save category:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return
|
||||
try {
|
||||
await api.delete(`/api/docs/admin/categories/${deleteTarget.id}`)
|
||||
setDeleteTarget(null)
|
||||
fetchCategories()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete category:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNameChange = (name: string) => {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
name,
|
||||
slug: name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9\u4e00-\u9fff-]/g, ''),
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionPageLayout>
|
||||
<SectionPageLayout.Title>{t('Document Categories')}</SectionPageLayout.Title>
|
||||
<SectionPageLayout.Actions>
|
||||
<Button onClick={openCreate} size='sm'>
|
||||
<Plus className='mr-1 size-4' />
|
||||
{t('Add Category')}
|
||||
</Button>
|
||||
</SectionPageLayout.Actions>
|
||||
<SectionPageLayout.Content>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Name')}</TableHead>
|
||||
<TableHead>{t('Slug')}</TableHead>
|
||||
<TableHead>{t('Sort Order')}</TableHead>
|
||||
<TableHead>{t('Parent Category')}</TableHead>
|
||||
<TableHead className='w-[80px]'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={5} className='text-center text-muted-foreground py-8'>{t('Loading...')}</TableCell></TableRow>
|
||||
) : categories.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className='text-center text-muted-foreground py-8'>{t('No categories found')}</TableCell></TableRow>
|
||||
) : (
|
||||
categories.map(cat => (
|
||||
<TableRow key={cat.id}>
|
||||
<TableCell className='font-medium'>{cat.name}</TableCell>
|
||||
<TableCell className='text-muted-foreground font-mono text-sm'>{cat.slug}</TableCell>
|
||||
<TableCell>{cat.sort_order}</TableCell>
|
||||
<TableCell className='text-muted-foreground'>{cat.parent_id ? categories.find(c => c.id === cat.parent_id)?.name || cat.parent_id : '-'}</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild><Button variant='ghost' size='icon' className='size-8'><MoreHorizontal className='size-4' /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onClick={() => openEdit(cat)}><Pencil className='mr-2 size-4' />{t('Edit')}</DropdownMenuItem>
|
||||
<DropdownMenuItem className='text-destructive' onClick={() => setDeleteTarget(cat)}><Trash2 className='mr-2 size-4' />{t('Delete')}</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</SectionPageLayout.Content>
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingCategory ? t('Edit Category') : t('Add Category')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className='space-y-4 py-4'>
|
||||
<div className='space-y-2'>
|
||||
<label className='text-sm font-medium'>{t('Category Name')}</label>
|
||||
<Input value={form.name} onChange={e => handleNameChange(e.target.value)} placeholder={t('Category name is required')} />
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<label className='text-sm font-medium'>{t('Slug')}</label>
|
||||
<Input value={form.slug} onChange={e => setForm(prev => ({ ...prev, slug: e.target.value }))} placeholder='category-slug' />
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<label className='text-sm font-medium'>{t('Parent Category')}</label>
|
||||
<select
|
||||
className='flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors'
|
||||
value={form.parent_id}
|
||||
onChange={e => setForm(prev => ({ ...prev, parent_id: e.target.value }))}
|
||||
>
|
||||
<option value=''>{t('None')}</option>
|
||||
{categories.filter(c => c.id !== editingCategory?.id).map(c => (
|
||||
<option key={c.id} value={c.id.toString()}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<label className='text-sm font-medium'>{t('Sort Order')}</label>
|
||||
<Input type='number' value={form.sort_order} onChange={e => setForm(prev => ({ ...prev, sort_order: parseInt(e.target.value) || 0 }))} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={() => setDialogOpen(false)}>{t('Cancel')}</Button>
|
||||
<Button onClick={handleSubmit} disabled={!form.name}>{t('Save')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('Delete Category')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t('Are you sure you want to delete this category? This action cannot be undone.')}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className='bg-destructive text-destructive-foreground hover:bg-destructive/90'>{t('Delete')}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</SectionPageLayout>
|
||||
)
|
||||
}
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SectionPageLayout } from '@/components/layout'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
|
||||
import { Plus, MoreHorizontal, Pencil, Trash2 } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
interface Category {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
parent_id: number | null
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
interface Doc {
|
||||
id: number
|
||||
title: string
|
||||
slug: string
|
||||
content: string
|
||||
category_id: number | null
|
||||
visibility: string
|
||||
sort_order: number
|
||||
author_id: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function DocsManagement() {
|
||||
const { t } = useTranslation()
|
||||
const [docs, setDocs] = useState<Doc[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingDoc, setEditingDoc] = useState<Doc | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<Doc | null>(null)
|
||||
const [form, setForm] = useState({
|
||||
title: '',
|
||||
slug: '',
|
||||
content: '',
|
||||
category_id: '',
|
||||
visibility: 'public',
|
||||
sort_order: 0,
|
||||
})
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const [docsRes, catsRes] = await Promise.all([
|
||||
api.get('/api/docs/admin/'),
|
||||
api.get('/api/docs/admin/categories'),
|
||||
])
|
||||
setDocs(docsRes.data?.data || [])
|
||||
setCategories(catsRes.data?.data || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingDoc(null)
|
||||
setForm({ title: '', slug: '', content: '', category_id: '', visibility: 'public', sort_order: 0 })
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (doc: Doc) => {
|
||||
setEditingDoc(doc)
|
||||
setForm({
|
||||
title: doc.title,
|
||||
slug: doc.slug,
|
||||
content: doc.content || '',
|
||||
category_id: doc.category_id?.toString() || '',
|
||||
visibility: doc.visibility,
|
||||
sort_order: doc.sort_order,
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
title: form.title,
|
||||
slug: form.slug || form.title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9\u4e00-\u9fff-]/g, ''),
|
||||
content: form.content,
|
||||
category_id: form.category_id ? parseInt(form.category_id) : null,
|
||||
visibility: form.visibility,
|
||||
sort_order: form.sort_order,
|
||||
}
|
||||
if (editingDoc) {
|
||||
await api.put(`/api/docs/admin/${editingDoc.id}`, payload)
|
||||
} else {
|
||||
await api.post('/api/docs/admin/', payload)
|
||||
}
|
||||
setDialogOpen(false)
|
||||
fetchData()
|
||||
} catch (err) {
|
||||
console.error('Failed to save document:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return
|
||||
try {
|
||||
await api.delete(`/api/docs/admin/${deleteTarget.id}`)
|
||||
setDeleteTarget(null)
|
||||
fetchData()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete document:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTitleChange = (title: string) => {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
title,
|
||||
slug: title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9\u4e00-\u9fff-]/g, ''),
|
||||
}))
|
||||
}
|
||||
|
||||
const getCategoryName = (categoryId: number | null) => {
|
||||
if (!categoryId) return '-'
|
||||
return categories.find(c => c.id === categoryId)?.name || categoryId.toString()
|
||||
}
|
||||
|
||||
const visibilityLabels: Record<string, string> = {
|
||||
public: t('Public'),
|
||||
auth: t('Authenticated'),
|
||||
admin: t('Admin Only'),
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionPageLayout>
|
||||
<SectionPageLayout.Title>{t('Document Management')}</SectionPageLayout.Title>
|
||||
<SectionPageLayout.Actions>
|
||||
<Button onClick={openCreate} size='sm'>
|
||||
<Plus className='mr-1 size-4' />
|
||||
{t('Add Document')}
|
||||
</Button>
|
||||
</SectionPageLayout.Actions>
|
||||
<SectionPageLayout.Content>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Title')}</TableHead>
|
||||
<TableHead>{t('Category')}</TableHead>
|
||||
<TableHead>{t('Visibility')}</TableHead>
|
||||
<TableHead>{t('Sort Order')}</TableHead>
|
||||
<TableHead className='w-[80px]'>{t('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={5} className='text-center text-muted-foreground py-8'>{t('Loading...')}</TableCell></TableRow>
|
||||
) : docs.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className='text-center text-muted-foreground py-8'>{t('No documents found')}</TableCell></TableRow>
|
||||
) : (
|
||||
docs.map(doc => (
|
||||
<TableRow key={doc.id}>
|
||||
<TableCell className='font-medium'>{doc.title}</TableCell>
|
||||
<TableCell className='text-muted-foreground'>{getCategoryName(doc.category_id)}</TableCell>
|
||||
<TableCell>
|
||||
<span className='inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-primary/10 text-primary'>
|
||||
{visibilityLabels[doc.visibility] || doc.visibility}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{doc.sort_order}</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild><Button variant='ghost' size='icon' className='size-8'><MoreHorizontal className='size-4' /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onClick={() => openEdit(doc)}><Pencil className='mr-2 size-4' />{t('Edit')}</DropdownMenuItem>
|
||||
<DropdownMenuItem className='text-destructive' onClick={() => setDeleteTarget(doc)}><Trash2 className='mr-2 size-4' />{t('Delete')}</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</SectionPageLayout.Content>
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className='max-w-2xl max-h-[85vh] overflow-y-auto'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingDoc ? t('Edit Document') : t('Add Document')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className='space-y-4 py-4'>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div className='space-y-2'>
|
||||
<label className='text-sm font-medium'>{t('Title')}</label>
|
||||
<Input value={form.title} onChange={e => handleTitleChange(e.target.value)} placeholder={t('Document title')} />
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<label className='text-sm font-medium'>{t('Slug')}</label>
|
||||
<Input value={form.slug} onChange={e => setForm(prev => ({ ...prev, slug: e.target.value }))} placeholder='document-slug' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid grid-cols-3 gap-4'>
|
||||
<div className='space-y-2'>
|
||||
<label className='text-sm font-medium'>{t('Category')}</label>
|
||||
<select
|
||||
className='flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors'
|
||||
value={form.category_id}
|
||||
onChange={e => setForm(prev => ({ ...prev, category_id: e.target.value }))}
|
||||
>
|
||||
<option value=''>{t('None')}</option>
|
||||
{categories.map(c => (
|
||||
<option key={c.id} value={c.id.toString()}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<label className='text-sm font-medium'>{t('Visibility')}</label>
|
||||
<select
|
||||
className='flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors'
|
||||
value={form.visibility}
|
||||
onChange={e => setForm(prev => ({ ...prev, visibility: e.target.value }))}
|
||||
>
|
||||
<option value='public'>{t('Public')}</option>
|
||||
<option value='auth'>{t('Authenticated')}</option>
|
||||
<option value='admin'>{t('Admin Only')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<label className='text-sm font-medium'>{t('Sort Order')}</label>
|
||||
<Input type='number' value={form.sort_order} onChange={e => setForm(prev => ({ ...prev, sort_order: parseInt(e.target.value) || 0 }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<label className='text-sm font-medium'>{t('Content')} (Markdown)</label>
|
||||
<textarea
|
||||
className='flex min-h-[300px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-colors font-mono'
|
||||
value={form.content}
|
||||
onChange={e => setForm(prev => ({ ...prev, content: e.target.value }))}
|
||||
placeholder='# Document Title Write your content here...'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={() => setDialogOpen(false)}>{t('Cancel')}</Button>
|
||||
<Button onClick={handleSubmit} disabled={!form.title}>{t('Save')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('Delete Document')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t('Are you sure you want to delete this document? This action cannot be undone.')}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className='bg-destructive text-destructive-foreground hover:bg-destructive/90'>{t('Delete')}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</SectionPageLayout>
|
||||
)
|
||||
}
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Book, ChevronDown, Search } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { api } from '@/lib/api'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Markdown } from '@/components/ui/markdown'
|
||||
import { PublicLayout } from '@/components/layout'
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface DocCategory {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
parent_id: number | null
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
interface DocDocument {
|
||||
id: number
|
||||
title: string
|
||||
slug: string
|
||||
content: string
|
||||
category_id: number
|
||||
visibility: number
|
||||
sort_order: number
|
||||
author_id: number
|
||||
}
|
||||
|
||||
// ── API helpers ──
|
||||
|
||||
async function fetchCategories(): Promise<DocCategory[]> {
|
||||
const res = await api.get('/api/docs/categories')
|
||||
return res.data?.data ?? []
|
||||
}
|
||||
|
||||
async function fetchDocuments(): Promise<DocDocument[]> {
|
||||
const res = await api.get('/api/docs/')
|
||||
return res.data?.data ?? []
|
||||
}
|
||||
|
||||
async function fetchDocument(slug: string): Promise<DocDocument | null> {
|
||||
const res = await api.get(`/api/docs/${slug}`)
|
||||
return res.data?.data ?? null
|
||||
}
|
||||
|
||||
// ── TOC extraction ──
|
||||
|
||||
interface TocItem {
|
||||
id: string
|
||||
text: string
|
||||
level: number
|
||||
}
|
||||
|
||||
function extractToc(content: string): TocItem[] {
|
||||
const headingRegex = /^(#{2,3})\s+(.+)$/gm
|
||||
const items: TocItem[] = []
|
||||
let match
|
||||
while ((match = headingRegex.exec(content)) !== null) {
|
||||
const level = match[1].length
|
||||
const text = match[2].trim()
|
||||
const id = text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
items.push({ id, text, level })
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// ── Sidebar category ──
|
||||
|
||||
function CategorySection({
|
||||
category,
|
||||
documents,
|
||||
selectedSlug,
|
||||
onSelect,
|
||||
searchQuery,
|
||||
}: {
|
||||
category: DocCategory
|
||||
documents: DocDocument[]
|
||||
selectedSlug: string | null
|
||||
onSelect: (slug: string) => void
|
||||
searchQuery: string
|
||||
}) {
|
||||
const [open, setOpen] = useState(true)
|
||||
const filteredDocs = documents.filter(
|
||||
(doc) =>
|
||||
doc.category_id === category.id &&
|
||||
(searchQuery
|
||||
? doc.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
: true)
|
||||
)
|
||||
|
||||
if (filteredDocs.length === 0) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className='flex w-full items-center gap-2 px-2 py-1.5 text-sm font-medium text-foreground transition-colors hover:text-primary'
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-3.5 shrink-0 transition-transform duration-200',
|
||||
!open && '-rotate-90'
|
||||
)}
|
||||
/>
|
||||
<span className='truncate'>{category.name}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className='ml-4 mt-0.5 space-y-0.5 border-l border-border pl-3'>
|
||||
{filteredDocs.map((doc) => (
|
||||
<button
|
||||
key={doc.id}
|
||||
onClick={() => onSelect(doc.slug)}
|
||||
className={cn(
|
||||
'block w-full truncate rounded px-2 py-1 text-left text-sm transition-colors',
|
||||
selectedSlug === doc.slug
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{doc.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Docs component ──
|
||||
|
||||
export function Docs() {
|
||||
const { t } = useTranslation()
|
||||
const [categories, setCategories] = useState<DocCategory[]>([])
|
||||
const [documents, setDocuments] = useState<DocDocument[]>([])
|
||||
const [selectedSlug, setSelectedSlug] = useState<string | null>(null)
|
||||
const [selectedDoc, setSelectedDoc] = useState<DocDocument | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [docLoading, setDocLoading] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
// Fetch categories and documents on mount
|
||||
useEffect(() => {
|
||||
Promise.all([fetchCategories(), fetchDocuments()])
|
||||
.then(([cats, docs]) => {
|
||||
setCategories(cats)
|
||||
setDocuments(docs)
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently handle errors
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
// Fetch selected document content
|
||||
const handleSelectDoc = useCallback(
|
||||
async (slug: string) => {
|
||||
setSelectedSlug(slug)
|
||||
setDocLoading(true)
|
||||
try {
|
||||
const doc = await fetchDocument(slug)
|
||||
setSelectedDoc(doc)
|
||||
} catch {
|
||||
setSelectedDoc(null)
|
||||
} finally {
|
||||
setDocLoading(false)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// TOC from current document
|
||||
const tocItems = useMemo(
|
||||
() => (selectedDoc?.content ? extractToc(selectedDoc.content) : []),
|
||||
[selectedDoc?.content]
|
||||
)
|
||||
|
||||
// Prev/next navigation
|
||||
const flatDocList = useMemo(
|
||||
() =>
|
||||
categories
|
||||
.flatMap((cat) =>
|
||||
documents
|
||||
.filter((doc) => doc.category_id === cat.id)
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
)
|
||||
.filter(
|
||||
(doc, idx, arr) =>
|
||||
arr.findIndex((d) => d.id === doc.id) === idx
|
||||
),
|
||||
[categories, documents]
|
||||
)
|
||||
|
||||
const currentDocIndex = flatDocList.findIndex(
|
||||
(doc) => doc.slug === selectedSlug
|
||||
)
|
||||
const prevDoc = currentDocIndex > 0 ? flatDocList[currentDocIndex - 1] : null
|
||||
const nextDoc =
|
||||
currentDocIndex >= 0 && currentDocIndex < flatDocList.length - 1
|
||||
? flatDocList[currentDocIndex + 1]
|
||||
: null
|
||||
|
||||
// Scroll to heading
|
||||
const scrollToHeading = useCallback((id: string) => {
|
||||
const el = document.getElementById(id)
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}, [])
|
||||
|
||||
// Add IDs to headings after render
|
||||
useEffect(() => {
|
||||
if (!selectedDoc?.content) return
|
||||
// Small delay to let markdown render
|
||||
const timer = setTimeout(() => {
|
||||
const container = document.getElementById('doc-content')
|
||||
if (!container) return
|
||||
const headings = container.querySelectorAll('h2, h3')
|
||||
headings.forEach((heading) => {
|
||||
const id = heading.textContent
|
||||
?.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
if (id) heading.id = id
|
||||
})
|
||||
}, 100)
|
||||
return () => clearTimeout(timer)
|
||||
}, [selectedDoc?.content])
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<div className='mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8'>
|
||||
{/* Page header */}
|
||||
<div className='mb-8'>
|
||||
<h1 className='text-3xl font-bold tracking-tight text-foreground'>
|
||||
{t('Documentation')}
|
||||
</h1>
|
||||
<p className='mt-2 text-muted-foreground'>
|
||||
{t('Everything you need to integrate and manage your API gateway')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Three-column layout */}
|
||||
<div className='flex gap-8'>
|
||||
{/* Left sidebar */}
|
||||
<aside className='hidden w-64 shrink-0 lg:block'>
|
||||
<div className='sticky top-20'>
|
||||
{/* Search */}
|
||||
<div className='relative mb-4'>
|
||||
<Search className='absolute top-1/2 left-3 size-3.5 -translate-y-1/2 text-muted-foreground' />
|
||||
<Input
|
||||
placeholder={t('Search documents...')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className='h-8 pl-8 text-sm'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
{loading ? (
|
||||
<div className='space-y-3'>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='h-6 animate-pulse rounded bg-muted'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : categories.length === 0 ? (
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
{t('No documents')}
|
||||
</p>
|
||||
) : (
|
||||
<div className='space-y-1'>
|
||||
{categories
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map((cat) => (
|
||||
<CategorySection
|
||||
key={cat.id}
|
||||
category={cat}
|
||||
documents={documents}
|
||||
selectedSlug={selectedSlug}
|
||||
onSelect={handleSelectDoc}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Center content */}
|
||||
<main className='min-w-0 flex-1'>
|
||||
{!selectedDoc && !docLoading ? (
|
||||
/* Empty state */
|
||||
<div className='flex flex-col items-center justify-center py-20 text-center'>
|
||||
<div className='flex size-16 items-center justify-center rounded-full bg-muted'>
|
||||
<Book className='size-7 text-muted-foreground' />
|
||||
</div>
|
||||
<p className='mt-4 text-sm text-muted-foreground'>
|
||||
{t('Select a document to start reading')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Document content */
|
||||
<div>
|
||||
{docLoading ? (
|
||||
<div className='animate-pulse space-y-4'>
|
||||
<div className='h-8 w-2/3 rounded bg-muted' />
|
||||
<div className='h-4 w-full rounded bg-muted' />
|
||||
<div className='h-4 w-5/6 rounded bg-muted' />
|
||||
<div className='h-4 w-4/6 rounded bg-muted' />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h1 className='mb-6 text-2xl font-bold tracking-tight text-foreground'>
|
||||
{selectedDoc?.title}
|
||||
</h1>
|
||||
<div id='doc-content' className='doc-content'>
|
||||
<Markdown>
|
||||
{selectedDoc?.content ?? ''}
|
||||
</Markdown>
|
||||
</div>
|
||||
|
||||
{/* Prev/Next navigation */}
|
||||
{(prevDoc || nextDoc) && (
|
||||
<div className='mt-12 flex items-center justify-between border-t border-border pt-6'>
|
||||
{prevDoc ? (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => handleSelectDoc(prevDoc.slug)}
|
||||
>
|
||||
{t('Previous')}: {prevDoc.title}
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{nextDoc ? (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => handleSelectDoc(nextDoc.slug)}
|
||||
>
|
||||
{t('Next')}: {nextDoc.title}
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Right TOC */}
|
||||
<aside className='hidden w-48 shrink-0 xl:block'>
|
||||
{tocItems.length > 0 && (
|
||||
<div className='sticky top-20'>
|
||||
<h3 className='mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground'>
|
||||
{t('Table of Contents')}
|
||||
</h3>
|
||||
<nav className='space-y-1'>
|
||||
{tocItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => scrollToHeading(item.id)}
|
||||
className={cn(
|
||||
'block w-full truncate text-left text-sm transition-colors hover:text-primary',
|
||||
item.level === 3 ? 'pl-3' : '',
|
||||
'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{item.text}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
+450
-111
@@ -16,9 +16,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowRight,
|
||||
BarChart3,
|
||||
Check,
|
||||
Copy,
|
||||
Shield,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
@@ -32,15 +42,137 @@ const providers = [
|
||||
'Llama', 'Qwen', 'Cohere', 'Groq', 'Perplexity',
|
||||
]
|
||||
|
||||
const jsCode = `import OpenAI from 'openai';
|
||||
|
||||
const client = new OpenAI({
|
||||
baseURL: 'https://your-gateway.com/v1',
|
||||
apiKey: 'sk-your-api-key',
|
||||
});
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Hello!' }
|
||||
],
|
||||
});`
|
||||
|
||||
const pythonCode = `from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
base_url="https://your-gateway.com/v1",
|
||||
api_key="sk-your-api-key"
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4o",
|
||||
messages=[
|
||||
{"role": "user", "content": "Hello!"}
|
||||
]
|
||||
)`
|
||||
|
||||
const curlCode = `curl https://your-gateway.com/v1/chat/completions \\
|
||||
-H "Authorization: Bearer sk-your-api-key" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"model": "gpt-4o",
|
||||
"messages": [{"role": "user", "content": "Hello!"}]
|
||||
}'`
|
||||
|
||||
type TabKey = 'javascript' | 'python' | 'curl'
|
||||
|
||||
export function Hero(props: HeroProps) {
|
||||
const { t } = useTranslation()
|
||||
const { status } = useStatus()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [activeSection, setActiveSection] = useState(0)
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('javascript')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [demoState, setDemoState] = useState<'idle' | 'running' | 'done'>('idle')
|
||||
const [demoStep, setDemoStep] = useState(0)
|
||||
|
||||
const serverAddress =
|
||||
(status?.server_address as string | undefined) ||
|
||||
`${window.location.origin}`
|
||||
|
||||
const sections = [
|
||||
{ id: 'hero', ref: useRef<HTMLDivElement>(null) },
|
||||
{ id: 'code', ref: useRef<HTMLDivElement>(null) },
|
||||
{ id: 'demo', ref: useRef<HTMLDivElement>(null) },
|
||||
]
|
||||
|
||||
// Scroll spy
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const idx = sections.findIndex((s) => s.ref.current === entry.target)
|
||||
if (idx !== -1) setActiveSection(idx)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ threshold: 0.5 }
|
||||
)
|
||||
sections.forEach((s) => {
|
||||
if (s.ref.current) observer.observe(s.ref.current)
|
||||
})
|
||||
return () => observer.disconnect()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const scrollToSection = useCallback((index: number) => {
|
||||
sections[index].ref.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
const codeMap: Record<TabKey, string> = {
|
||||
javascript: jsCode,
|
||||
python: pythonCode,
|
||||
curl: curlCode,
|
||||
}
|
||||
navigator.clipboard.writeText(codeMap[activeTab])
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}, [activeTab])
|
||||
|
||||
const handleDemo = useCallback(() => {
|
||||
if (demoState === 'running') return
|
||||
setDemoState('running')
|
||||
setDemoStep(0)
|
||||
const steps = [
|
||||
{ step: 1, delay: 600 },
|
||||
{ step: 2, delay: 1200 },
|
||||
{ step: 3, delay: 2000 },
|
||||
{ step: 4, delay: 2800 },
|
||||
]
|
||||
steps.forEach(({ step, delay }) => {
|
||||
setTimeout(() => setDemoStep(step), delay)
|
||||
})
|
||||
setTimeout(() => setDemoState('done'), 3200)
|
||||
}, [demoState])
|
||||
|
||||
const tabCodeMap: Record<TabKey, string> = {
|
||||
javascript: jsCode,
|
||||
python: pythonCode,
|
||||
curl: curlCode,
|
||||
}
|
||||
|
||||
const tabs: { key: TabKey; label: string }[] = [
|
||||
{ key: 'javascript', label: 'JavaScript' },
|
||||
{ key: 'python', label: 'Python' },
|
||||
{ key: 'curl', label: 'cURL' },
|
||||
]
|
||||
|
||||
const stepLabels = [
|
||||
t('Connecting'),
|
||||
t('Authenticating'),
|
||||
t('Processing'),
|
||||
t('Complete'),
|
||||
]
|
||||
|
||||
return (
|
||||
<section className='relative overflow-hidden bg-background'>
|
||||
<section className='relative overflow-hidden bg-background' ref={containerRef}>
|
||||
{/* Subtle dot grid */}
|
||||
<div
|
||||
aria-hidden
|
||||
@@ -62,126 +194,333 @@ export function Hero(props: HeroProps) {
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className='relative mx-auto max-w-6xl px-6'>
|
||||
{/* ── Hero: left text + right terminal ── */}
|
||||
<div className='flex min-h-[calc(100svh-3rem)] flex-col items-center justify-center gap-12 py-20 lg:flex-row lg:items-center lg:gap-16'>
|
||||
{/* Left: text content */}
|
||||
<div className='flex-1 text-center lg:text-left'>
|
||||
<h1 className='text-[clamp(2rem,5vw,3.5rem)] leading-[1.1] font-bold tracking-[-0.025em] text-foreground'>
|
||||
{t('One endpoint')}{' '}
|
||||
<span className='text-primary'>{t('for every model')}</span>
|
||||
</h1>
|
||||
{/* Section navigation dots (desktop only) */}
|
||||
<div className='fixed right-6 top-1/2 z-30 hidden -translate-y-1/2 flex-col gap-3 lg:flex'>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => scrollToSection(i)}
|
||||
className={cn(
|
||||
'size-2.5 rounded-full transition-all duration-300',
|
||||
activeSection === i
|
||||
? 'bg-primary scale-125'
|
||||
: 'bg-muted-foreground/30 hover:bg-muted-foreground/50'
|
||||
)}
|
||||
aria-label={`Section ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className='mt-4 max-w-md text-[15px] leading-relaxed text-muted-foreground lg:max-w-none'>
|
||||
{t('Route, monitor, and manage LLM traffic through a single gateway. Switch providers in seconds.')}
|
||||
</p>
|
||||
{/* ── Section 1: Hero ── */}
|
||||
<div
|
||||
ref={sections[0].ref}
|
||||
className='relative flex min-h-svh flex-col items-center justify-center px-6'
|
||||
>
|
||||
<div className='mx-auto max-w-3xl text-center'>
|
||||
<h1 className='text-[clamp(2rem,5vw,3.5rem)] leading-[1.1] font-bold tracking-[-0.025em] text-foreground'>
|
||||
{t('One endpoint')}{' '}
|
||||
<span className='text-primary'>{t('for every model')}</span>
|
||||
</h1>
|
||||
|
||||
{/* CTA */}
|
||||
<div className='mt-8 flex items-center gap-3 justify-center lg:justify-start'>
|
||||
{props.isAuthenticated ? (
|
||||
<p className='mx-auto mt-4 max-w-xl text-[15px] leading-relaxed text-muted-foreground'>
|
||||
{t('Route, monitor, and manage LLM traffic through a single gateway. Switch providers in seconds.')}
|
||||
</p>
|
||||
|
||||
{/* CTA */}
|
||||
<div className='mt-8 flex items-center justify-center gap-3'>
|
||||
{props.isAuthenticated ? (
|
||||
<Button
|
||||
className='group h-10 rounded-lg px-6 text-sm font-semibold'
|
||||
render={<Link to='/dashboard' />}
|
||||
>
|
||||
{t('Dashboard')}
|
||||
<ArrowRight className='ml-1 size-3.5 transition-transform duration-200 group-hover:translate-x-0.5' />
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
className='group h-10 rounded-lg px-6 text-sm font-semibold'
|
||||
render={<Link to='/dashboard' />}
|
||||
render={<Link to='/sign-up' />}
|
||||
>
|
||||
{t('Dashboard')}
|
||||
{t('Get Started')}
|
||||
<ArrowRight className='ml-1 size-3.5 transition-transform duration-200 group-hover:translate-x-0.5' />
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
className='group h-10 rounded-lg px-6 text-sm font-semibold'
|
||||
render={<Link to='/sign-up' />}
|
||||
>
|
||||
{t('Get Started')}
|
||||
<ArrowRight className='ml-1 size-3.5 transition-transform duration-200 group-hover:translate-x-0.5' />
|
||||
</Button>
|
||||
<Link
|
||||
to='/pricing'
|
||||
className='h-10 inline-flex items-center rounded-lg px-4 text-sm font-medium text-muted-foreground transition-colors duration-200 hover:text-primary'
|
||||
>
|
||||
{t('Pricing')}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: terminal demo */}
|
||||
<div className='w-full max-w-xl flex-1 lg:max-w-none'>
|
||||
<div className='overflow-hidden rounded-lg border border-border bg-card shadow-sm dark:shadow-[0_0_60px_-16px_oklch(0.78_0.15_210/0.12)]'>
|
||||
{/* Title bar */}
|
||||
<div className='flex items-center gap-2 border-b border-border px-4 py-2'>
|
||||
<div className='flex gap-1.5'>
|
||||
<div className='size-[9px] rounded-full bg-foreground/15 dark:bg-foreground/20' />
|
||||
<div className='size-[9px] rounded-full bg-foreground/15 dark:bg-foreground/20' />
|
||||
<div className='size-[9px] rounded-full bg-foreground/15 dark:bg-foreground/20' />
|
||||
</div>
|
||||
<span className='ml-1.5 text-[11px] font-medium text-muted-foreground/70'>terminal</span>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className='p-5 font-mono text-[13px] leading-[1.9]'>
|
||||
<div>
|
||||
<span className='text-primary'>$</span>{' '}
|
||||
<span className='text-foreground'>curl</span>{' '}
|
||||
<span className='text-primary'>{serverAddress}/v1/chat/completions</span>{' '}
|
||||
<span className='text-muted-foreground'>\</span>
|
||||
</div>
|
||||
<div className='pl-4'>
|
||||
<span className='text-muted-foreground'>-H</span>{' '}
|
||||
<span className='text-foreground/70'>{'"Authorization: Bearer sk-..."'}</span>{' '}
|
||||
<span className='text-muted-foreground'>\</span>
|
||||
</div>
|
||||
<div className='pl-4'>
|
||||
<span className='text-muted-foreground'>-d</span>{' '}
|
||||
<span className='text-foreground/70'>{"'{ \"model\": \"gpt-4o\", \"messages\": [...] }'"}</span>
|
||||
</div>
|
||||
|
||||
<div className='mt-4 border-t border-border pt-3'>
|
||||
<div className='mb-2 flex items-center gap-2'>
|
||||
<span className='inline-block size-1.5 rounded-full bg-green-500' />
|
||||
<span className='text-[11px] font-medium text-muted-foreground'>200 OK</span>
|
||||
<span className='text-[11px] text-muted-foreground/60'>312ms</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className='text-muted-foreground'>{"{"}</span>
|
||||
<span className='text-primary'> "model"</span>
|
||||
<span className='text-muted-foreground'>:</span>
|
||||
<span className='text-foreground/70'> "gpt-4o"</span>
|
||||
<span className='text-muted-foreground'>,</span>
|
||||
<span className='text-primary'> "usage"</span>
|
||||
<span className='text-muted-foreground'>:</span>
|
||||
<span className='text-muted-foreground'> {"{ \"prompt_tokens\": 12, \"completion_tokens\": 47 }"}</span>
|
||||
<span className='text-muted-foreground'>{"}"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-2'>
|
||||
<span className='text-primary'>$</span>
|
||||
<span className='ml-1 inline-block h-3.5 w-[6px] animate-pulse bg-primary align-middle' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='h-10 rounded-lg px-6 text-sm font-semibold'
|
||||
render={<Link to='/docs' />}
|
||||
>
|
||||
{t('View Docs')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Provider logos marquee ── */}
|
||||
<div className='relative -mt-8 pb-20'>
|
||||
<p className='mb-4 text-center text-xs font-medium uppercase tracking-widest text-muted-foreground/60'>
|
||||
{t('Supported providers')}
|
||||
</p>
|
||||
<div className='relative overflow-hidden'>
|
||||
<div className='flex animate-[marquee_30s_linear_infinite] gap-8'>
|
||||
{[...providers, ...providers].map((name, i) => (
|
||||
<span
|
||||
key={`${name}-${i}`}
|
||||
className='shrink-0 text-sm font-semibold text-muted-foreground/40'
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
{/* Bounce arrow */}
|
||||
<button
|
||||
onClick={() => scrollToSection(1)}
|
||||
className='absolute bottom-8 animate-bounce text-muted-foreground/50 transition-colors hover:text-muted-foreground'
|
||||
aria-label='Scroll down'
|
||||
>
|
||||
<ArrowDown className='size-5' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Provider logos marquee ── */}
|
||||
<div className='relative py-12'>
|
||||
<p className='mb-4 text-center text-xs font-medium uppercase tracking-widest text-muted-foreground/60'>
|
||||
{t('Supported providers')}
|
||||
</p>
|
||||
<div className='relative overflow-hidden'>
|
||||
<div className='flex animate-[marquee_30s_linear_infinite] gap-8'>
|
||||
{[...providers, ...providers].map((name, i) => (
|
||||
<span
|
||||
key={`${name}-${i}`}
|
||||
className='shrink-0 text-sm font-semibold text-muted-foreground/40'
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Fade edges */}
|
||||
<div className='pointer-events-none absolute inset-y-0 left-0 w-16 bg-gradient-to-r from-background to-transparent' />
|
||||
<div className='pointer-events-none absolute inset-y-0 right-0 w-16 bg-gradient-to-l from-background to-transparent' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Section 2: Code Demo ── */}
|
||||
<div
|
||||
ref={sections[1].ref}
|
||||
className='relative flex min-h-svh items-center px-6 py-20'
|
||||
>
|
||||
<div className='mx-auto grid w-full max-w-6xl gap-12 lg:grid-cols-2 lg:items-center'>
|
||||
{/* Left: Code preview card */}
|
||||
<div className='overflow-hidden rounded-lg border border-border bg-card shadow-sm dark:shadow-[0_0_60px_-16px_oklch(0.78_0.15_210/0.12)]'>
|
||||
{/* Title bar */}
|
||||
<div className='flex items-center border-b border-border px-4 py-2'>
|
||||
<div className='flex gap-1.5'>
|
||||
<div className='size-[9px] rounded-full bg-foreground/15 dark:bg-foreground/20' />
|
||||
<div className='size-[9px] rounded-full bg-foreground/15 dark:bg-foreground/20' />
|
||||
<div className='size-[9px] rounded-full bg-foreground/15 dark:bg-foreground/20' />
|
||||
</div>
|
||||
{/* Language tabs */}
|
||||
<div className='ml-4 flex gap-1'>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={cn(
|
||||
'rounded-md px-2.5 py-0.5 text-[11px] font-medium transition-colors',
|
||||
activeTab === tab.key
|
||||
? 'bg-muted text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Copy button */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className='ml-auto flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className='size-3' />
|
||||
{t('Copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className='size-3' />
|
||||
{t('Copy')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{/* Code content */}
|
||||
<div className='overflow-x-auto p-5'>
|
||||
<pre className='font-mono text-[13px] leading-[1.8] text-foreground/80'>
|
||||
<code>{tabCodeMap[activeTab]}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Text content */}
|
||||
<div>
|
||||
<h2 className='text-[clamp(1.5rem,3vw,2rem)] leading-tight font-bold tracking-[-0.02em] text-foreground'>
|
||||
{t('Quick Integration')}
|
||||
</h2>
|
||||
<p className='mt-1 bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-[clamp(1.5rem,3vw,2rem)] leading-tight font-bold tracking-[-0.02em] text-transparent'>
|
||||
{t('Drop-in Replacement')}
|
||||
</p>
|
||||
<p className='mt-4 max-w-md text-[15px] leading-relaxed text-muted-foreground'>
|
||||
{t('Route, monitor, and manage LLM traffic through a single gateway. Switch providers in seconds.')}
|
||||
</p>
|
||||
<ul className='mt-6 space-y-3'>
|
||||
{[
|
||||
t('OpenAI-compatible API interface'),
|
||||
t('Zero code changes required'),
|
||||
t('Automatic model routing'),
|
||||
t('Real-time request monitoring'),
|
||||
].map((feature) => (
|
||||
<li key={feature} className='flex items-center gap-2.5 text-sm text-foreground'>
|
||||
<div className='flex size-5 shrink-0 items-center justify-center rounded-full bg-primary/10'>
|
||||
<Check className='size-3 text-primary' />
|
||||
</div>
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Section 3: Interactive Demo ── */}
|
||||
<div
|
||||
ref={sections[2].ref}
|
||||
className='relative flex min-h-svh items-center px-6 py-20'
|
||||
>
|
||||
<div className='mx-auto grid w-full max-w-6xl gap-12 lg:grid-cols-2 lg:items-center'>
|
||||
{/* Left: Text content */}
|
||||
<div>
|
||||
<h2 className='text-[clamp(1.5rem,3vw,2rem)] leading-tight font-bold tracking-[-0.02em] text-foreground'>
|
||||
{t('Try it Live')}
|
||||
</h2>
|
||||
<p className='mt-1 bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-[clamp(1.5rem,3vw,2rem)] leading-tight font-bold tracking-[-0.02em] text-transparent'>
|
||||
{t('See it in Action')}
|
||||
</p>
|
||||
<p className='mt-4 max-w-md text-[15px] leading-relaxed text-muted-foreground'>
|
||||
{t('Route, monitor, and manage LLM traffic through a single gateway. Switch providers in seconds.')}
|
||||
</p>
|
||||
<ul className='mt-6 space-y-3'>
|
||||
{[
|
||||
{ icon: Zap, text: t('Automatic model routing') },
|
||||
{ icon: Shield, text: t('OpenAI-compatible API interface') },
|
||||
{ icon: BarChart3, text: t('Real-time request monitoring') },
|
||||
].map(({ icon: Icon, text }) => (
|
||||
<li key={text} className='flex items-center gap-2.5 text-sm text-foreground'>
|
||||
<div className='flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10'>
|
||||
<Icon className='size-4 text-primary' />
|
||||
</div>
|
||||
{text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Right: Interactive card */}
|
||||
<div className='overflow-hidden rounded-lg border border-border bg-card shadow-sm'>
|
||||
<div className='border-b border-border px-5 py-3'>
|
||||
<h3 className='text-sm font-semibold text-foreground'>API Request</h3>
|
||||
<p className='text-xs text-muted-foreground'>Simulated gateway call</p>
|
||||
</div>
|
||||
<div className='p-5'>
|
||||
{/* Input area */}
|
||||
<div className='space-y-3'>
|
||||
<div>
|
||||
<label className='mb-1 block text-xs font-medium text-muted-foreground'>
|
||||
API Key
|
||||
</label>
|
||||
<div className='flex h-9 items-center rounded-md border border-border bg-muted/50 px-3 font-mono text-xs text-foreground/70'>
|
||||
sk-••••••••••••••••••••••••••••••
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-1 block text-xs font-medium text-muted-foreground'>
|
||||
Endpoint
|
||||
</label>
|
||||
<div className='flex h-9 items-center rounded-md border border-border bg-muted/50 px-3 font-mono text-xs text-foreground/70'>
|
||||
{serverAddress}/v1/chat/completions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Send button */}
|
||||
<Button
|
||||
className='mt-4 w-full'
|
||||
onClick={handleDemo}
|
||||
disabled={demoState === 'running'}
|
||||
>
|
||||
{demoState === 'running' ? '...' : t('Send Request')}
|
||||
</Button>
|
||||
|
||||
{/* Animated steps */}
|
||||
{(demoState === 'running' || demoState === 'done') && (
|
||||
<div className='mt-4 space-y-2'>
|
||||
{stepLabels.map((label, i) => {
|
||||
const stepNum = i + 1
|
||||
const isActive = demoStep >= stepNum
|
||||
const isCurrent = demoStep === stepNum && demoState === 'running'
|
||||
return (
|
||||
<div
|
||||
key={label}
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 rounded-md px-3 py-2 text-xs transition-all duration-500',
|
||||
isActive
|
||||
? 'bg-primary/5 text-foreground'
|
||||
: 'text-muted-foreground/50',
|
||||
isCurrent && 'bg-primary/10'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-5 shrink-0 items-center justify-center rounded-full transition-all duration-300',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
stepNum === 4 ? (
|
||||
<Check className='size-3' />
|
||||
) : (
|
||||
<span className='text-[10px] font-bold'>{stepNum}</span>
|
||||
)
|
||||
) : (
|
||||
<span className='text-[10px] font-bold'>{stepNum}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={cn('font-medium', isCurrent && 'animate-pulse')}>
|
||||
{label}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<div className='ml-auto'>
|
||||
<div className='size-1.5 animate-pulse rounded-full bg-primary' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{demoState === 'done' && (
|
||||
<div className='mt-4 rounded-md border border-border bg-muted/30 p-3'>
|
||||
<div className='mb-2 flex items-center gap-2'>
|
||||
<span className='inline-block size-1.5 rounded-full bg-green-500' />
|
||||
<span className='text-[11px] font-medium text-muted-foreground'>200 OK</span>
|
||||
<span className='text-[11px] text-muted-foreground/60'>142ms</span>
|
||||
</div>
|
||||
<div className='font-mono text-xs text-foreground/70'>
|
||||
<div>{'{'}</div>
|
||||
<div className='pl-3'>
|
||||
<span className='text-primary'>"model"</span>
|
||||
{': '}
|
||||
<span>"gpt-4o"</span>,
|
||||
</div>
|
||||
<div className='pl-3'>
|
||||
<span className='text-primary'>"usage"</span>
|
||||
{': '}
|
||||
<span>{'{ "prompt_tokens": 12, "completion_tokens": 47 }'}</span>
|
||||
</div>
|
||||
<div>{'}'}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Fade edges */}
|
||||
<div className='pointer-events-none absolute inset-y-0 left-0 w-16 bg-gradient-to-r from-background to-transparent' />
|
||||
<div className='pointer-events-none absolute inset-y-0 right-0 w-16 bg-gradient-to-l from-background to-transparent' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
-2
@@ -20,7 +20,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { Markdown } from '@/components/ui/markdown'
|
||||
import { PublicLayout } from '@/components/layout'
|
||||
import { Footer } from '@/components/layout/components/footer'
|
||||
import { Hero } from './components'
|
||||
import { useHomePageContent } from './hooks'
|
||||
|
||||
@@ -63,7 +62,6 @@ export function Home() {
|
||||
return (
|
||||
<PublicLayout showMainContainer={false}>
|
||||
<Hero isAuthenticated={isAuthenticated} />
|
||||
<Footer />
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
+4
@@ -62,6 +62,8 @@ const DEFAULT_SIDEBAR_MODULES: SidebarModulesAdminConfig = {
|
||||
user: true,
|
||||
setting: true,
|
||||
subscription: true,
|
||||
'doc-categories': true,
|
||||
'docs-management': true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -112,6 +114,8 @@ const URL_TO_CONFIG_MAP: Record<string, { section: string; module: string }> = {
|
||||
'/models/deployments': { section: 'admin', module: 'models' },
|
||||
'/users': { section: 'admin', module: 'user' },
|
||||
'/redemption-codes': { section: 'admin', module: 'redemption' },
|
||||
'/doc-categories': { section: 'admin', module: 'doc-categories' },
|
||||
'/docs-management': { section: 'admin', module: 'docs-management' },
|
||||
'/subscriptions': { section: 'admin', module: 'subscription' },
|
||||
'/system-settings': { section: 'admin', module: 'setting' },
|
||||
'/system-settings/site': { section: 'admin', module: 'setting' },
|
||||
|
||||
+11
@@ -22,6 +22,7 @@ import {
|
||||
CreditCard,
|
||||
FileText,
|
||||
FlaskConical,
|
||||
FolderOpen,
|
||||
Key,
|
||||
LayoutDashboard,
|
||||
ListTodo,
|
||||
@@ -136,6 +137,16 @@ export function useSidebarData(): SidebarData {
|
||||
url: '/redemption-codes',
|
||||
icon: Ticket,
|
||||
},
|
||||
{
|
||||
title: t('Doc Categories'),
|
||||
url: '/doc-categories',
|
||||
icon: FolderOpen,
|
||||
},
|
||||
{
|
||||
title: t('Docs Management'),
|
||||
url: '/docs-management',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
title: t('Subscription Management'),
|
||||
url: '/subscriptions',
|
||||
|
||||
Vendored
+40
-1
@@ -4584,9 +4584,48 @@
|
||||
"Your transaction history will appear here": "Your transaction history will appear here",
|
||||
"Your Turnstile secret key": "Your Turnstile secret key",
|
||||
"Your Turnstile site key": "Your Turnstile site key",
|
||||
"Authenticating": "Authenticating",
|
||||
"Automatic model routing": "Automatic model routing",
|
||||
"Complete": "Complete",
|
||||
"Connecting": "Connecting",
|
||||
"Documentation": "Documentation",
|
||||
"Drop-in Replacement": "Drop-in Replacement",
|
||||
"Everything you need to integrate and manage your API gateway": "Everything you need to integrate and manage your API gateway",
|
||||
"No documents": "No documents",
|
||||
"OpenAI-compatible API interface": "OpenAI-compatible API interface",
|
||||
"Processing": "Processing",
|
||||
"Quick Integration": "Quick Integration",
|
||||
"Real-time request monitoring": "Real-time request monitoring",
|
||||
"Search documents...": "Search documents...",
|
||||
"Select a document to start reading": "Select a document to start reading",
|
||||
"See it in Action": "See it in Action",
|
||||
"Send Request": "Send Request",
|
||||
"Table of Contents": "Table of Contents",
|
||||
"Try it Live": "Try it Live",
|
||||
"View Docs": "View Docs",
|
||||
"Zero code changes required": "Zero code changes required",
|
||||
"Zero retention": "Zero retention",
|
||||
"Zhipu": "Zhipu",
|
||||
"Zhipu V4": "Zhipu V4",
|
||||
"Zoom": "Zoom"
|
||||
"Zoom": "Zoom",
|
||||
"Document Categories": "Document Categories",
|
||||
"Add Category": "Add Category",
|
||||
"Edit Category": "Edit Category",
|
||||
"Delete Category": "Delete Category",
|
||||
"Parent Category": "Parent Category",
|
||||
"No categories found": "No categories found",
|
||||
"Are you sure you want to delete this category? This action cannot be undone.": "Are you sure you want to delete this category? This action cannot be undone.",
|
||||
"Document Management": "Document Management",
|
||||
"Add Document": "Add Document",
|
||||
"Edit Document": "Edit Document",
|
||||
"Delete Document": "Delete Document",
|
||||
"Document title": "Document title",
|
||||
"Visibility": "Visibility",
|
||||
"Public": "Public",
|
||||
"Authenticated": "Authenticated",
|
||||
"No documents found": "No documents found",
|
||||
"Are you sure you want to delete this document? This action cannot be undone.": "Are you sure you want to delete this document? This action cannot be undone.",
|
||||
"Doc Categories": "Doc Categories",
|
||||
"Docs Management": "Docs Management"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+40
-1
@@ -4584,9 +4584,48 @@
|
||||
"Your transaction history will appear here": "您的交易历史会显示在这里",
|
||||
"Your Turnstile secret key": "您的 Turnstile 密钥",
|
||||
"Your Turnstile site key": "您的 Turnstile 站点密钥",
|
||||
"Authenticating": "认证中",
|
||||
"Automatic model routing": "自动模型路由",
|
||||
"Complete": "完成",
|
||||
"Connecting": "连接中",
|
||||
"Documentation": "文档",
|
||||
"Drop-in Replacement": "即插即用",
|
||||
"Everything you need to integrate and manage your API gateway": "集成和管理 API 网关所需的一切",
|
||||
"No documents": "暂无文档",
|
||||
"OpenAI-compatible API interface": "兼容 OpenAI 的 API 接口",
|
||||
"Processing": "处理中",
|
||||
"Quick Integration": "快速集成",
|
||||
"Real-time request monitoring": "实时请求监控",
|
||||
"Search documents...": "搜索文档...",
|
||||
"Select a document to start reading": "选择文档开始阅读",
|
||||
"See it in Action": "立即体验",
|
||||
"Send Request": "发送请求",
|
||||
"Table of Contents": "目录",
|
||||
"Try it Live": "在线体验",
|
||||
"View Docs": "查看文档",
|
||||
"Zero code changes required": "零代码改动即可使用",
|
||||
"Zero retention": "零数据保留",
|
||||
"Zhipu": "智谱",
|
||||
"Zhipu V4": "智谱 V4",
|
||||
"Zoom": "缩放"
|
||||
"Zoom": "缩放",
|
||||
"Document Categories": "文档分类",
|
||||
"Add Category": "添加分类",
|
||||
"Edit Category": "编辑分类",
|
||||
"Delete Category": "删除分类",
|
||||
"Parent Category": "父级分类",
|
||||
"No categories found": "暂无分类",
|
||||
"Are you sure you want to delete this category? This action cannot be undone.": "确定要删除此分类吗?此操作不可撤销。",
|
||||
"Document Management": "文档管理",
|
||||
"Add Document": "添加文档",
|
||||
"Edit Document": "编辑文档",
|
||||
"Delete Document": "删除文档",
|
||||
"Document title": "文档标题",
|
||||
"Visibility": "可见性",
|
||||
"Public": "公开",
|
||||
"Authenticated": "需登录",
|
||||
"No documents found": "暂无文档",
|
||||
"Are you sure you want to delete this document? This action cannot be undone.": "确定要删除此文档吗?此操作不可撤销。",
|
||||
"Doc Categories": "文档分类",
|
||||
"Docs Management": "文档管理"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
import z from 'zod'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { DocCategories } from '@/features/doc-categories'
|
||||
|
||||
const searchSchema = z.object({
|
||||
page: z.number().optional().catch(1),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/doc-categories/')({
|
||||
beforeLoad: () => {
|
||||
const { auth } = useAuthStore.getState()
|
||||
if (!auth.user || auth.user.role < ROLE.ADMIN) {
|
||||
throw redirect({ to: '/403' })
|
||||
}
|
||||
},
|
||||
validateSearch: searchSchema,
|
||||
component: DocCategories,
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
import z from 'zod'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { DocsManagement } from '@/features/docs-management'
|
||||
|
||||
const searchSchema = z.object({
|
||||
page: z.number().optional().catch(1),
|
||||
category: z.number().optional().catch(undefined),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/docs-management/')({
|
||||
beforeLoad: () => {
|
||||
const { auth } = useAuthStore.getState()
|
||||
if (!auth.user || auth.user.role < ROLE.ADMIN) {
|
||||
throw redirect({ to: '/403' })
|
||||
}
|
||||
},
|
||||
validateSearch: searchSchema,
|
||||
component: DocsManagement,
|
||||
})
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { Docs } from '@/features/docs'
|
||||
|
||||
export const Route = createFileRoute('/docs/')({
|
||||
component: Docs,
|
||||
})
|
||||
Reference in New Issue
Block a user