tripkit 1.2.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,16 +117,20 @@ 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}
@@ -98,6 +140,36 @@ body{font-family:var(--font);background:var(--bg);color:var(--text)}
98
140
  .endpin span{transform:rotate(45deg);letter-spacing:.02em}
99
141
  .endpin.start{background:#1b5e3b}
100
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}}
101
173
  @media(max-width:480px){
102
174
  .hero{height:140px}.hero-text{padding:12px 14px}.hero-text h1{font-size:20px}
103
175
  .day-nav{padding:8px 10px;gap:1px}.day-btn{padding:5px 8px;font-size:10px}
@@ -127,6 +199,15 @@ body{font-family:var(--font);background:var(--bg);color:var(--text)}
127
199
  <div class="legend" id="legend"></div>
128
200
  <button class="fullbtn" onclick="showAll()">Full route</button>
129
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>
130
211
  <script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
131
212
  <script>
132
213
  // ============================================================
@@ -156,6 +237,7 @@ const TRIP_DATA = {};
156
237
  const theme = data.theme || {};
157
238
 
158
239
  // --- Apply theme ---
240
+ if (theme.dark_mode) document.documentElement.setAttribute('data-theme', 'dark');
159
241
  if (theme.accent_color) document.documentElement.style.setProperty('--accent', theme.accent_color);
160
242
  if (theme.font_family) document.documentElement.style.setProperty('--font', theme.font_family);
161
243
 
@@ -176,6 +258,39 @@ const TRIP_DATA = {};
176
258
  return stop.image || defaultImages[stop.type] || defaultImages.default;
177
259
  }
178
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
+
179
294
  function getDayHero(day) {
180
295
  // Use first stop's image as day hero
181
296
  if (day.stops && day.stops.length > 0) return getImage(day.stops[0]);
@@ -206,6 +321,7 @@ const TRIP_DATA = {};
206
321
  // Nav button
207
322
  const b = document.createElement('button');
208
323
  b.className = 'day-btn' + (i === 0 ? ' active' : '') + (hasAlerts ? ' has-alert' : '');
324
+ if (d.status) b.dataset.status = d.status;
209
325
  b.textContent = `Day ${d.number}`;
210
326
  b.onclick = () => sel(i);
211
327
  nav.appendChild(b);
@@ -266,13 +382,16 @@ const TRIP_DATA = {};
266
382
  h += `<div class="stop" onclick="tripFocus(${i},${si})">`;
267
383
  h += `<img class="stop-img" src="${img}" alt="${s.name}" loading="lazy" onerror="this.style.opacity=0">`;
268
384
  h += `<div class="stop-body">`;
269
- 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>`;
270
388
  h += `<div class="stop-desc">${s.description || ''}</div>`;
271
389
  const meta = [];
272
390
  if (s.duration) meta.push(`⏱ ${s.duration}`);
273
391
  if (s.parking_fee) meta.push(`🅿 ${s.parking_fee}`);
274
392
  if (s.kid_friendly === false) meta.push(`⚠ Not kid-friendly`);
275
393
  if (meta.length) h += `<div class="stop-meta">${meta.map(m => `<span>${m}</span>`).join('')}</div>`;
394
+ h += mediaStripHTML(i, si, sMedia);
276
395
  h += `</div></div>`;
277
396
  });
278
397
  }
@@ -336,18 +455,27 @@ const TRIP_DATA = {};
336
455
  // Auto-generate routes. For each day, the polyline runs:
337
456
  // prev day's lodging (if any) → today's stops → today's lodging (if any)
338
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.
339
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
+ };
340
469
  days.forEach((d, di) => {
341
470
  const pts = [];
471
+ if (di === 0 && hasOrigin) pts.push([trip.origin_lat, trip.origin_lng]);
342
472
  const prevLodging = di > 0 ? days[di - 1].lodging : null;
343
- if (isAnchorLodging(prevLodging)) pts.push([prevLodging.lat, prevLodging.lng]);
344
- (d.stops || []).forEach(s => { if (s.lat && s.lng) pts.push([s.lat, s.lng]); });
345
- if (isAnchorLodging(d.lodging)) {
346
- const last = pts[pts.length - 1];
347
- if (!last || last[0] !== d.lodging.lat || last[1] !== d.lodging.lng) {
348
- pts.push([d.lodging.lat, d.lodging.lng]);
349
- }
350
- }
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]);
351
479
  if (pts.length > 1) {
352
480
  L.polyline(pts, { color: '#fff', weight: 7, opacity: 0.4 }).addTo(map);
353
481
  L.polyline(pts, { color: d.color || '#666', weight: 4, opacity: 0.8 }).addTo(map);
@@ -361,21 +489,37 @@ const TRIP_DATA = {};
361
489
  const dm = [];
362
490
  d.stops.forEach((s, si) => {
363
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
+ : '';
364
498
  const icon = L.divIcon({
365
499
  className: '',
366
- 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>`,
367
501
  iconSize: [26, 26], iconAnchor: [13, 13]
368
502
  });
