ExposedGays: map, shop, auth, Supabase schema, Coolify Dockerfile
This commit is contained in:
360
src/components/Map.tsx
Normal file
360
src/components/Map.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
"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='© <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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user