489 lines
17 KiB
TypeScript
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} />;
|
|
} |