369
503
  const m = L.marker([s.lat, s.lng], { icon }).addTo(map);
370
- 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
+ }
371
514
  m.bindPopup(`<div>
372
- <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">
373
516
  <div class="pp-body">
374
517
  <div class="pp-day">Day ${d.number} · ${d.date}</div>
375
518
  <div class="pp-name">${s.name}</div>
376
519
  <div class="pp-desc">${s.description || ''}</div>
377
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>` : ''}
378
521
  </div>
522
+ ${ppMedia}
379
523
  </div>`, { maxWidth: 280 });
380
524
  m.on('click', () => sel(di));
381
525
  dm.push(m);
@@ -511,6 +655,111 @@ const TRIP_DATA = {};
511
655
  map.fitBounds(fullBounds, { animate: true });
512
656
  };
513
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
+
514
763
  // Set page title
515
764
  document.title = trip.title + ' — TripKit';
516
765
 
@@ -62,6 +62,7 @@ days:
62
62
  lng: number
63
63
  notes: string # Free-form: pool, breakfast, special features
64
64
  navigate_url: string # Google Maps nav link
65
+ media: # Optional — same shape as stops[].media (see below). Post-trip photos of the stay.
65
66
 
66
67
  alerts: # Optional array of important warnings
67
68
  - string # e.g. "⏰ Dad has a 7:30 PM meeting..."
@@ -86,6 +87,23 @@ days:
86
87
  kid_friendly: boolean # Optional
87
88
  accessibility: string # Optional — wheelchair, stroller notes
88
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
+
89
107
  # ============================================================
90
108
  # ROUTES — map polylines connecting stops
91
109
  # Optional: if omitted, renderer draws straight lines between stops
@@ -11,10 +11,10 @@ const fs = require('fs');
11
11
  const path = require('path');
12
12
 
13
13
  const ROOT = path.join(__dirname, '..');
14
- const skill = fs.readFileSync(path.join(ROOT, 'agent', 'AGENT-SKILL.md'), 'utf8');
14
+ const skill = fs.readFileSync(path.join(ROOT, 'agent', 'SKILL.md'), 'utf8');
15
15
 
16
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.
17
+ // meaningful field, add it here and to agent/SKILL.md in the same change.
18
18
  const REQUIRED_FIELDS = [
19
19
  // trip
20
20
  'trip.title', 'trip.dates', 'trip.total_days', 'trip.total_stops',
@@ -27,7 +27,7 @@ const REQUIRED_FIELDS = [
27
27
  // stops
28
28
  'stops[].name', 'stops[].lat', 'stops[].lng', 'stops[].type', 'stops[].label',
29
29
  'stops[].description', 'stops[].kid_friendly', 'stops[].reservation_required',
30
- 'stops[].navigate_url',
30
+ 'stops[].navigate_url', 'stops[].media',
31
31
  // lodging
32
32
  'lodging.name', 'lodging.lat', 'lodging.lng', 'lodging.confirmation', 'lodging.booked',
33
33
  // routes
@@ -55,12 +55,12 @@ for (const field of REQUIRED_FIELDS) {
55
55
  }
56
56
 
57
57
  if (missing.length === 0) {
58
- console.log(`✓ AGENT-SKILL.md covers all ${REQUIRED_FIELDS.length} required fields`);
58
+ console.log(`✓ agent/SKILL.md covers all ${REQUIRED_FIELDS.length} required fields`);
59
59
  process.exit(0);
60
60
  }
61
61
 
62
- console.error(`✖ AGENT-SKILL.md is missing references to ${missing.length} required field(s):`);
62
+ console.error(`✖ agent/SKILL.md is missing references to ${missing.length} required field(s):`);
63
63
  for (const f of missing) console.error(` - ${f}`);
64
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.');
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
66
  process.exit(1);