app.js 11 KB

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