Преглед на файлове

Allow dragging folders into other folders

Backend:
- Extend PUT /api/directories/:id to accept parentId for reparenting
- Server-side cycle check: walks ancestry of target, rejects if it
  passes through the directory being moved (400 with clear message)
- Name and parentId can be updated independently or together

Frontend:
- API.moveDir(id, parentId) sends PUT with { parentId }
- Dir items are now draggable (draggable="true"); .dir-item.dragging
  dims them like track items during drag
- dragstart on a dir sets dragDirId; dragend clears it
- Drop targets accept both track drags and dir drags
- Client-side isValidDirDrop() walks dirMeta to reject self-drops and
  descendant-drops before even showing the highlight; backend validates
  again as a safety net
- confirmMoveDir() shows confirmation dialog then calls API.moveDir
- Auto-scroll works for folder drags too (dragDirId check added)

Backend restart required (directories route changed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
k4be преди 7 часа
родител
ревизия
396bcc0784
променени са 4 файла, в които са добавени 112 реда и са изтрити 16 реда
  1. 33 4
      gpx-vis-backend/src/routes/directories.js
  2. 2 1
      gpx-vis-frontend/css/style.css
  3. 1 0
      gpx-vis-frontend/js/api.js
  4. 76 11
      gpx-vis-frontend/js/browser.js

+ 33 - 4
gpx-vis-backend/src/routes/directories.js

@@ -62,14 +62,43 @@ router.post('/', requireAuth, async (req, res) => {
   }
 });
 
