Add interest feed customization, 20-photo gallery with crop, videos, and profile media curation
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
40
src/lib/feed-preferences.ts
Normal file
40
src/lib/feed-preferences.ts
Normal 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;
|
||||
}
|
||||
@@ -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
87
src/lib/image-optimize.ts
Normal 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
57
src/lib/interests.ts
Normal 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)));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
50
src/lib/profile-media.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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> = {
|
||||
|
||||
71
supabase/migration_v3_media.sql
Normal file
71
supabase/migration_v3_media.sql
Normal 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);
|
||||
Reference in New Issue
Block a user