tripkit 1.1.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,11 +9,45 @@
9
9
  <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,300;1,9..40,400&family=Instrument+Serif:ital@0;1&display=swap" rel="stylesheet">
10
10
  <style>
11
11
  *{margin:0;padding:0;box-sizing:border-box}
12
- :root{--bg:#f4f1eb;--card:#fff;--text:#1a1a18;--muted:#7a7768;--subtle:#b5b1a4;
13
- --accent:#1b5e3b;--accent-soft:#dceee4;--warn:#c44b25;--warn-soft:#fdeee8;
14
- --blue:#2563a8;--blue-soft:#e8f0fa;--purple:#5b44b0;--purple-soft:#eeeafb;
15
- --amber:#a16207;--amber-soft:#fdf4e3;--coral:#c44b25;
16
- --border:rgba(0,0,0,.08);--font:'DM Sans',system-ui,sans-serif;--serif:'Instrument Serif',Georgia,serif;--r:10px}
12
+ :root{
13
+ --bg:#f4f1eb;--card:#fff;--text:#1a1a18;--muted:#7a7768;--subtle:#b5b1a4;
14
+ --accent:#1b5e3b;--accent-soft:#dceee4;
15
+ --warn:#c44b25;--warn-soft:#fdeee8;
16
+ --blue:#2563a8;--blue-soft:#e8f0fa;
17
+ --purple:#5b44b0;--purple-soft:#eeeafb;
18
+ --amber:#a16207;--amber-soft:#fdf4e3;
19
+ --border:rgba(0,0,0,.08);
20
+ --shadow:0 2px 10px rgba(0,0,0,.08);
21
+ --shadow-lift:0 6px 18px rgba(0,0,0,.10);
22
+ --b-hike-bg:#dceee4;--b-hike-fg:#1b5e3b;
23
+ --b-scenic-bg:#e8f0fa;--b-scenic-fg:#2563a8;
24
+ --b-food-bg:#fdeee8;--b-food-fg:#c44b25;
25
+ --b-city-bg:#eeeafb;--b-city-fg:#5b44b0;
26
+ --b-activity-bg:#fdf4e3;--b-activity-fg:#a16207;
27
+ --b-beach-bg:#e0f7fa;--b-beach-fg:#00838f;
28
+ --b-museum-bg:#fce4ec;--b-museum-fg:#c62828;
29
+ --b-shopping-bg:#f3e5f5;--b-shopping-fg:#7b1fa2;
30
+ --font:'DM Sans',system-ui,sans-serif;--serif:'Instrument Serif',Georgia,serif;--r:10px
31
+ }
32
+ [data-theme="dark"]{
33
+ --bg:#14140f;--card:#1f1d18;--text:#f0ede4;--muted:#908a78;--subtle:#5a564a;
34
+ --accent:#5dad7d;--accent-soft:rgba(93,173,125,.15);
35
+ --warn:#e07555;--warn-soft:rgba(224,117,85,.13);
36
+ --blue:#5b9adc;--blue-soft:rgba(91,154,220,.13);
37
+ --purple:#9582d8;--purple-soft:rgba(149,130,216,.13);
38
+ --amber:#d4a444;--amber-soft:rgba(212,164,68,.13);
39
+ --border:rgba(255,255,255,.08);
40
+ --shadow:0 2px 12px rgba(0,0,0,.4);
41
+ --shadow-lift:0 8px 24px rgba(0,0,0,.5);
42
+ --b-hike-bg:rgba(93,173,125,.18);--b-hike-fg:#7dc99f;
43
+ --b-scenic-bg:rgba(91,154,220,.18);--b-scenic-fg:#7dadde;
44
+ --b-food-bg:rgba(224,117,85,.18);--b-food-fg:#ec9270;
45
+ --b-city-bg:rgba(149,130,216,.18);--b-city-fg:#b09fe0;
46
+ --b-activity-bg:rgba(212,164,68,.18);--b-activity-fg:#dcb966;
47
+ --b-beach-bg:rgba(50,180,200,.18);--b-beach-fg:#5fc8d8;
48
+ --b-museum-bg:rgba(220,90,120,.18);--b-museum-fg:#e08aa0;
49
+ --b-shopping-bg:rgba(180,100,200,.18);--b-shopping-fg:#c898d8;
50
+ }
17
51
  html,body{height:100%;overflow:hidden}
18
52
  body{font-family:var(--font);background:var(--bg);color:var(--text)}
19
53
  .app{display:grid;grid-template-columns:460px 1fr;height:100vh}
@@ -37,6 +71,10 @@ body{font-family:var(--font);background:var(--bg);color:var(--text)}
37
71
  .day-btn:hover{background:var(--bg);color:var(--text)}
