feat: implement drag-and-drop reordering infrastructure

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
This commit is contained in:
Matthias Lotz 2025-11-03 21:06:39 +01:00
parent abd12923aa
commit 7564525c7e
10 changed files with 636 additions and 30 deletions

View File

@ -311,6 +311,69 @@ class GroupRepository {
latestUpload: latestGroup ? latestGroup.upload_date : null
};
}
// Aktualisiere die Reihenfolge der Bilder in einer Gruppe
async updateImageOrder(groupId, imageIds) {
if (!groupId) {
throw new Error('Group ID is required');
}
if (!Array.isArray(imageIds) || imageIds.length === 0) {
throw new Error('Image IDs array is required and cannot be empty');
}
return await dbManager.transaction(async (db) => {
// Zunächst prüfen, ob die Gruppe existiert
const group = await db.get('SELECT group_id FROM groups WHERE group_id = ?', [groupId]);
if (!group) {
throw new Error(`Group with ID ${groupId} not found`);
}
// Alle Bilder der Gruppe laden und prüfen ob alle IDs gültig sind
const existingImages = await db.all(
'SELECT id, file_name FROM images WHERE group_id = ? ORDER BY upload_order ASC',
[groupId]
);
const existingImageIds = existingImages.map(img => img.id);
// Prüfen ob alle übergebenen IDs zur Gruppe gehören
const invalidIds = imageIds.filter(id => !existingImageIds.includes(id));
if (invalidIds.length > 0) {
throw new Error(`Invalid image IDs found: ${invalidIds.join(', ')}. These images do not belong to group ${groupId}`);
}
// Prüfen ob alle Bilder der Gruppe in der Liste enthalten sind
const missingIds = existingImageIds.filter(id => !imageIds.includes(id));
if (missingIds.length > 0) {
throw new Error(`Missing image IDs: ${missingIds.join(', ')}. All images of the group must be included in the reorder operation`);
}
// Batch-Update der upload_order Werte
let updateCount = 0;
for (let i = 0; i < imageIds.length; i++) {
const imageId = imageIds[i];
const newOrder = i + 1; // upload_order beginnt bei 1
const result = await db.run(
'UPDATE images SET upload_order = ? WHERE id = ? AND group_id = ?',
[newOrder, imageId, groupId]
);
if (result.changes === 0) {
throw new Error(`Failed to update image with ID ${imageId}`);
}
updateCount += result.changes;
}
return {
groupId: groupId,
updatedImages: updateCount,
newOrder: imageIds
};
});
}
}
module.exports = new GroupRepository();

View File

@ -3,9 +3,11 @@ const downloadRouter = require('./download');
const batchUploadRouter = require('./batchUpload');
const groupsRouter = require('./groups');
const migrationRouter = require('./migration');
const reorderRouter = require('./reorder');
const renderRoutes = (app) => {
[uploadRouter, downloadRouter, batchUploadRouter, groupsRouter, migrationRouter].forEach(router => app.use('/', router));
app.use('/api/groups', reorderRouter);
};
module.exports = { renderRoutes };

View File

