diff --git a/src/app/globals.css b/src/app/globals.css index 62ef96b..934a50a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -318,4 +318,17 @@ :focus-visible { 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; } \ No newline at end of file diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 79ed0df..6b45e51 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -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() {
- -
- {KINK_OPTIONS.map((k) => ( + +

+ Customizes your map feed, tickers, and who you see nearby. +

+
+ {INTEREST_OPTIONS.map((opt) => ( 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} ))}
+ {kinks.length > 0 && ( +
+ +
+ )}
@@ -311,7 +325,7 @@ export default function ProfilePage() { - setIdModalOpen(true)} /> + setIdModalOpen(true)} /> diff --git a/src/components/CruiserProfileView.tsx b/src/components/CruiserProfileView.tsx index 649dbc6..8dcd728 100644 --- a/src/components/CruiserProfileView.tsx +++ b/src/components/CruiserProfileView.tsx @@ -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("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 (
@@ -146,21 +168,23 @@ export function CruiserProfileView({
{/* Hero photo + thumbnails */}
-
openLightbox(activePhoto)} > {name} -
+ {shouldBlurPhoto(photos[activePhoto]?.is_explicit ?? false) && (
@@ -180,15 +204,19 @@ export function CruiserProfileView({ )}
)} - {thumbs.length > 0 && ( -
+ {thumbs.length > 1 && ( +
{thumbs.map((ph, i) => (
+ + setLightboxOpen(false)} + onIndexChange={(i) => { + setLightboxIndex(i); + setActivePhoto(i); + }} + blur={shouldBlurPhoto(mediaItems[lightboxIndex]?.is_explicit ?? false)} + />
); } \ No newline at end of file diff --git a/src/components/InterestPreviewRail.tsx b/src/components/InterestPreviewRail.tsx new file mode 100644 index 0000000..b1b1d47 --- /dev/null +++ b/src/components/InterestPreviewRail.tsx @@ -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 ( +
+

+ Your scene · {profiles.length} nearby +

+
+ {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 ( + + ); + })} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/InterestTicker.tsx b/src/components/InterestTicker.tsx new file mode 100644 index 0000000..a7b8881 --- /dev/null +++ b/src/components/InterestTicker.tsx @@ -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 ( +
+
+ {doubled.map((item, i) => ( + + {item.emoji} + {item.label} + + ))} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/Map.tsx b/src/components/Map.tsx index d7fcc82..13937b9 100644 --- a/src/components/Map.tsx +++ b/src/components/Map.tsx @@ -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(MOCK_PROFILES); const [spots] = useState(MOCK_SPOTS); const [selectedProfile, setSelectedProfile] = useState(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) {
- {KINK_OPTIONS.slice(0, 12).map((k) => ( + {INTEREST_OPTIONS.slice(0, 14).map((opt) => ( toggleKink(k)} + onClick={() => toggleKink(opt.id)} > - {k} + {opt.label} ))}
@@ -275,9 +291,30 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
)} + {/* Feed customization — ticker + preview */} + {feedInterests.length > 0 && ( +
+
+ +
+
+ +
+
+ )} + {/* Guest banner mobile */} {!capabilities.canSendMessages && ( -
+
)} diff --git a/src/components/MediaGalleryManager.tsx b/src/components/MediaGalleryManager.tsx new file mode 100644 index 0000000..0e25edf --- /dev/null +++ b/src/components/MediaGalleryManager.tsx @@ -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([]); + const [photos, setPhotos] = useState([]); + const [videos, setVideos] = useState([]); + const [activeAlbum, setActiveAlbum] = useState("main"); + const [newAlbumName, setNewAlbumName] = useState(""); + const [showNewAlbum, setShowNewAlbum] = useState(false); + const [message, setMessage] = useState(""); + const [loading, setLoading] = useState(true); + const [cropFile, setCropFile] = useState(null); + const [cropOpen, setCropOpen] = useState(false); + const photoRef = useRef(null); + const videoRef = useRef(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) => { + 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) => { + 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 ; + } + + if (loading) { + return

Loading gallery...

; + } + + return ( + <> + + +
+ + + Photos & Videos + + + {photos.length}/{MAX_PHOTOS_PER_USER} · {videos.length}/{MAX_VIDEOS_PER_USER} vid + +
+ {blurExplicit && ( + + )} +
+ + + {/* Profile preview strip */} +
+
+ + + {pinnedCount}/{MAX_PROFILE_MEDIA_SLOTS} slots + +
+

+ Tap ★ to pin. Reorder with arrows. Others see these first. +

+
+ {profileItems.map((item, i) => ( +
+
+ {item.type === "video" ? ( +
+
+ ) : ( + + )} +
+
+ + +
+
+ ))} + {profileItems.length === 0 && ( +

Add media below — first 5 auto-pin.

+ )} +
+
+ + +
+ + {albums.map((a) => ( + + {a.name} + {!a.is_default && ( + + )} + + ))} + + {albums.length < MAX_ALBUMS_PER_USER && ( + + )} +
+
+ + {activeAlbumMeta && !activeAlbumMeta.is_default && ( +
+
+ {activeAlbumMeta.is_public ? ( + + ) : ( + + )} + Album visible to others +
+ + setAlbumPublic(activeAlbum, v, user?.id).then(refresh) + } + /> +
+ )} + + {showNewAlbum && ( +
+ setNewAlbumName(e.target.value)} + className="flex-1" + /> + + +
+ )} + +
+ {albumPhotos.map((photo) => { + const onProfile = photo.show_on_profile; + return ( +
+ +
+ +
+ +
+ ); + })} + + {albumVideos.map((video) => { + const onProfile = video.show_on_profile; + return ( +
+
+ ); + })} + + {photos.length < MAX_PHOTOS_PER_USER && ( + + )} + + {videos.length < MAX_VIDEOS_PER_USER && ( + + )} +
+ + + + + {message && ( +

+ {message} +

+ )} + +

+ {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. +

+
+
+ + { + setCropOpen(false); + setCropFile(null); + }} + onConfirm={handleCropConfirm} + /> + + ); +} + +/** @deprecated */ +export function PhotoGalleryManager(props: MediaGalleryManagerProps) { + return ; +} \ No newline at end of file diff --git a/src/components/MediaLightbox.tsx b/src/components/MediaLightbox.tsx new file mode 100644 index 0000000..858b2a4 --- /dev/null +++ b/src/components/MediaLightbox.tsx @@ -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 ( +
+
+ + {index + 1} / {items.length} + + +
+ +
e.stopPropagation()} + > + {item.type === "video" ? ( +
+ +
e.stopPropagation()} + > + {items.map((m, i) => ( + + ))} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/PhotoCropModal.tsx b/src/components/PhotoCropModal.tsx new file mode 100644 index 0000000..70b29d9 --- /dev/null +++ b/src/components/PhotoCropModal.tsx @@ -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(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) => { + 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 ( + !v && onClose()}> + + + Crop photo + + Drag to reposition. Pinch-friendly on mobile. + + + + {preview && ( +
+ Crop preview +
+
+ )} + +
+ + setZoom(parseFloat(e.target.value))} + className="w-full accent-primary" + /> +
+ +
+ + +
+ +
+ ); +} \ No newline at end of file diff --git a/src/components/PhotoGalleryManager.tsx b/src/components/PhotoGalleryManager.tsx deleted file mode 100644 index 2253c5d..0000000 --- a/src/components/PhotoGalleryManager.tsx +++ /dev/null @@ -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([]); - const [photos, setPhotos] = useState([]); - const [activeAlbum, setActiveAlbum] = useState("main"); - const [newAlbumName, setNewAlbumName] = useState(""); - const [showNewAlbum, setShowNewAlbum] = useState(false); - const [message, setMessage] = useState(""); - const [loading, setLoading] = useState(true); - const fileRef = useRef(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) => { - 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 ; - } - - if (loading) { - return

Loading gallery...

; - } - - return ( - - -
- - - Photo Gallery - - - {photos.length}/{MAX_PHOTOS_PER_USER} photos · {albums.length}/{MAX_ALBUMS_PER_USER} albums - -
- {blurExplicit && ( - - )} -
- - -
- - {albums.map((a) => ( - - {a.name} - {!a.is_default && ( - - )} - - ))} - - {albums.length < MAX_ALBUMS_PER_USER && ( - - )} -
-
- - {showNewAlbum && ( -
- setNewAlbumName(e.target.value)} - className="flex-1" - /> - - -
- )} - -
- {albumPhotos.map((photo) => ( -
- Gallery photo - -
- ))} - - {photos.length < MAX_PHOTOS_PER_USER && ( - - )} -
- - - - {message && ( -

- {message} -

- )} - -

- Up to {MAX_PHOTOS_PER_USER} photos across {MAX_ALBUMS_PER_USER} albums. JPG, PNG, WebP · max {MAX_PHOTO_SIZE_MB}MB each. -

-
-
- ); -} \ No newline at end of file diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx index 682dec9..dfbb5fb 100644 --- a/src/components/Providers.tsx +++ b/src/components/Providers.tsx @@ -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({ blurExplicit: true, }); +interface FeedPreferencesValue { + feedInterests: string[]; + setFeedInterests: (interests: string[]) => void; + toggleFeedInterest: (id: string) => void; +} + +const FeedPreferencesContext = createContext({ + 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([]); 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).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 ( +