altricade_portofolio/src/components/admin/article-editor.tsx
Заид Омар Медхат | Zaid Omar Medhat fab8983d5c
Some checks failed
Deploy / deploy (push) Failing after 5m27s
init
2026-07-09 12:21:21 +05:00

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>
)
}