Prechádzať zdrojové kódy

Add folder tree browser, drag-drop move, folder zoom, and docs

- Replace flat folder navigation with expandable inline tree; expanded
  state is preserved across reloads
- Clicking a folder selects it and zooms the map to contain all tracks
  in that folder, loading them onto the map automatically
- Tracks are draggable onto folder items; drop shows a confirmation
  dialog before moving
- Fix file-drop zone on map to ignore track drag events
- Add CLAUDE.md with project structure and design notes for Claude
- Add README.md with feature list, install instructions, and nginx
  proxy-pass configuration example

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
k4be 10 hodín pred
rodič
commit
e3dba8d542
4 zmenil súbory, kde vykonal 721 pridanie a 148 odobranie
  1. 64 0
      CLAUDE.md
  2. 227 0
      README.md
  3. 79 0
      gpx-vis-frontend/css/style.css
  4. 351 148
      gpx-vis-frontend/js/browser.js

+ 64 - 0
CLAUDE.md

@@ -0,0 +1,64 @@
+# GPX Visualizer — Project Notes for Claude
+
+## Repository layout
+
+```
+gpx-vis/
+├── gpx-vis-backend/     Node.js/Express REST API (default port 3000)
+├── gpx-vis-frontend/    Vanilla HTML/CSS/JS SPA (served by any web server)
+└── sample/              Standalone drag-and-drop GPX viewer (no backend)
+```
+
+## Backend (`gpx-vis-backend/`)
+
+**Stack:** Express 5, Sequelize ORM, SQLite (default) or MySQL/MariaDB, bcryptjs, JWT, multer, xml2js
+
+**Entry point:** `index.js`
+**Config:** Copy `config.example.js` → `config.js`
+
+**Routes:**
+- `src/routes/auth.js` — login, register, `/api/auth/me`
+- `src/routes/directories.js` — folder CRUD
+- `src/routes/tracks.js` — track upload, list, update, delete, share
+- `src/routes/stats.js` — per-user and per-directory stats
+- `src/routes/share.js` — public share link access
+- `src/routes/admin.js` — user management (admin only)
+
+**Models:** `User`, `Directory` (tree via parentId), `Track`, `TrackPoint`, `ShareLink`
+
+**Key behaviours:**
+- First registered user becomes admin + active; subsequent users need admin activation
+- GPX track points are stored in the DB (`TrackPoint` table), not as files
+- GPX compression: skip a point if distance < 2 m OR (time < 30 s AND bearing change < 30°)
+- Track date = first GPX timestamp if present, else upload date
+- Share links use 10-char consonant-vowel alternating codes (e.g. `bodaveximu`)
+
+**Tests:** `npm test` — mocha, uses in-memory SQLite via `test/setup.js` (overrides `Module._load` for config at module level)
+
+## Frontend (`gpx-vis-frontend/`)
+
+**Stack:** Vanilla JS, Leaflet.js (CDN), no build step
+**Config:** Copy `config.example.js` → `config.js`
+
+**JS modules (loaded in order):**
+- `js/api.js` — all `fetch` wrappers (`API.*`)
+- `js/auth.js` — login/register forms, JWT storage
+- `js/map.js` — Leaflet map, track layers, URL hash state (`#map=lat,lng,zoom&tracks=id1,id2&open=id`)
+- `js/browser.js` — file/folder tree browser, drag-and-drop, track actions
+- `js/stats.js` — stats tab rendering
+- `js/app.js` — global utilities, admin panel, share-page init, main entry point
+
+**`browser.js` design:**
+- Tree view with expand/collapse triangles; expanded state survives reloads
+- Clicking a folder selects it (upload context) and loads all its tracks onto the map, then zooms to fit
+- Tracks are draggable onto folders; drop asks for confirmation before moving
+- `selectedDirId` is the current upload context (new folder / file upload go here)
+- `dirContents` cache: key `'root'` or numeric dir id → `{dirs, tracks}`
+- `dirMeta` cache: dir id → `{id, name, parentId}` (used for breadcrumb path)
+
+## Development notes
+
+- Do not mock the database in tests — `test/setup.js` wires real in-memory SQLite
+- All track geometry is stored as `TrackPoint` rows; no file storage after upload
+- The `Module._load` override in `test/setup.js` is required because `config.js` is loaded at module level in several files
+- `API.getTracks('')` returns root-level tracks (directoryId = null)

+ 227 - 0
README.md

