From 3eb12c8b345ac0e59f66dfc0caa7c7e33c3719b0 Mon Sep 17 00:00:00 2001 From: Ryan Salazar Date: Fri, 26 Jun 2026 17:32:15 -0400 Subject: [PATCH] ExposedGays: map, shop, auth, Supabase schema, Coolify Dockerfile --- .env.example | 13 ++ .gitignore | 35 +++ Dockerfile | 29 +++ README.md | 163 ++++++++++++++ components.json | 21 ++ deploy/setup-infra.py | 237 ++++++++++++++++++++ eslint.config.mjs | 14 ++ next.config.ts | 15 ++ package.json | 51 +++++ postcss.config.mjs | 9 + public/manifest.json | 22 ++ src/app/chat/page.tsx | 24 +++ src/app/globals.css | 96 +++++++++ src/app/layout.tsx | 39 ++++ src/app/page.tsx | 18 ++ src/app/privacy/page.tsx | 12 ++ src/app/profile/page.tsx | 257 ++++++++++++++++++++++ src/app/shop/page.tsx | 5 + src/app/spots/page.tsx | 5 + src/app/terms/page.tsx | 12 ++ src/components/AgeGate.tsx | 150 +++++++++++++ src/components/AuthModal.tsx | 170 +++++++++++++++ src/components/ChatPanel.tsx | 215 ++++++++++++++++++ src/components/CruiserCard.tsx | 90 ++++++++ src/components/Map.tsx | 360 +++++++++++++++++++++++++++++++ src/components/Nav.tsx | 124 +++++++++++ src/components/ProfilePopup.tsx | 104 +++++++++ src/components/Providers.tsx | 61 ++++++ src/components/Shop.tsx | 321 +++++++++++++++++++++++++++ src/components/SpotDirectory.tsx | 126 +++++++++++ src/components/ui/badge.tsx | 33 +++ src/components/ui/button.tsx | 46 ++++ src/components/ui/card.tsx | 46 ++++ src/components/ui/dialog.tsx | 64 ++++++ src/components/ui/input.tsx | 19 ++ src/components/ui/label.tsx | 19 ++ src/components/ui/slider.tsx | 25 +++ src/components/ui/switch.tsx | 28 +++ src/components/ui/tabs.tsx | 51 +++++ src/lib/auth/context.tsx | 189 ++++++++++++++++ src/lib/cart.ts | 58 +++++ src/lib/mock-data.ts | 179 +++++++++++++++ src/lib/products.ts | 167 ++++++++++++++ src/lib/supabase/client.ts | 8 + src/lib/supabase/hooks.ts | 93 ++++++++ src/lib/supabase/middleware.ts | 30 +++ src/lib/supabase/server.ts | 27 +++ src/lib/utils.ts | 21 ++ src/middleware.ts | 12 ++ src/types/index.ts | 131 +++++++++++ supabase/functions.sql | 45 ++++ supabase/schema.sql | 246 +++++++++++++++++++++ tailwind.config.ts | 66 ++++++ tsconfig.json | 23 ++ 54 files changed, 4424 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 components.json create mode 100644 deploy/setup-infra.py create mode 100644 eslint.config.mjs create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 public/manifest.json create mode 100644 src/app/chat/page.tsx create mode 100644 src/app/globals.css create mode 100644 src/app/layout.tsx create mode 100644 src/app/page.tsx create mode 100644 src/app/privacy/page.tsx create mode 100644 src/app/profile/page.tsx create mode 100644 src/app/shop/page.tsx create mode 100644 src/app/spots/page.tsx create mode 100644 src/app/terms/page.tsx create mode 100644 src/components/AgeGate.tsx create mode 100644 src/components/AuthModal.tsx create mode 100644 src/components/ChatPanel.tsx create mode 100644 src/components/CruiserCard.tsx create mode 100644 src/components/Map.tsx create mode 100644 src/components/Nav.tsx create mode 100644 src/components/ProfilePopup.tsx create mode 100644 src/components/Providers.tsx create mode 100644 src/components/Shop.tsx create mode 100644 src/components/SpotDirectory.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/button.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/slider.tsx create mode 100644 src/components/ui/switch.tsx create mode 100644 src/components/ui/tabs.tsx create mode 100644 src/lib/auth/context.tsx create mode 100644 src/lib/cart.ts create mode 100644 src/lib/mock-data.ts create mode 100644 src/lib/products.ts create mode 100644 src/lib/supabase/client.ts create mode 100644 src/lib/supabase/hooks.ts create mode 100644 src/lib/supabase/middleware.ts create mode 100644 src/lib/supabase/server.ts create mode 100644 src/lib/utils.ts create mode 100644 src/middleware.ts create mode 100644 src/types/index.ts create mode 100644 supabase/functions.sql create mode 100644 supabase/schema.sql create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..200cb17 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Supabase — create project at https://supabase.com or self-host +NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key +SUPABASE_SERVICE_ROLE_KEY=your-service-role-key + +# App +NEXT_PUBLIC_APP_URL=https://exposedgays.com +NEXT_PUBLIC_DEFAULT_CITY=Fort Lauderdale +NEXT_PUBLIC_DEFAULT_LAT=26.1224 +NEXT_PUBLIC_DEFAULT_LNG=-80.1373 + +# Optional: third-party age verification (Veriff, Yoti, etc.) +NEXT_PUBLIC_AGE_VERIFY_PROVIDER=placeholder \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d856a4b --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# env files +.env +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a3f3e60 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM node:22-alpine AS base + +FROM base AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci + +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" +CMD ["node", "server.js"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..65b4460 --- /dev/null +++ b/README.md @@ -0,0 +1,163 @@ +# ExposedGays + +100% free gay cruising map + chat + spots directory + sex shop. No subscriptions ever. + +**Domain:** exposedgays.com + +## Quick Start + +```bash +npm install +cp .env.example .env.local +# Edit .env.local with your Supabase keys (optional for local demo — mock data works offline) +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000) + +## Features + +| Feature | Status | +|---------|--------| +| Real-time cruising map (Leaflet + OSM) | ✅ Mock data + Supabase-ready | +| User clustering + position badges | ✅ | +| Map filters (age, position, kinks, PrEP) | ✅ | +| Cruiser Cards + Profile popup | ✅ | +| Instant chat + city chat + DMs + groups | ✅ Mock + Supabase schema | +| Cruising spot directory | ✅ | +| **Shop** (affiliate links, local cart) | ✅ | +| Age verification gate (Florida-compliant) | ✅ | +| Vanilla mode blur toggle | ✅ | +| Dark mode, mobile-first, PWA manifest | ✅ | +| Anonymous browsing + optional profile | ✅ | + +## Tech Stack + +- **Next.js 15** App Router + TypeScript +- **Tailwind CSS** + shadcn/ui components +- **React-Leaflet** + OpenStreetMap tiles +- **Supabase** — Auth, Postgres + PostGIS, Realtime, Storage +- **Zustand** — localStorage cart + +## Project Structure + +``` +exposedgays/ +├── public/ +│ └── manifest.json # PWA manifest +├── src/ +│ ├── app/ +│ │ ├── layout.tsx # Root layout + nav +│ │ ├── page.tsx # Map (home) +│ │ ├── shop/page.tsx # Sex shop +│ │ ├── chat/page.tsx # City chat + DMs +│ │ ├── spots/page.tsx # Spot directory +│ │ └── profile/page.tsx # User profile editor +│ ├── components/ +│ │ ├── AgeGate.tsx # 18+ DOB verification +│ │ ├── Map.tsx # Interactive cruising map +│ │ ├── Shop.tsx # Product grid + cart +│ │ ├── Nav.tsx # Top/bottom nav with Shop link +│ │ ├── ChatPanel.tsx # Real-time chat UI +│ │ ├── CruiserCard.tsx # Squirt-style cruiser cards +│ │ ├── ProfilePopup.tsx # Tap icon → profile modal +│ │ ├── SpotDirectory.tsx # Spot listings + search +│ │ └── ui/ # shadcn components +│ ├── lib/ +│ │ ├── products.ts # Dummy shop inventory +│ │ ├── cart.ts # Zustand localStorage cart +│ │ ├── mock-data.ts # Demo cruisers + spots +│ │ └── supabase/ # Supabase client helpers +│ └── types/index.ts +├── supabase/ +│ └── schema.sql # Full Postgres + PostGIS + RLS +├── .env.example +└── package.json +``` + +## Supabase Setup + +### 1. Create project + +Go to [supabase.com](https://supabase.com) → New Project (free tier). + +### 2. Run schema + +SQL Editor → paste contents of `supabase/schema.sql` → Run. + +### 3. Enable Realtime + +Dashboard → Database → Replication → enable `chat_messages` and `profiles`. + +### 4. Create Storage bucket + +```sql +INSERT INTO storage.buckets (id, name, public) VALUES ('gallery', 'gallery', true); +INSERT INTO storage.buckets (id, name, public) VALUES ('shop', 'shop', true); +``` + +### 5. Copy keys to `.env.local` + +``` +NEXT_PUBLIC_SUPABASE_URL=https://xxxxx.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ... +SUPABASE_SERVICE_ROLE_KEY=eyJ... +``` + +### CLI alternative + +```bash +npx supabase init +npx supabase link --project-ref YOUR_PROJECT_REF +npx supabase db push +``` + +## 5-Minute Deployment (Vercel + Supabase + DNS) + +### Step 1 — Push to GitHub (1 min) + +```bash +git init +git add . +git commit -m "ExposedGays starter" +gh repo create exposedgays --private --push +``` + +### Step 2 — Vercel deploy (2 min) + +1. [vercel.com](https://vercel.com) → Import Git repo +2. Framework: **Next.js** (auto-detected) +3. Add environment variables from `.env.example` +4. Deploy → get `exposedgays.vercel.app` + +### Step 3 — Custom domain (1 min) + +1. Vercel → Project → Settings → Domains → Add `exposedgays.com` + `www.exposedgays.com` +2. Vercel shows required DNS records + +### Step 4 — DNS at registrar (1 min) + +| Type | Name | Value | +|------|------|-------| +| A | @ | `76.76.21.21` | +| CNAME | www | `cname.vercel-dns.com` | + +(Or use Cloudflare CNAME flattening if proxied.) + +### Step 5 — Verify + +- Visit `https://exposedgays.com` — age gate appears +- Map loads with cruiser icons +- Shop nav works with affiliate buttons +- PWA installable on mobile + +## Shop Affiliate Notes + +- Cart is **localStorage only** — no checkout on-site +- "Buy Now" opens affiliate URLs in new tab (`rel="sponsored"`) +- Replace placeholder URLs with real Amazon Associates / AdamMale affiliate links +- Products defined in `src/lib/products.ts` (static) or `shop_products` table (DB) + +## License + +Private — exposedgays.com © 2026 \ No newline at end of file diff --git a/components.json b/components.json new file mode 100644 index 0000000..0e8b633 --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/deploy/setup-infra.py b/deploy/setup-infra.py new file mode 100644 index 0000000..68c4b36 --- /dev/null +++ b/deploy/setup-infra.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""Push ExposedGays to Gitea, create Coolify app, apply Supabase schema.""" +import json +import os +import subprocess +import sys +import urllib.request +import urllib.error + +try: + import paramiko +except ImportError: + subprocess.check_call([sys.executable, "-m", "pip", "install", "paramiko", "-q"]) + import paramiko + +HOST = "10.10.0.10" +SUPA_HOST = "10.10.0.11" +USER = "localadministrator" +PASS = os.environ.get("COOLIFY_SSH_PASS", "Bbt9115xty9176!") +GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "dbf033cee4b0e3be5699f374a15f3fa240e2896a") +COOLIFY_TOKEN = os.environ.get("COOLIFY_TOKEN", "1|zALRdpUD3VkejNVlEV5UuRzTeoYVhshZtke5VkCNa6e9262d") +REPO_NAME = "exposedgays" +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +GIT_AUTH = f"https://{USER}:{GITEA_TOKEN}@gitea.onsethost.com/localadministrator/{REPO_NAME}.git" + + +def ssh_run(host, cmd, timeout=120): + 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) + print(f"[{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") + if out.strip(): + print(out[-8000:]) + print(f"exit={code}") + c.close() + return code, out + + +def gitea_api(method, path, data=None): + url = f"https://gitea.onsethost.com/api/v1{path}" + body = json.dumps(data).encode() if data else None + req = urllib.request.Request(url, data=body, method=method) + req.add_header("Authorization", f"token {GITEA_TOKEN}") + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return resp.status, json.loads(resp.read().decode()) + except urllib.error.HTTPError as ex: + body = ex.read().decode() + try: + return ex.code, json.loads(body) + except Exception: + return ex.code, {"message": body} + + +def coolify_api(method, path, data=None): + url = f"https://coolify.onsethost.com/api/v1{path}" + body = json.dumps(data).encode() if data else None + req = urllib.request.Request(url, data=body, method=method) + req.add_header("Authorization", f"Bearer {COOLIFY_TOKEN}") + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req, timeout=60) as resp: + raw = resp.read().decode() + return resp.status, json.loads(raw) if raw else {} + except urllib.error.HTTPError as ex: + body = ex.read().decode() + try: + return ex.code, json.loads(body) + except Exception: + return ex.code, {"message": body} + + +def push_gitea(): + print("\n=== 1. Create Gitea repo (if missing) ===") + code, resp = gitea_api("POST", "/user/repos", { + "name": REPO_NAME, + "private": True, + "auto_init": False, + "description": "ExposedGays — free gay cruising + shop", + }) + if code in (201, 409): + print(f"Repo ready (HTTP {code})") + else: + print(f"Repo create: {code} {resp}") + + print("\n=== 2. Git commit + push from COOLIFY_01 ===") + schema_sql = open(os.path.join(PROJECT_ROOT, "supabase", "schema.sql"), encoding="utf-8").read() + functions_sql = open(os.path.join(PROJECT_ROOT, "supabase", "functions.sql"), encoding="utf-8").read() + + # Tar project and upload via SSH + 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) + sftp = c.open_sftp() + + import tarfile + import io + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + for root, dirs, files in os.walk(PROJECT_ROOT): + dirs[:] = [d for d in dirs if d not in (".next", "node_modules", ".git")] + for f in files: + if f in (".env.local",): + continue + full = os.path.join(root, f) + arc = os.path.relpath(full, PROJECT_ROOT) + tar.add(full, arcname=arc) + buf.seek(0) + sftp.putfo(buf, "/tmp/exposedgays.tar.gz") + sftp.close() + + cmds = [ + "rm -rf /tmp/exposedgays && mkdir -p /tmp/exposedgays && tar -xzf /tmp/exposedgays.tar.gz -C /tmp/exposedgays", + f"cd /tmp/exposedgays && git init -b main && git config user.email 'ryan.salazar@justtworoommates.com' && git config user.name 'Ryan Salazar'", + "cd /tmp/exposedgays && git add -A && git commit -m 'ExposedGays: map, shop, auth, Supabase schema' || true", + f"cd /tmp/exposedgays && git remote remove origin 2>/dev/null; git remote add origin {GIT_AUTH}", + f"cd /tmp/exposedgays && git push -u origin main --force", + ] + for cmd in cmds: + _, o, e = c.exec_command(cmd, timeout=180) + code = o.channel.recv_exit_status() + out = (o.read() + e.read()).decode(errors="replace") + print(out[-3000:]) + print(f"exit={code}") + c.close() + return GIT_AUTH.replace(GITEA_TOKEN, "***") + + +def apply_supabase_schema(): + print("\n=== 3. Apply Supabase schema on SUPABASE_01 ===") + schema_path = os.path.join(PROJECT_ROOT, "supabase", "schema.sql") + func_path = os.path.join(PROJECT_ROOT, "supabase", "functions.sql") + + c = paramiko.SSHClient() + c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + c.connect(SUPA_HOST, username=USER, password=PASS, timeout=20, allow_agent=False, look_for_keys=False) + sftp = c.open_sftp() + sftp.put(schema_path, "/tmp/eg_schema.sql") + sftp.put(func_path, "/tmp/eg_functions.sql") + sftp.close() + + cmd = ( + "docker exec -i supabase-db psql -U postgres -d postgres " + "-f /dev/stdin < /tmp/eg_schema.sql 2>&1; " + "docker exec -i supabase-db psql -U postgres -d postgres " + "-f /dev/stdin < /tmp/eg_functions.sql 2>&1" + ) + # Copy into container first + for f in ("eg_schema.sql", "eg_functions.sql"): + c.exec_command(f"docker cp /tmp/{f} supabase-db:/tmp/{f}") + import time + time.sleep(2) + _, o, e = c.exec_command( + "docker exec supabase-db psql -U postgres -d postgres -f /tmp/eg_schema.sql 2>&1; " + "docker exec supabase-db psql -U postgres -d postgres -f /tmp/eg_functions.sql 2>&1", + timeout=120, + ) + out = (o.read() + e.read()).decode(errors="replace") + print(out[-5000:]) + c.close() + + +def create_coolify_app(git_url_auth): + print("\n=== 4. Create Coolify application ===") + # List projects + code, projects = coolify_api("GET", "/projects") + print(f"Projects: {code}") + project_uuid = None + if code == 200 and projects: + items = projects if isinstance(projects, list) else projects.get("data", projects) + if items: + project_uuid = items[0].get("uuid") or items[0].get("id") + print(f"Using project: {project_uuid}") + + payload = { + "project_uuid": project_uuid, + "server_uuid": None, + "environment_name": "production", + "git_repository": f"https://gitea.onsethost.com/localadministrator/{REPO_NAME}.git", + "git_branch": "main", + "build_pack": "dockerfile", + "dockerfile_location": "/Dockerfile", + "ports_exposes": "3000", + "name": "exposedgays", + "description": "ExposedGays — free cruising + shop", + "domains": "https://exposedgays.com,https://www.exposedgays.com", + "instant_deploy": True, + } + + code, resp = coolify_api("POST", "/applications/public", payload) + print(f"Create app: HTTP {code}") + print(json.dumps(resp, indent=2)[:3000]) + + app_uuid = resp.get("uuid") if isinstance(resp, dict) else None + if not app_uuid and isinstance(resp, dict): + app_uuid = resp.get("id") + + if app_uuid: + print(f"\n=== 5. Set env vars on {app_uuid} ===") + envs = [ + ("NEXT_PUBLIC_SUPABASE_URL", "https://supabase.onsethost.com"), + ("NEXT_PUBLIC_SUPABASE_ANON_KEY", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc2NzEzOTczLCJleHAiOjIwOTIwNzM5NzN9.oRPm6uJt4Gj-ASOixznas7-IC1pCtlZtEFew-SmY9wo"), + ("NEXT_PUBLIC_APP_URL", "https://exposedgays.com"), + ("NEXT_PUBLIC_DEFAULT_CITY", "Fort Lauderdale"), + ("NEXT_PUBLIC_DEFAULT_LAT", "26.1224"), + ("NEXT_PUBLIC_DEFAULT_LNG", "-80.1373"), + ] + for key, value in envs: + c, r = coolify_api("POST", f"/applications/{app_uuid}/envs", {"key": key, "value": value}) + print(f" {key}: HTTP {c}") + + print("\n=== 6. Trigger deploy ===") + code, dep = coolify_api("POST", f"/deploy?uuid={app_uuid}&force_rebuild=true") + print(f"Deploy: HTTP {code} {dep}") + + return app_uuid + + +def main(): + git_url = push_gitea() + try: + apply_supabase_schema() + except Exception as ex: + print(f"Supabase schema warning: {ex}") + app_uuid = create_coolify_app(git_url) + print("\n=== DONE ===") + print(f"Gitea: https://gitea.onsethost.com/localadministrator/{REPO_NAME}") + print(f"Coolify app: {app_uuid or 'check Coolify UI'}") + print("Next: add exposedgays.com to Cloudflare tunnel → Coolify container") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..a89e338 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,14 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [...compat.extends("next/core-web-vitals")]; + +export default eslintConfig; \ No newline at end of file diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..9e35ce3 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,15 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", + images: { + remotePatterns: [ + { protocol: "https", hostname: "images.unsplash.com" }, + { protocol: "https", hostname: "placehold.co" }, + { protocol: "https", hostname: "*.supabase.co" }, + { protocol: "https", hostname: "supabase.onsethost.com" }, + ], + }, +}; + +export default nextConfig; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..0c6f8d6 --- /dev/null +++ b/package.json @@ -0,0 +1,51 @@ +{ + "name": "exposedgays", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@radix-ui/react-avatar": "^1.1.3", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-slider": "^1.2.3", + "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-switch": "^1.1.3", + "@radix-ui/react-tabs": "^1.1.3", + "@supabase/ssr": "^0.5.2", + "@supabase/supabase-js": "^2.49.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "leaflet": "^1.9.4", + "leaflet.markercluster": "^1.5.3", + "lucide-react": "^0.475.0", + "next": "^15.2.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-leaflet": "^5.0.0", + "react-leaflet-cluster": "^2.1.0", + "tailwind-merge": "^3.0.1", + "tailwindcss-animate": "^1.0.7", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@types/leaflet": "^1.9.16", + "@types/leaflet.markercluster": "^1.5.5", + "@types/node": "^22.13.4", + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "autoprefixer": "^10.4.20", + "eslint": "^9.20.1", + "eslint-config-next": "^15.2.0", + "postcss": "^8.5.3", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.3" + } +} \ No newline at end of file diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..0c2a10b --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,9 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + +export default config; \ No newline at end of file diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..07b81fd --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "ExposedGays", + "short_name": "ExposedGays", + "description": "Free gay cruising map, chat & shop", + "start_url": "/", + "display": "standalone", + "background_color": "#0d0a0f", + "theme_color": "#ff2d6b", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} \ No newline at end of file diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx new file mode 100644 index 0000000..b9c7b40 --- /dev/null +++ b/src/app/chat/page.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { Suspense } from "react"; +import { ChatPanel } from "@/components/ChatPanel"; + +function ChatContent() { + const searchParams = useSearchParams(); + const userId = searchParams.get("user"); + + return ( +
+ +
+ ); +} + +export default function ChatPage() { + return ( + Loading chat...}> + + + ); +} \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..aa05237 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,96 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 280 30% 6%; + --foreground: 0 0% 98%; + --card: 280 25% 10%; + --card-foreground: 0 0% 98%; + --popover: 280 25% 10%; + --popover-foreground: 0 0% 98%; + --primary: 340 100% 59%; + --primary-foreground: 0 0% 100%; + --secondary: 280 40% 18%; + --secondary-foreground: 0 0% 98%; + --muted: 280 20% 16%; + --muted-foreground: 280 10% 65%; + --accent: 280 60% 45%; + --accent-foreground: 0 0% 100%; + --destructive: 0 84% 60%; + --destructive-foreground: 0 0% 98%; + --border: 280 20% 20%; + --input: 280 20% 20%; + --ring: 340 100% 59%; + --radius: 0.75rem; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground antialiased; + font-feature-settings: "rlig" 1, "calt" 1; + } +} + +/* Leaflet dark theme overrides */ +.leaflet-container { + background: #0d0a0f !important; + font-family: inherit; +} +.leaflet-popup-content-wrapper { + background: hsl(280 25% 10%); + color: hsl(0 0% 98%); + border-radius: 0.75rem; + border: 1px solid hsl(280 20% 20%); +} +.leaflet-popup-tip { + background: hsl(280 25% 10%); +} +.marker-cluster-small, +.marker-cluster-medium, +.marker-cluster-large { + background-color: rgba(255, 45, 107, 0.3) !important; +} +.marker-cluster-small div, +.marker-cluster-medium div, +.marker-cluster-large div { + background-color: rgba(255, 45, 107, 0.8) !important; + color: white !important; + font-weight: 700; +} + +/* Vanilla mode blur */ +.vanilla-blur img, +.vanilla-blur video, +.vanilla-blur .explicit-content { + filter: blur(24px); + transition: filter 0.3s ease; +} +.vanilla-blur:hover img, +.vanilla-blur:hover video, +.vanilla-blur:hover .explicit-content { + filter: blur(8px); +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: hsl(280 30% 6%); +} +::-webkit-scrollbar-thumb { + background: hsl(340 100% 59% / 0.5); + border-radius: 3px; +} + +/* PWA safe areas */ +.safe-bottom { + padding-bottom: env(safe-area-inset-bottom, 0); +} \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..82d2b5f --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,39 @@ +import type { Metadata, Viewport } from "next"; +import { Inter } from "next/font/google"; +import { Providers } from "@/components/Providers"; +import "./globals.css"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "ExposedGays — Free Gay Cruising & Shop", + description: + "100% free gay cruising map, chat, spots directory, and sex shop. No subscriptions ever.", + manifest: "/manifest.json", + appleWebApp: { + capable: true, + statusBarStyle: "black-translucent", + title: "ExposedGays", + }, +}; + +export const viewport: Viewport = { + themeColor: "#ff2d6b", + width: "device-width", + initialScale: 1, + maximumScale: 1, +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..54613da --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { CruisingMap } from "@/components/Map"; +import { useSetActiveCount, useVanillaMode } from "@/components/Providers"; + +export default function HomePage() { + const setActiveCount = useSetActiveCount(); + const vanillaMode = useVanillaMode(); + + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx new file mode 100644 index 0000000..8d8f7c5 --- /dev/null +++ b/src/app/privacy/page.tsx @@ -0,0 +1,12 @@ +export default function PrivacyPage() { + return ( +
+

Privacy Policy

+

+ 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). + Placeholder policy — consult legal counsel before launch. +

