1
0

4 Коммитууд 9ffe4d72d3 ... 412c7f06f2

Эзэн SHA1 Мессеж Огноо
  k4be 412c7f06f2 Add GPX export button to track list 1 сар өмнө
  k4be ebb10eb407 Show elevation gain and loss in elevation profile chart 1 сар өмнө
  k4be 2edcff8a58 Fix 401 handler: only clear token and reload if token exists 1 сар өмнө
  k4be aed4ee7c7c Add interactive CLI for user management 1 сар өмнө

+ 184 - 0
gpx-vis-backend/cli.js

@@ -0,0 +1,184 @@
+#!/usr/bin/env node
+'use strict';
+
+const readline = require('readline');
+const bcrypt = require('bcryptjs');
+
+const { initDatabase } = require('./src/database');
+const { User } = require('./src/models');
+
+const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
+
+function ask(question) {
+  return new Promise(resolve => rl.question(question, resolve));
+}
+
+function askHidden(question) {
+  return new Promise(resolve => {
+    process.stdout.write(question);
+    const stdin = process.stdin;
+    const wasRaw = stdin.isRaw;
+    stdin.setRawMode(true);
+    stdin.resume();
+    stdin.setEncoding('utf8');
+    let input = '';
+    const onData = ch => {
+      if (ch === '\r' || ch === '\n') {
+        stdin.setRawMode(wasRaw || false);
+        stdin.pause();
+        stdin.removeListener('data', onData);
+        process.stdout.write('\n');
+        resolve(input);
+      } else if (ch === '\u0003') {
+        process.stdout.write('\n');
+        process.exit();
+      } else if (ch === '\u007f' || ch === '\b') {
+        if (input.length > 0) input = input.slice(0, -1);
+      } else {
+        input += ch;
+      }
+    };
+    stdin.on('data', onData);
+  });
+}
+
+function formatDate(d) {
+  return d ? new Date(d).toISOString().slice(0, 10) : '-';
+}
+
+function printUsers(users) {
+  console.log('');
+  const header = `  ${'ID'.padEnd(4)}  ${'Login'.padEnd(20)}  ${'Email'.padEnd(28)}  ${'Active'.padEnd(6)}  ${'Admin'.padEnd(5)}  Created`;
+  console.log(header);
+  console.log('-'.repeat(header.length));
+  for (const u of users) {
+    console.log(
+      `  ${String(u.id).padEnd(4)}  ${u.login.padEnd(20)}  ${(u.email || '').padEnd(28)}  ${(u.isActive ? 'yes' : 'no').padEnd(6)}  ${(u.isAdmin ? 'yes' : 'no').padEnd(5)}  ${formatDate(u.createdAt)}`
+    );
+  }
+  console.log('');
+}
+
+async function listUsers() {
+  const users = await User.findAll({ order: [['id', 'ASC']] });
+  if (users.length === 0) {
+    console.log('No users found.');
+  } else {
+    printUsers(users);
+  }
+}
+
+async function findUser(prompt) {
+  const input = (await ask(prompt)).trim();
+  if (!input) return null;
+  const byId = /^\d+$/.test(input);
+  const user = byId
+    ? await User.findByPk(parseInt(input))
+    : await User.findOne({ where: { login: input } });
+  if (!user) { console.log('User not found.'); return null; }
+  return user;
+}
+
+async function activateDeactivate() {
+  await listUsers();
+  const user = await findUser('Enter user ID or login: ');
+  if (!user) return;
+  const current = user.isActive ? 'active' : 'inactive';
+  const action = user.isActive ? 'deactivate' : 'activate';
+  const confirm = (await ask(`User "${user.login}" is ${current}. ${action}? [y/N] `)).trim().toLowerCase();
+  if (confirm !== 'y') { console.log('Cancelled.'); return; }
+  await user.update({ isActive: !user.isActive });
+  console.log(`User "${user.login}" is now ${user.isActive ? 'active' : 'inactive'}.`);
+}
+
+async function changePassword() {
+  await listUsers();
+  const user = await findUser('Enter user ID or login: ');
+  if (!user) return;
+  const pw1 = await askHidden(`New password for "${user.login}": `);
+  if (!pw1) { console.log('Password cannot be empty.'); return; }
+  const pw2 = await askHidden('Confirm password: ');
+  if (pw1 !== pw2) { console.log('Passwords do not match.'); return; }
+  const hash = await bcrypt.hash(pw1, 10);
+  await user.update({ passwordHash: hash });
+  console.log(`Password updated for "${user.login}".`);
+}
+
+async function toggleAdmin() {
+  await listUsers();
+  const user = await findUser('Enter user ID or login: ');
+  if (!user) return;
+  const current = user.isAdmin ? 'admin' : 'regular user';
+  const action = user.isAdmin ? 'remove admin rights from' : 'grant admin rights to';
+  const confirm = (await ask(`User "${user.login}" is ${current}. ${action}? [y/N] `)).trim().toLowerCase();
+  if (confirm !== 'y') { console.log('Cancelled.'); return; }
+  await user.update({ isAdmin: !user.isAdmin });
+  console.log(`User "${user.login}" is now ${user.isAdmin ? 'an admin' : 'a regular user'}.`);
+}
+
+async function createUser() {
+  const login = (await ask('Login: ')).trim();
+  if (!login) { console.log('Login cannot be empty.'); return; }
+  const existing = await User.findOne({ where: { login } });
+  if (existing) { console.log('Login already taken.'); return; }
+  const email = (await ask('Email (optional): ')).trim() || null;
+  const pw = await askHidden('Password: ');
+  if (!pw) { console.log('Password cannot be empty.'); return; }
+  const pw2 = await askHidden('Confirm password: ');
+  if (pw !== pw2) { console.log('Passwords do not match.'); return; }
+  const isAdmin = (await ask('Admin? [y/N] ')).trim().toLowerCase() === 'y';
+  const isActive = (await ask('Active? [Y/n] ')).trim().toLowerCase() !== 'n';
+  const hash = await bcrypt.hash(pw, 10);
+  const user = await User.create({ login, email, passwordHash: hash, isAdmin, isActive });
+  console.log(`User "${user.login}" created (id=${user.id}).`);
+}
+
+async function deleteUser() {
+  await listUsers();
+  const user = await findUser('Enter user ID or login to delete: ');
+  if (!user) return;
+  const confirm = (await ask(`Delete user "${user.login}" and all their data? [y/N] `)).trim().toLowerCase();
+  if (confirm !== 'y') { console.log('Cancelled.'); return; }
+  await user.destroy();
+  console.log(`User "${user.login}" deleted.`);
+}
+
+const MENU = [
+  { key: '1', label: 'List users',              fn: listUsers },
+  { key: '2', label: 'Activate / deactivate',   fn: activateDeactivate },
+  { key: '3', label: 'Change password',          fn: changePassword },
+  { key: '4', label: 'Toggle admin',             fn: toggleAdmin },
+  { key: '5', label: 'Create user',              fn: createUser },
+  { key: '6', label: 'Delete user',              fn: deleteUser },
+  { key: 'q', label: 'Quit',                     fn: null },
+];
+
+function printMenu() {
+  console.log('\n=== GPX-Vis User Manager ===');
+  for (const item of MENU) {
+    console.log(`  [${item.key}] ${item.label}`);
+  }
+}
+
+async function main() {
+  await initDatabase();
+  console.log('Connected to database.');
+
+  while (true) {
+    printMenu();
+    const choice = (await ask('> ')).trim().toLowerCase();
+    const item = MENU.find(m => m.key === choice);
+    if (!item) { console.log('Invalid choice.'); continue; }
+    if (!item.fn) break;
+    try {
+      await item.fn();
+    } catch (e) {
+      console.error('Error:', e.message);
+    }
+  }
+
+  rl.close();
+  process.exit(0);
+}
+
+main().catch(e => { console.error(e); process.exit(1); });

