602 lines
20 KiB
TypeScript
602 lines
20 KiB
TypeScript
'use client'
|
|
import { useState, useEffect, useRef } from 'react'
|
|
import { useRouter, useParams } from 'next/navigation'
|
|
import { useForm } from 'react-hook-form'
|
|
import { zodResolver } from '@hookform/resolvers/zod'
|
|
import { z } from 'zod'
|
|
import dynamic from 'next/dynamic'
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from '@/components/ui/form'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
|
import { Header } from '@/components/header'
|
|
import { Footer } from '@/components/footer'
|
|
import ImageUpload from '@/components/topics/ImageUpload'
|
|
import SearchableTagSelect, { Tag } from '@/components/topics/SearchableTagSelect'
|
|
import { RequireAuth } from '@/components/auth/RequireAuth'
|
|
import { useAuth } from '@/contexts/AuthContext'
|
|
import { useToast } from '@/hooks/use-toast'
|
|
import { ArrowLeft, Save, FileText, BookOpen, Trash2 } from 'lucide-react'
|
|
import { PartialBlock } from '@blocknote/core'
|
|
import { ITopic } from '@/models/topic'
|
|
|
|
// Dynamic import to avoid SSR issues with BlockNote
|
|
const BlockNoteEditor = dynamic(() => import('@/components/BlockNoteEditor'), {
|
|
ssr: false,
|
|
loading: () => (
|
|
<div className="border border-gray-200 rounded-lg p-8 bg-muted/20 min-h-[500px]">
|
|
<div className="flex items-center justify-center h-full">
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
|
<span className="ml-2 text-muted-foreground">Loading editor...</span>
|
|
</div>
|
|
</div>
|
|
),
|
|
})
|
|
|
|
// Form schema (same as create page)
|
|
const topicFormSchema = z.object({
|
|
title: z.string().min(1, 'Title is required').max(100, 'Title must be 100 characters or less'),
|
|
excerpt: z.string().min(1, 'Excerpt is required').max(200, 'Excerpt must be 200 characters or less'),
|
|
content: z.string().min(1, 'Content is required'),
|
|
contentRTE: z.array(z.any()).optional(),
|
|
contentImages: z.array(z.string()).optional(),
|
|
coverImage: z.union([
|
|
z.instanceof(File).refine(file => file.size <= 5 * 1024 * 1024, 'Cover image must be less than 5MB'),
|
|
z.string()
|
|
]).optional(),
|
|
tags: z
|
|
.array(
|
|
z.object({
|
|
id: z.string().optional(),
|
|
name: z.string().min(1, 'Tag name is required'),
|
|
})
|
|
)
|
|
.min(1, 'At least one tag is required')
|
|
.max(5, 'Maximum 5 tags allowed'),
|
|
featured: z.boolean().optional().default(false),
|
|
isDraft: z.boolean().optional().default(false),
|
|
})
|
|
|
|
export default function EditTopicPage() {
|
|
return (
|
|
<RequireAuth>
|
|
<EditTopicContent />
|
|
</RequireAuth>
|
|
)
|
|
}
|
|
|
|
function EditTopicContent() {
|
|
const router = useRouter()
|
|
const params = useParams()
|
|
const { user } = useAuth()
|
|
const { toast } = useToast()
|
|
const [topic, setTopic] = useState<ITopic | null>(null)
|
|
const [availableTags, setAvailableTags] = useState<Tag[]>([])
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
const [isDeleting, setIsDeleting] = useState(false)
|
|
|
|
// Editor content ref for storing RTE data
|
|
const editorContentRef = useRef({
|
|
contentRTE: [] as PartialBlock[],
|
|
contentImages: [] as string[],
|
|
content: '',
|
|
})
|
|
|
|
// Initialize form
|
|
const form = useForm({
|
|
resolver: zodResolver(topicFormSchema),
|
|
defaultValues: {
|
|
title: '',
|
|
excerpt: '',
|
|
content: '',
|
|
contentRTE: [] as any[],
|
|
contentImages: [] as string[],
|
|
coverImage: '',
|
|
tags: [] as { name: string; id?: string }[],
|
|
featured: false,
|
|
isDraft: false,
|
|
},
|
|
})
|
|
|
|
// Load topic and tags
|
|
useEffect(() => {
|
|
const fetchData = async () => {
|
|
try {
|
|
// Fetch topic by slug
|
|
const topicResponse = await fetch(`/api/topic/slug/${params.slug}`)
|
|
if (!topicResponse.ok) {
|
|
throw new Error('Topic not found')
|
|
}
|
|
const topicData = await topicResponse.json()
|
|
if (!topicData.success) {
|
|
throw new Error('Topic not found')
|
|
}
|
|
|
|
const topicPost = topicData.data as ITopic
|
|
|
|
// Check if user owns this topic
|
|
if (topicPost.authorId !== user?.id) {
|
|
toast({
|
|
title: 'Error',
|
|
description: 'You are not authorized to edit this topic',
|
|
variant: 'destructive',
|
|
})
|
|
router.push('/topics')
|
|
return
|
|
}
|
|
|
|
setTopic(topicPost)
|
|
|
|
// Set form values
|
|
form.reset({
|
|
title: topicPost.title,
|
|
excerpt: topicPost.excerpt,
|
|
content: topicPost.content,
|
|
contentRTE: topicPost.contentRTE as any[],
|
|
contentImages: topicPost.contentImages || [],
|
|
coverImage: topicPost.coverImage,
|
|
tags: topicPost.tags,
|
|
featured: topicPost.featured,
|
|
isDraft: topicPost.isDraft,
|
|
})
|
|
|
|
// Set editor content
|
|
editorContentRef.current = {
|
|
contentRTE: topicPost.contentRTE as PartialBlock[],
|
|
contentImages: topicPost.contentImages || [],
|
|
content: topicPost.content,
|
|
}
|
|
|
|
// Fetch available tags
|
|
const tagsResponse = await fetch('/api/tags')
|
|
if (tagsResponse.ok) {
|
|
const tagsData = await tagsResponse.json()
|
|
if (tagsData.success) {
|
|
setAvailableTags(tagsData.data || [])
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch topic:', error)
|
|
toast({
|
|
title: 'Error',
|
|
description: 'Failed to load topic post',
|
|
variant: 'destructive',
|
|
})
|
|
router.push('/topics')
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
if (params.slug && user) {
|
|
fetchData()
|
|
}
|
|
}, [params.slug, user, form, router, toast])
|
|
|
|
// Handle tag creation
|
|
const handleCreateTag = async (tagName: string) => {
|
|
const newTag: Tag = {
|
|
name: tagName,
|
|
id: `temp-${Date.now()}`,
|
|
}
|
|
setAvailableTags(prev => [...prev, newTag])
|
|
return newTag
|
|
}
|
|
|
|
// Handle editor content changes
|
|
const handleEditorChange = (blocks: any[], html: string) => {
|
|
editorContentRef.current = {
|
|
contentRTE: blocks,
|
|
contentImages: [], // Will be populated by editor if it tracks images
|
|
content: html,
|
|
}
|
|
|
|
// Update form values
|
|
form.setValue('content', html)
|
|
form.setValue('contentRTE', blocks)
|
|
form.setValue('contentImages', editorContentRef.current.contentImages)
|
|
}
|
|
|
|
// Handle form submission
|
|
const handleSubmit = async (data: any, saveAsDraft = false) => {
|
|
if (!topic) return
|
|
|
|
try {
|
|
setIsSubmitting(true)
|
|
|
|
// Create FormData object (like dev-portfolio)
|
|
const formData = new FormData()
|
|
|
|
// Add topic ID for update
|
|
formData.append('topicId', topic.id)
|
|
|
|
// Append all form fields to FormData
|
|
Object.entries(data).forEach(([key, value]) => {
|
|
if (key === 'tags') {
|
|
// Handle tags array - ensure each tag has id and name
|
|
const validTags = (value as Tag[]).map((tag) => ({
|
|
id: tag.id,
|
|
name: tag.name,
|
|
}))
|
|
formData.append('tags', JSON.stringify(validTags))
|
|
} else if (key === 'coverImage') {
|
|
// Only append if it's a new File object
|
|
if (value instanceof File) {
|
|
formData.append('coverImage', value)
|
|
}
|
|
// Skip if it's a string (existing image URL) or empty object
|
|
} else {
|
|
// Convert all other values to string
|
|
formData.append(key, String(value))
|
|
}
|
|
})
|
|
|
|
// Add additional fields
|
|
formData.append('author', user?.name || 'Anonymous')
|
|
formData.append('authorId', user?.id || '')
|
|
formData.append('isDraft', String(saveAsDraft))
|
|
|
|
// Handle content fields
|
|
formData.delete('content')
|
|
formData.delete('contentRTE')
|
|
formData.delete('contentImages')
|
|
formData.append('contentRTE', JSON.stringify(editorContentRef.current.contentRTE))
|
|
formData.append('content', editorContentRef.current.content)
|
|
formData.append('contentImages', JSON.stringify(editorContentRef.current.contentImages))
|
|
|
|
const response = await fetch(`/api/topic?topicId=${topic.id}`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
credentials: 'include',
|
|
})
|
|
|
|
const result = await response.json()
|
|
|
|
if (!result.success) {
|
|
throw new Error(result.error?.message || 'Failed to update topic')
|
|
}
|
|
|
|
toast({
|
|
title: 'Success!',
|
|
description: saveAsDraft
|
|
? 'Topic updated and saved as draft'
|
|
: 'Topic updated successfully',
|
|
})
|
|
|
|
// Redirect to the updated topic
|
|
router.push(`/topics/${result.data.slug}`)
|
|
} catch (error) {
|
|
console.error('Failed to update topic:', error)
|
|
toast({
|
|
title: 'Error',
|
|
description: error instanceof Error ? error.message : 'Failed to update topic',
|
|
variant: 'destructive',
|
|
})
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
}
|
|
|
|
// Handle topic deletion
|
|
const handleDelete = async () => {
|
|
if (!topic) return
|
|
|
|
if (!confirm('Are you sure you want to delete this topic? This action cannot be undone.')) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
setIsDeleting(true)
|
|
|
|
const response = await fetch(`/api/topic?id=${topic.id}`, {
|
|
method: 'DELETE',
|
|
credentials: 'include',
|
|
})
|
|
|
|
const result = await response.json()
|
|
|
|
if (!result.success) {
|
|
throw new Error(result.error?.message || 'Failed to delete topic')
|
|
}
|
|
|
|
toast({
|
|
title: 'Success!',
|
|
description: 'Topic deleted successfully',
|
|
})
|
|
|
|
router.push('/topics')
|
|
} catch (error) {
|
|
console.error('Failed to delete topic:', error)
|
|
toast({
|
|
title: 'Error',
|
|
description: error instanceof Error ? error.message : 'Failed to delete topic',
|
|
variant: 'destructive',
|
|
})
|
|
} finally {
|
|
setIsDeleting(false)
|
|
}
|
|
}
|
|
|
|
const onSubmit = (data: any) => {
|
|
handleSubmit(data, data.isDraft)
|
|
}
|
|
|
|
const handleSaveAsDraft = () => {
|
|
const formData = form.getValues()
|
|
handleSubmit(formData, true)
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
<Header />
|
|
<div className="flex items-center justify-center min-h-[400px]">
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
|
|
<p>Loading topic...</p>
|
|
</div>
|
|
</div>
|
|
<Footer />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!topic) {
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
<Header />
|
|
<div className="flex items-center justify-center min-h-[400px]">
|
|
<div className="text-center">
|
|
<p>Topic not found</p>
|
|
</div>
|
|
</div>
|
|
<Footer />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
<Header />
|
|
<main className="container mx-auto px-4 pt-24 pb-16">
|
|
{/* Page Header */}
|
|
<div className="mb-8">
|
|
<div className="flex items-center gap-4 mb-6">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => router.back()}
|
|
className="flex items-center gap-2"
|
|
>
|
|
<ArrowLeft className="w-4 h-4" />
|
|
Back
|
|
</Button>
|
|
</div>
|
|
<h1 className="text-3xl font-bold">Edit Topic</h1>
|
|
</div>
|
|
|
|
{/* Form */}
|
|
<Form {...form}>
|
|
<form onSubmit={form.handleSubmit(onSubmit)} className="max-w-4xl mx-auto space-y-8">
|
|
{/* Header Fields */}
|
|
<div className="space-y-6">
|
|
{/* Title */}
|
|
<FormField
|
|
control={form.control}
|
|
name="title"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="text-sm font-medium text-gray-700">Title</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder="Topic title"
|
|
{...field}
|
|
className="text-lg border-0 border-b-2 border-gray-200 rounded-none px-0 py-3 bg-transparent focus:border-blue-500 focus-visible:ring-0 shadow-none"
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
{/* Author + Date Row */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<FormItem>
|
|
<FormLabel className="text-sm font-medium text-gray-700">Author</FormLabel>
|
|
<Input
|
|
placeholder="Author name"
|
|
value={topic.author}
|
|
disabled
|
|
className="border-0 border-b-2 border-gray-200 rounded-none px-0 py-3 bg-transparent text-gray-500"
|
|
/>
|
|
</FormItem>
|
|
|
|
<FormItem>
|
|
<FormLabel className="text-sm font-medium text-gray-700">Publication Date</FormLabel>
|
|
<Input
|
|
type="date"
|
|
value={new Date(topic.publishedAt).toISOString().split('T')[0]}
|
|
disabled
|
|
className="border-0 border-b-2 border-gray-200 rounded-none px-0 py-3 bg-transparent text-gray-500"
|
|
/>
|
|
</FormItem>
|
|
</div>
|
|
|
|
{/* Excerpt */}
|
|
<FormField
|
|
control={form.control}
|
|
name="excerpt"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="text-sm font-medium text-gray-700">Excerpt</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder="Brief summary of the topic"
|
|
{...field}
|
|
rows={4}
|
|
className="border-2 border-gray-200 rounded-lg resize-none focus:border-blue-500 focus-visible:ring-0 shadow-none mt-2"
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Main Editor */}
|
|
<div>
|
|
<FormField
|
|
control={form.control}
|
|
name="content"
|
|
render={() => (
|
|
<FormItem>
|
|
<FormLabel className="text-sm font-medium text-gray-700">Content</FormLabel>
|
|
<FormControl>
|
|
<div className="border-2 border-gray-200 rounded-lg min-h-[500px] mt-2 focus-within:border-blue-500">
|
|
<BlockNoteEditor
|
|
onDataChange={handleEditorChange}
|
|
initialContent={topic.contentRTE as any[]}
|
|
/>
|
|
</div>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Cover Image */}
|
|
<div>
|
|
<FormField
|
|
control={form.control}
|
|
name="coverImage"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="text-sm font-medium text-gray-700">Cover Image</FormLabel>
|
|
<FormControl>
|
|
<div className="mt-2">
|
|
<ImageUpload
|
|
value={field.value}
|
|
onChange={(file) => field.onChange(file)}
|
|
label="Upload new cover image"
|
|
currentImage={topic.coverImage}
|
|
/>
|
|
</div>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Tags */}
|
|
<div>
|
|
<FormField
|
|
control={form.control}
|
|
name="tags"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="text-sm font-medium text-gray-700">Tags</FormLabel>
|
|
<FormControl>
|
|
<div className="mt-2">
|
|
<SearchableTagSelect
|
|
availableTags={availableTags}
|
|
selectedTags={field.value}
|
|
onChange={field.onChange}
|
|
onCreateTag={handleCreateTag}
|
|
placeholder="Add tags..."
|
|
maxTags={5}
|
|
/>
|
|
</div>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Settings */}
|
|
<div>
|
|
<FormField
|
|
control={form.control}
|
|
name="featured"
|
|
render={({ field }) => (
|
|
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
|
<FormControl>
|
|
<Checkbox
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
<div className="space-y-1 leading-none">
|
|
<FormLabel className="text-sm font-medium">Featured</FormLabel>
|
|
</div>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Action Buttons */}
|
|
<div className="flex flex-col sm:flex-row justify-between gap-4 pt-8 border-t">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={handleDelete}
|
|
disabled={isDeleting || isSubmitting}
|
|
className="flex items-center gap-2 text-red-600 border-red-200 hover:bg-red-50"
|
|
>
|
|
{isDeleting && <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-red-600"></div>}
|
|
<Trash2 className="w-4 h-4" />
|
|
Delete Topic
|
|
</Button>
|
|
|
|
<div className="flex flex-col sm:flex-row gap-4">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => router.back()}
|
|
disabled={isSubmitting || isDeleting}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={handleSaveAsDraft}
|
|
disabled={isSubmitting || isDeleting}
|
|
className="flex items-center gap-2"
|
|
>
|
|
{isSubmitting && <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-600"></div>}
|
|
<FileText className="w-4 h-4" />
|
|
Save as Draft
|
|
</Button>
|
|
|
|
<Button
|
|
type="submit"
|
|
disabled={isSubmitting || isDeleting}
|
|
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700"
|
|
>
|
|
{isSubmitting && <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>}
|
|
<BookOpen className="w-4 h-4" />
|
|
Update Topic
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</Form>
|
|
</main>
|
|
<Footer />
|
|
</div>
|
|
)
|
|
}
|