Files
exposedgays/src/components/Map.tsx

360 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState, useCallback, useMemo } from "react";
import dynamic from "next/dynamic";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { CruiserCard } from "@/components/CruiserCard";
import { ProfilePopup } from "@/components/ProfilePopup";
import { Filter, Users, X } from "lucide-react";
import type { Profile, CruisingSpot, MapFilters, Position } from "@/types";
import { KINK_OPTIONS, POSITION_COLORS } from "@/types";
import { MOCK_PROFILES, MOCK_SPOTS } from "@/lib/mock-data";
import "leaflet/dist/leaflet.css";
import "leaflet.markercluster/dist/MarkerCluster.css";
import "leaflet.markercluster/dist/MarkerCluster.Default.css";
const MapContainer = dynamic(
() => import("react-leaflet").then((m) => m.MapContainer),
{ ssr: false }
);
const TileLayer = dynamic(
() => import("react-leaflet").then((m) => m.TileLayer),
{ ssr: false }
);
const Marker = dynamic(
() => import("react-leaflet").then((m) => m.Marker),
{ ssr: false }
);
const Popup = dynamic(
() => import("react-leaflet").then((m) => m.Popup),
{ ssr: false }
);
const MarkerClusterGroup = dynamic(
() => import("react-leaflet-cluster"),
{ ssr: false }
);
const DEFAULT_LAT = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LAT || "26.1224");
const DEFAULT_LNG = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LNG || "-80.1373");
interface MapProps {
vanillaMode: boolean;
onActiveCountChange?: (count: number) => void;
}
export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
const [profiles, setProfiles] = useState<Profile[]>(MOCK_PROFILES);
const [spots] = useState<CruisingSpot[]>(MOCK_SPOTS);
const [selectedProfile, setSelectedProfile] = useState<Profile | null>(null);
const [showFilters, setShowFilters] = useState(false);
const [showSidebar, setShowSidebar] = useState(true);
const [leafletReady, setLeafletReady] = useState(false);
const [userIcon, setUserIcon] = useState<unknown>(null);
const [spotIcon, setSpotIcon] = useState<unknown>(null);
const [filters, setFilters] = useState<MapFilters>({
ageMin: 18,
ageMax: 65,
positions: [],
kinks: [],
onPrep: null,
status: [],
onlineOnly: true,
});
useEffect(() => {
import("leaflet").then((L) => {
const createUserIcon = (position: string | null, status: string) => {
const color = position
? POSITION_COLORS[position as Position] || "#ff2d6b"
: "#ff2d6b";
const pulse = status !== "offline" ? "animation:pulse 2s infinite;" : "";
return L.divIcon({
className: "custom-marker",
html: `<div style="width:36px;height:36px;border-radius:50%;background:${color};border:3px solid white;box-shadow:0 2px 8px rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;${pulse}">
<svg width="18" height="18" viewBox="0 0 24 24" fill="white"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
</div>`,
iconSize: [36, 36],
iconAnchor: [18, 18],
});
};
const createSpotIcon = (category: string) => {
const colors: Record<string, string> = {
park: "#22c55e",
beach: "#3b82f6",
gym: "#f97316",
restroom: "#a855f7",
bookstore: "#ec4899",
club: "#ff2d6b",
other: "#6b7280",
};
const color = colors[category] || "#6b7280";
return L.divIcon({
className: "spot-marker",
html: `<div style="width:28px;height:28px;border-radius:4px;background:${color};border:2px solid white;box-shadow:0 2px 6px rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="white"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>
</div>`,
iconSize: [28, 28],
iconAnchor: [14, 14],
});
};
setUserIcon({ create: createUserIcon });
setSpotIcon({ create: createSpotIcon });
setLeafletReady(true);
});
}, []);
const filteredProfiles = useMemo(() => {
return profiles.filter((p) => {
if (filters.onlineOnly && p.status === "offline") return false;
if (p.age && (p.age < filters.ageMin || p.age > filters.ageMax)) return false;
if (filters.positions.length && p.position && !filters.positions.includes(p.position))
return false;
if (filters.kinks.length && !filters.kinks.some((k) => p.kinks.includes(k)))
return false;
if (filters.onPrep === true && !p.on_prep) return false;
if (filters.status.length && !filters.status.includes(p.status)) return false;
return true;
});
}, [profiles, filters]);
useEffect(() => {
onActiveCountChange?.(filteredProfiles.filter((p) => p.status !== "offline").length);
}, [filteredProfiles, onActiveCountChange]);
const handleChat = useCallback((profile: Profile) => {
window.location.href = `/chat?user=${profile.id}`;
}, []);
const togglePosition = (pos: Position) => {
setFilters((f) => ({
...f,
positions: f.positions.includes(pos)
? f.positions.filter((p) => p !== pos)
: [...f.positions, pos],
}));
};
const toggleKink = (kink: string) => {
setFilters((f) => ({
...f,
kinks: f.kinks.includes(kink)
? f.kinks.filter((k) => k !== kink)
: [...f.kinks, kink],
}));
};
return (
<div className="relative h-full w-full">
{/* Filter bar */}
<div className="absolute top-2 left-2 right-2 z-[1000] flex gap-2 flex-wrap">
<Button
variant="secondary"
size="sm"
onClick={() => setShowFilters(!showFilters)}
className="shadow-lg"
>
<Filter className="h-4 w-4" />
Filters
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setShowSidebar(!showSidebar)}
className="shadow-lg md:hidden"
>
<Users className="h-4 w-4" />
{filteredProfiles.length} Cruisers
</Button>
<Badge variant="online" className="shadow-lg self-center">
{filteredProfiles.filter((p) => p.status !== "offline").length} active now
</Badge>
</div>
{/* Filter panel */}
{showFilters && (
<div className="absolute top-14 left-2 z-[1000] w-72 rounded-xl border border-border bg-horny-surface/95 backdrop-blur-md p-4 shadow-2xl space-y-4 max-h-[70vh] overflow-y-auto">
<div className="flex items-center justify-between">
<span className="font-semibold text-sm">Map Filters</span>
<Button variant="ghost" size="icon" onClick={() => setShowFilters(false)}>
<X className="h-4 w-4" />
</Button>
</div>
<div>
<Label className="text-xs">Age: {filters.ageMin}{filters.ageMax}</Label>
<Slider
min={18}
max={80}
step={1}
value={[filters.ageMin, filters.ageMax]}
onValueChange={([min, max]) =>
setFilters((f) => ({ ...f, ageMin: min, ageMax: max }))
}
className="mt-2"
/>
</div>
<div>
<Label className="text-xs mb-2 block">Position</Label>
<div className="flex flex-wrap gap-1">
{(["top", "bottom", "vers", "side"] as Position[]).map((pos) => (
<Badge
key={pos}
variant={filters.positions.includes(pos) ? pos : "outline"}
className="cursor-pointer uppercase"
onClick={() => togglePosition(pos)}
>
{pos}
</Badge>
))}
</div>
</div>
<div>
<Label className="text-xs mb-2 block">Into</Label>
<div className="flex flex-wrap gap-1 max-h-24 overflow-y-auto">
{KINK_OPTIONS.map((k) => (
<Badge
key={k}
variant={filters.kinks.includes(k) ? "default" : "outline"}
className="cursor-pointer text-[10px]"
onClick={() => toggleKink(k)}
>
{k}
</Badge>
))}
</div>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs">On PrEP only</Label>
<Switch
checked={filters.onPrep === true}
onCheckedChange={(v) =>
setFilters((f) => ({ ...f, onPrep: v ? true : null }))
}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs">Online only</Label>
<Switch
checked={filters.onlineOnly}
onCheckedChange={(v) =>
setFilters((f) => ({ ...f, onlineOnly: v }))
}
/>
</div>
</div>
)}
{/* Sidebar — active cruisers */}
{showSidebar && (
<div className="absolute top-14 right-2 z-[1000] w-64 hidden md:block max-h-[calc(100%-5rem)] overflow-y-auto space-y-2 rounded-xl border border-border bg-horny-surface/95 backdrop-blur-md p-3 shadow-2xl">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Active Cruisers
</p>
{filteredProfiles
.filter((p) => p.status !== "offline")
.map((p) => (
<CruiserCard
key={p.id}
profile={p}
onChat={handleChat}
onView={setSelectedProfile}
vanillaMode={vanillaMode}
/>
))}
</div>
)}
{/* Map */}
{leafletReady && userIcon && spotIcon && (
<MapContainer
center={[DEFAULT_LAT, DEFAULT_LNG]}
zoom={14}
className="h-full w-full"
zoomControl={false}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MarkerClusterGroup chunkedLoading>
{filteredProfiles.map((p) =>
p.lat && p.lng ? (
<Marker
key={p.id}
position={[p.lat, p.lng]}
icon={(userIcon as { create: (pos: string | null, status: string) => unknown }).create(p.position, p.status)}
eventHandlers={{
click: () => setSelectedProfile(p),
}}
>
<Popup>
<div className="text-sm">
<strong>
{p.is_anonymous ? "Anonymous" : p.display_name || "Cruiser"}
</strong>
{p.position && (
<Badge variant={p.position} className="ml-2 text-[10px] uppercase">
{p.position}
</Badge>
)}
<p className="text-xs mt-1">{p.bio}</p>
<Button
variant="horny"
size="sm"
className="mt-2 w-full"
onClick={() => handleChat(p)}
>
Chat
</Button>
</div>
</Popup>
</Marker>
) : null
)}
</MarkerClusterGroup>
{spots.map((s) => (
<Marker
key={s.id}
position={[s.lat, s.lng]}
icon={(spotIcon as { create: (cat: string) => unknown }).create(s.category)}
>
<Popup>
<div className="text-sm">
<strong>{s.name}</strong>
<Badge variant="outline" className="ml-2 text-[10px]">
{s.category}
</Badge>
<p className="text-xs mt-1">{s.description}</p>
<p className="text-xs text-muted-foreground mt-1">
{s.active_count} active · {s.rating}
</p>
</div>
</Popup>
</Marker>
))}
</MapContainer>
)}
<ProfilePopup
profile={selectedProfile}
open={!!selectedProfile}
onClose={() => setSelectedProfile(null)}
onChat={handleChat}
vanillaMode={vanillaMode}
/>
</div>
);
}