@@ -0,0 +1,227 @@
+# GPX Visualizer
+
+A self-hosted web application for uploading, organising, and visualising GPX tracks on an interactive map.
+
+## Features
+
+- **Interactive map** — OpenStreetMap, topographic, and satellite base layers via Leaflet.js
+- **GPX upload** — drag files onto the map or use the upload button; multiple files at once
+- **Folder tree** — create nested folders, expand/collapse inline, drag tracks between folders
+- **Map zoom to folder** — clicking a folder loads all its tracks and zooms the map to fit
+- **Track info** — distance, point count, date shown in a floating panel
+- **Stats tab** — totals and per-month breakdown of distance and track count
+- **Share links** — generate a public URL for any track (no login required to view)
+- **User accounts** — JWT auth; first registered user becomes admin; admin activates subsequent users
+- **Admin panel** — activate/deactivate users, grant/revoke admin, delete users
+- **URL state** — map position, visible tracks, and open track are saved to the URL hash
+- **Database** — SQLite (default) or MySQL/MariaDB via Sequelize ORM
+
+## Requirements
+
+- Node.js 18+
+- npm
+- A web server to serve the frontend (nginx, Apache, Caddy, etc.)
+
+## Installation
+
+### 1. Clone the repository
+
+```bash
+git clone <repo-url>
+cd gpx-vis
+```
+
+### 2. Install backend dependencies
+
+```bash
+cd gpx-vis-backend
+npm install
+```
+
+### 3. Configure the backend
+
+```bash
+cp config.example.js config.js
+```
+
+Edit `config.js`:
+
+```js
+module.exports = {
+  port: 3000,
+  database: {
+    type: 'sqlite',
+    storage: './data/gpx-vis.db',  // created automatically
+  },
+  jwt: {
+    secret: 'replace-with-a-long-random-string',
+    expiresIn: '7d',
+  },
+  upload: {
+    maxFileSizeMB: 50,
+    tempDir: './temp',
+  },
+  cors: {
+    origin: 'https://your-domain.example.com',  // frontend origin
+  },
+};
+```
+
+For MySQL/MariaDB replace the `database` block:
+
+```js
+database: {
+  type: 'mariadb',   // or 'mysql'
+  host: 'localhost',
+  port: 3306,
+  database: 'gpx_vis',
+  username: 'gpxuser',
+  password: 'secret',
+},
+```
+
+### 4. Configure the frontend
+
+```bash
+cd ../gpx-vis-frontend
+cp config.example.js config.js
+```
+
+Edit `config.js`:
+
+```js
+window.APP_CONFIG = {
+  apiUrl: 'https://your-domain.example.com/api-proxy',  // see nginx section below
+  appName: 'GPX Visualizer',
+};
+```
+
+### 5. Start the backend
+
+```bash
+cd ../gpx-vis-backend
+npm start
+```
+
+The database schema is created automatically on first run.
+
+To run as a system service, create `/etc/systemd/system/gpx-vis.service`:
+
+```ini
+[Unit]
+Description=GPX Visualizer backend
+After=network.target
+
+[Service]
+Type=simple
+User=www-data
+WorkingDirectory=/opt/gpx-vis/gpx-vis-backend
+ExecStart=/usr/bin/node index.js
+Restart=on-failure
+Environment=NODE_ENV=production
+
+[Install]
+WantedBy=multi-user.target
+```
+
+```bash
+systemctl enable --now gpx-vis
+```
+
+### 6. Serve the frontend with nginx
+
+Copy `gpx-vis-frontend/` to your web root (e.g. `/var/www/gpx-vis`), then configure nginx:
+
+```nginx
+server {
+    listen 443 ssl;
+    server_name your-domain.example.com;
+
+    # SSL configuration (certbot / manual)
+    ssl_certificate     /etc/letsencrypt/live/your-domain.example.com/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/your-domain.example.com/privkey.pem;
+
+    root /var/www/gpx-vis;
+    index index.html;
+
+    # Proxy /api/* to the Node.js backend
+    location /api/ {
+        proxy_pass         http://127.0.0.1:3000;
+        proxy_http_version 1.1;
+        proxy_set_header   Host              $host;
+        proxy_set_header   X-Real-IP         $remote_addr;
+        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
+        proxy_set_header   X-Forwarded-Proto $scheme;
+
+        # Allow large GPX uploads
+        client_max_body_size 55m;
+    }
+
+    # SPA fallback — all non-file paths serve index.html
+    location / {
+        try_files $uri $uri/ /index.html;
+    }
+}
+
+# Redirect HTTP to HTTPS
+server {
+    listen 80;
+    server_name your-domain.example.com;
+    return 301 https://$host$request_uri;
+}
+```
+
+With this setup, set `apiUrl` in the frontend config to the **same origin** (no separate port needed):
+
+```js
+window.APP_CONFIG = {
+  apiUrl: '',   // empty string = same origin; requests go to /api/...
+  appName: 'GPX Visualizer',
+};
+```
+
+> **Note:** The backend `cors.origin` can be set to `''` or removed when the frontend and API share the same origin via the proxy.
+
+### Running without nginx (development)
+
+Start the backend on port 3000 and open the frontend directly in a browser (via `file://` or a simple HTTP server). Set `apiUrl: 'http://localhost:3000'` in the frontend config.
+
+```bash
+# Simple dev server for the frontend
+cd gpx-vis-frontend
+npx serve .
+```
+
+## Running tests
+
+```bash
+cd gpx-vis-backend
+npm test
+```
+
+Tests use an in-memory SQLite database and cover geo utilities, GPX processing, and the full REST API (56 tests).
+
+## Project structure
+
+```
+gpx-vis/
+├── gpx-vis-backend/
+│   ├── config.example.js
+│   ├── index.js
+│   └── src/
+│       ├── middleware/auth.js
+│       ├── models/           (User, Directory, Track, TrackPoint, ShareLink)
+│       ├── routes/           (auth, directories, tracks, stats, share, admin)
+│       └── utils/            (geo, gpx-processor, shortcode)
+├── gpx-vis-frontend/
+│   ├── config.example.js
+│   ├── index.html
+│   ├── css/style.css
+│   └── js/                   (api, auth, map, browser, stats, app)
+└── sample/
+    └── index.html            (standalone viewer, no backend)
+```
+
+## License
+
+MIT

