438 lines
20 KiB
TypeScript
438 lines
20 KiB
TypeScript
"use client"
|
||
|
||
import { useState, useEffect } from "react"
|
||
import { useForm } from "react-hook-form"
|
||
import { zodResolver } from "@hookform/resolvers/zod"
|
||
import * as z from "zod"
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||
import { Button } from "@/components/ui/button"
|
||
import {
|
||
Form,
|
||
FormControl,
|
||
FormField,
|
||
FormItem,
|
||
FormLabel,
|
||
FormMessage,
|
||
} from "@/components/ui/form"
|
||
import { Input } from "@/components/ui/input"
|
||
import { Textarea } from "@/components/ui/textarea"
|
||
import { Post } from "@/types/post"
|
||
import { categoryService } from "@/services/categoryService"
|
||
import { postService } from "@/services/postService"
|
||
import { tagService } from "@/services/tagService"
|
||
import { Category } from "@/types/category"
|
||
import { Tag } from "@/types/tag"
|
||
import { toast } from "sonner"
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select"
|
||
import { useSlug } from "@/hooks/useSlug"
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||
|
||
// MultiSelect component specifically for Shadcn UI
|
||
// Since Shadcn doesn't have a native MultiSelect, we'll use a simple implementation or standard select with multiple
|
||
// For better UI, using a basic select list for now, ideally should use a proper MultiSelect component
|
||
const MultiSelect = ({
|
||
options,
|
||
selected,
|
||
onChange
|
||
}: {
|
||
options: { label: string; value: string }[]
|
||
selected: string[]
|
||
onChange: (values: string[]) => void
|
||
}) => {
|
||
return (
|
||
<div className="border rounded-md p-2 max-h-40 overflow-y-auto">
|
||
{options.map((option) => (
|
||
<div key={option.value} className="flex items-center gap-2 mb-1">
|
||
<input
|
||
type="checkbox"
|
||
id={`opt-${option.value}`}
|
||
checked={selected.includes(option.value)}
|
||
onChange={(e) => {
|
||
if (e.target.checked) {
|
||
onChange([...selected, option.value])
|
||
} else {
|
||
onChange(selected.filter((v) => v !== option.value))
|
||
}
|
||
}}
|
||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||
/>
|
||
<label htmlFor={`opt-${option.value}`} className="text-sm cursor-pointer select-none">
|
||
{option.label}
|
||
</label>
|
||
</div>
|
||
))}
|
||
{options.length === 0 && <p className="text-sm text-gray-500 py-2 text-center">Veri bulunamadı.</p>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const formSchema = z.object({
|
||
title: z.string().min(2, "Başlık en az 2 karakter olmalı"),
|
||
slug: z.string().min(2, "Slug en az 2 karakter olmalı"),
|
||
content: z.string().min(10, "İçerik en az 10 karakter olmalı"),
|
||
category_ids: z.array(z.string()).min(1, "En az bir kategori seçilmelidir"),
|
||
tag_names: z.array(z.string()).optional(), // Changed to array for MultiSelect
|
||
|
||
// Image Config
|
||
images: z.any().optional(),
|
||
width: z.coerce.number().min(1).default(800),
|
||
height: z.coerce.number().min(1).default(600),
|
||
quality: z.coerce.number().min(1).max(100).default(85),
|
||
format: z.string().default("webp"),
|
||
})
|
||
|
||
interface PostDialogProps {
|
||
open: boolean
|
||
onOpenChange: (open: boolean) => void
|
||
post?: Post | null
|
||
onSuccess?: () => void
|
||
}
|
||
|
||
export function PostDialog({ open, onOpenChange, post, onSuccess }: PostDialogProps) {
|
||
const [loading, setLoading] = useState(false)
|
||
const [categories, setCategories] = useState<Category[]>([])
|
||
const [tags, setTags] = useState<Tag[]>([])
|
||
const [preview, setPreview] = useState<string | null>(null)
|
||
const { slugify } = useSlug()
|
||
|
||
const form = useForm<z.infer<typeof formSchema>>({
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
resolver: zodResolver(formSchema) as any,
|
||
defaultValues: {
|
||
title: "",
|
||
slug: "",
|
||
content: "",
|
||
category_ids: [],
|
||
tag_names: [],
|
||
width: 800,
|
||
height: 600,
|
||
quality: 85,
|
||
format: "webp",
|
||
},
|
||
})
|
||
|
||
useEffect(() => {
|
||
const loadData = async () => {
|
||
try {
|
||
const catRes = await categoryService.getCategories(1, 100)
|
||
setCategories(catRes.items || [])
|
||
|
||
const tagRes = await tagService.getTags(1, 100)
|
||
setTags(tagRes.items || [])
|
||
|
||
} catch (error) {
|
||
console.error("Failed to load categories/tags", error)
|
||
}
|
||
}
|
||
|
||
if (open) {
|
||
loadData()
|
||
}
|
||
}, [open])
|
||
|
||
useEffect(() => {
|
||
if (post) {
|
||
const categoryIds =
|
||
post.categories
|
||
?.map(c => {
|
||
const id = c.id ?? c.ID
|
||
return id != null ? id.toString() : null
|
||
})
|
||
.filter((id): id is string => !!id) || []
|
||
|
||
form.reset({
|
||
title: post.title,
|
||
slug: post.slug,
|
||
content: post.content,
|
||
category_ids: categoryIds,
|
||
tag_names: post.tags?.map(t => t.name) || [],
|
||
width: post.width || 800,
|
||
height: post.height || 600,
|
||
quality: post.quality || 85,
|
||
format: post.format || "webp",
|
||
})
|
||
|
||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080"
|
||
if (post.images) {
|
||
// Backend \"images\" alanı birden fazla path'i virgülle birleştirebiliyor.
|
||
// Dialog önizlemesi için ilk path'i kullan.
|
||
const firstImage = post.images
|
||
.split(",")
|
||
.map(p => p.trim())
|
||
.filter(Boolean)[0]
|
||
|
||
if (firstImage) {
|
||
setPreview(firstImage.startsWith("http") ? firstImage : `${apiUrl}${firstImage}`)
|
||
}
|
||
}
|
||
} else {
|
||
form.reset({
|
||
title: "",
|
||
slug: "",
|
||
content: "",
|
||
category_ids: [],
|
||
tag_names: [],
|
||
width: 800,
|
||
height: 600,
|
||
quality: 85,
|
||
format: "webp",
|
||
})
|
||
setPreview(null)
|
||
}
|
||
}, [post, form, open])
|
||
|
||
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const title = e.target.value
|
||
form.setValue("title", title)
|
||
if (!post) { // Only auto-slug on create
|
||
form.setValue("slug", slugify(title))
|
||
}
|
||
}
|
||
|
||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0]
|
||
if (file) {
|
||
form.setValue("images", file)
|
||
const reader = new FileReader()
|
||
reader.onloadend = () => {
|
||
setPreview(reader.result as string)
|
||
}
|
||
reader.readAsDataURL(file)
|
||
}
|
||
}
|
||
|
||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||
setLoading(true)
|
||
const formData = new FormData()
|
||
|
||
formData.append("title", values.title)
|
||
formData.append("slug", values.slug)
|
||
formData.append("content", values.content)
|
||
|
||
// Append categories - Backend likely expects multiple fields with same name or comma separated
|
||
// Based on curl example: -F 'category_ids=1'
|
||
// If multiple, standard is usually repeating the field
|
||
values.category_ids.forEach(id => {
|
||
formData.append("category_ids", id)
|
||
})
|
||
|
||
// Tags - Backend, dokümana göre tekrar eden 'tag_names' alanlarını bekliyor:
|
||
// -F 'tag_names=tag1' -F 'tag_names=tag2'
|
||
if (values.tag_names && values.tag_names.length > 0) {
|
||
values.tag_names.forEach(name => {
|
||
formData.append("tag_names", name)
|
||
})
|
||
}
|
||
|
||
// Image config
|
||
formData.append("width", values.width.toString())
|
||
formData.append("height", values.height.toString())
|
||
formData.append("quality", values.quality.toString())
|
||
formData.append("format", values.format)
|
||
|
||
if (values.images instanceof File) {
|
||
formData.append("images", values.images)
|
||
}
|
||
|
||
try {
|
||
if (post) {
|
||
await postService.updatePost(post.id || post.ID!, formData)
|
||
toast.success("Yazı başarıyla güncellendi")
|
||
} else {
|
||
await postService.createPost(formData)
|
||
toast.success("Yazı başarıyla oluşturuldu")
|
||
}
|
||
onOpenChange(false)
|
||
if (onSuccess) onSuccess()
|
||
} catch (error: unknown) {
|
||
console.error("Post save error:", error)
|
||
toast.error((error as Error).message || "Bir hata oluştu")
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="sm:max-w-[800px] h-[90vh] overflow-y-auto flex flex-col">
|
||
<DialogHeader>
|
||
<DialogTitle>{post ? "Yazı Düzenle" : "Yeni Yazı Ekle"}</DialogTitle>
|
||
</DialogHeader>
|
||
|
||
<div className="flex-1 overflow-y-auto py-2">
|
||
<Form {...form}>
|
||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||
<Tabs defaultValue="content" className="w-full">
|
||
<TabsList className="grid w-full grid-cols-2">
|
||
<TabsTrigger value="content">İçerik</TabsTrigger>
|
||
<TabsTrigger value="media">Medya & SEO</TabsTrigger>
|
||
</TabsList>
|
||
|
||
{/* CONTENT TAB */}
|
||
<TabsContent value="content" className="space-y-4 pt-4">
|
||
<FormField
|
||
control={form.control}
|
||
name="title"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>Başlık</FormLabel>
|
||
<FormControl>
|
||
<Input placeholder="Yazı Başlığı" {...field} onChange={handleTitleChange} />
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="slug"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>SEO URL (Slug)</FormLabel>
|
||
<FormControl>
|
||
<Input placeholder="yazi-basligi" {...field} />
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<FormField
|
||
control={form.control}
|
||
name="category_ids"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>Kategoriler</FormLabel>
|
||
<FormControl>
|
||
<MultiSelect
|
||
options={categories.map(c => ({ label: c.title, value: c.id.toString() }))}
|
||
selected={field.value}
|
||
onChange={field.onChange}
|
||
/>
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="tag_names"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>Etiketler</FormLabel>
|
||
<FormControl>
|
||
<MultiSelect
|
||
options={tags.map(t => ({ label: t.name, value: t.name }))}
|
||
selected={field.value || []}
|
||
onChange={field.onChange}
|
||
/>
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</div>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="content"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>İçerik</FormLabel>
|
||
<FormControl>
|
||
<Textarea className="min-h-[300px]" placeholder="Yazı içeriği..." {...field} />
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</TabsContent>
|
||
|
||
{/* MEDIA TAB */}
|
||
<TabsContent value="media" className="space-y-4 pt-4">
|
||
<div className="border p-4 rounded-md space-y-4">
|
||
<h3 className="font-semibold text-lg">Öne Çıkan Görsel</h3>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<FormField
|
||
control={form.control}
|
||
name="width"
|
||
render={({ field }) => (
|
||
<FormItem><FormLabel>Genişlik</FormLabel><FormControl><Input type="number" {...field} /></FormControl><FormMessage /></FormItem>
|
||
)}
|
||
/>
|
||
<FormField
|
||
control={form.control}
|
||
name="height"
|
||
render={({ field }) => (
|
||
<FormItem><FormLabel>Yükseklik</FormLabel><FormControl><Input type="number" {...field} /></FormControl><FormMessage /></FormItem>
|
||
)}
|
||
/>
|
||
<FormField
|
||
control={form.control}
|
||
name="quality"
|
||
render={({ field }) => (
|
||
<FormItem><FormLabel>Kalite</FormLabel><FormControl><Input type="number" {...field} /></FormControl><FormMessage /></FormItem>
|
||
)}
|
||
/>
|
||
<FormField
|
||
control={form.control}
|
||
name="format"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>Format</FormLabel>
|
||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||
<FormControl>
|
||
<SelectTrigger><SelectValue placeholder="Format" /></SelectTrigger>
|
||
</FormControl>
|
||
<SelectContent>
|
||
<SelectItem value="webp">WebP</SelectItem>
|
||
<SelectItem value="avif">AVIF</SelectItem>
|
||
<SelectItem value="jpeg">JPEG</SelectItem>
|
||
<SelectItem value="png">PNG</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</div>
|
||
|
||
<FormItem>
|
||
<FormLabel>Görsel Dosyası</FormLabel>
|
||
<FormControl>
|
||
<Input type="file" accept="image/*" onChange={handleImageChange} />
|
||
</FormControl>
|
||
{preview && (
|
||
<div className="mt-2 w-full h-48 bg-gray-100 rounded flex items-center justify-center overflow-hidden border">
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img src={preview} alt="Preview" className="h-full object-contain" />
|
||
</div>
|
||
)}
|
||
</FormItem>
|
||
</div>
|
||
</TabsContent>
|
||
</Tabs>
|
||
|
||
<DialogFooter>
|
||
<Button type="submit" disabled={loading}>
|
||
{loading ? "Kaydediliyor..." : "Kaydet"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</form>
|
||
</Form>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|