feat(select): add per-tile checkbox selection in Admin grid and all gallery layouts; tile click opens viewer; checkbox toggles selection; auto-enable selection mode; add testids
Mirror to GitHub / mirror (push) Successful in 38s
Test and Lint / backend-test (push) Successful in 1m34s
Test and Lint / frontend-test (push) Successful in 2m13s
Version and Release / version-bump (push) Successful in 57s
Version and Release / trigger-drone (push) Successful in 3s

This commit is contained in:
2025-09-14 17:02:03 +02:00
parent 0d77a3a0a8
commit 9fda54bd06
7 changed files with 159 additions and 105 deletions
@@ -29,7 +29,10 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
if (e) { if (e) {
e.stopPropagation(); e.stopPropagation();
} }
// Auto-enable selection mode when selecting via checkbox
if (!isSelectionMode) {
setIsSelectionMode(true);
}
const newSelected = new Set(selectedPhotos); const newSelected = new Set(selectedPhotos);
if (newSelected.has(photoId)) { if (newSelected.has(photoId)) {
newSelected.delete(photoId); newSelected.delete(photoId);
@@ -126,7 +129,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
{isSelectionMode ? 'Cancel Selection' : 'Select Photos'} {isSelectionMode ? 'Cancel Selection' : 'Select Photos'}
</Button> </Button>
{isSelectionMode && ( {(isSelectionMode || selectedPhotos.size > 0) && (
<> <>
<Button <Button
variant="ghost" variant="ghost"
@@ -167,25 +170,32 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
return ( return (
<div <div
key={photo.id} key={photo.id}
data-testid={`admin-photo-tile-${photo.id}`}
className={`relative group cursor-pointer rounded-lg overflow-hidden bg-neutral-100 transition-opacity ${ className={`relative group cursor-pointer rounded-lg overflow-hidden bg-neutral-100 transition-opacity ${
isSelectionMode ? 'ring-2 ring-offset-2 ' + (selectedPhotos.has(photo.id) ? 'ring-primary-500' : 'ring-transparent') : '' isSelectionMode ? 'ring-2 ring-offset-2 ' + (selectedPhotos.has(photo.id) ? 'ring-primary-500' : 'ring-transparent') : ''
} ${isDeleting ? 'opacity-50' : ''}`} } ${isDeleting ? 'opacity-50' : ''}`}
onClick={() => !isDeleting && (isSelectionMode ? handlePhotoSelect(photo.id) : onPhotoClick(photo, index))} onClick={() => !isDeleting && onPhotoClick(photo, index)}
> >
{/* Selection Checkbox */} {/* Selection Checkbox (top-right) */}
{isSelectionMode && ( <button
<div className="absolute top-2 left-2 z-10"> type="button"
<div className={`w-6 h-6 rounded border-2 flex items-center justify-center ${ aria-label={`Select ${photo.filename}`}
selectedPhotos.has(photo.id) role="checkbox"
? 'bg-primary-500 border-primary-500' aria-checked={selectedPhotos.has(photo.id)}
: 'bg-white/80 border-neutral-300' data-testid={`admin-photo-checkbox-${photo.id}`}
}`}> className={`absolute top-2 right-2 z-20 transition-opacity ${
{selectedPhotos.has(photo.id) && ( selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
<Check className="w-4 h-4 text-white" /> }`}
)} onClick={(e) => handlePhotoSelect(photo.id, e)}
</div> >
<div className={`w-6 h-6 rounded border-2 flex items-center justify-center ${
selectedPhotos.has(photo.id)
? 'bg-primary-600 border-primary-600'
: 'bg-white/90 border-white'
}`}>
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
</div> </div>
)} </button>
{/* Thumbnail */} {/* Thumbnail */}
<div className="aspect-square"> <div className="aspect-square">
@@ -240,7 +250,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
{/* Category Badge */} {/* Category Badge */}
{photo.category_name && ( {photo.category_name && (
<div className="absolute top-2 right-2"> <div className="absolute right-2" style={{ top: (selectedPhotos.has(photo.id) || isSelectionMode) ? '40px' : '8px' }}>
<span className="px-2 py-1 text-xs font-medium bg-white/90 text-neutral-700 rounded"> <span className="px-2 py-1 text-xs font-medium bg-white/90 text-neutral-700 rounded">
{photo.category_name} {photo.category_name}
</span> </span>
@@ -81,6 +81,14 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
}; };
const handlePhotoSelect = (photoId: number) => { const handlePhotoSelect = (photoId: number) => {
// Auto-enable selection mode when selecting via checkbox
if (!isSelectionMode) {
if (parentToggleSelectionMode) {
parentToggleSelectionMode();
} else {
setLocalSelectionMode(true);
}
}
const newSelected = new Set(selectedPhotos); const newSelected = new Set(selectedPhotos);
if (newSelected.has(photoId)) { if (newSelected.has(photoId)) {
newSelected.delete(photoId); newSelected.delete(photoId);
@@ -12,6 +12,7 @@ interface GridPhotoProps {
isSelectionMode: boolean; isSelectionMode: boolean;
onClick: (e: React.MouseEvent) => void; onClick: (e: React.MouseEvent) => void;
onDownload: (e: React.MouseEvent) => void; onDownload: (e: React.MouseEvent) => void;
onToggleSelect: () => void;
animationType?: string; animationType?: string;
allowDownloads?: boolean; allowDownloads?: boolean;
slug?: string; slug?: string;
@@ -26,6 +27,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
isSelectionMode, isSelectionMode,
onClick, onClick,
onDownload, onDownload,
onToggleSelect,
animationType = 'fade', animationType = 'fade',
allowDownloads = true, allowDownloads = true,
slug, slug,
@@ -105,13 +107,22 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
)} )}
</div> </div>
{isSelectionMode && ( {/* Selection Checkbox (visible on hover or when selected) */}
<div className={`absolute top-2 right-2 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}> <button
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}> type="button"
{isSelected && <Check className="w-4 h-4 text-white" />} aria-label={`Select ${photo.filename}`}
</div> role="checkbox"
aria-checked={isSelected}
data-testid={`gallery-photo-checkbox-${photo.id}`}
className={`absolute top-2 right-2 z-20 transition-opacity ${
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }}
>
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{isSelected && <Check className="w-4 h-4 text-white" />}
</div> </div>
)} </button>
{/* Feedback Indicators */} {/* Feedback Indicators */}
{feedbackEnabled && (photo.comment_count > 0 || photo.average_rating > 0 || photo.like_count > 0) && ( {feedbackEnabled && (photo.comment_count > 0 || photo.average_rating > 0 || photo.like_count > 0) && (
@@ -187,13 +198,8 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photo={photo} photo={photo}
isSelected={selectedPhotos.has(photo.id)} isSelected={selectedPhotos.has(photo.id)}
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
onClick={() => { onClick={() => onPhotoClick(index)}
if (isSelectionMode && onPhotoSelect) { onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
onPhotoSelect(photo.id);
} else {
onPhotoClick(index);
}
}}
onDownload={(e) => onDownload(photo, e)} onDownload={(e) => onDownload(photo, e)}
animationType={animation} animationType={animation}
allowDownloads={allowDownloads} allowDownloads={allowDownloads}
@@ -152,13 +152,7 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
<div <div
key={photo.id} key={photo.id}
className="relative group cursor-pointer aspect-square" className="relative group cursor-pointer aspect-square"
onClick={() => { onClick={() => onPhotoClick(actualIndex)}
if (isSelectionMode && onPhotoSelect) {
onPhotoSelect(photo.id);
} else {
onPhotoClick(actualIndex);
}
}}
> >
<AuthenticatedImage <AuthenticatedImage
src={photo.thumbnail_url || photo.url} src={photo.thumbnail_url || photo.url}
@@ -198,13 +192,22 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
)} )}
</div> </div>
{isSelectionMode && ( {/* Selection Checkbox (visible on hover or when selected) */}
<div className={`absolute top-2 right-2 ${selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}> <button
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}> type="button"
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />} aria-label={`Select ${photo.filename}`}
</div> role="checkbox"
aria-checked={selectedPhotos.has(photo.id)}
data-testid={`gallery-photo-checkbox-${photo.id}`}
className={`absolute top-2 right-2 z-20 transition-opacity ${
selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={(e) => { e.stopPropagation(); onPhotoSelect && onPhotoSelect(photo.id); }}
>
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
</div> </div>
)} </button>
</div> </div>
); );
})} })}
@@ -11,6 +11,7 @@ interface MasonryPhotoProps {
isSelectionMode: boolean; isSelectionMode: boolean;
onClick: (e: React.MouseEvent) => void; onClick: (e: React.MouseEvent) => void;
onDownload: (e: React.MouseEvent) => void; onDownload: (e: React.MouseEvent) => void;
onToggleSelect: () => void;
style?: React.CSSProperties; style?: React.CSSProperties;
allowDownloads?: boolean; allowDownloads?: boolean;
feedbackEnabled?: boolean; feedbackEnabled?: boolean;
@@ -22,6 +23,7 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
isSelectionMode, isSelectionMode,
onClick, onClick,
onDownload, onDownload,
onToggleSelect,
style, style,
allowDownloads = true, allowDownloads = true,
feedbackEnabled = false feedbackEnabled = false
@@ -104,13 +106,22 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
)} )}
</div> </div>
{isSelectionMode && ( {/* Selection Checkbox (visible on hover or when selected) */}
<div className={`absolute top-2 right-2 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}> <button
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}> type="button"
{isSelected && <Check className="w-4 h-4 text-white" />} aria-label={`Select ${photo.filename}`}
</div> role="checkbox"
aria-checked={isSelected}
data-testid={`gallery-photo-checkbox-${photo.id}`}
className={`absolute top-2 right-2 z-20 transition-opacity ${
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }}
>
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{isSelected && <Check className="w-4 h-4 text-white" />}
</div> </div>
)} </button>
{photo.type === 'collage' && ( {photo.type === 'collage' && (
<div className="absolute bottom-2 left-2"> <div className="absolute bottom-2 left-2">
@@ -182,14 +193,9 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photo={photo} photo={photo}
isSelected={selectedPhotos.has(photo.id)} isSelected={selectedPhotos.has(photo.id)}
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
onClick={() => { onClick={() => onPhotoClick(originalIndex)}
if (isSelectionMode && onPhotoSelect) {
onPhotoSelect(photo.id);
} else {
onPhotoClick(originalIndex);
}
}}
onDownload={(e) => onDownload(photo, e)} onDownload={(e) => onDownload(photo, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
allowDownloads={allowDownloads} allowDownloads={allowDownloads}
feedbackEnabled={feedbackEnabled} feedbackEnabled={feedbackEnabled}
/> />
@@ -11,6 +11,7 @@ interface MosaicPhotoProps {
isSelectionMode: boolean; isSelectionMode: boolean;
onClick: (e: React.MouseEvent) => void; onClick: (e: React.MouseEvent) => void;
onDownload: (e: React.MouseEvent) => void; onDownload: (e: React.MouseEvent) => void;
onToggleSelect: () => void;
className?: string; className?: string;
allowDownloads?: boolean; allowDownloads?: boolean;
} }
@@ -21,6 +22,7 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
isSelectionMode, isSelectionMode,
onClick, onClick,
onDownload, onDownload,
onToggleSelect,
className = '', className = '',
allowDownloads = true allowDownloads = true
}) => { }) => {
@@ -69,13 +71,22 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
)} )}
</div> </div>
{isSelectionMode && ( {/* Selection Checkbox (visible on hover or when selected) */}
<div className={`absolute top-2 right-2 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}> <button
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}> type="button"
{isSelected && <Check className="w-4 h-4 text-white" />} aria-label={`Select ${photo.filename}`}
</div> role="checkbox"
aria-checked={isSelected}
data-testid={`gallery-photo-checkbox-${photo.id}`}
className={`absolute top-2 right-2 z-20 transition-opacity ${
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }}
>
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{isSelected && <Check className="w-4 h-4 text-white" />}
</div> </div>
)} </button>
{photo.type === 'collage' && ( {photo.type === 'collage' && (
<div className="absolute bottom-2 left-2"> <div className="absolute bottom-2 left-2">
@@ -136,8 +147,9 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photo={photo0} photo={photo0}
isSelected={selectedPhotos.has(photo0.id)} isSelected={selectedPhotos.has(photo0.id)}
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(idx0, photo0.id)} onClick={() => onPhotoClick(idx0)}
onDownload={(e) => onDownload(photo0, e)} onDownload={(e) => onDownload(photo0, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo0.id)}
className="col-span-1" className="col-span-1"
allowDownloads={allowDownloads} allowDownloads={allowDownloads}
/> />
@@ -148,22 +160,24 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photo={photo1} photo={photo1}
isSelected={selectedPhotos.has(photo1.id)} isSelected={selectedPhotos.has(photo1.id)}
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(idx1, photo1.id)} onClick={() => onPhotoClick(idx1)}
onDownload={(e) => onDownload(photo1, e)} onDownload={(e) => onDownload(photo1, e)}
className="" onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo1.id)}
allowDownloads={allowDownloads} className=""
/> allowDownloads={allowDownloads}
/>
)} )}
{photo2 && ( {photo2 && (
<MosaicPhoto <MosaicPhoto
photo={photo2} photo={photo2}
isSelected={selectedPhotos.has(photo2.id)} isSelected={selectedPhotos.has(photo2.id)}
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(idx2, photo2.id)} onClick={() => onPhotoClick(idx2)}
onDownload={(e) => onDownload(photo2, e)} onDownload={(e) => onDownload(photo2, e)}
className="" onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo2.id)}
allowDownloads={allowDownloads} className=""
/> allowDownloads={allowDownloads}
/>
)} )}
</div> </div>
</div> </div>
@@ -209,8 +223,9 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photo={photo0} photo={photo0}
isSelected={selectedPhotos.has(photo0.id)} isSelected={selectedPhotos.has(photo0.id)}
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(idx0, photo0.id)} onClick={() => onPhotoClick(idx0)}
onDownload={(e) => onDownload(photo0, e)} onDownload={(e) => onDownload(photo0, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo0.id)}
className="col-span-2" className="col-span-2"
allowDownloads={allowDownloads} allowDownloads={allowDownloads}
/> />
@@ -221,22 +236,24 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photo={photo1} photo={photo1}
isSelected={selectedPhotos.has(photo1.id)} isSelected={selectedPhotos.has(photo1.id)}
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(idx1, photo1.id)} onClick={() => onPhotoClick(idx1)}
onDownload={(e) => onDownload(photo1, e)} onDownload={(e) => onDownload(photo1, e)}
className="" onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo1.id)}
allowDownloads={allowDownloads} className=""
/> allowDownloads={allowDownloads}
/>
)} )}
{photo2 && ( {photo2 && (
<MosaicPhoto <MosaicPhoto
photo={photo2} photo={photo2}
isSelected={selectedPhotos.has(photo2.id)} isSelected={selectedPhotos.has(photo2.id)}
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(idx2, photo2.id)} onClick={() => onPhotoClick(idx2)}
onDownload={(e) => onDownload(photo2, e)} onDownload={(e) => onDownload(photo2, e)}
className="" onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo2.id)}
allowDownloads={allowDownloads} className=""
/> allowDownloads={allowDownloads}
/>
)} )}
</div> </div>
</div> </div>
@@ -263,8 +280,9 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photo={photo} photo={photo}
isSelected={selectedPhotos.has(photo.id)} isSelected={selectedPhotos.has(photo.id)}
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(index, photo.id)} onClick={() => onPhotoClick(index)}
onDownload={(e) => onDownload(photo, e)} onDownload={(e) => onDownload(photo, e)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
className="aspect-square" className="aspect-square"
allowDownloads={allowDownloads} allowDownloads={allowDownloads}
/> />
@@ -90,13 +90,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
<div <div
key={photo.id} key={photo.id}
className="relative group cursor-pointer aspect-square" className="relative group cursor-pointer aspect-square"
onClick={() => { onClick={() => onPhotoClick(actualIndex)}
if (isSelectionMode && onPhotoSelect) {
onPhotoSelect(photo.id);
} else {
onPhotoClick(actualIndex);
}
}}
> >
<AuthenticatedImage <AuthenticatedImage
src={photo.thumbnail_url || photo.url} src={photo.thumbnail_url || photo.url}
@@ -141,13 +135,22 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
)} )}
</div> </div>
{isSelectionMode && ( {/* Selection Checkbox (visible on hover or when selected) */}
<div className={`absolute top-2 right-2 ${selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}> <button
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}> type="button"
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />} aria-label={`Select ${photo.filename}`}
</div> role="checkbox"
aria-checked={selectedPhotos.has(photo.id)}
data-testid={`gallery-photo-checkbox-${photo.id}`}
className={`absolute top-2 right-2 z-20 transition-opacity ${
selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={(e) => { e.stopPropagation(); onPhotoSelect && onPhotoSelect(photo.id); }}
>
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
</div> </div>
)} </button>
</div> </div>
); );
})} })}