Files
goGin/frontend/app/admin/posts/_components/post-dialog.tsx
Beyhan Oğur 2a5b661443 first commit
2026-04-26 21:46:42 +03:00

438 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
)
}