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)}
>
-
{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 && (