38
72
  .day-btn.active{background:var(--accent);color:#fff}
39
73
  .day-btn.has-alert{box-shadow:inset 0 -2px 0 var(--warn)}
74
+ .day-btn[data-status="completed"]{opacity:.5}
75
+ .day-btn[data-status="completed"].active{opacity:1}
76
+ .day-btn[data-status="active"]::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--accent);display:inline-block;margin-right:6px;vertical-align:middle}
77
+ .day-btn[data-status="active"].active::before{background:#fff}
40
78
  .day-panel{display:none;padding:16px 22px 40px}
41
79
  .day-panel.active{display:block;animation:fadeIn .25s ease}
42
80
  @keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
@@ -55,16 +93,16 @@ body{font-family:var(--font);background:var(--bg);color:var(--text)}
55
93
  .callout.sleep{background:var(--purple-soft);border-left:3px solid var(--purple)}
56
94
  .cl{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;margin-bottom:3px}
57
95
  .callout.warn .cl{color:var(--warn)}.callout.tip .cl{color:var(--accent)}.callout.weather .cl{color:var(--blue)}.callout.eat .cl{color:var(--amber)}.callout.sleep .cl{color:var(--purple)}
58
- .stop{display:flex;gap:10px;padding:10px 0;border-top:1px solid var(--border);cursor:pointer;transition:background .12s}
59
- .stop:hover{background:var(--bg);margin:0 -10px;padding:10px;border-radius:8px}
96
+ .stop{display:flex;gap:10px;padding:10px 0;border-top:1px solid var(--border);cursor:pointer;transition:background .15s,transform .15s,box-shadow .15s}
97
+ .stop:hover{background:var(--bg);margin:0 -10px;padding:10px;border-radius:8px;box-shadow:var(--shadow-lift);transform:translateY(-1px)}
60
98
  .stop:first-of-type{border-top:none}
61
99
  .stop-img{width:72px;height:54px;border-radius:7px;object-fit:cover;flex-shrink:0;background:linear-gradient(135deg,var(--accent-soft),var(--blue-soft))}
62
100
  .stop-body{flex:1;min-width:0}
63
101
  .stop-name{font-weight:500;font-size:13px;display:flex;align-items:center;gap:6px}
64
102
  .badge{font-size:8px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;padding:2px 6px;border-radius:3px;flex-shrink:0}
65
- .b-hike{background:#dceee4;color:#1b5e3b}.b-scenic{background:#e8f0fa;color:#2563a8}.b-food{background:#fdeee8;color:#c44b25}
66
- .b-city{background:#eeeafb;color:#5b44b0}.b-activity{background:#fdf4e3;color:#a16207}.b-beach{background:#e0f7fa;color:#00838f}
67
- .b-museum{background:#fce4ec;color:#c62828}.b-shopping{background:#f3e5f5;color:#7b1fa2}
103
+ .b-hike{background:var(--b-hike-bg);color:var(--b-hike-fg)}.b-scenic{background:var(--b-scenic-bg);color:var(--b-scenic-fg)}.b-food{background:var(--b-food-bg);color:var(--b-food-fg)}
104
+ .b-city{background:var(--b-city-bg);color:var(--b-city-fg)}.b-activity{background:var(--b-activity-bg);color:var(--b-activity-fg)}.b-beach{background:var(--b-beach-bg);color:var(--b-beach-fg)}
105
+ .b-museum{background:var(--b-museum-bg);color:var(--b-museum-fg)}.b-shopping{background:var(--b-shopping-bg);color:var(--b-shopping-fg)}
68
106
  .stop-desc{font-size:11px;color:var(--muted);line-height:1.45;margin-top:2px}
69
107
  .stop-meta{display:flex;gap:8px;margin-top:3px;font-size:10px;color:var(--subtle)}
70
108
  .sec-label{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--subtle);margin:14px 0 6px;padding-top:6px;border-top:1px solid var(--border)}
@@ -79,21 +117,59 @@ body{font-family:var(--font);background:var(--bg);color:var(--text)}
79
117
  .lr{display:flex;align-items:center;gap:6px}.lr i{width:16px;height:3px;border-radius:2px;display:inline-block}
80
118
  .fullbtn{position:absolute;bottom:16px;right:12px;z-index:1000;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:6px 12px;font-family:var(--font);font-size:11px;font-weight:500;cursor:pointer;box-shadow:0 2px 10px rgba(0,0,0,.1)}
81
119
  .fullbtn:hover{background:var(--text);color:#fff}
82
- .leaflet-popup-content-wrapper{border-radius:10px!important;box-shadow:0 4px 16px rgba(0,0,0,.12)!important;border:1px solid var(--border)!important;padding:0!important;overflow:hidden}
120
+ .leaflet-popup-content-wrapper{border-radius:10px!important;box-shadow:0 4px 16px rgba(0,0,0,.18)!important;border:1px solid var(--border)!important;padding:0!important;overflow:hidden;background:var(--card)!important;color:var(--text)!important}
83
121
  .leaflet-popup-content{margin:0!important;width:280px!important;font-family:var(--font)!important}
84
- .leaflet-popup-tip{border-top-color:var(--card)!important}
122
+ .leaflet-popup-tip{background:var(--card)!important;border-top-color:var(--card)!important}
85
123
  .pp-img{width:100%;height:140px;object-fit:cover;display:block;background:linear-gradient(135deg,var(--accent-soft),var(--blue-soft))}
86
124
  .pp-body{padding:12px 14px}
87
125
  .pp-day{font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin-bottom:3px}
88
126
  .pp-name{font-family:var(--serif);font-size:16px;line-height:1.25;margin-bottom:5px}
89
127
  .pp-desc{font-size:11px;color:var(--muted);line-height:1.5}
90
- .cm{display:flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:50%;font-weight:600;font-size:10px;color:#fff;box-shadow:0 2px 5px rgba(0,0,0,.3);border:2px solid #fff;font-family:var(--font);cursor:pointer;transition:transform .12s}
128
+ .cm{display:flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:50%;font-weight:600;font-size:10px;color:#fff;box-shadow:0 2px 5px rgba(0,0,0,.3);border:2px solid #fff;font-family:var(--font);cursor:pointer;transition:transform .12s;position:relative}
91
129
  .cm:hover{transform:scale(1.2)}
130
+ /* Media cue on a stop marker: camera + count, clickable straight to the lightbox. */
131
+ .cm-media{position:absolute;top:-7px;right:-9px;display:flex;align-items:center;gap:1px;height:15px;padding:0 4px;border-radius:8px;background:#fff;color:var(--accent);font-size:9px;font-weight:700;line-height:1;box-shadow:0 1px 4px rgba(0,0,0,.35);border:1.5px solid var(--accent);cursor:pointer;transition:transform .12s}
132
+ .cm-media:hover{transform:scale(1.18)}
133
+ .cm-media .ic{font-size:8px;line-height:1}
92
134
  .hm{display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:6px;
93
135
  font-weight:700;font-size:9px;color:#1b5e3b;background:#fff;box-shadow:0 2px 8px rgba(0,0,0,.35);
94
136
  border:2px solid #1b5e3b;font-family:var(--font);cursor:pointer;transition:transform .12s;letter-spacing:-.02em}
95
137
  .hm:hover{transform:scale(1.15)}
96
138
  .pp-conf{font-size:11px;font-weight:600;color:var(--accent);background:var(--accent-soft);padding:4px 8px;border-radius:4px;display:inline-block;margin-top:6px;letter-spacing:.02em}
139
+ .endpin{display:flex;align-items:center;justify-content:center;width:28px;height:34px;font-size:11px;font-weight:700;color:#fff;font-family:var(--font);border-radius:14px 14px 14px 2px;transform:rotate(-45deg);box-shadow:0 2px 6px rgba(0,0,0,.35);border:2px solid #fff;cursor:pointer}
140
+ .endpin span{transform:rotate(45deg);letter-spacing:.02em}
141
+ .endpin.start{background:#1b5e3b}
142
+ .endpin.end{background:#7a1f1f}
143
+ /* --- Media gallery (sidebar strip + count badge) --- */
144
+ .stop-media{display:flex;gap:4px;margin-top:6px;flex-wrap:wrap}
145
+ .stop-media .gthumb{width:46px;height:46px;border-radius:6px;object-fit:cover;cursor:pointer;background:var(--bg);border:1px solid var(--border);transition:transform .12s}
146
+ .stop-media .gthumb:hover{transform:scale(1.06)}
147
+ .stop-media .gmore{display:flex;align-items:center;justify-content:center;width:46px;height:46px;border-radius:6px;background:var(--accent-soft);color:var(--accent);font-size:11px;font-weight:600;cursor:pointer;border:1px solid var(--border)}
148
+ .gthumb-wrap{position:relative;display:inline-block;line-height:0}
149
+ .gthumb-wrap .vbadge{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:0;height:0;border-left:11px solid #fff;border-top:7px solid transparent;border-bottom:7px solid transparent;filter:drop-shadow(0 0 2px rgba(0,0,0,.6));pointer-events:none}
150
+ .pp-media{display:flex;gap:4px;padding:8px 14px 0;flex-wrap:wrap}
151
+ .pp-media .gthumb{width:40px;height:40px;border-radius:5px;object-fit:cover;cursor:pointer;border:1px solid var(--border)}
152
+ .media-count{display:inline-flex;align-items:center;gap:3px;font-size:9px;font-weight:600;color:var(--accent);background:var(--accent-soft);padding:1px 6px;border-radius:10px;margin-left:4px}
153
+ /* --- Photo-pin marker --- */
154
+ .photopin{width:14px;height:14px;border-radius:50%;background:#fff;border:2px solid var(--accent);box-shadow:0 1px 4px rgba(0,0,0,.4);cursor:pointer;transition:transform .12s}
155
+ .photopin:hover{transform:scale(1.35)}
156
+ .mbtn.toggle.active{background:var(--accent);color:#fff;border-color:var(--accent)}
157
+ /* --- Lightbox --- */
158
+ .lightbox{position:fixed;inset:0;z-index:5000;background:rgba(0,0,0,.92);display:none;align-items:center;justify-content:center;flex-direction:column}
159
+ .lightbox.open{display:flex;animation:lbFade .2s ease}
160
+ @keyframes lbFade{from{opacity:0}to{opacity:1}}
161
+ .lb-stage{position:relative;max-width:92vw;max-height:78vh;display:flex;align-items:center;justify-content:center}
162
+ .lb-stage img,.lb-stage video{max-width:92vw;max-height:78vh;border-radius:8px;box-shadow:0 8px 40px rgba(0,0,0,.6);object-fit:contain;background:#000}
163
+ .lb-cap{color:#f0ede4;font-family:var(--font);font-size:13px;text-align:center;margin-top:14px;max-width:80vw;line-height:1.5;min-height:18px}
164
+ .lb-meta{color:#908a78;font-size:11px;margin-top:4px}
165
+ .lb-count{position:absolute;top:20px;left:0;right:0;text-align:center;color:#cbc8bd;font-size:12px;font-family:var(--font);letter-spacing:.04em;pointer-events:none}
166
+ .lb-close{position:absolute;top:16px;right:22px;width:38px;height:38px;border:none;border-radius:50%;background:rgba(255,255,255,.12);color:#fff;font-size:20px;cursor:pointer;line-height:1;z-index:2}
167
+ .lb-close:hover{background:rgba(255,255,255,.24)}
168
+ .lb-nav{position:absolute;top:50%;transform:translateY(-50%);width:48px;height:48px;border:none;border-radius:50%;background:rgba(255,255,255,.12);color:#fff;font-size:24px;cursor:pointer;display:flex;align-items:center;justify-content:center}
169
+ .lb-nav:hover{background:rgba(255,255,255,.24)}
170
+ .lb-prev{left:18px}.lb-next{right:18px}
171
+ .lb-nav[disabled]{opacity:.25;cursor:default}
172
+ @media(max-width:480px){.lb-nav{width:40px;height:40px;font-size:20px}.lb-close{top:10px;right:12px}}
97
173
  @media(max-width:480px){
98
174
  .hero{height:140px}.hero-text{padding:12px 14px}.hero-text h1{font-size:20px}
99
175
  .day-nav{padding:8px 10px;gap:1px}.day-btn{padding:5px 8px;font-size:10px}
@@ -123,6 +199,15 @@ body{font-family:var(--font);background:var(--bg);color:var(--text)}
123
199
  <div class="legend" id="legend"></div>
124
200
  <button class="fullbtn" onclick="showAll()">Full route</button>
125
201
  </div></div>
202
+ <div class="lightbox" id="lightbox" role="dialog" aria-modal="true" aria-label="Photo viewer">
203
+ <button class="lb-close" id="lbClose" aria-label="Close">×</button>
204
+ <div class="lb-count" id="lbCount"></div>
205
+ <button class="lb-nav lb-prev" id="lbPrev" aria-label="Previous">‹</button>
206
+ <div class="lb-stage" id="lbStage"></div>
207
+ <button class="lb-nav lb-next" id="lbNext" aria-label="Next">›</button>
208
+ <div class="lb-cap" id="lbCap"></div>
209
+ <div class="lb-meta" id="lbMeta"></div>
210
+ </div>
126
211
  <script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
127
212
  <script>
128
213
  // ============================================================
@@ -152,6 +237,7 @@ const TRIP_DATA = {};
152
237
  const theme = data.theme || {};
153
238
 
154
239
  // --- Apply theme ---
240
+ if (theme.dark_mode) document.documentElement.setAttribute('data-theme', 'dark');
155
241
  if (theme.accent_color) document.documentElement.style.setProperty('--accent', theme.accent_color);
156
242
  if (theme.font_family) document.documentElement.style.setProperty('--font', theme.font_family);
157
243
 
@@ -172,6 +258,39 @@ const TRIP_DATA = {};
172
258
  return stop.image || defaultImages[stop.type] || defaultImages.default;
173
259
  }
174
260
 
261
+ // --- Post-trip media helpers ---
262
+ // Normalize a stop's media[] into a clean list. `src`/`thumb` may be a relative
263
+ // path or a URL; the renderer just assigns them as element src/href either way.
264
+ function getStopMedia(stop) {
265
+ if (!Array.isArray(stop.media)) return [];
266
+ return stop.media.filter(m => m && m.src).map(m => ({
267
+ src: m.src,
268
+ thumb: m.thumb || m.src,
269
+ type: m.type === 'video' ? 'video' : 'photo',
270
+ caption: m.caption || '',
271
+ lat: Number.isFinite(m.lat) ? m.lat : null,
272
+ lng: Number.isFinite(m.lng) ? m.lng : null,
273
+ taken_at: m.taken_at || ''
274
+ }));
275
+ }
276
+ function escAttr(s) { return String(s).replace(/"/g, '&quot;'); }
277
+ function escHtml(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
278
+
279
+ // Build the sidebar thumbnail strip for a stop's media (up to 4 shown + "+N").
280
+ function mediaStripHTML(di, si, media) {
281
+ if (!media.length) return '';
282
+ const MAX = 4;
283
+ let html = '<div class="stop-media">';
284
+ media.slice(0, MAX).forEach((m, mi) => {
285
+ html += `<span class="gthumb-wrap"><img class="gthumb" src="${escAttr(m.thumb)}" alt="${escAttr(m.caption || 'photo')}" loading="lazy" onclick="event.stopPropagation();openLightbox(${di},${si},${mi})" onerror="this.style.opacity=.25">${m.type === 'video' ? '<span class="vbadge"></span>' : ''}</span>`;
286
+ });
287
+ if (media.length > MAX) {
288
+ html += `<span class="gmore" onclick="event.stopPropagation();openLightbox(${di},${si},${MAX})">+${media.length - MAX}</span>`;
289
+ }
290
+ html += '</div>';
291
+ return html;
292
+ }
293
+
175
294
  function getDayHero(day) {
176
295
  // Use first stop's image as day hero
177
296
  if (day.stops && day.stops.length > 0) return getImage(day.stops[0]);
@@ -202,6 +321,7 @@ const TRIP_DATA = {};
202
321
  // Nav button
203
322
  const b = document.createElement('button');
204
323
  b.className = 'day-btn' + (i === 0 ? ' active' : '') + (hasAlerts ? ' has-alert' : '');
324
+ if (d.status) b.dataset.status = d.status;
205
325
  b.textContent = `Day ${d.number}`;
206
326
  b.onclick = () => sel(i);
207
327
  nav.appendChild(b);
@@ -262,13 +382,16 @@ const TRIP_DATA = {};
262
382
  h += `<div class="stop" onclick="tripFocus(${i},${si})">`;
263
383
  h += `<img class="stop-img" src="${img}" alt="${s.name}" loading="lazy" onerror="this.style.opacity=0">`;
264
384
  h += `<div class="stop-body">`;
265
- h += `<div class="stop-name"><span class="badge ${bc}">${s.label || s.type}</span>${s.name}</div>`;
385
+ const sMedia = getStopMedia(s);
386
+ const countBadge = sMedia.length ? `<span class="media-count">📷 ${sMedia.length}</span>` : '';
387
+ h += `<div class="stop-name"><span class="badge ${bc}">${s.label || s.type}</span>${s.name}${countBadge}</div>`;
266
388
  h += `<div class="stop-desc">${s.description || ''}</div>`;
267
389
  const meta = [];
268
390
  if (s.duration) meta.push(`⏱ ${s.duration}`);
269
391
  if (s.parking_fee) meta.push(`🅿 ${s.parking_fee}`);
270
392
  if (s.kid_friendly === false) meta.push(`⚠ Not kid-friendly`);
271
393
  if (meta.length) h += `<div class="stop-meta">${meta.map(m => `<span>${m}</span>`).join('')}</div>`;
394
+ h += mediaStripHTML(i, si, sMedia);
272
395
  h += `</div></div>`;
273
396
  });
274
397
  }
@@ -329,14 +452,33 @@ const TRIP_DATA = {};
329
452
  L.polyline(r.points, { color: r.color || '#666', weight: r.width || 4, opacity: 0.8 }).addTo(map);
330
453
  });
331
454
  } else {
332
- // Auto-generate routes from consecutive stops
333
- days.forEach(d => {
334
- if (d.stops && d.stops.length > 1) {
335
- const pts = d.stops.filter(s => s.lat && s.lng).map(s => [s.lat, s.lng]);
336
- if (pts.length > 1) {
337
- L.polyline(pts, { color: '#fff', weight: 7, opacity: 0.4 }).addTo(map);
338
- L.polyline(pts, { color: d.color || '#666', weight: 4, opacity: 0.8 }).addTo(map);
339
- }
455
+ // Auto-generate routes. For each day, the polyline runs:
456
+ // prev day's lodging (if any) → today's stops → today's lodging (if any)
457
+ // This stitches consecutive days together so the map reads as one continuous trip.
458
+ // Day 1 also anchors to trip.origin, and the final day returns to the
459
+ // destination (or origin, for a round trip), so the Start/End pins connect to
460
+ // the route instead of floating unattached.
461
+ const isAnchorLodging = (l) => l && l.lat && l.lng && l.name !== 'Home';
462
+ const hasOrigin = Number.isFinite(trip.origin_lat) && Number.isFinite(trip.origin_lng);
463
+ const destLat = Number.isFinite(trip.destination_lat) ? trip.destination_lat : (hasOrigin ? trip.origin_lat : null);
464
+ const destLng = Number.isFinite(trip.destination_lng) ? trip.destination_lng : (hasOrigin ? trip.origin_lng : null);
465
+ const pushUnique = (pts, p) => {
466
+ const last = pts[pts.length - 1];
467
+ if (!last || last[0] !== p[0] || last[1] !== p[1]) pts.push(p);
468
+ };
469
+ days.forEach((d, di) => {
470
+ const pts = [];
471
+ if (di === 0 && hasOrigin) pts.push([trip.origin_lat, trip.origin_lng]);
472
+ const prevLodging = di > 0 ? days[di - 1].lodging : null;
473
+ if (isAnchorLodging(prevLodging)) pushUnique(pts, [prevLodging.lat, prevLodging.lng]);
474
+ (d.stops || []).forEach(s => { if (s.lat && s.lng) pushUnique(pts, [s.lat, s.lng]); });
475
+ if (isAnchorLodging(d.lodging)) pushUnique(pts, [d.lodging.lat, d.lodging.lng]);
476
+ // On the last day, close the loop back to the destination/origin so the trip
477
+ // visibly returns home (covers the common `lodging.name: "Home"` last day).
478
+ if (di === days.length - 1 && destLat != null && destLng != null) pushUnique(pts, [destLat, destLng]);
479
+ if (pts.length > 1) {
480
+ L.polyline(pts, { color: '#fff', weight: 7, opacity: 0.4 }).addTo(map);
481
+ L.polyline(pts, { color: d.color || '#666', weight: 4, opacity: 0.8 }).addTo(map);
340
482
  }
341
483
  });
342
484
  }
@@ -347,21 +489,37 @@ const TRIP_DATA = {};
347
489
  const dm = [];
348
490
  d.stops.forEach((s, si) => {
349
491
  if (!s.lat || !s.lng) return;
492
+ const sMedia = getStopMedia(s);
493
+ // When a stop has media, overlay a camera+count badge on its marker that jumps
494
+ // straight to the lightbox (the badge stops propagation so the popup stays put).
495
+ const mediaBadge = sMedia.length
496
+ ? `<span class="cm-media" title="${sMedia.length} photo${sMedia.length === 1 ? '' : 's'}/video" onclick="event.stopPropagation();openLightbox(${di},${si},0)"><span class="ic">📷</span>${sMedia.length}</span>`
497
+ : '';
350
498
  const icon = L.divIcon({
351
499
  className: '',
352
- html: `<div class="cm" style="background:${d.color || '#666'}">${d.number}</div>`,
500
+ html: `<div class="cm" style="background:${d.color || '#666'}">${d.number}${mediaBadge}</div>`,
353
501
  iconSize: [26, 26], iconAnchor: [13, 13]
354
502
  });
355
503
  const m = L.marker([s.lat, s.lng], { icon }).addTo(map);
356
- const img = getImage(s);
504
+ // Lead the popup with the first real photo when available; else the hero/Unsplash image.
505
+ const img = sMedia.length ? sMedia[0].thumb : getImage(s);
506
+ let ppMedia = '';
507
+ if (sMedia.length) {
508
+ ppMedia = '<div class="pp-media">';
509
+ sMedia.slice(0, 5).forEach((mm, mi) => {
510
+ ppMedia += `<span class="gthumb-wrap"><img class="gthumb" src="${escAttr(mm.thumb)}" alt="${escAttr(mm.caption || 'photo')}" onclick="openLightbox(${di},${si},${mi})" onerror="this.style.opacity=.25">${mm.type === 'video' ? '<span class="vbadge"></span>' : ''}</span>`;
511
+ });
512
+ ppMedia += '</div>';
513
+ }
357
514
  m.bindPopup(`<div>
358
- <img class="pp-img" src="${img}" alt="${s.name}" onerror="this.style.opacity=0">
515
+ <img class="pp-img" src="${escAttr(img)}" alt="${escAttr(s.name)}" ${sMedia.length ? `style="cursor:pointer" onclick="openLightbox(${di},${si},0)"` : ''} onerror="this.style.opacity=0">
359
516
  <div class="pp-body">
360
517
  <div class="pp-day">Day ${d.number} · ${d.date}</div>
361
518
  <div class="pp-name">${s.name}</div>
362
519
  <div class="pp-desc">${s.description || ''}</div>
363
520
  ${s.navigate_url ? `<a href="${s.navigate_url}" target="_blank" style="display:inline-block;margin-top:6px;font-size:11px;color:var(--accent);text-decoration:none">📍 Navigate →</a>` : ''}
364
521
  </div>
522
+ ${ppMedia}
365
523
  </div>`, { maxWidth: 280 });
366
524
  m.on('click', () => sel(di));
367
525
  dm.push(m);
@@ -369,8 +527,34 @@ const TRIP_DATA = {};
369
527
  markers.push(dm);
370
528
  });
371
529
 
530
+ // --- Start / End pins (origin & destination) ---
531
+ function endpoint(lat, lng, kind, name) {
532
+ const labelTxt = kind === 'start' ? 'A' : 'B';
533
+ const titleTxt = kind === 'start' ? 'Start' : 'End';
534
+ const icon = L.divIcon({
535
+ className: '',
536
+ html: `<div class="endpin ${kind}"><span>${labelTxt}</span></div>`,
537
+ iconSize: [28, 34], iconAnchor: [4, 32]
538
+ });
539
+ const m = L.marker([lat, lng], { icon, zIndexOffset: 800 }).addTo(map);
540
+ m.bindPopup(`<div class="pp-body" style="padding:14px">
541
+ <div class="pp-day">${titleTxt}</div>
542
+ <div class="pp-name">${name || titleTxt}</div>
543
+ </div>`, { maxWidth: 240 });
544
+ }
545
+ if (Number.isFinite(trip.origin_lat) && Number.isFinite(trip.origin_lng)) {
546
+ endpoint(trip.origin_lat, trip.origin_lng, 'start', trip.origin);
547
+ const destLat = Number.isFinite(trip.destination_lat) ? trip.destination_lat : trip.origin_lat;
548
+ const destLng = Number.isFinite(trip.destination_lng) ? trip.destination_lng : trip.origin_lng;
549
+ // Suppress the "End" pin if it's at the same coords as the Start (round trip).
550
+ if (Math.abs(destLat - trip.origin_lat) > 0.001 || Math.abs(destLng - trip.origin_lng) > 0.001) {
551
+ endpoint(destLat, destLng, 'end', trip.destination || trip.origin);
552
+ }
553
+ }
554
+
372
555
  // --- Hotel markers ---
373
- const hotels = days
556
+ // Build per-night list, then dedupe by lat/lng so multi-night stays render as one marker.
557
+ const nights = days
374
558
  .filter(d => d.lodging && d.lodging.lat && d.lodging.lng && d.lodging.name !== 'Home')
375
559
  .map((d, i) => ({
376
560
  name: d.lodging.name,
@@ -384,11 +568,24 @@ const TRIP_DATA = {};
384
568
  navigate_url: d.lodging.navigate_url
385
569
  }));
386
570
 
387
- const hotelLabel = (hotels.length > 0 && data.agent_context?.preferences?.accommodation_chain)
388
- ? data.agent_context.preferences.accommodation_chain.split(' ')[0].substring(0, 3).toUpperCase()
389
- : '🏨';
390
-
391
- hotels.forEach(h => {
571
+ const hotelGroups = new Map();
572
+ nights.forEach(n => {
573
+ const key = n.lat.toFixed(4) + ',' + n.lng.toFixed(4);
574
+ if (!hotelGroups.has(key)) hotelGroups.set(key, []);
575
+ hotelGroups.get(key).push(n);
576
+ });
577
+ const hotels = Array.from(hotelGroups.values());
578
+
579
+ const hotelLabel = theme.hotel_label
580
+ || (data.agent_context?.preferences?.accommodation_chain
581
+ ? data.agent_context.preferences.accommodation_chain.split(' ')[0].substring(0, 3).toUpperCase()
582
+ : '🏨');
583
+
584
+ hotels.forEach(group => {
585
+ const h = group[0];
586
+ const ns = group.map(g => g.night);
587
+ const nightLabel = ns.length === 1 ? `Night ${ns[0]}` : `Nights ${ns[0]}–${ns[ns.length - 1]}`;
588
+ const dateLabel = group.length === 1 ? h.day : `${h.day} – ${group[group.length - 1].day}`;
392
589
  const icon = L.divIcon({
393
590
  className: '',
394
591
  html: `<div class="hm">${hotelLabel}</div>`,
@@ -396,7 +593,7 @@ const TRIP_DATA = {};
396
593
  });
397
594
  const m = L.marker([h.lat, h.lng], { icon, zIndexOffset: 1000 }).addTo(map);
398
595
  let popupHTML = `<div class="pp-body" style="padding:16px">
399
- <div class="pp-day">Night ${h.night} · ${h.day}</div>
596
+ <div class="pp-day">${nightLabel} · ${dateLabel}</div>
400
597
  <div class="pp-name">${h.name}</div>
401
598
  <div style="font-size:12px;color:var(--muted);margin-bottom:6px">${h.loc}</div>`;
402
599
  if (h.booked && h.conf) popupHTML += `<div class="pp-conf">Conf# ${h.conf}</div>`;
@@ -421,7 +618,13 @@ const TRIP_DATA = {};
421
618
  legendEl.querySelector('.legend-toggle')?.addEventListener('click', () => legendEl.classList.toggle('collapsed'));
422
619
 
423
620
  // --- Fit bounds ---
424
- const allPts = days.flatMap(d => (d.stops || []).filter(s => s.lat && s.lng).map(s => [s.lat, s.lng]));
621
+ // Include stops, hotels, and origin/destination pins so the initial fit covers everything.
622
+ const allPts = [
623
+ ...days.flatMap(d => (d.stops || []).filter(s => s.lat && s.lng).map(s => [s.lat, s.lng])),
624
+ ...days.flatMap(d => (d.lodging && d.lodging.lat && d.lodging.lng && d.lodging.name !== 'Home') ? [[d.lodging.lat, d.lodging.lng]] : []),
625
+ ];
626
+ if (Number.isFinite(trip.origin_lat) && Number.isFinite(trip.origin_lng)) allPts.push([trip.origin_lat, trip.origin_lng]);
627
+ if (Number.isFinite(trip.destination_lat) && Number.isFinite(trip.destination_lng)) allPts.push([trip.destination_lat, trip.destination_lng]);
425
628
  const fullBounds = L.latLngBounds(allPts).pad(0.06);
426
629
  map.fitBounds(fullBounds);
427
630
 
@@ -452,6 +655,111 @@ const TRIP_DATA = {};
452
655
  map.fitBounds(fullBounds, { animate: true });
453
656
  };
454
657
 
658
+ // === LIGHTBOX ===
659
+ // Resolve a stop's media list on demand so it always reflects current data.
660
+ const lb = document.getElementById('lightbox');
661
+ const lbStage = document.getElementById('lbStage');
662
+ const lbCap = document.getElementById('lbCap');
663
+ const lbMeta = document.getElementById('lbMeta');
664
+ const lbCount = document.getElementById('lbCount');
665
+ const lbPrev = document.getElementById('lbPrev');
666
+ const lbNext = document.getElementById('lbNext');
667
+ let lbList = [];
668
+ let lbIdx = 0;
669
+
670
+ function lbRender() {
671
+ const m = lbList[lbIdx];
672
+ if (!m) return;
673
+ lbStage.innerHTML = m.type === 'video'
674
+ ? `<video src="${escAttr(m.src)}" controls autoplay playsinline></video>`
675
+ : `<img src="${escAttr(m.src)}" alt="${escAttr(m.caption || 'photo')}">`;
676
+ lbCap.textContent = m.caption || '';
677
+ const meta = [];
678
+ if (m.taken_at) meta.push(new Date(m.taken_at).toString() !== 'Invalid Date' ? new Date(m.taken_at).toLocaleString() : m.taken_at);
679
+ if (m.lat != null && m.lng != null) meta.push(`${m.lat.toFixed(4)}, ${m.lng.toFixed(4)}`);
680
+ lbMeta.textContent = meta.join(' · ');
681
+ lbCount.textContent = lbList.length > 1 ? `${lbIdx + 1} / ${lbList.length}` : '';
682
+ lbPrev.disabled = lbIdx === 0;
683
+ lbNext.disabled = lbIdx === lbList.length - 1;
684
+ }
685
+ function lbStep(delta) {
686
+ const n = lbIdx + delta;
687
+ if (n < 0 || n >= lbList.length) return;
688
+ lbIdx = n;
689
+ lbRender();
690
+ }
691
+ function lbClose() {
692
+ lb.classList.remove('open');
693
+ lbStage.innerHTML = ''; // stop any playing video
694
+ }
695
+ // Open from a stop (di, si) at media index mi, or from an explicit list.
696
+ window.openLightbox = function (di, si, mi) {
697
+ lbList = getStopMedia(days[di].stops[si]);
698
+ if (!lbList.length) return;
699
+ lbIdx = Math.max(0, Math.min(mi || 0, lbList.length - 1));
700
+ lb.classList.add('open');
701
+ lbRender();
702
+ };
703
+ window.openLightboxList = function (list, idx) {
704
+ lbList = list;
705
+ if (!lbList.length) return;
706
+ lbIdx = Math.max(0, Math.min(idx || 0, lbList.length - 1));
707
+ lb.classList.add('open');
708
+ lbRender();
709
+ };
710
+ lbPrev.onclick = () => lbStep(-1);
711
+ lbNext.onclick = () => lbStep(1);
712
+ document.getElementById('lbClose').onclick = lbClose;
713
+ lb.addEventListener('click', (e) => { if (e.target === lb) lbClose(); });
714
+ document.addEventListener('keydown', (e) => {
715
+ if (!lb.classList.contains('open')) return;
716
+ if (e.key === 'Escape') lbClose();
717
+ else if (e.key === 'ArrowLeft') lbStep(-1);
718
+ else if (e.key === 'ArrowRight') lbStep(1);
719
+ });
720
+ // Basic swipe nav on touch devices.
721
+ let touchX = null;
722
+ lbStage.addEventListener('touchstart', (e) => { touchX = e.changedTouches[0].clientX; }, { passive: true });
723
+ lbStage.addEventListener('touchend', (e) => {
724
+ if (touchX == null) return;
725
+ const dx = e.changedTouches[0].clientX - touchX;
726
+ if (Math.abs(dx) > 40) lbStep(dx < 0 ? 1 : -1);
727
+ touchX = null;
728
+ }, { passive: true });
729
+
730
+ // === PHOTO-PIN LAYER ===
731
+ // Each media item that carries its own EXIF lat/lng becomes a small pin,
732
+ // distinct from the day-numbered stop markers. Toggleable via the map control.
733
+ const photoLayer = L.layerGroup();
734
+ let photoLayerOn = false;
735
+ days.forEach((d, di) => {
736
+ (d.stops || []).forEach((s, si) => {
737
+ getStopMedia(s).forEach((m, mi) => {
738
+ if (m.lat == null || m.lng == null) return;
739
+ const icon = L.divIcon({ className: '', html: '<div class="photopin"></div>', iconSize: [14, 14], iconAnchor: [7, 7] });
740
+ const pin = L.marker([m.lat, m.lng], { icon });
741
+ pin.on('click', () => window.openLightbox(di, si, mi));
742
+ photoLayer.addLayer(pin);
743
+ });
744
+ });
745
+ });
746
+ const photoPinCount = photoLayer.getLayers().length;
747
+ if (photoPinCount > 0) {
748
+ const btn = document.createElement('button');
749
+ btn.className = 'mbtn toggle';
750
+ btn.style.marginLeft = '6px';
751
+ btn.style.borderRadius = '5px';
752
+ btn.style.borderLeft = '1px solid var(--border)';
753
+ btn.textContent = `📷 Photos`;
754
+ btn.title = `${photoPinCount} geotagged media`;
755
+ btn.addEventListener('click', () => {
756
+ photoLayerOn = !photoLayerOn;
757
+ btn.classList.toggle('active', photoLayerOn);
758
+ if (photoLayerOn) photoLayer.addTo(map); else map.removeLayer(photoLayer);
759
+ });
760
+ document.querySelector('.map-ctrl').appendChild(btn);
761
+ }
762
+
455
763
  // Set page title
456
764
  document.title = trip.title + ' — TripKit';
457
765
 
@@ -18,6 +18,10 @@ trip:
18
18
  children: integer
19
19
  ages: string # e.g. "Kids: 14, 17"
20
20
  origin: string # e.g. "Folsom, CA"
21
+ origin_lat: number # Optional — if set with origin_lng, renders a "Start" pin on the map
22
+ origin_lng: number
23
+ destination_lat: number # Optional — if absent, defaults to origin coords (round trip)
24
+ destination_lng: number # Set explicitly only if the trip ends somewhere different from where it started
21
25
  vehicle: string # e.g. "SUV", "Sedan", "RV"
22
26
 
23
27
  # ============================================================
@@ -58,6 +62,7 @@ days:
58
62
  lng: number
59
63
  notes: string # Free-form: pool, breakfast, special features
60
64
  navigate_url: string # Google Maps nav link
65
+ media: # Optional — same shape as stops[].media (see below). Post-trip photos of the stay.
61
66
 
62
67
  alerts: # Optional array of important warnings
63
68
  - string # e.g. "⏰ Dad has a 7:30 PM meeting..."
@@ -82,6 +87,23 @@ days:
82
87
  kid_friendly: boolean # Optional
83
88
  accessibility: string # Optional — wheelchair, stroller notes
84
89
 
90
+ # POST-TRIP MEDIA (optional) — actual photos/videos captured at this stop.
91
+ # Populated by `tripkit media <folder> <trip.yaml>` from geotagged files,
92
+ # reviewed/captioned, then merged with `tripkit media apply`. Purely additive:
93
+ # stops without `media` render exactly as before. `image` above remains the
94
+ # single hero/primary image; `media` is the gallery shown in cards, popups,
95
+ # the lightbox, and (when items carry lat/lng) the toggleable photo-pin layer.
96
+ media:
97
+ - src: string # Relative path (e.g. "media/IMG_2401.jpg") OR full URL.
98
+ # URLs keep the output a shareable single file.
99
+ type: string # "photo" | "video"
100
+ thumb: string # Optional pre-generated thumbnail path/URL (sharp, if available);
101
+ # renderer falls back to `src` when absent.
102
+ caption: string # Optional human/agent caption shown in the lightbox
103
+ lat: number # Optional — original EXIF GPS; drives the photo-pin map layer
104
+ lng: number
105
+ taken_at: string # Optional — EXIF timestamp (ISO 8601), used for day matching
106
+
85
107
  # ============================================================
86
108
  # ROUTES — map polylines connecting stops
87
109
  # Optional: if omitted, renderer draws straight lines between stops
@@ -102,6 +124,9 @@ theme:
102
124
  map_style: string # "terrain" | "satellite" | "topo" | "street"
103
125
  dark_mode: boolean # Default: false
104
126
  hero_style: string # "photo" | "gradient" | "minimal"
127
+ hotel_label: string # Optional 1–4 char label for hotel markers (e.g. "MAR", "BW", "INN").
128
+ # Falls back to first 3 chars of agent_context.preferences.accommodation_chain,
129
+ # then 🏨 emoji.
105
130
 
106
131
  # ============================================================
107
132
  # AGENT CONTEXT — metadata for the planning agent
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Coverage check: every renderer-meaningful schema field must be mentioned
4
+ * by name in the agent skill. Prevents drift when fields are added.
5
+ *
6
+ * Run: node scripts/check-skill-coverage.js
7
+ * (also wired into `npm test`)
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ const ROOT = path.join(__dirname, '..');
14
+ const skill = fs.readFileSync(path.join(ROOT, 'agent', 'SKILL.md'), 'utf8');
15
+
16
+ // Hand-curated list of fields the agent must know about. When you add a renderer-
17
+ // meaningful field, add it here and to agent/SKILL.md in the same change.
18
+ const REQUIRED_FIELDS = [
19
+ // trip
20
+ 'trip.title', 'trip.dates', 'trip.total_days', 'trip.total_stops',
21
+ 'trip.origin', 'trip.origin_lat', 'trip.origin_lng',
22
+ 'trip.destination_lat', 'trip.destination_lng',
23
+ // days
24
+ 'days[].number', 'days[].title', 'days[].date', 'days[].status', 'days[].color',
25
+ 'days[].summary', 'days[].weather', 'days[].meals', 'days[].lodging',
26
+ 'days[].alerts', 'days[].tips', 'days[].stops',
27
+ // stops
28
+ 'stops[].name', 'stops[].lat', 'stops[].lng', 'stops[].type', 'stops[].label',
29
+ 'stops[].description', 'stops[].kid_friendly', 'stops[].reservation_required',
30
+ 'stops[].navigate_url', 'stops[].media',
31
+ // lodging
32
+ 'lodging.name', 'lodging.lat', 'lodging.lng', 'lodging.confirmation', 'lodging.booked',
33
+ // routes
34
+ 'routes[]',
35
+ // theme
36
+ 'theme.font_family', 'theme.accent_color', 'theme.map_style', 'theme.dark_mode',
37
+ 'theme.hotel_label',
38
+ // agent_context
39
+ 'agent_context.preferences', 'agent_context.constraints', 'agent_context.iteration_log',
40
+ ];
41
+
42
+ // Each entry above resolves to one or more "tokens" the skill must contain.
43
+ // Strip the path prefix so the lookup is forgiving across formats like
44
+ // `trip.origin_lat`, ``trip.origin_lat``, or just `origin_lat`.
45
+ function lookupTokens(field) {
46
+ const leaf = field.replace(/^.*\./, '').replace(/\[\]$/, '');
47
+ return [field, leaf];
48
+ }
49
+
50
+ const missing = [];
51
+ for (const field of REQUIRED_FIELDS) {
52
+ const tokens = lookupTokens(field);
53
+ const found = tokens.some(t => skill.includes(t));
54
+ if (!found) missing.push(field);
55
+ }
56
+
57
+ if (missing.length === 0) {
58
+ console.log(`✓ agent/SKILL.md covers all ${REQUIRED_FIELDS.length} required fields`);
59
+ process.exit(0);
60
+ }
61
+
62
+ console.error(`✖ agent/SKILL.md is missing references to ${missing.length} required field(s):`);
63
+ for (const f of missing) console.error(` - ${f}`);
64
+ console.error('');
65
+ console.error('Either add a mention to agent/agent/SKILL.md, or remove it from REQUIRED_FIELDS in this script if the field is no longer renderer-meaningful.');
66
+ process.exit(1);