app.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. // ===== Sidebar resize =====
  2. function setupSidebarResize() {
  3. const handle = document.getElementById('sidebar-resize-handle');
  4. const sidebar = document.getElementById('sidebar');
  5. if (!handle || !sidebar) return;
  6. const MIN_WIDTH = 180;
  7. const MAX_WIDTH = 600;
  8. handle.addEventListener('pointerdown', (e) => {
  9. e.preventDefault();
  10. handle.classList.add('dragging');
  11. handle.setPointerCapture(e.pointerId);
  12. const startX = e.clientX;
  13. const startWidth = sidebar.offsetWidth;
  14. sidebar.classList.add('resizing');
  15. function onMove(ev) {
  16. const newWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + ev.clientX - startX));
  17. sidebar.style.width = newWidth + 'px';
  18. }
  19. function onUp() {
  20. sidebar.classList.remove('resizing');
  21. handle.classList.remove('dragging');
  22. handle.removeEventListener('pointermove', onMove);
  23. handle.removeEventListener('pointerup', onUp);
  24. const m = MapView.getMap();
  25. if (m) m.invalidateSize();
  26. }
  27. handle.addEventListener('pointermove', onMove);
  28. handle.addEventListener('pointerup', onUp);
  29. });
  30. }
  31. // ===== Utility functions (global scope) =====
  32. function escHtml(s) {
  33. if (s === null || s === undefined) return '';
  34. return String(s)
  35. .replace(/&/g, '&')
  36. .replace(/</g, '&lt;')
  37. .replace(/>/g, '&gt;')
  38. .replace(/"/g, '&quot;')
  39. .replace(/'/g, '&#039;');
  40. }
  41. function escAttr(s) {
  42. return escHtml(s);
  43. }
  44. function formatDistance(meters) {
  45. if (!meters && meters !== 0) return '';
  46. if (meters === 0) return '0 m';
  47. if (meters >= 1000) return (meters / 1000).toFixed(2) + ' km';
  48. return Math.round(meters) + ' m';
  49. }
  50. function formatDate(dateStr) {
  51. if (!dateStr) return '';
  52. const d = new Date(dateStr);
  53. if (isNaN(d.getTime())) return '';
  54. return d.toLocaleDateString();
  55. }
  56. let _toastTimeout;
  57. function showToast(msg, type) {
  58. const toast = document.getElementById('upload-toast');
  59. const text = document.getElementById('upload-toast-text');
  60. if (!toast || !text) return;
  61. text.textContent = msg;
  62. toast.className = type || '';
  63. toast.style.display = 'block';
  64. clearTimeout(_toastTimeout);
  65. _toastTimeout = setTimeout(() => { toast.style.display = 'none'; }, 3000);
  66. }
  67. function showUploadToast(msg) {
  68. const toast = document.getElementById('upload-toast');
  69. const text = document.getElementById('upload-toast-text');
  70. if (!toast || !text) return;
  71. text.textContent = msg;
  72. toast.className = '';
  73. toast.style.display = 'block';
  74. }
  75. function hideUploadToast() {
  76. setTimeout(() => {
  77. const toast = document.getElementById('upload-toast');
  78. if (toast) toast.style.display = 'none';
  79. }, 1000);
  80. }
  81. // ===== Admin Panel =====
  82. async function loadAdminPanel() {
  83. const content = document.getElementById('admin-content');
  84. content.innerHTML = '<div class="loading">Loading...</div>';
  85. try {
  86. const users = await API.adminGetUsers();
  87. let html = `<div style="overflow-x:auto">
  88. <table class="admin-table">
  89. <thead>
  90. <tr>
  91. <th>ID</th>
  92. <th>Login</th>
  93. <th>Email</th>
  94. <th>Admin</th>
  95. <th>Active</th>
  96. <th>Tracks</th>
  97. <th>Created</th>
  98. <th>Actions</th>
  99. </tr>
  100. </thead>
  101. <tbody>`;
  102. for (const u of users) {
  103. html += `<tr>
  104. <td>${u.id}</td>
  105. <td>${escHtml(u.login)}</td>
  106. <td>${escHtml(u.email || '')}</td>
  107. <td>${u.isAdmin ? '✓' : ''}</td>
  108. <td>${u.isActive ? '✓' : '✗'}</td>
  109. <td>${u.trackCount || 0}</td>
  110. <td>${formatDate(u.createdAt)}</td>
  111. <td>
  112. ${!u.isActive
  113. ? `<button class="btn-small btn-primary" onclick="adminActivate(${u.id}, true)">Activate</button>`
  114. : `<button class="btn-small btn-secondary" onclick="adminActivate(${u.id}, false)">Deactivate</button>`
  115. }
  116. ${!u.isAdmin
  117. ? `<button class="btn-small btn-secondary" onclick="adminSetAdmin(${u.id}, true)">Make Admin</button>`
  118. : `<button class="btn-small btn-secondary" onclick="adminSetAdmin(${u.id}, false)">Revoke Admin</button>`
  119. }
  120. <button class="btn-small btn-danger" onclick="adminDeleteUser(${u.id})">Delete</button>
  121. </td>
  122. </tr>`;
  123. }
  124. html += '</tbody></table></div>';
  125. content.innerHTML = html;
  126. } catch (e) {
  127. content.innerHTML = '<div class="error-msg">Error: ' + escHtml(e.message) + '</div>';
  128. }
  129. }
  130. async function adminActivate(userId, isActive) {
  131. try {
  132. await API.adminActivate(userId, isActive);
  133. await loadAdminPanel();
  134. showToast(isActive ? 'User activated' : 'User deactivated', 'success');
  135. } catch (e) {
  136. showToast('Error: ' + e.message, 'error');
  137. }
  138. }
  139. async function adminSetAdmin(userId, isAdmin) {
  140. try {
  141. await API.adminSetAdmin(userId, isAdmin);
  142. await loadAdminPanel();
  143. showToast(isAdmin ? 'Admin rights granted' : 'Admin rights revoked', 'success');
  144. } catch (e) {
  145. showToast('Error: ' + e.message, 'error');
  146. }
  147. }
  148. async function adminDeleteUser(userId) {
  149. document.getElementById('confirm-title').textContent = 'Delete User';
  150. document.getElementById('confirm-message').textContent = 'Delete this user and ALL their data? This cannot be undone.';
  151. const modal = document.getElementById('confirm-modal');
  152. modal.style.display = 'flex';
  153. document.getElementById('confirm-ok-btn').onclick = async () => {
  154. modal.style.display = 'none';
  155. try {
  156. await API.adminDeleteUser(userId);
  157. await loadAdminPanel();
  158. showToast('User deleted', 'success');
  159. } catch (e) {
  160. showToast('Error: ' + e.message, 'error');
  161. }
  162. };
  163. }
  164. // ===== Share Page (no auth) =====
  165. async function initSharePage(code) {
  166. document.getElementById('auth-page').style.display = 'none';
  167. document.getElementById('app').style.display = 'flex';
  168. document.getElementById('app').style.flexDirection = 'column';
  169. const sidebar = document.getElementById('sidebar');
  170. if (sidebar) sidebar.style.display = 'none';
  171. const appName = escHtml(window.APP_CONFIG?.appName || 'GPX Visualizer');
  172. document.getElementById('topbar').innerHTML = `
  173. <div id="topbar-left">
  174. <span id="topbar-title">${appName}</span>
  175. </div>
  176. <div id="topbar-right">
  177. <span style="color:rgba(255,255,255,0.6);font-size:13px">Shared track</span>
  178. </div>
  179. `;
  180. MapView.init();
  181. Elevation.init();
  182. const panel = document.getElementById('track-info-panel');
  183. const infoContent = document.getElementById('track-info-content');
  184. try {
  185. const data = await API.getShared(code);
  186. MapView.addTrack(data, 'shared');
  187. MapView.fitTrack('shared');
  188. MapView.setCurrentTrack('shared');
  189. const pts = MapView.getTrackPoints('shared');
  190. if (pts) Elevation.setTrack(pts);
  191. panel.style.display = 'block';
  192. infoContent.innerHTML = `
  193. <h3>${escHtml(data.meta?.name || 'Shared Track')}</h3>
  194. <div class="info-row"><label>Distance</label><span>${formatDistance(data.meta?.totalDistance)}</span></div>
  195. <div class="info-row"><label>Points</label><span>${data.meta?.pointCount || 0}</span></div>
  196. ${data.meta?.trackDate ? `<div class="info-row"><label>Date</label><span>${formatDate(data.meta.trackDate)}</span></div>` : ''}
  197. `;
  198. } catch (e) {
  199. panel.style.display = 'block';
  200. infoContent.innerHTML = '<div class="error-msg">Track not found or link has expired.</div>';
  201. }
  202. document.getElementById('track-info-close').onclick = () => {
  203. panel.style.display = 'none';
  204. };
  205. // Update document title
  206. document.title = (window.APP_CONFIG?.appName || 'GPX Visualizer') + ' - Shared Track';
  207. }
  208. // ===== Guest Mode =====
  209. function initGuestMode() {
  210. document.getElementById('auth-page').style.display = 'none';
  211. document.getElementById('app').style.display = 'flex';
  212. // Hide Stats tab — not useful without backend data
  213. document.querySelectorAll('.sidebar-tab').forEach(btn => {
  214. if (btn.dataset.tab === 'stats') btn.style.display = 'none';
  215. });
  216. const appName = escHtml(window.APP_CONFIG?.appName || 'GPX Visualizer');
  217. document.getElementById('topbar-left').innerHTML = `
  218. <button id="sidebar-toggle-btn" class="icon-btn" title="Toggle sidebar">☰</button>
  219. <span id="topbar-title">${appName}</span>
  220. `;
  221. document.getElementById('topbar-right').innerHTML = `
  222. <span style="color:rgba(255,255,255,0.6);font-size:13px">Guest</span>
  223. <button id="login-btn" class="topbar-btn" onclick="window.location.reload()">Login</button>
  224. `;
  225. // Re-bind sidebar toggle (innerHTML replaced the element)
  226. const sidebar = document.getElementById('sidebar');
  227. document.getElementById('sidebar-toggle-btn').addEventListener('click', () => {
  228. sidebar.classList.toggle('collapsed');
  229. setTimeout(() => {
  230. const m = MapView.getMap();
  231. if (m) m.invalidateSize();
  232. }, 250);
  233. });
  234. MapView.init();
  235. Elevation.init();
  236. LocalViewer.init();
  237. setupSidebarResize();
  238. }
  239. // ===== Main Init =====
  240. async function main() {
  241. const appName = window.APP_CONFIG?.appName || 'GPX Visualizer';
  242. document.title = appName;
  243. document.getElementById('app-title').textContent = appName;
  244. document.getElementById('topbar-title').textContent = appName;
  245. // Check if this is a share link
  246. const path = window.location.pathname;
  247. const shareMatch = path.match(/\/share\/([a-z0-9]+)$/i);
  248. if (shareMatch) {
  249. await initSharePage(shareMatch[1]);
  250. return;
  251. }
  252. // Setup modal close buttons
  253. document.querySelectorAll('.modal-close').forEach(btn => {
  254. btn.addEventListener('click', () => {
  255. const modal = document.getElementById(btn.dataset.modal);
  256. if (modal) modal.style.display = 'none';
  257. });
  258. });
  259. // Click outside modal to close
  260. document.querySelectorAll('.modal').forEach(modal => {
  261. modal.addEventListener('click', (e) => {
  262. if (e.target === modal) modal.style.display = 'none';
  263. });
  264. });
  265. // Keyboard: Escape to close modals
  266. document.addEventListener('keydown', (e) => {
  267. if (e.key === 'Escape') {
  268. document.querySelectorAll('.modal').forEach(modal => {
  269. if (modal.style.display !== 'none') modal.style.display = 'none';
  270. });
  271. }
  272. });
  273. // Sidebar tabs
  274. document.querySelectorAll('.sidebar-tab').forEach(btn => {
  275. btn.addEventListener('click', () => {
  276. document.querySelectorAll('.sidebar-tab').forEach(b => b.classList.remove('active'));
  277. btn.classList.add('active');
  278. document.querySelectorAll('.sidebar-tab-content').forEach(c => c.style.display = 'none');
  279. const tab = btn.dataset.tab;
  280. const tabEl = document.getElementById(tab + '-tab');
  281. if (tabEl) tabEl.style.display = 'flex';
  282. if (tab === 'stats') Stats.init();
  283. });
  284. });
  285. // Sidebar toggle
  286. const sidebar = document.getElementById('sidebar');
  287. document.getElementById('sidebar-toggle-btn').addEventListener('click', () => {
  288. sidebar.classList.toggle('collapsed');
  289. // Invalidate map size after transition
  290. setTimeout(() => {
  291. const m = MapView.getMap();
  292. if (m) m.invalidateSize();
  293. }, 250);
  294. });
  295. // Guest mode link
  296. document.getElementById('guest-mode-link').addEventListener('click', (e) => {
  297. e.preventDefault();
  298. initGuestMode();
  299. });
  300. // Check auth
  301. Auth.setupForms();
  302. const loggedIn = await Auth.init();
  303. if (!loggedIn) {
  304. document.getElementById('auth-page').style.display = 'flex';
  305. document.getElementById('app').style.display = 'none';
  306. return;
  307. }
  308. // Show app
  309. document.getElementById('auth-page').style.display = 'none';
  310. document.getElementById('app').style.display = 'flex';
  311. const user = Auth.getCurrentUser();
  312. document.getElementById('topbar-user').textContent = user.login;
  313. document.title = appName + ' - ' + user.login;
  314. if (user.isAdmin) {
  315. const adminBtn = document.getElementById('admin-btn');
  316. adminBtn.style.display = '';
  317. adminBtn.addEventListener('click', () => {
  318. document.getElementById('admin-modal').style.display = 'flex';
  319. loadAdminPanel();
  320. });
  321. }
  322. document.getElementById('logout-btn').addEventListener('click', Auth.logout);
  323. // Init map
  324. MapView.init();
  325. Elevation.init();
  326. // Init browser
  327. await Browser.init();
  328. setupSidebarResize();
  329. // Restore open track from hash
  330. const params = MapView.getHashParams();
  331. if (params.open) {
  332. const trackId = parseInt(params.open);
  333. if (!isNaN(trackId)) {
  334. try {
  335. const data = await API.getTrackPoints(trackId);
  336. MapView.addTrack(data, trackId);
  337. // Don't fit if we already have a saved map position
  338. if (!params.map) {
  339. MapView.fitTrack(trackId);
  340. }
  341. MapView.setCurrentTrack(trackId);
  342. // Show track info panel
  343. if (data.meta) {
  344. const panel = document.getElementById('track-info-panel');
  345. document.getElementById('track-info-content').innerHTML = `
  346. <h3>${escHtml(data.meta.name || 'Track')}</h3>
  347. <div class="info-row"><label>Distance</label><span>${formatDistance(data.meta.totalDistance)}</span></div>
  348. <div class="info-row"><label>Points</label><span>${data.meta.pointCount || 0}</span></div>
  349. ${data.meta.trackDate ? `<div class="info-row"><label>Date</label><span>${formatDate(data.meta.trackDate)}</span></div>` : ''}
  350. `;
  351. panel.style.display = 'block';
  352. document.getElementById('track-info-close').onclick = () => { panel.style.display = 'none'; };
  353. }
  354. } catch (e) {
  355. console.warn('Could not restore track from hash:', e.message);
  356. }
  357. }
  358. }
  359. }
  360. document.addEventListener('DOMContentLoaded', main);