perf: memoize parseImageIds, fix checkLimit OR query, loading skeletons, dashboard cache, compound indexes, sidebar active state, matches notified fix, padding fixes, match criteria in property detail

This commit is contained in:
egecankomur
2026-05-13 13:08:05 +03:00
parent 933cb17107
commit 7c677dfa4b
34 changed files with 1257 additions and 308 deletions
+43 -12
View File
@@ -2,7 +2,8 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { BellRinging, Checks } from '@/lib/icons';
import Link from "next/link";
import { BellRinging, Checks, Phone, Envelope } from '@/lib/icons';
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -46,8 +47,8 @@ export function MatchesClient({ matches, customers, properties, searches }: Matc
const propertyMap = Object.fromEntries(properties.map((p) => [p.$id, p]));
const searchMap = Object.fromEntries(searches.map((s) => [s.$id, s]));
const pendingCount = items.filter((m) => !m.notified).length;
const visible = tab === "pending" ? items.filter((m) => !m.notified) : items;
const pendingCount = items.filter((m) => m.notified !== true).length;
const visible = tab === "pending" ? items.filter((m) => m.notified !== true) : items;
function openBreakdown(m: PropertyMatch) {
setSelectedMatch(m);
@@ -157,7 +158,7 @@ export function MatchesClient({ matches, customers, properties, searches }: Matc
<tr
key={m.$id}
className={`border-b last:border-0 ${
!m.notified ? "bg-amber-50/40 dark:bg-amber-950/10" : "hover:bg-muted/30"
m.notified !== true ? "bg-amber-50/40 dark:bg-amber-950/10" : "hover:bg-muted/30"
}`}
>
<td className="p-3">
@@ -165,21 +166,51 @@ export function MatchesClient({ matches, customers, properties, searches }: Matc
</td>
<td className="p-3 cursor-pointer" onClick={() => openBreakdown(m)}>
<p className="font-medium">{customer?.name ?? m.customerId}</p>
{customer?.phone && (
<p className="text-xs text-muted-foreground">{customer.phone}</p>
)}
<div className="flex flex-col gap-0.5 mt-0.5" onClick={(e) => e.stopPropagation()}>
{customer?.phone && (
<a
href={`tel:${customer.phone}`}
className="flex items-center gap-1 text-xs text-foreground hover:text-primary transition-colors w-fit"
>
<Phone className="size-3 shrink-0" />
{customer.phone}
</a>
)}
{customer?.email && (
<a
href={`mailto:${customer.email}`}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors w-fit"
>
<Envelope className="size-3 shrink-0" />
{customer.email}
</a>
)}
</div>
</td>
<td className="p-3 cursor-pointer max-w-[180px]" onClick={() => openBreakdown(m)}>
<p className="truncate">{property?.title ?? m.propertyId}</p>
<td className="p-3 max-w-[200px]">
<Link
href={`/properties/${m.propertyId}`}
className="font-medium hover:text-primary hover:underline transition-colors truncate block"
onClick={(e) => e.stopPropagation()}
>
{property?.title ?? m.propertyId}
</Link>
{property?.city && (
<p className="text-xs text-muted-foreground">{property.city}</p>
<p className="text-xs text-muted-foreground">
{property.city}{property.district ? `, ${property.district}` : ""}
</p>
)}
{property?.price != null && (
<p className="text-xs font-medium text-foreground/80">
{property.price.toLocaleString("tr-TR")} {property.currency ?? "₺"}
</p>
)}
</td>
<td className="p-3 text-muted-foreground hidden md:table-cell">
{new Date(m.$createdAt).toLocaleDateString("tr-TR")}
</td>
<td className="p-3">
{m.notified ? (
{m.notified === true ? (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Checks className="size-3.5 text-green-500" />
Bildirildi
@@ -192,7 +223,7 @@ export function MatchesClient({ matches, customers, properties, searches }: Matc
)}
</td>
<td className="p-3">
{!m.notified && (
{m.notified !== true && (
<Button
size="sm"
variant="ghost"
+52 -36
View File
@@ -40,10 +40,23 @@ export function NavMain({
}) {
const pathname = usePathname()
// Check if any subitem is active to determine if parent should be open
// Returns the url of the best-matching subitem (longest prefix wins).
// Handles detail pages: /customers/123 correctly activates the /customers subitem
// without accidentally activating /customers/searches (a sibling).
function bestSubMatch(subitems: { url: string }[]): string | null {
let best: string | null = null
for (const sub of subitems) {
if (pathname === sub.url || pathname.startsWith(sub.url + "/")) {
if (!best || sub.url.length > best.length) best = sub.url
}
}
return best
}
const shouldBeOpen = (item: typeof items[0]) => {
if (item.isActive) return true
return item.items?.some(subItem => pathname === subItem.url) || false
if (item.items) return bestSubMatch(item.items) !== null
return false
}
return (
@@ -58,40 +71,43 @@ export function NavMain({
className="group/collapsible"
>
<SidebarMenuItem>
{item.items?.length ? (
<>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title} className="cursor-pointer">
{item.icon && <item.icon />}
<span>{item.title}</span>
<CaretRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton asChild className="cursor-pointer" isActive={pathname === subItem.url}>
<Link
href={subItem.url}
target={(item.title === "Auth Pages" || item.title === "Errors") ? "_blank" : undefined}
rel={(item.title === "Auth Pages" || item.title === "Errors") ? "noopener noreferrer" : undefined}
>
<span>{subItem.title}</span>
{subItem.badge != null && subItem.badge > 0 && (
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-destructive-foreground">
{subItem.badge > 99 ? "99+" : subItem.badge}
</span>
)}
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</>
) : (
<SidebarMenuButton asChild tooltip={item.title} className="cursor-pointer" isActive={pathname === item.url}>
{item.items?.length ? (() => {
const activeSubUrl = bestSubMatch(item.items)
return (
<>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title} className="cursor-pointer">
{item.icon && <item.icon />}
<span>{item.title}</span>
<CaretRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton asChild className="cursor-pointer" isActive={activeSubUrl === subItem.url}>
<Link
href={subItem.url}
target={(item.title === "Auth Pages" || item.title === "Errors") ? "_blank" : undefined}
rel={(item.title === "Auth Pages" || item.title === "Errors") ? "noopener noreferrer" : undefined}
>
<span>{subItem.title}</span>
{subItem.badge != null && subItem.badge > 0 && (
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-destructive-foreground">
{subItem.badge > 99 ? "99+" : subItem.badge}
</span>
)}
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</>
)
})() : (
<SidebarMenuButton asChild tooltip={item.title} className="cursor-pointer" isActive={pathname === item.url || pathname.startsWith(item.url + "/")}>
<Link href={item.url}>
{item.icon && <item.icon />}
<span>{item.title}</span>
+22 -16
View File
@@ -103,10 +103,15 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
const mappedCount = filteredProperties.filter((p) => p.mapLat != null && p.mapLng != null).length;
const parsedImageIds = useMemo(() =>
Object.fromEntries(properties.map((p) => [p.$id, parseImageIds(p.imageIds)])),
[properties],
);
const { withImages, withoutImages } = useMemo(() => ({
withImages: filteredProperties.filter((p) => parseImageIds(p.imageIds).length > 0),
withoutImages: filteredProperties.filter((p) => parseImageIds(p.imageIds).length === 0),
}), [filteredProperties]);
withImages: filteredProperties.filter((p) => (parsedImageIds[p.$id]?.length ?? 0) > 0),
withoutImages: filteredProperties.filter((p) => (parsedImageIds[p.$id]?.length ?? 0) === 0),
}), [filteredProperties, parsedImageIds]);
const STATUS_TABS: Array<{ key: PropertyStatus | "all"; label: string }> = [
{ key: "all", label: "Tümü" },
@@ -220,13 +225,13 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
{withoutImages.length > 0 && (
<ImageGroupHeader hasImages count={withImages.length} />
)}
{withImages.map((p) => <MobilePropertyCard key={p.$id} p={p} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />)}
{withImages.map((p) => <MobilePropertyCard key={p.$id} p={p} imageIds={parsedImageIds[p.$id] ?? []} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />)}
</>
)}
{withImages.length > 0 && withoutImages.length > 0 && (
<ImageGroupHeader hasImages={false} count={withoutImages.length} />
)}
{withoutImages.map((p) => <MobilePropertyCard key={p.$id} p={p} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />)}
{withoutImages.map((p) => <MobilePropertyCard key={p.$id} p={p} imageIds={parsedImageIds[p.$id] ?? []} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />)}
</div>
{/* Desktop table — hidden on mobile */}
@@ -260,7 +265,7 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
</TableRow>
)}
{withImages.map((p) => (
<PropertyTableRow key={p.$id} p={p} rowRefs={rowRefs} openEdit={openEdit} setDeleteTarget={setDeleteTarget} router={router} />
<PropertyTableRow key={p.$id} p={p} imageIds={parsedImageIds[p.$id] ?? []} rowRefs={rowRefs} openEdit={openEdit} setDeleteTarget={setDeleteTarget} router={router} />
))}
{withImages.length > 0 && withoutImages.length > 0 && (
<TableRow className="hover:bg-transparent">
@@ -270,7 +275,7 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
</TableRow>
)}
{withoutImages.map((p) => (
<PropertyTableRow key={p.$id} p={p} rowRefs={rowRefs} openEdit={openEdit} setDeleteTarget={setDeleteTarget} router={router} />
<PropertyTableRow key={p.$id} p={p} imageIds={parsedImageIds[p.$id] ?? []} rowRefs={rowRefs} openEdit={openEdit} setDeleteTarget={setDeleteTarget} router={router} />
))}
</TableBody>
</Table>
@@ -289,7 +294,7 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
{withoutImages.length > 0 && <ImageGroupHeader hasImages count={withImages.length} />}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-3">
{withImages.map((p) => (
<GalleryCard key={p.$id} p={p} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />
<GalleryCard key={p.$id} p={p} imageIds={parsedImageIds[p.$id] ?? []} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />
))}
</div>
</>
@@ -302,7 +307,7 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
{withoutImages.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-3">
{withoutImages.map((p) => (
<GalleryCard key={p.$id} p={p} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />
<GalleryCard key={p.$id} p={p} imageIds={parsedImageIds[p.$id] ?? []} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />
))}
</div>
)}
@@ -321,7 +326,7 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
<p className="text-muted-foreground text-sm text-center py-10">Henüz ilan yok.</p>
)}
{filteredProperties.map((p) => {
const coverImageId = parseImageIds(p.imageIds)[0];
const coverImageId = (parsedImageIds[p.$id] ?? [])[0];
const hasCoords = p.mapLat != null && p.mapLng != null;
const isSelected = selectedId === p.$id;
return (
@@ -419,12 +424,12 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
}
/* ── Galeri kartı ── */
function GalleryCard({ p, openEdit, setDeleteTarget }: {
function GalleryCard({ p, imageIds, openEdit, setDeleteTarget }: {
p: Property;
imageIds: string[];
openEdit: (p: Property) => void;
setDeleteTarget: (p: Property) => void;
}) {
const imageIds = parseImageIds(p.imageIds);
const [idx, setIdx] = useState(0);
const [lightbox, setLightbox] = useState(false);
const safeIdx = Math.min(idx, imageIds.length - 1);
@@ -550,12 +555,13 @@ function ImageGroupHeader({ hasImages, count }: { hasImages: boolean; count: num
}
/* ── Mobil kart ── */
function MobilePropertyCard({ p, openEdit, setDeleteTarget }: {
function MobilePropertyCard({ p, imageIds, openEdit, setDeleteTarget }: {
p: Property;
imageIds: string[];
openEdit: (p: Property) => void;
setDeleteTarget: (p: Property) => void;
}) {
const coverImageId = parseImageIds(p.imageIds)[0];
const coverImageId = imageIds[0];
return (
<div className="rounded-lg border bg-card overflow-hidden">
{coverImageId && (
@@ -611,14 +617,14 @@ function MobilePropertyCard({ p, openEdit, setDeleteTarget }: {
}
/* ── Tablo satırı ── */
function PropertyTableRow({ p, rowRefs, openEdit, setDeleteTarget, router }: {
function PropertyTableRow({ p, imageIds, rowRefs, openEdit, setDeleteTarget, router }: {
p: Property;
imageIds: string[];
rowRefs: React.MutableRefObject<Record<string, HTMLTableRowElement>>;
openEdit: (p: Property) => void;
setDeleteTarget: (p: Property) => void;
router: ReturnType<typeof useRouter>;
}) {
const imageIds = parseImageIds(p.imageIds);
const coverImageId = imageIds[0];
const [lightbox, setLightbox] = useState(false);
@@ -3,7 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { ArrowLeft, PencilSimple, MapPin, ImageSquare, Plus, FileText, ClipboardText, Users, CaretLeft, CaretRight, X } from '@/lib/icons';
import { ArrowLeft, PencilSimple, MapPin, ImageSquare, Plus, FileText, ClipboardText, Users, CaretLeft, CaretRight, X, Phone, Envelope, BellRinging, Checks } from '@/lib/icons';
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -19,6 +19,8 @@ import {
ACTIVITY_TYPE_LABELS,
type Property,
type PropertyMatch,
type CustomerSearch,
type Customer,
type Activity,
} from "@/lib/appwrite/schema";
import { getPropertyImageUrl } from "@/lib/appwrite/storage-utils";
@@ -28,7 +30,8 @@ interface Props {
matches: PropertyMatch[];
activities: Activity[];
imageIds: string[];
customerMap: Record<string, string>;
customers: Customer[];
searches: CustomerSearch[];
}
const STATUS_COLOR: Record<string, string> = {
@@ -39,7 +42,9 @@ const STATUS_COLOR: Record<string, string> = {
rezerve: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400",
};
export function PropertyDetailClient({ property, matches, activities, imageIds, customerMap }: Props) {
export function PropertyDetailClient({ property, matches, activities, imageIds, customers, searches }: Props) {
const customerMap = Object.fromEntries(customers.map((c) => [c.$id, c]));
const searchMap = Object.fromEntries(searches.map((s) => [s.$id, s]));
const router = useRouter();
const [editOpen, setEditOpen] = useState(false);
const [imageOpen, setImageOpen] = useState(false);
@@ -219,15 +224,19 @@ export function PropertyDetailClient({ property, matches, activities, imageIds,
{matches.length === 0 ? (
<p className="text-sm text-muted-foreground">Henüz eşleşme yok.</p>
) : (
<div className="space-y-2">
{matches.map((m) => (
<div key={m.$id} className="flex items-center justify-between gap-2 border-b pb-2 last:border-0 last:pb-0">
<span className="text-sm truncate">
{customerMap[m.customerId] ?? m.customerId}
</span>
<ScoreBadge score={m.score} />
</div>
))}
<div className="space-y-3">
{matches.map((m) => {
const customer = customerMap[m.customerId];
const search = searchMap[m.searchId];
return (
<MatchCard
key={m.$id}
match={m}
customer={customer}
search={search}
/>
);
})}
</div>
)}
</TabsContent>
@@ -375,6 +384,109 @@ function Spec({ label, value }: { label: string; value: string }) {
}
function MatchCard({ match, customer, search }: {
match: PropertyMatch;
customer?: Customer;
search?: CustomerSearch;
}) {
const fmt = (n: number) => n.toLocaleString("tr-TR");
const criteria: string[] = [];
if (search?.listingType) criteria.push(LISTING_TYPE_LABELS[search.listingType] ?? search.listingType);
if (search?.propertyTypes) {
try {
const types = JSON.parse(search.propertyTypes) as string[];
types.forEach((t) => criteria.push(PROPERTY_TYPE_LABELS[t as keyof typeof PROPERTY_TYPE_LABELS] ?? t));
} catch { /* ignore */ }
}
if (search?.roomCounts) {
try {
const rooms = JSON.parse(search.roomCounts) as string[];
if (rooms.length) criteria.push(`${rooms.join(", ")} oda`);
} catch { /* ignore */ }
}
if (search?.minPrice != null || search?.maxPrice != null) {
const min = search.minPrice != null ? `${fmt(search.minPrice)}` : null;
const max = search.maxPrice != null ? `${fmt(search.maxPrice)}` : null;
criteria.push(min && max ? `${min} ${max}` : min ? `min ${min}` : `max ${max!}`);
}
if (search?.minM2 != null || search?.maxM2 != null) {
const min = search.minM2 != null ? `${search.minM2}` : null;
const max = search.maxM2 != null ? `${search.maxM2}` : null;
criteria.push(min && max ? `${min} ${max}` : min ? `min ${min}` : `max ${max!}`);
}
if (search?.cities) {
try {
const cities = JSON.parse(search.cities) as string[];
if (cities.length) criteria.push(cities.join(", "));
} catch { /* ignore */ }
}
if (search?.districts) {
try {
const districts = JSON.parse(search.districts) as string[];
if (districts.length) criteria.push(districts.join(", "));
} catch { /* ignore */ }
}
return (
<div className={`rounded-lg border p-3 space-y-2.5 ${match.notified !== true ? "bg-amber-50/30 dark:bg-amber-950/10 border-amber-200/50 dark:border-amber-800/30" : ""}`}>
{/* Üst satır: isim + puan + bildirim durumu */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<Link
href={`/customers/${match.customerId}`}
className="font-medium text-sm hover:text-primary hover:underline transition-colors"
>
{customer?.name ?? match.customerId}
</Link>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5">
{customer?.phone && (
<a href={`tel:${customer.phone}`} className="flex items-center gap-1 text-xs text-foreground/80 hover:text-primary transition-colors">
<Phone className="size-3 shrink-0" />
{customer.phone}
</a>
)}
{customer?.email && (
<a href={`mailto:${customer.email}`} className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors">
<Envelope className="size-3 shrink-0" />
{customer.email}
</a>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{match.notified === true ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Checks className="size-3.5 text-green-500" />
Bildirildi
</span>
) : (
<span className="flex items-center gap-1 text-xs text-amber-600 font-medium">
<BellRinging className="size-3.5" />
Bekliyor
</span>
)}
<ScoreBadge score={match.score} />
</div>
</div>
{/* Arama kriterleri */}
{criteria.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1.5 border-t border-dashed">
{criteria.map((c, i) => (
<span key={i} className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{c}
</span>
))}
</div>
)}
{search?.notes && (
<p className="text-xs text-muted-foreground italic border-t pt-1.5">{search.notes}</p>
)}
</div>
);
}
function ScoreBadge({ score }: { score?: number | null }) {
const s = score ?? 0;
const color =
@@ -17,7 +17,12 @@ import {
} from "@/components/ui/drawer";
import { Button } from "@/components/ui/button";
import { PropertyImageUploader } from "./property-image-uploader";
import { updatePropertyImagesAction } from "@/lib/appwrite/property-actions";
import {
updatePropertyImagesAction,
} from "@/lib/appwrite/property-actions";
import {
deletePropertyImageAndSyncAction,
} from "@/lib/appwrite/storage-actions";
import { useIsMobile } from "@/hooks/use-mobile";
interface PropertyImageSheetProps {
@@ -39,6 +44,20 @@ export function PropertyImageSheet({
const [imageIds, setImageIds] = useState<string[]>(initialImageIds);
const [saving, setSaving] = useState(false);
// Silme: storage + DB'yi tek seferde güncelle, "Kaydet" gerektirmez
async function handleDeleteImage(fileId: string): Promise<boolean> {
const remaining = imageIds.filter((id) => id !== fileId);
const result = await deletePropertyImageAndSyncAction(propertyId, fileId, remaining);
if (result.ok) {
setImageIds(remaining);
onSuccess?.();
return true;
}
toast.error(result.error ?? "Fotoğraf silinemedi.");
return false;
}
// Yeni yüklenen fotoğrafları kaydet
async function handleSave() {
setSaving(true);
const result = await updatePropertyImagesAction(propertyId, imageIds);
@@ -56,12 +75,13 @@ export function PropertyImageSheet({
<div className="space-y-4">
<PropertyImageUploader
name="imageIds"
initialImageIds={initialImageIds}
initialImageIds={imageIds}
onChangeIds={setImageIds}
onDeleteImage={handleDeleteImage}
/>
<div className="flex justify-end gap-2 pt-2 border-t">
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
İptal
Kapat
</Button>
<Button type="button" onClick={handleSave} disabled={saving}>
{saving ? "Kaydediliyor…" : "Kaydet"}
@@ -15,11 +15,12 @@ interface PropertyImageUploaderProps {
name: string;
initialImageIds?: string[];
onChangeIds?: (ids: string[]) => void;
onDeleteImage?: (fileId: string) => Promise<boolean>;
maxImages?: number;
isOwner?: boolean;
}
export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds, maxImages, isOwner }: PropertyImageUploaderProps) {
export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds, onDeleteImage, maxImages, isOwner }: PropertyImageUploaderProps) {
const [imageIds, setImageIds] = useState<string[]>(initialImageIds);
const [queue, setQueue] = useState<UploadingFile[]>([]);
const [dragging, setDragging] = useState(false);
@@ -119,6 +120,11 @@ export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds,
}
async function handleDelete(fileId: string) {
if (onDeleteImage) {
const ok = await onDeleteImage(fileId);
if (ok) updateIds((prev) => prev.filter((id) => id !== fileId));
return;
}
const result = await deletePropertyImageAction(fileId);
if (result.ok) {
updateIds((prev) => prev.filter((id) => id !== fileId));
@@ -195,7 +201,7 @@ export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds,
{imageIds.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{imageIds.map((id) => (
<div key={id} className="group relative aspect-video overflow-hidden rounded-md border bg-muted">
<div key={id} className="relative aspect-video overflow-hidden rounded-md border bg-muted">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getPropertyImagePreviewUrl(id, 400, 300)}
@@ -206,7 +212,7 @@ export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds,
type="button"
onClick={() => handleDelete(id)}
disabled={busy}
className="absolute right-1 top-1 hidden size-6 items-center justify-center rounded-full bg-black/60 text-white transition-colors hover:bg-red-500 group-hover:flex"
className="absolute right-1 top-1 flex size-6 items-center justify-center rounded-full bg-black/50 text-white transition-colors hover:bg-red-500"
>
<X className="size-3" />
</button>