601 lines
27 KiB
TypeScript
601 lines
27 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import { useRouter, useSearchParams } from "next/navigation"
|
|
import { useQuery, useMutation, gql } from '@apollo/client'
|
|
import { EnhancedSimpleEditor } from '@/components/tiptap-templates/simple/enhanced-simple-editor'
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Textarea } from "@/components/ui/textarea"
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
import { Label } from "@/components/ui/label"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { IconLoader, IconDeviceFloppy, IconEye, IconArrowLeft, IconX, IconPlus } from "@tabler/icons-react"
|
|
import { Checkbox } from "@/components/ui/checkbox"
|
|
import { toast } from "sonner"
|
|
import { SidebarTrigger } from "@/components/ui/sidebar"
|
|
|
|
const GET_BLOG = gql`
|
|
query GetBlog($id: UUID!) {
|
|
blog(id: $id) {
|
|
id
|
|
title
|
|
slug
|
|
excerpt
|
|
content
|
|
categoryId
|
|
status
|
|
featuredImage
|
|
metaTitle
|
|
metaDescription
|
|
publishedAt
|
|
viewCount
|
|
isFeatured
|
|
isActive
|
|
createdAt
|
|
updatedAt
|
|
tags {
|
|
id
|
|
name
|
|
slug
|
|
color
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
const GET_TAGS_AND_CATEGORIES = gql`
|
|
query GetTagsAndCategories {
|
|
blogTags {
|
|
id
|
|
name
|
|
slug
|
|
color
|
|
}
|
|
blogCategories {
|
|
id
|
|
name
|
|
slug
|
|
}
|
|
}
|
|
`
|
|
|
|
const CREATE_BLOG = gql`
|
|
mutation CreateBlog($input: CreateBlogInput!) {
|
|
createBlog(input: $input) {
|
|
id
|
|
title
|
|
slug
|
|
status
|
|
}
|
|
}
|
|
`
|
|
|
|
const UPDATE_BLOG = gql`
|
|
mutation UpdateBlog($id: UUID!, $input: UpdateBlogInput!) {
|
|
updateBlog(id: $id, input: $input) {
|
|
id
|
|
title
|
|
slug
|
|
status
|
|
updatedAt
|
|
}
|
|
}
|
|
`
|
|
|
|
interface BlogData {
|
|
title: string
|
|
slug: string
|
|
excerpt: string
|
|
content: any
|
|
categoryId?: string
|
|
tagIds?: string[]
|
|
status: 'draft' | 'published' | 'archived'
|
|
featuredImage?: string
|
|
metaTitle?: string
|
|
metaDescription?: string
|
|
isFeatured: boolean
|
|
isActive: boolean
|
|
}
|
|
|
|
interface Tag {
|
|
id: string
|
|
name: string
|
|
slug: string
|
|
color?: string
|
|
}
|
|
|
|
interface Category {
|
|
id: string
|
|
name: string
|
|
slug: string
|
|
}
|
|
|
|
export default function EditorComponent() {
|
|
const router = useRouter()
|
|
const searchParams = useSearchParams()
|
|
const blogId = searchParams.get('id')
|
|
const isEditing = Boolean(blogId)
|
|
|
|
const [blogData, setBlogData] = React.useState<BlogData>({
|
|
title: '',
|
|
slug: '',
|
|
excerpt: '',
|
|
content: null,
|
|
status: 'draft',
|
|
tagIds: [],
|
|
isFeatured: false,
|
|
isActive: true
|
|
})
|
|
|
|
const [isLoading, setIsLoading] = React.useState(false)
|
|
|
|
const { data: tagsAndCategoriesData, loading: tagsAndCategoriesLoading } = useQuery(GET_TAGS_AND_CATEGORIES)
|
|
|
|
const { data: blogQuery, loading: blogLoading } = useQuery(GET_BLOG, {
|
|
variables: { id: blogId },
|
|
skip: !isEditing,
|
|
onCompleted: (data) => {
|
|
if (data?.blog) {
|
|
setBlogData({
|
|
title: data.blog.title || '',
|
|
slug: data.blog.slug || '',
|
|
excerpt: data.blog.excerpt || '',
|
|
content: data.blog.content,
|
|
categoryId: data.blog.categoryId,
|
|
tagIds: data.blog.tags?.map((tag: Tag) => tag.id) || [],
|
|
status: data.blog.status || 'draft',
|
|
featuredImage: data.blog.featuredImage,
|
|
metaTitle: data.blog.metaTitle || '',
|
|
metaDescription: data.blog.metaDescription || '',
|
|
isFeatured: data.blog.isFeatured || false,
|
|
isActive: data.blog.isActive ?? true
|
|
})
|
|
}
|
|
}
|
|
})
|
|
|
|
const [createBlog] = useMutation(CREATE_BLOG)
|
|
const [updateBlog] = useMutation(UPDATE_BLOG)
|
|
|
|
const generateSlug = React.useCallback((title: string) => {
|
|
return title
|
|
.toLowerCase()
|
|
.replace(/[^\w\s-]/g, '')
|
|
.replace(/\s+/g, '-')
|
|
.replace(/-+/g, '-')
|
|
.trim()
|
|
}, [])
|
|
|
|
React.useEffect(() => {
|
|
if (blogData.title && !isEditing) {
|
|
const slug = generateSlug(blogData.title)
|
|
setBlogData(prev => ({ ...prev, slug }))
|
|
}
|
|
}, [blogData.title, generateSlug, isEditing])
|
|
|
|
const handleSave = async (publishStatus?: 'draft' | 'published') => {
|
|
if (!blogData.title || !blogData.slug || !blogData.content) {
|
|
toast.error('请填写标题、链接和内容')
|
|
return
|
|
}
|
|
|
|
setIsLoading(true)
|
|
|
|
try {
|
|
const input = {
|
|
title: blogData.title,
|
|
slug: blogData.slug,
|
|
excerpt: blogData.excerpt || undefined,
|
|
content: blogData.content,
|
|
categoryId: blogData.categoryId || undefined,
|
|
tagIds: blogData.tagIds?.length ? blogData.tagIds : undefined,
|
|
status: publishStatus || blogData.status,
|
|
featuredImage: blogData.featuredImage || undefined,
|
|
metaTitle: blogData.metaTitle || undefined,
|
|
metaDescription: blogData.metaDescription || undefined,
|
|
isFeatured: blogData.isFeatured,
|
|
isActive: blogData.isActive
|
|
}
|
|
|
|
if (isEditing) {
|
|
await updateBlog({
|
|
variables: {
|
|
id: blogId,
|
|
input
|
|
}
|
|
})
|
|
toast.success('博客已更新')
|
|
} else {
|
|
const result = await createBlog({
|
|
variables: { input }
|
|
})
|
|
toast.success('博客已创建')
|
|
const newBlogId = result.data?.createBlog?.id
|
|
if (newBlogId) {
|
|
router.push(`/admin/editor?id=${newBlogId}`)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
toast.error(isEditing ? '更新失败' : '创建失败')
|
|
console.error('Save error:', error)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleContentChange = (content: any) => {
|
|
setBlogData(prev => ({ ...prev, content }))
|
|
}
|
|
|
|
if (blogLoading || tagsAndCategoriesLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full">
|
|
<IconLoader className="h-8 w-8 animate-spin" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const availableTags = tagsAndCategoriesData?.blogTags || []
|
|
const availableCategories = tagsAndCategoriesData?.blogCategories || []
|
|
|
|
return (
|
|
<div className="flex h-full">
|
|
{/* Main Editor Area */}
|
|
<div className="flex-1 flex flex-col">
|
|
{/* Header Toolbar */}
|
|
<div className="border-b sticky top-0 z-10">
|
|
<div className="flex h-14 items-center justify-between px-6">
|
|
<div className="flex items-center gap-2">
|
|
<SidebarTrigger className="-ml-1" />
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => router.push('/admin/blogs')}
|
|
>
|
|
<IconArrowLeft className="h-4 w-4 mr-2" />
|
|
返回
|
|
</Button>
|
|
|
|
<Badge variant={blogData.status === 'published' ? 'default' : 'secondary'}>
|
|
{blogData.status === 'draft' ? '草稿' :
|
|
blogData.status === 'published' ? '已发布' : '已归档'}
|
|
</Badge>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleSave('draft')}
|
|
disabled={isLoading}
|
|
>
|
|
<IconDeviceFloppy className="h-4 w-4 mr-2" />
|
|
保存草稿
|
|
</Button>
|
|
|
|
<Button
|
|
size="sm"
|
|
onClick={() => handleSave('published')}
|
|
disabled={isLoading}
|
|
>
|
|
{isLoading ? (
|
|
<IconLoader className="h-4 w-4 mr-2 animate-spin" />
|
|
) : (
|
|
<IconEye className="h-4 w-4 mr-2" />
|
|
)}
|
|
{blogData.status === 'published' ? '更新' : '发布'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Editor Content */}
|
|
<div className="flex-1 overflow-auto">
|
|
<div className="max-w-4xl mx-auto px-6 py-8">
|
|
{/* Title Input - WordPress Style */}
|
|
<div className="mb-6">
|
|
<input
|
|
type="text"
|
|
value={blogData.title}
|
|
onChange={(e) => setBlogData(prev => ({ ...prev, title: e.target.value }))}
|
|
placeholder="在此处输入标题"
|
|
className="w-full text-4xl font-bold border-none outline-none placeholder:text-gray-400 bg-transparent resize-none"
|
|
style={{ lineHeight: '1.2' }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Slug Display (read-only for now) */}
|
|
{blogData.slug && (
|
|
<div className="mb-6 text-sm text-gray-500">
|
|
永久链接: <span className="text-blue-600">/blog/{blogData.slug}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Editor */}
|
|
<div className="min-h-[600px]">
|
|
<EnhancedSimpleEditor
|
|
content={blogData.content}
|
|
onChange={handleContentChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sidebar */}
|
|
<div className="w-80 border-l overflow-auto">
|
|
<div className="p-6 space-y-6">
|
|
{/* Categories */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-medium">分类</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Select
|
|
value={blogData.categoryId || 'none'}
|
|
onValueChange={(value) =>
|
|
setBlogData(prev => ({ ...prev, categoryId: value === 'none' ? undefined : value }))
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="选择分类" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">无分类</SelectItem>
|
|
{availableCategories.map((category: Category) => (
|
|
<SelectItem key={category.id} value={category.id}>
|
|
{category.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Tags */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-medium">标签</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3">
|
|
{/* Selected Tags */}
|
|
{blogData.tagIds && blogData.tagIds.length > 0 && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{blogData.tagIds.map((tagId) => {
|
|
const tag = availableTags.find((t: Tag) => t.id === tagId)
|
|
if (!tag) return null
|
|
|
|
return (
|
|
<Badge
|
|
key={tagId}
|
|
variant="secondary"
|
|
className="flex items-center gap-1"
|
|
style={{
|
|
backgroundColor: tag.color ? `${tag.color}20` : undefined,
|
|
borderColor: tag.color || undefined
|
|
}}
|
|
>
|
|
{tag.name}
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-auto p-0 hover:bg-transparent"
|
|
onClick={() => {
|
|
setBlogData(prev => ({
|
|
...prev,
|
|
tagIds: prev.tagIds?.filter(id => id !== tagId) || []
|
|
}))
|
|
}}
|
|
>
|
|
<IconX className="h-3 w-3" />
|
|
</Button>
|
|
</Badge>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Available Tags */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-medium text-gray-600">选择标签</Label>
|
|
<div className="max-h-32 overflow-y-auto border rounded-md">
|
|
{availableTags.map((tag: Tag) => (
|
|
<div
|
|
key={tag.id}
|
|
className="flex items-center space-x-2 p-2 hover:bg-gray-50 border-b last:border-b-0"
|
|
>
|
|
<Checkbox
|
|
id={`tag-${tag.id}`}
|
|
checked={blogData.tagIds?.includes(tag.id) || false}
|
|
onCheckedChange={(checked) => {
|
|
setBlogData(prev => {
|
|
const currentTags = prev.tagIds || []
|
|
if (checked) {
|
|
return {
|
|
...prev,
|
|
tagIds: [...currentTags, tag.id]
|
|
}
|
|
} else {
|
|
return {
|
|
...prev,
|
|
tagIds: currentTags.filter(id => id !== tag.id)
|
|
}
|
|
}
|
|
})
|
|
}}
|
|
/>
|
|
<Label
|
|
htmlFor={`tag-${tag.id}`}
|
|
className="flex-1 text-sm cursor-pointer flex items-center gap-2"
|
|
>
|
|
{tag.color && (
|
|
<div
|
|
className="w-3 h-3 rounded-full"
|
|
style={{ backgroundColor: tag.color }}
|
|
/>
|
|
)}
|
|
{tag.name}
|
|
</Label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Publish Section */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-medium">发布</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div>
|
|
<Label htmlFor="status" className="text-xs font-medium text-gray-600">状态</Label>
|
|
<Select
|
|
value={blogData.status}
|
|
onValueChange={(value: 'draft' | 'published' | 'archived') =>
|
|
setBlogData(prev => ({ ...prev, status: value }))
|
|
}
|
|
>
|
|
<SelectTrigger className="mt-1">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="draft">草稿</SelectItem>
|
|
<SelectItem value="published">已发布</SelectItem>
|
|
<SelectItem value="archived">已归档</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="isFeatured"
|
|
checked={blogData.isFeatured}
|
|
onCheckedChange={(checked) =>
|
|
setBlogData(prev => ({ ...prev, isFeatured: !!checked }))
|
|
}
|
|
/>
|
|
<Label htmlFor="isFeatured" className="text-sm">推荐文章</Label>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="isActive"
|
|
checked={blogData.isActive}
|
|
onCheckedChange={(checked) =>
|
|
setBlogData(prev => ({ ...prev, isActive: !!checked }))
|
|
}
|
|
/>
|
|
<Label htmlFor="isActive" className="text-sm">启用文章</Label>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Featured Image */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-medium">特色图片</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3">
|
|
{blogData.featuredImage ? (
|
|
<div className="relative">
|
|
<img
|
|
src={blogData.featuredImage}
|
|
alt="特色图片"
|
|
className="w-full h-32 object-cover rounded-md"
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setBlogData(prev => ({ ...prev, featuredImage: '' }))}
|
|
className="mt-2 w-full"
|
|
>
|
|
移除图片
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="border-2 border-dashed border-gray-300 rounded-md p-6 text-center">
|
|
<p className="text-sm text-gray-500 mb-3">添加特色图片</p>
|
|
<Input
|
|
placeholder="图片URL"
|
|
value={blogData.featuredImage || ''}
|
|
onChange={(e) => setBlogData(prev => ({ ...prev, featuredImage: e.target.value }))}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Excerpt */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-medium">摘要</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Textarea
|
|
value={blogData.excerpt}
|
|
onChange={(e) => setBlogData(prev => ({ ...prev, excerpt: e.target.value }))}
|
|
placeholder="输入摘要..."
|
|
rows={4}
|
|
className="resize-none"
|
|
/>
|
|
<p className="text-xs text-gray-500 mt-2">摘要是对文章内容的简短总结</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* SEO Settings */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-medium">SEO 设置</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div>
|
|
<Label htmlFor="metaTitle" className="text-xs font-medium text-gray-600">Meta 标题</Label>
|
|
<Input
|
|
id="metaTitle"
|
|
value={blogData.metaTitle || ''}
|
|
onChange={(e) => setBlogData(prev => ({ ...prev, metaTitle: e.target.value }))}
|
|
placeholder="SEO 标题"
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="metaDescription" className="text-xs font-medium text-gray-600">Meta 描述</Label>
|
|
<Textarea
|
|
id="metaDescription"
|
|
value={blogData.metaDescription || ''}
|
|
onChange={(e) => setBlogData(prev => ({ ...prev, metaDescription: e.target.value }))}
|
|
placeholder="SEO 描述"
|
|
rows={3}
|
|
className="mt-1 resize-none"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="slug" className="text-xs font-medium text-gray-600">URL 别名</Label>
|
|
<Input
|
|
id="slug"
|
|
value={blogData.slug}
|
|
onChange={(e) => setBlogData(prev => ({ ...prev, slug: e.target.value }))}
|
|
placeholder="url-slug"
|
|
className="mt-1"
|
|
/>
|
|
<p className="text-xs text-gray-500 mt-1">URL 的最后部分</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
} |