From 910a7d85162749b24adbd7d1e5b30933dc41c3cf Mon Sep 17 00:00:00 2001 From: Ryan Salazar Date: Sat, 27 Jun 2026 14:08:31 -0400 Subject: [PATCH] Go-live: live data wiring, auth callback, SEO, legal pages, UI fixes --- README.md | 3 + deploy/apply-supabase-functions.py | 44 ++ deploy/retry-stalled-tasks.py | 91 ++++ src/app/api/pulse-track/route.ts | 25 ++ src/app/auth/callback/route.ts | 18 + src/app/chat/page.tsx | 4 +- src/app/contact/page.tsx | 82 ++++ src/app/globals.css | 57 +++ src/app/layout.tsx | 22 +- src/app/not-found.tsx | 21 + src/app/page.tsx | 24 +- src/app/privacy/page.tsx | 115 ++++- src/app/robots.ts | 13 + src/app/sitemap.ts | 16 + src/app/terms/page.tsx | 112 ++++- src/components/ChatPanel.tsx | 12 +- src/components/CreateHostingEventModal.tsx | 343 +++++++++++++++ src/components/HomeMap.tsx | 22 + src/components/HostingEventSheet.tsx | 364 +++++++++++++++ src/components/Map.tsx | 202 ++++++++- src/components/MapControls.tsx | 44 ++ src/components/MobileHeader.tsx | 22 +- src/components/Providers.tsx | 8 +- src/components/SiteFooter.tsx | 18 +- src/components/SpotDetailSheet.tsx | 80 ++++ src/components/SpotDirectory.tsx | 43 +- src/lib/auth/context.tsx | 7 +- src/lib/hosting-events-store.ts | 486 +++++++++++++++++++++ src/lib/hosting-events.ts | 64 +++ src/lib/limits.ts | 4 + src/lib/live-data.ts | 111 +++++ src/lib/mock-hosting-events.ts | 124 ++++++ src/types/index.ts | 77 ++++ supabase/migration_v6_hosting_events.sql | 163 +++++++ 34 files changed, 2780 insertions(+), 61 deletions(-) create mode 100644 deploy/apply-supabase-functions.py create mode 100644 deploy/retry-stalled-tasks.py create mode 100644 src/app/auth/callback/route.ts create mode 100644 src/app/contact/page.tsx create mode 100644 src/app/not-found.tsx create mode 100644 src/app/robots.ts create mode 100644 src/app/sitemap.ts create mode 100644 src/components/CreateHostingEventModal.tsx create mode 100644 src/components/HomeMap.tsx create mode 100644 src/components/HostingEventSheet.tsx create mode 100644 src/components/MapControls.tsx create mode 100644 src/components/SpotDetailSheet.tsx create mode 100644 src/lib/hosting-events-store.ts create mode 100644 src/lib/hosting-events.ts create mode 100644 src/lib/live-data.ts create mode 100644 src/lib/mock-hosting-events.ts create mode 100644 supabase/migration_v6_hosting_events.sql diff --git a/README.md b/README.md index 4cea6a6..6658c37 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ Open [http://localhost:3000](http://localhost:3000) | Vanilla mode blur toggle | ✅ | | Dark mode, mobile-first, PWA manifest | ✅ | | Anonymous browsing + optional profile | ✅ | +| **Hosting events** (Sniffies-style map parties) | ✅ | +| Schedule ahead · 2 photos + 1 video · invites | ✅ | +| Request/auto-accept · edit · cancel · RSVP | ✅ | ## Tech Stack diff --git a/deploy/apply-supabase-functions.py b/deploy/apply-supabase-functions.py new file mode 100644 index 0000000..61dcc77 --- /dev/null +++ b/deploy/apply-supabase-functions.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Apply ExposedGays Supabase functions + seed if missing.""" +import paramiko + +HOST = "10.10.0.11" +USER = "localadministrator" +PASS = "Bbt9115xty9176!" + +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect(HOST, username=USER, password=PASS, timeout=20, allow_agent=False, look_for_keys=False) + +def run(cmd, t=120): + print("$", cmd[:140]) + _, o, e = c.exec_command(cmd, timeout=t) + code = o.channel.recv_exit_status() + out = (o.read() + e.read()).decode(errors="replace") + print(out[-3000:]) + print("exit", code, "\n") + return code + +# Pipe functions.sql into postgres +with open(r"C:\Users\Local Administrator\exposedgays\supabase\functions.sql", "r", encoding="utf-8") as f: + sql = f.read() + +sftp = c.open_sftp() +with sftp.file("/tmp/eg_functions.sql", "w") as remote: + remote.write(sql) +sftp.close() + +run("cat /tmp/eg_functions.sql | docker exec -i supabase-db psql -U postgres -d postgres 2>&1 | tail -20") + +# Ensure privacy columns exist +run( + "docker exec -i supabase-db psql -U postgres -d postgres -c " + "\"ALTER TABLE profiles ADD COLUMN IF NOT EXISTS height_in integer, " + "ADD COLUMN IF NOT EXISTS weight_lb integer, " + "ADD COLUMN IF NOT EXISTS body_type text, " + "ADD COLUMN IF NOT EXISTS community text, " + "ADD COLUMN IF NOT EXISTS seeking text;\" 2>&1" +) + +c.close() +print("Done.") \ No newline at end of file diff --git a/deploy/retry-stalled-tasks.py b/deploy/retry-stalled-tasks.py new file mode 100644 index 0000000..c7284da --- /dev/null +++ b/deploy/retry-stalled-tasks.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Retry stalled Pink Pulse + Supabase tasks with tight timeouts.""" +import paramiko +import sys + +COOLIFY = ("10.10.0.10", "localadministrator", "Bbt9115xty9176!") +SUPABASE = ("10.10.0.11", "localadministrator", "Bbt9115xty9176!") +MIGRATION_LOCAL = r"C:\Users\Local Administrator\exposedgays\supabase\migration_pulse_analytics.sql" + + +def run(host, user, password, cmd, timeout=45): + c = paramiko.SSHClient() + c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + c.connect(host, username=user, password=password, timeout=15, allow_agent=False, look_for_keys=False) + try: + print(f"\n=== {host}: {cmd[:120]} ===") + _, o, e = c.exec_command(cmd, timeout=timeout) + code = o.channel.recv_exit_status() + out = (o.read() + e.read()).decode(errors="replace") + print(out[:8000] if out else "(no output)") + print(f"exit {code}") + return code, out + finally: + c.close() + + +def scp_file(host, user, password, local_path, remote_path, timeout=60): + c = paramiko.SSHClient() + c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + c.connect(host, username=user, password=password, timeout=15, allow_agent=False, look_for_keys=False) + try: + print(f"\n=== SCP {local_path} -> {host}:{remote_path} ===") + sftp = c.open_sftp() + sftp.put(local_path, remote_path) + sftp.close() + print("SCP ok") + return 0 + finally: + c.close() + + +def main(): + h, u, p = COOLIFY + errors = 0 + + # 1. Pink Pulse ads dir + code, _ = run(h, u, p, + "test -d /home/localadministrator/thepinkpulse && " + "ls /home/localadministrator/thepinkpulse/src/components/ads/ 2>/dev/null | head -10 || " + "echo 'ads dir missing or empty'" + ) + if code != 0: + errors += 1 + + # 2. Pink Pulse advertising grep + code, _ = run(h, u, p, + "grep -rn 'advertis\\|Advertise\\|popup' " + "/home/localadministrator/thepinkpulse/src/components --include='*.tsx' 2>/dev/null | head -40 || " + "echo 'no matches'" + ) + if code != 0: + errors += 1 + + # 3. ExposedGays health + code, _ = run(h, u, p, "curl -sS --max-time 10 https://exposedgays.com/ 2>&1 | head -c 300") + if code != 0: + errors += 1 + + # 4. Supabase migration + sh, su, sp = SUPABASE + try: + scp_file(sh, su, sp, MIGRATION_LOCAL, "/tmp/migration_pulse_analytics.sql") + except Exception as ex: + print(f"SCP failed: {ex}") + errors += 1 + else: + code, out = run( + sh, su, sp, + "cat /tmp/migration_pulse_analytics.sql | " + "docker exec -i supabase-db psql -U postgres -d postgres 2>&1 | tail -40", + timeout=90, + ) + if code != 0 and "already exists" not in out.lower(): + errors += 1 + + print(f"\n=== DONE ({errors} issues) ===") + sys.exit(1 if errors else 0) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/app/api/pulse-track/route.ts b/src/app/api/pulse-track/route.ts index 0d08d6d..3778f6a 100644 --- a/src/app/api/pulse-track/route.ts +++ b/src/app/api/pulse-track/route.ts @@ -4,6 +4,22 @@ import type { PulseTrackPayload } from "@/lib/pulse-analytics"; export const runtime = "nodejs"; +const rateBucket = new Map(); +const RATE_LIMIT = 60; +const RATE_WINDOW_MS = 60_000; + +function checkRateLimit(key: string): boolean { + const now = Date.now(); + const entry = rateBucket.get(key); + if (!entry || now > entry.reset) { + rateBucket.set(key, { count: 1, reset: now + RATE_WINDOW_MS }); + return true; + } + if (entry.count >= RATE_LIMIT) return false; + entry.count += 1; + return true; +} + function adminClient() { const url = process.env.NEXT_PUBLIC_SUPABASE_URL; const key = process.env.SUPABASE_SERVICE_ROLE_KEY; @@ -24,6 +40,15 @@ export async function POST(req: NextRequest) { return NextResponse.json({ ok: false, error: "missing_fields" }, { status: 400 }); } + const ip = + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + req.headers.get("x-real-ip") || + "unknown"; + const rateKey = `${ip}:${payload.session_id ?? "anon"}`; + if (!checkRateLimit(rateKey)) { + return NextResponse.json({ ok: false, error: "rate_limited" }, { status: 429 }); + } + const supabase = adminClient(); if (!supabase) { return NextResponse.json({ ok: true, stored: false }); diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts new file mode 100644 index 0000000..31beed7 --- /dev/null +++ b/src/app/auth/callback/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; + +export async function GET(request: Request) { + const { searchParams, origin } = new URL(request.url); + const code = searchParams.get('code'); + const next = searchParams.get('next') ?? '/profile'; + + if (code) { + const supabase = await createClient(); + const { error } = await supabase.auth.exchangeCodeForSession(code); + if (!error) { + return NextResponse.redirect(`${origin}${next}`); + } + } + + return NextResponse.redirect(`${origin}/profile?auth=error`); +} \ No newline at end of file diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index b475227..6e71cbf 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -7,10 +7,12 @@ import { ChatPanel } from "@/components/ChatPanel"; function ChatContent() { const searchParams = useSearchParams(); const userId = searchParams.get("user"); + const spotId = searchParams.get("spot"); + const spotRoom = searchParams.get("room"); return (
- +
); } diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx new file mode 100644 index 0000000..ceb9da7 --- /dev/null +++ b/src/app/contact/page.tsx @@ -0,0 +1,82 @@ +import Link from 'next/link'; +import { Mail, Shield } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +export const metadata = { + title: 'Contact & Safety — ExposedGays', + description: 'Report abuse, contact support, and safety resources for ExposedGays.', +}; + +export default function ContactPage() { + return ( +
+
+

Contact & Safety

+

+ ExposedGays is operated by Just Two Roommates LLC. We take user safety seriously. +

+
+ + + + + + Support + + + +

+ General support:{' '} + + support@justtworoommates.com + +

+

+ Advertising inquiries:{' '} + + advertising@justtworoommates.com + +

+
+
+ + + + + + Report abuse or illegal content + + + +

+ To report harassment, impersonation, underage users, or illegal content, email{' '} + + abuse@justtworoommates.com + {' '} + with screenshots, usernames, and timestamps. We review reports within 24 hours. +

+

+ You can also block users instantly from their profile menu and manage blocks in{' '} + + Profile → Privacy + + . +

+
+
+ +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index de569c1..972cae4 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -209,6 +209,63 @@ padding: 0 3px; } +.sniffies-event-wrap { + background: transparent !important; + border: none !important; +} + +.sniffies-event { + position: relative; + width: 48px; + height: 52px; + display: flex; + flex-direction: column; + align-items: center; +} + +.sniffies-event-pin { + width: 40px; + height: 40px; + border-radius: 12px; + background: linear-gradient(145deg, #ff2d9b 0%, #8b22cc 100%); + border: 2px solid rgba(255, 255, 255, 0.9); + box-shadow: 0 0 0 2px rgba(249, 43, 155, 0.4), 0 4px 14px rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + line-height: 1; +} + +.sniffies-event-pin.sniffies-event-live { + animation: sniffies-event-pulse 1.8s ease-in-out infinite; +} + +@keyframes sniffies-event-pulse { + 0%, + 100% { + box-shadow: 0 0 0 2px rgba(249, 43, 155, 0.4), 0 4px 14px rgba(0, 0, 0, 0.55); + } + 50% { + box-shadow: 0 0 0 6px rgba(249, 43, 155, 0.25), 0 4px 18px rgba(249, 43, 155, 0.35); + } +} + +.sniffies-event-count { + margin-top: 2px; + min-width: 18px; + height: 16px; + padding: 0 4px; + border-radius: 8px; + background: #1a003a; + border: 1px solid rgba(249, 43, 155, 0.5); + color: #ff6ec7; + font-size: 9px; + font-weight: 700; + line-height: 14px; + text-align: center; +} + .leaflet-popup-content-wrapper { background: hsl(268 100% 11%); color: hsl(228 100% 97%); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5f9fac7..7207c7e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -8,11 +8,31 @@ const manrope = Manrope({ variable: "--font-manrope", }); +const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://exposedgays.com"; + export const metadata: Metadata = { - title: "ExposedGays — Free Gay Cruising & Shop", + metadataBase: new URL(siteUrl), + title: { + default: "ExposedGays — Free Gay Cruising & Shop", + template: "%s · ExposedGays", + }, description: "100% free gay cruising map, chat, spots directory, and sex shop. No subscriptions ever.", manifest: "/manifest.json", + openGraph: { + type: "website", + locale: "en_US", + url: siteUrl, + siteName: "ExposedGays", + title: "ExposedGays — Free Gay Cruising & Shop", + description: + "100% free gay cruising map, chat, spots directory, and sex shop. No subscriptions ever.", + }, + twitter: { + card: "summary_large_image", + title: "ExposedGays — Free Gay Cruising", + description: "Free gay cruising map, chat & spots. No subscriptions ever.", + }, icons: { icon: "/logo.svg", apple: "/logo.svg", diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..1576c8c --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,21 @@ +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; + +export default function NotFound() { + return ( +
+

404

+

+ This page doesn't exist — but the map is still hot. +

+
+ + +
+
+ ); +} \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index d81aae1..d4d0a59 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,18 +1,16 @@ -"use client"; - -import { CruisingMap } from "@/components/Map"; -import { useSetActiveCount, useVanillaMode } from "@/components/Providers"; +import { Suspense } from "react"; +import HomeMap from "@/components/HomeMap"; export default function HomePage() { - const setActiveCount = useSetActiveCount(); - const vanillaMode = useVanillaMode(); - return ( -
- -
+ + Loading map… + + } + > + + ); } \ No newline at end of file diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx index 9494636..3db3f3d 100644 --- a/src/app/privacy/page.tsx +++ b/src/app/privacy/page.tsx @@ -1,13 +1,112 @@ +import Link from 'next/link'; + +export const metadata = { + title: 'Privacy Policy — ExposedGays', +}; + export default function PrivacyPage() { return ( -
-

Privacy Policy

-

- ExposedGays is operated by Just Two Roommates LLC. We collect minimal data. - Location is used for map features and city chat auto-join. Anonymous browsing - is supported. Cart data stays in your browser (localStorage). When you follow - links to our community partner The Pink Pulse, their privacy policy applies on - their site. Placeholder policy — consult legal counsel before launch. +

+

Privacy Policy

+

Last updated: June 27, 2026

+ +
+

Who we are

+

+ ExposedGays is operated by Just Two Roommates LLC, Wilton Manors, FL. + Contact:{' '} + + support@justtworoommates.com + +

+
+ +
+

What we collect

+
    +
  • + Account: email (if you sign up), profile fields you choose to share + (age, photos, bio, interests, health info you enter). +
  • +
  • + Location: approximate map coordinates for cruising features and city + chat auto-join. You can browse with limited location precision. +
  • +
  • + Device: browser type, session IDs for analytics, age-gate state in + localStorage. +
  • +
  • + Messages: chat content stored when you use city, spot, or DM features + while signed in. +
  • +
+
+ +
+

What stays on your device

+

+ Guest browsing stores profile drafts, block lists, hide rules, and cart items in{' '} + localStorage until you create an account. Signed-in users sync profile + and gallery to our database (Supabase, self-hosted). +

+
+ +
+

Photos & X-rated scan

+

+ Uploaded images are automatically scanned for explicit content before display to other + users. Explicit media is blurred for guests and users in states requiring ID verification + until verification is complete. +

+
+ +
+

Privacy controls you have

+
    +
  • Block list — mutual invisibility
  • +
  • Hide-from-trait rules — control who can see you
  • +
  • Anonymous browsing mode
  • +
  • Separate toggles for anonymous and no-photo users
  • +
+
+ +
+

Third parties

+

+ We use self-hosted Supabase for database/auth, Cloudflare for delivery, and may link to{' '} + The Pink Pulse for news and events. Partner sites have their own privacy + policies. Shop affiliate links go to external retailers. +

+
+ +
+

Retention & deletion

+

+ Delete your account from Profile → Sign Out and email{' '} + + support@justtworoommates.com + {' '} + for full data removal. We retain abuse reports as needed for safety investigations. +

+
+ +
+

Your rights

+

+ California and EU users may request access, correction, or deletion of personal data. + Contact us with your account email. +

+
+ +

+ + Contact & Safety + {' '} + ·{' '} + + Terms of Service +

); diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 0000000..6bf2e65 --- /dev/null +++ b/src/app/robots.ts @@ -0,0 +1,13 @@ +import type { MetadataRoute } from 'next'; + +export default function robots(): MetadataRoute.Robots { + const base = process.env.NEXT_PUBLIC_SITE_URL || 'https://exposedgays.com'; + return { + rules: { + userAgent: '*', + allow: '/', + disallow: ['/api/', '/profile'], + }, + sitemap: `${base}/sitemap.xml`, + }; +} \ No newline at end of file diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 0000000..8c37e91 --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,16 @@ +import type { MetadataRoute } from 'next'; + +export default function sitemap(): MetadataRoute.Sitemap { + const base = process.env.NEXT_PUBLIC_SITE_URL || 'https://exposedgays.com'; + const now = new Date(); + return [ + { url: base, lastModified: now, changeFrequency: 'hourly', priority: 1 }, + { url: `${base}/spots`, lastModified: now, changeFrequency: 'daily', priority: 0.9 }, + { url: `${base}/chat`, lastModified: now, changeFrequency: 'hourly', priority: 0.9 }, + { url: `${base}/shop`, lastModified: now, changeFrequency: 'weekly', priority: 0.7 }, + { url: `${base}/profile`, lastModified: now, changeFrequency: 'weekly', priority: 0.6 }, + { url: `${base}/terms`, lastModified: now, changeFrequency: 'monthly', priority: 0.3 }, + { url: `${base}/privacy`, lastModified: now, changeFrequency: 'monthly', priority: 0.3 }, + { url: `${base}/contact`, lastModified: now, changeFrequency: 'monthly', priority: 0.4 }, + ]; +} \ No newline at end of file diff --git a/src/app/terms/page.tsx b/src/app/terms/page.tsx index fbce163..11c5ce6 100644 --- a/src/app/terms/page.tsx +++ b/src/app/terms/page.tsx @@ -1,15 +1,107 @@ +import Link from 'next/link'; + +export const metadata = { + title: 'Terms of Service — ExposedGays', +}; + export default function TermsPage() { return ( -
-

Terms of Service

-

- ExposedGays is a free adult platform operated by Just Two Roommates LLC. You must - be 18+ to use this site. All content is user-generated. We reserve the right to - remove illegal content. No paid subscriptions — ever. Links to our community - partner The Pink Pulse (thepinkpulse.com) are provided for LGBTQ+ news, events, - and local resources — separate editorial property, same parent company. - Placeholder terms — consult legal counsel before launch. -

+
+

Terms of Service

+

Last updated: June 27, 2026

+ +
+

1. Operator

+

+ ExposedGays.com is operated by Just Two Roommates LLC ("we," + "us"). This is an adult-only platform for gay and queer men and aligned + communities. By using the site you agree to these terms. +

+
+ +
+

2. Eligibility

+

+ You must be 18 years or older to use ExposedGays. You confirm you are of + legal age in your jurisdiction. We may terminate accounts that appear to belong to minors + without notice. +

+
+ +
+

3. Free service

+

+ Core features — map, chat, spots directory, and profile — are free forever. + We do not sell subscriptions for cruising features. The shop section contains affiliate + links to third-party retailers; those purchases are governed by the retailer's terms. +

+
+ +
+

4. User content & conduct

+
    +
  • You are responsible for photos, messages, and profile information you post.
  • +
  • No illegal content, harassment, impersonation, or non-consensual imagery.
  • +
  • No solicitation of minors or trafficking-related activity — zero tolerance.
  • +
  • We may remove content and ban users at our discretion.
  • +
+
+ +
+

5. Privacy & location

+

+ Location data powers map and city chat features. You control visibility through profile + privacy settings, block lists, and hide rules. See our{' '} + + Privacy Policy + + . +

+
+ +
+

6. ID verification

+

+ In certain US states, explicit media requires government ID verification. Verification + status is stored on your account and may be checked by third-party providers we integrate + with. +

+
+ +
+

7. Partner properties

+

+ Links to The Pink Pulse (thepinkpulse.com) and other Just Two Roommates + properties are provided for LGBTQ+ news, events, and resources. Those sites have separate + editorial teams and policies. +

+
+ +
+

8. Disclaimer

+

+ ExposedGays is provided "as is." Meet other users at your own risk. We do not + conduct criminal background checks. Report abuse to{' '} + + abuse@justtworoommates.com + + . +

+
+ +
+

9. Contact

+

+ Questions:{' '} + + Contact page + {' '} + or{' '} + + support@justtworoommates.com + +

+
); } \ No newline at end of file diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 61b221a..657b008 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -75,16 +75,20 @@ const MOCK_DM_MESSAGES: Message[] = [ interface ChatPanelProps { dmUserId?: string | null; + spotId?: string | null; + spotRoomName?: string | null; } -export function ChatPanel({ dmUserId }: ChatPanelProps) { +export function ChatPanel({ dmUserId, spotId, spotRoomName }: ChatPanelProps) { const { capabilities } = useAccess(); const { user, profile: authProfile } = useAuth(); const { blockList } = usePrivacy(); const [cityMessages, setCityMessages] = useState(MOCK_CITY_MESSAGES); const [dmMessages, setDmMessages] = useState(MOCK_DM_MESSAGES); const [input, setInput] = useState(""); - const [activeTab, setActiveTab] = useState(dmUserId ? "dm" : "city"); + const [activeTab, setActiveTab] = useState( + dmUserId ? "dm" : spotId ? "city" : "city" + ); const [showQuickReplies, setShowQuickReplies] = useState(false); const bottomRef = useRef(null); @@ -161,7 +165,9 @@ export function ChatPanel({ dmUserId }: ChatPanelProps) { {cityMessages.length + 12} in room

- Auto-joined based on your location. Public city chat. + {spotId && spotRoomName + ? `Spot chat — ${decodeURIComponent(spotRoomName)}. Also connected to ${CITY} city room.` + : "Auto-joined based on your location. Public city chat."}

diff --git a/src/components/CreateHostingEventModal.tsx b/src/components/CreateHostingEventModal.tsx new file mode 100644 index 0000000..6777466 --- /dev/null +++ b/src/components/CreateHostingEventModal.tsx @@ -0,0 +1,343 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Image from "next/image"; +import { Calendar, ImagePlus, Video, X } from "lucide-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 { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; +import { HOSTING_EVENT_TYPES, EVENT_TYPE_META } from "@/lib/hosting-events"; +import { + createHostingEvent, + updateHostingEvent, + type EventMediaDraft, +} from "@/lib/hosting-events-store"; +import { + MAX_EVENT_PHOTOS, + MAX_EVENT_VIDEOS, + MAX_EVENT_DESCRIPTION_LENGTH, +} from "@/lib/limits"; +import type { HostingEvent, HostingEventType } from "@/types"; +import { cn } from "@/lib/utils"; + +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 CreateHostingEventModalProps { + open: boolean; + onClose: () => void; + onSaved: (event: HostingEvent) => void; + hostId: string; + hostMeta?: { display_name?: string | null; avatar_url?: string | null }; + editEvent?: HostingEvent | null; +} + +function toLocalDatetimeValue(iso: string) { + const d = new Date(iso); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +function fromLocalDatetimeValue(value: string) { + return new Date(value).toISOString(); +} + +export function CreateHostingEventModal({ + open, + onClose, + onSaved, + hostId, + hostMeta, + editEvent, +}: CreateHostingEventModalProps) { + const [eventType, setEventType] = useState("cum-dump"); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [startsAt, setStartsAt] = useState(""); + const [endsAt, setEndsAt] = useState(""); + const [autoAccept, setAutoAccept] = useState(false); + const [maxGuests, setMaxGuests] = useState(""); + const [photos, setPhotos] = useState([]); + const [video, setVideo] = useState(null); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + if (editEvent) { + setEventType(editEvent.event_type); + setTitle(editEvent.title); + setDescription(editEvent.description); + setStartsAt(toLocalDatetimeValue(editEvent.starts_at)); + setEndsAt(toLocalDatetimeValue(editEvent.ends_at)); + setAutoAccept(editEvent.auto_accept); + setMaxGuests(editEvent.max_guests ? String(editEvent.max_guests) : ""); + setPhotos( + editEvent.media + .filter((m) => m.media_type === "photo") + .map((m) => ({ type: "photo" as const, url: m.url })) + ); + const v = editEvent.media.find((m) => m.media_type === "video"); + setVideo(v ? { type: "video", url: v.url, thumbnail_url: v.thumbnail_url } : null); + } else { + const start = new Date(Date.now() + 3600000); + const end = new Date(Date.now() + 5 * 3600000); + setEventType("cum-dump"); + setTitle(EVENT_TYPE_META["cum-dump"].defaultTitle); + setDescription(""); + setStartsAt(toLocalDatetimeValue(start.toISOString())); + setEndsAt(toLocalDatetimeValue(end.toISOString())); + setAutoAccept(false); + setMaxGuests(""); + setPhotos([]); + setVideo(null); + } + setError(null); + }, [open, editEvent]); + + const handleTypeChange = (type: HostingEventType) => { + setEventType(type); + if (!editEvent && (!title || HOSTING_EVENT_TYPES.some((t) => t.defaultTitle === title))) { + setTitle(EVENT_TYPE_META[type].defaultTitle); + } + }; + + const readFile = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + + const handlePhotoPick = async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files ?? []); + if (!files.length) return; + const room = MAX_EVENT_PHOTOS - photos.length; + const picked = files.slice(0, room); + const urls = await Promise.all(picked.map(readFile)); + setPhotos((prev) => [ + ...prev, + ...urls.map((url) => ({ type: "photo" as const, url })), + ]); + e.target.value = ""; + }; + + const handleVideoPick = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const url = await readFile(file); + setVideo({ type: "video", url }); + e.target.value = ""; + }; + + const handleSave = async () => { + setSaving(true); + setError(null); + const mediaDrafts: EventMediaDraft[] = [ + ...photos, + ...(video ? [video] : []), + ]; + const input = { + title, + description, + event_type: eventType, + starts_at: fromLocalDatetimeValue(startsAt), + ends_at: fromLocalDatetimeValue(endsAt), + lat: editEvent?.lat ?? DEFAULT_LAT, + lng: editEvent?.lng ?? DEFAULT_LNG, + city: editEvent?.city ?? "Fort Lauderdale", + auto_accept: autoAccept, + max_guests: maxGuests ? parseInt(maxGuests, 10) : null, + }; + + const result = editEvent + ? await updateHostingEvent(editEvent.id, hostId, input, mediaDrafts) + : await createHostingEvent(input, hostId, hostMeta, mediaDrafts); + + setSaving(false); + if (result.error) { + setError(result.error); + return; + } + if (result.event) { + onSaved(result.event); + onClose(); + } + }; + + return ( + !v && onClose()}> + + + + + {editEvent ? "Edit Hosting Event" : "Host on the Map"} + + + +
+
+ +
+ {HOSTING_EVENT_TYPES.map((t) => ( + handleTypeChange(t.id)} + > + {t.emoji} {t.label} + + ))} +
+
+ +
+ + setTitle(e.target.value)} + className="mt-1 bg-black/40 border-white/15" + /> +
+ +
+ +