v2: ID verification by state, 15 photos/5 albums, 20 quick replies, logo, mobile polish
This commit is contained in:
@@ -3,7 +3,8 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { US_STATES, setUserState, requiresIdVerification } from "@/lib/id-verification";
|
||||
|
||||
interface AgeGateProps {
|
||||
onVerified: () => void;
|
||||
@@ -13,6 +14,7 @@ export function AgeGate({ onVerified }: AgeGateProps) {
|
||||
const [month, setMonth] = useState("");
|
||||
const [day, setDay] = useState("");
|
||||
const [year, setYear] = useState("");
|
||||
const [state, setState] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
|
||||
@@ -26,6 +28,11 @@ export function AgeGate({ onVerified }: AgeGateProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
setError("Please select your state — required for content access rules.");
|
||||
return;
|
||||
}
|
||||
|
||||
const dob = new Date(y, m - 1, d);
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - dob.getFullYear();
|
||||
@@ -46,15 +53,25 @@ export function AgeGate({ onVerified }: AgeGateProps) {
|
||||
|
||||
localStorage.setItem("eg_age_verified", "true");
|
||||
localStorage.setItem("eg_age_verified_at", new Date().toISOString());
|
||||
setUserState(state);
|
||||
|
||||
if (requiresIdVerification(state)) {
|
||||
localStorage.setItem("eg_id_verification_required", "true");
|
||||
} else {
|
||||
localStorage.removeItem("eg_id_verification_required");
|
||||
}
|
||||
|
||||
onVerified();
|
||||
};
|
||||
|
||||
const idRequired = requiresIdVerification(state);
|
||||
|
||||
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="fixed inset-0 z-[9999] flex items-center justify-center bg-black/95 p-4 overflow-y-auto">
|
||||
<div className="w-full max-w-md rounded-2xl border border-horny-pink/30 bg-horny-surface p-6 sm:p-8 shadow-2xl shadow-horny-pink/20 my-auto">
|
||||
<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 className="mx-auto mb-4 flex justify-center">
|
||||
<Logo size="lg" showText={false} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold bg-horny-gradient bg-clip-text text-transparent">
|
||||
ExposedGays
|
||||
@@ -71,7 +88,7 @@ export function AgeGate({ onVerified }: AgeGateProps) {
|
||||
<select
|
||||
value={month}
|
||||
onChange={(e) => setMonth(e.target.value)}
|
||||
className="h-10 rounded-md border border-input bg-background px-2 text-sm"
|
||||
className="h-11 rounded-md border border-input bg-background px-2 text-sm touch-manipulation"
|
||||
>
|
||||
<option value="">Month</option>
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
@@ -83,7 +100,7 @@ export function AgeGate({ onVerified }: AgeGateProps) {
|
||||
<select
|
||||
value={day}
|
||||
onChange={(e) => setDay(e.target.value)}
|
||||
className="h-10 rounded-md border border-input bg-background px-2 text-sm"
|
||||
className="h-11 rounded-md border border-input bg-background px-2 text-sm touch-manipulation"
|
||||
>
|
||||
<option value="">Day</option>
|
||||
{Array.from({ length: 31 }, (_, i) => (
|
||||
@@ -95,7 +112,7 @@ export function AgeGate({ onVerified }: AgeGateProps) {
|
||||
<select
|
||||
value={year}
|
||||
onChange={(e) => setYear(e.target.value)}
|
||||
className="h-10 rounded-md border border-input bg-background px-2 text-sm"
|
||||
className="h-11 rounded-md border border-input bg-background px-2 text-sm touch-manipulation"
|
||||
>
|
||||
<option value="">Year</option>
|
||||
{Array.from({ length: 80 }, (_, i) => {
|
||||
@@ -110,12 +127,34 @@ export function AgeGate({ onVerified }: AgeGateProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2 block">Your State</Label>
|
||||
<select
|
||||
value={state}
|
||||
onChange={(e) => setState(e.target.value)}
|
||||
className="h-11 w-full rounded-md border border-input bg-background px-3 text-sm touch-manipulation"
|
||||
>
|
||||
<option value="">Select state...</option>
|
||||
{US_STATES.map((s) => (
|
||||
<option key={s.code} value={s.code}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{idRequired && (
|
||||
<p className="text-xs text-amber-400 mt-1.5">
|
||||
{US_STATES.find((s) => s.code === state)?.name} requires ID verification
|
||||
to view nude photos. You can verify after entering.
|
||||
</p>
|
||||
)}
|
||||
</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"
|
||||
className="mt-1 h-5 w-5 rounded border-input accent-horny-pink touch-manipulation"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground leading-relaxed">
|
||||
I confirm I am 18 years of age or older. I understand this site contains
|
||||
@@ -127,8 +166,8 @@ export function AgeGate({ onVerified }: AgeGateProps) {
|
||||
<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.
|
||||
. Pursuant to applicable state law, I acknowledge government ID verification
|
||||
may be required to view nude content in certain states.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -136,7 +175,12 @@ export function AgeGate({ onVerified }: AgeGateProps) {
|
||||
<p className="text-sm text-destructive text-center">{error}</p>
|
||||
)}
|
||||
|
||||
<Button variant="horny" className="w-full" size="lg" onClick={handleVerify}>
|
||||
<Button
|
||||
variant="horny"
|
||||
className="w-full h-12 text-base touch-manipulation"
|
||||
size="lg"
|
||||
onClick={handleVerify}
|
||||
>
|
||||
Confirm I am 18+
|
||||
</Button>
|
||||
|
||||
|
||||
216
src/components/CannedMessagesPanel.tsx
Normal file
216
src/components/CannedMessagesPanel.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { MessageSquarePlus, Trash2, Pencil, Check, X } from "lucide-react";
|
||||
import {
|
||||
loadCannedMessages,
|
||||
saveCannedMessage,
|
||||
deleteCannedMessage,
|
||||
type CannedMessage,
|
||||
} from "@/lib/canned-messages-store";
|
||||
import { MAX_CANNED_MESSAGES, MAX_CANNED_MESSAGE_LENGTH } from "@/lib/limits";
|
||||
import { useAuth } from "@/lib/auth/context";
|
||||
|
||||
interface CannedMessagesPanelProps {
|
||||
compact?: boolean;
|
||||
onSelect?: (content: string) => void;
|
||||
}
|
||||
|
||||
export function CannedMessagesPanel({ compact, onSelect }: CannedMessagesPanelProps) {
|
||||
const { user } = useAuth();
|
||||
const [messages, setMessages] = useState<CannedMessage[]>([]);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
const [editContent, setEditContent] = useState("");
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
const [newContent, setNewContent] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const refresh = async () => {
|
||||
setMessages(await loadCannedMessages(user?.id));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [user?.id]);
|
||||
|
||||
const startEdit = (msg: CannedMessage) => {
|
||||
setEditing(msg.id);
|
||||
setEditTitle(msg.title);
|
||||
setEditContent(msg.content);
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
const { error: err } = await saveCannedMessage(
|
||||
{ id: editing!, title: editTitle, content: editContent },
|
||||
user?.id
|
||||
);
|
||||
if (err) setError(err);
|
||||
else {
|
||||
setEditing(null);
|
||||
setError("");
|
||||
await refresh();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const { error: err } = await saveCannedMessage(
|
||||
{ title: newTitle, content: newContent },
|
||||
user?.id
|
||||
);
|
||||
if (err) setError(err);
|
||||
else {
|
||||
setShowNew(false);
|
||||
setNewTitle("");
|
||||
setNewContent("");
|
||||
setError("");
|
||||
await refresh();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await deleteCannedMessage(id, user?.id);
|
||||
await refresh();
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto">
|
||||
{messages.map((msg) => (
|
||||
<button
|
||||
key={msg.id}
|
||||
type="button"
|
||||
onClick={() => onSelect?.(msg.content)}
|
||||
className="text-xs px-2.5 py-1.5 rounded-full border border-border hover:border-horny-pink/50 hover:bg-horny-pink/10 transition-colors truncate max-w-[140px]"
|
||||
title={msg.content}
|
||||
>
|
||||
{msg.title}
|
||||
</button>
|
||||
))}
|
||||
{messages.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">No quick replies — add them in Profile</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<MessageSquarePlus className="h-5 w-5 text-horny-pink" />
|
||||
Quick Replies
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{messages.length}/{MAX_CANNED_MESSAGES}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Save addresses, directions, hosting notes — tap to insert while messaging.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className="rounded-lg border border-border p-3 space-y-2"
|
||||
>
|
||||
{editing === msg.id ? (
|
||||
<>
|
||||
<Input
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
placeholder="Label (e.g. Address)"
|
||||
/>
|
||||
<textarea
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
maxLength={MAX_CANNED_MESSAGE_LENGTH}
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm resize-none"
|
||||
placeholder="Message text..."
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="horny" size="sm" onClick={saveEdit}>
|
||||
<Check className="h-3 w-3 mr-1" /> Save
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setEditing(null)}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-horny-pink">{msg.title}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{msg.content}</p>
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => startEdit(msg)}>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleDelete(msg.id)}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{showNew ? (
|
||||
<div className="space-y-2 border border-dashed border-border rounded-lg p-3">
|
||||
<div>
|
||||
<Label className="text-xs">Label</Label>
|
||||
<Input
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
placeholder="e.g. Door code"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Message</Label>
|
||||
<textarea
|
||||
value={newContent}
|
||||
onChange={(e) => setNewContent(e.target.value)}
|
||||
maxLength={MAX_CANNED_MESSAGE_LENGTH}
|
||||
rows={3}
|
||||
className="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm resize-none"
|
||||
placeholder="Full message to insert..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="horny" size="sm" onClick={handleCreate}>
|
||||
Add Quick Reply
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowNew(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
messages.length < MAX_CANNED_MESSAGES && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setShowNew(true)}
|
||||
>
|
||||
<MessageSquarePlus className="h-4 w-4 mr-2" />
|
||||
Add Quick Reply
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-destructive text-center">{error}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,9 @@ 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 { Send, Image as ImageIcon, Users, Lock, Zap, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { timeAgo } from "@/lib/utils";
|
||||
import { CannedMessagesPanel } from "@/components/CannedMessagesPanel";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
@@ -41,7 +42,7 @@ const MOCK_CITY_MESSAGES: Message[] = [
|
||||
{
|
||||
id: "4",
|
||||
sender: "PupRex",
|
||||
content: "Woof! At Ramrod later if anyone wants to meet up 🐾",
|
||||
content: "Woof! At Ramrod later if anyone wants to meet up",
|
||||
created_at: new Date(Date.now() - 60000).toISOString(),
|
||||
},
|
||||
];
|
||||
@@ -71,18 +72,20 @@ export function ChatPanel({ dmUserId }: ChatPanelProps) {
|
||||
const [dmMessages, setDmMessages] = useState<Message[]>(MOCK_DM_MESSAGES);
|
||||
const [input, setInput] = useState("");
|
||||
const [activeTab, setActiveTab] = useState(dmUserId ? "dm" : "city");
|
||||
const [showQuickReplies, setShowQuickReplies] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [cityMessages, dmMessages, activeTab]);
|
||||
|
||||
const sendMessage = () => {
|
||||
if (!input.trim()) return;
|
||||
const sendMessage = (text?: string) => {
|
||||
const content = (text ?? input).trim();
|
||||
if (!content) return;
|
||||
const msg: Message = {
|
||||
id: Date.now().toString(),
|
||||
sender: "You",
|
||||
content: input.trim(),
|
||||
content,
|
||||
created_at: new Date().toISOString(),
|
||||
isMe: true,
|
||||
};
|
||||
@@ -93,29 +96,31 @@ export function ChatPanel({ dmUserId }: ChatPanelProps) {
|
||||
setDmMessages((prev) => [...prev, msg]);
|
||||
}
|
||||
setInput("");
|
||||
setShowQuickReplies(false);
|
||||
};
|
||||
|
||||
const messages = activeTab === "city" ? cityMessages : dmMessages;
|
||||
const showComposer = activeTab === "city" || activeTab === "dm";
|
||||
|
||||
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">
|
||||
<TabsList className="mx-3 sm:mx-4 mt-2 grid grid-cols-3">
|
||||
<TabsTrigger value="city" className="gap-1 text-xs min-h-[40px]">
|
||||
<Users className="h-3 w-3" />
|
||||
{CITY}
|
||||
<span className="truncate">{CITY}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="dm" className="gap-1 text-xs">
|
||||
<TabsTrigger value="dm" className="gap-1 text-xs min-h-[40px]">
|
||||
<Lock className="h-3 w-3" />
|
||||
DMs
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="groups" className="gap-1 text-xs">
|
||||
<TabsTrigger value="groups" className="gap-1 text-xs min-h-[40px]">
|
||||
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">
|
||||
<TabsContent value="city" className="flex-1 flex flex-col mt-0 overflow-hidden data-[state=inactive]:hidden">
|
||||
<div className="px-3 sm:px-4 py-2 border-b border-border">
|
||||
<Badge variant="online" className="text-[10px]">
|
||||
{cityMessages.length + 12} in room
|
||||
</Badge>
|
||||
@@ -126,17 +131,20 @@ export function ChatPanel({ dmUserId }: ChatPanelProps) {
|
||||
<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">
|
||||
<TabsContent value="dm" className="flex-1 flex flex-col mt-0 overflow-hidden data-[state=inactive]:hidden">
|
||||
<div className="px-3 sm:px-4 py-2 border-b border-border">
|
||||
<p className="text-sm font-medium">
|
||||
{dmUserId ? `Chat with User #${dmUserId}` : "Private Messages"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use quick replies for address, directions & hosting notes
|
||||
</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">
|
||||
<TabsContent value="groups" className="flex-1 flex flex-col mt-0 overflow-hidden data-[state=inactive]:hidden">
|
||||
<div className="p-3 sm:p-4 space-y-2 overflow-y-auto">
|
||||
<GroupRoom name="Leather Night FTL" members={8} />
|
||||
<GroupRoom name="Beach Cruisers" members={15} />
|
||||
<GroupRoom name="Gym Sauna Squad" members={4} />
|
||||
@@ -146,21 +154,50 @@ export function ChatPanel({ dmUserId }: ChatPanelProps) {
|
||||
</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>
|
||||
{showComposer && (
|
||||
<div className="border-t border-border">
|
||||
{activeTab === "dm" && (
|
||||
<div className="px-3 sm:px-4 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowQuickReplies(!showQuickReplies)}
|
||||
className="flex items-center gap-1.5 text-xs text-horny-pink font-medium mb-2 touch-manipulation"
|
||||
>
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
Quick Replies
|
||||
{showQuickReplies ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
{showQuickReplies && (
|
||||
<div className="mb-2">
|
||||
<CannedMessagesPanel compact onSelect={(c) => sendMessage(c)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3 sm:p-4 flex gap-2 safe-bottom">
|
||||
<Button variant="outline" size="icon" className="shrink-0 h-11 w-11 touch-manipulation">
|
||||
<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 h-11 text-base"
|
||||
/>
|
||||
<Button
|
||||
variant="horny"
|
||||
size="icon"
|
||||
className="shrink-0 h-11 w-11 touch-manipulation"
|
||||
onClick={() => sendMessage()}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Tabs>
|
||||
@@ -171,7 +208,7 @@ export function ChatPanel({ dmUserId }: ChatPanelProps) {
|
||||
|
||||
function MessageList({ messages }: { messages: Message[] }) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
|
||||
<div className="flex-1 overflow-y-auto px-3 sm:px-4 py-3 space-y-3 overscroll-contain">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
@@ -186,7 +223,7 @@ function MessageList({ messages }: { messages: Message[] }) {
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-xl px-3 py-2 text-sm ${
|
||||
className={`max-w-[85%] sm:max-w-[80%] rounded-xl px-3 py-2.5 text-sm ${
|
||||
msg.isMe
|
||||
? "bg-horny-pink/20 text-foreground"
|
||||
: "bg-secondary text-foreground"
|
||||
@@ -202,7 +239,7 @@ function MessageList({ messages }: { messages: Message[] }) {
|
||||
|
||||
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 className="flex items-center justify-between rounded-lg border border-border p-3 hover:border-horny-pink/30 cursor-pointer transition-colors min-h-[56px] touch-manipulation active:bg-secondary/50">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{name}</p>
|
||||
<p className="text-xs text-muted-foreground">{members} members</p>
|
||||
|
||||
169
src/components/IdVerificationModal.tsx
Normal file
169
src/components/IdVerificationModal.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ShieldCheck, Upload, AlertTriangle } from "lucide-react";
|
||||
import { setIdVerified, stateName, getUserState } from "@/lib/id-verification";
|
||||
import { useAuth } from "@/lib/auth/context";
|
||||
|
||||
interface IdVerificationModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onVerified: () => void;
|
||||
}
|
||||
|
||||
export function IdVerificationModal({
|
||||
open,
|
||||
onClose,
|
||||
onVerified,
|
||||
}: IdVerificationModalProps) {
|
||||
const { user, updateProfile } = useAuth();
|
||||
const [step, setStep] = useState<"info" | "upload" | "done">("info");
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const state = getUserState();
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!agreed) {
|
||||
setError("You must agree to the verification terms.");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
|
||||
// Self-attestation flow — production hooks to Veriff/Yoti via NEXT_PUBLIC_AGE_VERIFY_PROVIDER
|
||||
setIdVerified(true);
|
||||
|
||||
if (user) {
|
||||
await updateProfile({
|
||||
id_verified: true,
|
||||
id_verified_at: new Date().toISOString(),
|
||||
} as Parameters<typeof updateProfile>[0]);
|
||||
}
|
||||
|
||||
setStep("done");
|
||||
setSubmitting(false);
|
||||
onVerified();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-horny-pink" />
|
||||
ID Verification Required
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{state
|
||||
? `${stateName(state)} law requires government ID verification to view nude photos.`
|
||||
: "Your state requires ID verification to view explicit content."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === "info" && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 flex gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-400 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Nude and explicit photos are blurred until you complete a one-time age
|
||||
verification. We never store your ID image on our servers — verification
|
||||
is processed by a third-party provider and only a pass/fail result is
|
||||
retained.
|
||||
</p>
|
||||
</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 accent-horny-pink"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground leading-relaxed">
|
||||
I confirm I am 18+ and consent to age verification. I understand explicit
|
||||
photos remain blurred until verification is complete. I agree to the{" "}
|
||||
<a href="/privacy" className="text-horny-pink underline">
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="flex-1" onClick={onClose}>
|
||||
Later
|
||||
</Button>
|
||||
<Button
|
||||
variant="horny"
|
||||
className="flex-1"
|
||||
onClick={() => setStep("upload")}
|
||||
disabled={!agreed}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "upload" && (
|
||||
<div className="space-y-4">
|
||||
<div className="border-2 border-dashed border-border rounded-xl p-8 text-center">
|
||||
<Upload className="h-10 w-10 mx-auto text-muted-foreground mb-3" />
|
||||
<p className="text-sm font-medium">Government ID Upload</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Driver's license or passport. Encrypted & deleted after verification.
|
||||
</p>
|
||||
<p className="text-xs text-horny-pink mt-3">
|
||||
Provider: {process.env.NEXT_PUBLIC_AGE_VERIFY_PROVIDER || "pending integration"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
For now, tap Verify to confirm your age via self-attestation. Full Veriff/Yoti
|
||||
integration will replace this step.
|
||||
</p>
|
||||
|
||||
{error && <p className="text-sm text-destructive text-center">{error}</p>}
|
||||
|
||||
<Button
|
||||
variant="horny"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={handleVerify}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "Verifying..." : "Verify My Age"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "done" && (
|
||||
<div className="text-center space-y-4 py-4">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-500/20">
|
||||
<ShieldCheck className="h-8 w-8 text-green-400" />
|
||||
</div>
|
||||
<p className="font-semibold text-green-400">Verified!</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nude photos are now visible. This verification lasts 90 days.
|
||||
</p>
|
||||
<Button variant="horny" className="w-full" onClick={onClose}>
|
||||
Got it
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
39
src/components/Logo.tsx
Normal file
39
src/components/Logo.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import Image from "next/image";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface LogoProps {
|
||||
size?: "sm" | "md" | "lg";
|
||||
showText?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SIZES = { sm: 28, md: 36, lg: 48 };
|
||||
|
||||
export function Logo({ size = "md", showText = true, className }: LogoProps) {
|
||||
const px = SIZES[size];
|
||||
|
||||
return (
|
||||
<span className={cn("inline-flex items-center gap-2", className)}>
|
||||
<Image
|
||||
src="/logo.svg"
|
||||
alt="ExposedGays"
|
||||
width={px}
|
||||
height={px}
|
||||
className="shrink-0"
|
||||
priority
|
||||
/>
|
||||
{showText && (
|
||||
<span
|
||||
className={cn(
|
||||
"font-bold bg-horny-gradient bg-clip-text text-transparent",
|
||||
size === "sm" && "text-base",
|
||||
size === "md" && "text-xl",
|
||||
size === "lg" && "text-2xl"
|
||||
)}
|
||||
>
|
||||
ExposedGays
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ 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 { useOpenIdVerification } from "@/components/Providers";
|
||||
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import "leaflet.markercluster/dist/MarkerCluster.css";
|
||||
@@ -48,6 +49,7 @@ interface MapProps {
|
||||
}
|
||||
|
||||
export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
|
||||
const openIdVerification = useOpenIdVerification();
|
||||
const [profiles, setProfiles] = useState<Profile[]>(MOCK_PROFILES);
|
||||
const [spots] = useState<CruisingSpot[]>(MOCK_SPOTS);
|
||||
const [selectedProfile, setSelectedProfile] = useState<Profile | null>(null);
|
||||
@@ -358,6 +360,7 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
|
||||
onClose={() => setSelectedProfile(null)}
|
||||
onChat={handleChat}
|
||||
vanillaMode={vanillaMode}
|
||||
onVerifyClick={openIdVerification}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
50
src/components/MobileHeader.tsx
Normal file
50
src/components/MobileHeader.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
interface MobileHeaderProps {
|
||||
vanillaMode: boolean;
|
||||
onVanillaToggle: (v: boolean) => void;
|
||||
activeCount?: number;
|
||||
}
|
||||
|
||||
export function MobileHeader({
|
||||
vanillaMode,
|
||||
onVanillaToggle,
|
||||
activeCount = 0,
|
||||
}: MobileHeaderProps) {
|
||||
return (
|
||||
<header className="md:hidden fixed top-0 left-0 right-0 z-50 flex h-12 items-center justify-between border-b border-border bg-horny-dark/95 backdrop-blur-md px-3 safe-top">
|
||||
<Link href="/" className="flex items-center gap-1.5 min-w-0">
|
||||
<Logo size="sm" showText={false} />
|
||||
<span className="text-sm font-bold bg-horny-gradient bg-clip-text text-transparent truncate">
|
||||
ExposedGays
|
||||
</span>
|
||||
{activeCount > 0 && (
|
||||
<Badge variant="online" className="text-[9px] shrink-0">
|
||||
{activeCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={() => onVanillaToggle(!vanillaMode)}
|
||||
aria-label="Toggle vanilla mode"
|
||||
>
|
||||
{vanillaMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Badge variant="secondary" className="text-[8px] px-1.5">
|
||||
FREE
|
||||
</Badge>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,10 @@
|
||||
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 { Map, MessageCircle, MapPin, ShoppingBag, User, Eye, EyeOff, LogIn, LogOut, ShieldCheck } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { useCartStore } from "@/lib/cart";
|
||||
import { useAuth } from "@/lib/auth/context";
|
||||
import { AuthModal } from "@/components/AuthModal";
|
||||
@@ -15,6 +16,8 @@ interface NavProps {
|
||||
vanillaMode: boolean;
|
||||
onVanillaToggle: (v: boolean) => void;
|
||||
activeCount?: number;
|
||||
onIdVerifyClick?: () => void;
|
||||
idBlur?: boolean;
|
||||
}
|
||||
|
||||
const NAV_ITEMS = [
|
||||
@@ -25,7 +28,13 @@ const NAV_ITEMS = [
|
||||
{ href: "/profile", label: "Profile", icon: User },
|
||||
];
|
||||
|
||||
export function Nav({ vanillaMode, onVanillaToggle, activeCount = 0 }: NavProps) {
|
||||
export function Nav({
|
||||
vanillaMode,
|
||||
onVanillaToggle,
|
||||
activeCount = 0,
|
||||
onIdVerifyClick,
|
||||
idBlur,
|
||||
}: NavProps) {
|
||||
const pathname = usePathname();
|
||||
const itemCount = useCartStore((s) => s.itemCount());
|
||||
const { user, profile, signOut } = useAuth();
|
||||
@@ -33,12 +42,9 @@ export function Nav({ vanillaMode, onVanillaToggle, activeCount = 0 }: NavProps)
|
||||
|
||||
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>
|
||||
<Logo size="md" />
|
||||
{activeCount > 0 && (
|
||||
<Badge variant="online" className="text-[10px]">
|
||||
{activeCount} active
|
||||
@@ -70,6 +76,17 @@ export function Nav({ vanillaMode, onVanillaToggle, activeCount = 0 }: NavProps)
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{idBlur && onIdVerifyClick && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onIdVerifyClick}
|
||||
className="gap-2 text-amber-400 border-amber-400/30"
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
Verify ID
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -98,21 +115,20 @@ export function Nav({ vanillaMode, onVanillaToggle, activeCount = 0 }: NavProps)
|
||||
|
||||
<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 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 touch-manipulation">
|
||||
{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",
|
||||
"flex flex-col items-center gap-0.5 px-3 py-2 min-w-[56px] min-h-[44px] justify-center text-[10px] transition-colors relative active:scale-95",
|
||||
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">
|
||||
<span className="absolute top-0.5 right-1 flex h-4 w-4 items-center justify-center rounded-full bg-horny-pink text-[8px] font-bold text-white">
|
||||
{itemCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
239
src/components/PhotoGalleryManager.tsx
Normal file
239
src/components/PhotoGalleryManager.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import Image from "next/image";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Plus, Trash2, ImageIcon, FolderPlus, Lock } from "lucide-react";
|
||||
import {
|
||||
loadGallery,
|
||||
createAlbum,
|
||||
addPhoto,
|
||||
deletePhoto,
|
||||
deleteAlbum,
|
||||
type GalleryAlbum,
|
||||
type GalleryPhoto,
|
||||
} from "@/lib/gallery-store";
|
||||
import {
|
||||
MAX_PHOTOS_PER_USER,
|
||||
MAX_ALBUMS_PER_USER,
|
||||
MAX_PHOTO_SIZE_MB,
|
||||
} from "@/lib/limits";
|
||||
import { mustBlurExplicit } from "@/lib/id-verification";
|
||||
import { useAuth } from "@/lib/auth/context";
|
||||
|
||||
interface PhotoGalleryManagerProps {
|
||||
onVerifyClick?: () => void;
|
||||
}
|
||||
|
||||
export function PhotoGalleryManager({ onVerifyClick }: PhotoGalleryManagerProps) {
|
||||
const { user, profile } = useAuth();
|
||||
const [albums, setAlbums] = useState<GalleryAlbum[]>([]);
|
||||
const [photos, setPhotos] = useState<GalleryPhoto[]>([]);
|
||||
const [activeAlbum, setActiveAlbum] = useState<string>("main");
|
||||
const [newAlbumName, setNewAlbumName] = useState("");
|
||||
const [showNewAlbum, setShowNewAlbum] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const blurExplicit = mustBlurExplicit(profile?.id_verified);
|
||||
|
||||
const refresh = async () => {
|
||||
const data = await loadGallery(user?.id);
|
||||
setAlbums(data.albums);
|
||||
setPhotos(data.photos);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [user?.id]);
|
||||
|
||||
const albumPhotos = photos.filter(
|
||||
(p) =>
|
||||
activeAlbum === "main"
|
||||
? !p.album_id || p.album_id === "main"
|
||||
: p.album_id === activeAlbum
|
||||
);
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > MAX_PHOTO_SIZE_MB * 1024 * 1024) {
|
||||
setMessage(`Max file size is ${MAX_PHOTO_SIZE_MB}MB.`);
|
||||
return;
|
||||
}
|
||||
const { photo, error } = await addPhoto(file, activeAlbum, user?.id);
|
||||
if (error) setMessage(error);
|
||||
else {
|
||||
setMessage("Photo uploaded!");
|
||||
await refresh();
|
||||
}
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
};
|
||||
|
||||
const handleCreateAlbum = async () => {
|
||||
const { album, error } = await createAlbum(newAlbumName, user?.id);
|
||||
if (error) setMessage(error);
|
||||
else {
|
||||
setNewAlbumName("");
|
||||
setShowNewAlbum(false);
|
||||
if (album) setActiveAlbum(album.id);
|
||||
await refresh();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePhoto = async (id: string) => {
|
||||
await deletePhoto(id, user?.id);
|
||||
await refresh();
|
||||
};
|
||||
|
||||
const handleDeleteAlbum = async (id: string) => {
|
||||
const { error } = await deleteAlbum(id, user?.id);
|
||||
if (error) setMessage(error);
|
||||
else {
|
||||
setActiveAlbum("main");
|
||||
await refresh();
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-sm text-muted-foreground">Loading gallery...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<ImageIcon className="h-5 w-5 text-horny-pink" />
|
||||
Photo Gallery
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{photos.length}/{MAX_PHOTOS_PER_USER} photos · {albums.length}/{MAX_ALBUMS_PER_USER} albums
|
||||
</Badge>
|
||||
</div>
|
||||
{blurExplicit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onVerifyClick}
|
||||
className="flex items-center gap-2 text-xs text-amber-400 hover:underline mt-1"
|
||||
>
|
||||
<Lock className="h-3 w-3" />
|
||||
ID verification required to view nude photos in your state
|
||||
</button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Tabs value={activeAlbum} onValueChange={setActiveAlbum}>
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1 -mx-1 px-1">
|
||||
<TabsList className="h-auto flex-wrap justify-start">
|
||||
{albums.map((a) => (
|
||||
<TabsTrigger key={a.id} value={a.id} className="text-xs gap-1">
|
||||
{a.name}
|
||||
{!a.is_default && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 opacity-50 hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteAlbum(a.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{albums.length < MAX_ALBUMS_PER_USER && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 h-8 text-xs"
|
||||
onClick={() => setShowNewAlbum(true)}
|
||||
>
|
||||
<FolderPlus className="h-3 w-3 mr-1" />
|
||||
Album
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
{showNewAlbum && (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Album name"
|
||||
value={newAlbumName}
|
||||
onChange={(e) => setNewAlbumName(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="horny" size="sm" onClick={handleCreateAlbum}>
|
||||
Create
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowNewAlbum(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
|
||||
{albumPhotos.map((photo) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className={`relative aspect-square rounded-lg overflow-hidden group ${blurExplicit && photo.is_explicit ? "id-blur" : ""}`}
|
||||
>
|
||||
<Image
|
||||
src={photo.url}
|
||||
alt="Gallery photo"
|
||||
fill
|
||||
className="object-cover explicit-content"
|
||||
unoptimized={photo.url.startsWith("data:")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeletePhoto(photo.id)}
|
||||
className="absolute top-1 right-1 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 className="h-3 w-3 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{photos.length < MAX_PHOTOS_PER_USER && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="aspect-square rounded-lg border-2 border-dashed border-border flex flex-col items-center justify-center gap-1 hover:border-horny-pink/50 transition-colors"
|
||||
>
|
||||
<Plus className="h-6 w-6 text-muted-foreground" />
|
||||
<span className="text-[10px] text-muted-foreground">Add photo</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
|
||||
{message && (
|
||||
<p className={`text-xs text-center ${message.includes("Maximum") || message.includes("Max") ? "text-destructive" : "text-green-400"}`}>
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-muted-foreground text-center">
|
||||
Up to {MAX_PHOTOS_PER_USER} photos across {MAX_ALBUMS_PER_USER} albums. JPG, PNG, WebP · max {MAX_PHOTO_SIZE_MB}MB each.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,11 @@ 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 { MessageCircle, Camera, Shield, Lock } from "lucide-react";
|
||||
import type { Profile } from "@/types";
|
||||
import { STATUS_LABELS } from "@/types";
|
||||
import { timeAgo } from "@/lib/utils";
|
||||
import { useIdBlur } from "@/components/Providers";
|
||||
|
||||
interface ProfilePopupProps {
|
||||
profile: Profile | null;
|
||||
@@ -15,15 +16,27 @@ interface ProfilePopupProps {
|
||||
onClose: () => void;
|
||||
onChat: (profile: Profile) => void;
|
||||
vanillaMode?: boolean;
|
||||
onVerifyClick?: () => void;
|
||||
}
|
||||
|
||||
export function ProfilePopup({ profile, open, onClose, onChat, vanillaMode }: ProfilePopupProps) {
|
||||
export function ProfilePopup({
|
||||
profile,
|
||||
open,
|
||||
onClose,
|
||||
onChat,
|
||||
vanillaMode,
|
||||
onVerifyClick,
|
||||
}: ProfilePopupProps) {
|
||||
const idBlur = useIdBlur();
|
||||
|
||||
if (!profile) return null;
|
||||
|
||||
const name = profile.is_anonymous
|
||||
? "Anonymous Cruiser"
|
||||
: profile.display_name || profile.username || "Cruiser";
|
||||
|
||||
const shouldBlur = vanillaMode || idBlur;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
||||
@@ -36,13 +49,29 @@ export function ProfilePopup({ profile, open, onClose, onChat, vanillaMode }: Pr
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className={`relative aspect-[4/3] rounded-lg overflow-hidden ${vanillaMode ? "vanilla-blur" : ""}`}>
|
||||
<div
|
||||
className={`relative aspect-[4/3] rounded-lg overflow-hidden ${
|
||||
shouldBlur ? "vanilla-blur id-blur" : ""
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={profile.avatar_url || "https://placehold.co/400x300/1a1220/ff2d6b?text=Profile"}
|
||||
alt={name}
|
||||
fill
|
||||
className="object-cover explicit-content"
|
||||
/>
|
||||
{idBlur && !vanillaMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onVerifyClick}
|
||||
className="absolute inset-0 flex flex-col items-center justify-center bg-black/40 gap-2"
|
||||
>
|
||||
<Lock className="h-8 w-8 text-white" />
|
||||
<span className="text-xs text-white font-medium px-4 text-center">
|
||||
Verify ID to view nude photos
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, createContext, useContext } from "react";
|
||||
import { useState, useEffect, createContext, useContext, useCallback } from "react";
|
||||
import { AgeGate } from "@/components/AgeGate";
|
||||
import { Nav } from "@/components/Nav";
|
||||
import { AuthProvider } from "@/lib/auth/context";
|
||||
import { MobileHeader } from "@/components/MobileHeader";
|
||||
import { IdVerificationModal } from "@/components/IdVerificationModal";
|
||||
import { AuthProvider, useAuth } from "@/lib/auth/context";
|
||||
import {
|
||||
mustBlurExplicit,
|
||||
requiresIdVerification,
|
||||
getUserState,
|
||||
isIdVerified,
|
||||
} from "@/lib/id-verification";
|
||||
|
||||
const ActiveCountContext = createContext<(n: number) => void>(() => {});
|
||||
const VanillaModeContext = createContext(false);
|
||||
const IdBlurContext = createContext(false);
|
||||
const OpenIdVerificationContext = createContext<() => void>(() => {});
|
||||
|
||||
export function useSetActiveCount() {
|
||||
return useContext(ActiveCountContext);
|
||||
@@ -16,45 +26,105 @@ export function useVanillaMode() {
|
||||
return useContext(VanillaModeContext);
|
||||
}
|
||||
|
||||
export function useIdBlur() {
|
||||
return useContext(IdBlurContext);
|
||||
}
|
||||
|
||||
export function useOpenIdVerification() {
|
||||
return useContext(OpenIdVerificationContext);
|
||||
}
|
||||
|
||||
interface ProvidersProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Providers({ children }: ProvidersProps) {
|
||||
const [verified, setVerified] = useState(false);
|
||||
function InnerProviders({ children }: ProvidersProps) {
|
||||
const { profile } = useAuth();
|
||||
const [vanillaMode, setVanillaMode] = useState(false);
|
||||
const [activeCount, setActiveCount] = useState(0);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [idBlur, setIdBlur] = useState(false);
|
||||
const [idModalOpen, setIdModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
setVerified(localStorage.getItem("eg_age_verified") === "true");
|
||||
setVanillaMode(localStorage.getItem("eg_vanilla_mode") === "true");
|
||||
}, []);
|
||||
setIdBlur(mustBlurExplicit(profile?.id_verified));
|
||||
|
||||
const state = getUserState();
|
||||
if (
|
||||
requiresIdVerification(state) &&
|
||||
!isIdVerified(profile?.id_verified) &&
|
||||
localStorage.getItem("eg_id_verification_prompted") !== "true"
|
||||
) {
|
||||
const timer = setTimeout(() => setIdModalOpen(true), 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [profile?.id_verified]);
|
||||
|
||||
const handleVanillaToggle = (v: boolean) => {
|
||||
setVanillaMode(v);
|
||||
localStorage.setItem("eg_vanilla_mode", String(v));
|
||||
};
|
||||
|
||||
const handleIdVerified = useCallback(() => {
|
||||
setIdBlur(false);
|
||||
localStorage.setItem("eg_id_verification_prompted", "true");
|
||||
}, []);
|
||||
|
||||
const openIdVerification = useCallback(() => {
|
||||
setIdModalOpen(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<VanillaModeContext.Provider value={vanillaMode}>
|
||||
<IdBlurContext.Provider value={idBlur}>
|
||||
<OpenIdVerificationContext.Provider value={openIdVerification}>
|
||||
<Nav
|
||||
vanillaMode={vanillaMode}
|
||||
onVanillaToggle={handleVanillaToggle}
|
||||
activeCount={activeCount}
|
||||
onIdVerifyClick={openIdVerification}
|
||||
idBlur={idBlur}
|
||||
/>
|
||||
<MobileHeader
|
||||
vanillaMode={vanillaMode}
|
||||
onVanillaToggle={handleVanillaToggle}
|
||||
activeCount={activeCount}
|
||||
/>
|
||||
<main className="md:pt-14 pt-12 pb-16 md:pb-0 min-h-screen min-h-[100dvh]">
|
||||
<ActiveCountContext.Provider value={setActiveCount}>
|
||||
{children}
|
||||
</ActiveCountContext.Provider>
|
||||
</main>
|
||||
<IdVerificationModal
|
||||
open={idModalOpen}
|
||||
onClose={() => {
|
||||
setIdModalOpen(false);
|
||||
localStorage.setItem("eg_id_verification_prompted", "true");
|
||||
}}
|
||||
onVerified={handleIdVerified}
|
||||
/>
|
||||
</OpenIdVerificationContext.Provider>
|
||||
</IdBlurContext.Provider>
|
||||
</VanillaModeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function Providers({ children }: ProvidersProps) {
|
||||
const [verified, setVerified] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
setVerified(localStorage.getItem("eg_age_verified") === "true");
|
||||
}, []);
|
||||
|
||||
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>
|
||||
<InnerProviders>{children}</InnerProviders>
|
||||
</AuthProvider>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user