Phase 1 (Backend API): ✅ GroupRepository.updateImageOrder() with SQL transactions ✅ PUT /api/groups/:groupId/reorder API route with validation ✅ Manual testing: Reordering verified working (group qion_-lT1) ✅ Error handling: Invalid IDs, missing groups, empty arrays Phase 2 (Frontend DnD): ✅ @dnd-kit/core packages installed ✅ ReorderService.js for API communication ✅ useReordering.js custom hook with optimistic updates ✅ ImageGalleryCard.js extended with drag handles & sortable ✅ ImageGallery.js with DndContext and SortableContext ✅ CSS styles for drag states, handles, touch-friendly mobile Next: Integration with ModerationGroupImagesPage
245 lines
9.1 KiB
JavaScript
245 lines
9.1 KiB
JavaScript
import React from 'react';
|
||
import PropTypes from 'prop-types';
|
||
import { useSortable } from '@dnd-kit/sortable';
|
||
import { CSS } from '@dnd-kit/utilities';
|
||
|
||
import './Css/ImageGallery.css';
|
||
import { getImageSrc, getGroupPreviewSrc } from '../../Utils/imageUtils';
|
||
|
||
const ImageGalleryCard = ({
|
||
item,
|
||
onApprove,
|
||
onViewImages,
|
||
onDelete,
|
||
isPending,
|
||
showActions = true,
|
||
index,
|
||
mode = 'group', // 'group', 'moderation', or 'preview'
|
||
hidePreview = false, // Hide the preview image section
|
||
enableReordering = false, // Enable drag-and-drop reordering
|
||
isDragOverlay = false // Special styling when used as drag overlay
|
||
}) => {
|
||
// Handle both group data and individual image preview data
|
||
let previewUrl = null;
|
||
let title = '';
|
||
let subtitle = '';
|
||
let description = '';
|
||
let uploadDate = '';
|
||
let imageCount = 0;
|
||
let itemId = '';
|
||
|
||
if (mode === 'preview' || mode === 'single-image') {
|
||
// Preview mode: display individual images
|
||
// Use preview image (optimized thumbnails for gallery)
|
||
previewUrl = getImageSrc(item, true);
|
||
|
||
title = item.originalName || item.name || 'Bild';
|
||
|
||
// Show capture date if available
|
||
if (item.captureDate) {
|
||
subtitle = `Aufnahme: ${new Date(item.captureDate).toLocaleDateString('de-DE')}`;
|
||
}
|
||
|
||
itemId = item.id;
|
||
} else {
|
||
// Group mode: display group information
|
||
const group = item;
|
||
|
||
// Use preview image from first image in group
|
||
previewUrl = getGroupPreviewSrc(group, true);
|
||
|
||
title = group.title;
|
||
// Only show name if it exists and is not empty/null/undefined
|
||
subtitle = (group.name && group.name !== 'null') ? `${group.year} • ${group.name}` : `${group.year}`;
|
||
description = group.description;
|
||
uploadDate = group.uploadDate;
|
||
imageCount = group.imageCount;
|
||
itemId = group.groupId;
|
||
}
|
||
|
||
// Drag-and-Drop setup (only for reorderable items)
|
||
const {
|
||
attributes,
|
||
listeners,
|
||
setNodeRef,
|
||
transform,
|
||
transition,
|
||
isDragging
|
||
} = useSortable({
|
||
id: itemId,
|
||
disabled: !enableReordering
|
||
});
|
||
|
||
const style = {
|
||
transform: CSS.Transform.toString(transform),
|
||
transition,
|
||
opacity: isDragging ? 0.5 : 1,
|
||
cursor: enableReordering ? 'grab' : 'default'
|
||
};
|
||
|
||
// CSS classes for different states
|
||
const cardClasses = [
|
||
'image-gallery-card',
|
||
isPending ? 'pending' : 'approved',
|
||
'card-stretch',
|
||
enableReordering ? 'reorderable' : '',
|
||
isDragging ? 'dragging' : '',
|
||
isDragOverlay ? 'drag-overlay' : ''
|
||
].filter(Boolean).join(' ');
|
||
|
||
return (
|
||
<div
|
||
ref={setNodeRef}
|
||
style={style}
|
||
className={cardClasses}
|
||
{...attributes}
|
||
{...(enableReordering ? listeners : {})}
|
||
>
|
||
{!hidePreview && (
|
||
<div className="image-gallery-card-preview">
|
||
{previewUrl ? (
|
||
<img src={previewUrl} alt="Preview" className="image-gallery-card-preview-image" />
|
||
) : (
|
||
<div className="image-gallery-card-no-preview">Kein Vorschaubild</div>
|
||
)}
|
||
|
||
{/* Drag Handle - nur sichtbar wenn Reordering aktiviert */}
|
||
{enableReordering && (
|
||
<div className="drag-handle" title="Bild verschieben">
|
||
<span>≡≡</span>
|
||
</div>
|
||
)}
|
||
|
||
{mode === 'preview' && index !== undefined && (
|
||
<div className="image-gallery-card-image-order">{index + 1}</div>
|
||
)}
|
||
|
||
{mode !== 'preview' && imageCount > 0 && (
|
||
<div className="image-gallery-card-image-count">{imageCount} Bilder</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="image-gallery-card-info">
|
||
<h3>{title}</h3>
|
||
{subtitle && <p className="image-gallery-card-meta">{subtitle}</p>}
|
||
{description && (
|
||
<p className="image-gallery-card-description">{description}</p>
|
||
)}
|
||
{uploadDate && (
|
||
<p className="image-gallery-card-upload-date">
|
||
Hochgeladen: {new Date(uploadDate).toLocaleDateString('de-DE')}
|
||
</p>
|
||
)}
|
||
|
||
{/* Additional metadata for preview mode */}
|
||
{mode === 'preview' && item.remoteUrl && item.remoteUrl.includes('/download/') && (
|
||
<div className="image-gallery-card-file-meta">
|
||
Server-Datei: {item.remoteUrl.split('/').pop()}
|
||
</div>
|
||
)}
|
||
{mode === 'preview' && item.filePath && !item.remoteUrl && (
|
||
<div className="image-gallery-card-file-meta">
|
||
Server-Datei: {item.filePath.split('/').pop()}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Only show actions section if there are actions to display */}
|
||
{(showActions || (mode !== 'single-image' && !showActions)) && (
|
||
<div className="image-gallery-card-actions">
|
||
{showActions ? (
|
||
mode === 'preview' ? (
|
||
// Preview mode actions (for upload preview)
|
||
<>
|
||
<button
|
||
className="btn btn-danger"
|
||
onClick={() => onDelete(index !== undefined ? index : itemId)}
|
||
>
|
||
🗑️ Löschen
|
||
</button>
|
||
<button
|
||
className="btn btn-secondary btn-sm"
|
||
disabled
|
||
>
|
||
Sort
|
||
</button>
|
||
</>
|
||
) : (
|
||
// Moderation mode actions (for existing groups)
|
||
<>
|
||
<button
|
||
className="btn btn-secondary"
|
||
onClick={() => onViewImages(item)}
|
||
>
|
||
✏️ Gruppe editieren
|
||
</button>
|
||
|
||
{isPending ? (
|
||
<button
|
||
className="btn btn-success"
|
||
onClick={() => onApprove(itemId, true)}
|
||
>
|
||
✅ Freigeben
|
||
</button>
|
||
) : (
|
||
<button
|
||
className="btn btn-warning"
|
||
onClick={() => onApprove(itemId, false)}
|
||
>
|
||
⏸️ Sperren
|
||
</button>
|
||
)}
|
||
|
||
<button
|
||
className="btn btn-danger"
|
||
onClick={() => onDelete(itemId)}
|
||
>
|
||
🗑️ Löschen
|
||
</button>
|
||
</>
|
||
)
|
||
) : mode !== 'single-image' ? (
|
||
// Public view mode (only for group cards, not single images)
|
||
<button
|
||
className="view-button"
|
||
onClick={() => onViewImages(item)}
|
||
title="Anzeigen"
|
||
>
|
||
Anzeigen
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
ImageGalleryCard.propTypes = {
|
||
item: PropTypes.object.isRequired,
|
||
onApprove: PropTypes.func,
|
||
onViewImages: PropTypes.func,
|
||
onDelete: PropTypes.func,
|
||
isPending: PropTypes.bool,
|
||
showActions: PropTypes.bool,
|
||
index: PropTypes.number,
|
||
mode: PropTypes.oneOf(['group', 'moderation', 'preview', 'single-image']),
|
||
hidePreview: PropTypes.bool,
|
||
enableReordering: PropTypes.bool,
|
||
isDragOverlay: PropTypes.bool
|
||
};
|
||
|
||
ImageGalleryCard.defaultProps = {
|
||
onApprove: () => {},
|
||
onViewImages: () => {},
|
||
onDelete: () => {},
|
||
isPending: false,
|
||
showActions: true,
|
||
mode: 'group',
|
||
hidePreview: false,
|
||
enableReordering: false,
|
||
isDragOverlay: false
|
||
};
|
||
|
||
export default ImageGalleryCard;
|