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:
parent
abd12923aa
commit
7564525c7e
|
|
@ -311,6 +311,69 @@ class GroupRepository {
|
||||||
latestUpload: latestGroup ? latestGroup.upload_date : null
|
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();
|
module.exports = new GroupRepository();
|
||||||
|
|
@ -3,9 +3,11 @@ const downloadRouter = require('./download');
|
||||||
const batchUploadRouter = require('./batchUpload');
|
const batchUploadRouter = require('./batchUpload');
|
||||||
const groupsRouter = require('./groups');
|
const groupsRouter = require('./groups');
|
||||||
const migrationRouter = require('./migration');
|
const migrationRouter = require('./migration');
|
||||||
|
const reorderRouter = require('./reorder');
|
||||||
|
|
||||||
const renderRoutes = (app) => {
|
const renderRoutes = (app) => {
|
||||||
[uploadRouter, downloadRouter, batchUploadRouter, groupsRouter, migrationRouter].forEach(router => app.use('/', router));
|
[uploadRouter, downloadRouter, batchUploadRouter, groupsRouter, migrationRouter].forEach(router => app.use('/', router));
|
||||||
|
app.use('/api/groups', reorderRouter);
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { renderRoutes };
|
module.exports = { renderRoutes };
|
||||||
100
backend/src/routes/reorder.js
Normal file
100
backend/src/routes/reorder.js
Normal 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;
|
||||||
56
frontend/package-lock.json
generated
56
frontend/package-lock.json
generated
|
|
@ -8,6 +8,9 @@
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"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/react": "^11.11.0",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.11.0",
|
||||||
"@mui/icons-material": "^5.14.0",
|
"@mui/icons-material": "^5.14.0",
|
||||||
|
|
@ -2233,6 +2236,59 @@
|
||||||
"postcss-selector-parser": "^6.0.10"
|
"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": {
|
"node_modules/@emotion/babel-plugin": {
|
||||||
"version": "11.13.5",
|
"version": "11.13.5",
|
||||||
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
|
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,23 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mui/material": "^5.14.0",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@mui/icons-material": "^5.14.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@emotion/react": "^11.11.0",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@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/jest-dom": "^5.11.9",
|
||||||
"@testing-library/react": "^11.2.5",
|
"@testing-library/react": "^11.2.5",
|
||||||
"@testing-library/user-event": "^12.8.3",
|
"@testing-library/user-event": "^12.8.3",
|
||||||
"axios": "^0.21.1",
|
"axios": "^0.21.1",
|
||||||
|
"lottie-react": "^2.4.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
|
||||||
"react-code-blocks": "^0.0.8",
|
"react-code-blocks": "^0.0.8",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
"react-dropzone": "^11.3.1",
|
"react-dropzone": "^11.3.1",
|
||||||
"react-helmet": "^6.1.0",
|
"react-helmet": "^6.1.0",
|
||||||
"lottie-react": "^2.4.0",
|
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"sass": "^1.32.8",
|
"sass": "^1.32.8",
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
/* Responsive: 1 column on mobile */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.image-gallery-grid {
|
.image-gallery-grid {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,19 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
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 ImageGalleryCard from './ImageGalleryCard';
|
||||||
import './Css/ImageGallery.css';
|
import './Css/ImageGallery.css';
|
||||||
|
|
||||||
|
|
@ -12,8 +26,40 @@ const ImageGallery = ({
|
||||||
showActions,
|
showActions,
|
||||||
mode,
|
mode,
|
||||||
title,
|
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) {
|
if (!items || items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="image-gallery-empty">
|
<div className="image-gallery-empty">
|
||||||
|
|
@ -22,28 +68,49 @@ const ImageGallery = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const itemIds = items.map(item => item.id || item.groupId);
|
||||||
|
|
||||||
|
const galleryContent = (
|
||||||
|
<div className="image-gallery-grid">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<ImageGalleryCard
|
||||||
|
key={item.id || item.groupId || index}
|
||||||
|
item={item}
|
||||||
|
index={index}
|
||||||
|
onApprove={onApprove}
|
||||||
|
onViewImages={onViewImages}
|
||||||
|
onDelete={onDelete}
|
||||||
|
isPending={isPending}
|
||||||
|
showActions={showActions}
|
||||||
|
mode={mode}
|
||||||
|
enableReordering={enableReordering}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="image-gallery-container">
|
<div className="image-gallery-container">
|
||||||
{title && (
|
{title && (
|
||||||
<h2 className="image-gallery-title">{title}</h2>
|
<h2 className="image-gallery-title">{title}</h2>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="image-gallery-grid">
|
{enableReordering ? (
|
||||||
{items.map((item, index) => (
|
<DndContext
|
||||||
<div key={item.id || item.groupId || index} className="grid-item-stretch">
|
sensors={sensors}
|
||||||
<ImageGalleryCard
|
collisionDetection={closestCenter}
|
||||||
item={item}
|
onDragEnd={handleDragEnd}
|
||||||
index={index}
|
>
|
||||||
onApprove={onApprove}
|
<SortableContext
|
||||||
onViewImages={onViewImages}
|
items={itemIds}
|
||||||
onDelete={onDelete}
|
strategy={rectSortingStrategy}
|
||||||
isPending={isPending}
|
>
|
||||||
showActions={showActions}
|
{galleryContent}
|
||||||
mode={mode}
|
</SortableContext>
|
||||||
/>
|
</DndContext>
|
||||||
</div>
|
) : (
|
||||||
))}
|
galleryContent
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -57,7 +124,9 @@ ImageGallery.propTypes = {
|
||||||
showActions: PropTypes.bool,
|
showActions: PropTypes.bool,
|
||||||
mode: PropTypes.oneOf(['group', 'moderation', 'preview', 'single-image']),
|
mode: PropTypes.oneOf(['group', 'moderation', 'preview', 'single-image']),
|
||||||
title: PropTypes.string,
|
title: PropTypes.string,
|
||||||
emptyMessage: PropTypes.string
|
emptyMessage: PropTypes.string,
|
||||||
|
enableReordering: PropTypes.bool,
|
||||||
|
onReorder: PropTypes.func
|
||||||
};
|
};
|
||||||
|
|
||||||
ImageGallery.defaultProps = {
|
ImageGallery.defaultProps = {
|
||||||
|
|
@ -66,7 +135,9 @@ ImageGallery.defaultProps = {
|
||||||
onDelete: () => {},
|
onDelete: () => {},
|
||||||
isPending: false,
|
isPending: false,
|
||||||
showActions: true,
|
showActions: true,
|
||||||
mode: 'group'
|
mode: 'group',
|
||||||
|
enableReordering: false,
|
||||||
|
onReorder: null
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ImageGallery;
|
export default ImageGallery;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
|
||||||
import './Css/ImageGallery.css';
|
import './Css/ImageGallery.css';
|
||||||
import { getImageSrc, getGroupPreviewSrc } from '../../Utils/imageUtils';
|
import { getImageSrc, getGroupPreviewSrc } from '../../Utils/imageUtils';
|
||||||
|
|
@ -13,7 +15,9 @@ const ImageGalleryCard = ({
|
||||||
showActions = true,
|
showActions = true,
|
||||||
index,
|
index,
|
||||||
mode = 'group', // 'group', 'moderation', or 'preview'
|
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
|
// Handle both group data and individual image preview data
|
||||||
let previewUrl = null;
|
let previewUrl = null;
|
||||||
|
|
@ -53,8 +57,44 @@ const ImageGalleryCard = ({
|
||||||
itemId = group.groupId;
|
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 (
|
return (
|
||||||
<div className={`image-gallery-card ${isPending ? 'pending' : 'approved'} card-stretch`}>
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={cardClasses}
|
||||||
|
{...attributes}
|
||||||
|
{...(enableReordering ? listeners : {})}
|
||||||
|
>
|
||||||
{!hidePreview && (
|
{!hidePreview && (
|
||||||
<div className="image-gallery-card-preview">
|
<div className="image-gallery-card-preview">
|
||||||
{previewUrl ? (
|
{previewUrl ? (
|
||||||
|
|
@ -63,6 +103,13 @@ const ImageGalleryCard = ({
|
||||||
<div className="image-gallery-card-no-preview">Kein Vorschaubild</div>
|
<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 && (
|
{mode === 'preview' && index !== undefined && (
|
||||||
<div className="image-gallery-card-image-order">{index + 1}</div>
|
<div className="image-gallery-card-image-order">{index + 1}</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -177,7 +224,9 @@ ImageGalleryCard.propTypes = {
|
||||||
showActions: PropTypes.bool,
|
showActions: PropTypes.bool,
|
||||||
index: PropTypes.number,
|
index: PropTypes.number,
|
||||||
mode: PropTypes.oneOf(['group', 'moderation', 'preview', 'single-image']),
|
mode: PropTypes.oneOf(['group', 'moderation', 'preview', 'single-image']),
|
||||||
hidePreview: PropTypes.bool
|
hidePreview: PropTypes.bool,
|
||||||
|
enableReordering: PropTypes.bool,
|
||||||
|
isDragOverlay: PropTypes.bool
|
||||||
};
|
};
|
||||||
|
|
||||||
ImageGalleryCard.defaultProps = {
|
ImageGalleryCard.defaultProps = {
|
||||||
|
|
@ -187,7 +236,9 @@ ImageGalleryCard.defaultProps = {
|
||||||
isPending: false,
|
isPending: false,
|
||||||
showActions: true,
|
showActions: true,
|
||||||
mode: 'group',
|
mode: 'group',
|
||||||
hidePreview: false
|
hidePreview: false,
|
||||||
|
enableReordering: false,
|
||||||
|
isDragOverlay: false
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ImageGalleryCard;
|
export default ImageGalleryCard;
|
||||||
|
|
|
||||||
111
frontend/src/hooks/useReordering.js
Normal file
111
frontend/src/hooks/useReordering.js
Normal 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
|
||||||
|
};
|
||||||
|
};
|
||||||
82
frontend/src/services/reorderService.js
Normal file
82
frontend/src/services/reorderService.js
Normal 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();
|
||||||
Loading…
Reference in New Issue
Block a user