Add interest feed customization, 20-photo gallery with crop, videos, and profile media curation
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
58
src/components/InterestPreviewRail.tsx
Normal file
58
src/components/InterestPreviewRail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
src/components/InterestTicker.tsx
Normal file
42
src/components/InterestTicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
489
src/components/MediaGalleryManager.tsx
Normal file
489
src/components/MediaGalleryManager.tsx
Normal 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} />;
|
||||
}
|
||||
98
src/components/MediaLightbox.tsx
Normal file
98
src/components/MediaLightbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
src/components/PhotoCropModal.tsx
Normal file
147
src/components/PhotoCropModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user