app.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  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. // ===== Main Init =====
  179. async function main() {
  180. const appName = window.APP_CONFIG?.appName || 'GPX Visualizer';
  181. document.title = appName;
  182. document.getElementById('app-title').textContent = appName;
  183. document.getElementById('topbar-title').textContent = appName;
  184. // Check if this is a share link
  185. const path = window.location.pathname;
  186. const shareMatch = path.match(/\/share\/([a-z0-9]+)$/i);
  187. if (shareMatch) {
  188. await initSharePage(shareMatch[1]);
  189. return;
  190. }
  191. // Setup modal close buttons
  192. document.querySelectorAll('.modal-close').forEach(btn => {
  193. btn.addEventListener('click', () => {
  194. const modal = document.getElementById(btn.dataset.modal);
  195. if (modal) modal.style.display = 'none';
  196. });
  197. });
  198. // Click outside modal to close
  199. document.querySelectorAll('.modal').forEach(modal => {
  200. modal.addEventListener('click', (e) => {
  201. if (e.target === modal) modal.style.display = 'none';
  202. });
  203. });
  204. // Keyboard: Escape to close modals
  205. document.addEventListener('keydown', (e) => {
  206. if (e.key === 'Escape') {
  207. document.querySelectorAll('.modal').forEach(modal => {
  208. if (modal.style.display !== 'none') modal.style.display = 'none';
  209. });
  210. }
  211. });
  212. // Sidebar tabs
  213. document.querySelectorAll('.sidebar-tab').forEach(btn => {
  214. btn.addEventListener('click', () => {
  215. document.querySelectorAll('.sidebar-tab').forEach(b => b.classList.remove('active'));
  216. btn.classList.add('active');
  217. document.querySelectorAll('.sidebar-tab-content').forEach(c => c.style.display = 'none');
  218. const tab = btn.dataset.tab;
  219. const tabEl = document.getElementById(tab + '-tab');
  220. if (tabEl) tabEl.style.display = 'flex';
  221. if (tab === 'stats') Stats.init();
  222. });
  223. });
  224. // Sidebar toggle
  225. const sidebar = document.getElementById('sidebar');
  226. document.getElementById('sidebar-toggle-btn').addEventListener('click', () => {
  227. sidebar.classList.toggle('collapsed');
  228. // Invalidate map size after transition
  229. setTimeout(() => {
  230. const m = MapView.getMap();
  231. if (m) m.invalidateSize();
  232. }, 250);
  233. });
  234. // Check auth
  235. Auth.setupForms();
  236. const loggedIn = await Auth.init();
  237. if (!loggedIn) {
  238. document.getElementById('auth-page').style.display = 'flex';
  239. document.getElementById('app').style.display = 'none';
  240. return;
  241. }
  242. // Show app
  243. document.getElementById('auth-page').style.display = 'none';
  244. document.getElementById('app').style.display = 'flex';
  245. const user = Auth.getCurrentUser();
  246. document.getElementById('topbar-user').textContent = user.login;
  247. document.title = appName + ' - ' + user.login;
  248. if (user.isAdmin) {
  249. const adminBtn = document.getElementById('admin-btn');
  250. adminBtn.style.display = '';
  251. adminBtn.addEventListener('click', () => {
  252. document.getElementById('admin-modal').style.display = 'flex';
  253. loadAdminPanel();
  254. });
  255. }
  256. document.getElementById('logout-btn').addEventListener('click', Auth.logout);
  257. // Init map
  258. MapView.init();
  259. Elevation.init();
  260. // Init browser
  261. await Browser.init();
  262. // Restore open track from hash
  263. const params = MapView.getHashParams();
  264. if (params.open) {
  265. const trackId = parseInt(params.open);
  266. if (!isNaN(trackId)) {
  267. try {
  268. const data = await API.getTrackPoints(trackId);
  269. MapView.addTrack(data, trackId);
  270. // Don't fit if we already have a saved map position
  271. if (!params.map) {
  272. MapView.fitTrack(trackId);
  273. }
  274. MapView.setCurrentTrack(trackId);
  275. // Show track info panel
  276. if (data.meta) {
  277. const panel = document.getElementById('track-info-panel');
  278. document.getElementById('track-info-content').innerHTML = `
  279. <h3>${escHtml(data.meta.name || 'Track')}</h3>
  280. <div class="info-row"><label>Distance</label><span>${formatDistance(data.meta.totalDistance)}</span></div>
  281. <div class="info-row"><label>Points</label><span>${data.meta.pointCount || 0}</span></div>
  282. ${data.meta.trackDate ? `<div class="info-row"><label>Date</label><span>${formatDate(data.meta.trackDate)}</span></div>` : ''}
  283. `;
  284. panel.style.display = 'block';
  285. document.getElementById('track-info-close').onclick = () => { panel.style.display = 'none'; };
  286. }
  287. } catch (e) {
  288. console.warn('Could not restore track from hash:', e.message);
  289. }
  290. }
  291. }
  292. }
  293. document.addEventListener('DOMContentLoaded', main);