فهرست منبع

Add file browser module with upload, share, and move

k4be 7 ساعت پیش
والد
کامیت
a7cfe933a0
1فایلهای تغییر یافته به همراه438 افزوده شده و 0 حذف شده
  1. 438 0
      gpx-vis-frontend/js/browser.js

+ 438 - 0
gpx-vis-frontend/js/browser.js

@@ -0,0 +1,438 @@
+const Browser = (() => {
+  let currentDirId = null; // null = root
+  let dirStack = []; // breadcrumb stack: [{id, name}]
+  let allDirs = []; // flat list for move dialog
+
+  async function init() {
+    document.getElementById('new-dir-btn').addEventListener('click', createDirPrompt);
+    document.getElementById('upload-btn').addEventListener('click', () => {
+      document.getElementById('file-input').click();
+    });
+    document.getElementById('file-input').addEventListener('change', handleFileUpload);
+
+    // Drop zone on map
+    setupDropZone();
+
+    await loadDir(null);
+  }
+
+  async function loadDir(dirId) {
+    currentDirId = dirId;
+    renderBreadcrumb();
+
+    try {
+      let dirs, tracks;
+      if (dirId === null) {
+        dirs = await API.getDirs();
+        const allTracks = await API.getTracks('');
+        tracks = allTracks;
+      } else {
+        const data = await API.getDir(dirId);
+        dirs = data.children || [];
+        tracks = data.tracks || [];
+      }
+      renderList(dirs, tracks);
+    } catch (e) {
+      showToast('Error loading directory: ' + e.message, 'error');
+      renderList([], []);
+    }
+  }
+
+  function renderBreadcrumb() {
+    const bc = document.getElementById('breadcrumb');
+    let html = '<span class="bc-item" data-id="null">Root</span>';
+    dirStack.forEach((item) => {
+      html += ' <span class="bc-sep">›</span> ';
+      html += `<span class="bc-item" data-id="${item.id}">${escHtml(item.name)}</span>`;
+    });
+    bc.innerHTML = html;
+    bc.querySelectorAll('.bc-item').forEach(el => {
+      el.addEventListener('click', () => {
+        const id = el.dataset.id === 'null' ? null : parseInt(el.dataset.id);
+        if (id === null) {
+          dirStack = [];
+        } else {
+          const idx = dirStack.findIndex(d => d.id === id);
+          if (idx >= 0) dirStack = dirStack.slice(0, idx + 1);
+        }
+        loadDir(id);
+      });
+    });
+  }
+
+  function renderList(dirs, tracks) {
+    const list = document.getElementById('browser-list');
+    let html = '';
+
+    if (dirs.length === 0 && tracks.length === 0) {
+      html = '<div class="empty-list">No files here. Upload a GPX or create a folder.</div>';
+    }
+
+    for (const dir of dirs) {
+      html += `<div class="dir-item" data-id="${dir.id}">
+        <span class="item-icon">📁</span>
+        <span class="item-name">${escHtml(dir.name)}</span>
+        <span class="item-date">${formatDate(dir.updatedAt)}</span>
+        <span class="item-actions">
+          <button class="item-btn rename-dir-btn" data-id="${dir.id}" data-name="${escAttr(dir.name)}" title="Rename">✏️</button>
+          <button class="item-btn delete-dir-btn" data-id="${dir.id}" data-name="${escAttr(dir.name)}" title="Delete">🗑️</button>
+        </span>
+      </div>`;
+    }
+
+    for (const track of tracks) {
+      const dist = track.totalDistance ? formatDistance(track.totalDistance) : '';
+      html += `<div class="track-item" data-id="${track.id}">
+        <span class="item-icon">🗺️</span>
+        <span class="item-name">${escHtml(track.name)}</span>
+        <span class="item-meta">${dist}</span>
+        <span class="item-date">${formatDate(track.trackDate || track.uploadDate)}</span>
+        <span class="item-actions">
+          <button class="item-btn view-track-btn" data-id="${track.id}" title="View on map">👁️</button>
+          <button class="item-btn share-track-btn" data-id="${track.id}" title="Share">🔗</button>
+          <button class="item-btn move-track-btn" data-id="${track.id}" data-name="${escAttr(track.name)}" title="Move">📂</button>
+          <button class="item-btn delete-track-btn" data-id="${track.id}" data-name="${escAttr(track.name)}" title="Delete">🗑️</button>
+        </span>
+      </div>`;
+    }
+
+    list.innerHTML = html;
+
+    // Bind directory click (navigate into)
+    list.querySelectorAll('.dir-item').forEach(el => {
+      el.addEventListener('click', (e) => {
+        if (e.target.closest('.item-actions')) return;
+        const id = parseInt(el.dataset.id);
+        const name = el.querySelector('.item-name').textContent;
+        dirStack.push({ id, name });
+        loadDir(id);
+      });
+    });
+
+    list.querySelectorAll('.rename-dir-btn').forEach(btn => {
+      btn.addEventListener('click', (e) => {
+        e.stopPropagation();
+        renameDir(parseInt(btn.dataset.id), btn.dataset.name);
+      });
+    });
+
+    list.querySelectorAll('.delete-dir-btn').forEach(btn => {
+      btn.addEventListener('click', (e) => {
+        e.stopPropagation();
+        confirmDelete('directory', btn.dataset.name, () => deleteDir(parseInt(btn.dataset.id)));
+      });
+    });
+
+    // Bind track click (open on map)
+    list.querySelectorAll('.track-item').forEach(el => {
+      el.addEventListener('click', (e) => {
+        if (e.target.closest('.item-actions')) return;
+        openTrack(parseInt(el.dataset.id));
+      });
+    });
+
+    list.querySelectorAll('.view-track-btn').forEach(btn => {
+      btn.addEventListener('click', (e) => {
+        e.stopPropagation();
+        openTrack(parseInt(btn.dataset.id));
+      });
+    });
+
+    list.querySelectorAll('.share-track-btn').forEach(btn => {
+      btn.addEventListener('click', (e) => {
+        e.stopPropagation();
+        shareTrack(parseInt(btn.dataset.id));
+      });
+    });
+
+    list.querySelectorAll('.move-track-btn').forEach(btn => {
+      btn.addEventListener('click', (e) => {
+        e.stopPropagation();
+        showMoveDialog(parseInt(btn.dataset.id), btn.dataset.name);
+      });
+    });
+
+    list.querySelectorAll('.delete-track-btn').forEach(btn => {
+      btn.addEventListener('click', (e) => {
+        e.stopPropagation();
+        confirmDelete('track', btn.dataset.name, () => deleteTrack(parseInt(btn.dataset.id)));
+      });
+    });
+  }
+
+  async function openTrack(trackId) {
+    try {
+      if (MapView.hasTrack(trackId)) {
+        // Toggle off
+        MapView.removeTrack(trackId);
+        MapView.setCurrentTrack(null);
+        document.getElementById('track-info-panel').style.display = 'none';
+        return;
+      }
+      const data = await API.getTrackPoints(trackId);
+      MapView.addTrack(data, trackId);
+      MapView.fitTrack(trackId);
+      MapView.setCurrentTrack(trackId);
+      showTrackInfo(data.meta);
+    } catch (e) {
+      showToast('Error loading track: ' + e.message, 'error');
+    }
+  }
+
+  function showTrackInfo(meta) {
+    if (!meta) return;
+    const panel = document.getElementById('track-info-panel');
+    document.getElementById('track-info-content').innerHTML = `
+      <h3>${escHtml(meta.name || 'Track')}</h3>
+      <div class="info-row"><label>Distance</label><span>${formatDistance(meta.totalDistance)}</span></div>
+      <div class="info-row"><label>Points</label><span>${meta.pointCount || 0}</span></div>
+      ${meta.trackDate ? `<div class="info-row"><label>Date</label><span>${formatDate(meta.trackDate)}</span></div>` : ''}
+    `;
+    panel.style.display = 'block';
+    document.getElementById('track-info-close').onclick = () => { panel.style.display = 'none'; };
+  }
+
+  async function createDirPrompt() {
+    const name = prompt('Folder name:');
+    if (!name || !name.trim()) return;
+    try {
+      await API.createDir(name.trim(), currentDirId);
+      await loadDir(currentDirId);
+    } catch (e) {
+      showToast('Error: ' + e.message, 'error');
+    }
+  }
+
+  async function renameDir(id, currentName) {
+    const name = prompt('New name:', currentName);
+    if (!name || !name.trim() || name.trim() === currentName) return;
+    try {
+      await API.renameDir(id, name.trim());
+      await loadDir(currentDirId);
+    } catch (e) {
+      showToast('Error: ' + e.message, 'error');
+    }
+  }
+
+  async function deleteDir(id) {
+    try {
+      await API.deleteDir(id);
+      await loadDir(currentDirId);
+    } catch (e) {
+      showToast('Error: ' + e.message, 'error');
+    }
+  }
+
+  async function deleteTrack(id) {
+    try {
+      MapView.removeTrack(id);
+      await API.deleteTrack(id);
+      await loadDir(currentDirId);
+    } catch (e) {
+      showToast('Error: ' + e.message, 'error');
+    }
+  }
+
+  async function handleFileUpload(e) {
+    const files = Array.from(e.target.files);
+    if (files.length === 0) return;
+    e.target.value = '';
+
+    for (const file of files) {
+      showUploadToast(`Uploading ${file.name}...`);
+      try {
+        await API.uploadTrack(file, currentDirId, null);
+        showToast(`Uploaded: ${file.name}`, 'success');
+      } catch (err) {
+        showToast(`Error uploading ${file.name}: ${err.message}`, 'error');
+      }
+    }
+    hideUploadToast();
+    await loadDir(currentDirId);
+  }
+
+  function setupDropZone() {
+    const mapContainer = document.getElementById('map-container');
+    mapContainer.addEventListener('dragover', (e) => {
+      e.preventDefault();
+      mapContainer.classList.add('drag-over');
+    });
+    mapContainer.addEventListener('dragleave', (e) => {
+      if (!mapContainer.contains(e.relatedTarget)) {
+        mapContainer.classList.remove('drag-over');
+      }
+    });
+    mapContainer.addEventListener('drop', async (e) => {
+      e.preventDefault();
+      mapContainer.classList.remove('drag-over');
+      const files = Array.from(e.dataTransfer.files).filter(f => f.name.toLowerCase().endsWith('.gpx'));
+      if (files.length === 0) {
+        showToast('No GPX files found in drop', 'error');
+        return;
+      }
+      for (const file of files) {
+        showUploadToast(`Uploading ${file.name}...`);
+        try {
+          await API.uploadTrack(file, currentDirId, null);
+          showToast(`Uploaded: ${file.name}`, 'success');
+        } catch (err) {
+          showToast(`Error: ${err.message}`, 'error');
+        }
+      }
+      hideUploadToast();
+      await loadDir(currentDirId);
+    });
+  }
+
+  async function shareTrack(trackId) {
+    try {
+      const track = await API.getTrack(trackId);
+      const modal = document.getElementById('share-modal');
+      const content = document.getElementById('share-content');
+
+      const existingCode = track.ShareLink?.code || track.shareCode;
+
+      if (existingCode) {
+        const shareUrl = buildShareUrl(existingCode);
+        content.innerHTML = `
+          <p>Share link:</p>
+          <div class="share-url-box">
+            <input type="text" id="share-url-input" value="${escAttr(shareUrl)}" readonly>
+            <button id="copy-share-btn">Copy</button>
+          </div>
+          <div style="margin-top:12px">
+            <button id="share-revoke-btn" class="btn-danger">Revoke link</button>
+          </div>
+        `;
+        content.querySelector('#copy-share-btn').addEventListener('click', () => {
+          navigator.clipboard.writeText(shareUrl).then(() => showToast('Copied!', 'success')).catch(() => {
+            document.getElementById('share-url-input').select();
+            document.execCommand('copy');
+            showToast('Copied!', 'success');
+          });
+        });
+        content.querySelector('#share-revoke-btn').addEventListener('click', async () => {
+          try {
+            await API.deleteShare(trackId);
+            modal.style.display = 'none';
+            showToast('Share link revoked', 'success');
+          } catch (err) {
+            showToast('Error: ' + err.message, 'error');
+          }
+        });
+      } else {
+        content.innerHTML = `
+          <p>No share link yet.</p>
+          <button id="share-create-btn" class="btn-primary">Create share link</button>
+        `;
+        content.querySelector('#share-create-btn').addEventListener('click', async () => {
+          try {
+            const res = await API.createShare(trackId);
+            const shareUrl = buildShareUrl(res.code);
+            content.innerHTML = `
+              <p>Share link created:</p>
+              <div class="share-url-box">
+                <input type="text" id="share-url-input-new" value="${escAttr(shareUrl)}" readonly>
+                <button id="copy-share-new-btn">Copy</button>
+              </div>
+            `;
+            content.querySelector('#copy-share-new-btn').addEventListener('click', () => {
+              navigator.clipboard.writeText(shareUrl).then(() => showToast('Copied!', 'success')).catch(() => {
+                document.getElementById('share-url-input-new').select();
+                document.execCommand('copy');
+                showToast('Copied!', 'success');
+              });
+            });
+          } catch (err) {
+            showToast('Error: ' + err.message, 'error');
+          }
+        });
+      }
+
+      modal.style.display = 'flex';
+    } catch (e) {
+      showToast('Error: ' + e.message, 'error');
+    }
+  }
+
+  function buildShareUrl(code) {
+    const base = window.location.origin + window.location.pathname;
+    // Ensure we link to share/CODE relative to the app root
+    const appRoot = base.replace(/\/[^/]*$/, '/');
+    return appRoot + 'share/' + code;
+  }
+
+  async function showMoveDialog(trackId, trackName) {
+    try {
+      allDirs = await loadAllDirs();
+      const list = document.getElementById('move-dir-list');
+      let html = '<div class="move-dir-item" data-id=""><span>📁</span> (Root)</div>';
+      for (const dir of allDirs) {
+        html += `<div class="move-dir-item" data-id="${dir.id}"><span>📁</span> ${escHtml(dir.path)}</div>`;
+      }
+      list.innerHTML = html;
+
+      let selectedDirId = null;
+      list.querySelectorAll('.move-dir-item').forEach(el => {
+        el.addEventListener('click', () => {
+          list.querySelectorAll('.move-dir-item').forEach(e => e.classList.remove('selected'));
+          el.classList.add('selected');
+          selectedDirId = el.dataset.id || null;
+        });
+      });
+
+      document.getElementById('move-confirm-btn').onclick = async () => {
+        try {
+          await API.updateTrack(trackId, { directoryId: selectedDirId || null });
+          document.getElementById('move-modal').style.display = 'none';
+          await loadDir(currentDirId);
+          showToast('Track moved', 'success');
+        } catch (e) {
+          showToast('Error: ' + e.message, 'error');
+        }
+      };
+
+      document.getElementById('move-modal').style.display = 'flex';
+    } catch (e) {
+      showToast('Error: ' + e.message, 'error');
+    }
+  }
+
+  async function loadAllDirs(parentId, prefix) {
+    const result = [];
+    let dirs;
+    try {
+      if (parentId !== undefined && parentId !== null) {
+        const data = await API.getDir(parentId);
+        dirs = data.children || [];
+      } else {
+        dirs = await API.getDirs();
+      }
+    } catch (e) {
+      return result;
+    }
+    for (const d of dirs) {
+      const path = (prefix ? prefix + ' / ' : '') + d.name;
+      result.push({ id: d.id, name: d.name, path });
+      const sub = await loadAllDirs(d.id, path);
+      result.push(...sub);
+    }
+    return result;
+  }
+
+  function confirmDelete(type, name, onConfirm) {
+    document.getElementById('confirm-title').textContent = `Delete ${type}`;
+    document.getElementById('confirm-message').textContent = `Are you sure you want to delete "${name}"? This cannot be undone.`;
+    const modal = document.getElementById('confirm-modal');
+    modal.style.display = 'flex';
+    document.getElementById('confirm-ok-btn').onclick = () => {
+      modal.style.display = 'none';
+      onConfirm();
+    };
+  }
+
+  function getCurrentDirId() { return currentDirId; }
+  function refresh() { return loadDir(currentDirId); }
+
+  return { init, loadDir, getCurrentDirId, refresh };
+})();