400 lines
14 KiB
TypeScript
400 lines
14 KiB
TypeScript
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
|
import { supabase } from '@/integrations/supabase/client'; // Still needed for collections (no API yet)
|
|
import { fetchPictures as fetchPicturesAPI } from '@/modules/posts/client-pictures';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { T, translate } from '@/i18n';
|
|
import { Search, Image as ImageIcon, Check, Tag, FolderOpen, X } from 'lucide-react';
|
|
import { useAuth } from '@/hooks/useAuth';
|
|
import MediaCard from '@/components/MediaCard';
|
|
import { MediaType } from '@/lib/mediaRegistry';
|
|
|
|
interface ImagePickerDialogProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onSelect?: (pictureId: string) => void;
|
|
onSelectPicture?: (picture: Picture) => void;
|
|
onMultiSelect?: (pictureIds: string[]) => void;
|
|
onMultiSelectPictures?: (pictures: Picture[]) => void;
|
|
currentValue?: string | null;
|
|
currentValues?: string[];
|
|
multiple?: boolean;
|
|
}
|
|
interface Picture {
|
|
id: string;
|
|
title: string;
|
|
image_url: string;
|
|
thumbnail_url?: string;
|
|
type?: string;
|
|
user_id: string;
|
|
tags: string[] | null;
|
|
meta: any;
|
|
}
|
|
|
|
interface Collection {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
}
|
|
|
|
export const ImagePickerDialog: React.FC<ImagePickerDialogProps> = ({
|
|
isOpen,
|
|
onClose,
|
|
onSelect,
|
|
onMultiSelect,
|
|
onMultiSelectPictures,
|
|
currentValue,
|
|
currentValues = [],
|
|
multiple = false,
|
|
onSelectPicture
|
|
}) => {
|
|
const { user } = useAuth();
|
|
const [pictures, setPictures] = useState<Picture[]>([]);
|
|
const [allTags, setAllTags] = useState<string[]>([]);
|
|
const [collections, setCollections] = useState<Collection[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
|
const [selectedCollections, setSelectedCollections] = useState<string[]>([]);
|
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
const [selectedId, setSelectedId] = useState<string | null>(currentValue || null);
|
|
const prevIsOpen = useRef(false);
|
|
|
|
// Initial data fetch - only runs when dialog opens
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
fetchPictures(); // Also extracts tags
|
|
fetchCollections();
|
|
}
|
|
}, [isOpen]);
|
|
|
|
// Sync props to local state - only when dialog first opens (not on every render)
|
|
useEffect(() => {
|
|
const justOpened = isOpen && !prevIsOpen.current;
|
|
prevIsOpen.current = isOpen;
|
|
|
|
if (justOpened) {
|
|
if (multiple) {
|
|
setSelectedIds(currentValues || []);
|
|
} else {
|
|
setSelectedId(currentValue || null);
|
|
}
|
|
}
|
|
}, [isOpen, currentValue, currentValues, multiple]);
|
|
|
|
const fetchPictures = async () => {
|
|
setLoading(true);
|
|
try {
|
|
// Fetch via API — returns all pictures, we filter client-side
|
|
const data = await fetchPicturesAPI({ limit: 100 });
|
|
// Filter to only selected pictures
|
|
const selected = (data || []).filter((p: any) => p.is_selected);
|
|
setPictures(selected);
|
|
|
|
// Extract unique tags from the same data
|
|
const tagsSet = new Set<string>();
|
|
selected.forEach((pic: any) => {
|
|
pic.tags?.forEach((tag: string) => tagsSet.add(tag));
|
|
});
|
|
setAllTags(Array.from(tagsSet).sort());
|
|
} catch (error) {
|
|
console.error('Error fetching pictures:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchCollections = async () => {
|
|
if (!user) return;
|
|
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('collections')
|
|
.select('id, name, slug')
|
|
.eq('user_id', user.id)
|
|
.order('name');
|
|
|
|
if (error) throw error;
|
|
setCollections(data || []);
|
|
} catch (error) {
|
|
console.error('Error fetching collections:', error);
|
|
}
|
|
};
|
|
|
|
const filteredPictures = useMemo(() => {
|
|
return pictures.filter(pic => {
|
|
// Search filter
|
|
const matchesSearch = pic.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
pic.id.toLowerCase().includes(searchQuery.toLowerCase());
|
|
|
|
// Tag filter
|
|
const matchesTags = selectedTags.length === 0 ||
|
|
(pic.tags && selectedTags.some(tag => pic.tags?.includes(tag)));
|
|
|
|
return matchesSearch && matchesTags;
|
|
});
|
|
}, [pictures, searchQuery, selectedTags]);
|
|
|
|
// Further filter by collections if any selected
|
|
const [finalPictures, setFinalPictures] = useState<Picture[]>([]);
|
|
|
|
useEffect(() => {
|
|
const filterByCollections = async () => {
|
|
if (selectedCollections.length === 0) {
|
|
setFinalPictures(filteredPictures);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('collection_pictures')
|
|
.select('picture_id')
|
|
.in('collection_id', selectedCollections);
|
|
|
|
if (error) throw error;
|
|
|
|
const pictureIdsInCollections = new Set(data?.map(cp => cp.picture_id) || []);
|
|
setFinalPictures(filteredPictures.filter(pic => pictureIdsInCollections.has(pic.id)));
|
|
} catch (error) {
|
|
console.error('Error filtering by collections:', error);
|
|
setFinalPictures(filteredPictures);
|
|
}
|
|
};
|
|
|
|
filterByCollections();
|
|
}, [filteredPictures, selectedCollections]);
|
|
|
|
const handleSelect = () => {
|
|
if (multiple) {
|
|
if (onMultiSelect) {
|
|
onMultiSelect(selectedIds);
|
|
}
|
|
if (onMultiSelectPictures) {
|
|
const selectedPictures = pictures.filter(p => selectedIds.includes(p.id));
|
|
onMultiSelectPictures(selectedPictures);
|
|
}
|
|
} else {
|
|
if (selectedId) {
|
|
if (onSelect) onSelect(selectedId);
|
|
if (onSelectPicture) {
|
|
const pic = pictures.find(p => p.id === selectedId);
|
|
if (pic) onSelectPicture(pic);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const toggleSelection = (id: string) => {
|
|
if (multiple) {
|
|
setSelectedIds(prev =>
|
|
prev.includes(id)
|
|
? prev.filter(pid => pid !== id)
|
|
: [...prev, id]
|
|
);
|
|
} else {
|
|
setSelectedId(id);
|
|
}
|
|
};
|
|
|
|
console.log('selectedIds', selectedIds);
|
|
console.log('selectedId', selectedId);
|
|
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
|
|
<DialogHeader>
|
|
<DialogTitle><T>Select Picture</T></DialogTitle>
|
|
<DialogDescription>
|
|
<T>Choose a picture from your published images</T>
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{/* Search */}
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder={translate('Search by title or ID...')}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-10"
|
|
/>
|
|
</div>
|
|
|
|
{/* Filter Buttons */}
|
|
<div className="space-y-3">
|
|
{/* Tags Filter */}
|
|
{allTags.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
|
<Tag className="h-4 w-4" />
|
|
<span><T>Tags</T></span>
|
|
{selectedTags.length > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 px-2 text-xs"
|
|
onClick={() => setSelectedTags([])}
|
|
>
|
|
<T>Clear</T>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{allTags.slice(0, 12).map(tag => (
|
|
<Badge
|
|
key={tag}
|
|
variant={selectedTags.includes(tag) ? "default" : "outline"}
|
|
className="cursor-pointer hover:shadow-md transition-shadow"
|
|
onClick={() => {
|
|
setSelectedTags(prev =>
|
|
prev.includes(tag)
|
|
? prev.filter(t => t !== tag)
|
|
: [...prev, tag]
|
|
);
|
|
}}
|
|
>
|
|
{tag}
|
|
{selectedTags.includes(tag) && (
|
|
<X className="ml-1 h-3 w-3" />
|
|
)}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Collections Filter */}
|
|
{collections.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
|
<FolderOpen className="h-4 w-4" />
|
|
<span><T>Collections</T></span>
|
|
{selectedCollections.length > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 px-2 text-xs"
|
|
onClick={() => setSelectedCollections([])}
|
|
>
|
|
<T>Clear</T>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{collections.map(collection => (
|
|
<Badge
|
|
key={collection.id}
|
|
variant={selectedCollections.includes(collection.id) ? "default" : "outline"}
|
|
className="cursor-pointer hover:shadow-md transition-shadow"
|
|
onClick={() => {
|
|
setSelectedCollections(prev =>
|
|
prev.includes(collection.id)
|
|
? prev.filter(c => c !== collection.id)
|
|
: [...prev, collection.id]
|
|
);
|
|
}}
|
|
>
|
|
{collection.name}
|
|
{selectedCollections.includes(collection.id) && (
|
|
<X className="ml-1 h-3 w-3" />
|
|
)}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Pictures Grid */}
|
|
<div className="flex-1 overflow-y-auto min-h-0 -mx-6 px-6">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
|
</div>
|
|
) : finalPictures.length === 0 ? (
|
|
<div className="text-center py-12">
|
|
<ImageIcon className="h-12 w-12 mx-auto mb-4 text-muted-foreground opacity-50" />
|
|
<p className="text-muted-foreground">
|
|
<T>{searchQuery || selectedTags.length > 0 || selectedCollections.length > 0 ? 'No pictures found' : 'No pictures available'}</T>
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 p-1">
|
|
{finalPictures.map((picture) => {
|
|
const isSelected = multiple
|
|
? selectedIds.includes(picture.id)
|
|
: selectedId === picture.id;
|
|
|
|
return (
|
|
<div
|
|
key={picture.id}
|
|
onClick={() => toggleSelection(picture.id)}
|
|
onDoubleClick={() => {
|
|
if (!multiple) {
|
|
setSelectedId(picture.id);
|
|
if (onSelect) onSelect(picture.id);
|
|
if (onSelectPicture) onSelectPicture(picture);
|
|
} else {
|
|
toggleSelection(picture.id);
|
|
}
|
|
}}
|
|
className={`relative cursor-pointer rounded-lg overflow-hidden border-2 transition-all group ${isSelected
|
|
? 'border-primary shadow-lg scale-[1.02]'
|
|
: 'border-transparent hover:border-primary/50'
|
|
}`}
|
|
>
|
|
<div className="aspect-square relative pointer-events-none">
|
|
<MediaCard
|
|
id={picture.id}
|
|
pictureId={picture.id}
|
|
url={picture.image_url}
|
|
thumbnailUrl={picture.thumbnail_url}
|
|
title={picture.title}
|
|
author={'Me'}
|
|
authorId={picture.user_id}
|
|
likes={0}
|
|
comments={0}
|
|
type={picture.type as MediaType}
|
|
meta={picture.meta}
|
|
showContent={false}
|
|
onClick={() => { }}
|
|
onLike={undefined}
|
|
onDelete={undefined}
|
|
onEdit={undefined}
|
|
/>
|
|
</div>
|
|
{/* Overlay Title */}
|
|
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-2 pointer-events-none">
|
|
<p className="text-white text-xs font-medium truncate">
|
|
{picture.title}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex justify-between items-center pt-4 border-t mt-auto">
|
|
<div className="text-sm text-muted-foreground">
|
|
{multiple && <span>{selectedIds.length} selected</span>}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={onClose}>
|
|
<T>Cancel</T>
|
|
</Button>
|
|
<Button onClick={handleSelect} disabled={multiple ? selectedIds.length === 0 : !selectedId}>
|
|
<T>Select Picture{multiple ? 's' : ''}</T>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|