|
@@ -1,7 +1,11 @@
|
|
|
const Browser = (() => {
|
|
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() {
|
|
async function init() {
|
|
|
document.getElementById('new-dir-btn').addEventListener('click', createDirPrompt);
|
|
document.getElementById('new-dir-btn').addEventListener('click', createDirPrompt);
|
|
@@ -9,67 +13,107 @@ const Browser = (() => {
|
|
|
document.getElementById('file-input').click();
|
|
document.getElementById('file-input').click();
|
|
|
});
|
|
});
|
|
|
document.getElementById('file-input').addEventListener('change', handleFileUpload);
|
|
document.getElementById('file-input').addEventListener('change', handleFileUpload);
|
|
|
-
|
|
|
|
|
- // Drop zone on map
|
|
|
|
|
setupDropZone();
|
|
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);
|
|
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() {
|
|
function renderBreadcrumb() {
|
|
|
const bc = document.getElementById('breadcrumb');
|
|
const bc = document.getElementById('breadcrumb');
|
|
|
|
|
+ const path = buildPath(selectedDirId);
|
|
|
let html = '<span class="bc-item" data-id="null">Root</span>';
|
|
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-sep">›</span> ';
|
|
|
html += `<span class="bc-item" data-id="${item.id}">${escHtml(item.name)}</span>`;
|
|
html += `<span class="bc-item" data-id="${item.id}">${escHtml(item.name)}</span>`;
|
|
|
- });
|
|
|
|
|
|
|
+ }
|
|
|
bc.innerHTML = html;
|
|
bc.innerHTML = html;
|
|
|
bc.querySelectorAll('.bc-item').forEach(el => {
|
|
bc.querySelectorAll('.bc-item').forEach(el => {
|
|
|
el.addEventListener('click', () => {
|
|
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>';
|
|
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) {
|
|
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-icon">📁</span>
|
|
|
<span class="item-name">${escHtml(dir.name)}</span>
|
|
<span class="item-name">${escHtml(dir.name)}</span>
|
|
|
<span class="item-date">${formatDate(dir.updatedAt)}</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>
|
|
<button class="item-btn delete-dir-btn" data-id="${dir.id}" data-name="${escAttr(dir.name)}" title="Delete">🗑️</button>
|
|
|
</span>
|
|
</span>
|
|
|
</div>`;
|
|
</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) {
|
|
for (const track of tracks) {
|
|
|
const dist = track.totalDistance ? formatDistance(track.totalDistance) : '';
|
|
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-icon">🗺️</span>
|
|
|
<span class="item-name">${escHtml(track.name)}</span>
|
|
<span class="item-name">${escHtml(track.name)}</span>
|
|
|
<span class="item-meta">${dist}</span>
|
|
<span class="item-meta">${dist}</span>
|
|
@@ -96,74 +150,194 @@ const Browser = (() => {
|
|
|
</div>`;
|
|
</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) => {
|
|
btn.addEventListener('click', (e) => {
|
|
|
e.stopPropagation();
|
|
e.stopPropagation();
|
|
|
renameDir(parseInt(btn.dataset.id), btn.dataset.name);
|
|
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) => {
|
|
btn.addEventListener('click', (e) => {
|
|
|
e.stopPropagation();
|
|
e.stopPropagation();
|
|
|
confirmDelete('directory', btn.dataset.name, () => deleteDir(parseInt(btn.dataset.id)));
|
|
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) => {
|
|
el.addEventListener('click', (e) => {
|
|
|
if (e.target.closest('.item-actions')) return;
|
|
if (e.target.closest('.item-actions')) return;
|
|
|
openTrack(parseInt(el.dataset.id));
|
|
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) => {
|
|
btn.addEventListener('click', (e) => {
|
|
|
e.stopPropagation();
|
|
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) => {
|
|
btn.addEventListener('click', (e) => {
|
|
|
e.stopPropagation();
|
|
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();
|
|
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) {
|
|
async function openTrack(trackId) {
|
|
|
try {
|
|
try {
|
|
|
if (MapView.hasTrack(trackId)) {
|
|
if (MapView.hasTrack(trackId)) {
|
|
|
- // Toggle off
|
|
|
|
|
MapView.removeTrack(trackId);
|
|
MapView.removeTrack(trackId);
|
|
|
MapView.setCurrentTrack(null);
|
|
MapView.setCurrentTrack(null);
|
|
|
document.getElementById('track-info-panel').style.display = 'none';
|
|
document.getElementById('track-info-panel').style.display = 'none';
|
|
@@ -192,12 +366,24 @@ const Browser = (() => {
|
|
|
document.getElementById('track-info-close').onclick = () => { panel.style.display = 'none'; };
|
|
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() {
|
|
async function createDirPrompt() {
|
|
|
const name = prompt('Folder name:');
|
|
const name = prompt('Folder name:');
|
|
|
if (!name || !name.trim()) return;
|
|
if (!name || !name.trim()) return;
|
|
|
try {
|
|
try {
|
|
|
- await API.createDir(name.trim(), currentDirId);
|
|
|
|
|
- await loadDir(currentDirId);
|
|
|
|
|
|
|
+ await API.createDir(name.trim(), selectedDirId);
|
|
|
|
|
+ await reload();
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
showToast('Error: ' + e.message, 'error');
|
|
showToast('Error: ' + e.message, 'error');
|
|
|
}
|
|
}
|
|
@@ -208,7 +394,7 @@ const Browser = (() => {
|
|
|
if (!name || !name.trim() || name.trim() === currentName) return;
|
|
if (!name || !name.trim() || name.trim() === currentName) return;
|
|
|
try {
|
|
try {
|
|
|
await API.renameDir(id, name.trim());
|
|
await API.renameDir(id, name.trim());
|
|
|
- await loadDir(currentDirId);
|
|
|
|
|
|
|
+ await reload();
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
showToast('Error: ' + e.message, 'error');
|
|
showToast('Error: ' + e.message, 'error');
|
|
|
}
|
|
}
|
|
@@ -217,21 +403,15 @@ const Browser = (() => {
|
|
|
async function deleteDir(id) {
|
|
async function deleteDir(id) {
|
|
|
try {
|
|
try {
|
|
|
await API.deleteDir(id);
|
|
await API.deleteDir(id);
|
|
|
- await loadDir(currentDirId);
|
|
|
|
|
|
|
+ if (selectedDirId === id) selectedDirId = null;
|
|
|
|
|
+ expandedDirs.delete(id);
|
|
|
|
|
+ await reload();
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
showToast('Error: ' + e.message, 'error');
|
|
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) {
|
|
async function handleFileUpload(e) {
|
|
|
const files = Array.from(e.target.files);
|
|
const files = Array.from(e.target.files);
|
|
@@ -241,21 +421,24 @@ const Browser = (() => {
|
|
|
for (const file of files) {
|
|
for (const file of files) {
|
|
|
showUploadToast(`Uploading ${file.name}...`);
|
|
showUploadToast(`Uploading ${file.name}...`);
|
|
|
try {
|
|
try {
|
|
|
- await API.uploadTrack(file, currentDirId, null);
|
|
|
|
|
|
|
+ await API.uploadTrack(file, selectedDirId, null);
|
|
|
showToast(`Uploaded: ${file.name}`, 'success');
|
|
showToast(`Uploaded: ${file.name}`, 'success');
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
showToast(`Error uploading ${file.name}: ${err.message}`, 'error');
|
|
showToast(`Error uploading ${file.name}: ${err.message}`, 'error');
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
hideUploadToast();
|
|
hideUploadToast();
|
|
|
- await loadDir(currentDirId);
|
|
|
|
|
|
|
+ await reload();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function setupDropZone() {
|
|
function setupDropZone() {
|
|
|
const mapContainer = document.getElementById('map-container');
|
|
const mapContainer = document.getElementById('map-container');
|
|
|
mapContainer.addEventListener('dragover', (e) => {
|
|
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) => {
|
|
mapContainer.addEventListener('dragleave', (e) => {
|
|
|
if (!mapContainer.contains(e.relatedTarget)) {
|
|
if (!mapContainer.contains(e.relatedTarget)) {
|
|
@@ -266,24 +449,101 @@ const Browser = (() => {
|
|
|
e.preventDefault();
|
|
e.preventDefault();
|
|
|
mapContainer.classList.remove('drag-over');
|
|
mapContainer.classList.remove('drag-over');
|
|
|
const files = Array.from(e.dataTransfer.files).filter(f => f.name.toLowerCase().endsWith('.gpx'));
|
|
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) {
|
|
for (const file of files) {
|
|
|
showUploadToast(`Uploading ${file.name}...`);
|
|
showUploadToast(`Uploading ${file.name}...`);
|
|
|
try {
|
|
try {
|
|
|
- await API.uploadTrack(file, currentDirId, null);
|
|
|
|
|
|
|
+ await API.uploadTrack(file, selectedDirId, null);
|
|
|
showToast(`Uploaded: ${file.name}`, 'success');
|
|
showToast(`Uploaded: ${file.name}`, 'success');
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
showToast(`Error: ${err.message}`, 'error');
|
|
showToast(`Error: ${err.message}`, 'error');
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
hideUploadToast();
|
|
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) {
|
|
async function shareTrack(trackId) {
|
|
|
try {
|
|
try {
|
|
|
const track = await API.getTrack(trackId);
|
|
const track = await API.getTrack(trackId);
|
|
@@ -357,68 +617,11 @@ const Browser = (() => {
|
|
|
|
|
|
|
|
function buildShareUrl(code) {
|
|
function buildShareUrl(code) {
|
|
|
const base = window.location.origin + window.location.pathname;
|
|
const base = window.location.origin + window.location.pathname;
|
|
|
- // Ensure we link to share/CODE relative to the app root
|
|
|
|
|
const appRoot = base.replace(/\/[^/]*$/, '/');
|
|
const appRoot = base.replace(/\/[^/]*$/, '/');
|
|
|
return appRoot + 'share/' + code;
|
|
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) {
|
|
function confirmDelete(type, name, onConfirm) {
|
|
|
document.getElementById('confirm-title').textContent = `Delete ${type}`;
|
|
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 };
|
|
|
})();
|
|
})();
|