first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:46:42 +03:00
commit 2a5b661443
202 changed files with 49770 additions and 0 deletions

View File

@@ -0,0 +1,437 @@
"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>
)
}