Files
exposedgays/src/components/MediaGalleryManager.tsx

489 lines
17 KiB
TypeScript

"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} />;
}