-// Rename directory
+// Update directory (rename and/or move)
 router.put('/:id', requireAuth, async (req, res) => {
   try {
     const dir = await Directory.findOne({ where: { id: req.params.id, userId: req.user.id } });
     if (!dir) return res.status(404).json({ error: 'Directory not found' });
-    const { name } = req.body;
-    if (!name || name.trim().length === 0) return res.status(400).json({ error: 'Name required' });
-    await dir.update({ name: name.trim() });
+
+    const updates = {};
+
+    if (req.body.name !== undefined) {
+      const name = req.body.name;
+      if (!name || name.trim().length === 0) return res.status(400).json({ error: 'Name required' });
+      updates.name = name.trim();
+    }
+
+    if ('parentId' in req.body) {
+      const newParentId = req.body.parentId || null;
+      if (newParentId !== null) {
+        if (newParentId === dir.id) return res.status(400).json({ error: 'Cannot move a directory into itself' });
+        const parent = await Directory.findOne({ where: { id: newParentId, userId: req.user.id } });
+        if (!parent) return res.status(404).json({ error: 'Target directory not found' });
+        // Cycle check: walk up from newParentId; if we hit dir.id it would be a circular reference
+        let cur = parent.parentId;
+        const seen = new Set();
+        while (cur !== null && cur !== undefined) {
+          if (cur === dir.id) return res.status(400).json({ error: 'Cannot move a directory into its own descendant' });
+          if (seen.has(cur)) break;
+          seen.add(cur);
+          const p = await Directory.findOne({ where: { id: cur, userId: req.user.id } });
+          if (!p) break;
+          cur = p.parentId;
+        }
+      }
+      updates.parentId = newParentId;
+    }
+
+    if (Object.keys(updates).length === 0) return res.status(400).json({ error: 'Nothing to update' });
+    await dir.update(updates);
     res.json(dir);
   } catch (e) {
     res.status(500).json({ error: 'Server error' });

+ 2 - 1
gpx-vis-frontend/css/style.css

@@ -502,7 +502,8 @@ form button[type="submit"]:hover {
 }
 
 /* ===== Dragging track ===== */
-.track-item.dragging {
+.track-item.dragging,
+.dir-item.dragging {
   opacity: 0.4;
 }
 

+ 1 - 0
gpx-vis-frontend/js/api.js

@@ -37,6 +37,7 @@ const API = (() => {
     getDir: (id) => request('GET', `/api/directories/${id}`),
     createDir: (name, parentId) => request('POST', '/api/directories', { name, parentId }),
     renameDir: (id, name) => request('PUT', `/api/directories/${id}`, { name }),
+    moveDir: (id, parentId) => request('PUT', `/api/directories/${id}`, { parentId }),
     deleteDir: (id) => request('DELETE', `/api/directories/${id}`),
 
     // Tracks

+ 76 - 11
gpx-vis-frontend/js/browser.js

@@ -4,6 +4,7 @@ const Browser = (() => {
   let dirContents = {};  // 'root' | dirId -> { dirs, tracks }
   let dirMeta = {};      // dirId -> { id, name, parentId }
   let dragTrackIds = [];   // ids being dragged (may be multiple)
+  let dragDirId = null;    // dir id being dragged (single)
   let allDirs = [];  // flat list for move dialog
 
   // ===== Multi-select state =====
@@ -120,7 +121,7 @@ const Browser = (() => {
       const isSelected = selectedDirId === dir.id;
       html += `<div class="tree-item dir-item${isSelected ? ' selected' : ''}"
         data-id="${dir.id}" data-name="${escAttr(dir.name)}"
-        style="padding-left:${12 + baseIndent}px" data-drop-target="true">
+        style="padding-left:${12 + baseIndent}px" data-drop-target="true" draggable="true">
         <span class="expand-btn${isExpanded ? ' expanded' : ''}" data-expand="${dir.id}"></span>
         <span class="item-icon">📁</span>
         <span class="item-name">${escHtml(dir.name)}</span>
@@ -260,7 +261,6 @@ const Browser = (() => {
     container.querySelectorAll('.track-item[draggable="true"]').forEach(el => {
       el.addEventListener('dragstart', (e) => {
         const id = parseInt(el.dataset.id);
-        // If dragging a selected item, drag all selected; otherwise just this one
         if (selectedTrackIds.has(id)) {
           dragTrackIds = [...selectedTrackIds];
         } else {
@@ -281,10 +281,27 @@ const Browser = (() => {
       });
     });
 
+    // Drag folders
+    container.querySelectorAll('.dir-item[draggable="true"]').forEach(el => {
+      el.addEventListener('dragstart', (e) => {
+        e.stopPropagation();
+        dragDirId = parseInt(el.dataset.id);
+        e.dataTransfer.effectAllowed = 'move';
+        e.dataTransfer.setData('text/plain', String(dragDirId));
+        el.classList.add('dragging');
+      });
+      el.addEventListener('dragend', () => {
+        el.classList.remove('dragging');
+        container.querySelectorAll('.drag-target').forEach(d => d.classList.remove('drag-target'));
+        dragDirId = null;
+        stopAutoScroll();
+      });
+    });
+
     // Auto-scroll when dragging near list edges
     const list = document.getElementById('browser-list');
     list.addEventListener('dragover', (e) => {
-      if (dragTrackIds.length === 0) return;
+      if (dragTrackIds.length === 0 && dragDirId === null) return;
       const rect = list.getBoundingClientRect();
       const ZONE = 48;
       if (e.clientY < rect.top + ZONE) {
@@ -302,14 +319,36 @@ const Browser = (() => {
     // Drop targets (folders)
     container.querySelectorAll('.dir-item[data-drop-target]').forEach(el => {
       el._dragCount = 0;
+
+      function isDragging() { return dragTrackIds.length > 0 || dragDirId !== null; }
+
+      // For dir drags: reject drop onto self or own descendant
+      function isValidDirDrop(targetId) {
+        if (dragDirId === null) return false;
+        if (dragDirId === targetId) return false;
+        // Walk up from targetId through dirMeta; if we hit dragDirId it's a descendant
+        let cur = targetId;
+        const seen = new Set();
+        while (cur !== null && cur !== undefined) {
+          if (seen.has(cur)) break;
+          seen.add(cur);
+          const meta = dirMeta[cur];
+          if (!meta) break;
+          cur = meta.parentId;
+          if (cur === dragDirId) return false;
+        }
+        return true;
+      }
+
       el.addEventListener('dragenter', (e) => {
-        if (dragTrackIds.length === 0) return;
+        if (!isDragging()) return;
+        if (dragDirId !== null && !isValidDirDrop(parseInt(el.dataset.id))) return;
         e.preventDefault();
         el._dragCount = (el._dragCount || 0) + 1;
         el.classList.add('drag-target');
       });
       el.addEventListener('dragleave', () => {
-        if (dragTrackIds.length === 0) return;
+        if (!isDragging()) return;
         el._dragCount = (el._dragCount || 1) - 1;
         if (el._dragCount <= 0) {
           el._dragCount = 0;
@@ -317,7 +356,8 @@ const Browser = (() => {
         }
       });
       el.addEventListener('dragover', (e) => {
-        if (dragTrackIds.length === 0) return;
+        if (!isDragging()) return;
+        if (dragDirId !== null && !isValidDirDrop(parseInt(el.dataset.id))) return;
         e.preventDefault();
         e.dataTransfer.dropEffect = 'move';
       });
@@ -326,13 +366,20 @@ const Browser = (() => {
         e.stopPropagation();
         el._dragCount = 0;
         el.classList.remove('drag-target');
-        if (dragTrackIds.length === 0) return;
         const targetDirId = parseInt(el.dataset.id);
         const targetDirName = el.dataset.name;
-        const ids = dragTrackIds.slice();
-        dragTrackIds = [];
-        stopAutoScroll();
-        confirmMoveTracks(ids, targetDirId, targetDirName);
+        if (dragTrackIds.length > 0) {
+          const ids = dragTrackIds.slice();
+          dragTrackIds = [];
+          stopAutoScroll();
+          confirmMoveTracks(ids, targetDirId, targetDirName);
+        } else if (dragDirId !== null && isValidDirDrop(targetDirId)) {
+          const srcId = dragDirId;
+          const srcName = dirMeta[srcId]?.name || 'folder';
+          dragDirId = null;
+          stopAutoScroll();
+          confirmMoveDir(srcId, srcName, targetDirId, targetDirName);
+        }
       });
     });
   }
@@ -588,6 +635,24 @@ const Browser = (() => {
     };
   }
 
+  function confirmMoveDir(srcId, srcName, targetDirId, targetDirName) {
+    document.getElementById('confirm-title').textContent = 'Move Folder';
+    document.getElementById('confirm-message').textContent =
+      `Move folder "${srcName}" into "${targetDirName}"?`;
+    const modal = document.getElementById('confirm-modal');
+    modal.style.display = 'flex';
+    document.getElementById('confirm-ok-btn').onclick = async () => {
+      modal.style.display = 'none';
+      try {
+        await API.moveDir(srcId, targetDirId);
+        await reload();
+        showToast('Folder moved', 'success');
+      } catch (e) {
+        showToast('Error: ' + e.message, 'error');
+      }
+    };
+  }
+
   async function showMoveDialog(trackId, trackName) {
     try {
       allDirs = await loadAllDirs();