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

@@ -319,3 +319,16 @@
outline: 2px solid rgba(249, 43, 155, 0.6);
outline-offset: 2px;
}
@keyframes interest-ticker {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
.animate-interest-ticker {
animation: interest-ticker 28s linear infinite;
}

View File

@@ -8,16 +8,17 @@ import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ShieldCheck, MapPin } from "lucide-react";
import { KINK_OPTIONS } from "@/types";
import { INTEREST_OPTIONS } from "@/lib/interests";
import { InterestTicker } from "@/components/InterestTicker";
import type { Position } from "@/types";
import { useAuth } from "@/lib/auth/context";
import { AuthModal } from "@/components/AuthModal";
import { PhotoGalleryManager } from "@/components/PhotoGalleryManager";
import { MediaGalleryManager } from "@/components/MediaGalleryManager";
import { CannedMessagesPanel } from "@/components/CannedMessagesPanel";
import { IdVerificationModal } from "@/components/IdVerificationModal";
import { LocalGuidePanel } from "@/components/LocalGuidePanel";
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
import { useAccess } from "@/components/Providers";
import { useAccess, useFeedPreferences } from "@/components/Providers";
import {
US_STATES,
getUserState,
@@ -30,6 +31,7 @@ import {
export default function ProfilePage() {
const { user, profile, loading, updateProfile, signOut } = useAuth();
const { capabilities } = useAccess();
const { setFeedInterests } = useFeedPreferences();
const [authOpen, setAuthOpen] = useState(false);
const [idModalOpen, setIdModalOpen] = useState(false);
const [isAnonymous, setIsAnonymous] = useState(true);
@@ -51,7 +53,9 @@ export default function ProfilePage() {
setAge(profile.age?.toString() || "");
setPosition(profile.position);
setBio(profile.bio || "");
setKinks(profile.kinks || []);
const profileKinks = profile.kinks || [];
setKinks(profileKinks);
if (profileKinks.length) setFeedInterests(profileKinks);
setOnPrep(profile.on_prep);
setStiStatus(profile.sti_status || "");
setState(profile.state || getUserState() || "");
@@ -77,9 +81,11 @@ export default function ProfilePage() {
}, [profile]);
const toggleKink = (k: string) => {
setKinks((prev) =>
prev.includes(k) ? prev.filter((x) => x !== k) : [...prev, k]
);
setKinks((prev) => {
const next = prev.includes(k) ? prev.filter((x) => x !== k) : [...prev, k];
setFeedInterests(next);
return next;
});
};
const handleSave = async () => {
@@ -279,19 +285,27 @@ export default function ProfilePage() {
</div>
<div>
<Label className="mb-2 block">Into</Label>
<div className="flex flex-wrap gap-1">
{KINK_OPTIONS.map((k) => (
<Label className="mb-2 block">Your scene pick all that apply</Label>
<p className="text-[10px] text-muted-foreground mb-2">
Customizes your map feed, tickers, and who you see nearby.
</p>
<div className="flex flex-wrap gap-1.5">
{INTEREST_OPTIONS.map((opt) => (
<Badge
key={k}
variant={kinks.includes(k) ? "default" : "outline"}
className="cursor-pointer text-xs min-h-[32px] touch-manipulation"
onClick={() => toggleKink(k)}
key={opt.id}
variant={kinks.includes(opt.id) ? "pill-active" : "pill"}
className="cursor-pointer text-xs min-h-[36px] px-3 touch-manipulation"
onClick={() => toggleKink(opt.id)}
>
{k}
{opt.label}
</Badge>
))}
</div>
{kinks.length > 0 && (
<div className="mt-3">
<InterestTicker interests={kinks} />
</div>
)}
</div>
<div className="flex items-center justify-between">
@@ -311,7 +325,7 @@ export default function ProfilePage() {
</CardContent>
</Card>
<PhotoGalleryManager onVerifyClick={() => setIdModalOpen(true)} />
<MediaGalleryManager onVerifyClick={() => setIdModalOpen(true)} />
<CannedMessagesPanel />

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",

View File

@@ -0,0 +1,40 @@
import { normalizeInterestId } from "@/lib/interests";
const LS_KEY = "eg_feed_interests";
export function loadFeedInterests(): string[] {
if (typeof window === "undefined") return [];
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as string[];
return Array.isArray(parsed) ? parsed.map(normalizeInterestId) : [];
} catch {
return [];
}
}
export function saveFeedInterests(interests: string[]): void {
if (typeof window === "undefined") return;
const unique = [...new Set(interests.map(normalizeInterestId))];
localStorage.setItem(LS_KEY, JSON.stringify(unique));
window.dispatchEvent(new CustomEvent("eg-feed-interests", { detail: unique }));
}
export function toggleFeedInterest(id: string): string[] {
const norm = normalizeInterestId(id);
const current = loadFeedInterests();
const next = current.includes(norm)
? current.filter((x) => x !== norm)
: [...current, norm];
saveFeedInterests(next);
return next;
}
export function mergeFeedWithProfile(profileKinks?: string[] | null): string[] {
const local = loadFeedInterests();
if (!profileKinks?.length) return local;
const merged = [...new Set([...local, ...profileKinks.map(normalizeInterestId)])];
saveFeedInterests(merged);
return merged;
}

View File

@@ -1,27 +1,22 @@
import { createClient } from "@/lib/supabase/client";
import { optimizeImage, getVideoDuration } from "@/lib/image-optimize";
import { countProfilePinned } from "@/lib/profile-media";
import {
MAX_ALBUMS_PER_USER,
MAX_PHOTOS_PER_USER,
MAX_VIDEOS_PER_USER,
MAX_VIDEO_DURATION_SEC,
MAX_VIDEO_SIZE_MB,
MAX_ALBUM_NAME_LENGTH,
MAX_PROFILE_MEDIA_SLOTS,
} from "@/lib/limits";
import type { GalleryAlbum, GalleryPhoto, GalleryVideo } from "@/types";
export interface GalleryAlbum {
id: string;
name: string;
is_default: boolean;
created_at: string;
}
export interface GalleryPhoto {
id: string;
album_id: string | null;
url: string;
is_explicit: boolean;
created_at: string;
}
export type { GalleryAlbum, GalleryPhoto, GalleryVideo };
const LS_ALBUMS = "eg_gallery_albums";
const LS_PHOTOS = "eg_gallery_photos";
const LS_VIDEOS = "eg_gallery_videos";
function uid() {
return crypto.randomUUID();
@@ -41,46 +36,99 @@ function writeLocal<T>(key: string, data: T) {
localStorage.setItem(key, JSON.stringify(data));
}
function defaultPhotoFields(sort: number): Pick<
GalleryPhoto,
"show_on_profile" | "profile_sort_order" | "sort_order"
> {
return {
show_on_profile: sort < MAX_PROFILE_MEDIA_SLOTS,
profile_sort_order: sort < MAX_PROFILE_MEDIA_SLOTS ? sort : 999,
sort_order: sort,
};
}
export function getDefaultAlbums(): GalleryAlbum[] {
return [
{
id: "main",
name: "Main",
is_default: true,
is_public: true,
sort_order: 0,
created_at: new Date().toISOString(),
},
];
}
function normalizeAlbum(a: Partial<GalleryAlbum>): GalleryAlbum {
return {
id: a.id!,
name: a.name!,
is_default: a.is_default ?? false,
is_public: a.is_public ?? true,
sort_order: a.sort_order ?? 0,
created_at: a.created_at ?? new Date().toISOString(),
};
}
function normalizePhoto(p: Partial<GalleryPhoto>, index: number): GalleryPhoto {
const defaults = defaultPhotoFields(index);
return {
id: p.id!,
album_id: p.album_id ?? null,
url: p.url!,
is_explicit: p.is_explicit ?? true,
show_on_profile: p.show_on_profile ?? defaults.show_on_profile,
profile_sort_order: p.profile_sort_order ?? defaults.profile_sort_order,
sort_order: p.sort_order ?? index,
created_at: p.created_at ?? new Date().toISOString(),
};
}
function normalizeVideo(v: Partial<GalleryVideo>, index: number): GalleryVideo {
return {
id: v.id!,
album_id: v.album_id ?? null,
url: v.url!,
thumbnail_url: v.thumbnail_url ?? null,
duration_sec: v.duration_sec ?? null,
is_explicit: v.is_explicit ?? true,
show_on_profile: v.show_on_profile ?? false,
profile_sort_order: v.profile_sort_order ?? 999,
sort_order: v.sort_order ?? index,
created_at: v.created_at ?? new Date().toISOString(),
};
}
export async function loadGallery(userId?: string | null): Promise<{
albums: GalleryAlbum[];
photos: GalleryPhoto[];
videos: GalleryVideo[];
}> {
if (!userId) {
const albums = readLocal<GalleryAlbum[]>(LS_ALBUMS, getDefaultAlbums());
const photos = readLocal<GalleryPhoto[]>(LS_PHOTOS, []);
return { albums: albums.length ? albums : getDefaultAlbums(), photos };
const albums = readLocal<GalleryAlbum[]>(LS_ALBUMS, getDefaultAlbums()).map(normalizeAlbum);
const photos = readLocal<GalleryPhoto[]>(LS_PHOTOS, []).map((p, i) => normalizePhoto(p, i));
const videos = readLocal<GalleryVideo[]>(LS_VIDEOS, []).map((v, i) => normalizeVideo(v, i));
return {
albums: albums.length ? albums : getDefaultAlbums(),
photos,
videos,
};
}
const supabase = createClient();
const [{ data: albums }, { data: photos }] = await Promise.all([
supabase
.from("gallery_albums")
.select("*")
.eq("user_id", userId)
.order("created_at"),
supabase
.from("gallery_photos")
.select("*")
.eq("user_id", userId)
.order("created_at"),
const [{ data: albums }, { data: photos }, { data: videos }] = await Promise.all([
supabase.from("gallery_albums").select("*").eq("user_id", userId).order("sort_order"),
supabase.from("gallery_photos").select("*").eq("user_id", userId).order("sort_order"),
supabase.from("gallery_videos").select("*").eq("user_id", userId).order("sort_order"),
]);
return {
albums: (albums as GalleryAlbum[])?.length
? (albums as GalleryAlbum[])
? (albums as GalleryAlbum[]).map(normalizeAlbum)
: getDefaultAlbums(),
photos: (photos as GalleryPhoto[]) ?? [],
photos: ((photos as GalleryPhoto[]) ?? []).map((p, i) => normalizePhoto(p, i)),
videos: ((videos as GalleryVideo[]) ?? []).map((v, i) => normalizeVideo(v, i)),
};
}
@@ -96,12 +144,14 @@ export async function createAlbum(
if (albums.length >= MAX_ALBUMS_PER_USER) {
return { error: `Maximum ${MAX_ALBUMS_PER_USER} albums allowed.` };
}
const album: GalleryAlbum = {
const album = normalizeAlbum({
id: uid(),
name: trimmed,
is_default: false,
is_public: true,
sort_order: albums.length,
created_at: new Date().toISOString(),
};
});
writeLocal(LS_ALBUMS, [...albums, album]);
return { album };
}
@@ -118,12 +168,36 @@ export async function createAlbum(
const { data, error } = await supabase
.from("gallery_albums")
.insert({ user_id: userId, name: trimmed })
.insert({ user_id: userId, name: trimmed, is_public: true, sort_order: count ?? 0 })
.select()
.single();
if (error) return { error: error.message };
return { album: data as GalleryAlbum };
return { album: normalizeAlbum(data as GalleryAlbum) };
}
export async function setAlbumPublic(
albumId: string,
isPublic: boolean,
userId?: string | null
): Promise<{ error?: string }> {
if (!userId) {
const albums = readLocal<GalleryAlbum[]>(LS_ALBUMS, getDefaultAlbums());
writeLocal(
LS_ALBUMS,
albums.map((a) => (a.id === albumId ? { ...a, is_public: isPublic } : a))
);
return {};
}
const supabase = createClient();
const { error } = await supabase
.from("gallery_albums")
.update({ is_public: isPublic })
.eq("id", albumId)
.eq("user_id", userId);
return error ? { error: error.message } : {};
}
export async function addPhoto(
@@ -131,19 +205,24 @@ export async function addPhoto(
albumId: string | null,
userId?: string | null
): Promise<{ photo?: GalleryPhoto; error?: string }> {
const optimized = await optimizeImage(file);
if (!userId) {
const photos = readLocal<GalleryPhoto[]>(LS_PHOTOS, []);
if (photos.length >= MAX_PHOTOS_PER_USER) {
return { error: `Maximum ${MAX_PHOTOS_PER_USER} photos allowed.` };
}
const url = await fileToDataUrl(file);
const photo: GalleryPhoto = {
id: uid(),
album_id: albumId,
url,
is_explicit: true,
created_at: new Date().toISOString(),
};
const url = await fileToDataUrl(optimized);
const photo = normalizePhoto(
{
id: uid(),
album_id: albumId,
url,
is_explicit: true,
created_at: new Date().toISOString(),
},
photos.length
);
writeLocal(LS_PHOTOS, [...photos, photo]);
return { photo };
}
@@ -158,12 +237,11 @@ export async function addPhoto(
return { error: `Maximum ${MAX_PHOTOS_PER_USER} photos allowed.` };
}
const ext = file.name.split(".").pop() || "jpg";
const ext = "jpg";
const path = `${userId}/${uid()}.${ext}`;
const { error: uploadError } = await supabase.storage
.from("gallery")
.upload(path, file, { upsert: false });
.upload(path, optimized, { upsert: false, contentType: "image/jpeg" });
if (uploadError) return { error: uploadError.message };
@@ -171,6 +249,10 @@ export async function addPhoto(
data: { publicUrl },
} = supabase.storage.from("gallery").getPublicUrl(path);
const existing = await loadGallery(userId);
const pinned = countProfilePinned(existing.photos, existing.videos);
const showOnProfile = pinned < MAX_PROFILE_MEDIA_SLOTS;
const { data, error } = await supabase
.from("gallery_photos")
.insert({
@@ -178,12 +260,252 @@ export async function addPhoto(
album_id: albumId === "main" ? null : albumId,
url: publicUrl,
is_explicit: true,
show_on_profile: showOnProfile,
profile_sort_order: showOnProfile ? pinned : 999,
sort_order: count ?? 0,
})
.select()
.single();
if (error) return { error: error.message };
return { photo: data as GalleryPhoto };
return { photo: normalizePhoto(data as GalleryPhoto, count ?? 0) };
}
export async function addVideo(
file: File,
albumId: string | null,
userId?: string | null
): Promise<{ video?: GalleryVideo; error?: string }> {
if (file.size > MAX_VIDEO_SIZE_MB * 1024 * 1024) {
return { error: `Max video size is ${MAX_VIDEO_SIZE_MB}MB.` };
}
let duration: number;
try {
duration = await getVideoDuration(file);
} catch {
return { error: "Could not read video file." };
}
if (duration > MAX_VIDEO_DURATION_SEC + 0.5) {
return { error: `Videos must be ${MAX_VIDEO_DURATION_SEC} seconds or less.` };
}
if (!userId) {
const videos = readLocal<GalleryVideo[]>(LS_VIDEOS, []);
if (videos.length >= MAX_VIDEOS_PER_USER) {
return { error: `Maximum ${MAX_VIDEOS_PER_USER} videos allowed.` };
}
const url = await fileToDataUrl(file);
const video = normalizeVideo(
{
id: uid(),
album_id: albumId,
url,
duration_sec: duration,
is_explicit: true,
created_at: new Date().toISOString(),
},
videos.length
);
writeLocal(LS_VIDEOS, [...videos, video]);
return { video };
}
const supabase = createClient();
const { count } = await supabase
.from("gallery_videos")
.select("*", { count: "exact", head: true })
.eq("user_id", userId);
if ((count ?? 0) >= MAX_VIDEOS_PER_USER) {
return { error: `Maximum ${MAX_VIDEOS_PER_USER} videos allowed.` };
}
const ext = file.name.split(".").pop() || "mp4";
const path = `${userId}/videos/${uid()}.${ext}`;
const { error: uploadError } = await supabase.storage
.from("gallery")
.upload(path, file, { upsert: false, contentType: file.type });
if (uploadError) return { error: uploadError.message };
const {
data: { publicUrl },
} = supabase.storage.from("gallery").getPublicUrl(path);
const { data, error } = await supabase
.from("gallery_videos")
.insert({
user_id: userId,
album_id: albumId === "main" ? null : albumId,
url: publicUrl,
duration_sec: duration,
is_explicit: true,
sort_order: count ?? 0,
})
.select()
.single();
if (error) return { error: error.message };
return { video: normalizeVideo(data as GalleryVideo, count ?? 0) };
}
export async function updatePhotoProfileMeta(
photoId: string,
updates: Partial<Pick<GalleryPhoto, "show_on_profile" | "profile_sort_order" | "sort_order">>,
userId?: string | null
): Promise<{ error?: string }> {
if (!userId) {
const photos = readLocal<GalleryPhoto[]>(LS_PHOTOS, []);
writeLocal(
LS_PHOTOS,
photos.map((p) => (p.id === photoId ? { ...p, ...updates } : p))
);
return {};
}
const supabase = createClient();
const { error } = await supabase
.from("gallery_photos")
.update(updates)
.eq("id", photoId)
.eq("user_id", userId);
return error ? { error: error.message } : {};
}
export async function reorderProfileMedia(
orderedIds: { id: string; type: "photo" | "video" }[],
userId?: string | null
): Promise<{ error?: string }> {
const updates = orderedIds.map((item, index) => ({
...item,
show_on_profile: true,
profile_sort_order: index,
}));
if (!userId) {
const photos = readLocal<GalleryPhoto[]>(LS_PHOTOS, []);
const videos = readLocal<GalleryVideo[]>(LS_VIDEOS, []);
const photoMap = new Map(updates.filter((u) => u.type === "photo").map((u) => [u.id, u]));
const videoMap = new Map(updates.filter((u) => u.type === "video").map((u) => [u.id, u]));
writeLocal(
LS_PHOTOS,
photos.map((p) => {
const u = photoMap.get(p.id);
if (u) return { ...p, show_on_profile: true, profile_sort_order: u.profile_sort_order };
return { ...p, show_on_profile: false, profile_sort_order: 999 };
})
);
writeLocal(
LS_VIDEOS,
videos.map((v) => {
const u = videoMap.get(v.id);
if (u) return { ...v, show_on_profile: true, profile_sort_order: u.profile_sort_order };
return { ...v, show_on_profile: false, profile_sort_order: 999 };
})
);
return {};
}
const supabase = createClient();
for (const u of updates) {
const table = u.type === "photo" ? "gallery_photos" : "gallery_videos";
await supabase
.from(table)
.update({ show_on_profile: true, profile_sort_order: u.profile_sort_order })
.eq("id", u.id)
.eq("user_id", userId);
}
const allPhotoIds = new Set(updates.filter((u) => u.type === "photo").map((u) => u.id));
const allVideoIds = new Set(updates.filter((u) => u.type === "video").map((u) => u.id));
const gallery = await loadGallery(userId);
for (const p of gallery.photos) {
if (!allPhotoIds.has(p.id)) {
await supabase
.from("gallery_photos")
.update({ show_on_profile: false, profile_sort_order: 999 })
.eq("id", p.id);
}
}
for (const v of gallery.videos) {
if (!allVideoIds.has(v.id)) {
await supabase
.from("gallery_videos")
.update({ show_on_profile: false, profile_sort_order: 999 })
.eq("id", v.id);
}
}
return {};
}
export async function updateVideoProfileMeta(
videoId: string,
updates: Partial<Pick<GalleryVideo, "show_on_profile" | "profile_sort_order">>,
userId?: string | null
): Promise<{ error?: string }> {
if (!userId) {
const videos = readLocal<GalleryVideo[]>(LS_VIDEOS, []);
writeLocal(
LS_VIDEOS,
videos.map((v) => (v.id === videoId ? { ...v, ...updates } : v))
);
return {};
}
const supabase = createClient();
const { error } = await supabase
.from("gallery_videos")
.update(updates)
.eq("id", videoId)
.eq("user_id", userId);
return error ? { error: error.message } : {};
}
export async function toggleVideoOnProfile(
videoId: string,
on: boolean,
userId?: string | null
): Promise<{ error?: string }> {
const gallery = await loadGallery(userId);
const pinned = countProfilePinned(gallery.photos, gallery.videos);
if (on && pinned >= MAX_PROFILE_MEDIA_SLOTS) {
return { error: `Maximum ${MAX_PROFILE_MEDIA_SLOTS} items on public profile.` };
}
return updateVideoProfileMeta(
videoId,
{
show_on_profile: on,
profile_sort_order: on ? pinned : 999,
},
userId
);
}
export async function togglePhotoOnProfile(
photoId: string,
on: boolean,
userId?: string | null
): Promise<{ error?: string }> {
const gallery = await loadGallery(userId);
const pinned = countProfilePinned(gallery.photos, gallery.videos);
if (on && pinned >= MAX_PROFILE_MEDIA_SLOTS) {
return { error: `Maximum ${MAX_PROFILE_MEDIA_SLOTS} items on public profile.` };
}
return updatePhotoProfileMeta(
photoId,
{
show_on_profile: on,
profile_sort_order: on ? pinned : 999,
},
userId
);
}
export async function deletePhoto(
@@ -209,6 +531,29 @@ export async function deletePhoto(
return error ? { error: error.message } : {};
}
export async function deleteVideo(
videoId: string,
userId?: string | null
): Promise<{ error?: string }> {
if (!userId) {
const videos = readLocal<GalleryVideo[]>(LS_VIDEOS, []);
writeLocal(
LS_VIDEOS,
videos.filter((v) => v.id !== videoId)
);
return {};
}
const supabase = createClient();
const { error } = await supabase
.from("gallery_videos")
.delete()
.eq("id", videoId)
.eq("user_id", userId);
return error ? { error: error.message } : {};
}
export async function deleteAlbum(
albumId: string,
userId?: string | null
@@ -218,6 +563,7 @@ export async function deleteAlbum(
if (!userId) {
const albums = readLocal<GalleryAlbum[]>(LS_ALBUMS, getDefaultAlbums());
const photos = readLocal<GalleryPhoto[]>(LS_PHOTOS, []);
const videos = readLocal<GalleryVideo[]>(LS_VIDEOS, []);
writeLocal(
LS_ALBUMS,
albums.filter((a) => a.id !== albumId)
@@ -228,6 +574,12 @@ export async function deleteAlbum(
p.album_id === albumId ? { ...p, album_id: "main" } : p
)
);
writeLocal(
LS_VIDEOS,
videos.map((v) =>
v.album_id === albumId ? { ...v, album_id: "main" } : v
)
);
return {};
}

87
src/lib/image-optimize.ts Normal file
View File

@@ -0,0 +1,87 @@
import { IMAGE_OPTIMIZE_MAX_PX, IMAGE_OPTIMIZE_QUALITY } from "@/lib/limits";
export async function optimizeImage(file: File): Promise<File> {
if (!file.type.startsWith("image/")) return file;
const bitmap = await createImageBitmap(file);
let { width, height } = bitmap;
const max = IMAGE_OPTIMIZE_MAX_PX;
if (width > max || height > max) {
const scale = max / Math.max(width, height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) return file;
ctx.drawImage(bitmap, 0, 0, width, height);
bitmap.close();
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/jpeg", IMAGE_OPTIMIZE_QUALITY)
);
if (!blob) return file;
const base = file.name.replace(/\.[^.]+$/, "") || "photo";
return new File([blob], `${base}.jpg`, { type: "image/jpeg", lastModified: Date.now() });
}
export async function cropImageToSquare(
file: File,
crop: { x: number; y: number; size: number }
): Promise<File> {
const bitmap = await createImageBitmap(file);
const canvas = document.createElement("canvas");
const out = Math.min(1200, Math.round(crop.size));
canvas.width = out;
canvas.height = out;
const ctx = canvas.getContext("2d");
if (!ctx) {
bitmap.close();
return file;
}
ctx.drawImage(
bitmap,
crop.x,
crop.y,
crop.size,
crop.size,
0,
0,
out,
out
);
bitmap.close();
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/jpeg", IMAGE_OPTIMIZE_QUALITY)
);
if (!blob) return file;
const base = file.name.replace(/\.[^.]+$/, "") || "photo";
return new File([blob], `${base}.jpg`, { type: "image/jpeg", lastModified: Date.now() });
}
export function getVideoDuration(file: File): Promise<number> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const video = document.createElement("video");
video.preload = "metadata";
video.onloadedmetadata = () => {
URL.revokeObjectURL(url);
resolve(video.duration);
};
video.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("Could not read video"));
};
video.src = url;
});
}

57
src/lib/interests.ts Normal file
View File

@@ -0,0 +1,57 @@
/** Scene / interest tags — unlimited selection, powers feed + map filters. */
export interface InterestOption {
id: string;
label: string;
tickerEmoji?: string;
}
export const INTEREST_OPTIONS: InterestOption[] = [
{ id: "exhibitionist", label: "Exhibitionist", tickerEmoji: "👀" },
{ id: "daddy", label: "Daddies", tickerEmoji: "🧔" },
{ id: "twinks", label: "Twinks", tickerEmoji: "✨" },
{ id: "leather", label: "Leather", tickerEmoji: "🖤" },
{ id: "anon", label: "Anonymous", tickerEmoji: "🎭" },
{ id: "bondage", label: "Bondage" },
{ id: "chastity", label: "Chastity" },
{ id: "cruising", label: "Cruising" },
{ id: "feet", label: "Feet" },
{ id: "fisting", label: "Fisting" },
{ id: "group", label: "Group" },
{ id: "oral", label: "Oral" },
{ id: "pnp", label: "PnP" },
{ id: "pup", label: "Pup" },
{ id: "raw", label: "Raw" },
{ id: "rimming", label: "Rimming" },
{ id: "roleplay", label: "Roleplay" },
{ id: "spanking", label: "Spanking" },
{ id: "underwear", label: "Underwear" },
{ id: "watersports", label: "Watersports" },
];
const LABEL_MAP = new Map(INTEREST_OPTIONS.map((o) => [o.id, o.label]));
export function interestLabel(id: string): string {
return LABEL_MAP.get(id) ?? id.replace(/_/g, " ");
}
/** @deprecated use INTEREST_OPTIONS */
export const KINK_OPTIONS = INTEREST_OPTIONS.map((o) => o.id);
export function normalizeInterestId(raw: string): string {
const lower = raw.toLowerCase().trim();
if (lower === "bdsm") return "leather";
if (lower === "exhibitionism") return "exhibitionist";
if (lower === "daddies") return "daddy";
if (lower === "twink") return "twinks";
return lower;
}
export function profileMatchesInterests(
profileInterests: string[],
selected: string[]
): boolean {
if (!selected.length) return true;
const normalized = new Set(profileInterests.map(normalizeInterestId));
return selected.some((s) => normalized.has(normalizeInterestId(s)));
}

View File

@@ -1,7 +1,13 @@
/** Hard product limits — enforced in UI and DB triggers */
export const MAX_PHOTOS_PER_USER = 15;
export const MAX_PHOTOS_PER_USER = 20;
export const MAX_VIDEOS_PER_USER = 5;
export const MAX_VIDEO_DURATION_SEC = 30;
export const MAX_ALBUMS_PER_USER = 5;
export const MAX_PROFILE_MEDIA_SLOTS = 5;
export const MAX_CANNED_MESSAGES = 20;
export const MAX_PHOTO_SIZE_MB = 8;
export const MAX_PHOTO_SIZE_MB = 12;
export const MAX_VIDEO_SIZE_MB = 80;
export const MAX_CANNED_MESSAGE_LENGTH = 500;
export const MAX_ALBUM_NAME_LENGTH = 40;
export const IMAGE_OPTIMIZE_MAX_PX = 1920;
export const IMAGE_OPTIMIZE_QUALITY = 0.85;

View File

@@ -29,7 +29,7 @@ export const MOCK_PROFILES: Profile[] = [
status: "hosting",
is_anonymous: false,
is_verified: true,
kinks: ["oral", "rimming", "daddy", "exhibitionism"],
kinks: ["oral", "rimming", "daddy", "exhibitionist", "leather"],
sti_status: "Negative, On PrEP",
on_prep: true,
last_active: new Date().toISOString(),
@@ -115,7 +115,7 @@ export const MOCK_PROFILES: Profile[] = [
status: "online",
is_anonymous: false,
is_verified: true,
kinks: ["leather", "bdsm", "oral", "fisting"],
kinks: ["leather", "oral", "fisting", "twinks"],
sti_status: "Negative",
on_prep: true,
last_active: new Date(Date.now() - 300000).toISOString(),
@@ -255,7 +255,7 @@ export const MOCK_PROFILES: Profile[] = [
status: "looking",
is_anonymous: false,
is_verified: true,
kinks: ["anon", "oral", "exhibitionism"],
kinks: ["anon", "oral", "exhibitionist", "twinks"],
sti_status: "Negative",
on_prep: false,
last_active: new Date(Date.now() - 90000).toISOString(),

50
src/lib/profile-media.ts Normal file
View File

@@ -0,0 +1,50 @@
import { MAX_PROFILE_MEDIA_SLOTS } from "@/lib/limits";
import type { GalleryPhoto, GalleryVideo, ProfileMediaItem } from "@/types";
export function getProfileMediaItems(
photos: GalleryPhoto[],
videos: GalleryVideo[] = []
): ProfileMediaItem[] {
const photoItems: ProfileMediaItem[] = photos.map((p) => ({
id: p.id,
type: "photo" as const,
url: p.url,
thumbnail_url: p.url,
is_explicit: p.is_explicit,
show_on_profile: p.show_on_profile,
profile_sort_order: p.profile_sort_order,
sort_order: p.sort_order,
created_at: p.created_at,
}));
const videoItems: ProfileMediaItem[] = videos.map((v) => ({
id: v.id,
type: "video" as const,
url: v.url,
thumbnail_url: v.thumbnail_url,
is_explicit: v.is_explicit,
show_on_profile: v.show_on_profile,
profile_sort_order: v.profile_sort_order,
sort_order: v.sort_order,
duration_sec: v.duration_sec,
created_at: v.created_at,
}));
const all = [...photoItems, ...videoItems];
const pinned = all
.filter((m) => m.show_on_profile)
.sort((a, b) => a.profile_sort_order - b.profile_sort_order);
if (pinned.length) return pinned.slice(0, MAX_PROFILE_MEDIA_SLOTS);
return all
.sort((a, b) => a.sort_order - b.sort_order || a.created_at.localeCompare(b.created_at))
.slice(0, MAX_PROFILE_MEDIA_SLOTS);
}
export function countProfilePinned(photos: GalleryPhoto[], videos: GalleryVideo[] = []) {
return (
photos.filter((p) => p.show_on_profile).length +
videos.filter((v) => v.show_on_profile).length
);
}

View File

@@ -57,6 +57,8 @@ export interface GalleryAlbum {
user_id?: string;
name: string;
is_default: boolean;
is_public: boolean;
sort_order: number;
created_at: string;
}
@@ -66,6 +68,36 @@ export interface GalleryPhoto {
album_id: string | null;
url: string;
is_explicit: boolean;
show_on_profile: boolean;
profile_sort_order: number;
sort_order: number;
created_at: string;
}
export interface GalleryVideo {
id: string;
user_id?: string;
album_id: string | null;
url: string;
thumbnail_url: string | null;
duration_sec: number | null;
is_explicit: boolean;
show_on_profile: boolean;
profile_sort_order: number;
sort_order: number;
created_at: string;
}
export interface ProfileMediaItem {
id: string;
type: "photo" | "video";
url: string;
thumbnail_url: string | null;
is_explicit: boolean;
show_on_profile: boolean;
profile_sort_order: number;
sort_order: number;
duration_sec?: number | null;
created_at: string;
}
@@ -147,17 +179,19 @@ export interface MapFilters {
onlineOnly: boolean;
}
/** @deprecated import INTEREST_OPTIONS from @/lib/interests */
export const KINK_OPTIONS = [
"exhibitionist",
"daddy",
"twinks",
"leather",
"anon",
"bdsm",
"bondage",
"chastity",
"cruising",
"daddy",
"feet",
"fisting",
"group",
"leather",
"oral",
"pnp",
"pup",
@@ -167,7 +201,6 @@ export const KINK_OPTIONS = [
"spanking",
"underwear",
"watersports",
"ws",
] as const;
export const POSITION_COLORS: Record<Position, string> = {

View File

@@ -0,0 +1,71 @@
-- ExposedGays v3: 20 photos, videos, profile media order, album visibility
-- Run on SUPABASE_01 after migration_v2.sql
-- Photo limit 20
CREATE OR REPLACE FUNCTION public.enforce_gallery_photo_limit()
RETURNS TRIGGER AS $$
BEGIN
IF (SELECT COUNT(*) FROM public.gallery_photos WHERE user_id = NEW.user_id) >= 20 THEN
RAISE EXCEPTION 'Maximum 20 photos per user';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Album visibility + sort
ALTER TABLE public.gallery_albums
ADD COLUMN IF NOT EXISTS is_public BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;
-- Photo profile curation
ALTER TABLE public.gallery_photos
ADD COLUMN IF NOT EXISTS show_on_profile BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS profile_sort_order INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;
-- Videos (max 5 per user, 30s)
CREATE TABLE IF NOT EXISTS public.gallery_videos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
album_id UUID REFERENCES public.gallery_albums(id) ON DELETE SET NULL,
url TEXT NOT NULL,
thumbnail_url TEXT,
duration_sec NUMERIC(6,2),
is_explicit BOOLEAN NOT NULL DEFAULT true,
show_on_profile BOOLEAN NOT NULL DEFAULT false,
profile_sort_order INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_gallery_videos_user ON public.gallery_videos (user_id);
CREATE OR REPLACE FUNCTION public.enforce_gallery_video_limit()
RETURNS TRIGGER AS $$
BEGIN
IF (SELECT COUNT(*) FROM public.gallery_videos WHERE user_id = NEW.user_id) >= 5 THEN
RAISE EXCEPTION 'Maximum 5 videos per user';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_gallery_video_limit ON public.gallery_videos;
CREATE TRIGGER trg_gallery_video_limit
BEFORE INSERT ON public.gallery_videos
FOR EACH ROW EXECUTE FUNCTION public.enforce_gallery_video_limit();
ALTER TABLE public.gallery_videos ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Videos viewable when album public or owner"
ON public.gallery_videos FOR SELECT USING (
auth.uid() = user_id
OR album_id IS NULL
OR EXISTS (
SELECT 1 FROM public.gallery_albums a
WHERE a.id = album_id AND a.is_public = true
)
);
CREATE POLICY "Users manage own videos"
ON public.gallery_videos FOR ALL USING (auth.uid() = user_id);