Files
exposedgays/src/components/Map.tsx

349 lines
12 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 { CruiserProfileView } from "@/components/CruiserProfileView";
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
import { Filter, X, Locate } from "lucide-react";
import type { Profile, CruisingSpot, MapFilters, Position } from "@/types";
import { KINK_OPTIONS } from "@/types";
import { MOCK_PROFILES, MOCK_SPOTS } from "@/lib/mock-data";
import { useOpenIdVerification, useAccess } from "@/components/Providers";
import { distanceMiles } from "@/lib/profile-stats";
import "leaflet/dist/leaflet.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 DEFAULT_LAT = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LAT || "26.1224");
const DEFAULT_LNG = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LNG || "-80.1373");
const DARK_TILES =
"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
interface MapProps {
vanillaMode: boolean;
onActiveCountChange?: (count: number) => void;
}
export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
const openIdVerification = useOpenIdVerification();
const { capabilities, blurExplicit } = useAccess();
const [profiles] = useState<Profile[]>(MOCK_PROFILES);
const [spots] = useState<CruisingSpot[]>(MOCK_SPOTS);
const [selectedProfile, setSelectedProfile] = useState<Profile | null>(null);
const [showFilters, setShowFilters] = useState(false);
const [leafletReady, setLeafletReady] = useState(false);
const [iconFactory, setIconFactory] = useState<{
createPhoto: (p: Profile, blur: boolean) => import("leaflet").DivIcon;
createYou: () => import("leaflet").DivIcon;
createSpot: (category: string, count: number) => import("leaflet").DivIcon;
} | null>(null);
const [filters, setFilters] = useState<MapFilters>({
ageMin: 18,
ageMax: 65,
positions: [],
kinks: [],
onPrep: null,
status: [],
onlineOnly: true,
});
useEffect(() => {
import("leaflet").then((L) => {
const createPhoto = (p: Profile, blur: boolean) => {
const url =
p.avatar_url ||
"https://placehold.co/96x96/0a1628/3b82f6?text=?";
const dist =
p.distance_miles ??
(p.lat && p.lng
? distanceMiles(DEFAULT_LAT, DEFAULT_LNG, p.lat, p.lng)
: 1);
const distLabel =
dist < 1 ? dist.toFixed(1) : String(Math.max(1, Math.round(dist)));
const blurStyle = blur ? "filter:blur(6px);" : "";
const safeUrl = url.replace(/'/g, "%27");
return L.divIcon({
className: "sniffies-marker-wrap",
html: `<div class="sniffies-marker">
<div class="sniffies-avatar-ring ${p.status !== "offline" ? "sniffies-online" : ""}">
<div class="sniffies-avatar" style="background-image:url('${safeUrl}');${blurStyle}"></div>
</div>
<div class="sniffies-dist-badge">${distLabel}</div>
</div>`,
iconSize: [52, 58],
iconAnchor: [26, 29],
});
};
const createYou = () =>
L.divIcon({
className: "sniffies-you-wrap",
html: `<div class="sniffies-you">
<div class="sniffies-you-dot"></div>
<span class="sniffies-you-label">You</span>
</div>`,
iconSize: [48, 56],
iconAnchor: [24, 28],
});
const createSpot = (category: string, count: number) => {
const icons: Record<string, string> = {
club: "🍸",
gym: "💪",
beach: "🏖",
park: "🌳",
bookstore: "📚",
restroom: "🚻",
other: "📍",
};
const emoji = icons[category] || "📍";
return L.divIcon({
className: "sniffies-spot-wrap",
html: `<div class="sniffies-spot">
<span>${emoji}</span>
${count > 0 ? `<div class="sniffies-spot-count">${count}</div>` : ""}
</div>`,
iconSize: [36, 36],
iconAnchor: [18, 18],
});
};
setIconFactory({ createPhoto, createYou, createSpot });
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) => {
if (!capabilities.canSendMessages) return;
window.location.href = `/chat?user=${profile.id}`;
}, [capabilities.canSendMessages]);
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 bg-[#0a1628]">
{/* Minimal top controls — Sniffies style */}
<div className="absolute top-2 left-2 right-2 z-[1000] flex gap-2 items-start pointer-events-none">
<div className="pointer-events-auto flex gap-2 flex-wrap">
<Button
variant="secondary"
size="sm"
onClick={() => setShowFilters(!showFilters)}
className="shadow-lg bg-[#0a1628]/90 border-white/10 text-white h-9"
>
<Filter className="h-4 w-4" />
</Button>
<Badge
variant="online"
className="shadow-lg self-center bg-blue-500/20 text-blue-300 border-blue-500/30"
>
{filteredProfiles.filter((p) => p.status !== "offline").length} cruising
</Badge>
</div>
{!capabilities.canSendMessages && (
<div className="pointer-events-auto flex-1 max-w-xs ml-auto hidden sm:block">
<GuestUpgradeBanner reason="general" compact />
</div>
)}
</div>
{showFilters && (
<div className="absolute top-12 left-2 z-[1000] w-72 rounded-xl border border-white/10 bg-[#0a1628]/98 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 text-white">Filters</span>
<Button variant="ghost" size="icon" onClick={() => setShowFilters(false)}>
<X className="h-4 w-4" />
</Button>
</div>
<div>
<Label className="text-xs text-white/70">
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 text-white/70">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 text-white/70">Into</Label>
<div className="flex flex-wrap gap-1 max-h-24 overflow-y-auto">
{KINK_OPTIONS.slice(0, 12).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 text-white/70">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 text-white/70">Online only</Label>
<Switch
checked={filters.onlineOnly}
onCheckedChange={(v) =>
setFilters((f) => ({ ...f, onlineOnly: v }))
}
/>
</div>
</div>
)}
{/* Guest banner mobile */}
{!capabilities.canSendMessages && (
<div className="sm:hidden absolute top-12 left-2 right-2 z-[999]">
<GuestUpgradeBanner reason="general" compact />
</div>
)}
{leafletReady && iconFactory && (
<MapContainer
center={[DEFAULT_LAT, DEFAULT_LNG]}
zoom={15}
className="h-full w-full sniffies-map"
zoomControl={false}
>
<TileLayer
attribution='&copy; <a href="https://carto.com/">CARTO</a>'
url={DARK_TILES}
/>
<Marker
position={[DEFAULT_LAT, DEFAULT_LNG]}
icon={iconFactory.createYou()}
zIndexOffset={1000}
/>
{filteredProfiles.map((p) =>
p.lat && p.lng ? (
<Marker
key={p.id}
position={[p.lat, p.lng]}
icon={iconFactory.createPhoto(p, blurExplicit)}
eventHandlers={{
click: () => setSelectedProfile(p),
}}
/>
) : null
)}
{spots.map((s) => (
<Marker
key={s.id}
position={[s.lat, s.lng]}
icon={iconFactory.createSpot(s.category, s.active_count)}
eventHandlers={{
click: () => {
/* spot detail future */
},
}}
/>
))}
</MapContainer>
)}
{/* Recenter */}
<button
type="button"
className="absolute bottom-20 right-3 z-[1000] p-3 rounded-full bg-[#0a1628]/90 border border-white/15 text-blue-400 shadow-lg touch-manipulation md:bottom-6"
aria-label="Recenter map"
>
<Locate className="h-5 w-5" />
</button>
<CruiserProfileView
profile={selectedProfile}
open={!!selectedProfile}
onClose={() => setSelectedProfile(null)}
onChat={handleChat}
vanillaMode={vanillaMode}
onVerifyClick={openIdVerification}
/>
</div>
);
}