browser.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. const Browser = (() => {
  2. let currentDirId = null; // null = root
  3. let dirStack = []; // breadcrumb stack: [{id, name}]
  4. let allDirs = []; // flat list for move dialog
  5. async function init() {
  6. document.getElementById('new-dir-btn').addEventListener('click', createDirPrompt);
  7. document.getElementById('upload-btn').addEventListener('click', () => {
  8. document.getElementById('file-input').click();
  9. });
  10. document.getElementById('file-input').addEventListener('change', handleFileUpload);
  11. // Drop zone on map
  12. setupDropZone();
  13. await loadDir(null);
  14. }
  15. async function loadDir(dirId) {
  16. currentDirId = dirId;
  17. renderBreadcrumb();
  18. try {
  19. let dirs, tracks;
  20. if (dirId === null) {
  21. dirs = await API.getDirs();
  22. const allTracks = await API.getTracks('');
  23. tracks = allTracks;
  24. } else {
  25. const data = await API.getDir(dirId);
  26. dirs = data.children || [];
  27. tracks = data.tracks || [];
  28. }
  29. renderList(dirs, tracks);
  30. } catch (e) {
  31. showToast('Error loading directory: ' + e.message, 'error');
  32. renderList([], []);
  33. }
  34. }
  35. function renderBreadcrumb() {
  36. const bc = document.getElementById('breadcrumb');
  37. let html = '<span class="bc-item" data-id="null">Root</span>';
  38. dirStack.forEach((item) => {
  39. html += ' <span class="bc-sep">›</span> ';
  40. html += `<span class="bc-item" data-id="${item.id}">${escHtml(item.name)}</span>`;
  41. });
  42. bc.innerHTML = html;
  43. bc.querySelectorAll('.bc-item').forEach(el => {
  44. el.addEventListener('click', () => {
  45. const id = el.dataset.id === 'null' ? null : parseInt(el.dataset.id);
  46. if (id === null) {
  47. dirStack = [];
  48. } else {
  49. const idx = dirStack.findIndex(d => d.id === id);
  50. if (idx >= 0) dirStack = dirStack.slice(0, idx + 1);
  51. }
  52. loadDir(id);
  53. });
  54. });
  55. }
  56. function renderList(dirs, tracks) {
  57. const list = document.getElementById('browser-list');
  58. let html = '';
  59. if (dirs.length === 0 && tracks.length === 0) {
  60. html = '<div class="empty-list">No files here. Upload a GPX or create a folder.</div>';
  61. }
  62. for (const dir of dirs) {
  63. html += `<div class="dir-item" data-id="${dir.id}">
  64. <span class="item-icon">📁</span>
  65. <span class="item-name">${escHtml(dir.name)}</span>
  66. <span class="item-date">${formatDate(dir.updatedAt)}</span>
  67. <span class="item-actions">
  68. <button class="item-btn rename-dir-btn" data-id="${dir.id}" data-name="${escAttr(dir.name)}" title="Rename">✏️</button>
  69. <button class="item-btn delete-dir-btn" data-id="${dir.id}" data-name="${escAttr(dir.name)}" title="Delete">🗑️</button>
  70. </span>
  71. </div>`;
  72. }
  73. for (const track of tracks) {
  74. const dist = track.totalDistance ? formatDistance(track.totalDistance) : '';
  75. html += `<div class="track-item" data-id="${track.id}">
  76. <span class="item-icon">🗺️</span>
  77. <span class="item-name">${escHtml(track.name)}</span>
  78. <span class="item-meta">${dist}</span>
  79. <span class="item-date">${formatDate(track.trackDate || track.uploadDate)}</span>
  80. <span class="item-actions">
  81. <button class="item-btn view-track-btn" data-id="${track.id}" title="View on map">👁️</button>
  82. <button class="item-btn share-track-btn" data-id="${track.id}" title="Share">🔗</button>
  83. <button class="item-btn move-track-btn" data-id="${track.id}" data-name="${escAttr(track.name)}" title="Move">📂</button>
  84. <button class="item-btn delete-track-btn" data-id="${track.id}" data-name="${escAttr(track.name)}" title="Delete">🗑️</button>
  85. </span>
  86. </div>`;
  87. }
  88. list.innerHTML = html;
  89. // Bind directory click (navigate into)
  90. list.querySelectorAll('.dir-item').forEach(el => {
  91. el.addEventListener('click', (e) => {
  92. if (e.target.closest('.item-actions')) return;
  93. const id = parseInt(el.dataset.id);
  94. const name = el.querySelector('.item-name').textContent;
  95. dirStack.push({ id, name });
  96. loadDir(id);
  97. });
  98. });
  99. list.querySelectorAll('.rename-dir-btn').forEach(btn => {
  100. btn.addEventListener('click', (e) => {
  101. e.stopPropagation();
  102. renameDir(parseInt(btn.dataset.id), btn.dataset.name);
  103. });
  104. });
  105. list.querySelectorAll('.delete-dir-btn').forEach(btn => {
  106. btn.addEventListener('click', (e) => {
  107. e.stopPropagation();
  108. confirmDelete('directory', btn.dataset.name, () => deleteDir(parseInt(btn.dataset.id)));
  109. });
  110. });
  111. // Bind track click (open on map)
  112. list.querySelectorAll('.track-item').forEach(el => {
  113. el.addEventListener('click', (e) => {
  114. if (e.target.closest('.item-actions')) return;
  115. openTrack(parseInt(el.dataset.id));
  116. });
  117. });
  118. list.querySelectorAll('.view-track-btn').forEach(btn => {
  119. btn.addEventListener('click', (e) => {
  120. e.stopPropagation();
  121. openTrack(parseInt(btn.dataset.id));
  122. });
  123. });
  124. list.querySelectorAll('.share-track-btn').forEach(btn => {
  125. btn.addEventListener('click', (e) => {
  126. e.stopPropagation();
  127. shareTrack(parseInt(btn.dataset.id));
  128. });
  129. });
  130. list.querySelectorAll('.move-track-btn').forEach(btn => {
  131. btn.addEventListener('click', (e) => {
  132. e.stopPropagation();
  133. showMoveDialog(parseInt(btn.dataset.id), btn.dataset.name);
  134. });
  135. });
  136. list.querySelectorAll('.delete-track-btn').forEach(btn => {
  137. btn.addEventListener('click', (e) => {
  138. e.stopPropagation();
  139. confirmDelete('track', btn.dataset.name, () => deleteTrack(parseInt(btn.dataset.id)));
  140. });
  141. });
  142. }
  143. async function openTrack(trackId) {
  144. try {
  145. if (MapView.hasTrack(trackId)) {
  146. // Toggle off
  147. MapView.removeTrack(trackId);
  148. MapView.setCurrentTrack(null);
  149. document.getElementById('track-info-panel').style.display = 'none';
  150. return;
  151. }
  152. const data = await API.getTrackPoints(trackId);
  153. MapView.addTrack(data, trackId);
  154. MapView.fitTrack(trackId);
  155. MapView.setCurrentTrack(trackId);
  156. showTrackInfo(data.meta);
  157. } catch (e) {
  158. showToast('Error loading track: ' + e.message, 'error');
  159. }
  160. }
  161. function showTrackInfo(meta) {
  162. if (!meta) return;
  163. const panel = document.getElementById('track-info-panel');
  164. document.getElementById('track-info-content').innerHTML = `
  165. <h3>${escHtml(meta.name || 'Track')}</h3>
  166. <div class="info-row"><label>Distance</label><span>${formatDistance(meta.totalDistance)}</span></div>
  167. <div class="info-row"><label>Points</label><span>${meta.pointCount || 0}</span></div>
  168. ${meta.trackDate ? `<div class="info-row"><label>Date</label><span>${formatDate(meta.trackDate)}</span></div>` : ''}
  169. `;
  170. panel.style.display = 'block';
  171. document.getElementById('track-info-close').onclick = () => { panel.style.display = 'none'; };
  172. }
  173. async function createDirPrompt() {
  174. const name = prompt('Folder name:');
  175. if (!name || !name.trim()) return;
  176. try {
  177. await API.createDir(name.trim(), currentDirId);
  178. await loadDir(currentDirId);
  179. } catch (e) {
  180. showToast('Error: ' + e.message, 'error');
  181. }
  182. }
  183. async function renameDir(id, currentName) {
  184. const name = prompt('New name:', currentName);
  185. if (!name || !name.trim() || name.trim() === currentName) return;
  186. try {
  187. await API.renameDir(id, name.trim());
  188. await loadDir(currentDirId);
  189. } catch (e) {
  190. showToast('Error: ' + e.message, 'error');
  191. }
  192. }
  193. async function deleteDir(id) {
  194. try {
  195. await API.deleteDir(id);
  196. await loadDir(currentDirId);
  197. } catch (e) {
  198. showToast('Error: ' + e.message, 'error');
  199. }
  200. }
  201. async function deleteTrack(id) {
  202. try {
  203. MapView.removeTrack(id);
  204. await API.deleteTrack(id);
  205. await loadDir(currentDirId);
  206. } catch (e) {
  207. showToast('Error: ' + e.message, 'error');
  208. }
  209. }
  210. async function handleFileUpload(e) {
  211. const files = Array.from(e.target.files);
  212. if (files.length === 0) return;
  213. e.target.value = '';
  214. for (const file of files) {
  215. showUploadToast(`Uploading ${file.name}...`);
  216. try {
  217. await API.uploadTrack(file, currentDirId, null);
  218. showToast(`Uploaded: ${file.name}`, 'success');
  219. } catch (err) {
  220. showToast(`Error uploading ${file.name}: ${err.message}`, 'error');
  221. }
  222. }
  223. hideUploadToast();
  224. await loadDir(currentDirId);
  225. }
  226. function setupDropZone() {
  227. const mapContainer = document.getElementById('map-container');
  228. mapContainer.addEventListener('dragover', (e) => {
  229. e.preventDefault();
  230. mapContainer.classList.add('drag-over');
  231. });
  232. mapContainer.addEventListener('dragleave', (e) => {
  233. if (!mapContainer.contains(e.relatedTarget)) {
  234. mapContainer.classList.remove('drag-over');
  235. }
  236. });
  237. mapContainer.addEventListener('drop', async (e) => {
  238. e.preventDefault();
  239. mapContainer.classList.remove('drag-over');
  240. const files = Array.from(e.dataTransfer.files).filter(f => f.name.toLowerCase().endsWith('.gpx'));
  241. if (files.length === 0) {
  242. showToast('No GPX files found in drop', 'error');
  243. return;
  244. }
  245. for (const file of files) {
  246. showUploadToast(`Uploading ${file.name}...`);
  247. try {
  248. await API.uploadTrack(file, currentDirId, null);
  249. showToast(`Uploaded: ${file.name}`, 'success');
  250. } catch (err) {
  251. showToast(`Error: ${err.message}`, 'error');
  252. }
  253. }
  254. hideUploadToast();
  255. await loadDir(currentDirId);
  256. });
  257. }
  258. async function shareTrack(trackId) {
  259. try {
  260. const track = await API.getTrack(trackId);
  261. const modal = document.getElementById('share-modal');
  262. const content = document.getElementById('share-content');
  263. const existingCode = track.ShareLink?.code || track.shareCode;
  264. if (existingCode) {
  265. const shareUrl = buildShareUrl(existingCode);
  266. content.innerHTML = `
  267. <p>Share link:</p>
  268. <div class="share-url-box">
  269. <input type="text" id="share-url-input" value="${escAttr(shareUrl)}" readonly>
  270. <button id="copy-share-btn">Copy</button>
  271. </div>
  272. <div style="margin-top:12px">
  273. <button id="share-revoke-btn" class="btn-danger">Revoke link</button>
  274. </div>
  275. `;
  276. content.querySelector('#copy-share-btn').addEventListener('click', () => {
  277. navigator.clipboard.writeText(shareUrl).then(() => showToast('Copied!', 'success')).catch(() => {
  278. document.getElementById('share-url-input').select();
  279. document.execCommand('copy');
  280. showToast('Copied!', 'success');
  281. });
  282. });
  283. content.querySelector('#share-revoke-btn').addEventListener('click', async () => {
  284. try {
  285. await API.deleteShare(trackId);
  286. modal.style.display = 'none';
  287. showToast('Share link revoked', 'success');
  288. } catch (err) {
  289. showToast('Error: ' + err.message, 'error');
  290. }
  291. });
  292. } else {
  293. content.innerHTML = `
  294. <p>No share link yet.</p>
  295. <button id="share-create-btn" class="btn-primary">Create share link</button>
  296. `;
  297. content.querySelector('#share-create-btn').addEventListener('click', async () => {
  298. try {
  299. const res = await API.createShare(trackId);
  300. const shareUrl = buildShareUrl(res.code);
  301. content.innerHTML = `
  302. <p>Share link created:</p>
  303. <div class="share-url-box">
  304. <input type="text" id="share-url-input-new" value="${escAttr(shareUrl)}" readonly>
  305. <button id="copy-share-new-btn">Copy</button>
  306. </div>
  307. `;
  308. content.querySelector('#copy-share-new-btn').addEventListener('click', () => {
  309. navigator.clipboard.writeText(shareUrl).then(() => showToast('Copied!', 'success')).catch(() => {
  310. document.getElementById('share-url-input-new').select();
  311. document.execCommand('copy');
  312. showToast('Copied!', 'success');
  313. });
  314. });
  315. } catch (err) {
  316. showToast('Error: ' + err.message, 'error');
  317. }
  318. });
  319. }
  320. modal.style.display = 'flex';
  321. } catch (e) {
  322. showToast('Error: ' + e.message, 'error');
  323. }
  324. }
  325. function buildShareUrl(code) {
  326. const base = window.location.origin + window.location.pathname;
  327. // Ensure we link to share/CODE relative to the app root
  328. const appRoot = base.replace(/\/[^/]*$/, '/');
  329. return appRoot + 'share/' + code;
  330. }
  331. async function showMoveDialog(trackId, trackName) {
  332. try {
  333. allDirs = await loadAllDirs();
  334. const list = document.getElementById('move-dir-list');
  335. let html = '<div class="move-dir-item" data-id=""><span>📁</span> (Root)</div>';
  336. for (const dir of allDirs) {
  337. html += `<div class="move-dir-item" data-id="${dir.id}"><span>📁</span> ${escHtml(dir.path)}</div>`;
  338. }
  339. list.innerHTML = html;
  340. let selectedDirId = null;
  341. list.querySelectorAll('.move-dir-item').forEach(el => {
  342. el.addEventListener('click', () => {
  343. list.querySelectorAll('.move-dir-item').forEach(e => e.classList.remove('selected'));
  344. el.classList.add('selected');
  345. selectedDirId = el.dataset.id || null;
  346. });
  347. });
  348. document.getElementById('move-confirm-btn').onclick = async () => {
  349. try {
  350. await API.updateTrack(trackId, { directoryId: selectedDirId || null });
  351. document.getElementById('move-modal').style.display = 'none';
  352. await loadDir(currentDirId);
  353. showToast('Track moved', 'success');
  354. } catch (e) {
  355. showToast('Error: ' + e.message, 'error');
  356. }
  357. };
  358. document.getElementById('move-modal').style.display = 'flex';
  359. } catch (e) {
  360. showToast('Error: ' + e.message, 'error');
  361. }
  362. }
  363. async function loadAllDirs(parentId, prefix) {
  364. const result = [];
  365. let dirs;
  366. try {
  367. if (parentId !== undefined && parentId !== null) {
  368. const data = await API.getDir(parentId);
  369. dirs = data.children || [];
  370. } else {
  371. dirs = await API.getDirs();
  372. }
  373. } catch (e) {
  374. return result;
  375. }
  376. for (const d of dirs) {
  377. const path = (prefix ? prefix + ' / ' : '') + d.name;
  378. result.push({ id: d.id, name: d.name, path });
  379. const sub = await loadAllDirs(d.id, path);
  380. result.push(...sub);
  381. }
  382. return result;
  383. }
  384. function confirmDelete(type, name, onConfirm) {
  385. document.getElementById('confirm-title').textContent = `Delete ${type}`;
  386. document.getElementById('confirm-message').textContent = `Are you sure you want to delete "${name}"? This cannot be undone.`;
  387. const modal = document.getElementById('confirm-modal');
  388. modal.style.display = 'flex';
  389. document.getElementById('confirm-ok-btn').onclick = () => {
  390. modal.style.display = 'none';
  391. onConfirm();
  392. };
  393. }
  394. function getCurrentDirId() { return currentDirId; }
  395. function refresh() { return loadDir(currentDirId); }
  396. return { init, loadDir, getCurrentDirId, refresh };
  397. })();