+
+ ); +} \ No newline at end of file diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx new file mode 100644 index 0000000..c2e50e3 --- /dev/null +++ b/src/app/profile/page.tsx @@ -0,0 +1,257 @@ +"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 { Badge } from "@/components/ui/badge"; +import { Switch } from "@/components/ui/switch"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { KINK_OPTIONS } from "@/types"; +import type { Position } from "@/types"; +import { useAuth } from "@/lib/auth/context"; +import { AuthModal } from "@/components/AuthModal"; + +export default function ProfilePage() { + const { user, profile, loading, updateProfile, signOut } = useAuth(); + const [authOpen, setAuthOpen] = useState(false); + const [isAnonymous, setIsAnonymous] = useState(true); + const [displayName, setDisplayName] = useState(""); + const [age, setAge] = useState(""); + const [position, setPosition] = useState(null); + const [bio, setBio] = useState(""); + const [kinks, setKinks] = useState([]); + const [onPrep, setOnPrep] = useState(false); + const [stiStatus, setStiStatus] = useState(""); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(""); + + useEffect(() => { + if (profile) { + setIsAnonymous(profile.is_anonymous); + setDisplayName(profile.display_name || ""); + setAge(profile.age?.toString() || ""); + setPosition(profile.position); + setBio(profile.bio || ""); + setKinks(profile.kinks || []); + setOnPrep(profile.on_prep); + setStiStatus(profile.sti_status || ""); + } else { + const local = localStorage.getItem("eg_profile"); + if (local) { + try { + const p = JSON.parse(local); + setIsAnonymous(p.isAnonymous ?? true); + setDisplayName(p.displayName || ""); + setAge(p.age?.toString() || ""); + setPosition(p.position); + setBio(p.bio || ""); + setKinks(p.kinks || []); + setOnPrep(p.onPrep || false); + setStiStatus(p.stiStatus || ""); + } catch { + /* ignore */ + } + } + } + }, [profile]); + + const toggleKink = (k: string) => { + setKinks((prev) => + prev.includes(k) ? prev.filter((x) => x !== k) : [...prev, k] + ); + }; + + const handleSave = async () => { + setSaving(true); + setMessage(""); + + const data = { + display_name: isAnonymous ? null : displayName || null, + age: parseInt(age) || null, + position, + bio: bio || null, + kinks, + on_prep: onPrep, + sti_status: stiStatus || null, + is_anonymous: isAnonymous, + status: "online" as const, + city: process.env.NEXT_PUBLIC_DEFAULT_CITY || "Fort Lauderdale", + lat: parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LAT || "26.1224"), + lng: parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LNG || "-80.1373"), + }; + + if (user) { + const { error } = await updateProfile(data); + setMessage(error ? `Error: ${error}` : "Profile synced to cloud."); + } else { + localStorage.setItem( + "eg_profile", + JSON.stringify({ + displayName: data.display_name, + age: data.age, + position: data.position, + bio: data.bio, + kinks: data.kinks, + onPrep: data.on_prep, + stiStatus: data.sti_status, + isAnonymous: data.is_anonymous, + }) + ); + setMessage("Profile saved locally. Sign up free for cloud sync."); + } + setSaving(false); + }; + + if (loading) { + return ( +
+ Loading profile... +
+ ); + } + + return ( +
+
+
+

Your Profile

+

+ {user + ? `Signed in as ${user.email || "anonymous user"}` + : "Browsing locally — sign up free for cloud sync"} +

+
+ {!user && ( + + )} +
+ + + + Visibility + + +
+ + +
+ {!isAnonymous && ( +
+ + setDisplayName(e.target.value)} + placeholder="Your name" + className="mt-1" + /> +
+ )} +
+
+ + + + Details + + +
+ + setAge(e.target.value)} + placeholder="18+" + min={18} + max={99} + className="mt-1" + /> +
+ +
+ +
+ {(["top", "bottom", "vers", "side"] as Position[]).map((pos) => ( + setPosition(position === pos ? null : pos)} + > + {pos} + + ))} +
+
+ +
+ + setBio(e.target.value)} + placeholder="Say something..." + className="mt-1" + /> +
+ +
+ +
+ {KINK_OPTIONS.map((k) => ( + toggleKink(k)} + > + {k} + + ))} +
+
+ +
+ + +
+ +
+ + setStiStatus(e.target.value)} + placeholder="e.g. Negative, tested Jan 2026" + className="mt-1" + /> +
+
+
+ + {message && ( +

+ {message} +

+ )} + + + + {user && ( + + )} + + setAuthOpen(false)} /> +
+ ); +} \ No newline at end of file diff --git a/src/app/shop/page.tsx b/src/app/shop/page.tsx new file mode 100644 index 0000000..54c281a --- /dev/null +++ b/src/app/shop/page.tsx @@ -0,0 +1,5 @@ +import { Shop } from "@/components/Shop"; + +export default function ShopPage() { + return ; +} \ No newline at end of file diff --git a/src/app/spots/page.tsx b/src/app/spots/page.tsx new file mode 100644 index 0000000..2a013cb --- /dev/null +++ b/src/app/spots/page.tsx @@ -0,0 +1,5 @@ +import { SpotDirectory } from "@/components/SpotDirectory"; + +export default function SpotsPage() { + return ; +} \ No newline at end of file diff --git a/src/app/terms/page.tsx b/src/app/terms/page.tsx new file mode 100644 index 0000000..7c3c673 --- /dev/null +++ b/src/app/terms/page.tsx @@ -0,0 +1,12 @@ +export default function TermsPage() { + return ( +
+

Terms of Service

+

+ ExposedGays is a free adult platform. 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. Placeholder terms — consult legal counsel before launch. +

+
+ ); +} \ No newline at end of file diff --git a/src/components/AgeGate.tsx b/src/components/AgeGate.tsx new file mode 100644 index 0000000..7375008 --- /dev/null +++ b/src/components/AgeGate.tsx @@ -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 ( +
+
+
+
+ +
+

+ ExposedGays +

+

+ Adults only. Explicit content ahead. +

+
+ +
+
+ +
+ + + +
+
+ + + + {error && ( +

{error}

+ )} + + + +

+ 100% free forever. No subscriptions. No credit card required. +

+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/AuthModal.tsx b/src/components/AuthModal.tsx new file mode 100644 index 0000000..53fcbd8 --- /dev/null +++ b/src/components/AuthModal.tsx @@ -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 ( + !v && onClose()}> + + + Join ExposedGays — Free Forever + + + + + Sign Up + Log In + + + +
+ +
+ + setEmail(e.target.value)} + placeholder="you@example.com" + className="pl-10" + /> +
+
+
+ +
+ + setPassword(e.target.value)} + placeholder="8+ characters" + className="pl-10" + minLength={8} + /> +
+
+ {error &&

{error}

} + {success &&

{success}

} + +
+ + +
+ + setEmail(e.target.value)} + placeholder="you@example.com" + className="mt-1" + /> +
+
+ + setPassword(e.target.value)} + className="mt-1" + /> +
+ {error &&

{error}

} + +
+
+ +
+
+ +
+
+ or +
+
+ + + +