+ 4 - 2
gpx-vis-frontend/js/api.js

@@ -16,8 +16,10 @@ const API = (() => {
 
     const res = await fetch(url, opts);
     if (res.status === 401) {
-      localStorage.removeItem('token');
-      window.location.reload();
+      if (getToken()) {
+        localStorage.removeItem('token');
+        window.location.reload();
+      }
       throw new Error('Unauthorized');
     }
 

+ 50 - 0
gpx-vis-frontend/js/browser.js

@@ -200,6 +200,7 @@ const Browser = (() => {
           <button class="item-btn view-track-btn" data-id="${track.id}" title="View on map">👁️</button>
           <button class="item-btn edit-track-btn" data-id="${track.id}" data-name="${escAttr(track.name)}" data-type="${escAttr(track.trackType || '')}" title="Edit">✏️</button>
           <button class="item-btn share-track-btn" data-id="${track.id}" title="Share">🔗</button>
+          <button class="item-btn export-track-btn" data-id="${track.id}" data-name="${escAttr(track.name)}" title="Export GPX">⬇️</button>
           <button class="item-btn move-track-btn" data-id="${track.id}" data-name="${escAttr(track.name)}" title="Move">📂</button>
           <button class="item-btn delete-track-btn" data-id="${track.id}" data-name="${escAttr(track.name)}" title="Delete">🗑️</button>
         </span>
@@ -296,6 +297,13 @@ const Browser = (() => {
       });
     });
 
+    container.querySelectorAll('.export-track-btn').forEach(btn => {
+      btn.addEventListener('click', (e) => {
+        e.stopPropagation();
+        exportTrack(parseInt(btn.dataset.id), btn.dataset.name);
+      });
+    });
+
     container.querySelectorAll('.move-track-btn').forEach(btn => {
       btn.addEventListener('click', (e) => {
         e.stopPropagation();
@@ -616,6 +624,48 @@ const Browser = (() => {
     }
   }
 
+  async function exportTrack(trackId, trackName) {
+    try {
+      let data = trackDataCache[trackId];
+      if (!data) {
+        data = await API.getTrackPoints(trackId);
+        trackDataCache[trackId] = data;
+      }
+      const gpx = buildGpx(data);
+      const blob = new Blob([gpx], { type: 'application/gpx+xml' });
+      const url = URL.createObjectURL(blob);
+      const a = document.createElement('a');
+      a.href = url;
+      a.download = (trackName || 'track').replace(/[/\\:*?"<>|]/g, '_') + '.gpx';
+      document.body.appendChild(a);
+      a.click();
+      document.body.removeChild(a);
+      URL.revokeObjectURL(url);
+    } catch (e) {
+      showToast('Export failed: ' + e.message, 'error');
+    }
+  }
+
+  function buildGpx(data) {
+    const name = data.meta?.name || 'Track';
+    const esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
+    let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
+    xml += '<gpx version="1.1" creator="gpx-vis" xmlns="http://www.topografix.com/GPX/1/1">\n';
+    xml += `  <trk>\n    <name>${esc(name)}</name>\n`;
+    for (const seg of (data.segments || [])) {
+      xml += '    <trkseg>\n';
+      for (const [lat, lon, ele, time] of seg) {
+        xml += `      <trkpt lat="${lat}" lon="${lon}">`;
+        if (ele != null) xml += `<ele>${ele}</ele>`;
+        if (time) xml += `<time>${time}</time>`;
+        xml += '</trkpt>\n';
+      }
+      xml += '    </trkseg>\n';
+    }
+    xml += '  </trk>\n</gpx>\n';
+    return xml;
+  }
+
   // ===== Dir Actions =====
 
   async function createDirPrompt() {

+ 25 - 1
gpx-vis-frontend/js/elevation.js

@@ -8,6 +8,7 @@ const Elevation = (() => {
   let chartPts = null;  // downsampled
   let bounds = null;    // computed after draw: {cw,ch,minE,eRange,totDist}
   let trackMeta = null; // {name, trackDate, ...} from the track's meta
+  let elevStats = null; // {gain, loss} computed from full-res points
 
   // ===== Public API =====
 
@@ -25,11 +26,12 @@ const Elevation = (() => {
     trackMeta = meta || null;
     chartPts  = downsample(pts, CHART_MAX_PTS);
     bounds    = null;
+    elevStats = computeElevStats(pts);
     raf(draw);
   }
 
   function clear() {
-    points = chartPts = bounds = trackMeta = null;
+    points = chartPts = bounds = trackMeta = elevStats = null;
     hideTooltip();
     if (canvas) {
       canvas.width = canvas.width; // reset context
@@ -91,6 +93,19 @@ const Elevation = (() => {
     return out;
   }
 
+  function computeElevStats(pts) {
+    let gain = 0, loss = 0, prev = null;
+    for (const p of pts) {
+      if (p.ele == null) continue;
+      if (prev != null) {
+        const d = p.ele - prev;
+        if (d > 0) gain += d; else loss -= d;
+      }
+      prev = p.ele;
+    }
+    return { gain: Math.round(gain), loss: Math.round(loss) };
+  }
+
   // ===== Chart drawing =====
 
   function draw() {
@@ -125,6 +140,7 @@ const Elevation = (() => {
 
     drawGrid(ctx, rect, cw, ch, minE, maxE, eRange, totDist);
     drawProfile(ctx, cw, ch, minE, eRange, totDist);
+    if (elevStats) drawStats(ctx, cw);
 
     bounds = { cw, ch, minE, maxE, eRange, totDist };
   }
@@ -191,6 +207,14 @@ const Elevation = (() => {
     ctx.stroke();
   }
 
+  function drawStats(ctx, cw) {
+    const text = `▲ ${elevStats.gain} m  ▼ ${elevStats.loss} m`;
+    ctx.font = '10px sans-serif';
+    ctx.textAlign = 'right';
+    ctx.fillStyle = 'rgba(120,120,120,0.9)';
+    ctx.fillText(text, PAD.left + cw - 2, PAD.top + 10);
+  }
+
   // Draw a vertical cursor + dot at the given point (CSS pixel coords)
   function drawIndicator(point) {
     if (!bounds || !canvas) return;