@ -0,0 +1,100 @@
const express = require('express');
const router = express.Router();
const GroupRepository = require('../repositories/GroupRepository');
/**
* PUT /api/groups/:groupId/reorder
* Reorder images within a group
*
* Request Body:
* {
* "imageIds": [123, 456, 789] // Array of image IDs in new order
* }
*
* Response:
* {
* "success": true,
* "message": "Image order updated successfully",
* "data": {
* "groupId": "abc123",
* "updatedImages": 3,
* "newOrder": [123, 456, 789]
* }
* }
*/
router.put('/:groupId/reorder', async (req, res) => {
try {
const { groupId } = req.params;
const { imageIds } = req.body;
// Input validation
if (!groupId) {
return res.status(400).json({
success: false,
message: 'Group ID is required'
});
}
if (!imageIds || !Array.isArray(imageIds) || imageIds.length === 0) {
return res.status(400).json({
success: false,
message: 'imageIds array is required and cannot be empty'
});
}
// Validate that all imageIds are numbers
const invalidIds = imageIds.filter(id => !Number.isInteger(id) || id <= 0);
if (invalidIds.length > 0) {
return res.status(400).json({
success: false,
message: `Invalid image IDs: ${invalidIds.join(', ')}. Image IDs must be positive integers`
});
}
// Log the reordering operation
console.log(`[REORDER] Group ${groupId}: Reordering ${imageIds.length} images`);
console.log(`[REORDER] New order: ${imageIds.join(' → ')}`);
// Execute the reordering
const result = await GroupRepository.updateImageOrder(groupId, imageIds);
// Success response
res.status(200).json({
success: true,
message: 'Image order updated successfully',
data: result
});
// Log success
console.log(`[REORDER] Success: Updated ${result.updatedImages} images in group ${groupId}`);
} catch (error) {
console.error(`[REORDER] Error reordering images in group ${req.params.groupId}:`, error.message);
// Handle specific error types
if (error.message.includes('not found')) {
return res.status(404).json({
success: false,
message: error.message
});
}
if (error.message.includes('Invalid image IDs') ||
error.message.includes('Missing image IDs') ||
error.message.includes('is required')) {
return res.status(400).json({
success: false,
message: error.message
});
}
// Generic server error
res.status(500).json({
success: false,
message: 'Internal server error during image reordering',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
});
module.exports = router;

View File

@ -8,6 +8,9 @@
"name": "frontend",
"version": "0.1.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.0",
@ -2233,6 +2236,59 @@
"postcss-selector-parser": "^6.0.10"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emotion/babel-plugin": {
"version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",

View File

@ -3,20 +3,23 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@mui/material": "^5.14.0",
"@mui/icons-material": "^5.14.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.0",
"@mui/material": "^5.14.0",
"@testing-library/jest-dom": "^5.11.9",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^12.8.3",
"axios": "^0.21.1",
"lottie-react": "^2.4.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-code-blocks": "^0.0.8",
"react-dom": "^18.3.1",
"react-dropzone": "^11.3.1",
"react-helmet": "^6.1.0",
"lottie-react": "^2.4.0",
"react-router-dom": "^6.28.0",
"react-scripts": "5.0.1",
"sass": "^1.32.8",

View File

@ -183,6 +183,73 @@
}
}
/* Drag-and-Drop Styles */
.image-gallery-card.reorderable {
cursor: grab;
transition: transform 0.2s, box-shadow 0.2s, opacity 0.2s;
}
.image-gallery-card.reorderable:active {
cursor: grabbing;
}
.image-gallery-card.dragging {
opacity: 0.5;
transform: rotate(5deg);
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
z-index: 1000;
}
.image-gallery-card.drag-overlay {
opacity: 0.9;
transform: rotate(5deg);
box-shadow: 0 12px 32px rgba(0,0,0,0.4);
z-index: 2000;
}
/* Drag Handle */
.drag-handle {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0,0,0,0.7);
color: white;
border-radius: 4px;
padding: 4px 8px;
font-size: 14px;
cursor: grab;
user-select: none;
z-index: 10;
opacity: 0;
transition: opacity 0.2s;
}
.image-gallery-card.reorderable:hover .drag-handle {
opacity: 1;
}
.drag-handle:active {
cursor: grabbing;
}
.drag-handle span {
font-family: monospace;
font-weight: bold;
}
/* Touch-friendly drag handle on mobile */
@media (max-width: 768px) {
.drag-handle {
opacity: 1;
padding: 8px 12px;
font-size: 16px;
}
.image-gallery-card.reorderable {
cursor: default; /* No grab cursor on touch devices */
}
}
/* Responsive: 1 column on mobile */
@media (max-width: 768px) {
.image-gallery-grid {

View File

@ -1,5 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
rectSortingStrategy
} from '@dnd-kit/sortable';
import ImageGalleryCard from './ImageGalleryCard';
import './Css/ImageGallery.css';
@ -12,8 +26,40 @@ const ImageGallery = ({
showActions,
mode,
title,
emptyMessage = 'Keine Elemente vorhanden'
emptyMessage = 'Keine Elemente vorhanden',
enableReordering = false,
onReorder = null
}) => {
// Sensors for drag and drop (touch-friendly)
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // Require 8px movement before drag starts
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = (event) => {
const { active, over } = event;
if (active.id !== over?.id && onReorder) {
const oldIndex = items.findIndex(item =>
(item.id || item.groupId) === active.id
);
const newIndex = items.findIndex(item =>
(item.id || item.groupId) === over.id
);
if (oldIndex !== -1 && newIndex !== -1) {
const reorderedItems = arrayMove(items, oldIndex, newIndex);
onReorder(reorderedItems, oldIndex, newIndex);
}
}
};
if (!items || items.length === 0) {
return (
<div className="image-gallery-empty">
@ -22,16 +68,13 @@ const ImageGallery = ({
);
}
return (
<div className="image-gallery-container">
{title && (
<h2 className="image-gallery-title">{title}</h2>
)}
const itemIds = items.map(item => item.id || item.groupId);
const galleryContent = (
<div className="image-gallery-grid">
{items.map((item, index) => (
<div key={item.id || item.groupId || index} className="grid-item-stretch">
<ImageGalleryCard
key={item.id || item.groupId || index}
item={item}
index={index}
onApprove={onApprove}
@ -40,10 +83,34 @@ const ImageGallery = ({
isPending={isPending}
showActions={showActions}
mode={mode}
enableReordering={enableReordering}
/>
</div>
))}
</div>
);
return (
<div className="image-gallery-container">
{title && (
<h2 className="image-gallery-title">{title}</h2>
)}
{enableReordering ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={itemIds}
strategy={rectSortingStrategy}
>
{galleryContent}
</SortableContext>
</DndContext>
) : (
galleryContent
)}
</div>
);
};
@ -57,7 +124,9 @@ ImageGallery.propTypes = {
showActions: PropTypes.bool,
mode: PropTypes.oneOf(['group', 'moderation', 'preview', 'single-image']),
title: PropTypes.string,
emptyMessage: PropTypes.string
emptyMessage: PropTypes.string,
enableReordering: PropTypes.bool,
onReorder: PropTypes.func
};
ImageGallery.defaultProps = {
@ -66,7 +135,9 @@ ImageGallery.defaultProps = {
onDelete: () => {},
isPending: false,
showActions: true,
mode: 'group'
mode: 'group',
enableReordering: false,
onReorder: null
};
export default ImageGallery;

View File

@ -1,5 +1,7 @@
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';
@ -13,7 +15,9 @@ const ImageGalleryCard = ({
showActions = true,
index,
mode = 'group', // 'group', 'moderation', or 'preview'
hidePreview = false // Hide the preview image section
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;
@ -53,8 +57,44 @@ const ImageGalleryCard = ({
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 className={`image-gallery-card ${isPending ? 'pending' : 'approved'} card-stretch`}>
<div
ref={setNodeRef}
style={style}
className={cardClasses}
{...attributes}
{...(enableReordering ? listeners : {})}
>
{!hidePreview && (
<div className="image-gallery-card-preview">
{previewUrl ? (
@ -63,6 +103,13 @@ const ImageGalleryCard = ({
<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>
)}
@ -177,7 +224,9 @@ ImageGalleryCard.propTypes = {
showActions: PropTypes.bool,
index: PropTypes.number,
mode: PropTypes.oneOf(['group', 'moderation', 'preview', 'single-image']),
hidePreview: PropTypes.bool
hidePreview: PropTypes.bool,
enableReordering: PropTypes.bool,
isDragOverlay: PropTypes.bool
};
ImageGalleryCard.defaultProps = {
@ -187,7 +236,9 @@ ImageGalleryCard.defaultProps = {
isPending: false,
showActions: true,
mode: 'group',
hidePreview: false
hidePreview: false,
enableReordering: false,
isDragOverlay: false
};
export default ImageGalleryCard;

View File

@ -0,0 +1,111 @@
import { useState, useCallback } from 'react';
import reorderService from '../services/reorderService';
/**
* Custom Hook für Drag-and-Drop Reordering
* @param {Object[]} initialImages - Initiale Bilder-Array
* @param {string} groupId - Gruppen-ID für API-Calls
* @param {function} onSuccess - Success-Callback
* @param {function} onError - Error-Callback
*/
export const useReordering = (initialImages = [], groupId = null, onSuccess = null, onError = null) => {
const [images, setImages] = useState(initialImages);
const [isReordering, setIsReordering] = useState(false);
const [reorderError, setReorderError] = useState(null);
/**
* Handle drag end event from @dnd-kit
* @param {Object} event - DnD event with active and over properties
*/
const handleDragEnd = useCallback(async (event) => {
const { active, over } = event;
if (!over || active.id === over.id) {
return; // No change needed
}
const activeIndex = images.findIndex(img => img.id === active.id);
const overIndex = images.findIndex(img => img.id === over.id);
if (activeIndex === -1 || overIndex === -1) {
console.error('[useReordering] Invalid drag indices:', { activeIndex, overIndex });
return;
}
// Optimistic update - update UI immediately
const reorderedImages = reorderService.reorderArray(images, activeIndex, overIndex);
setImages(reorderedImages);
// Clear previous errors
setReorderError(null);
if (!groupId) {
console.warn('[useReordering] No groupId provided, skipping API call');
return;
}
setIsReordering(true);
try {
// Extract image IDs in new order
const newImageIds = reorderedImages.map(img => img.id);
// Save to backend
const response = await reorderService.updateImageOrder(groupId, newImageIds);
console.log('[useReordering] Reorder successful:', response);
// Call success callback if provided
if (onSuccess) {
onSuccess(reorderedImages, response);
}
} catch (error) {
console.error('[useReordering] Reorder failed:', error);
// Rollback optimistic update
setImages(images);
setReorderError(error.message || 'Failed to save new order');
// Call error callback if provided
if (onError) {
onError(error);
}
} finally {
setIsReordering(false);
}
}, [images, groupId, onSuccess, onError]);
/**
* Reset images to initial state
*/
const resetImages = useCallback(() => {
setImages(initialImages);
setReorderError(null);
}, [initialImages]);
/**
* Update images from external source (e.g., props change)
*/
const updateImages = useCallback((newImages) => {
setImages(newImages);
setReorderError(null);
}, []);
/**
* Clear error state
*/
const clearError = useCallback(() => {
setReorderError(null);
}, []);
return {
images,
isReordering,
reorderError,
handleDragEnd,
resetImages,
updateImages,
clearError
};
};

View File

@ -0,0 +1,82 @@
import { sendRequest } from './sendRequest';
/**
* Service für Drag-and-Drop Reordering von Bildern
*/
class ReorderService {
/**
* Reorder images within a group
* @param {string} groupId - The group ID
* @param {number[]} imageIds - Array of image IDs in new order
* @returns {Promise<Object>} API response with success status
*/
async updateImageOrder(groupId, imageIds) {
if (!groupId) {
throw new Error('Group ID is required');
}
if (!Array.isArray(imageIds) || imageIds.length === 0) {
throw new Error('Image IDs array is required and cannot be empty');
}
try {
const response = await sendRequest(`/api/groups/${groupId}/reorder`, 'PUT', {
imageIds: imageIds
});
if (!response.success) {
throw new Error(response.message || 'Failed to update image order');
}
return response;
} catch (error) {
console.error('[ReorderService] Error updating image order:', error);
throw error;
}
}
/**
* Validate that all image IDs belong to the specified group
* @param {Object[]} images - Array of image objects from the group
* @param {number[]} imageIds - Array of image IDs to validate
* @returns {boolean} True if all IDs are valid
*/
validateImageIds(images, imageIds) {
if (!Array.isArray(images) || !Array.isArray(imageIds)) {
return false;
}
const availableIds = images.map(img => img.id);
return imageIds.every(id => availableIds.includes(id));
}
/**
* Extract image IDs from image objects array
* @param {Object[]} images - Array of image objects
* @returns {number[]} Array of image IDs
*/
extractImageIds(images) {
if (!Array.isArray(images)) {
return [];
}
return images.map(img => img.id).filter(id => typeof id === 'number');
}
/**
* Reorder array based on new indices
* @param {Array} items - Original array to reorder
* @param {number} startIndex - Original index
* @param {number} endIndex - New index
* @returns {Array} Reordered array
*/
reorderArray(items, startIndex, endIndex) {
const result = Array.from(items);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
return result;
}
}
export default new ReorderService();