|
@@ -0,0 +1,328 @@
|
|
|
|
|
+// ===== Utility functions (global scope) =====
|
|
|
|
|
+
|
|
|
|
|
+function escHtml(s) {
|
|
|
|
|
+ if (s === null || s === undefined) return '';
|
|
|
|
|
+ return String(s)
|
|
|
|
|
+ .replace(/&/g, '&')
|
|
|
|
|
+ .replace(/</g, '<')
|
|
|
|
|
+ .replace(/>/g, '>')
|
|
|
|
|
+ .replace(/"/g, '"')
|
|
|
|
|
+ .replace(/'/g, ''');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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);
|