290 lines
12 KiB
TypeScript
Vendored
290 lines
12 KiB
TypeScript
Vendored
/*
|
|
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/'),
|
|
api.get('/api/docs/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>
|
|
)
|
|
}
|