457 lines
15 KiB
TypeScript
457 lines
15 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import DOMPurify from 'dompurify'
|
|
import { useNavigate } from '@tanstack/react-router'
|
|
import { useForm } from 'react-hook-form'
|
|
import { zodResolver } from '@hookform/resolvers/zod'
|
|
import { z } from 'zod'
|
|
import { ArrowLeft, Loader2, Save, Trash2, X } from 'lucide-react'
|
|
import { Button } from '@components/ui/button'
|
|
import { Input } from '@components/ui/input'
|
|
import { Textarea } from '@components/ui/textarea'
|
|
import { Switch } from '@components/ui/switch'
|
|
import { Badge } from '@components/ui/badge'
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@components/ui/card'
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormDescription,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from '@components/ui/form'
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from '@components/ui/alert-dialog'
|
|
import { Skeleton } from '@components/ui/skeleton'
|
|
import BundledEditor from '../BundledEditor'
|
|
import type { Editor } from 'tinymce'
|
|
import {
|
|
useAdminArticle,
|
|
useCreateArticle,
|
|
useDeleteArticle,
|
|
useUpdateArticle,
|
|
} from '@/hooks/use-articles'
|
|
|
|
const articleSchema = z.object({
|
|
title: z.string().min(1, 'Title is required').max(200, 'Title is too long'),
|
|
preview: z
|
|
.string()
|
|
.min(1, 'Preview is required')
|
|
.max(500, 'Preview must be 500 characters or less'),
|
|
content: z.string().min(1, 'Content is required'),
|
|
coverImage: z
|
|
.string()
|
|
.url('Must be a valid URL')
|
|
.optional()
|
|
.or(z.literal('')),
|
|
tags: z.array(z.string()),
|
|
published: z.boolean(),
|
|
})
|
|
|
|
type ArticleFormValues = z.infer<typeof articleSchema>
|
|
|
|
interface ArticleEditorProps {
|
|
articleId?: string
|
|
}
|
|
|
|
export function ArticleEditor({ articleId }: ArticleEditorProps) {
|
|
const navigate = useNavigate()
|
|
const isEdit = !!articleId
|
|
|
|
const { data: article, isLoading } = useAdminArticle(articleId || '')
|
|
const createArticle = useCreateArticle()
|
|
const updateArticle = useUpdateArticle(articleId || '')
|
|
const deleteArticle = useDeleteArticle()
|
|
|
|
const [tagInput, setTagInput] = useState('')
|
|
|
|
const form = useForm<ArticleFormValues>({
|
|
resolver: zodResolver(articleSchema),
|
|
defaultValues: {
|
|
title: '',
|
|
preview: '',
|
|
content: '',
|
|
coverImage: '',
|
|
tags: [],
|
|
published: false,
|
|
},
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (article) {
|
|
const sanitizedContent = DOMPurify.sanitize(article.content)
|
|
form.reset({
|
|
title: article.title,
|
|
preview: article.preview,
|
|
content: sanitizedContent,
|
|
coverImage: article.coverImage || '',
|
|
tags: article.tags,
|
|
published: article.published,
|
|
})
|
|
}
|
|
}, [article, form])
|
|
|
|
const onSubmit = (data: ArticleFormValues) => {
|
|
const payload = {
|
|
...data,
|
|
coverImage: data.coverImage || undefined,
|
|
}
|
|
|
|
if (isEdit) {
|
|
updateArticle.mutate(payload, {
|
|
onSuccess: () => navigate({ to: '/admin/articles' }),
|
|
})
|
|
} else {
|
|
createArticle.mutate(payload, {
|
|
onSuccess: () => navigate({ to: '/admin/articles' }),
|
|
})
|
|
}
|
|
}
|
|
|
|
const handleDelete = () => {
|
|
if (articleId) {
|
|
deleteArticle.mutate(articleId, {
|
|
onSuccess: () => navigate({ to: '/admin/articles' }),
|
|
})
|
|
}
|
|
}
|
|
|
|
const handleAddTag = () => {
|
|
const tag = tagInput.trim().toLowerCase()
|
|
if (tag && !form.getValues('tags').includes(tag)) {
|
|
form.setValue('tags', [...form.getValues('tags'), tag])
|
|
setTagInput('')
|
|
}
|
|
}
|
|
|
|
const handleRemoveTag = (tagToRemove: string) => {
|
|
form.setValue(
|
|
'tags',
|
|
form.getValues('tags').filter((tag) => tag !== tagToRemove),
|
|
)
|
|
}
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
handleAddTag()
|
|
}
|
|
}
|
|
|
|
const editorRef = useRef<Editor | null>(null)
|
|
const isPending = createArticle.isPending || updateArticle.isPending
|
|
|
|
if (isEdit && isLoading) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center gap-4">
|
|
<Skeleton className="h-10 w-10" />
|
|
<Skeleton className="h-8 w-48" />
|
|
</div>
|
|
<Card>
|
|
<CardHeader>
|
|
<Skeleton className="h-6 w-32" />
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<Skeleton className="h-10 w-full" />
|
|
<Skeleton className="h-24 w-full" />
|
|
<Skeleton className="h-48 w-full" />
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center gap-4">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => navigate({ to: '/admin/articles' })}
|
|
>
|
|
<ArrowLeft className="size-5" />
|
|
</Button>
|
|
<div className="flex-1">
|
|
<h1 className="text-3xl font-bold tracking-tight">
|
|
{isEdit ? 'Edit Article' : 'New Article'}
|
|
</h1>
|
|
<p className="text-muted-foreground">
|
|
{isEdit ? 'Update your article' : 'Create a new blog post'}
|
|
</p>
|
|
</div>
|
|
{isEdit && (
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button variant="destructive" size="sm">
|
|
<Trash2 className="mr-2 size-4" />
|
|
Delete
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete Article</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Are you sure you want to delete this article? This action
|
|
cannot be undone.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleDelete}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
Delete
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
)}
|
|
</div>
|
|
|
|
<Form {...form}>
|
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
|
<div className="grid gap-6 lg:grid-cols-3">
|
|
<div className="lg:col-span-2 space-y-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Content</CardTitle>
|
|
<CardDescription>Write your article content</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<FormField
|
|
control={form.control}
|
|
name="title"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Title</FormLabel>
|
|
<FormControl>
|
|
<Input placeholder="Article title" {...field} />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="preview"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Preview</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder="Short preview text (max 500 characters)"
|
|
className="resize-none"
|
|
rows={3}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{field.value.length}/500 characters
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="content"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Content</FormLabel>
|
|
<FormControl>
|
|
<BundledEditor
|
|
value={field.value}
|
|
onEditorChange={(value) =>
|
|
field.onChange(DOMPurify.sanitize(value))
|
|
}
|
|
onBlur={field.onBlur}
|
|
onInit={(_evt, editor) =>
|
|
(editorRef.current = editor)
|
|
}
|
|
init={{
|
|
height: 500,
|
|
menubar: true,
|
|
plugins: [
|
|
'advlist',
|
|
'autolink',
|
|
'lists',
|
|
'link',
|
|
'image',
|
|
'charmap',
|
|
'anchor',
|
|
'searchreplace',
|
|
'visualblocks',
|
|
'code',
|
|
'fullscreen',
|
|
'insertdatetime',
|
|
'media',
|
|
'table',
|
|
'preview',
|
|
'help',
|
|
'wordcount',
|
|
],
|
|
toolbar:
|
|
'undo redo | blocks | ' +
|
|
'bold italic forecolor | alignleft aligncenter ' +
|
|
'alignright alignjustify | bullist numlist outdent indent | ' +
|
|
'removeformat | help',
|
|
content_style:
|
|
'body { font-family:Helvetica,Arial,sans-serif; font-size:14px }',
|
|
}}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Publishing</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<FormField
|
|
control={form.control}
|
|
name="published"
|
|
render={({ field }) => (
|
|
<FormItem className="flex items-center justify-between rounded-lg border p-3">
|
|
<div className="space-y-0.5">
|
|
<FormLabel className="text-base">Published</FormLabel>
|
|
<FormDescription>
|
|
Make this article visible to the public
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<Button type="submit" className="w-full" disabled={isPending}>
|
|
{isPending && (
|
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
|
)}
|
|
<Save className="mr-2 size-4" />
|
|
{isEdit ? 'Save Changes' : 'Create Article'}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Cover Image</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<FormField
|
|
control={form.control}
|
|
name="coverImage"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormControl>
|
|
<Input placeholder="https://..." {...field} />
|
|
</FormControl>
|
|
<FormDescription>
|
|
URL to the cover image (optional)
|
|
</FormDescription>
|
|
<FormMessage />
|
|
{field.value && (
|
|
<img
|
|
src={field.value}
|
|
alt="Cover preview"
|
|
className="mt-2 rounded-lg object-cover w-full aspect-video"
|
|
onError={(e) => {
|
|
e.currentTarget.style.display = 'none'
|
|
}}
|
|
/>
|
|
)}
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Tags</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="flex gap-2">
|
|
<Input
|
|
placeholder="Add a tag"
|
|
value={tagInput}
|
|
onChange={(e) => setTagInput(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
onClick={handleAddTag}
|
|
variant="secondary"
|
|
>
|
|
Add
|
|
</Button>
|
|
</div>
|
|
<FormField
|
|
control={form.control}
|
|
name="tags"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<div className="flex flex-wrap gap-2">
|
|
{field.value.map((tag) => (
|
|
<Badge
|
|
key={tag}
|
|
variant="secondary"
|
|
className="gap-1"
|
|
>
|
|
{tag}
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRemoveTag(tag)}
|
|
className="ml-1 hover:text-destructive"
|
|
>
|
|
<X className="size-3" />
|
|
</button>
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
{field.value.length === 0 && (
|
|
<p className="text-sm text-muted-foreground">
|
|
No tags added yet
|
|
</p>
|
|
)}
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</Form>
|
|
</div>
|
|
)
|
|
}
|