From 5c4ed6206e149dcd9f76467e52d2b1bc9f3ed785 Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 14 Jun 2026 19:34:55 +0800 Subject: [PATCH] feat: redesign homepage, add docs page, admin doc/category management --- .../components/layout/components/footer.tsx | 34 -- .../layout/components/public-header.tsx | 5 + .../src/features/doc-categories/index.tsx | 217 +++++++ .../src/features/docs-management/index.tsx | 289 +++++++++ web/default/src/features/docs/index.tsx | 408 +++++++++++++ .../home/components/sections/hero.tsx | 561 ++++++++++++++---- web/default/src/features/home/index.tsx | 2 - web/default/src/hooks/use-sidebar-config.ts | 4 + web/default/src/hooks/use-sidebar-data.ts | 11 + web/default/src/i18n/locales/en.json | 41 +- web/default/src/i18n/locales/zh.json | 41 +- .../_authenticated/doc-categories/index.tsx | 38 ++ .../_authenticated/docs-management/index.tsx | 39 ++ web/default/src/routes/docs/index.tsx | 24 + 14 files changed, 1565 insertions(+), 149 deletions(-) create mode 100644 web/default/src/features/doc-categories/index.tsx create mode 100644 web/default/src/features/docs-management/index.tsx create mode 100644 web/default/src/features/docs/index.tsx create mode 100644 web/default/src/routes/_authenticated/doc-categories/index.tsx create mode 100644 web/default/src/routes/_authenticated/docs-management/index.tsx create mode 100644 web/default/src/routes/docs/index.tsx diff --git a/web/default/src/components/layout/components/footer.tsx b/web/default/src/components/layout/components/footer.tsx index 59308209d..4caa1316b 100644 --- a/web/default/src/components/layout/components/footer.tsx +++ b/web/default/src/components/layout/components/footer.tsx @@ -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 = ( - - © {props.currentYear}{' '} - - {t('ModelsToken')} - - . {t(NEW_API_FOOTER_ATTRIBUTION_KEY)} - - ) - if (props.inline) { - return content - } - return ( -
- {content} -
- ) -} - export function Footer(props: FooterProps) { const { t } = useTranslation() const { @@ -171,7 +139,6 @@ export function Footer(props: FooterProps) { />
-
@@ -201,7 +168,6 @@ export function Footer(props: FooterProps) {
© {currentYear} {displayName} -
diff --git a/web/default/src/components/layout/components/public-header.tsx b/web/default/src/components/layout/components/public-header.tsx index e5052e9ef..4faa9e369 100644 --- a/web/default/src/components/layout/components/public-header.tsx +++ b/web/default/src/components/layout/components/public-header.tsx @@ -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 | 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 }[] diff --git a/web/default/src/features/doc-categories/index.tsx b/web/default/src/features/doc-categories/index.tsx new file mode 100644 index 000000000..558edecd6 --- /dev/null +++ b/web/default/src/features/doc-categories/index.tsx @@ -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 . + +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([]) + const [loading, setLoading] = useState(true) + const [dialogOpen, setDialogOpen] = useState(false) + const [editingCategory, setEditingCategory] = useState(null) + const [deleteTarget, setDeleteTarget] = useState(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 ( + + {t('Document Categories')} + + + + + + + + {t('Name')} + {t('Slug')} + {t('Sort Order')} + {t('Parent Category')} + {t('Actions')} + + + + {loading ? ( + {t('Loading...')} + ) : categories.length === 0 ? ( + {t('No categories found')} + ) : ( + categories.map(cat => ( + + {cat.name} + {cat.slug} + {cat.sort_order} + {cat.parent_id ? categories.find(c => c.id === cat.parent_id)?.name || cat.parent_id : '-'} + + + + + openEdit(cat)}>{t('Edit')} + setDeleteTarget(cat)}>{t('Delete')} + + + + + )) + )} + +
+
+ + {/* Create/Edit Dialog */} + + + + {editingCategory ? t('Edit Category') : t('Add Category')} + +
+
+ + handleNameChange(e.target.value)} placeholder={t('Category name is required')} /> +
+
+ + setForm(prev => ({ ...prev, slug: e.target.value }))} placeholder='category-slug' /> +
+
+ + +
+
+ + setForm(prev => ({ ...prev, sort_order: parseInt(e.target.value) || 0 }))} /> +
+
+ + + + +
+
+ + {/* Delete Confirmation */} + setDeleteTarget(null)}> + + + {t('Delete Category')} + {t('Are you sure you want to delete this category? This action cannot be undone.')} + + + {t('Cancel')} + {t('Delete')} + + + +
+ ) +} diff --git a/web/default/src/features/docs-management/index.tsx b/web/default/src/features/docs-management/index.tsx new file mode 100644 index 000000000..ae9408d42 --- /dev/null +++ b/web/default/src/features/docs-management/index.tsx @@ -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 . + +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([]) + const [categories, setCategories] = useState([]) + const [loading, setLoading] = useState(true) + const [dialogOpen, setDialogOpen] = useState(false) + const [editingDoc, setEditingDoc] = useState(null) + const [deleteTarget, setDeleteTarget] = useState(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 = { + public: t('Public'), + auth: t('Authenticated'), + admin: t('Admin Only'), + } + + return ( + + {t('Document Management')} + + + + + + + + {t('Title')} + {t('Category')} + {t('Visibility')} + {t('Sort Order')} + {t('Actions')} + + + + {loading ? ( + {t('Loading...')} + ) : docs.length === 0 ? ( + {t('No documents found')} + ) : ( + docs.map(doc => ( + + {doc.title} + {getCategoryName(doc.category_id)} + + + {visibilityLabels[doc.visibility] || doc.visibility} + + + {doc.sort_order} + + + + + openEdit(doc)}>{t('Edit')} + setDeleteTarget(doc)}>{t('Delete')} + + + + + )) + )} + +
+
+ + {/* Create/Edit Dialog */} + + + + {editingDoc ? t('Edit Document') : t('Add Document')} + +
+
+
+ + handleTitleChange(e.target.value)} placeholder={t('Document title')} /> +
+
+ + setForm(prev => ({ ...prev, slug: e.target.value }))} placeholder='document-slug' /> +
+
+
+
+ + +
+
+ + +
+
+ + setForm(prev => ({ ...prev, sort_order: parseInt(e.target.value) || 0 }))} /> +
+
+
+ +