+ No credit card. No paid tier. Ever. +

+
+
+ ); +} \ No newline at end of file diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx new file mode 100644 index 0000000..0c4a189 --- /dev/null +++ b/src/components/ChatPanel.tsx @@ -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(MOCK_CITY_MESSAGES); + const [dmMessages, setDmMessages] = useState(MOCK_DM_MESSAGES); + const [input, setInput] = useState(""); + const [activeTab, setActiveTab] = useState(dmUserId ? "dm" : "city"); + const bottomRef = useRef(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 ( +
+ + + + + {CITY} + + + + DMs + + + Groups + + + + +
+ + {cityMessages.length + 12} in room + +

+ Auto-joined based on your location. Public city chat. +

+
+ +
+ + +
+

+ {dmUserId ? `Chat with User #${dmUserId}` : "Private Messages"} +

+
+ +
+ + +
+ + + +

+ Create a group room — free, no limits. +

+
+
+ + {(activeTab === "city" || activeTab === "dm") && ( +
+ + setInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && sendMessage()} + className="flex-1" + /> + +
+ )} +
+
+
+ ); +} + +function MessageList({ messages }: { messages: Message[] }) { + return ( +
+ {messages.map((msg) => ( +
+
+ + {msg.sender} + + + {timeAgo(msg.created_at)} + +
+
+ {msg.content} +
+
+ ))} +
+ ); +} + +function GroupRoom({ name, members }: { name: string; members: number }) { + return ( +
+
+

{name}

+

{members} members

+
+ + Live + +
+ ); +} \ No newline at end of file diff --git a/src/components/CruiserCard.tsx b/src/components/CruiserCard.tsx new file mode 100644 index 0000000..0b51af6 --- /dev/null +++ b/src/components/CruiserCard.tsx @@ -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 ( + onView(profile)} + > +
+ {name} + {profile.status !== "offline" && ( +
+ + {STATUS_LABELS[profile.status]} + +
+ )} + {profile.position && ( +
+ + {profile.position} + +
+ )} +
+ +
+ {name} + {profile.age && ( + {profile.age} + )} +
+ {profile.kinks.length > 0 && ( +
+ {profile.kinks.slice(0, 3).map((k) => ( + + {k} + + ))} +
+ )} +
+ + + {profile.city || "Nearby"} + + {timeAgo(profile.last_active)} +
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/components/Map.tsx b/src/components/Map.tsx new file mode 100644 index 0000000..a5b8f6f --- /dev/null +++ b/src/components/Map.tsx @@ -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(MOCK_PROFILES); + const [spots] = useState(MOCK_SPOTS); + const [selectedProfile, setSelectedProfile] = useState(null); + const [showFilters, setShowFilters] = useState(false); + const [showSidebar, setShowSidebar] = useState(true); + const [leafletReady, setLeafletReady] = useState(false); + const [userIcon, setUserIcon] = useState(null); + const [spotIcon, setSpotIcon] = useState(null); + + const [filters, setFilters] = useState({ + 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: `
+ +
`, + iconSize: [36, 36], + iconAnchor: [18, 18], + }); + }; + + const createSpotIcon = (category: string) => { + const colors: Record = { + 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: `
+ +
`, + 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 ( +
+ {/* Filter bar */} +
+ + + + {filteredProfiles.filter((p) => p.status !== "offline").length} active now + +
+ + {/* Filter panel */} + {showFilters && ( +
+
+ Map Filters + +
+ +
+ + + setFilters((f) => ({ ...f, ageMin: min, ageMax: max })) + } + className="mt-2" + /> +
+ +
+ +
+ {(["top", "bottom", "vers", "side"] as Position[]).map((pos) => ( + togglePosition(pos)} + > + {pos} + + ))} +
+
+ +
+ +
+ {KINK_OPTIONS.map((k) => ( + toggleKink(k)} + > + {k} + + ))} +
+
+ +
+ + + setFilters((f) => ({ ...f, onPrep: v ? true : null })) + } + /> +
+ +
+ + + setFilters((f) => ({ ...f, onlineOnly: v })) + } + /> +
+
+ )} + + {/* Sidebar — active cruisers */} + {showSidebar && ( +
+

+ Active Cruisers +

+ {filteredProfiles + .filter((p) => p.status !== "offline") + .map((p) => ( + + ))} +
+ )} + + {/* Map */} + {leafletReady && userIcon && spotIcon && ( + + + + {filteredProfiles.map((p) => + p.lat && p.lng ? ( + unknown }).create(p.position, p.status)} + eventHandlers={{ + click: () => setSelectedProfile(p), + }} + > + +
+ + {p.is_anonymous ? "Anonymous" : p.display_name || "Cruiser"} + + {p.position && ( + + {p.position} + + )} +

{p.bio}

+ +
+
+
+ ) : null + )} +
+ + {spots.map((s) => ( + unknown }).create(s.category)} + > + +
+ {s.name} + + {s.category} + +

