diff --git a/src/components/CruiserCard.tsx b/src/components/CruiserCard.tsx index 0b51af6..b4b083e 100644 --- a/src/components/CruiserCard.tsx +++ b/src/components/CruiserCard.tsx @@ -1,7 +1,9 @@ "use client"; -import Image from "next/image"; import { Badge } from "@/components/ui/badge"; +import { ScannedImage } from "@/components/ScannedImage"; +import { profileMediaLikelyExplicit } from "@/lib/xpic-scanner"; +import { useAccess } from "@/components/Providers"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { MessageCircle, MapPin } from "lucide-react"; @@ -17,6 +19,7 @@ interface CruiserCardProps { } export function CruiserCard({ profile, onChat, onView, vanillaMode }: CruiserCardProps) { + const { blurExplicit } = useAccess(); const name = profile.is_anonymous ? "Anonymous" : profile.display_name || profile.username || "Cruiser"; @@ -27,12 +30,14 @@ export function CruiserCard({ profile, onChat, onView, vanillaMode }: CruiserCar onClick={() => onView(profile)} >
- {name} {profile.status !== "offline" && (
diff --git a/src/components/CruiserProfileView.tsx b/src/components/CruiserProfileView.tsx index 1e22115..84bfe2c 100644 --- a/src/components/CruiserProfileView.tsx +++ b/src/components/CruiserProfileView.tsx @@ -2,6 +2,9 @@ import { useState } from "react"; import Image from "next/image"; +import { ScannedImage } from "@/components/ScannedImage"; +import { useExplicitScan } from "@/hooks/useExplicitScan"; +import { isExplicitForDisplay } from "@/lib/xpic-scanner"; import { ArrowLeft, Ban, @@ -99,6 +102,21 @@ export function CruiserProfileView({ const [lightboxOpen, setLightboxOpen] = useState(false); const [lightboxIndex, setLightboxIndex] = useState(0); + const scanTargetUrl = + profile && open + ? profile.gallery?.length + ? profile.gallery[Math.min(activePhoto, profile.gallery.length - 1)]?.url + : profile.avatar_url + : null; + const scanTargetExplicit = + profile && open + ? profile.gallery?.length + ? (profile.gallery[Math.min(activePhoto, profile.gallery.length - 1)]?.is_explicit ?? + true) + : true + : false; + const activeScan = useExplicitScan(scanTargetUrl, scanTargetExplicit); + if (!profile || !open) return null; const name = profile.is_verified @@ -111,7 +129,7 @@ export function CruiserProfileView({ const photos = profile.gallery?.length ? profile.gallery.slice(0, 5) : profile.avatar_url - ? [{ url: profile.avatar_url, is_explicit: false }] + ? [{ url: profile.avatar_url, is_explicit: true }] : []; const mediaItems: ProfileMediaItem[] = photos.map((ph, i) => ({ @@ -126,8 +144,14 @@ export function CruiserProfileView({ created_at: new Date().toISOString(), })); - const shouldBlurPhoto = (explicit: boolean) => - vanillaMode || blurExplicit || (explicit && !capabilities.canViewExplicit); + const shouldBlurPhoto = (explicit: boolean, scan?: typeof activeScan) => { + const xExplicit = isExplicitForDisplay(explicit, scan ?? 'pending'); + return ( + vanillaMode || + (blurExplicit && xExplicit) || + (xExplicit && !capabilities.canViewExplicit) + ); + }; const viewer = authProfile ?? guestViewerProfile(); const viewerId = user?.id ?? "guest"; @@ -209,7 +233,10 @@ export function CruiserProfileView({ type="button" className={cn( "relative w-full h-full block", - shouldBlurPhoto(photos[activePhoto]?.is_explicit ?? false) && "id-blur" + shouldBlurPhoto( + photos[activePhoto]?.is_explicit ?? true, + activeScan + ) && "id-blur" )} onClick={() => openLightbox(activePhoto)} > @@ -222,7 +249,10 @@ export function CruiserProfileView({ unoptimized /> - {shouldBlurPhoto(photos[activePhoto]?.is_explicit ?? false) && ( + {shouldBlurPhoto( + photos[activePhoto]?.is_explicit ?? true, + activeScan + ) && (

@@ -253,11 +283,17 @@ export function CruiserProfileView({ }} className={cn( "relative w-11 h-11 sm:w-12 sm:h-12 rounded-lg overflow-hidden border-2 touch-manipulation", - activePhoto === i ? "border-primary shadow-pulse-glow" : "border-white/30", - shouldBlurPhoto(ph.is_explicit) && "id-blur" + activePhoto === i ? "border-primary shadow-pulse-glow" : "border-white/30" )} > - + ))}

@@ -424,17 +460,16 @@ export function CruiserProfileView({ key={`${ph.url}-${i}`} type="button" onClick={() => setActivePhoto(i)} - className={cn( - "relative aspect-square rounded overflow-hidden", - shouldBlurPhoto(ph.is_explicit) && "id-blur" - )} + className="relative aspect-square rounded overflow-hidden" > - - {shouldBlurPhoto(ph.is_explicit) && ( -
- -
- )} + ))} {capabilities.canUploadPhotos && ( @@ -509,7 +544,10 @@ export function CruiserProfileView({ setLightboxIndex(i); setActivePhoto(i); }} - blur={shouldBlurPhoto(mediaItems[lightboxIndex]?.is_explicit ?? false)} + blur={shouldBlurPhoto( + mediaItems[lightboxIndex]?.is_explicit ?? true, + activeScan + )} />
); diff --git a/src/components/InterestPreviewRail.tsx b/src/components/InterestPreviewRail.tsx index b1b1d47..5347528 100644 --- a/src/components/InterestPreviewRail.tsx +++ b/src/components/InterestPreviewRail.tsx @@ -1,9 +1,11 @@ "use client"; -import Image from "next/image"; import type { Profile } from "@/types"; import { interestLabel } from "@/lib/interests"; import { formatDistance } from "@/lib/profile-stats"; +import { ScannedImage } from "@/components/ScannedImage"; +import { profileMediaLikelyExplicit } from "@/lib/xpic-scanner"; +import { useAccess } from "@/components/Providers"; interface InterestPreviewRailProps { profiles: Profile[]; @@ -16,6 +18,7 @@ export function InterestPreviewRail({ interests, onSelect, }: InterestPreviewRailProps) { + const { blurExplicit } = useAccess(); if (!interests.length || !profiles.length) return null; return ( @@ -37,7 +40,14 @@ export function InterestPreviewRail({ className="shrink-0 w-[72px] touch-manipulation active:scale-95 transition-transform" >
- + {p.status !== "offline" && ( )} diff --git a/src/components/Map.tsx b/src/components/Map.tsx index 63cc3cd..efe8113 100644 --- a/src/components/Map.tsx +++ b/src/components/Map.tsx @@ -21,6 +21,7 @@ import { MOCK_PROFILES, MOCK_SPOTS } from "@/lib/mock-data"; import { useAuth } from "@/lib/auth/context"; import { usePrivacy } from "@/contexts/PrivacyContext"; import { canViewerMessageTarget, filterVisibleProfiles, guestViewerProfile } from "@/lib/visibility"; +import { profileMediaLikelyExplicit, scanUrlHintsExplicit } from "@/lib/xpic-scanner"; import { loadHideFromRules } from "@/lib/privacy-rules"; import { loadBlockList } from "@/lib/block-list"; import { distanceMiles } from "@/lib/profile-stats"; @@ -87,10 +88,21 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) { useEffect(() => { import("leaflet").then((L) => { + const shouldBlurAvatar = (p: Profile, blur: boolean) => { + if (!blur) return false; + if (profileMediaLikelyExplicit(p.avatar_url, p.gallery)) return true; + const url = p.avatar_url || ""; + if (!url) return true; + const hint = scanUrlHintsExplicit(url); + if (!hint) return true; + return hint.isExplicit; + }; + const createPhoto = (p: Profile, blur: boolean) => { const url = p.avatar_url || "https://placehold.co/96x96/0a1628/3b82f6?text=?"; + const avatarBlur = shouldBlurAvatar(p, blur); const dist = p.distance_miles ?? (p.lat && p.lng @@ -98,7 +110,7 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) { : 1); const distLabel = dist < 1 ? dist.toFixed(1) : String(Math.max(1, Math.round(dist))); - const blurStyle = blur ? "filter:blur(6px);" : ""; + const blurStyle = avatarBlur ? "filter:blur(6px);" : ""; const safeUrl = url.replace(/'/g, "%27"); return L.divIcon({ diff --git a/src/components/MediaGalleryManager.tsx b/src/components/MediaGalleryManager.tsx index 0e25edf..5dab6ef 100644 --- a/src/components/MediaGalleryManager.tsx +++ b/src/components/MediaGalleryManager.tsx @@ -52,6 +52,7 @@ import { useAuth } from "@/lib/auth/context"; import { useAccess } from "@/components/Providers"; import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner"; import { cn } from "@/lib/utils"; +import { ScannedImage } from "@/components/ScannedImage"; interface MediaGalleryManagerProps { onVerifyClick?: () => void; @@ -124,10 +125,14 @@ export function MediaGalleryManager({ onVerifyClick }: MediaGalleryManagerProps) }; const handleCropConfirm = async (file: File) => { - const { error } = await addPhoto(file, activeAlbum, user?.id); + const { error, scanReason } = await addPhoto(file, activeAlbum, user?.id); if (error) setMessage(error); else { - setMessage("Photo added!"); + setMessage( + scanReason && scanReason !== 'looks safe' + ? `Photo added — auto-scanned as X-rated (${scanReason}). Blurred for guests until verified.` + : 'Photo added — passed safety scan.' + ); await refresh(); } }; @@ -201,6 +206,10 @@ export function MediaGalleryManager({ onVerifyClick }: MediaGalleryManagerProps) {photos.length}/{MAX_PHOTOS_PER_USER} · {videos.length}/{MAX_VIDEOS_PER_USER} vid
+

+ Uploads are auto-scanned for X-rated content — flagged pics stay blurred for guests + and unverified viewers. +

{blurExplicit && (