feat: redesign homepage, add docs page, admin doc/category management
Docker Build / Build and Push Docker Image (push) Successful in 4m15s

This commit is contained in:
2026-06-14 19:34:55 +08:00
parent 43f9869246
commit 5c4ed6206e
14 changed files with 1565 additions and 149 deletions
-34
View File
@@ -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'>
&copy; {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>&copy; {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
View File
@@ -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
View File
@@ -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&#10;&#10;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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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',
+40 -1
View File
@@ -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"
}
}
+40 -1
View File
@@ -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
View File
@@ -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,
})