Files
ai-wpa/app/topics/new/page.tsx
2025-08-30 18:18:57 +05:30

553 lines
19 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 } from 'lucide-react'
import { PartialBlock } from '@blocknote/core'
// 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
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 CreateTopicPage() {
return (
<RequireAuth>
<CreateTopicContent />
</RequireAuth>
)
}
function CreateTopicContent() {
const router = useRouter()
const params = useParams()
const { user } = useAuth()
const { toast } = useToast()
const topicId = params?.id as string
const isEditMode = topicId && topicId !== 'new'
const [availableTags, setAvailableTags] = useState<Tag[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isSubmitting, setIsSubmitting] = useState(false)
const [topicData, setTopicData] = useState<any>(null)
// 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 available tags and topic data if editing
useEffect(() => {
const fetchData = async () => {
try {
// Fetch available tags
const tagsResponse = await fetch('/api/tags')
if (tagsResponse.ok) {
const tagsData = await tagsResponse.json()
if (tagsData.success) {
setAvailableTags(tagsData.data || [])
}
}
// If editing, fetch topic data
if (isEditMode) {
const response = await fetch(`/api/topic/${topicId}`, {
credentials: 'include',
})
if (!response.ok) {
if (response.status === 404) {
toast({
title: 'Topic not found',
description: 'The topic you are trying to edit does not exist.',
variant: 'destructive',
})
router.push('/dashboard')
return
}
if (response.status === 403) {
toast({
title: 'Access denied',
description: 'You can only edit your own topics.',
variant: 'destructive',
})
router.push('/dashboard')
return
}
throw new Error('Failed to fetch topic data')
}
const result = await response.json()
if (!result.success) {
throw new Error(result.error?.message || 'Failed to fetch topic data')
}
const topic = result.data
setTopicData(topic)
// Populate form with existing data
form.reset({
title: topic.title,
excerpt: topic.excerpt,
content: topic.content,
contentRTE: topic.contentRTE || [],
contentImages: topic.contentImages || [],
coverImage: topic.coverImage,
tags: topic.tags || [],
featured: topic.featured || false,
isDraft: topic.isDraft || false,
})
// Set editor content
editorContentRef.current = {
contentRTE: topic.contentRTE || [],
contentImages: topic.contentImages || [],
content: topic.content,
}
}
} catch (error) {
console.error('Failed to fetch data:', error)
toast({
title: 'Error',
description: isEditMode ? 'Failed to load topic data for editing.' : 'Failed to load existing tags, but you can still create new ones',
variant: 'destructive',
})
if (isEditMode) {
router.push('/dashboard')
}
} finally {
setIsLoading(false)
}
}
fetchData()
}, [isEditMode, topicId, 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 onSubmit = async (data: any, isDraft: boolean = false) => {
try {
setIsSubmitting(true)
// Create FormData object (like dev-portfolio)
const formData = new FormData()
// 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 if (key === 'isDraft') {
// Skip isDraft here - we handle it separately below
} 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(isDraft))
// 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))
// Use dev-portfolio pattern: POST with topicId query param for updates
const url = isEditMode ? `/api/topic?topicId=${topicId}` : '/api/topic'
const response = await fetch(url, {
method: 'POST',
body: formData,
credentials: 'include', // Include authentication cookies
})
const result = await response.json()
if (!result.success) {
throw new Error(result.error?.message || `Failed to ${isEditMode ? 'update' : 'create'} topic`)
}
const statusMessage = isDraft ? 'saved as draft' : 'published'
toast({
title: 'Success!',
description: `Topic ${isEditMode ? 'updated' : 'created'} and ${statusMessage}!`,
})
// Redirect based on draft status - drafts go to dashboard, published go to topic page
const slug = result.data.slug || topicData?.slug
router.push(isDraft ? '/dashboard' : `/topics/${slug}`)
} catch (error) {
console.error(`Failed to ${isEditMode ? 'update' : 'create'} topic:`, error)
toast({
title: 'Error',
description: error instanceof Error ? error.message : `Failed to ${isEditMode ? 'update' : 'create'} topic`,
variant: 'destructive',
})
} finally {
setIsSubmitting(false)
}
}
const handleSaveAsDraft = () => {
const data = form.getValues()
onSubmit(data, 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...</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">
{isEditMode ? 'Edit Topic' : 'Create Topic'}
</h1>
{isEditMode && topicData && (
<p className="text-muted-foreground mt-2">
Editing: {topicData.title}
</p>
)}
</div>
{/* Form */}
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => onSubmit(data, false))} 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={user?.name || ''}
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={isEditMode && topicData ?
new Date(topicData.publishedAt).toISOString().split('T')[0] :
new Date().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={isEditMode ? editorContentRef.current.contentRTE : undefined}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Cover Image - Full Width */}
<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 cover image"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Tags - Full Width */}
<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-end gap-4 pt-8 border-t">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
type="button"
variant="outline"
onClick={handleSaveAsDraft}
disabled={isSubmitting}
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}
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" />
{isEditMode ? 'Update Topic' : 'Publish Topic'}
</Button>
</div>
</form>
</Form>
</main>
<Footer />
</div>
)
}