feat: ilan listesine harita görünümü eklendi (split layout)

- Liste / Harita toggle butonu header'da
- Harita modunda sol panel: kart listesi (fotoğraf, fiyat, oda/m²)
  + sağ panel: MapLibre harita tüm koordinatlı ilanlar
- İlan renkleri duruma göre: aktif=mavi, pasif=gri, satıldı=turuncu, kiralandı=mor
- Pini tıkla → kart listesinde o ilanın kartına scroll
- Kartı tıkla → haritada o ilanın pinine flyTo + popup açılır
- Popup içinde başlık, fiyat, özellikler, 'Detay →' linki
- Koordinatsız ilanlar listede görünür ama haritada pin yok (📍 Konum yok)
- PropertiesMapView: dynamic next/dynamic wrapper (ssr: false)
This commit is contained in:
egecankomur
2026-05-05 22:00:41 +03:00
parent 92428cb4fd
commit b1d3ee3681
4 changed files with 393 additions and 87 deletions
@@ -0,0 +1,137 @@
"use client";
import { useEffect, useRef } from "react";
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import type { Property } from "@/lib/appwrite/schema";
import {
PROPERTY_TYPE_LABELS,
LISTING_TYPE_LABELS,
} from "@/lib/appwrite/schema";
const STYLE_URL = "https://tiles.openfreemap.org/styles/bright";
const TURKEY_CENTER: [number, number] = [35.0, 39.0];
const STATUS_COLORS: Record<string, string> = {
aktif: "#2563eb",
pasif: "#9ca3af",
satildi: "#f97316",
kiralandit: "#8b5cf6",
};
interface Props {
properties: Property[];
selectedId?: string | null;
onSelect: (id: string) => void;
}
export function PropertiesMapViewInner({ properties, selectedId, onSelect }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const markersRef = useRef<Map<string, maplibregl.Marker>>(new Map());
const popupsRef = useRef<Map<string, maplibregl.Popup>>(new Map());
const onSelectRef = useRef(onSelect);
onSelectRef.current = onSelect;
const mapped = properties.filter((p) => p.mapLat != null && p.mapLng != null);
useEffect(() => {
if (!containerRef.current) return;
let center: [number, number] = TURKEY_CENTER;
let zoom = 5.5;
if (mapped.length === 1) {
center = [mapped[0].mapLng!, mapped[0].mapLat!];
zoom = 14;
} else if (mapped.length > 1) {
const avgLng = mapped.reduce((s, p) => s + p.mapLng!, 0) / mapped.length;
const avgLat = mapped.reduce((s, p) => s + p.mapLat!, 0) / mapped.length;
center = [avgLng, avgLat];
zoom = 9;
}
const map = new maplibregl.Map({
container: containerRef.current,
style: STYLE_URL,
center,
zoom,
});
map.addControl(new maplibregl.NavigationControl(), "top-right");
mapRef.current = map;
map.on("load", () => {
for (const p of mapped) {
const color = STATUS_COLORS[p.status] ?? "#2563eb";
const popup = new maplibregl.Popup({
offset: 28,
closeButton: true,
maxWidth: "260px",
className: "property-popup",
}).setHTML(`
<div style="font-family:system-ui,sans-serif;padding:2px 0">
<p style="font-weight:600;font-size:13px;margin:0 0 3px;line-height:1.3">${p.title}</p>
<p style="font-size:11px;color:#6b7280;margin:0 0 6px">${[p.neighborhood, p.district, p.city].filter(Boolean).join(", ")}</p>
<p style="font-size:18px;font-weight:700;color:#111;margin:0 0 6px">${p.price.toLocaleString("tr-TR")} <span style="font-size:12px;font-weight:400;color:#6b7280">${p.currency ?? "TRY"}</span></p>
<div style="display:flex;gap:5px;flex-wrap:wrap;margin-bottom:10px">
<span style="background:#f3f4f6;border-radius:999px;padding:2px 8px;font-size:11px;color:#374151">${PROPERTY_TYPE_LABELS[p.propertyType] ?? p.propertyType}</span>
<span style="background:#f3f4f6;border-radius:999px;padding:2px 8px;font-size:11px;color:#374151">${LISTING_TYPE_LABELS[p.listingType] ?? p.listingType}</span>
${p.roomCount ? `<span style="background:#f3f4f6;border-radius:999px;padding:2px 8px;font-size:11px;color:#374151">${p.roomCount}</span>` : ""}
${p.netM2 ? `<span style="background:#f3f4f6;border-radius:999px;padding:2px 8px;font-size:11px;color:#374151">${p.netM2} m²</span>` : ""}
</div>
<a href="/properties/${p.$id}" style="display:inline-flex;align-items:center;background:#2563eb;color:white;border-radius:6px;padding:5px 14px;font-size:12px;text-decoration:none;font-weight:500">Detay →</a>
</div>
`);
const marker = new maplibregl.Marker({ color })
.setLngLat([p.mapLng!, p.mapLat!])
.setPopup(popup)
.addTo(map);
marker.getElement().addEventListener("click", () => {
onSelectRef.current(p.$id);
});
markersRef.current.set(p.$id, marker);
popupsRef.current.set(p.$id, popup);
}
});
return () => {
map.remove();
mapRef.current = null;
markersRef.current.clear();
popupsRef.current.clear();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Fly to + open popup when selection changes
useEffect(() => {
if (!selectedId || !mapRef.current) return;
const prop = mapped.find((p) => p.$id === selectedId);
if (!prop) return;
mapRef.current.flyTo({
center: [prop.mapLng!, prop.mapLat!],
zoom: Math.max(mapRef.current.getZoom(), 14),
duration: 700,
});
const marker = markersRef.current.get(selectedId);
if (marker && !marker.getPopup().isOpen()) {
marker.togglePopup();
}
}, [selectedId]); // eslint-disable-line react-hooks/exhaustive-deps
if (mapped.length === 0) {
return (
<div className="flex h-full items-center justify-center bg-muted/30 rounded-lg border text-sm text-muted-foreground">
Koordinatlı ilan yok ilan formundan konum ekleyin.
</div>
);
}
return <div ref={containerRef} className="h-full w-full rounded-lg overflow-hidden border" />;
}
@@ -0,0 +1,31 @@
"use client";
import dynamic from "next/dynamic";
import { MapPin } from "lucide-react";
import type { Property } from "@/lib/appwrite/schema";
const Inner = dynamic(
() =>
import("./properties-map-view-inner").then((m) => m.PropertiesMapViewInner),
{
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center rounded-lg border bg-muted/30">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<MapPin className="size-4 animate-pulse" />
Harita yükleniyor...
</div>
</div>
),
},
);
interface Props {
properties: Property[];
selectedId?: string | null;
onSelect: (id: string) => void;
}
export function PropertiesMapView(props: Props) {
return <Inner {...props} />;
}
+224 -86
View File
@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { useState, useRef } from "react";
import Link from "next/link";
import { MoreHorizontal, Plus, Pencil, Trash2, ExternalLink } from "lucide-react";
import { MoreHorizontal, Plus, Pencil, Trash2, ExternalLink, List, Map } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -15,6 +15,8 @@ import {
} from "@/components/ui/dropdown-menu";
import { deletePropertyAction } from "@/lib/appwrite/property-actions";
import { PropertyFormSheet } from "./property-form-sheet";
import { PropertiesMapView } from "@/components/map/properties-map-view";
import { getPropertyImagePreviewUrl, parseImageIds } from "@/lib/appwrite/storage-utils";
import type { Property } from "@/lib/appwrite/schema";
import {
PROPERTY_STATUS_LABELS,
@@ -26,20 +28,19 @@ interface PropertiesClientProps {
initialProperties: Property[];
}
type ViewMode = "list" | "map";
export function PropertiesClient({ initialProperties }: PropertiesClientProps) {
const [properties, setProperties] = useState(initialProperties);
const [sheetOpen, setSheetOpen] = useState(false);
const [editing, setEditing] = useState<Property | null>(null);
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [selectedId, setSelectedId] = useState<string | null>(null);
const rowRefs = useRef<Record<string, HTMLTableRowElement>>({});
const cardRefs = useRef<Record<string, HTMLDivElement>>({});
function openCreate() {
setEditing(null);
setSheetOpen(true);
}
function openEdit(p: Property) {
setEditing(p);
setSheetOpen(true);
}
function openCreate() { setEditing(null); setSheetOpen(true); }
function openEdit(p: Property) { setEditing(p); setSheetOpen(true); }
async function handleDelete(p: Property) {
if (!confirm(`"${p.title}" silinsin mi?`)) return;
@@ -52,94 +53,231 @@ export function PropertiesClient({ initialProperties }: PropertiesClientProps) {
}
}
function handleSuccess() {
// Revalidation handles the data refresh; re-fetching in client is not
// necessary since the page will reload on next navigation.
// For immediate local update we just remove the item or close the sheet.
function handleMapSelect(id: string) {
setSelectedId(id);
// Scroll the card into view in the list panel
const card = cardRefs.current[id];
card?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
function handleCardClick(id: string) {
setSelectedId(id === selectedId ? null : id);
}
const mappedCount = properties.filter((p) => p.mapLat != null && p.mapLng != null).length;
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">İlanlar</h1>
<Button onClick={openCreate} size="sm">
<Plus className="mr-1.5 size-4" />
Yeni İlan
</Button>
<div className="flex flex-col gap-4 flex-1">
{/* Header */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<div>
<h1 className="text-2xl font-bold">İlanlar</h1>
{viewMode === "map" && mappedCount < properties.length && (
<p className="text-xs text-muted-foreground mt-0.5">
{mappedCount} / {properties.length} ilanın koordinatı var
</p>
)}
</div>
<div className="flex items-center gap-2">
{/* View toggle */}
<div className="flex rounded-md border overflow-hidden">
<button
type="button"
onClick={() => setViewMode("list")}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm transition-colors ${
viewMode === "list"
? "bg-primary text-primary-foreground"
: "bg-background text-muted-foreground hover:text-foreground"
}`}
>
<List className="size-3.5" />
Liste
</button>
<button
type="button"
onClick={() => setViewMode("map")}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm transition-colors border-l ${
viewMode === "map"
? "bg-primary text-primary-foreground"
: "bg-background text-muted-foreground hover:text-foreground"
}`}
>
<Map className="size-3.5" />
Harita
</button>
</div>
<Button onClick={openCreate} size="sm">
<Plus className="mr-1.5 size-4" />
Yeni İlan
</Button>
</div>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Başlık</TableHead>
<TableHead>Tip</TableHead>
<TableHead>Tür</TableHead>
<TableHead>Şehir</TableHead>
<TableHead className="text-right">Fiyat</TableHead>
<TableHead>Durum</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{properties.length === 0 && (
{/* List view */}
{viewMode === "list" && (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={7} className="text-muted-foreground text-center py-10">
Henüz ilan yok.
</TableCell>
<TableHead>Başlık</TableHead>
<TableHead>Tip</TableHead>
<TableHead>Tür</TableHead>
<TableHead>Şehir</TableHead>
<TableHead className="text-right">Fiyat</TableHead>
<TableHead>Durum</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{properties.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-muted-foreground text-center py-10">
Henüz ilan yok.
</TableCell>
</TableRow>
)}
{properties.map((p) => (
<TableRow
key={p.$id}
ref={(el) => { if (el) rowRefs.current[p.$id] = el; }}
>
<TableCell className="font-medium max-w-[200px] truncate">{p.title}</TableCell>
<TableCell>{PROPERTY_TYPE_LABELS[p.propertyType] ?? p.propertyType}</TableCell>
<TableCell>{LISTING_TYPE_LABELS[p.listingType] ?? p.listingType}</TableCell>
<TableCell>{[p.city, p.district].filter(Boolean).join(", ")}</TableCell>
<TableCell className="text-right tabular-nums">
{p.price.toLocaleString("tr-TR")} {p.currency ?? "TRY"}
</TableCell>
<TableCell>
<StatusBadge status={p.status} />
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
</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
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(p)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 size-4" />
Sil
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* Map view — split layout */}
{viewMode === "map" && (
<div className="flex gap-4 flex-1 min-h-0" style={{ height: "calc(100vh - 14rem)" }}>
{/* Left: scrollable property cards */}
<div className="w-80 shrink-0 overflow-y-auto space-y-2 pr-1">
{properties.length === 0 && (
<p className="text-muted-foreground text-sm text-center py-10">Henüz ilan yok.</p>
)}
{properties.map((p) => (
<TableRow key={p.$id}>
<TableCell className="font-medium max-w-[200px] truncate">{p.title}</TableCell>
<TableCell>{PROPERTY_TYPE_LABELS[p.propertyType] ?? p.propertyType}</TableCell>
<TableCell>{LISTING_TYPE_LABELS[p.listingType] ?? p.listingType}</TableCell>
<TableCell>{[p.city, p.district].filter(Boolean).join(", ")}</TableCell>
<TableCell className="text-right tabular-nums">
{p.price.toLocaleString("tr-TR")} {p.currency ?? "TRY"}
</TableCell>
<TableCell>
<StatusBadge status={p.status} />
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
</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
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(p)}
className="text-destructive focus:text-destructive"
{properties.map((p) => {
const coverImageId = parseImageIds(p.imageIds)[0];
const hasCoords = p.mapLat != null && p.mapLng != null;
const isSelected = selectedId === p.$id;
return (
<div
key={p.$id}
ref={(el) => { if (el) cardRefs.current[p.$id] = el; }}
onClick={() => hasCoords && handleCardClick(p.$id)}
className={`rounded-lg border bg-card overflow-hidden transition-all ${
hasCoords ? "cursor-pointer hover:shadow-md" : "opacity-60"
} ${isSelected ? "ring-2 ring-primary shadow-md" : ""}`}
>
{/* Cover image */}
{coverImageId && (
<div className="h-28 overflow-hidden bg-muted">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getPropertyImagePreviewUrl(coverImageId, 320, 180)}
alt={p.title}
className="h-full w-full object-cover"
/>
</div>
)}
<div className="p-3 space-y-1">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-semibold leading-snug line-clamp-2">{p.title}</p>
<StatusBadge status={p.status} />
</div>
<p className="text-xs text-muted-foreground line-clamp-1">
{[p.neighborhood, p.district, p.city].filter(Boolean).join(", ")}
</p>
<div className="flex items-center justify-between">
<p className="text-sm font-bold">
{p.price.toLocaleString("tr-TR")}
<span className="text-xs font-normal text-muted-foreground ml-1">{p.currency ?? "TRY"}</span>
</p>
<div className="flex gap-1">
{p.roomCount && (
<span className="text-xs bg-muted text-muted-foreground px-1.5 py-0.5 rounded">
{p.roomCount}
</span>
)}
{p.netM2 && (
<span className="text-xs bg-muted text-muted-foreground px-1.5 py-0.5 rounded">
{p.netM2}m²
</span>
)}
</div>
</div>
<div className="flex items-center justify-between pt-0.5">
{!hasCoords && (
<span className="text-xs text-muted-foreground">📍 Konum yok</span>
)}
<Link
href={`/properties/${p.$id}`}
onClick={(e) => e.stopPropagation()}
className="ml-auto text-xs text-primary hover:underline"
>
<Trash2 className="mr-2 size-4" />
Sil
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
Detay
</Link>
</div>
</div>
</div>
);
})}
</div>
{/* Right: map */}
<div className="flex-1 min-w-0">
<PropertiesMapView
properties={properties}
selectedId={selectedId}
onSelect={handleMapSelect}
/>
</div>
</div>
)}
<PropertyFormSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
property={editing}
onSuccess={handleSuccess}
onSuccess={() => {}}
/>
</div>
);
@@ -153,7 +291,7 @@ function StatusBadge({ status }: { status: string }) {
kiralandit: "outline",
};
return (
<Badge variant={map[status] ?? "secondary"}>
<Badge variant={map[status] ?? "secondary"} className="shrink-0 text-xs">
{PROPERTY_STATUS_LABELS[status as keyof typeof PROPERTY_STATUS_LABELS] ?? status}
</Badge>
);