initial commit
This commit is contained in:
601
app/topics/[slug]/edit/page.tsx
Normal file
601
app/topics/[slug]/edit/page.tsx
Normal file
@@ -0,0 +1,601 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
50
app/topics/[slug]/not-found.tsx
Normal file
50
app/topics/[slug]/not-found.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Header } from '@/components/header'
|
||||
import { Footer } from '@/components/footer'
|
||||
import { FileX, ArrowLeft } from 'lucide-react'
|
||||
|
||||
export default function TopicNotFound() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<main className="container pt-24 pb-16">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="p-12">
|
||||
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<FileX className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold mb-4">Topic Post Not Found</h1>
|
||||
<p className="text-muted-foreground mb-8 leading-relaxed">
|
||||
The topic post you're looking for doesn't exist or may have been moved.
|
||||
It could be a mistyped URL or the post might have been removed.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button asChild>
|
||||
<Link href="/topics" className="flex items-center gap-2">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Topic
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/">Go Home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 p-4 bg-muted/50 rounded-lg">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Looking for something specific? Try searching or browsing our latest articles.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
289
app/topics/[slug]/page.tsx
Normal file
289
app/topics/[slug]/page.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { ArrowLeft, Eye, Clock } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { ITopic } from '@/models/topic'
|
||||
import { Metadata } from 'next'
|
||||
import { Header } from '@/components/header'
|
||||
import { Footer } from '@/components/footer'
|
||||
import { TopicViewTracker } from '@/components/topics/TopicClientComponents'
|
||||
import { TopicEditButton } from '@/components/topics/TopicEditButton'
|
||||
import { SimpleShareButtons } from '@/components/topics/SimpleShareButtons'
|
||||
|
||||
// Force dynamic rendering for real-time updates
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// Server-side data fetching
|
||||
async function getTopicPost(slug: string): Promise<ITopic | null> {
|
||||
try {
|
||||
const baseUrl =
|
||||
process.env.NODE_ENV === 'production'
|
||||
? `${process.env.NEXTAUTH_URL || 'https://siliconpin.com'}`
|
||||
: 'http://localhost:4023'
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/topics/${encodeURIComponent(slug)}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return null
|
||||
}
|
||||
throw new Error(`Failed to fetch topic post: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
if (result.error?.code === 'POST_NOT_FOUND') {
|
||||
return null
|
||||
}
|
||||
throw new Error(result.error?.message || 'Failed to fetch topic post')
|
||||
}
|
||||
|
||||
return result.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching topic post:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function getRelatedPosts(slug: string, limit: number = 2): Promise<ITopic[]> {
|
||||
try {
|
||||
const baseUrl =
|
||||
process.env.NODE_ENV === 'production'
|
||||
? `${process.env.NEXTAUTH_URL || 'https://siliconpin.com'}`
|
||||
: 'http://localhost:4023'
|
||||
|
||||
const response = await fetch(
|
||||
`${baseUrl}/api/topics/${encodeURIComponent(slug)}/related?limit=${limit}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch related posts: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error?.message || 'Failed to fetch related posts')
|
||||
}
|
||||
|
||||
return result.data || []
|
||||
} catch (error) {
|
||||
console.error('Error fetching related posts:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Generate metadata for the page
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params
|
||||
const post = await getTopicPost(slug)
|
||||
|
||||
if (!post) {
|
||||
return {
|
||||
title: 'Topic Not Found - SiliconPin',
|
||||
description: 'The requested topic could not be found.',
|
||||
}
|
||||
}
|
||||
|
||||
const keywords = post.tags.map((tag) => tag.name).join(', ')
|
||||
|
||||
return {
|
||||
title: `${post.title} - SiliconPin Topics`,
|
||||
description: post.excerpt,
|
||||
keywords: keywords,
|
||||
authors: [{ name: post.author }],
|
||||
openGraph: {
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
type: 'article',
|
||||
publishedTime: new Date(post.publishedAt).toISOString(),
|
||||
authors: [post.author],
|
||||
images: post.coverImage ? [post.coverImage] : undefined,
|
||||
tags: post.tags.map((tag) => tag.name),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
images: post.coverImage ? [post.coverImage] : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function TopicPostPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
const post = await getTopicPost(slug)
|
||||
|
||||
if (!post) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const relatedPosts = await getRelatedPosts(slug)
|
||||
|
||||
const publishedDate = new Date(post.publishedAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
|
||||
{/* View tracking only - no visual component */}
|
||||
<TopicViewTracker topicSlug={post.slug} isDraft={post.isDraft || false} />
|
||||
|
||||
<article className="pt-20 pb-12">
|
||||
<div className="container-custom max-w-4xl">
|
||||
{/* Back to topics and Edit button */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<Button variant="ghost" asChild className="pl-0 transition-all duration-200 hover:pl-2">
|
||||
<Link href="/topics" className="flex items-center gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to all topics
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<TopicEditButton topicId={post.id} authorId={post.authorId} />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<header className="mb-10">
|
||||
<h1 className="mb-6 text-4xl font-bold md:text-5xl">{post.title}</h1>
|
||||
|
||||
<div className="mb-8 flex flex-wrap items-center gap-x-6 gap-y-2 text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
|
||||
{post.author.charAt(0)}
|
||||
</div>
|
||||
<span>{post.author}</span>
|
||||
</div>
|
||||
|
||||
<time dateTime={new Date(post.publishedAt).toISOString()}>{publishedDate}</time>
|
||||
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>
|
||||
{post.readingTime?.text || `${post.readingTime?.minutes || 1} min read`}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{(post.views || 0) > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye className="w-4 h-4" />
|
||||
<span>{post.views?.toLocaleString()} views</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-8 flex flex-wrap gap-2">
|
||||
{post.tags.map((tag) => (
|
||||
<Link key={tag.id || tag.name} href={`/topics?tag=${tag.name}`}>
|
||||
<Badge variant="outline" className="hover:bg-secondary">
|
||||
{tag.name}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Cover Image */}
|
||||
<div className="mb-10 overflow-hidden rounded-xl">
|
||||
<Image
|
||||
width={848}
|
||||
height={560}
|
||||
src={post.coverImage}
|
||||
alt={post.title}
|
||||
className="h-auto w-full object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="prose prose-lg max-w-none">
|
||||
<div className="mb-4" dangerouslySetInnerHTML={{ __html: post.content }} />
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="mt-12 border-t pt-8">
|
||||
<h3 className="mb-4 text-lg font-semibold">Tags</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{post.tags.map((tag) => (
|
||||
<Link key={tag.id || tag.name} href={`/topics?tag=${tag.name}`}>
|
||||
<Badge variant="outline" className="hover:bg-secondary">
|
||||
{tag.name}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Share Buttons */}
|
||||
<SimpleShareButtons title={post.title} url={`/topics/${post.slug}`} />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* Related posts */}
|
||||
{relatedPosts.length > 0 && (
|
||||
<section className="bg-muted/30 py-12">
|
||||
<div className="container-custom max-w-4xl">
|
||||
<h2 className="mb-8 text-2xl font-bold">Related Posts</h2>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||
{relatedPosts.map((relatedPost) => (
|
||||
<Link
|
||||
key={relatedPost.id}
|
||||
href={`/topics/${relatedPost.slug}`}
|
||||
className="topic-card group block"
|
||||
>
|
||||
<div className="aspect-video overflow-hidden">
|
||||
<Image
|
||||
width={406}
|
||||
height={228}
|
||||
src={relatedPost.coverImage}
|
||||
alt={relatedPost.title}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<h3 className="mb-2 text-xl font-bold transition-colors group-hover:text-primary">
|
||||
{relatedPost.title}
|
||||
</h3>
|
||||
<p className="mb-4 line-clamp-2 text-muted-foreground">{relatedPost.excerpt}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{relatedPost.tags.slice(0, 2).map((tag) => (
|
||||
<Badge key={tag.id || tag.name} variant="outline">
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
4
app/topics/edit/[id]/page.tsx
Normal file
4
app/topics/edit/[id]/page.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
'use client'
|
||||
import CreateTopicPage from '../../new/page'
|
||||
|
||||
export default CreateTopicPage
|
||||
553
app/topics/new/page.tsx
Normal file
553
app/topics/new/page.tsx
Normal file
@@ -0,0 +1,553 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
383
app/topics/page.tsx
Normal file
383
app/topics/page.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import { Suspense } from 'react'
|
||||
import { Metadata } from 'next'
|
||||
import { Header } from '@/components/header'
|
||||
import { Footer } from '@/components/footer'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/pagination'
|
||||
import { Search, BookOpen, Eye } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { ITopic } from '@/models/topic'
|
||||
|
||||
// Force dynamic rendering for real-time topic updates
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// SEO metadata for the topics page
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
title: 'Topic - SiliconPin | Web Development Insights & Tutorials',
|
||||
description:
|
||||
'Discover the latest insights, tutorials, and stories from the SiliconPin community. Learn about web development, cloud computing, and cutting-edge technology trends.',
|
||||
keywords: [
|
||||
'web development',
|
||||
'cloud computing',
|
||||
'programming tutorials',
|
||||
'tech insights',
|
||||
'software engineering',
|
||||
'SiliconPin topic',
|
||||
'development tutorials',
|
||||
],
|
||||
authors: [{ name: 'SiliconPin Team' }],
|
||||
openGraph: {
|
||||
title: 'SiliconPin Topic - Web Development Insights',
|
||||
description:
|
||||
'Discover the latest insights, tutorials, and stories from the SiliconPin community.',
|
||||
type: 'website',
|
||||
siteName: 'SiliconPin',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'SiliconPin Topic - Web Development Insights',
|
||||
description:
|
||||
'Discover the latest insights, tutorials, and stories from the SiliconPin community.',
|
||||
},
|
||||
alternates: {
|
||||
canonical: '/topics',
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Server-side data fetching
|
||||
async function parseSearchParams(
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
|
||||
) {
|
||||
const params = await searchParams
|
||||
return {
|
||||
page: params.page ? parseInt(params.page as string) : 1,
|
||||
tag: params.tag ? (params.tag as string) : undefined,
|
||||
search: params.q ? (params.q as string) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async function getTopics(searchParams: Promise<{ [key: string]: string | string[] | undefined }>) {
|
||||
const { page, tag, search } = await parseSearchParams(searchParams)
|
||||
|
||||
try {
|
||||
// Build API URL with parameters
|
||||
const baseUrl =
|
||||
process.env.NODE_ENV === 'production'
|
||||
? `${process.env.NEXTAUTH_URL || 'https://siliconpin.com'}`
|
||||
: 'http://localhost:4023'
|
||||
|
||||
const searchQuery = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: '6',
|
||||
...(tag && { tag }),
|
||||
...(search && { q: search }),
|
||||
})
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/topics?${searchQuery}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// Disable cache for dynamic data
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch topics: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error?.message || 'Failed to fetch topics')
|
||||
}
|
||||
|
||||
return {
|
||||
topics: result.data || [],
|
||||
pagination: result.pagination || {
|
||||
total: 0,
|
||||
page,
|
||||
limit: 6,
|
||||
totalPages: 0,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching topics:', error)
|
||||
|
||||
// Fallback to empty state
|
||||
return {
|
||||
topics: [],
|
||||
pagination: {
|
||||
total: 0,
|
||||
page,
|
||||
limit: 6,
|
||||
totalPages: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get unique tags from topics for filtering
|
||||
async function getTags(): Promise<Array<{ id: string; name: string }>> {
|
||||
try {
|
||||
const baseUrl =
|
||||
process.env.NODE_ENV === 'production'
|
||||
? `${process.env.NEXTAUTH_URL || 'https://siliconpin.com'}`
|
||||
: 'http://localhost:4023'
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/topics/tags`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch tags: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error?.message || 'Failed to fetch tags')
|
||||
}
|
||||
|
||||
return result.data || []
|
||||
} catch (error) {
|
||||
console.error('Error fetching tags:', error)
|
||||
|
||||
// Fallback to empty array
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Topic grid skeleton loader
|
||||
function TopicGridSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i} className="overflow-hidden">
|
||||
<Skeleton className="aspect-video w-full" />
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-full mb-3" />
|
||||
<Skeleton className="h-4 w-full mb-2" />
|
||||
<Skeleton className="h-4 w-3/4 mb-4" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-6 w-16" />
|
||||
<Skeleton className="h-6 w-20" />
|
||||
<Skeleton className="h-6 w-14" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function TopicsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
|
||||
}) {
|
||||
const {
|
||||
page: currentPage,
|
||||
tag: selectedTag,
|
||||
search: searchQuery,
|
||||
} = await parseSearchParams(searchParams)
|
||||
|
||||
const { topics, pagination } = await getTopics(searchParams)
|
||||
const tags = await getTags()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<section className="py-12 md:py-20">
|
||||
<div className="container-custom">
|
||||
<h1 className="section-heading mb-12 text-center">Topic Posts</h1>
|
||||
|
||||
{/* Search and filter section */}
|
||||
<div className="mb-10 space-y-6">
|
||||
<form action="/topics" method="GET" className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Search posts..."
|
||||
name="q"
|
||||
defaultValue={searchQuery}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button type="submit">
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
Search
|
||||
</Button>
|
||||
{searchQuery && (
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href="/topics">Clear</Link>
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
<Link
|
||||
key={tag.id}
|
||||
href={`/blogs${selectedTag === tag.name ? '' : `?tag=${tag.name}`}${searchQuery ? `&q=${searchQuery}` : ''}`}
|
||||
className="transition-colors"
|
||||
>
|
||||
<Badge
|
||||
variant={selectedTag === tag.name ? 'default' : 'outline'}
|
||||
className={
|
||||
selectedTag === tag.name
|
||||
? 'hover:bg-primary/90'
|
||||
: 'hover:bg-primary/10 hover:text-primary'
|
||||
}
|
||||
>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter info */}
|
||||
{(selectedTag || searchQuery) && (
|
||||
<div className="mb-6 rounded-md bg-muted p-3 text-sm">
|
||||
<p>
|
||||
{pagination.total} post(s) found
|
||||
{selectedTag && ` with tag: ${selectedTag}`}
|
||||
{searchQuery && ` containing: "${searchQuery}"`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Topic posts grid */}
|
||||
<Suspense fallback={<TopicGridSkeleton />}>
|
||||
{topics.length > 0 ? (
|
||||
<div className="mb-10 grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{topics.map((topic) => (
|
||||
<Link
|
||||
key={topic.id}
|
||||
href={`/topics/${topic.slug}`}
|
||||
className="topic-card group block"
|
||||
>
|
||||
<div className="aspect-video overflow-hidden">
|
||||
<Image
|
||||
src={topic.coverImage}
|
||||
alt={topic.title}
|
||||
width={400}
|
||||
height={225}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="mb-2 flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
{new Date(topic.publishedAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
{(topic.views || 0) > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye className="h-3 w-3" />
|
||||
{topic.views?.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="mb-2 text-xl font-bold transition-colors group-hover:text-primary">
|
||||
{topic.title}
|
||||
</h2>
|
||||
<p className="mb-4 line-clamp-3 text-muted-foreground">{topic.excerpt}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{topic.tags.map((tag) => (
|
||||
<Badge key={tag.id} variant="outline">
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-10 text-center">
|
||||
<p className="text-xl text-muted-foreground">
|
||||
No posts found matching your criteria.
|
||||
</p>
|
||||
<Button variant="outline" className="mt-4" asChild>
|
||||
<Link href="/topics">Reset filters</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{topics.length > 0 && pagination.totalPages > 1 && (
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
{currentPage > 1 && (
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href={`/topics?page=${currentPage - 1}${selectedTag ? `&tag=${selectedTag}` : ''}${searchQuery ? `&q=${searchQuery}` : ''}`}
|
||||
size="default"
|
||||
/>
|
||||
</PaginationItem>
|
||||
)}
|
||||
|
||||
{Array.from({ length: Math.min(pagination.totalPages, 5) }, (_, i) => {
|
||||
const page = i + 1
|
||||
return (
|
||||
<PaginationItem key={page}>
|
||||
<PaginationLink
|
||||
href={`/topics?page=${page}${selectedTag ? `&tag=${selectedTag}` : ''}${searchQuery ? `&q=${searchQuery}` : ''}`}
|
||||
isActive={page === currentPage}
|
||||
size="default"
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
)
|
||||
})}
|
||||
|
||||
{currentPage < pagination.totalPages && (
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href={`/topics?page=${currentPage + 1}${selectedTag ? `&tag=${selectedTag}` : ''}${searchQuery ? `&q=${searchQuery}` : ''}`}
|
||||
size="default"
|
||||
/>
|
||||
</PaginationItem>
|
||||
)}
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)}
|
||||
</Suspense>
|
||||
</div>
|
||||
</section>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user