app.js 12 KB

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