diff --git a/backend/src/repositories/GroupRepository.js b/backend/src/repositories/GroupRepository.js index 391d9b7..7a22228 100644 --- a/backend/src/repositories/GroupRepository.js +++ b/backend/src/repositories/GroupRepository.js @@ -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(); \ No newline at end of file diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index c4a3a97..26fbdde 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -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 }; \ No newline at end of file diff --git a/backend/src/routes/reorder.js b/backend/src/routes/reorder.js new file mode 100644 index 0000000..a0f6106 --- /dev/null +++ b/backend/src/routes/reorder.js @@ -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; \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c9523cd..d8b8eb6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index d3c86b9..99090e4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,20 +3,23 @@ "version": "0.1.0", "private": true, "dependencies": { - "@mui/material": "^5.14.0", - "@mui/icons-material": "^5.14.0", - "@emotion/react": "^11.11.0", - "@emotion/styled": "^11.11.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-helmet": "^6.1.0", "react-router-dom": "^6.28.0", "react-scripts": "5.0.1", "sass": "^1.32.8", diff --git a/frontend/src/Components/ComponentUtils/Css/ImageGallery.css b/frontend/src/Components/ComponentUtils/Css/ImageGallery.css index 6729003..9b0bbf8 100644 --- a/frontend/src/Components/ComponentUtils/Css/ImageGallery.css +++ b/frontend/src/Components/ComponentUtils/Css/ImageGallery.css @@ -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 { diff --git a/frontend/src/Components/ComponentUtils/ImageGallery.js b/frontend/src/Components/ComponentUtils/ImageGallery.js index 59951a5..6e8f4e6 100644 --- a/frontend/src/Components/ComponentUtils/ImageGallery.js +++ b/frontend/src/Components/ComponentUtils/ImageGallery.js @@ -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 (