Add interest feed customization, 20-photo gallery with crop, videos, and profile media curation

This commit is contained in:
Ryan Salazar
2026-06-26 22:01:23 -04:00
parent 9604a18f7c
commit 66e1df12b0
21 changed files with 1789 additions and 348 deletions

View File

@@ -22,6 +22,9 @@ import type { Profile } from "@/types";
import { formatStatsLine, formatDistance } from "@/lib/profile-stats";
import { timeAgo } from "@/lib/utils";
import { cn } from "@/lib/utils";
import { interestLabel } from "@/lib/interests";
import { MediaLightbox } from "@/components/MediaLightbox";
import type { ProfileMediaItem } from "@/types";
interface CruiserProfileViewProps {
profile: Profile | null;
@@ -83,6 +86,8 @@ export function CruiserProfileView({
const { capabilities, blurExplicit } = useAccess();
const [tab, setTab] = useState<Tab>("about");
const [activePhoto, setActivePhoto] = useState(0);
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
if (!profile || !open) return null;
@@ -94,11 +99,23 @@ export function CruiserProfileView({
const stats = formatStatsLine(profile);
const photos = profile.gallery?.length
? profile.gallery
? profile.gallery.slice(0, 5)
: profile.avatar_url
? [{ url: profile.avatar_url, is_explicit: false }]
: [];
const mediaItems: ProfileMediaItem[] = photos.map((ph, i) => ({
id: `ph-${i}`,
type: "photo",
url: ph.url,
thumbnail_url: ph.url,
is_explicit: ph.is_explicit,
show_on_profile: true,
profile_sort_order: i,
sort_order: i,
created_at: new Date().toISOString(),
}));
const shouldBlurPhoto = (explicit: boolean) =>
vanillaMode || blurExplicit || (explicit && !capabilities.canViewExplicit);
@@ -107,7 +124,12 @@ export function CruiserProfileView({
onChat(profile);
};
const thumbs = photos.slice(1, 3);
const thumbs = photos.slice(0, 5);
const openLightbox = (index: number) => {
setLightboxIndex(index);
setLightboxOpen(true);
};
return (
<div className="fixed inset-0 z-[2000] bg-black flex flex-col md:items-center md:justify-center md:p-4">
@@ -146,21 +168,23 @@ export function CruiserProfileView({
<div className="flex-1 overflow-y-auto overscroll-contain">
{/* Hero photo + thumbnails */}
<div className="relative aspect-[4/5] max-h-[50vh] bg-black">
<div
<button
type="button"
className={cn(
"relative w-full h-full",
"relative w-full h-full block",
shouldBlurPhoto(photos[activePhoto]?.is_explicit ?? false) && "id-blur"
)}
onClick={() => openLightbox(activePhoto)}
>
<Image
src={photos[activePhoto]?.url || "https://placehold.co/400x500/0a1628/3b82f6?text=?"}
src={photos[activePhoto]?.url || "https://placehold.co/400x500/1a003a/f92b9b?text=?"}
alt={name}
fill
className="object-cover explicit-content"
priority
unoptimized
/>
</div>
</button>
{shouldBlurPhoto(photos[activePhoto]?.is_explicit ?? false) && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/50 gap-2 px-6 text-center">
<Lock className="h-8 w-8 text-white" />
@@ -180,15 +204,19 @@ export function CruiserProfileView({
)}
</div>
)}
{thumbs.length > 0 && (
<div className="absolute top-3 right-3 flex flex-col gap-2">
{thumbs.length > 1 && (
<div className="absolute bottom-3 left-3 right-3 flex gap-1.5 justify-center">
{thumbs.map((ph, i) => (
<button
key={ph.url}
key={`${ph.url}-${i}`}
type="button"
onClick={() => setActivePhoto(i + 1)}
onClick={() => {
setActivePhoto(i);
openLightbox(i);
}}
className={cn(
"relative w-14 h-14 rounded-full overflow-hidden border-2 border-primary",
"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"
)}
>
@@ -264,10 +292,10 @@ export function CruiserProfileView({
</SectionRow>
)}
{profile.kinks.length > 0 && (
<SectionRow label="Kinks">
<SectionRow label="Into">
{profile.kinks.map((k) => (
<Pill key={k} active>
{k}
{interestLabel(k)}
</Pill>
))}
</SectionRow>
@@ -409,6 +437,18 @@ export function CruiserProfileView({
</Button>
</div>
</div>
<MediaLightbox
items={mediaItems}
index={lightboxIndex}
open={lightboxOpen}
onClose={() => setLightboxOpen(false)}
onIndexChange={(i) => {
setLightboxIndex(i);
setActivePhoto(i);
}}
blur={shouldBlurPhoto(mediaItems[lightboxIndex]?.is_explicit ?? false)}
/>
</div>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
import Image from "next/image";
import type { Profile } from "@/types";
import { interestLabel } from "@/lib/interests";
import { formatDistance } from "@/lib/profile-stats";
interface InterestPreviewRailProps {
profiles: Profile[];
interests: string[];
onSelect: (profile: Profile) => void;
}
export function InterestPreviewRail({
profiles,
interests,
onSelect,
}: InterestPreviewRailProps) {
if (!interests.length || !profiles.length) return null;
return (
<div className="rounded-xl border border-primary/20 bg-pulse-midnight/90 backdrop-blur-md p-2 space-y-1.5">
<p className="text-[10px] uppercase tracking-wider text-pulse-pink-light px-1">
Your scene · {profiles.length} nearby
</p>
<div className="flex gap-2 overflow-x-auto scrollbar-none pb-0.5">
{profiles.slice(0, 12).map((p) => {
const match = interests.find((i) => p.kinks.includes(i));
const avatar =
p.avatar_url ||
"https://placehold.co/96x96/1a003a/f92b9b?text=?";
return (
<button
key={p.id}
type="button"
onClick={() => onSelect(p)}
className="shrink-0 w-[72px] touch-manipulation active:scale-95 transition-transform"
>
<div className="relative w-[72px] h-[72px] rounded-xl overflow-hidden border-2 border-primary/40">
<Image src={avatar} alt="" fill className="object-cover" unoptimized />
{p.status !== "offline" && (
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-emerald-400 ring-2 ring-black/50" />
)}
</div>
<p className="text-[10px] font-semibold mt-1 truncate text-foreground">
{p.is_anonymous ? "Anon" : p.display_name || "Cruiser"}
</p>
<p className="text-[9px] text-muted-foreground truncate">
{match ? interestLabel(match) : ""}
{p.distance_miles != null && ` · ${formatDistance(p.distance_miles)}`}
</p>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
"use client";
import { interestLabel } from "@/lib/interests";
import { INTEREST_OPTIONS } from "@/lib/interests";
interface InterestTickerProps {
interests: string[];
className?: string;
}
export function InterestTicker({ interests, className }: InterestTickerProps) {
if (!interests.length) return null;
const items = interests.map((id) => {
const opt = INTEREST_OPTIONS.find((o) => o.id === id);
return {
id,
label: interestLabel(id),
emoji: opt?.tickerEmoji ?? "•",
};
});
const doubled = [...items, ...items];
return (
<div
className={`overflow-hidden rounded-full border border-primary/25 bg-pulse-midnight/90 backdrop-blur-md ${className ?? ""}`}
>
<div className="flex animate-interest-ticker w-max gap-2 py-1.5 px-2">
{doubled.map((item, i) => (
<span
key={`${item.id}-${i}`}
className="inline-flex items-center gap-1.5 shrink-0 pulse-pill-tag text-[11px] py-0.5"
>
<span>{item.emoji}</span>
{item.label}
</span>
))}
</div>
</div>
);
}

View File

@@ -12,10 +12,14 @@ import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
import { LocalGuidePanel } from "@/components/LocalGuidePanel";
import { Filter, X, Locate } from "lucide-react";
import type { Profile, CruisingSpot, MapFilters, Position } from "@/types";
import { KINK_OPTIONS } from "@/types";
import { INTEREST_OPTIONS, profileMatchesInterests } from "@/lib/interests";
import { InterestTicker } from "@/components/InterestTicker";
import { InterestPreviewRail } from "@/components/InterestPreviewRail";
import { useFeedPreferences } from "@/components/Providers";
import { MOCK_PROFILES, MOCK_SPOTS } from "@/lib/mock-data";
import { useOpenIdVerification, useAccess } from "@/components/Providers";
import { distanceMiles } from "@/lib/profile-stats";
import { cn } from "@/lib/utils";
import "leaflet/dist/leaflet.css";
@@ -45,6 +49,7 @@ interface MapProps {
export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
const openIdVerification = useOpenIdVerification();
const { capabilities, blurExplicit } = useAccess();
const { feedInterests } = useFeedPreferences();
const [profiles] = useState<Profile[]>(MOCK_PROFILES);
const [spots] = useState<CruisingSpot[]>(MOCK_SPOTS);
const [selectedProfile, setSelectedProfile] = useState<Profile | null>(null);
@@ -141,11 +146,22 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
return false;
if (filters.kinks.length && !filters.kinks.some((k) => p.kinks.includes(k)))
return false;
if (feedInterests.length && !profileMatchesInterests(p.kinks, feedInterests))
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]);
}, [profiles, filters, feedInterests]);
const interestMatches = useMemo(() => {
if (!feedInterests.length) return [];
return profiles.filter(
(p) =>
p.status !== "offline" &&
profileMatchesInterests(p.kinks, feedInterests)
);
}, [profiles, feedInterests]);
useEffect(() => {
onActiveCountChange?.(filteredProfiles.filter((p) => p.status !== "offline").length);
@@ -242,14 +258,14 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
<div>
<Label className="text-xs mb-2 block text-white/70">Into</Label>
<div className="flex flex-wrap gap-1 max-h-24 overflow-y-auto">
{KINK_OPTIONS.slice(0, 12).map((k) => (
{INTEREST_OPTIONS.slice(0, 14).map((opt) => (
<Badge
key={k}
variant={filters.kinks.includes(k) ? "default" : "outline"}
key={opt.id}
variant={filters.kinks.includes(opt.id) ? "default" : "outline"}
className="cursor-pointer text-[10px]"
onClick={() => toggleKink(k)}
onClick={() => toggleKink(opt.id)}
>
{k}
{opt.label}
</Badge>
))}
</div>
@@ -275,9 +291,30 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
</div>
)}
{/* Feed customization — ticker + preview */}
{feedInterests.length > 0 && (
<div className="absolute top-12 left-2 right-2 z-[999] space-y-2 pointer-events-none">
<div className="pointer-events-auto">
<InterestTicker interests={feedInterests} />
</div>
<div className="pointer-events-auto max-w-full sm:max-w-md sm:ml-auto">
<InterestPreviewRail
profiles={interestMatches}
interests={feedInterests}
onSelect={setSelectedProfile}
/>
</div>
</div>
)}
{/* Guest banner mobile */}
{!capabilities.canSendMessages && (
<div className="sm:hidden absolute top-12 left-2 right-2 z-[999]">
<div
className={cn(
"sm:hidden absolute left-2 right-2 z-[999]",
feedInterests.length ? "top-28" : "top-12"
)}
>
<GuestUpgradeBanner reason="general" compact />
</div>
)}

View File

@@ -0,0 +1,489 @@
"use client";
import { useState, useEffect, useRef, useMemo } from "react";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Plus,
Trash2,
ImageIcon,
FolderPlus,
Lock,
Video,
Star,
ChevronUp,
ChevronDown,
Eye,
EyeOff,
} from "lucide-react";
import {
loadGallery,
createAlbum,
addPhoto,
addVideo,
deletePhoto,
deleteVideo,
deleteAlbum,
setAlbumPublic,
togglePhotoOnProfile,
toggleVideoOnProfile,
reorderProfileMedia,
type GalleryAlbum,
type GalleryPhoto,
type GalleryVideo,
} from "@/lib/gallery-store";
import {
MAX_PHOTOS_PER_USER,
MAX_VIDEOS_PER_USER,
MAX_ALBUMS_PER_USER,
MAX_PHOTO_SIZE_MB,
MAX_VIDEO_DURATION_SEC,
MAX_PROFILE_MEDIA_SLOTS,
} from "@/lib/limits";
import { getProfileMediaItems, countProfilePinned } from "@/lib/profile-media";
import { PhotoCropModal } from "@/components/PhotoCropModal";
import { useAuth } from "@/lib/auth/context";
import { useAccess } from "@/components/Providers";
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
import { cn } from "@/lib/utils";
interface MediaGalleryManagerProps {
onVerifyClick?: () => void;
}
export function MediaGalleryManager({ onVerifyClick }: MediaGalleryManagerProps) {
const { user } = useAuth();
const { capabilities, blurExplicit } = useAccess();
const [albums, setAlbums] = useState<GalleryAlbum[]>([]);
const [photos, setPhotos] = useState<GalleryPhoto[]>([]);
const [videos, setVideos] = useState<GalleryVideo[]>([]);
const [activeAlbum, setActiveAlbum] = useState<string>("main");
const [newAlbumName, setNewAlbumName] = useState("");
const [showNewAlbum, setShowNewAlbum] = useState(false);
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(true);
const [cropFile, setCropFile] = useState<File | null>(null);
const [cropOpen, setCropOpen] = useState(false);
const photoRef = useRef<HTMLInputElement>(null);
const videoRef = useRef<HTMLInputElement>(null);
useEffect(() => {
let cancelled = false;
(async () => {
const data = await loadGallery(user?.id);
if (!cancelled) {
setAlbums(data.albums);
setPhotos(data.photos);
setVideos(data.videos);
setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [user?.id]);
const refresh = async () => {
const data = await loadGallery(user?.id);
setAlbums(data.albums);
setPhotos(data.photos);
setVideos(data.videos);
setLoading(false);
};
const activeAlbumMeta = albums.find((a) => a.id === activeAlbum);
const albumPhotos = photos.filter((p) =>
activeAlbum === "main" ? !p.album_id || p.album_id === "main" : p.album_id === activeAlbum
);
const albumVideos = videos.filter((v) =>
activeAlbum === "main" ? !v.album_id || v.album_id === "main" : v.album_id === activeAlbum
);
const profileItems = useMemo(
() => getProfileMediaItems(photos, videos),
[photos, videos]
);
const pinnedCount = countProfilePinned(photos, videos);
const handlePhotoPick = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > MAX_PHOTO_SIZE_MB * 1024 * 1024) {
setMessage(`Max file size is ${MAX_PHOTO_SIZE_MB}MB.`);
return;
}
setCropFile(file);
setCropOpen(true);
if (photoRef.current) photoRef.current.value = "";
};
const handleCropConfirm = async (file: File) => {
const { error } = await addPhoto(file, activeAlbum, user?.id);
if (error) setMessage(error);
else {
setMessage("Photo added!");
await refresh();
}
};
const handleVideoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const { error } = await addVideo(file, activeAlbum, user?.id);
if (error) setMessage(error);
else {
setMessage("Video added!");
await refresh();
}
if (videoRef.current) videoRef.current.value = "";
};
const handleCreateAlbum = async () => {
const { album, error } = await createAlbum(newAlbumName, user?.id);
if (error) setMessage(error);
else {
setNewAlbumName("");
setShowNewAlbum(false);
if (album) setActiveAlbum(album.id);
await refresh();
}
};
const moveProfileItem = async (index: number, dir: -1 | 1) => {
const next = [...profileItems];
const target = index + dir;
if (target < 0 || target >= next.length) return;
[next[index], next[target]] = [next[target], next[index]];
await reorderProfileMedia(
next.map((m) => ({ id: m.id, type: m.type })),
user?.id
);
await refresh();
};
const handleToggleProfile = async (
id: string,
type: "photo" | "video",
currentlyOn: boolean
) => {
const { error } =
type === "photo"
? await togglePhotoOnProfile(id, !currentlyOn, user?.id)
: await toggleVideoOnProfile(id, !currentlyOn, user?.id);
if (error) setMessage(error);
else await refresh();
};
if (!capabilities.canUploadPhotos) {
return <GuestUpgradeBanner reason="media" />;
}
if (loading) {
return <p className="text-sm text-muted-foreground">Loading gallery...</p>;
}
return (
<>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-lg flex items-center gap-2">
<ImageIcon className="h-5 w-5 text-primary" />
Photos & Videos
</CardTitle>
<Badge variant="pill" className="text-[10px] shrink-0">
{photos.length}/{MAX_PHOTOS_PER_USER} · {videos.length}/{MAX_VIDEOS_PER_USER} vid
</Badge>
</div>
{blurExplicit && (
<button
type="button"
onClick={onVerifyClick}
className="flex items-center gap-2 text-xs text-amber-400 hover:underline mt-1"
>
<Lock className="h-3 w-3" />
ID verification required to view nude media in your state
</button>
)}
</CardHeader>
<CardContent className="space-y-5">
{/* Profile preview strip */}
<div className="rounded-xl border border-primary/20 bg-pulse-midnight/40 p-3 space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm">Public profile preview</Label>
<span className="text-[10px] text-muted-foreground">
{pinnedCount}/{MAX_PROFILE_MEDIA_SLOTS} slots
</span>
</div>
<p className="text-[10px] text-muted-foreground">
Tap to pin. Reorder with arrows. Others see these first.
</p>
<div className="flex gap-2 overflow-x-auto scrollbar-none">
{profileItems.map((item, i) => (
<div key={item.id} className="relative shrink-0 w-16">
<div className="relative w-16 h-16 rounded-lg overflow-hidden border-2 border-primary/50">
{item.type === "video" ? (
<div className="w-full h-full bg-black flex items-center justify-center">
<Video className="h-5 w-5 text-primary" />
</div>
) : (
<Image
src={item.url}
alt=""
fill
className="object-cover"
unoptimized={item.url.startsWith("data:")}
/>
)}
</div>
<div className="flex justify-center gap-0.5 mt-1">
<button
type="button"
className="p-0.5 text-primary"
onClick={() => moveProfileItem(i, -1)}
disabled={i === 0}
>
<ChevronUp className="h-3 w-3" />
</button>
<button
type="button"
className="p-0.5 text-primary"
onClick={() => moveProfileItem(i, 1)}
disabled={i === profileItems.length - 1}
>
<ChevronDown className="h-3 w-3" />
</button>
</div>
</div>
))}
{profileItems.length === 0 && (
<p className="text-xs text-muted-foreground py-4">Add media below first 5 auto-pin.</p>
)}
</div>
</div>
<Tabs value={activeAlbum} onValueChange={setActiveAlbum}>
<div className="flex items-center gap-2 overflow-x-auto pb-1">
<TabsList className="h-auto flex-wrap justify-start">
{albums.map((a) => (
<TabsTrigger key={a.id} value={a.id} className="text-xs gap-1">
{a.name}
{!a.is_default && (
<button
type="button"
className="ml-1 opacity-50 hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
deleteAlbum(a.id, user?.id).then(refresh);
}}
>
<Trash2 className="h-3 w-3" />
</button>
)}
</TabsTrigger>
))}
</TabsList>
{albums.length < MAX_ALBUMS_PER_USER && (
<Button
variant="pill"
size="sm"
className="shrink-0 h-8 text-xs"
onClick={() => setShowNewAlbum(true)}
>
<FolderPlus className="h-3 w-3 mr-1" />
Album
</Button>
)}
</div>
</Tabs>
{activeAlbumMeta && !activeAlbumMeta.is_default && (
<div className="flex items-center justify-between rounded-lg border border-border px-3 py-2">
<div className="flex items-center gap-2 text-sm">
{activeAlbumMeta.is_public ? (
<Eye className="h-4 w-4 text-primary" />
) : (
<EyeOff className="h-4 w-4 text-muted-foreground" />
)}
<span>Album visible to others</span>
</div>
<Switch
checked={activeAlbumMeta.is_public}
onCheckedChange={(v) =>
setAlbumPublic(activeAlbum, v, user?.id).then(refresh)
}
/>
</div>
)}
{showNewAlbum && (
<div className="flex gap-2">
<Input
placeholder="Album name"
value={newAlbumName}
onChange={(e) => setNewAlbumName(e.target.value)}
className="flex-1"
/>
<Button variant="horny" size="sm" onClick={handleCreateAlbum}>
Create
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowNewAlbum(false)}>
Cancel
</Button>
</div>
)}
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
{albumPhotos.map((photo) => {
const onProfile = photo.show_on_profile;
return (
<div
key={photo.id}
className={cn(
"relative aspect-square rounded-lg overflow-hidden group border-2",
onProfile ? "border-primary/60" : "border-transparent",
blurExplicit && photo.is_explicit && "id-blur"
)}
>
<Image
src={photo.url}
alt=""
fill
className="object-cover explicit-content"
unoptimized={photo.url.startsWith("data:")}
/>
<div className="absolute top-1 left-1 flex gap-0.5">
<button
type="button"
onClick={() => handleToggleProfile(photo.id, "photo", onProfile)}
className={cn(
"p-1 rounded-full bg-black/60",
onProfile ? "text-primary" : "text-white/70"
)}
>
<Star className="h-3 w-3" fill={onProfile ? "currentColor" : "none"} />
</button>
</div>
<button
type="button"
onClick={() => deletePhoto(photo.id, user?.id).then(refresh)}
className="absolute top-1 right-1 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 className="h-3 w-3 text-white" />
</button>
</div>
);
})}
{albumVideos.map((video) => {
const onProfile = video.show_on_profile;
return (
<div
key={video.id}
className={cn(
"relative aspect-square rounded-lg overflow-hidden border-2 bg-black flex flex-col items-center justify-center",
onProfile ? "border-primary/60" : "border-pulse-violet/40"
)}
>
<Video className="h-8 w-8 text-primary mb-1" />
<span className="text-[10px] text-muted-foreground">
{video.duration_sec ? `${Math.round(video.duration_sec)}s` : "video"}
</span>
<button
type="button"
onClick={() => handleToggleProfile(video.id, "video", onProfile)}
className={cn(
"absolute top-1 left-1 p-1 rounded-full bg-black/60",
onProfile ? "text-primary" : "text-white/70"
)}
>
<Star className="h-3 w-3" fill={onProfile ? "currentColor" : "none"} />
</button>
<button
type="button"
onClick={() => deleteVideo(video.id, user?.id).then(refresh)}
className="absolute top-1 right-1 p-1 rounded-full bg-black/60"
>
<Trash2 className="h-3 w-3 text-white" />
</button>
</div>
);
})}
{photos.length < MAX_PHOTOS_PER_USER && (
<button
type="button"
onClick={() => photoRef.current?.click()}
className="aspect-square rounded-lg border-2 border-dashed border-border flex flex-col items-center justify-center gap-1 hover:border-primary/50 transition-colors touch-manipulation"
>
<Plus className="h-6 w-6 text-muted-foreground" />
<span className="text-[10px] text-muted-foreground">Photo</span>
</button>
)}
{videos.length < MAX_VIDEOS_PER_USER && (
<button
type="button"
onClick={() => videoRef.current?.click()}
className="aspect-square rounded-lg border-2 border-dashed border-pulse-violet/30 flex flex-col items-center justify-center gap-1 hover:border-primary/50 transition-colors touch-manipulation"
>
<Video className="h-6 w-6 text-muted-foreground" />
<span className="text-[10px] text-muted-foreground">Video {MAX_VIDEO_DURATION_SEC}s</span>
</button>
)}
</div>
<input
ref={photoRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/heic"
className="hidden"
onChange={handlePhotoPick}
/>
<input
ref={videoRef}
type="file"
accept="video/mp4,video/webm,video/quicktime"
className="hidden"
onChange={handleVideoUpload}
/>
{message && (
<p
className={`text-xs text-center ${message.includes("Maximum") || message.includes("Max") || message.includes("must") ? "text-destructive" : "text-green-400"}`}
>
{message}
</p>
)}
<p className="text-[10px] text-muted-foreground text-center leading-relaxed">
{MAX_PHOTOS_PER_USER} photos · {MAX_VIDEOS_PER_USER} videos ({MAX_VIDEO_DURATION_SEC}s) ·{" "}
{MAX_ALBUMS_PER_USER} albums. Photos auto-optimized on upload. Crop before saving.
</p>
</CardContent>
</Card>
<PhotoCropModal
file={cropFile}
open={cropOpen}
onClose={() => {
setCropOpen(false);
setCropFile(null);
}}
onConfirm={handleCropConfirm}
/>
</>
);
}
/** @deprecated */
export function PhotoGalleryManager(props: MediaGalleryManagerProps) {
return <MediaGalleryManager {...props} />;
}

View File

@@ -0,0 +1,98 @@
"use client";
import Image from "next/image";
import { X, Video } from "lucide-react";
import type { ProfileMediaItem } from "@/types";
import { cn } from "@/lib/utils";
interface MediaLightboxProps {
items: ProfileMediaItem[];
index: number;
open: boolean;
onClose: () => void;
onIndexChange: (i: number) => void;
blur?: boolean;
}
export function MediaLightbox({
items,
index,
open,
onClose,
onIndexChange,
blur,
}: MediaLightboxProps) {
if (!open || !items.length) return null;
const item = items[index];
return (
<div
className="fixed inset-0 z-[3000] bg-black/95 flex flex-col"
onClick={onClose}
>
<div className="flex items-center justify-between px-4 py-3 safe-top">
<span className="text-sm text-white/70">
{index + 1} / {items.length}
</span>
<button type="button" onClick={onClose} className="p-2 text-white">
<X className="h-6 w-6" />
</button>
</div>
<div
className="flex-1 relative mx-4 mb-4"
onClick={(e) => e.stopPropagation()}
>
{item.type === "video" ? (
<video
src={item.url}
controls
playsInline
className="w-full h-full max-h-[70dvh] object-contain mx-auto"
/>
) : (
<div
className={cn(
"relative w-full h-full min-h-[50dvh]",
blur && item.is_explicit && "id-blur"
)}
>
<Image
src={item.url}
alt=""
fill
className="object-contain"
unoptimized
/>
</div>
)}
</div>
<div
className="flex gap-2 overflow-x-auto px-4 pb-4 safe-bottom"
onClick={(e) => e.stopPropagation()}
>
{items.map((m, i) => (
<button
key={m.id}
type="button"
onClick={() => onIndexChange(i)}
className={cn(
"relative shrink-0 w-14 h-14 rounded-lg overflow-hidden border-2",
i === index ? "border-primary" : "border-white/20"
)}
>
{m.type === "video" ? (
<div className="w-full h-full bg-pulse-midnight flex items-center justify-center">
<Video className="h-4 w-4 text-primary" />
</div>
) : (
<Image src={m.url} alt="" fill className="object-cover" unoptimized />
)}
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,147 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cropImageToSquare, optimizeImage } from "@/lib/image-optimize";
interface PhotoCropModalProps {
file: File | null;
open: boolean;
onClose: () => void;
onConfirm: (file: File) => void;
}
export function PhotoCropModal({ file, open, onClose, onConfirm }: PhotoCropModalProps) {
const [preview, setPreview] = useState<string | null>(null);
const [natural, setNatural] = useState({ w: 0, h: 0 });
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const dragRef = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null);
useEffect(() => {
if (!file) {
setPreview(null);
return;
}
const url = URL.createObjectURL(file);
setPreview(url);
setOffset({ x: 0, y: 0 });
setZoom(1);
return () => URL.revokeObjectURL(url);
}, [file]);
const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget;
setNatural({ w: img.naturalWidth, h: img.naturalHeight });
};
const cropSize = Math.min(natural.w, natural.h) / zoom;
const maxX = Math.max(0, natural.w - cropSize);
const maxY = Math.max(0, natural.h - cropSize);
const clampOffset = useCallback(
(x: number, y: number) => ({
x: Math.min(maxX, Math.max(0, x)),
y: Math.min(maxY, Math.max(0, y)),
}),
[maxX, maxY]
);
const handlePointerDown = (e: React.PointerEvent) => {
dragRef.current = { x: e.clientX, y: e.clientY, ox: offset.x, oy: offset.y };
(e.target as HTMLElement).setPointerCapture(e.pointerId);
};
const handlePointerMove = (e: React.PointerEvent) => {
if (!dragRef.current || !natural.w) return;
const scale = 280 / natural.w;
const dx = (e.clientX - dragRef.current.x) / scale;
const dy = (e.clientY - dragRef.current.y) / scale;
setOffset(clampOffset(dragRef.current.ox - dx, dragRef.current.oy - dy));
};
const handlePointerUp = () => {
dragRef.current = null;
};
const handleConfirm = async () => {
if (!file || !natural.w) return;
const cropped = await cropImageToSquare(file, {
x: offset.x,
y: offset.y,
size: cropSize,
});
const optimized = await optimizeImage(cropped);
onConfirm(optimized);
onClose();
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Crop photo</DialogTitle>
<DialogDescription>
Drag to reposition. Pinch-friendly on mobile.
</DialogDescription>
</DialogHeader>
{preview && (
<div
className="relative mx-auto w-[280px] h-[280px] rounded-xl overflow-hidden bg-black touch-none"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
<Image
src={preview}
alt="Crop preview"
width={natural.w || 280}
height={natural.h || 280}
className="absolute top-0 left-0 max-w-none select-none"
style={{
transform: `translate(${-offset.x * (280 / natural.w)}px, ${-offset.y * (280 / natural.h)}px) scale(${280 / natural.w})`,
transformOrigin: "top left",
}}
onLoad={onImageLoad}
unoptimized
draggable={false}
/>
<div className="absolute inset-0 ring-2 ring-primary/80 ring-inset pointer-events-none rounded-xl" />
</div>
)}
<div className="space-y-2">
<label className="text-xs text-muted-foreground">Zoom</label>
<input
type="range"
min={1}
max={2.5}
step={0.05}
value={zoom}
onChange={(e) => setZoom(parseFloat(e.target.value))}
className="w-full accent-primary"
/>
</div>
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={onClose}>
Cancel
</Button>
<Button variant="horny" className="flex-1" onClick={handleConfirm}>
Use photo
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,255 +0,0 @@
"use client";
import { useState, useEffect, useRef } from "react";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Plus, Trash2, ImageIcon, FolderPlus, Lock } from "lucide-react";
import {
loadGallery,
createAlbum,
addPhoto,
deletePhoto,
deleteAlbum,
type GalleryAlbum,
type GalleryPhoto,
} from "@/lib/gallery-store";
import {
MAX_PHOTOS_PER_USER,
MAX_ALBUMS_PER_USER,
MAX_PHOTO_SIZE_MB,
} from "@/lib/limits";
import { useAuth } from "@/lib/auth/context";
import { useAccess } from "@/components/Providers";
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
interface PhotoGalleryManagerProps {
onVerifyClick?: () => void;
}
export function PhotoGalleryManager({ onVerifyClick }: PhotoGalleryManagerProps) {
const { user, profile } = useAuth();
const { capabilities, blurExplicit } = useAccess();
const [albums, setAlbums] = useState<GalleryAlbum[]>([]);
const [photos, setPhotos] = useState<GalleryPhoto[]>([]);
const [activeAlbum, setActiveAlbum] = useState<string>("main");
const [newAlbumName, setNewAlbumName] = useState("");
const [showNewAlbum, setShowNewAlbum] = useState(false);
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(true);
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
let cancelled = false;
(async () => {
const data = await loadGallery(user?.id);
if (!cancelled) {
setAlbums(data.albums);
setPhotos(data.photos);
setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [user?.id]);
const refresh = async () => {
const data = await loadGallery(user?.id);
setAlbums(data.albums);
setPhotos(data.photos);
setLoading(false);
};
const albumPhotos = photos.filter(
(p) =>
activeAlbum === "main"
? !p.album_id || p.album_id === "main"
: p.album_id === activeAlbum
);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > MAX_PHOTO_SIZE_MB * 1024 * 1024) {
setMessage(`Max file size is ${MAX_PHOTO_SIZE_MB}MB.`);
return;
}
const { photo, error } = await addPhoto(file, activeAlbum, user?.id);
if (error) setMessage(error);
else {
setMessage("Photo uploaded!");
await refresh();
}
if (fileRef.current) fileRef.current.value = "";
};
const handleCreateAlbum = async () => {
const { album, error } = await createAlbum(newAlbumName, user?.id);
if (error) setMessage(error);
else {
setNewAlbumName("");
setShowNewAlbum(false);
if (album) setActiveAlbum(album.id);
await refresh();
}
};
const handleDeletePhoto = async (id: string) => {
await deletePhoto(id, user?.id);
await refresh();
};
const handleDeleteAlbum = async (id: string) => {
const { error } = await deleteAlbum(id, user?.id);
if (error) setMessage(error);
else {
setActiveAlbum("main");
await refresh();
}
};
if (!capabilities.canUploadPhotos) {
return <GuestUpgradeBanner reason="media" />;
}
if (loading) {
return <p className="text-sm text-muted-foreground">Loading gallery...</p>;
}
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<ImageIcon className="h-5 w-5 text-horny-pink" />
Photo Gallery
</CardTitle>
<Badge variant="secondary" className="text-[10px]">
{photos.length}/{MAX_PHOTOS_PER_USER} photos · {albums.length}/{MAX_ALBUMS_PER_USER} albums
</Badge>
</div>
{blurExplicit && (
<button
type="button"
onClick={onVerifyClick}
className="flex items-center gap-2 text-xs text-amber-400 hover:underline mt-1"
>
<Lock className="h-3 w-3" />
ID verification required to view nude photos in your state
</button>
)}
</CardHeader>
<CardContent className="space-y-4">
<Tabs value={activeAlbum} onValueChange={setActiveAlbum}>
<div className="flex items-center gap-2 overflow-x-auto pb-1 -mx-1 px-1">
<TabsList className="h-auto flex-wrap justify-start">
{albums.map((a) => (
<TabsTrigger key={a.id} value={a.id} className="text-xs gap-1">
{a.name}
{!a.is_default && (
<button
type="button"
className="ml-1 opacity-50 hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
handleDeleteAlbum(a.id);
}}
>
<Trash2 className="h-3 w-3" />
</button>
)}
</TabsTrigger>
))}
</TabsList>
{albums.length < MAX_ALBUMS_PER_USER && (
<Button
variant="outline"
size="sm"
className="shrink-0 h-8 text-xs"
onClick={() => setShowNewAlbum(true)}
>
<FolderPlus className="h-3 w-3 mr-1" />
Album
</Button>
)}
</div>
</Tabs>
{showNewAlbum && (
<div className="flex gap-2">
<Input
placeholder="Album name"
value={newAlbumName}
onChange={(e) => setNewAlbumName(e.target.value)}
className="flex-1"
/>
<Button variant="horny" size="sm" onClick={handleCreateAlbum}>
Create
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowNewAlbum(false)}>
Cancel
</Button>
</div>
)}
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
{albumPhotos.map((photo) => (
<div
key={photo.id}
className={`relative aspect-square rounded-lg overflow-hidden group ${blurExplicit && photo.is_explicit ? "id-blur" : ""}`}
>
<Image
src={photo.url}
alt="Gallery photo"
fill
className="object-cover explicit-content"
unoptimized={photo.url.startsWith("data:")}
/>
<button
type="button"
onClick={() => handleDeletePhoto(photo.id)}
className="absolute top-1 right-1 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 className="h-3 w-3 text-white" />
</button>
</div>
))}
{photos.length < MAX_PHOTOS_PER_USER && (
<button
type="button"
onClick={() => fileRef.current?.click()}
className="aspect-square rounded-lg border-2 border-dashed border-border flex flex-col items-center justify-center gap-1 hover:border-horny-pink/50 transition-colors"
>
<Plus className="h-6 w-6 text-muted-foreground" />
<span className="text-[10px] text-muted-foreground">Add photo</span>
</button>
)}
</div>
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={handleUpload}
/>
{message && (
<p className={`text-xs text-center ${message.includes("Maximum") || message.includes("Max") ? "text-destructive" : "text-green-400"}`}>
{message}
</p>
)}
<p className="text-[10px] text-muted-foreground text-center">
Up to {MAX_PHOTOS_PER_USER} photos across {MAX_ALBUMS_PER_USER} albums. JPG, PNG, WebP · max {MAX_PHOTO_SIZE_MB}MB each.
</p>
</CardContent>
</Card>
);
}

View File

@@ -17,6 +17,12 @@ import {
shouldBlurExplicit,
type AccessCapabilities,
} from "@/lib/access-tier";
import {
loadFeedInterests,
saveFeedInterests,
mergeFeedWithProfile,
} from "@/lib/feed-preferences";
import { normalizeInterestId } from "@/lib/interests";
const ActiveCountContext = createContext<(n: number) => void>(() => {});
const VanillaModeContext = createContext(false);
@@ -32,6 +38,22 @@ const AccessContext = createContext<AccessContextValue>({
blurExplicit: true,
});
interface FeedPreferencesValue {
feedInterests: string[];
setFeedInterests: (interests: string[]) => void;
toggleFeedInterest: (id: string) => void;
}
const FeedPreferencesContext = createContext<FeedPreferencesValue>({
feedInterests: [],
setFeedInterests: () => {},
toggleFeedInterest: () => {},
});
export function useFeedPreferences() {
return useContext(FeedPreferencesContext);
}
export function useSetActiveCount() {
return useContext(ActiveCountContext);
}
@@ -62,6 +84,7 @@ function InnerProviders({ children }: ProvidersProps) {
const [vanillaMode, setVanillaMode] = useState(false);
const [activeCount, setActiveCount] = useState(0);
const [idModalOpen, setIdModalOpen] = useState(false);
const [feedInterests, setFeedInterestsState] = useState<string[]>([]);
const isLoggedIn = !!user;
const capabilities = useMemo(
@@ -74,8 +97,18 @@ function InnerProviders({ children }: ProvidersProps) {
);
useEffect(() => {
setFeedInterestsState(loadFeedInterests());
setVanillaMode(localStorage.getItem("eg_vanilla_mode") === "true");
const onFeed = (e: Event) => {
const detail = (e as CustomEvent<string[]>).detail;
if (Array.isArray(detail)) setFeedInterestsState(detail);
};
window.addEventListener("eg-feed-interests", onFeed);
return () => window.removeEventListener("eg-feed-interests", onFeed);
}, []);
useEffect(() => {
const state = getUserState();
if (
isLoggedIn &&
@@ -88,6 +121,12 @@ function InnerProviders({ children }: ProvidersProps) {
}
}, [isLoggedIn, profile?.id_verified]);
useEffect(() => {
if (profile?.kinks?.length) {
setFeedInterestsState(mergeFeedWithProfile(profile.kinks));
}
}, [profile?.kinks]);
const handleVanillaToggle = (v: boolean) => {
setVanillaMode(v);
localStorage.setItem("eg_vanilla_mode", String(v));
@@ -101,8 +140,28 @@ function InnerProviders({ children }: ProvidersProps) {
setIdModalOpen(true);
}, []);
const setFeedInterests = useCallback((interests: string[]) => {
const norm = interests.map(normalizeInterestId);
saveFeedInterests(norm);
setFeedInterestsState(norm);
}, []);
const toggleFeedInterest = useCallback((id: string) => {
const norm = normalizeInterestId(id);
setFeedInterestsState((prev) => {
const next = prev.includes(norm)
? prev.filter((x) => x !== norm)
: [...prev, norm];
saveFeedInterests(next);
return next;
});
}, []);
return (
<VanillaModeContext.Provider value={vanillaMode}>
<FeedPreferencesContext.Provider
value={{ feedInterests, setFeedInterests, toggleFeedInterest }}
>
<AccessContext.Provider value={{ capabilities, blurExplicit }}>
<OpenIdVerificationContext.Provider value={openIdVerification}>
<Nav
@@ -135,6 +194,7 @@ function InnerProviders({ children }: ProvidersProps) {
/>
</OpenIdVerificationContext.Provider>
</AccessContext.Provider>
</FeedPreferencesContext.Provider>
</VanillaModeContext.Provider>
);
}

View File

@@ -16,6 +16,8 @@ const badgeVariants = cva(
outline: "border-pulse-border/60 text-foreground bg-pulse-midnight/30",
pill:
"border-primary/30 bg-primary/15 text-pulse-pink-light",
"pill-active":
"border-transparent bg-pulse-gradient-bold text-white shadow-pulse-glow",
top: "border-transparent bg-primary/20 text-pulse-pink-light",
bottom: "border-transparent bg-orange-500/20 text-orange-300",
vers: "border-transparent bg-pulse-violet/25 text-pulse-lavender",