From 7564525c7ed3e855a25bf8514b9ceecbe57e5ab9 Mon Sep 17 00:00:00 2001 From: "matthias.lotz" Date: Mon, 3 Nov 2025 21:06:39 +0100 Subject: [PATCH] feat: implement drag-and-drop reordering infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/src/repositories/GroupRepository.js | 63 ++++++++++ backend/src/routes/index.js | 2 + backend/src/routes/reorder.js | 100 ++++++++++++++++ frontend/package-lock.json | 56 +++++++++ frontend/package.json | 17 +-- .../ComponentUtils/Css/ImageGallery.css | 67 +++++++++++ .../Components/ComponentUtils/ImageGallery.js | 109 ++++++++++++++--- .../ComponentUtils/ImageGalleryCard.js | 59 +++++++++- frontend/src/hooks/useReordering.js | 111 ++++++++++++++++++ frontend/src/services/reorderService.js | 82 +++++++++++++ 10 files changed, 636 insertions(+), 30 deletions(-) create mode 100644 backend/src/routes/reorder.js create mode 100644 frontend/src/hooks/useReordering.js create mode 100644 frontend/src/services/reorderService.js 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 (
@@ -22,28 +68,49 @@ const ImageGallery = ({ ); } + const itemIds = items.map(item => item.id || item.groupId); + + const galleryContent = ( +
+ {items.map((item, index) => ( + + ))} +
+ ); + return (
{title && (

{title}

)} -
- {items.map((item, index) => ( -
- -
- ))} -
+ {enableReordering ? ( + + + {galleryContent} + + + ) : ( + galleryContent + )}
); }; @@ -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; diff --git a/frontend/src/Components/ComponentUtils/ImageGalleryCard.js b/frontend/src/Components/ComponentUtils/ImageGalleryCard.js index 834adac..c969793 100644 --- a/frontend/src/Components/ComponentUtils/ImageGalleryCard.js +++ b/frontend/src/Components/ComponentUtils/ImageGalleryCard.js @@ -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 ( -
+
{!hidePreview && (
{previewUrl ? ( @@ -63,6 +103,13 @@ const ImageGalleryCard = ({
Kein Vorschaubild
)} + {/* Drag Handle - nur sichtbar wenn Reordering aktiviert */} + {enableReordering && ( +
+ ≡≡ +
+ )} + {mode === 'preview' && index !== undefined && (
{index + 1}
)} @@ -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; diff --git a/frontend/src/hooks/useReordering.js b/frontend/src/hooks/useReordering.js new file mode 100644 index 0000000..791759c --- /dev/null +++ b/frontend/src/hooks/useReordering.js @@ -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 + }; +}; \ No newline at end of file diff --git a/frontend/src/services/reorderService.js b/frontend/src/services/reorderService.js new file mode 100644 index 0000000..815aed5 --- /dev/null +++ b/frontend/src/services/reorderService.js @@ -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} 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(); \ No newline at end of file