mono/packages/ui/src/components/widgets/ImagePickerDialog.tsx
2026-02-19 17:07:46 +01:00

459 lines
17 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, 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 [lastSelectedId, setLastSelectedId] = useState<string | null>(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);
}
}
}
onClose();
};
const handleClear = () => {
if (multiple) {
if (onMultiSelect) onMultiSelect([]);
if (onMultiSelectPictures) onMultiSelectPictures([]);
} else {
if (onSelect) onSelect('');
if (onSelectPicture) onSelectPicture(null as any);
}
onClose();
};
const handleImageClick = (e: React.MouseEvent, id: string) => {
// Only capture modifiers if strict multi-select behavior is desired.
// However, given the user complaint "CTRL+CLICK doesn't add", it implies they expect:
// Click = Exclusive (Selects just this one)
// Ctrl+Click = Add (Toggle)
// Shift+Click = Range
if (!multiple) {
setSelectedId(id);
return;
}
e.preventDefault(); // Prevent text selection etc
if (e.ctrlKey || e.metaKey) {
// Toggle
setSelectedIds(prev =>
prev.includes(id)
? prev.filter(pid => pid !== id)
: [...prev, id]
);
setLastSelectedId(id);
} else if (e.shiftKey) {
// Range Select
const currentIndex = finalPictures.findIndex(p => p.id === id);
const lastIndex = lastSelectedId ? finalPictures.findIndex(p => p.id === lastSelectedId) : -1;
if (lastIndex !== -1 && currentIndex !== -1) {
const start = Math.min(currentIndex, lastIndex);
const end = Math.max(currentIndex, lastIndex);
const rangeIds = finalPictures.slice(start, end + 1).map(p => p.id);
// Add range to existing selection (union) so we don't lose previous non-contiguous selections
// OR replace? Standard usually replaces unless Ctrl is held.
// But since Shift extends selection from anchor...
// Let's go with Union for safety, or actually Replace relative to anchor?
// Standard behavior: Shift click selects from Anchor to Current.
// It should technically clear everything else unless Ctrl is also held.
// But simplifying: just add range for now.
setSelectedIds(prev => Array.from(new Set([...prev, ...rangeIds])));
} else {
setSelectedIds(prev => prev.includes(id) ? prev : [...prev, id]);
}
setLastSelectedId(id);
} else {
// Exclusive Select (clears others)
// This solves the complaint "Ctrl+Click doesn't add" because now Click REPLACES, so Ctrl+Click is the way to add.
setSelectedIds([id]);
setLastSelectedId(id);
}
};
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={(e) => handleImageClick(e, picture.id)}
onDoubleClick={() => {
if (!multiple) {
setSelectedId(picture.id);
if (onSelect) onSelect(picture.id);
if (onSelectPicture) onSelectPicture(picture);
} else {
// Double click in multi mode: Just toggle or maybe confirm?
// For now, let's treat it as select-exclusive+confirm?
// Or just toggle.
handleImageClick({ ctrlKey: true, preventDefault: () => { } } as any, picture.id);
handleSelect();
}
}}
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="flex items-center gap-4">
<span className="text-sm text-muted-foreground">
{multiple ? `${selectedIds.length} selected` : (selectedId ? '1 selected' : '')}
</span>
{(multiple ? selectedIds.length > 0 || (currentValues && currentValues.length > 0) : !!selectedId || !!currentValue) && (
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={handleClear}>
<X className="h-4 w-4 mr-1" />
<T>Clear</T>
</Button>
)}
</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>
);
};