+ 79 - 0
gpx-vis-frontend/css/style.css

@@ -392,6 +392,22 @@ form button[type="submit"]:hover {
   overflow-y: auto;
 }
 
+.tree-item.dir-item,
+.tree-item.track-item {
+  display: flex;
+  align-items: center;
+  border-bottom: 1px solid var(--color-border-light);
+  cursor: pointer;
+  position: relative;
+  gap: 6px;
+  transition: background 0.15s;
+  min-height: 36px;
+  padding-top: 6px;
+  padding-bottom: 6px;
+  padding-right: 8px;
+}
+
+/* Legacy selectors kept for compatibility */
 .dir-item,
 .track-item {
   display: flex;
@@ -410,6 +426,69 @@ form button[type="submit"]:hover {
   background: var(--color-bg);
 }
 
+/* ===== Expand Button (tree triangle) ===== */
+.expand-btn {
+  flex-shrink: 0;
+  width: 16px;
+  height: 16px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 10px;
+  color: var(--color-text-lighter);
+  transition: transform 0.15s, color 0.15s;
+  cursor: pointer;
+  border-radius: 3px;
+  user-select: none;
+}
+
+.expand-btn::before {
+  content: '▶';
+}
+
+.expand-btn.expanded {
+  transform: rotate(90deg);
+  color: var(--color-blue);
+}
+
+.expand-btn:hover {
+  color: var(--color-text);
+  background: var(--color-border-light);
+}
+
+/* ===== Selected Dir ===== */
+.dir-item.selected {
+  background: #e8f4fd;
+}
+
+.dir-item.selected .item-name {
+  color: var(--color-blue);
+  font-weight: 500;
+}
+
+.dir-item.selected:hover {
+  background: #d6ecf8;
+}
+
+/* ===== Drag target (folder highlight when dragging track) ===== */
+.dir-item.drag-target {
+  background: #d6ecf8;
+  outline: 2px dashed var(--color-blue);
+  outline-offset: -2px;
+}
+
+/* ===== Dragging track ===== */
+.track-item.dragging {
+  opacity: 0.4;
+}
+
+/* ===== Tree loading placeholder ===== */
+.tree-loading {
+  padding-top: 4px;
+  padding-bottom: 4px;
+  font-size: 12px;
+}
+
 .item-icon {
   flex-shrink: 0;
   width: 20px;

+ 351 - 148
gpx-vis-frontend/js/browser.js

@@ -1,7 +1,11 @@
 const Browser = (() => {
-  let currentDirId = null; // null = root
-  let dirStack = []; // breadcrumb stack: [{id, name}]
-  let allDirs = []; // flat list for move dialog
+  let selectedDirId = null;  // used as upload context
+  let expandedDirs = new Set();
+  let dirContents = {};  // 'root' | dirId -> { dirs, tracks }
+  let dirMeta = {};      // dirId -> { id, name, parentId }
+  let dragTrackId = null;
+  let dragTrackName = null;
+  let allDirs = [];  // flat list for move dialog
 
   async function init() {
     document.getElementById('new-dir-btn').addEventListener('click', createDirPrompt);
@@ -9,67 +13,107 @@ const Browser = (() => {
       document.getElementById('file-input').click();
     });
     document.getElementById('file-input').addEventListener('change', handleFileUpload);
-
-    // Drop zone on map
     setupDropZone();
-
-    await loadDir(null);
+    await reload();
   }
 
-  async function loadDir(dirId) {
-    currentDirId = dirId;
-    renderBreadcrumb();
+  async function reload() {
+    const prevExpanded = new Set(expandedDirs);
+    dirContents = {};
+    dirMeta = {};
 
-    try {
-      let dirs, tracks;
-      if (dirId === null) {
-        dirs = await API.getDirs();
-        const allTracks = await API.getTracks('');
-        tracks = allTracks;
-      } else {
+    await loadRootContents();
+
+    // Re-fetch previously expanded dirs to restore tree state
+    for (const dirId of prevExpanded) {
+      try {
         const data = await API.getDir(dirId);
-        dirs = data.children || [];
-        tracks = data.tracks || [];
+        dirMeta[data.id] = { id: data.id, name: data.name, parentId: data.parentId };
+        dirContents[dirId] = { dirs: data.children || [], tracks: data.tracks || [] };
+        (data.children || []).forEach(d => {
+          dirMeta[d.id] = { id: d.id, name: d.name, parentId: dirId };
+        });
+      } catch (e) {
+        expandedDirs.delete(dirId);
       }
-      renderList(dirs, tracks);
-    } catch (e) {
-      showToast('Error loading directory: ' + e.message, 'error');
-      renderList([], []);
     }
+
+    renderBreadcrumb();
+    renderTree();
+  }
+
+  async function loadRootContents() {
+    const [dirs, tracks] = await Promise.all([
+      API.getDirs(),
+      API.getTracks('')
+    ]);
+    dirContents['root'] = { dirs, tracks };
+    dirs.forEach(d => { dirMeta[d.id] = { id: d.id, name: d.name, parentId: null }; });
   }
 
+  // ===== Breadcrumb =====
+
   function renderBreadcrumb() {
     const bc = document.getElementById('breadcrumb');
+    const path = buildPath(selectedDirId);
     let html = '<span class="bc-item" data-id="null">Root</span>';
-    dirStack.forEach((item) => {
+    for (const item of path) {
       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);
+        selectedDirId = el.dataset.id === 'null' ? null : parseInt(el.dataset.id);
+        renderBreadcrumb();
+        renderTree();
       });
     });
   }
 
-  function renderList(dirs, tracks) {
-    const list = document.getElementById('browser-list');
-    let html = '';
+  function buildPath(dirId) {
+    if (dirId === null) return [];
+    const result = [];
+    let current = dirId;
+    const seen = new Set();
+    while (current !== null && current !== undefined && !seen.has(current)) {
+      seen.add(current);
+      const meta = dirMeta[current];
+      if (!meta) break;
+      result.unshift({ id: meta.id, name: meta.name });
+      current = meta.parentId;
+    }
+    return result;
+  }
 
-    if (dirs.length === 0 && tracks.length === 0) {
+  // ===== Tree Rendering =====
+
+  function renderTree() {
+    const list = document.getElementById('browser-list');
+    const root = dirContents['root'];
+    if (!root) {
+      list.innerHTML = '<div class="loading">Loading...</div>';
+      return;
+    }
+    let html = buildTreeHtml(root.dirs, root.tracks, 0);
+    if (!html.trim()) {
       html = '<div class="empty-list">No files here. Upload a GPX or create a folder.</div>';
     }
+    list.innerHTML = html;
+    bindEvents(list);
+  }
+
+  function buildTreeHtml(dirs, tracks, depth) {
+    let html = '';
+    const baseIndent = depth * 16;
 
     for (const dir of dirs) {
-      html += `<div class="dir-item" data-id="${dir.id}">
+      const isExpanded = expandedDirs.has(dir.id);
+      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">
+        <span class="expand-btn${isExpanded ? ' expanded' : ''}" data-expand="${dir.id}"></span>
         <span class="item-icon">📁</span>
         <span class="item-name">${escHtml(dir.name)}</span>
         <span class="item-date">${formatDate(dir.updatedAt)}</span>
@@ -78,11 +122,21 @@ const Browser = (() => {
           <button class="item-btn delete-dir-btn" data-id="${dir.id}" data-name="${escAttr(dir.name)}" title="Delete">🗑️</button>
         </span>
       </div>`;
+
+      if (isExpanded) {
+        const cached = dirContents[dir.id];
+        if (cached) {
+          html += buildTreeHtml(cached.dirs, cached.tracks, depth + 1);
+        } else {
+          html += `<div class="loading tree-loading" style="padding-left:${28 + baseIndent}px">Loading...</div>`;
+        }
+      }
     }
 
     for (const track of tracks) {
       const dist = track.totalDistance ? formatDistance(track.totalDistance) : '';
-      html += `<div class="track-item" data-id="${track.id}">
+      html += `<div class="tree-item track-item" data-id="${track.id}" draggable="true"
+        data-name="${escAttr(track.name)}" style="padding-left:${28 + baseIndent}px">
         <span class="item-icon">🗺️</span>
         <span class="item-name">${escHtml(track.name)}</span>
         <span class="item-meta">${dist}</span>
@@ -96,74 +150,194 @@ const Browser = (() => {
       </div>`;
     }
 
-    list.innerHTML = html;
+    return 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);
+  // ===== Event Binding =====
+
+  function bindEvents(container) {
+    // Expand/collapse triangle
+    container.querySelectorAll('.expand-btn[data-expand]').forEach(btn => {
+      btn.addEventListener('click', async (e) => {
+        e.stopPropagation();
+        await toggleExpand(parseInt(btn.dataset.expand));
       });
     });
 
-    list.querySelectorAll('.rename-dir-btn').forEach(btn => {
+    // Dir click → select + zoom map to folder tracks
+    container.querySelectorAll('.dir-item').forEach(el => {
+      el.addEventListener('click', async (e) => {
+        if (e.target.closest('.item-actions') || e.target.classList.contains('expand-btn')) return;
+        await selectDir(parseInt(el.dataset.id));
+      });
+    });
+
+    container.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 => {
+    container.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 => {
+    // Track click → open on map
+    container.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 => {
+    container.querySelectorAll('.view-track-btn').forEach(btn => {
+      btn.addEventListener('click', (e) => { e.stopPropagation(); openTrack(parseInt(btn.dataset.id)); });
+    });
+
+    container.querySelectorAll('.share-track-btn').forEach(btn => {
+      btn.addEventListener('click', (e) => { e.stopPropagation(); shareTrack(parseInt(btn.dataset.id)); });
+    });
+
+    container.querySelectorAll('.move-track-btn').forEach(btn => {
       btn.addEventListener('click', (e) => {
         e.stopPropagation();
-        openTrack(parseInt(btn.dataset.id));
+        showMoveDialog(parseInt(btn.dataset.id), btn.dataset.name);
       });
     });
 
-    list.querySelectorAll('.share-track-btn').forEach(btn => {
+    container.querySelectorAll('.delete-track-btn').forEach(btn => {
       btn.addEventListener('click', (e) => {
         e.stopPropagation();
-        shareTrack(parseInt(btn.dataset.id));
+        confirmDelete('track', btn.dataset.name, () => deleteTrack(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);
+    // Drag tracks
+    container.querySelectorAll('.track-item[draggable="true"]').forEach(el => {
+      el.addEventListener('dragstart', (e) => {
+        dragTrackId = parseInt(el.dataset.id);
+        dragTrackName = el.dataset.name;
+        e.dataTransfer.effectAllowed = 'move';
+        e.dataTransfer.setData('text/plain', String(dragTrackId));
+        el.classList.add('dragging');
+      });
+      el.addEventListener('dragend', () => {
+        el.classList.remove('dragging');
+        container.querySelectorAll('.drag-target').forEach(d => d.classList.remove('drag-target'));
+        dragTrackId = null;
+        dragTrackName = null;
       });
     });
 
-    list.querySelectorAll('.delete-track-btn').forEach(btn => {
-      btn.addEventListener('click', (e) => {
+    // Drop targets (folders)
+    container.querySelectorAll('.dir-item[data-drop-target]').forEach(el => {
+      el._dragCount = 0;
+      el.addEventListener('dragenter', (e) => {
+        if (dragTrackId === null) return;
+        e.preventDefault();
+        el._dragCount = (el._dragCount || 0) + 1;
+        el.classList.add('drag-target');
+      });
+      el.addEventListener('dragleave', () => {
+        if (dragTrackId === null) return;
+        el._dragCount = (el._dragCount || 1) - 1;
+        if (el._dragCount <= 0) {
+          el._dragCount = 0;
+          el.classList.remove('drag-target');
+        }
+      });
+      el.addEventListener('dragover', (e) => {
+        if (dragTrackId === null) return;
+        e.preventDefault();
+        e.dataTransfer.dropEffect = 'move';
+      });
+      el.addEventListener('drop', (e) => {
+        e.preventDefault();
         e.stopPropagation();
-        confirmDelete('track', btn.dataset.name, () => deleteTrack(parseInt(btn.dataset.id)));
+        el._dragCount = 0;
+        el.classList.remove('drag-target');
+        if (dragTrackId === null) return;
+        const targetDirId = parseInt(el.dataset.id);
+        const targetDirName = el.dataset.name;
+        const trackId = dragTrackId;
+        const trackName = dragTrackName;
+        dragTrackId = null;
+        dragTrackName = null;
+        confirmMoveTrack(trackId, trackName, targetDirId, targetDirName);
       });
     });
   }
 
+  // ===== Expand / Select =====
+
+  async function toggleExpand(dirId) {
+    if (expandedDirs.has(dirId)) {
+      expandedDirs.delete(dirId);
+      renderTree();
+    } else {
+      expandedDirs.add(dirId);
+      if (!dirContents[dirId]) {
+        renderTree(); // show loading placeholder
+        try {
+          const data = await API.getDir(dirId);
+          dirMeta[data.id] = { id: data.id, name: data.name, parentId: data.parentId };
+          dirContents[dirId] = { dirs: data.children || [], tracks: data.tracks || [] };
+          (data.children || []).forEach(d => {
+            dirMeta[d.id] = { id: d.id, name: d.name, parentId: dirId };
+          });
+        } catch (e) {
+          showToast('Error: ' + e.message, 'error');
+          expandedDirs.delete(dirId);
+        }
+      }
+      renderTree();
+    }
+  }
+
+  async function selectDir(dirId) {
+    selectedDirId = dirId;
+    renderBreadcrumb();
+    renderTree();
+
+    // Ensure contents loaded
+    if (!dirContents[dirId]) {
+      try {
+        const data = await API.getDir(dirId);
+        dirMeta[data.id] = { id: data.id, name: data.name, parentId: data.parentId };
+        dirContents[dirId] = { dirs: data.children || [], tracks: data.tracks || [] };
+        (data.children || []).forEach(d => {
+          dirMeta[d.id] = { id: d.id, name: d.name, parentId: dirId };
+        });
+      } catch (e) {
+        showToast('Error loading folder: ' + e.message, 'error');
+        return;
+      }
+    }
+
+    // Load all tracks in this folder onto map and zoom
+    const tracks = dirContents[dirId].tracks;
+    if (tracks.length === 0) return;
+
+    await Promise.all(tracks.map(async track => {
+      if (!MapView.hasTrack(track.id)) {
+        try {
+          const data = await API.getTrackPoints(track.id);
+          MapView.addTrack(data, track.id);
+        } catch (e) { /* ignore individual errors */ }
+      }
+    }));
+    MapView.fitAll();
+  }
+
+  // ===== Track Actions =====
+
   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';
@@ -192,12 +366,24 @@ const Browser = (() => {
     document.getElementById('track-info-close').onclick = () => { panel.style.display = 'none'; };
   }
 
+  async function deleteTrack(id) {
+    try {
+      MapView.removeTrack(id);
+      await API.deleteTrack(id);
+      await reload();
+    } catch (e) {
+      showToast('Error: ' + e.message, 'error');
+    }
+  }
+
+  // ===== Dir Actions =====
+
   async function createDirPrompt() {
     const name = prompt('Folder name:');
     if (!name || !name.trim()) return;
     try {
-      await API.createDir(name.trim(), currentDirId);
-      await loadDir(currentDirId);
+      await API.createDir(name.trim(), selectedDirId);
+      await reload();
     } catch (e) {
       showToast('Error: ' + e.message, 'error');
     }
@@ -208,7 +394,7 @@ const Browser = (() => {
     if (!name || !name.trim() || name.trim() === currentName) return;
     try {
       await API.renameDir(id, name.trim());
-      await loadDir(currentDirId);
+      await reload();
     } catch (e) {
       showToast('Error: ' + e.message, 'error');
     }
@@ -217,21 +403,15 @@ const Browser = (() => {
   async function deleteDir(id) {
     try {
       await API.deleteDir(id);
-      await loadDir(currentDirId);
+      if (selectedDirId === id) selectedDirId = null;
+      expandedDirs.delete(id);
+      await reload();
     } 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');
-    }
-  }
+  // ===== Upload =====
 
   async function handleFileUpload(e) {
     const files = Array.from(e.target.files);
@@ -241,21 +421,24 @@ const Browser = (() => {
     for (const file of files) {
       showUploadToast(`Uploading ${file.name}...`);
       try {
-        await API.uploadTrack(file, currentDirId, null);
+        await API.uploadTrack(file, selectedDirId, null);
         showToast(`Uploaded: ${file.name}`, 'success');
       } catch (err) {
         showToast(`Error uploading ${file.name}: ${err.message}`, 'error');
       }
     }
     hideUploadToast();
-    await loadDir(currentDirId);
+    await reload();
   }
 
   function setupDropZone() {
     const mapContainer = document.getElementById('map-container');
     mapContainer.addEventListener('dragover', (e) => {
-      e.preventDefault();
-      mapContainer.classList.add('drag-over');
+      // Only handle file drops (not track drags)
+      if (e.dataTransfer.types.includes('Files')) {
+        e.preventDefault();
+        mapContainer.classList.add('drag-over');
+      }
     });
     mapContainer.addEventListener('dragleave', (e) => {
       if (!mapContainer.contains(e.relatedTarget)) {
@@ -266,24 +449,101 @@ const Browser = (() => {
       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;
-      }
+      if (files.length === 0) return;
       for (const file of files) {
         showUploadToast(`Uploading ${file.name}...`);
         try {
-          await API.uploadTrack(file, currentDirId, null);
+          await API.uploadTrack(file, selectedDirId, null);
           showToast(`Uploaded: ${file.name}`, 'success');
         } catch (err) {
           showToast(`Error: ${err.message}`, 'error');
         }
       }
       hideUploadToast();
-      await loadDir(currentDirId);
+      await reload();
     });
   }
 
+  // ===== Move Track =====
+
+  function confirmMoveTrack(trackId, trackName, targetDirId, targetDirName) {
+    document.getElementById('confirm-title').textContent = 'Move Track';
+    document.getElementById('confirm-message').textContent =
+      `Move "${trackName}" to folder "${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.updateTrack(trackId, { directoryId: targetDirId });
+        await reload();
+        showToast('Track moved', 'success');
+      } catch (e) {
+        showToast('Error: ' + e.message, 'error');
+      }
+    };
+  }
+
+  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 selectedMoveId = 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');
+          selectedMoveId = el.dataset.id || null;
+        });
+      });
+
+      document.getElementById('move-confirm-btn').onclick = async () => {
+        try {
+          await API.updateTrack(trackId, { directoryId: selectedMoveId || null });
+          document.getElementById('move-modal').style.display = 'none';
+          await reload();
+          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;
+  }
+
+  // ===== Share =====
+
   async function shareTrack(trackId) {
     try {
       const track = await API.getTrack(trackId);
@@ -357,68 +617,11 @@ const Browser = (() => {
 
   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;
-  }
+  // ===== Confirm Delete =====
 
   function confirmDelete(type, name, onConfirm) {
     document.getElementById('confirm-title').textContent = `Delete ${type}`;
@@ -431,8 +634,8 @@ const Browser = (() => {
     };
   }
 
-  function getCurrentDirId() { return currentDirId; }
-  function refresh() { return loadDir(currentDirId); }
+  function getCurrentDirId() { return selectedDirId; }
+  function refresh() { return reload(); }
 
-  return { init, loadDir, getCurrentDirId, refresh };
+  return { init, getCurrentDirId, refresh };
 })();