ExposedGays: map, shop, auth, Supabase schema, Coolify Dockerfile
This commit is contained in:
150
src/components/AgeGate.tsx
Normal file
150
src/components/AgeGate.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
|
||||
interface AgeGateProps {
|
||||
onVerified: () => void;
|
||||
}
|
||||
|
||||
export function AgeGate({ onVerified }: AgeGateProps) {
|
||||
const [month, setMonth] = useState("");
|
||||
const [day, setDay] = useState("");
|
||||
const [year, setYear] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
|
||||
const handleVerify = () => {
|
||||
const m = parseInt(month, 10);
|
||||
const d = parseInt(day, 10);
|
||||
const y = parseInt(year, 10);
|
||||
|
||||
if (!m || !d || !y) {
|
||||
setError("Please enter your full date of birth.");
|
||||
return;
|
||||
}
|
||||
|
||||
const dob = new Date(y, m - 1, d);
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - dob.getFullYear();
|
||||
const monthDiff = today.getMonth() - dob.getMonth();
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) {
|
||||
age--;
|
||||
}
|
||||
|
||||
if (age < 18) {
|
||||
setError("You must be 18 or older to access this site.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!agreed) {
|
||||
setError("You must confirm you are 18+ and agree to the disclaimer.");
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem("eg_age_verified", "true");
|
||||
localStorage.setItem("eg_age_verified_at", new Date().toISOString());
|
||||
onVerified();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/95 p-4">
|
||||
<div className="w-full max-w-md rounded-2xl border border-horny-pink/30 bg-horny-surface p-8 shadow-2xl shadow-horny-pink/20">
|
||||
<div className="mb-6 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-horny-gradient">
|
||||
<ShieldAlert className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold bg-horny-gradient bg-clip-text text-transparent">
|
||||
ExposedGays
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Adults only. Explicit content ahead.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="mb-2 block">Date of Birth</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<select
|
||||
value={month}
|
||||
onChange={(e) => setMonth(e.target.value)}
|
||||
className="h-10 rounded-md border border-input bg-background px-2 text-sm"
|
||||
>
|
||||
<option value="">Month</option>
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<option key={i + 1} value={i + 1}>
|
||||
{new Date(2000, i).toLocaleString("en", { month: "long" })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={day}
|
||||
onChange={(e) => setDay(e.target.value)}
|
||||
className="h-10 rounded-md border border-input bg-background px-2 text-sm"
|
||||
>
|
||||
<option value="">Day</option>
|
||||
{Array.from({ length: 31 }, (_, i) => (
|
||||
<option key={i + 1} value={i + 1}>
|
||||
{i + 1}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={year}
|
||||
onChange={(e) => setYear(e.target.value)}
|
||||
className="h-10 rounded-md border border-input bg-background px-2 text-sm"
|
||||
>
|
||||
<option value="">Year</option>
|
||||
{Array.from({ length: 80 }, (_, i) => {
|
||||
const y = new Date().getFullYear() - 18 - i;
|
||||
return (
|
||||
<option key={y} value={y}>
|
||||
{y}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={agreed}
|
||||
onChange={(e) => setAgreed(e.target.checked)}
|
||||
className="mt-1 h-4 w-4 rounded border-input accent-horny-pink"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground leading-relaxed">
|
||||
I confirm I am 18 years of age or older. I understand this site contains
|
||||
explicit adult content including nudity and sexual material. I agree to the{" "}
|
||||
<a href="/terms" className="text-horny-pink underline">
|
||||
Terms of Service
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a href="/privacy" className="text-horny-pink underline">
|
||||
Privacy Policy
|
||||
</a>
|
||||
. Pursuant to Florida Statute § 847.013, I acknowledge this material may be
|
||||
harmful to minors. Third-party age verification may be required in the future.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive text-center">{error}</p>
|
||||
)}
|
||||
|
||||
<Button variant="horny" className="w-full" size="lg" onClick={handleVerify}>
|
||||
Confirm I am 18+
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
100% free forever. No subscriptions. No credit card required.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
170
src/components/AuthModal.tsx
Normal file
170
src/components/AuthModal.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { useAuth } from "@/lib/auth/context";
|
||||
import { Ghost, Mail, Lock } from "lucide-react";
|
||||
|
||||
interface AuthModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AuthModal({ open, onClose }: AuthModalProps) {
|
||||
const { signUp, signIn, signInAnonymously } = useAuth();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSignUp = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
const { error: err } = await signUp(email, password);
|
||||
setLoading(false);
|
||||
if (err) setError(err);
|
||||
else {
|
||||
setSuccess("Check your email to confirm, or sign in if confirmation is disabled.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignIn = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const { error: err } = await signIn(email, password);
|
||||
setLoading(false);
|
||||
if (err) setError(err);
|
||||
else onClose();
|
||||
};
|
||||
|
||||
const handleAnonymous = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const { error: err } = await signInAnonymously();
|
||||
setLoading(false);
|
||||
if (err) setError(err);
|
||||
else onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Join ExposedGays — Free Forever</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="signup">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="signup">Sign Up</TabsTrigger>
|
||||
<TabsTrigger value="login">Log In</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="signup" className="space-y-4 mt-4">
|
||||
<div>
|
||||
<Label>Email</Label>
|
||||
<div className="relative mt-1">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Password</Label>
|
||||
<div className="relative mt-1">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="8+ characters"
|
||||
className="pl-10"
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
{success && <p className="text-sm text-green-400">{success}</p>}
|
||||
<Button
|
||||
variant="horny"
|
||||
className="w-full"
|
||||
onClick={handleSignUp}
|
||||
disabled={loading || !email || password.length < 8}
|
||||
>
|
||||
Create Free Account
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="login" className="space-y-4 mt-4">
|
||||
<div>
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Password</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button
|
||||
variant="horny"
|
||||
className="w-full"
|
||||
onClick={handleSignIn}
|
||||
disabled={loading || !email || !password}
|
||||
>
|
||||
Log In
|
||||
</Button>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t border-border" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-2"
|
||||
onClick={handleAnonymous}
|
||||
disabled={loading}
|
||||
>
|
||||
<Ghost className="h-4 w-4" />
|
||||
Continue Anonymously
|
||||
</Button>
|
||||
|
||||
<p className="text-[10px] text-center text-muted-foreground">
|
||||
No credit card. No paid tier. Ever.
|
||||
</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
215
src/components/ChatPanel.tsx
Normal file
215
src/components/ChatPanel.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Send, Image as ImageIcon, Users, Lock } from "lucide-react";
|
||||
import { timeAgo } from "@/lib/utils";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
sender: string;
|
||||
content: string;
|
||||
image_url?: string;
|
||||
created_at: string;
|
||||
isMe?: boolean;
|
||||
}
|
||||
|
||||
const CITY = process.env.NEXT_PUBLIC_DEFAULT_CITY || "Fort Lauderdale";
|
||||
|
||||
const MOCK_CITY_MESSAGES: Message[] = [
|
||||
{
|
||||
id: "1",
|
||||
sender: "Mike_Top",
|
||||
content: "Anyone at the beach right now?",
|
||||
created_at: new Date(Date.now() - 300000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
sender: "Anonymous",
|
||||
content: "Yeah, south end. Busy tonight.",
|
||||
created_at: new Date(Date.now() - 240000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
sender: "VersBear",
|
||||
content: "Hosting near Wilton. Door code 1234. Clean only.",
|
||||
created_at: new Date(Date.now() - 120000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
sender: "PupRex",
|
||||
content: "Woof! At Ramrod later if anyone wants to meet up 🐾",
|
||||
created_at: new Date(Date.now() - 60000).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_DM_MESSAGES: Message[] = [
|
||||
{
|
||||
id: "dm1",
|
||||
sender: "You",
|
||||
content: "Hey, saw you on the map. What's up?",
|
||||
created_at: new Date(Date.now() - 600000).toISOString(),
|
||||
isMe: true,
|
||||
},
|
||||
{
|
||||
id: "dm2",
|
||||
sender: "Mike_Top",
|
||||
content: "Not much, hosting. You?",
|
||||
created_at: new Date(Date.now() - 540000).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
interface ChatPanelProps {
|
||||
dmUserId?: string | null;
|
||||
}
|
||||
|
||||
export function ChatPanel({ dmUserId }: ChatPanelProps) {
|
||||
const [cityMessages, setCityMessages] = useState<Message[]>(MOCK_CITY_MESSAGES);
|
||||
const [dmMessages, setDmMessages] = useState<Message[]>(MOCK_DM_MESSAGES);
|
||||
const [input, setInput] = useState("");
|
||||
const [activeTab, setActiveTab] = useState(dmUserId ? "dm" : "city");
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [cityMessages, dmMessages, activeTab]);
|
||||
|
||||
const sendMessage = () => {
|
||||
if (!input.trim()) return;
|
||||
const msg: Message = {
|
||||
id: Date.now().toString(),
|
||||
sender: "You",
|
||||
content: input.trim(),
|
||||
created_at: new Date().toISOString(),
|
||||
isMe: true,
|
||||
};
|
||||
|
||||
if (activeTab === "city") {
|
||||
setCityMessages((prev) => [...prev, msg]);
|
||||
} else {
|
||||
setDmMessages((prev) => [...prev, msg]);
|
||||
}
|
||||
setInput("");
|
||||
};
|
||||
|
||||
const messages = activeTab === "city" ? cityMessages : dmMessages;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
|
||||
<TabsList className="mx-4 mt-2 grid grid-cols-3">
|
||||
<TabsTrigger value="city" className="gap-1 text-xs">
|
||||
<Users className="h-3 w-3" />
|
||||
{CITY}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="dm" className="gap-1 text-xs">
|
||||
<Lock className="h-3 w-3" />
|
||||
DMs
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="groups" className="gap-1 text-xs">
|
||||
Groups
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="city" className="flex-1 flex flex-col mt-0 overflow-hidden">
|
||||
<div className="px-4 py-2 border-b border-border">
|
||||
<Badge variant="online" className="text-[10px]">
|
||||
{cityMessages.length + 12} in room
|
||||
</Badge>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Auto-joined based on your location. Public city chat.
|
||||
</p>
|
||||
</div>
|
||||
<MessageList messages={messages} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="dm" className="flex-1 flex flex-col mt-0 overflow-hidden">
|
||||
<div className="px-4 py-2 border-b border-border">
|
||||
<p className="text-sm font-medium">
|
||||
{dmUserId ? `Chat with User #${dmUserId}` : "Private Messages"}
|
||||
</p>
|
||||
</div>
|
||||
<MessageList messages={dmMessages} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="groups" className="flex-1 flex flex-col mt-0 overflow-hidden">
|
||||
<div className="p-4 space-y-2">
|
||||
<GroupRoom name="Leather Night FTL" members={8} />
|
||||
<GroupRoom name="Beach Cruisers" members={15} />
|
||||
<GroupRoom name="Gym Sauna Squad" members={4} />
|
||||
<p className="text-xs text-muted-foreground text-center pt-4">
|
||||
Create a group room — free, no limits.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{(activeTab === "city" || activeTab === "dm") && (
|
||||
<div className="p-4 border-t border-border flex gap-2 safe-bottom">
|
||||
<Button variant="outline" size="icon">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Input
|
||||
placeholder="Type a message..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && sendMessage()}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="horny" size="icon" onClick={sendMessage}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Tabs>
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageList({ messages }: { messages: Message[] }) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex flex-col ${msg.isMe ? "items-end" : "items-start"}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="text-xs font-semibold text-horny-pink">
|
||||
{msg.sender}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{timeAgo(msg.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-xl px-3 py-2 text-sm ${
|
||||
msg.isMe
|
||||
? "bg-horny-pink/20 text-foreground"
|
||||
: "bg-secondary text-foreground"
|
||||
}`}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupRoom({ name, members }: { name: string; members: number }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg border border-border p-3 hover:border-horny-pink/30 cursor-pointer transition-colors">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{name}</p>
|
||||
<p className="text-xs text-muted-foreground">{members} members</p>
|
||||
</div>
|
||||
<Badge variant="online" className="text-[10px]">
|
||||
Live
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
src/components/CruiserCard.tsx
Normal file
90
src/components/CruiserCard.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { MessageCircle, MapPin } from "lucide-react";
|
||||
import type { Profile } from "@/types";
|
||||
import { STATUS_LABELS } from "@/types";
|
||||
import { timeAgo } from "@/lib/utils";
|
||||
|
||||
interface CruiserCardProps {
|
||||
profile: Profile;
|
||||
onChat: (profile: Profile) => void;
|
||||
onView: (profile: Profile) => void;
|
||||
vanillaMode?: boolean;
|
||||
}
|
||||
|
||||
export function CruiserCard({ profile, onChat, onView, vanillaMode }: CruiserCardProps) {
|
||||
const name = profile.is_anonymous
|
||||
? "Anonymous"
|
||||
: profile.display_name || profile.username || "Cruiser";
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="cursor-pointer overflow-hidden transition-all hover:border-horny-pink/50 hover:shadow-lg hover:shadow-horny-pink/10"
|
||||
onClick={() => onView(profile)}
|
||||
>
|
||||
<div className={`relative aspect-square ${vanillaMode ? "vanilla-blur" : ""}`}>
|
||||
<Image
|
||||
src={profile.avatar_url || "https://placehold.co/200x200/1a1220/ff2d6b?text=?"}
|
||||
alt={name}
|
||||
fill
|
||||
className="object-cover explicit-content"
|
||||
sizes="200px"
|
||||
/>
|
||||
{profile.status !== "offline" && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<Badge variant="online" className="text-[10px]">
|
||||
{STATUS_LABELS[profile.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{profile.position && (
|
||||
<div className="absolute bottom-2 left-2">
|
||||
<Badge variant={profile.position} className="text-[10px] uppercase">
|
||||
{profile.position}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardContent className="p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-sm truncate">{name}</span>
|
||||
{profile.age && (
|
||||
<span className="text-xs text-muted-foreground">{profile.age}</span>
|
||||
)}
|
||||
</div>
|
||||
{profile.kinks.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{profile.kinks.slice(0, 3).map((k) => (
|
||||
<Badge key={k} variant="outline" className="text-[9px] px-1.5 py-0">
|
||||
{k}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{profile.city || "Nearby"}
|
||||
</span>
|
||||
<span>{timeAgo(profile.last_active)}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="horny"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChat(profile);
|
||||
}}
|
||||
>
|
||||
<MessageCircle className="h-3 w-3" />
|
||||
Chat Now
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
124
src/components/Nav.tsx
Normal file
124
src/components/Nav.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Map, MessageCircle, MapPin, ShoppingBag, User, Eye, EyeOff, LogIn, LogOut } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useCartStore } from "@/lib/cart";
|
||||
import { useAuth } from "@/lib/auth/context";
|
||||
import { AuthModal } from "@/components/AuthModal";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NavProps {
|
||||
vanillaMode: boolean;
|
||||
onVanillaToggle: (v: boolean) => void;
|
||||
activeCount?: number;
|
||||
}
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/", label: "Map", icon: Map },
|
||||
{ href: "/spots", label: "Spots", icon: MapPin },
|
||||
{ href: "/chat", label: "Chat", icon: MessageCircle },
|
||||
{ href: "/shop", label: "Shop", icon: ShoppingBag },
|
||||
{ href: "/profile", label: "Profile", icon: User },
|
||||
];
|
||||
|
||||
export function Nav({ vanillaMode, onVanillaToggle, activeCount = 0 }: NavProps) {
|
||||
const pathname = usePathname();
|
||||
const itemCount = useCartStore((s) => s.itemCount());
|
||||
const { user, profile, signOut } = useAuth();
|
||||
const [authOpen, setAuthOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop top nav */}
|
||||
<header className="hidden md:flex fixed top-0 left-0 right-0 z-50 h-14 items-center justify-between border-b border-border bg-horny-dark/95 backdrop-blur-md px-6">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<span className="text-xl font-bold bg-horny-gradient bg-clip-text text-transparent">
|
||||
ExposedGays
|
||||
</span>
|
||||
{activeCount > 0 && (
|
||||
<Badge variant="online" className="text-[10px]">
|
||||
{activeCount} active
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-1">
|
||||
{NAV_ITEMS.map(({ href, label, icon: Icon }) => (
|
||||
<Link key={href} href={href}>
|
||||
<Button
|
||||
variant={pathname === href ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"relative gap-2",
|
||||
href === "/shop" && "text-horny-pink font-semibold"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
{href === "/shop" && itemCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-horny-pink text-[10px] font-bold text-white">
|
||||
{itemCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onVanillaToggle(!vanillaMode)}
|
||||
className="gap-2"
|
||||
>
|
||||
{vanillaMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
Vanilla
|
||||
</Button>
|
||||
{user ? (
|
||||
<Button variant="ghost" size="sm" onClick={() => signOut()} className="gap-2">
|
||||
<LogOut className="h-4 w-4" />
|
||||
{profile?.display_name || "Sign Out"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="horny" size="sm" onClick={() => setAuthOpen(true)} className="gap-2">
|
||||
<LogIn className="h-4 w-4" />
|
||||
Join Free
|
||||
</Button>
|
||||
)}
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
FREE FOREVER
|
||||
</Badge>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<AuthModal open={authOpen} onClose={() => setAuthOpen(false)} />
|
||||
|
||||
{/* Mobile bottom nav */}
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-50 flex h-16 items-center justify-around border-t border-border bg-horny-dark/95 backdrop-blur-md safe-bottom">
|
||||
{NAV_ITEMS.map(({ href, label, icon: Icon }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-0.5 px-3 py-1 text-[10px] transition-colors relative",
|
||||
pathname === href ? "text-horny-pink" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{label}
|
||||
{href === "/shop" && itemCount > 0 && (
|
||||
<span className="absolute top-0 right-1 flex h-4 w-4 items-center justify-center rounded-full bg-horny-pink text-[8px] font-bold text-white">
|
||||
{itemCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
104
src/components/ProfilePopup.tsx
Normal file
104
src/components/ProfilePopup.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { MessageCircle, Camera, Shield } from "lucide-react";
|
||||
import type { Profile } from "@/types";
|
||||
import { STATUS_LABELS } from "@/types";
|
||||
import { timeAgo } from "@/lib/utils";
|
||||
|
||||
interface ProfilePopupProps {
|
||||
profile: Profile | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onChat: (profile: Profile) => void;
|
||||
vanillaMode?: boolean;
|
||||
}
|
||||
|
||||
export function ProfilePopup({ profile, open, onClose, onChat, vanillaMode }: ProfilePopupProps) {
|
||||
if (!profile) return null;
|
||||
|
||||
const name = profile.is_anonymous
|
||||
? "Anonymous Cruiser"
|
||||
: profile.display_name || profile.username || "Cruiser";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{name}
|
||||
{profile.age && (
|
||||
<span className="text-muted-foreground font-normal">{profile.age}</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className={`relative aspect-[4/3] rounded-lg overflow-hidden ${vanillaMode ? "vanilla-blur" : ""}`}>
|
||||
<Image
|
||||
src={profile.avatar_url || "https://placehold.co/400x300/1a1220/ff2d6b?text=Profile"}
|
||||
alt={name}
|
||||
fill
|
||||
className="object-cover explicit-content"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{profile.position && (
|
||||
<Badge variant={profile.position} className="uppercase">
|
||||
{profile.position}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant={profile.status === "offline" ? "secondary" : "online"}>
|
||||
{STATUS_LABELS[profile.status]}
|
||||
</Badge>
|
||||
{profile.on_prep && (
|
||||
<Badge variant="outline" className="text-green-400 border-green-400/30">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
On PrEP
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{profile.bio && (
|
||||
<p className="text-sm text-muted-foreground">{profile.bio}</p>
|
||||
)}
|
||||
|
||||
{profile.kinks.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold mb-1 text-muted-foreground">INTO</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{profile.kinks.map((k) => (
|
||||
<Badge key={k} variant="outline" className="text-xs">
|
||||
{k}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profile.sti_status && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
STI Status: {profile.sti_status}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Last active: {timeAgo(profile.last_active)} · {profile.city}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="horny" className="flex-1" onClick={() => onChat(profile)}>
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
Instant Chat
|
||||
</Button>
|
||||
<Button variant="outline" size="icon">
|
||||
<Camera className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
61
src/components/Providers.tsx
Normal file
61
src/components/Providers.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, createContext, useContext } from "react";
|
||||
import { AgeGate } from "@/components/AgeGate";
|
||||
import { Nav } from "@/components/Nav";
|
||||
import { AuthProvider } from "@/lib/auth/context";
|
||||
|
||||
const ActiveCountContext = createContext<(n: number) => void>(() => {});
|
||||
const VanillaModeContext = createContext(false);
|
||||
|
||||
export function useSetActiveCount() {
|
||||
return useContext(ActiveCountContext);
|
||||
}
|
||||
|
||||
export function useVanillaMode() {
|
||||
return useContext(VanillaModeContext);
|
||||
}
|
||||
|
||||
interface ProvidersProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Providers({ children }: ProvidersProps) {
|
||||
const [verified, setVerified] = useState(false);
|
||||
const [vanillaMode, setVanillaMode] = useState(false);
|
||||
const [activeCount, setActiveCount] = useState(0);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
setVerified(localStorage.getItem("eg_age_verified") === "true");
|
||||
setVanillaMode(localStorage.getItem("eg_vanilla_mode") === "true");
|
||||
}, []);
|
||||
|
||||
const handleVanillaToggle = (v: boolean) => {
|
||||
setVanillaMode(v);
|
||||
localStorage.setItem("eg_vanilla_mode", String(v));
|
||||
};
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!verified && <AgeGate onVerified={() => setVerified(true)} />}
|
||||
<AuthProvider>
|
||||
<VanillaModeContext.Provider value={vanillaMode}>
|
||||
<Nav
|
||||
vanillaMode={vanillaMode}
|
||||
onVanillaToggle={handleVanillaToggle}
|
||||
activeCount={activeCount}
|
||||
/>
|
||||
<main className="md:pt-14 pb-16 md:pb-0 min-h-screen">
|
||||
<ActiveCountContext.Provider value={setActiveCount}>
|
||||
{children}
|
||||
</ActiveCountContext.Provider>
|
||||
</main>
|
||||
</VanillaModeContext.Provider>
|
||||
</AuthProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
321
src/components/Shop.tsx
Normal file
321
src/components/Shop.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import Image from "next/image";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ShoppingCart, ExternalLink, Search, Package } from "lucide-react";
|
||||
import { SHOP_PRODUCTS, CATEGORY_LABELS, CRUISING_BUNDLES } from "@/lib/products";
|
||||
import { useCartStore } from "@/lib/cart";
|
||||
import { formatPrice } from "@/lib/utils";
|
||||
import type { ShopCategory } from "@/types";
|
||||
|
||||
export function Shop() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [category, setCategory] = useState<ShopCategory | "all">("all");
|
||||
const [priceRange, setPriceRange] = useState([0, 100]);
|
||||
const [selectedKink, setSelectedKink] = useState<string | null>(null);
|
||||
const [showCart, setShowCart] = useState(false);
|
||||
|
||||
const { items, addItem, removeItem, updateQuantity, itemCount, clearCart } =
|
||||
useCartStore();
|
||||
|
||||
const allKinks = useMemo(() => {
|
||||
const kinks = new Set<string>();
|
||||
SHOP_PRODUCTS.forEach((p) => p.kinks.forEach((k) => kinks.add(k)));
|
||||
return Array.from(kinks).sort();
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return SHOP_PRODUCTS.filter((p) => {
|
||||
if (category !== "all" && p.category !== category) return false;
|
||||
if (p.price < priceRange[0] || p.price > priceRange[1]) return false;
|
||||
if (search && !p.name.toLowerCase().includes(search.toLowerCase())) return false;
|
||||
if (selectedKink && !p.kinks.includes(selectedKink)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [category, priceRange, search, selectedKink]);
|
||||
|
||||
const cartProducts = items.map((item) => {
|
||||
const product = SHOP_PRODUCTS.find((p) => p.id === item.productId);
|
||||
return product ? { ...product, quantity: item.quantity } : null;
|
||||
}).filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-horny-dark">
|
||||
{/* Hero */}
|
||||
<div className="bg-horny-gradient px-4 py-8 md:py-12">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white">
|
||||
ExposedGays Shop
|
||||
</h1>
|
||||
<p className="mt-2 text-white/80 text-sm md:text-base">
|
||||
Toys, lube, poppers, jocks & more — affiliate links, 100% free to browse.
|
||||
We earn a small commission. You pay nothing extra.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||
{/* Search + Cart */}
|
||||
<div className="flex gap-3 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search products..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="relative gap-2"
|
||||
onClick={() => setShowCart(!showCart)}
|
||||
>
|
||||
<ShoppingCart className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Cart</span>
|
||||
{itemCount() > 0 && (
|
||||
<span className="absolute -top-2 -right-2 flex h-5 w-5 items-center justify-center rounded-full bg-horny-pink text-[10px] font-bold text-white">
|
||||
{itemCount()}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Cart drawer */}
|
||||
{showCart && (
|
||||
<Card className="mb-6 border-horny-pink/30">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold flex items-center gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
Your Cart ({itemCount()} items)
|
||||
</h3>
|
||||
{items.length > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={clearCart}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{cartProducts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
Cart is empty. Add items to track what you want to buy.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{cartProducts.map((p) => p && (
|
||||
<div key={p.id} className="flex items-center gap-3">
|
||||
<Image
|
||||
src={p.image}
|
||||
alt={p.name}
|
||||
width={48}
|
||||
height={48}
|
||||
className="rounded-md object-cover"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{p.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatPrice(p.price)} × {p.quantity}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => updateQuantity(p.id, p.quantity - 1)}
|
||||
>
|
||||
−
|
||||
</Button>
|
||||
<span className="text-sm w-6 text-center">{p.quantity}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => updateQuantity(p.id, p.quantity + 1)}
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="horny"
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
<a href={p.affiliateUrl} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-xs text-muted-foreground text-center pt-2">
|
||||
Checkout happens on affiliate sites. Cart is local only.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Categories */}
|
||||
<Tabs value={category} onValueChange={(v) => setCategory(v as ShopCategory | "all")} className="mb-6">
|
||||
<TabsList className="flex flex-wrap h-auto gap-1 bg-transparent p-0">
|
||||
<TabsTrigger value="all" className="data-[state=active]:bg-horny-pink data-[state=active]:text-white">
|
||||
All
|
||||
</TabsTrigger>
|
||||
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
|
||||
<TabsTrigger
|
||||
key={key}
|
||||
value={key}
|
||||
className="data-[state=active]:bg-horny-pink data-[state=active]:text-white text-xs"
|
||||
>
|
||||
{label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{/* Filters row */}
|
||||
<div className="flex flex-wrap gap-4 mb-6 items-center">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
Price: {formatPrice(priceRange[0])} – {formatPrice(priceRange[1])}
|
||||
</p>
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={priceRange}
|
||||
onValueChange={setPriceRange}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{allKinks.map((k) => (
|
||||
<Badge
|
||||
key={k}
|
||||
variant={selectedKink === k ? "default" : "outline"}
|
||||
className="cursor-pointer text-[10px]"
|
||||
onClick={() => setSelectedKink(selectedKink === k ? null : k)}
|
||||
>
|
||||
{k}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cruising bundles */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold mb-3">Frequently Bought With Cruising Supplies</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{CRUISING_BUNDLES.map((bundle) => {
|
||||
const bundleProducts = bundle.productIds
|
||||
.map((id) => SHOP_PRODUCTS.find((p) => p.id === id))
|
||||
.filter(Boolean);
|
||||
const totalPrice = bundleProducts.reduce((sum, p) => sum + (p?.price || 0), 0);
|
||||
return (
|
||||
<Card key={bundle.id} className="border-horny-purple/30">
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-semibold">{bundle.name}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{bundleProducts.map((p) => p?.name).join(" + ")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="text-lg font-bold text-horny-pink">
|
||||
{formatPrice(totalPrice - bundle.savings)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground line-through">
|
||||
{formatPrice(totalPrice)}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Save {formatPrice(bundle.savings)}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant="horny"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={() =>
|
||||
bundle.productIds.forEach((id) => addItem(id))
|
||||
}
|
||||
>
|
||||
<ShoppingCart className="h-3 w-3" />
|
||||
Add Bundle to Cart
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{filtered.map((product) => (
|
||||
<Card
|
||||
key={product.id}
|
||||
className="overflow-hidden group hover:border-horny-pink/50 transition-all"
|
||||
>
|
||||
<div className="relative aspect-square">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform"
|
||||
sizes="(max-width: 768px) 50vw, 25vw"
|
||||
/>
|
||||
</div>
|
||||
<CardContent className="p-3 space-y-1">
|
||||
<h3 className="font-semibold text-sm leading-tight">{product.name}</h3>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{product.description}
|
||||
</p>
|
||||
<p className="text-lg font-bold text-horny-pink">
|
||||
{product.price === 0 ? "FREE" : formatPrice(product.price)}
|
||||
</p>
|
||||
{product.kinks.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{product.kinks.map((k) => (
|
||||
<Badge key={k} variant="outline" className="text-[9px] px-1">
|
||||
{k}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="p-3 pt-0 flex flex-col gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => addItem(product.id)}
|
||||
>
|
||||
<ShoppingCart className="h-3 w-3" />
|
||||
Add to Cart
|
||||
</Button>
|
||||
<Button variant="horny" size="sm" className="w-full" asChild>
|
||||
<a
|
||||
href={product.affiliateUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer sponsored"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{product.affiliateLabel}
|
||||
</a>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-center text-muted-foreground py-12">
|
||||
No products match your filters.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
src/components/SpotDirectory.tsx
Normal file
126
src/components/SpotDirectory.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Search, MapPin, Star, Users, MessageCircle } from "lucide-react";
|
||||
import { MOCK_SPOTS } from "@/lib/mock-data";
|
||||
import type { CruisingSpot } from "@/types";
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
park: "Park",
|
||||
beach: "Beach",
|
||||
gym: "Gym",
|
||||
restroom: "Restroom",
|
||||
bookstore: "Bookstore",
|
||||
club: "Club",
|
||||
other: "Other",
|
||||
};
|
||||
|
||||
export function SpotDirectory() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [category, setCategory] = useState<string | null>(null);
|
||||
|
||||
const filtered = MOCK_SPOTS.filter((s) => {
|
||||
if (search && !s.name.toLowerCase().includes(search.toLowerCase())) return false;
|
||||
if (category && s.category !== category) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-6">
|
||||
<h1 className="text-2xl font-bold mb-2">Cruising Spots</h1>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
Directory of popular cruising locations. Tap to view on map or join spot chat.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3 mb-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search spots..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1 mb-6">
|
||||
<Badge
|
||||
variant={category === null ? "default" : "outline"}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setCategory(null)}
|
||||
>
|
||||
All
|
||||
</Badge>
|
||||
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
|
||||
<Badge
|
||||
key={key}
|
||||
variant={category === key ? "default" : "outline"}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setCategory(category === key ? null : key)}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{filtered.map((spot) => (
|
||||
<SpotCard key={spot.id} spot={spot} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpotCard({ spot }: { spot: CruisingSpot }) {
|
||||
return (
|
||||
<Card className="hover:border-horny-pink/30 transition-colors">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold">{spot.name}</h3>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{CATEGORY_LABELS[spot.category]}
|
||||
</Badge>
|
||||
{spot.is_popular && (
|
||||
<Badge className="text-[10px] bg-horny-pink/20 text-horny-pink">
|
||||
Popular
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{spot.description}</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{spot.city}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Star className="h-3 w-3 text-yellow-500" />
|
||||
{spot.rating}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3 text-green-400" />
|
||||
{spot.active_count} active
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={`/?spot=${spot.id}`}>View Map</a>
|
||||
</Button>
|
||||
<Button variant="horny" size="sm" className="gap-1">
|
||||
<MessageCircle className="h-3 w-3" />
|
||||
Spot Chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
33
src/components/ui/badge.tsx
Normal file
33
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
top: "border-transparent bg-blue-500/20 text-blue-400",
|
||||
bottom: "border-transparent bg-orange-500/20 text-orange-400",
|
||||
vers: "border-transparent bg-purple-500/20 text-purple-400",
|
||||
side: "border-transparent bg-green-500/20 text-green-400",
|
||||
online: "border-transparent bg-green-500/20 text-green-400 animate-pulse",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
46
src/components/ui/button.tsx
Normal file
46
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
horny: "bg-horny-gradient text-white shadow-lg hover:opacity-90 font-semibold",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-12 rounded-md px-8 text-base",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default", size: "default" },
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
46
src/components/ui/card.tsx
Normal file
46
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("rounded-xl border bg-card text-card-foreground shadow", className)} {...props} />
|
||||
)
|
||||
);
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("font-semibold leading-none tracking-tight", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
64
src/components/ui/dialog.tsx
Normal file
64
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||
);
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold leading-none tracking-tight", className)} {...props} />
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
export { Dialog, DialogPortal, DialogOverlay, DialogTrigger, DialogClose, DialogContent, DialogHeader, DialogTitle };
|
||||
19
src/components/ui/input.tsx
Normal file
19
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
19
src/components/ui/label.tsx
Normal file
19
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
25
src/components/ui/slider.tsx
Normal file
25
src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative flex w-full touch-none select-none items-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider };
|
||||
28
src/components/ui/switch.tsx
Normal file
28
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
51
src/components/ui/tabs.tsx
Normal file
51
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn("mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
Reference in New Issue
Block a user