feat: weighted match scoring, photo upload, property detail page
- scoring.ts: pure scoreMatch + scoreMatchBreakdown with per-criterion weights - matching.ts: soft scoring (0-100), updates score on re-sync, threshold 20 - search-form-sheet: weight selectors (1-5) per criterion - customer-search-actions: save/update weight fields - storage-actions: upload/delete property images to property-images bucket - storage-utils: getPropertyImagePreviewUrl, parseImageIds helpers - property-image-uploader: client component with preview grid + delete - property-form-sheet: integrated image uploader - properties/[id]: detail page with gallery, specs, matches sidebar - properties-client: Detay link in dropdown - matches page: MatchesClient with click-to-breakdown dialog - sunum page: cover image from first imageIds entry - matches-client + match-breakdown-dialog: score breakdown per criterion
This commit is contained in:
@@ -24,6 +24,30 @@ import type { Customer, CustomerSearch } from "@/lib/appwrite/schema";
|
||||
type ActionState = { ok: boolean; error?: string; fieldErrors?: Record<string, string[]> };
|
||||
const INITIAL: ActionState = { ok: false };
|
||||
|
||||
const WEIGHT_OPTIONS = [
|
||||
{ value: "1", label: "1 — Önemsiz" },
|
||||
{ value: "2", label: "2 — Az önemli" },
|
||||
{ value: "3", label: "3 — Orta" },
|
||||
{ value: "4", label: "4 — Önemli" },
|
||||
{ value: "5", label: "5 — Çok önemli" },
|
||||
];
|
||||
|
||||
function WeightSelect({ name, defaultValue }: { name: string; defaultValue?: number | null }) {
|
||||
return (
|
||||
<select
|
||||
name={name}
|
||||
defaultValue={String(defaultValue ?? 3)}
|
||||
className="border-input bg-background h-8 rounded-md border px-2 text-xs w-40"
|
||||
>
|
||||
{WEIGHT_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
interface SearchFormSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
@@ -59,7 +83,6 @@ export function SearchFormSheet({
|
||||
|
||||
const fe = state.fieldErrors ?? {};
|
||||
|
||||
// Parse existing JSON array fields back to comma-separated strings for display
|
||||
function parseJsonToInput(json?: string | null): string {
|
||||
if (!json) return "";
|
||||
try {
|
||||
@@ -77,7 +100,8 @@ export function SearchFormSheet({
|
||||
<SheetTitle>{search ? "Aramayı Düzenle" : "Yeni Arama Kriteri"}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<form action={formAction} className="mt-4 space-y-4 pb-6">
|
||||
<form action={formAction} className="mt-4 space-y-5 pb-6">
|
||||
{/* Müşteri */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Müşteri *</Label>
|
||||
<select
|
||||
@@ -95,6 +119,7 @@ export function SearchFormSheet({
|
||||
{fe.customerId && <p className="text-destructive text-xs">{fe.customerId[0]}</p>}
|
||||
</div>
|
||||
|
||||
{/* İlan türü */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label>İlan türü</Label>
|
||||
<select
|
||||
@@ -108,6 +133,7 @@ export function SearchFormSheet({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Emlak tipi + ağırlık */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="propertyTypes">Emlak tipleri</Label>
|
||||
<p className="text-muted-foreground text-xs">Virgülle ayırın. Örn: daire, villa</p>
|
||||
@@ -117,8 +143,13 @@ export function SearchFormSheet({
|
||||
defaultValue={parseJsonToInput(search?.propertyTypes)}
|
||||
placeholder="daire, villa"
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-muted-foreground text-xs">Önem:</span>
|
||||
<WeightSelect name="propertyTypeWeight" defaultValue={search?.propertyTypeWeight} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Oda sayısı + ağırlık */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="roomCounts">Oda sayıları</Label>
|
||||
<p className="text-muted-foreground text-xs">Virgülle ayırın. Örn: 2+1, 3+1</p>
|
||||
@@ -128,50 +159,76 @@ export function SearchFormSheet({
|
||||
defaultValue={parseJsonToInput(search?.roomCounts)}
|
||||
placeholder="2+1, 3+1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="minPrice">Min fiyat</Label>
|
||||
<Input id="minPrice" name="minPrice" type="number" min="0" defaultValue={search?.minPrice ?? ""} />
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="maxPrice">Max fiyat</Label>
|
||||
<Input id="maxPrice" name="maxPrice" type="number" min="0" defaultValue={search?.maxPrice ?? ""} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="minM2">Min m²</Label>
|
||||
<Input id="minM2" name="minM2" type="number" min="0" defaultValue={search?.minM2 ?? ""} />
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="maxM2">Max m²</Label>
|
||||
<Input id="maxM2" name="maxM2" type="number" min="0" defaultValue={search?.maxM2 ?? ""} />
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-muted-foreground text-xs">Önem:</span>
|
||||
<WeightSelect name="roomCountWeight" defaultValue={search?.roomCountWeight} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fiyat aralığı + ağırlık */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="cities">Şehirler</Label>
|
||||
<Input
|
||||
id="cities"
|
||||
name="cities"
|
||||
defaultValue={parseJsonToInput(search?.cities)}
|
||||
placeholder="İstanbul, Ankara"
|
||||
/>
|
||||
<Label>Fiyat aralığı</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1">
|
||||
<span className="text-muted-foreground text-xs">Min</span>
|
||||
<Input name="minPrice" type="number" min="0" defaultValue={search?.minPrice ?? ""} />
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<span className="text-muted-foreground text-xs">Max</span>
|
||||
<Input name="maxPrice" type="number" min="0" defaultValue={search?.maxPrice ?? ""} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-muted-foreground text-xs">Önem:</span>
|
||||
<WeightSelect name="priceWeight" defaultValue={search?.priceWeight} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* M2 aralığı + ağırlık */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="districts">İlçeler</Label>
|
||||
<Input
|
||||
id="districts"
|
||||
name="districts"
|
||||
defaultValue={parseJsonToInput(search?.districts)}
|
||||
placeholder="Kadıköy, Beşiktaş"
|
||||
/>
|
||||
<Label>m² aralığı</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1">
|
||||
<span className="text-muted-foreground text-xs">Min</span>
|
||||
<Input name="minM2" type="number" min="0" defaultValue={search?.minM2 ?? ""} />
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<span className="text-muted-foreground text-xs">Max</span>
|
||||
<Input name="maxM2" type="number" min="0" defaultValue={search?.maxM2 ?? ""} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-muted-foreground text-xs">Önem:</span>
|
||||
<WeightSelect name="m2Weight" defaultValue={search?.m2Weight} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Konum + ağırlık */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Konum</Label>
|
||||
<div className="grid gap-1">
|
||||
<span className="text-muted-foreground text-xs">Şehirler (virgülle ayırın)</span>
|
||||
<Input
|
||||
name="cities"
|
||||
defaultValue={parseJsonToInput(search?.cities)}
|
||||
placeholder="İstanbul, Ankara"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1 mt-1">
|
||||
<span className="text-muted-foreground text-xs">İlçeler (virgülle ayırın)</span>
|
||||
<Input
|
||||
name="districts"
|
||||
defaultValue={parseJsonToInput(search?.districts)}
|
||||
placeholder="Kadıköy, Beşiktaş"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-muted-foreground text-xs">Önem:</span>
|
||||
<WeightSelect name="locationWeight" defaultValue={search?.locationWeight} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notlar */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="notes">Notlar</Label>
|
||||
<Textarea id="notes" name="notes" rows={2} defaultValue={search?.notes ?? ""} />
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { scoreMatchBreakdown } from "@/lib/scoring";
|
||||
import type { Property, PropertyMatch, CustomerSearch } from "@/lib/appwrite/schema";
|
||||
|
||||
interface Props {
|
||||
match: PropertyMatch;
|
||||
property: Property | undefined;
|
||||
search: CustomerSearch | undefined;
|
||||
customerName: string;
|
||||
propertyTitle: string;
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export function MatchBreakdownDialog({
|
||||
match,
|
||||
property,
|
||||
search,
|
||||
customerName,
|
||||
propertyTitle,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: Props) {
|
||||
const breakdown =
|
||||
property && search ? scoreMatchBreakdown(property, search) : null;
|
||||
|
||||
const pctColor = (r: number) =>
|
||||
r >= 0.8
|
||||
? "text-green-600"
|
||||
: r >= 0.5
|
||||
? "text-blue-600"
|
||||
: r >= 0.3
|
||||
? "text-yellow-600"
|
||||
: "text-red-500";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Eşleşme Detayı</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg bg-muted/50 px-4 py-3">
|
||||
<div>
|
||||
<p className="font-semibold">{customerName}</p>
|
||||
<p className="text-muted-foreground text-sm">{propertyTitle}</p>
|
||||
</div>
|
||||
<ScoreCircle score={match.score ?? 0} />
|
||||
</div>
|
||||
|
||||
{!breakdown && (
|
||||
<p className="text-muted-foreground text-sm">Kırılım verisi bulunamadı.</p>
|
||||
)}
|
||||
|
||||
{breakdown && (
|
||||
<>
|
||||
{breakdown.criteria.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Bu aramada kriter belirtilmemiş — her ilan 100 puan alır.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{breakdown.criteria.map((c) => (
|
||||
<div key={c.label} className="grid grid-cols-[1fr_auto_auto] items-start gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<span>{c.label}</span>
|
||||
<span className="text-muted-foreground text-xs">(ağırlık: {c.weight})</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">{c.note}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`text-sm font-semibold ${pctColor(c.ratio)}`}>
|
||||
%{Math.round(c.ratio * 100)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{c.earned.toFixed(1)}/{c.weight}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="border-t pt-2 grid grid-cols-[1fr_auto_auto] gap-3">
|
||||
<span className="text-sm font-semibold">Toplam</span>
|
||||
<span className="text-right text-sm font-semibold">
|
||||
%{breakdown.score}
|
||||
</span>
|
||||
<span className="text-right text-xs text-muted-foreground">
|
||||
{breakdown.total.toFixed(1)}/{breakdown.maxPossible}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreCircle({ score }: { score: number }) {
|
||||
const color =
|
||||
score >= 80
|
||||
? "bg-green-100 text-green-700"
|
||||
: score >= 60
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: score >= 40
|
||||
? "bg-yellow-100 text-yellow-700"
|
||||
: "bg-gray-100 text-gray-500";
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex size-12 shrink-0 items-center justify-center rounded-full text-lg font-bold ${color}`}
|
||||
>
|
||||
{score}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { MatchBreakdownDialog } from "./match-breakdown-dialog";
|
||||
import type { Property, PropertyMatch, CustomerSearch, Customer } from "@/lib/appwrite/schema";
|
||||
|
||||
interface MatchesClientProps {
|
||||
matches: PropertyMatch[];
|
||||
customers: Customer[];
|
||||
properties: Property[];
|
||||
searches: CustomerSearch[];
|
||||
}
|
||||
|
||||
function ScoreBadge({ score }: { score?: number | null }) {
|
||||
const s = score ?? 0;
|
||||
const color =
|
||||
s >= 80
|
||||
? "bg-green-100 text-green-700"
|
||||
: s >= 60
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: s >= 40
|
||||
? "bg-yellow-100 text-yellow-700"
|
||||
: "bg-gray-100 text-gray-500";
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${color}`}
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function MatchesClient({ matches, customers, properties, searches }: MatchesClientProps) {
|
||||
const [selectedMatch, setSelectedMatch] = useState<PropertyMatch | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const customerMap = Object.fromEntries(customers.map((c) => [c.$id, c]));
|
||||
const propertyMap = Object.fromEntries(properties.map((p) => [p.$id, p]));
|
||||
const searchMap = Object.fromEntries(searches.map((s) => [s.$id, s]));
|
||||
|
||||
function openBreakdown(m: PropertyMatch) {
|
||||
setSelectedMatch(m);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Eşleşmeler</h1>
|
||||
<span className="text-muted-foreground text-sm">{matches.length} eşleşme</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="p-3 text-left font-medium">Puan</th>
|
||||
<th className="p-3 text-left font-medium">Müşteri</th>
|
||||
<th className="p-3 text-left font-medium">İlan</th>
|
||||
<th className="p-3 text-left font-medium">Tarih</th>
|
||||
<th className="p-3 text-left font-medium">Görüntülendi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{matches.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-muted-foreground py-10 text-center">
|
||||
Henüz eşleşme yok.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{matches.map((m) => {
|
||||
const customer = customerMap[m.customerId];
|
||||
const property = propertyMap[m.propertyId];
|
||||
return (
|
||||
<tr
|
||||
key={m.$id}
|
||||
className="hover:bg-muted/30 cursor-pointer border-b last:border-0"
|
||||
onClick={() => openBreakdown(m)}
|
||||
title="Eşleşme kırılımını görmek için tıklayın"
|
||||
>
|
||||
<td className="p-3">
|
||||
<ScoreBadge score={m.score} />
|
||||
</td>
|
||||
<td className="p-3">{customer?.name ?? m.customerId}</td>
|
||||
<td className="p-3">{property?.title ?? m.propertyId}</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{new Date(m.$createdAt).toLocaleDateString("tr-TR")}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{m.viewedAt ? (
|
||||
<span className="text-xs text-green-600">
|
||||
{new Date(m.viewedAt).toLocaleDateString("tr-TR")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">Hayır</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedMatch && (
|
||||
<MatchBreakdownDialog
|
||||
match={selectedMatch}
|
||||
property={propertyMap[selectedMatch.propertyId]}
|
||||
search={searchMap[selectedMatch.searchId]}
|
||||
customerName={customerMap[selectedMatch.customerId]?.name ?? selectedMatch.customerId}
|
||||
propertyTitle={propertyMap[selectedMatch.propertyId]?.title ?? selectedMatch.propertyId}
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { MoreHorizontal, Plus, Pencil, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { MoreHorizontal, Plus, Pencil, Trash2, ExternalLink } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -108,6 +109,12 @@ export function PropertiesClient({ initialProperties }: PropertiesClientProps) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/properties/${p.$id}`}>
|
||||
<ExternalLink className="mr-2 size-4" />
|
||||
Detay
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openEdit(p)}>
|
||||
<Pencil className="mr-2 size-4" />
|
||||
Düzenle
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { createPropertyAction, updatePropertyAction } from "@/lib/appwrite/property-actions";
|
||||
import { PropertyImageUploader } from "./property-image-uploader";
|
||||
import { parseImageIds } from "@/lib/appwrite/storage-utils";
|
||||
import type { Property } from "@/lib/appwrite/schema";
|
||||
|
||||
type ActionState = { ok: boolean; error?: string; fieldErrors?: Record<string, string[]> };
|
||||
@@ -168,6 +170,14 @@ export function PropertyFormSheet({ open, onOpenChange, property, onSuccess }: P
|
||||
<Textarea id="description" name="description" rows={3} defaultValue={property?.description ?? ""} placeholder="İlan detayları..." />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Fotoğraflar</Label>
|
||||
<PropertyImageUploader
|
||||
name="imageIds"
|
||||
initialImageIds={parseImageIds(property?.imageIds)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button type="submit" disabled={isPending} className="w-full">
|
||||
{isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { Upload, X, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { uploadPropertyImageAction, deletePropertyImageAction } from "@/lib/appwrite/storage-actions";
|
||||
import { getPropertyImagePreviewUrl } from "@/lib/appwrite/storage-utils";
|
||||
|
||||
interface PropertyImageUploaderProps {
|
||||
name: string;
|
||||
initialImageIds?: string[];
|
||||
}
|
||||
|
||||
export function PropertyImageUploader({ name, initialImageIds = [] }: PropertyImageUploaderProps) {
|
||||
const [imageIds, setImageIds] = useState<string[]>(initialImageIds);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function handleFiles(files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const result = await uploadPropertyImageAction(fd);
|
||||
if (result.ok && result.fileId) {
|
||||
setImageIds((prev) => [...prev, result.fileId!]);
|
||||
} else {
|
||||
toast.error(result.error ?? "Yükleme başarısız");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(fileId: string) {
|
||||
const result = await deletePropertyImageAction(fileId);
|
||||
if (result.ok) {
|
||||
setImageIds((prev) => prev.filter((id) => id !== fileId));
|
||||
} else {
|
||||
toast.error(result.error ?? "Fotoğraf silinemedi");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<input type="hidden" name={name} value={JSON.stringify(imageIds)} />
|
||||
|
||||
{imageIds.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{imageIds.map((id) => (
|
||||
<div
|
||||
key={id}
|
||||
className="group relative aspect-video rounded-md overflow-hidden border bg-muted"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getPropertyImagePreviewUrl(id, 400, 300)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(id)}
|
||||
className="absolute right-1 top-1 hidden size-6 items-center justify-center rounded-full bg-red-500 text-white group-hover:flex"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-2 rounded-md border border-dashed px-4 py-2.5 text-sm text-muted-foreground hover:bg-muted/50 disabled:opacity-50"
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="size-4" />
|
||||
)}
|
||||
{uploading ? "Yükleniyor..." : "Fotoğraf ekle"}
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => handleFiles(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user