Add interest feed customization, 20-photo gallery with crop, videos, and profile media curation
This commit is contained in:
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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user