first commit
This commit is contained in:
132
frontend/app/admin/posts/_components/columns.tsx
Normal file
132
frontend/app/admin/posts/_components/columns.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client"
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table"
|
||||
import { Post } from "@/types/post"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Edit, Trash, RotateCcw } from "lucide-react"
|
||||
|
||||
interface PostColumnsProps {
|
||||
onEdit: (post: Post) => void
|
||||
onDelete: (id: number) => void
|
||||
onRestore: (id: number) => void
|
||||
statusFilter: string
|
||||
deletedIds: number[]
|
||||
}
|
||||
|
||||
export const getPostColumns = ({ onEdit, onDelete, onRestore, statusFilter, deletedIds }: PostColumnsProps): ColumnDef<Post>[] => [
|
||||
{
|
||||
accessorKey: "images",
|
||||
header: "Görsel",
|
||||
cell: ({ row }) => {
|
||||
const rawImages = row.original.images
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080"
|
||||
|
||||
// Backend tarafında "images" alanı virgülle ayrılmış birden fazla path içerebilir.
|
||||
// Liste görünümünde ilk path'i küçük görsel için kullanalım.
|
||||
const firstImage = rawImages
|
||||
? rawImages
|
||||
.split(",")
|
||||
.map(p => p.trim())
|
||||
.filter(Boolean)[0]
|
||||
: null
|
||||
|
||||
const fullUrl = firstImage
|
||||
? (firstImage.startsWith("http") ? firstImage : `${apiUrl}${firstImage}`)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="w-16 h-10 bg-gray-100 rounded overflow-hidden flex items-center justify-center">
|
||||
{fullUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={fullUrl} alt={row.original.title} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">Yok</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: "Başlık",
|
||||
},
|
||||
{
|
||||
accessorKey: "categories",
|
||||
header: "Kategoriler",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{row.original.categories?.map((cat, index) => (
|
||||
<span key={cat.id || cat.title || index} className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
|
||||
{cat.title}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "tags",
|
||||
header: "Etiketler",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{row.original.tags?.map((tag, index) => (
|
||||
<span key={tag.id || tag.name || index} className="px-2 py-1 bg-gray-100 text-gray-800 text-xs rounded-full">
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "updated_at",
|
||||
header: "Son Güncelleme",
|
||||
cell: ({ row }) => {
|
||||
const updatedAt = row.original.updated_at || row.original.UpdatedAt
|
||||
return updatedAt ? new Date(updatedAt).toLocaleDateString("tr-TR") : "-"
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const id = row.original.id || row.original.ID
|
||||
const inDeletedList = typeof id === "number" && deletedIds.includes(id)
|
||||
|
||||
// "Sadece Silinenler" filtresinde hepsi silinmiş kabul edilir.
|
||||
// "Tümü (Dahil)" filtresinde ise deletedIds listesine bakılır.
|
||||
const isDeleted = statusFilter === "only" || inDeletedList
|
||||
return (
|
||||
<div className="flex gap-2 justify-end">
|
||||
{isDeleted ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onRestore(row.original.id || row.original.ID!)}
|
||||
className="h-8 w-8 p-0 text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||
title="Geri Yükle"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onEdit(row.original)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => onDelete(row.original.id || row.original.ID!)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
437
frontend/app/admin/posts/_components/post-dialog.tsx
Normal file
437
frontend/app/admin/posts/_components/post-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user