{s.description}

+

+ {s.active_count} active · ⭐ {s.rating} +

+
+
+
+ ))} +
+ )} + + setSelectedProfile(null)} + onChat={handleChat} + vanillaMode={vanillaMode} + /> +
+ ); +} \ No newline at end of file diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx new file mode 100644 index 0000000..569a55b --- /dev/null +++ b/src/components/Nav.tsx @@ -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 */} +
+ + + ExposedGays + + {activeCount > 0 && ( + + {activeCount} active + + )} + + + + +
+ + {user ? ( + + ) : ( + + )} + + FREE FOREVER + +
+
+ + setAuthOpen(false)} /> + + {/* Mobile bottom nav */} + + + ); +} \ No newline at end of file diff --git a/src/components/ProfilePopup.tsx b/src/components/ProfilePopup.tsx new file mode 100644 index 0000000..6efd888 --- /dev/null +++ b/src/components/ProfilePopup.tsx @@ -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 ( + !v && onClose()}> + + + + {name} + {profile.age && ( + {profile.age} + )} + + + +
+ {name} +
+ +
+ {profile.position && ( + + {profile.position} + + )} + + {STATUS_LABELS[profile.status]} + + {profile.on_prep && ( + + + On PrEP + + )} +
+ + {profile.bio && ( +

{profile.bio}

+ )} + + {profile.kinks.length > 0 && ( +
+

INTO

+
+ {profile.kinks.map((k) => ( + + {k} + + ))} +
+
+ )} + + {profile.sti_status && ( +

+ STI Status: {profile.sti_status} +

+ )} + +

+ Last active: {timeAgo(profile.last_active)} · {profile.city} +

+ +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx new file mode 100644 index 0000000..6a8e69a --- /dev/null +++ b/src/components/Providers.tsx @@ -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 && setVerified(true)} />} + + +