browser.js 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862
  1. const TRACK_TYPE_EMOJI = {
  2. hiking: '🥾',
  3. running: '🏃',
  4. cycling: '🚴',
  5. driving: '🚗',
  6. train: '🚆',
  7. other: '📍',
  8. };
  9. const Browser = (() => {
  10. let selectedDirId = null; // used as upload context
  11. let expandedDirs = new Set();
  12. let dirContents = {}; // 'root' | dirId -> { dirs, tracks }
  13. let dirMeta = {}; // dirId -> { id, name, parentId }
  14. let dragTrackIds = []; // ids being dragged (may be multiple)
  15. let dragDirId = null; // dir id being dragged (single)
  16. let allDirs = []; // flat list for move dialog
  17. // ===== Multi-select state =====
  18. let selectedTrackIds = new Set(); // Ctrl/Shift-selected track ids
  19. let lastClickedTrackId = null; // anchor for Shift-range
  20. // ===== Auto-scroll state =====
  21. let autoScrollRaf = null;
  22. let autoScrollDir = 0; // -1 up, +1 down, 0 stopped
  23. async function init() {
  24. document.getElementById('new-dir-btn').addEventListener('click', createDirPrompt);
  25. document.getElementById('upload-btn').addEventListener('click', () => {
  26. document.getElementById('file-input').click();
  27. });
  28. document.getElementById('file-input').addEventListener('change', handleFileUpload);
  29. setupDropZone();
  30. await reload();
  31. }
  32. async function reload() {
  33. const prevExpanded = new Set(expandedDirs);
  34. dirContents = {};
  35. dirMeta = {};
  36. selectedTrackIds.clear();
  37. await loadRootContents();
  38. // Re-fetch previously expanded dirs to restore tree state
  39. for (const dirId of prevExpanded) {
  40. try {
  41. const data = await API.getDir(dirId);
  42. dirMeta[data.id] = { id: data.id, name: data.name, parentId: data.parentId };
  43. dirContents[dirId] = { dirs: data.children || [], tracks: data.tracks || [] };
  44. (data.children || []).forEach(d => {
  45. dirMeta[d.id] = { id: d.id, name: d.name, parentId: dirId };
  46. });
  47. } catch (e) {
  48. expandedDirs.delete(dirId);
  49. }
  50. }
  51. renderBreadcrumb();
  52. renderTree();
  53. }
  54. async function loadRootContents() {
  55. const [dirs, tracks] = await Promise.all([
  56. API.getDirs(),
  57. API.getTracks('')
  58. ]);
  59. dirContents['root'] = { dirs, tracks };
  60. dirs.forEach(d => { dirMeta[d.id] = { id: d.id, name: d.name, parentId: null }; });
  61. }
  62. // ===== Breadcrumb =====
  63. function renderBreadcrumb() {
  64. const bc = document.getElementById('breadcrumb');
  65. const path = buildPath(selectedDirId);
  66. let html = '<span class="bc-item" data-id="null">Root</span>';
  67. for (const item of path) {
  68. html += ' <span class="bc-sep">›</span> ';
  69. html += `<span class="bc-item" data-id="${item.id}">${escHtml(item.name)}</span>`;
  70. }
  71. bc.innerHTML = html;
  72. bc.querySelectorAll('.bc-item').forEach(el => {
  73. el.addEventListener('click', () => {
  74. selectedDirId = el.dataset.id === 'null' ? null : parseInt(el.dataset.id);
  75. renderBreadcrumb();
  76. renderTree();
  77. });
  78. });
  79. }
  80. function buildPath(dirId) {
  81. if (dirId === null) return [];
  82. const result = [];
  83. let current = dirId;
  84. const seen = new Set();
  85. while (current !== null && current !== undefined && !seen.has(current)) {
  86. seen.add(current);
  87. const meta = dirMeta[current];
  88. if (!meta) break;
  89. result.unshift({ id: meta.id, name: meta.name });
  90. current = meta.parentId;
  91. }
  92. return result;
  93. }
  94. // ===== Tree Rendering =====
  95. function renderTree() {
  96. const list = document.getElementById('browser-list');
  97. const root = dirContents['root'];
  98. if (!root) {
  99. list.innerHTML = '<div class="loading">Loading...</div>';
  100. return;
  101. }
  102. let html = buildTreeHtml(root.dirs, root.tracks, 0);
  103. if (!html.trim()) {
  104. html = '<div class="empty-list">No files here. Upload a GPX or create a folder.</div>';
  105. }
  106. list.innerHTML = html;
  107. bindEvents(list);
  108. }
  109. function buildTreeHtml(dirs, tracks, depth) {
  110. let html = '';
  111. const baseIndent = depth * 16;
  112. for (const dir of dirs) {
  113. const isExpanded = expandedDirs.has(dir.id);
  114. const isSelected = selectedDirId === dir.id;
  115. html += `<div class="tree-item dir-item${isSelected ? ' selected' : ''}"
  116. data-id="${dir.id}" data-name="${escAttr(dir.name)}"
  117. style="padding-left:${12 + baseIndent}px" data-drop-target="true" draggable="true">
  118. <span class="expand-btn${isExpanded ? ' expanded' : ''}" data-expand="${dir.id}"></span>
  119. <span class="item-icon">📁</span>
  120. <span class="item-name">${escHtml(dir.name)}</span>
  121. <span class="item-date">${formatDate(dir.updatedAt)}</span>
  122. <span class="item-actions">
  123. <button class="item-btn rename-dir-btn" data-id="${dir.id}" data-name="${escAttr(dir.name)}" title="Rename">✏️</button>
  124. <button class="item-btn delete-dir-btn" data-id="${dir.id}" data-name="${escAttr(dir.name)}" title="Delete">🗑️</button>
  125. </span>
  126. </div>`;
  127. if (isExpanded) {
  128. const cached = dirContents[dir.id];
  129. if (cached) {
  130. html += buildTreeHtml(cached.dirs, cached.tracks, depth + 1);
  131. } else {
  132. html += `<div class="loading tree-loading" style="padding-left:${28 + baseIndent}px">Loading...</div>`;
  133. }
  134. }
  135. }
  136. for (const track of tracks) {
  137. const dist = track.totalDistance ? formatDistance(track.totalDistance) : '';
  138. const isSel = selectedTrackIds.has(track.id);
  139. const typeEmoji = TRACK_TYPE_EMOJI[track.trackType];
  140. const typeTitle = track.trackType
  141. ? track.trackType.charAt(0).toUpperCase() + track.trackType.slice(1)
  142. : '';
  143. const emojiPrefix = typeEmoji
  144. ? `<span class="type-emoji" title="${escAttr(typeTitle)}">${typeEmoji}</span> `
  145. : '';
  146. html += `<div class="tree-item track-item${isSel ? ' multi-selected' : ''}" data-id="${track.id}" draggable="true"
  147. data-name="${escAttr(track.name)}" style="padding-left:${28 + baseIndent}px">
  148. <span class="item-icon">🗺️</span>
  149. <span class="item-name">${emojiPrefix}${escHtml(track.name)}</span>
  150. <span class="item-meta">${dist}</span>
  151. <span class="item-date">${formatDate(track.trackDate || track.uploadDate)}</span>
  152. <span class="item-actions">
  153. <button class="item-btn view-track-btn" data-id="${track.id}" title="View on map">👁️</button>
  154. <button class="item-btn edit-track-btn" data-id="${track.id}" data-name="${escAttr(track.name)}" data-type="${escAttr(track.trackType || '')}" title="Edit">✏️</button>
  155. <button class="item-btn share-track-btn" data-id="${track.id}" title="Share">🔗</button>
  156. <button class="item-btn move-track-btn" data-id="${track.id}" data-name="${escAttr(track.name)}" title="Move">📂</button>
  157. <button class="item-btn delete-track-btn" data-id="${track.id}" data-name="${escAttr(track.name)}" title="Delete">🗑️</button>
  158. </span>
  159. </div>`;
  160. }
  161. return html;
  162. }
  163. // ===== Event Binding =====
  164. function bindEvents(container) {
  165. // Expand/collapse triangle
  166. container.querySelectorAll('.expand-btn[data-expand]').forEach(btn => {
  167. btn.addEventListener('click', async (e) => {
  168. e.stopPropagation();
  169. await toggleExpand(parseInt(btn.dataset.expand));
  170. });
  171. });
  172. // Dir click → select + zoom map to folder tracks
  173. container.querySelectorAll('.dir-item').forEach(el => {
  174. el.addEventListener('click', async (e) => {
  175. if (e.target.closest('.item-actions') || e.target.classList.contains('expand-btn')) return;
  176. await selectDir(parseInt(el.dataset.id));
  177. });
  178. });
  179. container.querySelectorAll('.rename-dir-btn').forEach(btn => {
  180. btn.addEventListener('click', (e) => {
  181. e.stopPropagation();
  182. renameDir(parseInt(btn.dataset.id), btn.dataset.name);
  183. });
  184. });
  185. container.querySelectorAll('.delete-dir-btn').forEach(btn => {
  186. btn.addEventListener('click', (e) => {
  187. e.stopPropagation();
  188. confirmDelete('directory', btn.dataset.name, () => deleteDir(parseInt(btn.dataset.id)));
  189. });
  190. });
  191. // Track click → open on map / multi-select; hover → highlight on map
  192. container.querySelectorAll('.track-item').forEach(el => {
  193. el.addEventListener('click', (e) => {
  194. if (e.target.closest('.item-actions')) return;
  195. const id = parseInt(el.dataset.id);
  196. if (e.ctrlKey || e.metaKey) {
  197. // Ctrl/Cmd: toggle this item in selection
  198. if (selectedTrackIds.has(id)) {
  199. selectedTrackIds.delete(id);
  200. } else {
  201. selectedTrackIds.add(id);
  202. }
  203. lastClickedTrackId = id;
  204. renderTree();
  205. return;
  206. }
  207. if (e.shiftKey && lastClickedTrackId !== null) {
  208. // Shift: range select between lastClickedTrackId and this one
  209. const allItems = Array.from(container.querySelectorAll('.track-item'))
  210. .map(el2 => parseInt(el2.dataset.id));
  211. const a = allItems.indexOf(lastClickedTrackId);
  212. const b = allItems.indexOf(id);
  213. if (a !== -1 && b !== -1) {
  214. const [lo, hi] = a < b ? [a, b] : [b, a];
  215. for (let i = lo; i <= hi; i++) selectedTrackIds.add(allItems[i]);
  216. renderTree();
  217. return;
  218. }
  219. }
  220. // Plain click: clear selection and open track
  221. selectedTrackIds.clear();
  222. lastClickedTrackId = id;
  223. renderTree();
  224. openTrack(id);
  225. });
  226. el.addEventListener('mouseenter', () => MapView.highlightTrack(parseInt(el.dataset.id)));
  227. el.addEventListener('mouseleave', () => MapView.unhighlightTrack());
  228. });
  229. container.querySelectorAll('.view-track-btn').forEach(btn => {
  230. btn.addEventListener('click', (e) => { e.stopPropagation(); openTrack(parseInt(btn.dataset.id)); });
  231. });
  232. container.querySelectorAll('.share-track-btn').forEach(btn => {
  233. btn.addEventListener('click', (e) => { e.stopPropagation(); shareTrack(parseInt(btn.dataset.id)); });
  234. });
  235. container.querySelectorAll('.edit-track-btn').forEach(btn => {
  236. btn.addEventListener('click', (e) => {
  237. e.stopPropagation();
  238. editTrack(parseInt(btn.dataset.id), btn.dataset.name, btn.dataset.type);
  239. });
  240. });
  241. container.querySelectorAll('.move-track-btn').forEach(btn => {
  242. btn.addEventListener('click', (e) => {
  243. e.stopPropagation();
  244. showMoveDialog(parseInt(btn.dataset.id), btn.dataset.name);
  245. });
  246. });
  247. container.querySelectorAll('.delete-track-btn').forEach(btn => {
  248. btn.addEventListener('click', (e) => {
  249. e.stopPropagation();
  250. confirmDelete('track', btn.dataset.name, () => deleteTrack(parseInt(btn.dataset.id)));
  251. });
  252. });
  253. // Drag tracks
  254. container.querySelectorAll('.track-item[draggable="true"]').forEach(el => {
  255. el.addEventListener('dragstart', (e) => {
  256. const id = parseInt(el.dataset.id);
  257. if (selectedTrackIds.has(id)) {
  258. dragTrackIds = [...selectedTrackIds];
  259. } else {
  260. selectedTrackIds.clear();
  261. dragTrackIds = [id];
  262. }
  263. e.dataTransfer.effectAllowed = 'move';
  264. e.dataTransfer.setData('text/plain', String(id));
  265. container.querySelectorAll('.track-item').forEach(item => {
  266. if (dragTrackIds.includes(parseInt(item.dataset.id))) item.classList.add('dragging');
  267. });
  268. });
  269. el.addEventListener('dragend', () => {
  270. container.querySelectorAll('.track-item.dragging').forEach(item => item.classList.remove('dragging'));
  271. container.querySelectorAll('.drag-target').forEach(d => d.classList.remove('drag-target'));
  272. dragTrackIds = [];
  273. stopAutoScroll();
  274. });
  275. });
  276. // Drag folders
  277. container.querySelectorAll('.dir-item[draggable="true"]').forEach(el => {
  278. el.addEventListener('dragstart', (e) => {
  279. e.stopPropagation();
  280. dragDirId = parseInt(el.dataset.id);
  281. e.dataTransfer.effectAllowed = 'move';
  282. e.dataTransfer.setData('text/plain', String(dragDirId));
  283. el.classList.add('dragging');
  284. });
  285. el.addEventListener('dragend', () => {
  286. el.classList.remove('dragging');
  287. container.querySelectorAll('.drag-target').forEach(d => d.classList.remove('drag-target'));
  288. dragDirId = null;
  289. stopAutoScroll();
  290. });
  291. });
  292. // Auto-scroll when dragging near list edges
  293. const list = document.getElementById('browser-list');
  294. list.addEventListener('dragover', (e) => {
  295. if (dragTrackIds.length === 0 && dragDirId === null) return;
  296. const rect = list.getBoundingClientRect();
  297. const ZONE = 48;
  298. if (e.clientY < rect.top + ZONE) {
  299. startAutoScroll(list, -1);
  300. } else if (e.clientY > rect.bottom - ZONE) {
  301. startAutoScroll(list, 1);
  302. } else {
  303. stopAutoScroll();
  304. }
  305. });
  306. list.addEventListener('dragleave', (e) => {
  307. if (!list.contains(e.relatedTarget)) stopAutoScroll();
  308. });
  309. // Drop targets (folders)
  310. container.querySelectorAll('.dir-item[data-drop-target]').forEach(el => {
  311. el._dragCount = 0;
  312. function isDragging() { return dragTrackIds.length > 0 || dragDirId !== null; }
  313. // For dir drags: reject drop onto self or own descendant
  314. function isValidDirDrop(targetId) {
  315. if (dragDirId === null) return false;
  316. if (dragDirId === targetId) return false;
  317. // Walk up from targetId through dirMeta; if we hit dragDirId it's a descendant
  318. let cur = targetId;
  319. const seen = new Set();
  320. while (cur !== null && cur !== undefined) {
  321. if (seen.has(cur)) break;
  322. seen.add(cur);
  323. const meta = dirMeta[cur];
  324. if (!meta) break;
  325. cur = meta.parentId;
  326. if (cur === dragDirId) return false;
  327. }
  328. return true;
  329. }
  330. el.addEventListener('dragenter', (e) => {
  331. if (!isDragging()) return;
  332. if (dragDirId !== null && !isValidDirDrop(parseInt(el.dataset.id))) return;
  333. e.preventDefault();
  334. el._dragCount = (el._dragCount || 0) + 1;
  335. el.classList.add('drag-target');
  336. });
  337. el.addEventListener('dragleave', () => {
  338. if (!isDragging()) return;
  339. el._dragCount = (el._dragCount || 1) - 1;
  340. if (el._dragCount <= 0) {
  341. el._dragCount = 0;
  342. el.classList.remove('drag-target');
  343. }
  344. });
  345. el.addEventListener('dragover', (e) => {
  346. if (!isDragging()) return;
  347. if (dragDirId !== null && !isValidDirDrop(parseInt(el.dataset.id))) return;
  348. e.preventDefault();
  349. e.dataTransfer.dropEffect = 'move';
  350. });
  351. el.addEventListener('drop', (e) => {
  352. e.preventDefault();
  353. e.stopPropagation();
  354. el._dragCount = 0;
  355. el.classList.remove('drag-target');
  356. const targetDirId = parseInt(el.dataset.id);
  357. const targetDirName = el.dataset.name;
  358. if (dragTrackIds.length > 0) {
  359. const ids = dragTrackIds.slice();
  360. dragTrackIds = [];
  361. stopAutoScroll();
  362. confirmMoveTracks(ids, targetDirId, targetDirName);
  363. } else if (dragDirId !== null && isValidDirDrop(targetDirId)) {
  364. const srcId = dragDirId;
  365. const srcName = dirMeta[srcId]?.name || 'folder';
  366. dragDirId = null;
  367. stopAutoScroll();
  368. confirmMoveDir(srcId, srcName, targetDirId, targetDirName);
  369. }
  370. });
  371. });
  372. }
  373. // ===== Auto-scroll helpers =====
  374. function startAutoScroll(el, dir) {
  375. if (autoScrollDir === dir) return;
  376. autoScrollDir = dir;
  377. stopAutoScroll();
  378. function step() {
  379. el.scrollTop += dir * 6;
  380. autoScrollRaf = requestAnimationFrame(step);
  381. }
  382. autoScrollRaf = requestAnimationFrame(step);
  383. }
  384. function stopAutoScroll() {
  385. autoScrollDir = 0;
  386. if (autoScrollRaf !== null) {
  387. cancelAnimationFrame(autoScrollRaf);
  388. autoScrollRaf = null;
  389. }
  390. }
  391. // ===== Expand / Select =====
  392. async function toggleExpand(dirId) {
  393. if (expandedDirs.has(dirId)) {
  394. expandedDirs.delete(dirId);
  395. renderTree();
  396. } else {
  397. expandedDirs.add(dirId);
  398. if (!dirContents[dirId]) {
  399. renderTree(); // show loading placeholder
  400. try {
  401. const data = await API.getDir(dirId);
  402. dirMeta[data.id] = { id: data.id, name: data.name, parentId: data.parentId };
  403. dirContents[dirId] = { dirs: data.children || [], tracks: data.tracks || [] };
  404. (data.children || []).forEach(d => {
  405. dirMeta[d.id] = { id: d.id, name: d.name, parentId: dirId };
  406. });
  407. } catch (e) {
  408. showToast('Error: ' + e.message, 'error');
  409. expandedDirs.delete(dirId);
  410. }
  411. }
  412. renderTree();
  413. }
  414. }
  415. async function selectDir(dirId) {
  416. selectedDirId = dirId;
  417. renderBreadcrumb();
  418. renderTree();
  419. // Ensure contents loaded
  420. if (!dirContents[dirId]) {
  421. try {
  422. const data = await API.getDir(dirId);
  423. dirMeta[data.id] = { id: data.id, name: data.name, parentId: data.parentId };
  424. dirContents[dirId] = { dirs: data.children || [], tracks: data.tracks || [] };
  425. (data.children || []).forEach(d => {
  426. dirMeta[d.id] = { id: d.id, name: d.name, parentId: dirId };
  427. });
  428. } catch (e) {
  429. showToast('Error loading folder: ' + e.message, 'error');
  430. return;
  431. }
  432. }
  433. // Load all tracks in this folder onto map and zoom
  434. const tracks = dirContents[dirId].tracks;
  435. if (tracks.length === 0) return;
  436. await Promise.all(tracks.map(async track => {
  437. if (!MapView.hasTrack(track.id)) {
  438. try {
  439. const data = await API.getTrackPoints(track.id);
  440. MapView.addTrack(data, track.id);
  441. } catch (e) { /* ignore individual errors */ }
  442. }
  443. }));
  444. MapView.fitAll();
  445. }
  446. // ===== Track Actions =====
  447. async function openTrack(trackId) {
  448. try {
  449. if (MapView.hasTrack(trackId)) {
  450. MapView.removeTrack(trackId);
  451. MapView.setCurrentTrack(null);
  452. document.getElementById('track-info-panel').style.display = 'none';
  453. if (typeof Elevation !== 'undefined') Elevation.clear();
  454. return;
  455. }
  456. const data = await API.getTrackPoints(trackId);
  457. MapView.addTrack(data, trackId);
  458. MapView.fitTrack(trackId);
  459. MapView.setCurrentTrack(trackId);
  460. showTrackInfo(data.meta);
  461. if (typeof Elevation !== 'undefined') {
  462. const pts = MapView.getTrackPoints(trackId);
  463. if (pts) Elevation.setTrack(pts, data.meta);
  464. }
  465. } catch (e) {
  466. showToast('Error loading track: ' + e.message, 'error');
  467. }
  468. }
  469. function showTrackInfo(meta) {
  470. if (!meta) return;
  471. const typeEmoji = TRACK_TYPE_EMOJI[meta.trackType];
  472. const typeTitle = meta.trackType
  473. ? meta.trackType.charAt(0).toUpperCase() + meta.trackType.slice(1)
  474. : '';
  475. const emojiPrefix = typeEmoji
  476. ? `<span class="type-emoji" title="${escAttr(typeTitle)}" style="margin-right:4px">${typeEmoji}</span>`
  477. : '';
  478. const panel = document.getElementById('track-info-panel');
  479. document.getElementById('track-info-content').innerHTML = `
  480. <h3>${emojiPrefix}${escHtml(meta.name || 'Track')}</h3>
  481. <div class="info-row"><label>Distance</label><span>${formatDistance(meta.totalDistance)}</span></div>
  482. <div class="info-row"><label>Points</label><span>${meta.pointCount || 0}</span></div>
  483. ${meta.trackDate ? `<div class="info-row"><label>Date</label><span>${formatDate(meta.trackDate)}</span></div>` : ''}
  484. `;
  485. panel.style.display = 'block';
  486. document.getElementById('track-info-close').onclick = () => { panel.style.display = 'none'; };
  487. }
  488. function editTrack(trackId, currentName, currentType) {
  489. const modal = document.getElementById('edit-track-modal');
  490. document.getElementById('edit-track-name').value = currentName || '';
  491. document.getElementById('edit-track-type').value = currentType || '';
  492. modal.style.display = 'flex';
  493. document.getElementById('edit-track-save-btn').onclick = async () => {
  494. const name = document.getElementById('edit-track-name').value.trim();
  495. const trackType = document.getElementById('edit-track-type').value || null;
  496. if (!name) { showToast('Name cannot be empty', 'error'); return; }
  497. modal.style.display = 'none';
  498. try {
  499. await API.updateTrack(trackId, { name, trackType });
  500. await reload();
  501. showToast('Track updated', 'success');
  502. } catch (e) {
  503. showToast('Error: ' + e.message, 'error');
  504. }
  505. };
  506. }
  507. async function deleteTrack(id) {
  508. try {
  509. MapView.removeTrack(id);
  510. await API.deleteTrack(id);
  511. await reload();
  512. } catch (e) {
  513. showToast('Error: ' + e.message, 'error');
  514. }
  515. }
  516. // ===== Dir Actions =====
  517. async function createDirPrompt() {
  518. const name = prompt('Folder name:');
  519. if (!name || !name.trim()) return;
  520. try {
  521. await API.createDir(name.trim(), selectedDirId);
  522. await reload();
  523. } catch (e) {
  524. showToast('Error: ' + e.message, 'error');
  525. }
  526. }
  527. async function renameDir(id, currentName) {
  528. const name = prompt('New name:', currentName);
  529. if (!name || !name.trim() || name.trim() === currentName) return;
  530. try {
  531. await API.renameDir(id, name.trim());
  532. await reload();
  533. } catch (e) {
  534. showToast('Error: ' + e.message, 'error');
  535. }
  536. }
  537. async function deleteDir(id) {
  538. try {
  539. await API.deleteDir(id);
  540. if (selectedDirId === id) selectedDirId = null;
  541. expandedDirs.delete(id);
  542. await reload();
  543. } catch (e) {
  544. showToast('Error: ' + e.message, 'error');
  545. }
  546. }
  547. // ===== Upload =====
  548. async function uploadFiles(files) {
  549. if (files.length === 0) return;
  550. const total = files.length;
  551. let failed = 0;
  552. for (let i = 0; i < files.length; i++) {
  553. const file = files[i];
  554. showUploadToast(total > 1
  555. ? `Uploading ${i + 1}/${total}: ${file.name}`
  556. : `Uploading ${file.name}…`);
  557. try {
  558. await API.uploadTrack(file, selectedDirId, null);
  559. } catch (err) {
  560. failed++;
  561. showToast(`Failed: ${file.name} — ${err.message}`, 'error');
  562. // Let the error toast show briefly before moving on
  563. await new Promise(r => setTimeout(r, 1200));
  564. }
  565. }
  566. const ok = total - failed;
  567. if (total === 1) {
  568. failed ? hideUploadToast() : showToast('Uploaded successfully', 'success');
  569. } else {
  570. showToast(
  571. failed === 0
  572. ? `Uploaded ${ok} file${ok !== 1 ? 's' : ''}`
  573. : `Uploaded ${ok}/${total} — ${failed} failed`,
  574. failed ? 'error' : 'success'
  575. );
  576. }
  577. await reload();
  578. }
  579. async function handleFileUpload(e) {
  580. const files = Array.from(e.target.files);
  581. e.target.value = '';
  582. await uploadFiles(files);
  583. }
  584. function setupDropZone() {
  585. const mapContainer = document.getElementById('map-container');
  586. mapContainer.addEventListener('dragover', (e) => {
  587. // Only handle file drops (not track drags)
  588. if (e.dataTransfer.types.includes('Files')) {
  589. e.preventDefault();
  590. mapContainer.classList.add('drag-over');
  591. }
  592. });
  593. mapContainer.addEventListener('dragleave', (e) => {
  594. if (!mapContainer.contains(e.relatedTarget)) {
  595. mapContainer.classList.remove('drag-over');
  596. }
  597. });
  598. mapContainer.addEventListener('drop', async (e) => {
  599. e.preventDefault();
  600. mapContainer.classList.remove('drag-over');
  601. const files = Array.from(e.dataTransfer.files).filter(f => f.name.toLowerCase().endsWith('.gpx'));
  602. await uploadFiles(files);
  603. });
  604. }
  605. // ===== Move Tracks =====
  606. function confirmMoveTracks(trackIds, targetDirId, targetDirName) {
  607. document.getElementById('confirm-title').textContent = 'Move Track' + (trackIds.length > 1 ? 's' : '');
  608. document.getElementById('confirm-message').textContent = trackIds.length === 1
  609. ? `Move this track to folder "${targetDirName}"?`
  610. : `Move ${trackIds.length} tracks to folder "${targetDirName}"?`;
  611. const modal = document.getElementById('confirm-modal');
  612. modal.style.display = 'flex';
  613. document.getElementById('confirm-ok-btn').onclick = async () => {
  614. modal.style.display = 'none';
  615. try {
  616. await Promise.all(trackIds.map(id => API.updateTrack(id, { directoryId: targetDirId })));
  617. selectedTrackIds.clear();
  618. await reload();
  619. showToast(trackIds.length === 1 ? 'Track moved' : `${trackIds.length} tracks moved`, 'success');
  620. } catch (e) {
  621. showToast('Error: ' + e.message, 'error');
  622. }
  623. };
  624. }
  625. function confirmMoveDir(srcId, srcName, targetDirId, targetDirName) {
  626. document.getElementById('confirm-title').textContent = 'Move Folder';
  627. document.getElementById('confirm-message').textContent =
  628. `Move folder "${srcName}" into "${targetDirName}"?`;
  629. const modal = document.getElementById('confirm-modal');
  630. modal.style.display = 'flex';
  631. document.getElementById('confirm-ok-btn').onclick = async () => {
  632. modal.style.display = 'none';
  633. try {
  634. await API.moveDir(srcId, targetDirId);
  635. await reload();
  636. showToast('Folder moved', 'success');
  637. } catch (e) {
  638. showToast('Error: ' + e.message, 'error');
  639. }
  640. };
  641. }
  642. async function showMoveDialog(trackId, trackName) {
  643. try {
  644. allDirs = await loadAllDirs();
  645. const list = document.getElementById('move-dir-list');
  646. let html = '<div class="move-dir-item" data-id=""><span>📁</span> (Root)</div>';
  647. for (const dir of allDirs) {
  648. html += `<div class="move-dir-item" data-id="${dir.id}"><span>📁</span> ${escHtml(dir.path)}</div>`;
  649. }
  650. list.innerHTML = html;
  651. let selectedMoveId = null;
  652. list.querySelectorAll('.move-dir-item').forEach(el => {
  653. el.addEventListener('click', () => {
  654. list.querySelectorAll('.move-dir-item').forEach(e => e.classList.remove('selected'));
  655. el.classList.add('selected');
  656. selectedMoveId = el.dataset.id || null;
  657. });
  658. });
  659. document.getElementById('move-confirm-btn').onclick = async () => {
  660. try {
  661. await API.updateTrack(trackId, { directoryId: selectedMoveId || null });
  662. document.getElementById('move-modal').style.display = 'none';
  663. await reload();
  664. showToast('Track moved', 'success');
  665. } catch (e) {
  666. showToast('Error: ' + e.message, 'error');
  667. }
  668. };
  669. document.getElementById('move-modal').style.display = 'flex';
  670. } catch (e) {
  671. showToast('Error: ' + e.message, 'error');
  672. }
  673. }
  674. async function loadAllDirs(parentId, prefix) {
  675. const result = [];
  676. let dirs;
  677. try {
  678. if (parentId !== undefined && parentId !== null) {
  679. const data = await API.getDir(parentId);
  680. dirs = data.children || [];
  681. } else {
  682. dirs = await API.getDirs();
  683. }
  684. } catch (e) {
  685. return result;
  686. }
  687. for (const d of dirs) {
  688. const path = (prefix ? prefix + ' / ' : '') + d.name;
  689. result.push({ id: d.id, name: d.name, path });
  690. const sub = await loadAllDirs(d.id, path);
  691. result.push(...sub);
  692. }
  693. return result;
  694. }
  695. // ===== Share =====
  696. async function shareTrack(trackId) {
  697. try {
  698. const track = await API.getTrack(trackId);
  699. const modal = document.getElementById('share-modal');
  700. const content = document.getElementById('share-content');
  701. const existingCode = track.ShareLink?.code || track.shareCode;
  702. if (existingCode) {
  703. const shareUrl = buildShareUrl(existingCode);
  704. content.innerHTML = `
  705. <p>Share link:</p>
  706. <div class="share-url-box">
  707. <input type="text" id="share-url-input" value="${escAttr(shareUrl)}" readonly>
  708. <button id="copy-share-btn">Copy</button>
  709. </div>
  710. <div style="margin-top:12px">
  711. <button id="share-revoke-btn" class="btn-danger">Revoke link</button>
  712. </div>
  713. `;
  714. content.querySelector('#copy-share-btn').addEventListener('click', () => {
  715. navigator.clipboard.writeText(shareUrl).then(() => showToast('Copied!', 'success')).catch(() => {
  716. document.getElementById('share-url-input').select();
  717. document.execCommand('copy');
  718. showToast('Copied!', 'success');
  719. });
  720. });
  721. content.querySelector('#share-revoke-btn').addEventListener('click', async () => {
  722. try {
  723. await API.deleteShare(trackId);
  724. modal.style.display = 'none';
  725. showToast('Share link revoked', 'success');
  726. } catch (err) {
  727. showToast('Error: ' + err.message, 'error');
  728. }
  729. });
  730. } else {
  731. content.innerHTML = `
  732. <p>No share link yet.</p>
  733. <button id="share-create-btn" class="btn-primary">Create share link</button>
  734. `;
  735. content.querySelector('#share-create-btn').addEventListener('click', async () => {
  736. try {
  737. const res = await API.createShare(trackId);
  738. const shareUrl = buildShareUrl(res.code);
  739. content.innerHTML = `
  740. <p>Share link created:</p>
  741. <div class="share-url-box">
  742. <input type="text" id="share-url-input-new" value="${escAttr(shareUrl)}" readonly>
  743. <button id="copy-share-new-btn">Copy</button>
  744. </div>
  745. `;
  746. content.querySelector('#copy-share-new-btn').addEventListener('click', () => {
  747. navigator.clipboard.writeText(shareUrl).then(() => showToast('Copied!', 'success')).catch(() => {
  748. document.getElementById('share-url-input-new').select();
  749. document.execCommand('copy');
  750. showToast('Copied!', 'success');
  751. });
  752. });
  753. } catch (err) {
  754. showToast('Error: ' + err.message, 'error');
  755. }
  756. });
  757. }
  758. modal.style.display = 'flex';
  759. } catch (e) {
  760. showToast('Error: ' + e.message, 'error');
  761. }
  762. }
  763. function buildShareUrl(code) {
  764. const base = window.location.origin + window.location.pathname;
  765. const appRoot = base.replace(/\/[^/]*$/, '/');
  766. return appRoot + 'share/' + code;
  767. }
  768. // ===== Confirm Delete =====
  769. function confirmDelete(type, name, onConfirm) {
  770. document.getElementById('confirm-title').textContent = `Delete ${type}`;
  771. document.getElementById('confirm-message').textContent = `Are you sure you want to delete "${name}"? This cannot be undone.`;
  772. const modal = document.getElementById('confirm-modal');
  773. modal.style.display = 'flex';
  774. document.getElementById('confirm-ok-btn').onclick = () => {
  775. modal.style.display = 'none';
  776. onConfirm();
  777. };
  778. }
  779. function getCurrentDirId() { return selectedDirId; }
  780. function refresh() { return reload(); }
  781. return { init, getCurrentDirId, refresh };
  782. })();