mosaicmap/app/admin/editor/editor-component.tsx
2025-08-17 20:28:13 +08:00

435 lines
18 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 } from "@tabler/icons-react"
import { Checkbox } from "@/components/ui/checkbox"
import { toast } from "sonner"
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
}
}
`
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
status: 'draft' | 'published' | 'archived'
featuredImage?: string
metaTitle?: string
metaDescription?: string
isFeatured: boolean
isActive: boolean
}
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',
isFeatured: false,
isActive: true
})
const [isLoading, setIsLoading] = React.useState(false)
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,
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,
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) {
return (
<div className="flex items-center justify-center h-full">
<IconLoader className="h-8 w-8 animate-spin" />
</div>
)
}
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">
<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">
{/* 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>
)
}