Переглянути джерело

Add main app orchestrator with routing and admin panel

k4be 7 годин тому
батько
коміт
0f10fb825f
1 змінених файлів з 328 додано та 0 видалено
  1. 328 0
      gpx-vis-frontend/js/app.js

+ 328 - 0
gpx-vis-frontend/js/app.js

@@ -0,0 +1,328 @@
+// ===== Utility functions (global scope) =====
+
+function escHtml(s) {
+  if (s === null || s === undefined) return '';
+  return String(s)
+    .replace(/&/g, '&')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+    .replace(/'/g, '&#039;');
+}
+
+function escAttr(s) {
+  return escHtml(s);
+}
+
+function formatDistance(meters) {
+  if (!meters && meters !== 0) return '';
+  if (meters === 0) return '0 m';
+  if (meters >= 1000) return (meters / 1000).toFixed(2) + ' km';
+  return Math.round(meters) + ' m';
+}
+
+function formatDate(dateStr) {
+  if (!dateStr) return '';
+  const d = new Date(dateStr);
+  if (isNaN(d.getTime())) return '';
+  return d.toLocaleDateString();
+}
+
+let _toastTimeout;
+function showToast(msg, type) {
+  const toast = document.getElementById('upload-toast');
+  const text = document.getElementById('upload-toast-text');
+  if (!toast || !text) return;
+  text.textContent = msg;
+  toast.className = type || '';
+  toast.style.display = 'block';
+  clearTimeout(_toastTimeout);
+  _toastTimeout = setTimeout(() => { toast.style.display = 'none'; }, 3000);
+}
+
+function showUploadToast(msg) {
+  const toast = document.getElementById('upload-toast');
+  const text = document.getElementById('upload-toast-text');
+  if (!toast || !text) return;
+  text.textContent = msg;
+  toast.className = '';
+  toast.style.display = 'block';
+}
+
+function hideUploadToast() {
+  setTimeout(() => {
+    const toast = document.getElementById('upload-toast');
+    if (toast) toast.style.display = 'none';
+  }, 1000);
+}
+
+// ===== Admin Panel =====
+
+async function loadAdminPanel() {
+  const content = document.getElementById('admin-content');
+  content.innerHTML = '<div class="loading">Loading...</div>';
+  try {
+    const users = await API.adminGetUsers();
+    let html = `<div style="overflow-x:auto">
+      <table class="admin-table">
+        <thead>
+          <tr>
+            <th>ID</th>
+            <th>Login</th>
+            <th>Email</th>
+            <th>Admin</th>
+            <th>Active</th>
+            <th>Tracks</th>
+            <th>Created</th>
+            <th>Actions</th>
+          </tr>
+        </thead>
+        <tbody>`;
+    for (const u of users) {
+      html += `<tr>
+        <td>${u.id}</td>
+        <td>${escHtml(u.login)}</td>
+        <td>${escHtml(u.email || '')}</td>
+        <td>${u.isAdmin ? '✓' : ''}</td>
+        <td>${u.isActive ? '✓' : '✗'}</td>
+        <td>${u.trackCount || 0}</td>
+        <td>${formatDate(u.createdAt)}</td>
+        <td>
+          ${!u.isActive
+            ? `<button class="btn-small btn-primary" onclick="adminActivate(${u.id}, true)">Activate</button>`
+            : `<button class="btn-small btn-secondary" onclick="adminActivate(${u.id}, false)">Deactivate</button>`
+          }
+          ${!u.isAdmin
+            ? `<button class="btn-small btn-secondary" onclick="adminSetAdmin(${u.id}, true)">Make Admin</button>`
+            : `<button class="btn-small btn-secondary" onclick="adminSetAdmin(${u.id}, false)">Revoke Admin</button>`
+          }
+          <button class="btn-small btn-danger" onclick="adminDeleteUser(${u.id})">Delete</button>
+        </td>
+      </tr>`;
+    }
+    html += '</tbody></table></div>';
+    content.innerHTML = html;
+  } catch (e) {
+    content.innerHTML = '<div class="error-msg">Error: ' + escHtml(e.message) + '</div>';
+  }
+}
+
+async function adminActivate(userId, isActive) {
+  try {
+    await API.adminActivate(userId, isActive);
+    await loadAdminPanel();
+    showToast(isActive ? 'User activated' : 'User deactivated', 'success');
+  } catch (e) {
+    showToast('Error: ' + e.message, 'error');
+  }
+}
+
+async function adminSetAdmin(userId, isAdmin) {
+  try {
+    await API.adminSetAdmin(userId, isAdmin);
+    await loadAdminPanel();
+    showToast(isAdmin ? 'Admin rights granted' : 'Admin rights revoked', 'success');
+  } catch (e) {
+    showToast('Error: ' + e.message, 'error');
+  }
+}
+
+async function adminDeleteUser(userId) {
+  document.getElementById('confirm-title').textContent = 'Delete User';
+  document.getElementById('confirm-message').textContent = 'Delete this user and ALL their data? This cannot be undone.';
+  const modal = document.getElementById('confirm-modal');
+  modal.style.display = 'flex';
+  document.getElementById('confirm-ok-btn').onclick = async () => {
+    modal.style.display = 'none';
+    try {
+      await API.adminDeleteUser(userId);
+      await loadAdminPanel();
+      showToast('User deleted', 'success');
+    } catch (e) {
+      showToast('Error: ' + e.message, 'error');
+    }
+  };
+}
+
+// ===== Share Page (no auth) =====
+
+async function initSharePage(code) {
+  document.getElementById('auth-page').style.display = 'none';
+  document.getElementById('app').style.display = 'flex';
+  document.getElementById('app').style.flexDirection = 'column';
+
+  const sidebar = document.getElementById('sidebar');
+  if (sidebar) sidebar.style.display = 'none';
+
+  const appName = escHtml(window.APP_CONFIG?.appName || 'GPX Visualizer');
+  document.getElementById('topbar').innerHTML = `
+    <div id="topbar-left">
+      <span id="topbar-title">${appName}</span>
+    </div>
+    <div id="topbar-right">
+      <span style="color:rgba(255,255,255,0.6);font-size:13px">Shared track</span>
+    </div>
+  `;
+
+  MapView.init();
+
+  const panel = document.getElementById('track-info-panel');
+  const infoContent = document.getElementById('track-info-content');
+
+  try {
+    const data = await API.getShared(code);
+    MapView.addTrack(data, 'shared');
+    MapView.fitTrack('shared');
+
+    panel.style.display = 'block';
+    infoContent.innerHTML = `
+      <h3>${escHtml(data.meta?.name || 'Shared Track')}</h3>
+      <div class="info-row"><label>Distance</label><span>${formatDistance(data.meta?.totalDistance)}</span></div>
+      <div class="info-row"><label>Points</label><span>${data.meta?.pointCount || 0}</span></div>
+      ${data.meta?.trackDate ? `<div class="info-row"><label>Date</label><span>${formatDate(data.meta.trackDate)}</span></div>` : ''}
+    `;
+  } catch (e) {
+    panel.style.display = 'block';
+    infoContent.innerHTML = '<div class="error-msg">Track not found or link has expired.</div>';
+  }
+
+  document.getElementById('track-info-close').onclick = () => {
+    panel.style.display = 'none';
+  };
+
+  // Update document title
+  document.title = (window.APP_CONFIG?.appName || 'GPX Visualizer') + ' - Shared Track';
+}
+
+// ===== Main Init =====
+
+async function main() {
+  const appName = window.APP_CONFIG?.appName || 'GPX Visualizer';
+  document.title = appName;
+  document.getElementById('app-title').textContent = appName;
+  document.getElementById('topbar-title').textContent = appName;
+
+  // Check if this is a share link
+  const path = window.location.pathname;
+  const shareMatch = path.match(/\/share\/([a-z0-9]+)$/i);
+  if (shareMatch) {
+    await initSharePage(shareMatch[1]);
+    return;
+  }
+
+  // Setup modal close buttons
+  document.querySelectorAll('.modal-close').forEach(btn => {
+    btn.addEventListener('click', () => {
+      const modal = document.getElementById(btn.dataset.modal);
+      if (modal) modal.style.display = 'none';
+    });
+  });
+
+  // Click outside modal to close
+  document.querySelectorAll('.modal').forEach(modal => {
+    modal.addEventListener('click', (e) => {
+      if (e.target === modal) modal.style.display = 'none';
+    });
+  });
+
+  // Keyboard: Escape to close modals
+  document.addEventListener('keydown', (e) => {
+    if (e.key === 'Escape') {
+      document.querySelectorAll('.modal').forEach(modal => {
+        if (modal.style.display !== 'none') modal.style.display = 'none';
+      });
+    }
+  });
+
+  // Sidebar tabs
+  document.querySelectorAll('.sidebar-tab').forEach(btn => {
+    btn.addEventListener('click', () => {
+      document.querySelectorAll('.sidebar-tab').forEach(b => b.classList.remove('active'));
+      btn.classList.add('active');
+      document.querySelectorAll('.sidebar-tab-content').forEach(c => c.style.display = 'none');
+      const tab = btn.dataset.tab;
+      const tabEl = document.getElementById(tab + '-tab');
+      if (tabEl) tabEl.style.display = 'flex';
+      if (tab === 'stats') Stats.init();
+    });
+  });
+
+  // Sidebar toggle
+  const sidebar = document.getElementById('sidebar');
+  document.getElementById('sidebar-toggle-btn').addEventListener('click', () => {
+    sidebar.classList.toggle('collapsed');
+    // Invalidate map size after transition
+    setTimeout(() => {
+      const m = MapView.getMap();
+      if (m) m.invalidateSize();
+    }, 250);
+  });
+
+  // Check auth
+  Auth.setupForms();
+  const loggedIn = await Auth.init();
+
+  if (!loggedIn) {
+    document.getElementById('auth-page').style.display = 'flex';
+    document.getElementById('app').style.display = 'none';
+    return;
+  }
+
+  // Show app
+  document.getElementById('auth-page').style.display = 'none';
+  document.getElementById('app').style.display = 'flex';
+
+  const user = Auth.getCurrentUser();
+  document.getElementById('topbar-user').textContent = user.login;
+  document.title = appName + ' - ' + user.login;
+
+  if (user.isAdmin) {
+    const adminBtn = document.getElementById('admin-btn');
+    adminBtn.style.display = '';
+    adminBtn.addEventListener('click', () => {
+      document.getElementById('admin-modal').style.display = 'flex';
+      loadAdminPanel();
+    });
+  }
+
+  document.getElementById('logout-btn').addEventListener('click', Auth.logout);
+
+  // Init map
+  MapView.init();
+
+  // Init browser
+  await Browser.init();
+
+  // Restore open track from hash
+  const params = MapView.getHashParams();
+  if (params.open) {
+    const trackId = parseInt(params.open);
+    if (!isNaN(trackId)) {
+      try {
+        const data = await API.getTrackPoints(trackId);
+        MapView.addTrack(data, trackId);
+        // Don't fit if we already have a saved map position
+        if (!params.map) {
+          MapView.fitTrack(trackId);
+        }
+        MapView.setCurrentTrack(trackId);
+        // Show track info panel
+        if (data.meta) {
+          const panel = document.getElementById('track-info-panel');
+          document.getElementById('track-info-content').innerHTML = `
+            <h3>${escHtml(data.meta.name || 'Track')}</h3>
+            <div class="info-row"><label>Distance</label><span>${formatDistance(data.meta.totalDistance)}</span></div>
+            <div class="info-row"><label>Points</label><span>${data.meta.pointCount || 0}</span></div>
+            ${data.meta.trackDate ? `<div class="info-row"><label>Date</label><span>${formatDate(data.meta.trackDate)}</span></div>` : ''}
+          `;
+          panel.style.display = 'block';
+          document.getElementById('track-info-close').onclick = () => { panel.style.display = 'none'; };
+        }
+      } catch (e) {
+        console.warn('Could not restore track from hash:', e.message);
+      }
+    }
+  }
+}
+
+document.addEventListener('DOMContentLoaded', main);