Parcourir la source

Add track editing (name + type) with emoji labels

Backend:
- Track model gets trackType VARCHAR(32) NULL field
- database.js: runMigrations() adds missing columns on startup via
  PRAGMA table_info (SQLite) or information_schema (MySQL/PG), safe to
  run repeatedly on existing databases
- PUT /api/tracks/:id now accepts trackType; validates against allowed
  values: hiking, running, cycling, driving, train, other
- GET /api/tracks and GET /api/tracks/:id/points now return trackType

Frontend:
- Edit modal with Name input and Type dropdown (emoji options)
- ✏️ Edit button added to each track item in the sidebar
- Track item icon changes to type emoji (🥾🏃🚴🚗🚆📍) when type is set
- Track info panel shows type row when set
- TRACK_TYPE_EMOJI map shared between list rendering and info panel
- Hover tooltip already receives meta.trackType via existing meta flow
- form-group / form-input CSS added for modal form fields

Backend restart required (model and database.js changed; migration
will run automatically on first startup and add the trackType column).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
k4be il y a 6 heures
Parent
commit
d73f34ba6d

+ 31 - 0
gpx-vis-backend/src/database.js

@@ -26,6 +26,36 @@ function getSequelize() {
   return sequelize;
 }
 
+// Additive-only column migrations. Safe to run on every startup — each
+// entry is applied only when the column is not yet present.
+async function runMigrations(sq) {
+  const migrations = [
+    { table: 'tracks', column: 'trackType', definition: 'VARCHAR(32) NULL DEFAULT NULL' },
+  ];
+  const dialect = sq.getDialect();
+  for (const { table, column, definition } of migrations) {
+    try {
+      let exists = false;
+      if (dialect === 'sqlite') {
+        const [cols] = await sq.query(`PRAGMA table_info("${table}")`);
+        exists = cols.some(c => c.name === column);
+      } else {
+        const [cols] = await sq.query(
+          `SELECT column_name FROM information_schema.columns WHERE table_name = :table AND column_name = :column`,
+          { replacements: { table, column } }
+        );
+        exists = cols.length > 0;
+      }
+      if (!exists) {
+        await sq.query(`ALTER TABLE "${table}" ADD COLUMN "${column}" ${definition}`);
+        console.log(`Migration: added ${table}.${column}`);
+      }
+    } catch (e) {
+      console.warn(`Migration warning (${table}.${column}):`, e.message);
+    }
+  }
+}
+
 async function initDatabase() {
   const sq = getSequelize();
   // Import models to register them
@@ -35,6 +65,7 @@ async function initDatabase() {
   // alter:true is intentionally avoided: on SQLite it drops and recreates
   // tables to change columns, which destroys data and breaks FK constraints.
   await sq.sync(isMemory ? {} : {});
+  if (!isMemory) await runMigrations(sq);
   console.log('Database initialized');
 }
 

+ 1 - 0
gpx-vis-backend/src/models/Track.js

@@ -8,4 +8,5 @@ module.exports = (sequelize, DataTypes) => sequelize.define('Track', {
   trackDate: { type: DataTypes.DATE, allowNull: true },
   pointCount: { type: DataTypes.INTEGER, defaultValue: 0 },
   totalDistance: { type: DataTypes.FLOAT, defaultValue: 0 }, // meters
+  trackType: { type: DataTypes.STRING(32), allowNull: true, defaultValue: null },
 }, { tableName: 'tracks', timestamps: true });

+ 9 - 2
gpx-vis-backend/src/routes/tracks.js

@@ -28,7 +28,7 @@ router.get('/', requireAuth, async (req, res) => {
     const tracks = await Track.findAll({
       where,
       order: [['trackDate', 'DESC'], ['uploadDate', 'DESC']],
-      attributes: ['id', 'name', 'originalFilename', 'uploadDate', 'trackDate', 'pointCount', 'totalDistance', 'directoryId'],
+      attributes: ['id', 'name', 'originalFilename', 'uploadDate', 'trackDate', 'pointCount', 'totalDistance', 'directoryId', 'trackType'],
     });
     res.json(tracks);
   } catch (e) {
@@ -69,7 +69,7 @@ router.get('/:id/points', requireAuth, async (req, res) => {
     const segments = Object.keys(segMap).sort((a,b)=>a-b).map(k => segMap[k]);
 
     res.json({
-      meta: { trackId: track.id, name: track.name, totalDistance: track.totalDistance, pointCount: track.pointCount, trackDate: track.trackDate },
+      meta: { trackId: track.id, name: track.name, totalDistance: track.totalDistance, pointCount: track.pointCount, trackDate: track.trackDate, trackType: track.trackType },
       segments
     });
   } catch (e) {
@@ -144,8 +144,15 @@ router.put('/:id', requireAuth, async (req, res) => {
     const track = await Track.findOne({ where: { id: req.params.id, userId: req.user.id } });
     if (!track) return res.status(404).json({ error: 'Track not found' });
 
+    const VALID_TYPES = ['hiking', 'running', 'cycling', 'driving', 'train', 'other', null, ''];
     const updates = {};
     if (req.body.name) updates.name = req.body.name;
+    if ('trackType' in req.body) {
+      const t = req.body.trackType || null;
+      if (t !== null && !VALID_TYPES.includes(t))
+        return res.status(400).json({ error: 'Invalid trackType' });
+      updates.trackType = t;
+    }
     if (req.body.directoryId !== undefined) {
       const dirId = req.body.directoryId || null;
       if (dirId) {

+ 30 - 0
gpx-vis-frontend/css/style.css

@@ -791,6 +791,36 @@ form button[type="submit"]:hover {
   transition: color 0.15s;
 }
 
+/* ===== Form fields (used in modals) ===== */
+.form-group {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  margin-bottom: 14px;
+}
+
+.form-group label {
+  font-size: 13px;
+  font-weight: 500;
+  color: var(--color-text-light);
+}
+
+.form-input {
+  width: 100%;
+  padding: 8px 10px;
+  border: 1px solid var(--color-border);
+  border-radius: var(--radius-sm);
+  font-size: 14px;
+  color: var(--color-text);
+  background: var(--color-white);
+  transition: border-color 0.15s;
+}
+
+.form-input:focus {
+  outline: none;
+  border-color: var(--color-blue);
+}
+
 .modal-close:hover {
   color: var(--color-text);
 }

+ 32 - 0
gpx-vis-frontend/index.html

@@ -146,6 +146,38 @@
     </div>
   </div>
 
+  <!-- Edit track modal -->
+  <div id="edit-track-modal" class="modal" style="display:none">
+    <div class="modal-content">
+      <div class="modal-header">
+        <h2>Edit Track</h2>
+        <button class="modal-close" data-modal="edit-track-modal">×</button>
+      </div>
+      <div id="edit-track-content">
+        <div class="form-group">
+          <label for="edit-track-name">Name</label>
+          <input type="text" id="edit-track-name" class="form-input" maxlength="255">
+        </div>
+        <div class="form-group">
+          <label for="edit-track-type">Type</label>
+          <select id="edit-track-type" class="form-input">
+            <option value="">— unset —</option>
+            <option value="hiking">🥾 Hiking</option>
+            <option value="running">🏃 Running</option>
+            <option value="cycling">🚴 Cycling</option>
+            <option value="driving">🚗 Driving</option>
+            <option value="train">🚆 Train</option>
+            <option value="other">📍 Other</option>
+          </select>
+        </div>
+        <div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end">
+          <button id="edit-track-save-btn" class="btn-primary">Save</button>
+          <button class="btn-secondary modal-close" data-modal="edit-track-modal">Cancel</button>
+        </div>
+      </div>
+    </div>
+  </div>
+
   <!-- Upload progress -->
   <div id="upload-toast" style="display:none">
     <div id="upload-toast-text">Uploading...</div>

+ 45 - 1
gpx-vis-frontend/js/browser.js

@@ -1,3 +1,12 @@
+const TRACK_TYPE_EMOJI = {
+  hiking: '🥾',
+  running: '🏃',
+  cycling: '🚴',
+  driving: '🚗',
+  train: '🚆',
+  other: '📍',
+};
+
 const Browser = (() => {
   let selectedDirId = null;  // used as upload context
   let expandedDirs = new Set();
@@ -145,14 +154,16 @@ const Browser = (() => {
     for (const track of tracks) {
       const dist = track.totalDistance ? formatDistance(track.totalDistance) : '';
       const isSel = selectedTrackIds.has(track.id);
+      const typeEmoji = TRACK_TYPE_EMOJI[track.trackType] || '🗺️';
       html += `<div class="tree-item track-item${isSel ? ' multi-selected' : ''}" data-id="${track.id}" draggable="true"
         data-name="${escAttr(track.name)}" style="padding-left:${28 + baseIndent}px">
-        <span class="item-icon">🗺️</span>
+        <span class="item-icon" title="${escAttr(track.trackType || '')}">${typeEmoji}</span>
         <span class="item-name">${escHtml(track.name)}</span>
         <span class="item-meta">${dist}</span>
         <span class="item-date">${formatDate(track.trackDate || track.uploadDate)}</span>
         <span class="item-actions">
           <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 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>
@@ -243,6 +254,13 @@ const Browser = (() => {
       btn.addEventListener('click', (e) => { e.stopPropagation(); shareTrack(parseInt(btn.dataset.id)); });
     });
 
+    container.querySelectorAll('.edit-track-btn').forEach(btn => {
+      btn.addEventListener('click', (e) => {
+        e.stopPropagation();
+        editTrack(parseInt(btn.dataset.id), btn.dataset.name, btn.dataset.type);
+      });
+    });
+
     container.querySelectorAll('.move-track-btn').forEach(btn => {
       btn.addEventListener('click', (e) => {
         e.stopPropagation();
@@ -493,17 +511,43 @@ const Browser = (() => {
 
   function showTrackInfo(meta) {
     if (!meta) return;
+    const typeEmoji = TRACK_TYPE_EMOJI[meta.trackType] || null;
+    const typeLabel = meta.trackType
+      ? `${typeEmoji ? typeEmoji + ' ' : ''}${meta.trackType.charAt(0).toUpperCase() + meta.trackType.slice(1)}`
+      : null;
     const panel = document.getElementById('track-info-panel');
     document.getElementById('track-info-content').innerHTML = `
       <h3>${escHtml(meta.name || 'Track')}</h3>
       <div class="info-row"><label>Distance</label><span>${formatDistance(meta.totalDistance)}</span></div>
       <div class="info-row"><label>Points</label><span>${meta.pointCount || 0}</span></div>
       ${meta.trackDate ? `<div class="info-row"><label>Date</label><span>${formatDate(meta.trackDate)}</span></div>` : ''}
+      ${typeLabel ? `<div class="info-row"><label>Type</label><span>${escHtml(typeLabel)}</span></div>` : ''}
     `;
     panel.style.display = 'block';
     document.getElementById('track-info-close').onclick = () => { panel.style.display = 'none'; };
   }
 
+  function editTrack(trackId, currentName, currentType) {
+    const modal = document.getElementById('edit-track-modal');
+    document.getElementById('edit-track-name').value = currentName || '';
+    document.getElementById('edit-track-type').value = currentType || '';
+    modal.style.display = 'flex';
+
+    document.getElementById('edit-track-save-btn').onclick = async () => {
+      const name = document.getElementById('edit-track-name').value.trim();
+      const trackType = document.getElementById('edit-track-type').value || null;
+      if (!name) { showToast('Name cannot be empty', 'error'); return; }
+      modal.style.display = 'none';
+      try {
+        await API.updateTrack(trackId, { name, trackType });
+        await reload();
+        showToast('Track updated', 'success');
+      } catch (e) {
+        showToast('Error: ' + e.message, 'error');
+      }
+    };
+  }
+
   async function deleteTrack(id) {
     try {
       MapView.removeTrack(id);