Skip to content

Commit dc3b514

Browse files
authored
Merge pull request #58 from ProLoser/copilot/add-swipable-cards-feature
2 parents 0398653 + b7960e9 commit dc3b514

5 files changed

Lines changed: 460 additions & 8 deletions

File tree

19hz/map.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ const CATEGORY_DELIMITER = '~';
44

55
// Spotify API configuration
66
// For production, these should be loaded from environment variables or a secure backend
7-
const SPOTIFY_CLIENT_ID = '__SPOTIFY_CLIENT_ID__';
8-
const SPOTIFY_CLIENT_SECRET = '__SPOTIFY_CLIENT_SECRET__';
7+
const SPOTIFY_CLIENT_ID = 'YOUR_SPOTIFY_CLIENT_ID';
8+
const SPOTIFY_CLIENT_SECRET = 'YOUR_SPOTIFY_CLIENT_SECRET';
99

1010
// Cache for Spotify access token
1111
let spotifyAccessToken = null;

index.html

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,43 @@
1212
<body>
1313
<div id="map-canvas"></div>
1414

15+
<div id="event-card">
16+
<div id="event-card-handle"></div>
17+
<div id="event-card-nav">
18+
<button id="event-card-prev" class="ui-button" title="Previous event">&#8249;</button>
19+
<span id="event-card-counter"></span>
20+
<button id="event-card-next" class="ui-button" title="Next event">&#8250;</button>
21+
</div>
22+
<div id="event-card-slider">
23+
<div id="event-card-current">
24+
<h2 class="event-card-title"></h2>
25+
<p class="event-card-meta"></p>
26+
<p class="event-card-cost"></p>
27+
<div id="event-card-actions">
28+
<button id="event-card-details" class="ui-button"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 5.83L15.17 9l1.41-1.41L12 3 7.41 7.59 8.83 9 12 5.83zm0 12.34L8.83 15l-1.41 1.41L12 21l4.59-4.59L15.17 15 12 18.17z"/></svg> Details</button>
29+
<span id="event-card-calendar-container"></span>
30+
<button id="event-card-close" class="ui-button">✕ Close</button>
31+
</div>
32+
</div>
33+
<div id="event-card-peek" aria-hidden="true">
34+
<h2 class="event-card-title"></h2>
35+
<p class="event-card-meta"></p>
36+
<p class="event-card-cost"></p>
37+
</div>
38+
</div>
39+
</div>
40+
41+
<button id="card-mode-toggle" class="ui-button" title="Toggle floating card view">
42+
<svg class="icon-open" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
43+
<path d="M20 3H4C2.9 3 2 3.9 2 5v14c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H4v-6h16v6zm0-8H4V5h16v6z"/>
44+
</svg>
45+
<svg class="icon-close" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
46+
<path d="M20 3H4C2.9 3 2 3.9 2 5v14c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H4v-6h16v6zm0-8H4V5h16v6z"/>
47+
<path d="M7 8l5 5 5-5z"/>
48+
</svg>
49+
Cards
50+
</button>
51+
1552
<a id="feedback" class="ui-button" target="_blank" href="https://github.com/ProLoser/funcheapmap/issues">
1653
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
1754
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z"></path>
@@ -79,7 +116,7 @@
79116
</form>
80117
<script async defer src="https://cdn.jsdelivr.net/npm/add-to-calendar-button@2"></script>
81118
<script src="map.js"></script>
82-
<script async src="https://maps.googleapis.com/maps/api/js?v=3.exp&loading=async&key=__GOOGLE_TOKEN__&libraries=marker&callback=initialize"></script>
119+
<script async src="https://maps.googleapis.com/maps/api/js?v=3.exp&loading=async&key=AIzaSyDOtvUXkVqgvf1TgyCGS3le1WaevMUZjwI&libraries=marker&callback=initialize"></script>
83120

84121
<!-- Google tag (gtag.js) -->
85122
<script async src="https://www.googletagmanager.com/gtag/js?id=G-0NECRETGYD"></script>

map.js

Lines changed: 276 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,267 @@ let userLocationMarker = null;
1717
// Global variable to track cluster markers
1818
let clusterMarkers = [];
1919

20+
// Floating card state
21+
let cardModeEnabled = localStorage.getItem('cardMode') !== 'false';
22+
let visibleEventsList = [];
23+
let currentCardIndex = -1;
24+
const SWIPE_THRESHOLD = 50;
25+
const VELOCITY_THRESHOLD = 0.3; // px/ms
26+
const CARD_TRANSITION = 'transform 0.3s cubic-bezier(0.4,0,0.2,1)';
27+
28+
function updateCardToggleButton() {
29+
const button = document.getElementById('card-mode-toggle');
30+
if (!button) return;
31+
button.classList.toggle('active', cardModeEnabled);
32+
button.title = cardModeEnabled ? 'Disable floating cards' : 'Enable floating cards';
33+
}
34+
35+
function buildCardContent(container, event) {
36+
const titleEl = container.querySelector('.event-card-title');
37+
titleEl.innerHTML = '';
38+
const titleLink = document.createElement('a');
39+
titleLink.target = '_blank';
40+
titleLink.href = event.url;
41+
titleLink.textContent = event.title;
42+
titleEl.appendChild(titleLink);
43+
44+
const metaEl = container.querySelector('.event-card-meta');
45+
metaEl.innerHTML = '';
46+
const venueLink = document.createElement('a');
47+
venueLink.target = '_blank';
48+
venueLink.href = `https://maps.google.com/?q=${encodeURIComponent(event.venue)}&ll=${event.geometry.lat},${event.geometry.lng}`;
49+
venueLink.textContent = event.venue;
50+
metaEl.appendChild(venueLink);
51+
metaEl.appendChild(document.createTextNode(` | ${event.date_text} | ${event.time}`));
52+
53+
const costEl = container.querySelector('.event-card-cost');
54+
costEl.innerHTML = '';
55+
const costLink = document.createElement('a');
56+
costLink.target = '_blank';
57+
costLink.href = event.eventUrl;
58+
costLink.textContent = event.cost;
59+
costEl.appendChild(costLink);
60+
if (event.cost_details) {
61+
costEl.appendChild(document.createTextNode(' — ' + event.cost_details));
62+
}
63+
const calendarContainer = container.querySelector('#event-card-calendar-container');
64+
if (calendarContainer) {
65+
const time = event.time.split(' to ');
66+
const start = new Date(`${event.date_text} ${time[0]}`);
67+
const startDate = start.toLocaleDateString('sv-SE');
68+
let endToken;
69+
if (time[1]) {
70+
if (time[1].substr(-2) == 'am' && time[0].substr(-2) == 'pm') {
71+
const endDate = new Date(start);
72+
endDate.setDate(endDate.getDate() + 1);
73+
endToken = `${endDate.toLocaleDateString('sv-SE').replace(/-/gi, '/')} ${time[1]}`;
74+
} else {
75+
endToken = `${startDate.replace(/-/gi, '/')} ${time[1]}`;
76+
}
77+
} else {
78+
endToken = start.getTime() + 60*60*1000;
79+
}
80+
const end = new Date(endToken);
81+
calendarContainer.innerHTML = `<add-to-calendar-button
82+
name="${event.title.replaceAll('"', "'")}"
83+
description="${event.eventUrl}"
84+
startDate="${startDate}"
85+
options="'Apple','Google','iCal','Outlook.com'"
86+
startTime="${start.toTimeString().substr(0, 5)}"
87+
endDate="${end.toLocaleDateString('sv-SE')}"
88+
endTime="${end.toTimeString().substr(0, 5)}"
89+
location="${event.venue}"
90+
listStyle="modal"
91+
buttonStyle="default"
92+
timeZone="America/Los_Angeles"
93+
size="4"
94+
hideTextLabelButton
95+
hideCheckmark
96+
forceOverlay
97+
></add-to-calendar-button>`;
98+
}
99+
}
100+
101+
function showEventCard(event) {
102+
currentCardIndex = visibleEventsList.indexOf(event);
103+
const current = document.getElementById('event-card-current');
104+
current.style.transition = 'none';
105+
current.style.transform = 'translateX(0)';
106+
buildCardContent(current, event);
107+
document.getElementById('event-card-counter').textContent =
108+
`${currentCardIndex + 1} of ${visibleEventsList.length}`;
109+
document.getElementById('event-card').classList.add('visible');
110+
Events.infoWindow(event).open(window.map, event.marker);
111+
}
112+
113+
function hideEventCard() {
114+
document.getElementById('event-card').classList.remove('visible');
115+
Events.infoWindow().close();
116+
Events.currentEvent = null;
117+
}
118+
119+
function commitNavigation(direction) {
120+
if (!visibleEventsList.length) return;
121+
const slider = document.getElementById('event-card-slider');
122+
const current = document.getElementById('event-card-current');
123+
const peek = document.getElementById('event-card-peek');
124+
const width = slider.offsetWidth;
125+
const newIndex = (currentCardIndex + direction + visibleEventsList.length) % visibleEventsList.length;
126+
127+
buildCardContent(peek, visibleEventsList[newIndex]);
128+
peek.style.transition = 'none';
129+
current.style.transition = 'none';
130+
peek.style.transform = `translateX(${direction > 0 ? width : -width}px)`;
131+
current.style.transform = 'translateX(0)';
132+
133+
// Force reflow so transition fires
134+
peek.offsetWidth;
135+
136+
peek.style.transition = CARD_TRANSITION;
137+
current.style.transition = CARD_TRANSITION;
138+
current.style.transform = `translateX(${direction > 0 ? -width : width}px)`;
139+
peek.style.transform = 'translateX(0)';
140+
141+
current.addEventListener('transitionend', () => finishNavigation(current, peek, newIndex), { once: true });
142+
}
143+
144+
function finishNavigation(current, peek, newIndex) {
145+
currentCardIndex = newIndex;
146+
current.style.transition = 'none';
147+
peek.style.transition = 'none';
148+
current.style.transform = 'translateX(0)';
149+
peek.style.transform = '';
150+
buildCardContent(current, visibleEventsList[currentCardIndex]);
151+
document.getElementById('event-card-counter').textContent =
152+
`${currentCardIndex + 1} of ${visibleEventsList.length}`;
153+
const ev = visibleEventsList[currentCardIndex];
154+
if (ev?.marker) {
155+
window.map.panTo(ev.geometry);
156+
Events.infoWindow(ev).open(window.map, ev.marker);
157+
}
158+
}
159+
160+
function initEventCard() {
161+
const card = document.getElementById('event-card');
162+
const slider = document.getElementById('event-card-slider');
163+
const current = document.getElementById('event-card-current');
164+
const peek = document.getElementById('event-card-peek');
165+
166+
let touchStartX = 0, touchStartY = 0;
167+
let lastMoveX = 0, lastMoveTime = 0, swipeVelocity = 0;
168+
let gestureAxis = null; // 'horizontal' | 'vertical'
169+
let peekDirection = 0; // 1 = peek is on right (next), -1 = peek is on left (prev)
170+
171+
card.addEventListener('touchstart', e => {
172+
touchStartX = e.touches[0].clientX;
173+
touchStartY = e.touches[0].clientY;
174+
lastMoveX = touchStartX;
175+
lastMoveTime = Date.now();
176+
swipeVelocity = 0;
177+
gestureAxis = null;
178+
peekDirection = 0;
179+
current.style.transition = 'none';
180+
peek.style.transition = 'none';
181+
}, { passive: true });
182+
183+
card.addEventListener('touchmove', e => {
184+
const deltaX = e.touches[0].clientX - touchStartX;
185+
const deltaY = e.touches[0].clientY - touchStartY;
186+
187+
if (!gestureAxis) {
188+
if (Math.abs(deltaX) > 8) gestureAxis = 'horizontal';
189+
else if (Math.abs(deltaY) > 8) gestureAxis = 'vertical';
190+
else return;
191+
}
192+
if (gestureAxis !== 'horizontal') return;
193+
194+
const now = Date.now();
195+
const dt = now - lastMoveTime;
196+
if (dt > 0) swipeVelocity = (e.touches[0].clientX - lastMoveX) / dt;
197+
lastMoveX = e.touches[0].clientX;
198+
lastMoveTime = now;
199+
200+
const width = slider.offsetWidth;
201+
const newDir = deltaX < 0 ? 1 : -1; // 1=next, -1=prev
202+
203+
if (peekDirection !== newDir) {
204+
peekDirection = newDir;
205+
const peekIndex = (currentCardIndex + newDir + visibleEventsList.length) % visibleEventsList.length;
206+
buildCardContent(peek, visibleEventsList[peekIndex]);
207+
peek.style.transition = 'none';
208+
peek.style.transform = `translateX(${newDir > 0 ? width : -width}px)`;
209+
}
210+
211+
current.style.transform = `translateX(${deltaX}px)`;
212+
peek.style.transform = `translateX(${(peekDirection > 0 ? width : -width) + deltaX}px)`;
213+
}, { passive: true });
214+
215+
card.addEventListener('touchend', e => {
216+
const deltaX = e.changedTouches[0].clientX - touchStartX;
217+
const deltaY = e.changedTouches[0].clientY - touchStartY;
218+
219+
if (gestureAxis === 'vertical') {
220+
if (deltaY > SWIPE_THRESHOLD) hideEventCard();
221+
return;
222+
}
223+
224+
if (gestureAxis !== 'horizontal' || !peekDirection) {
225+
return;
226+
}
227+
228+
const width = slider.offsetWidth;
229+
const shouldCommit = Math.abs(deltaX) > SWIPE_THRESHOLD || Math.abs(swipeVelocity) > VELOCITY_THRESHOLD;
230+
const commitDir = deltaX < 0 ? 1 : -1;
231+
232+
if (shouldCommit) {
233+
const newIndex = (currentCardIndex + commitDir + visibleEventsList.length) % visibleEventsList.length;
234+
current.style.transition = CARD_TRANSITION;
235+
peek.style.transition = CARD_TRANSITION;
236+
current.style.transform = `translateX(${commitDir > 0 ? -width : width}px)`;
237+
peek.style.transform = 'translateX(0)';
238+
239+
current.addEventListener('transitionend', () => finishNavigation(current, peek, newIndex), { once: true });
240+
} else {
241+
current.style.transition = CARD_TRANSITION;
242+
peek.style.transition = CARD_TRANSITION;
243+
current.style.transform = 'translateX(0)';
244+
peek.style.transform = `translateX(${peekDirection > 0 ? width : -width}px)`;
245+
}
246+
247+
gestureAxis = null;
248+
peekDirection = 0;
249+
}, { passive: true });
250+
251+
document.getElementById('event-card-prev').addEventListener('click', () => commitNavigation(-1));
252+
document.getElementById('event-card-next').addEventListener('click', () => commitNavigation(1));
253+
document.getElementById('event-card-close').addEventListener('click', hideEventCard);
254+
255+
document.getElementById('event-card-details').addEventListener('click', () => {
256+
const detailsEl = Events.infoWindow().getContent()?.querySelector('details');
257+
if (detailsEl) detailsEl.open = !detailsEl.open;
258+
});
259+
260+
window.addEventListener('keydown', e => {
261+
if (!document.getElementById('event-card').classList.contains('visible')) return;
262+
if (e.key === 'ArrowLeft') { e.preventDefault(); commitNavigation(-1); }
263+
else if (e.key === 'ArrowRight') { e.preventDefault(); commitNavigation(1); }
264+
});
265+
266+
const toggleButton = document.getElementById('card-mode-toggle');
267+
updateCardToggleButton();
268+
toggleButton.addEventListener('click', () => {
269+
cardModeEnabled = !cardModeEnabled;
270+
localStorage.setItem('cardMode', cardModeEnabled);
271+
updateCardToggleButton();
272+
if (cardModeEnabled && Events.currentEvent) {
273+
showEventCard(Events.currentEvent);
274+
} else if (!cardModeEnabled) {
275+
hideEventCard();
276+
}
277+
});
278+
}
279+
280+
20281
// Initialize the map
21282
// window.addEventListener('load', initialize)
22283
async function initialize() {
@@ -42,12 +303,14 @@ async function initialize() {
42303
window.addEventListener('keyup', event => {
43304
switch (event.keyCode) {
44305
case 27: // esc
45-
Events.infoWindow().close();
306+
Events.infoWindow().close();
307+
hideEventCard();
46308
break;
47309
}
48310
});
49311
google.maps.event.addListener(window.map, 'click', function(event) {
50312
Events.infoWindow().close();
313+
hideEventCard();
51314
});
52315

53316
// Add "You Are Here" button
@@ -100,13 +363,19 @@ async function initialize() {
100363
content.style.setProperty('--delay-time', time + 's');
101364
intersectionObserver.observe(content);
102365
event.marker.addListener('gmp-click', function () {
103-
Events.infoWindow(event).open(window.map, event.marker);
366+
if (cardModeEnabled) {
367+
showEventCard(event);
368+
} else {
369+
Events.infoWindow(event).open(window.map, event.marker);
370+
}
104371
});
105372
});
106373
const form = document.getElementById('controls');
107374
form.addEventListener('reset', window.filter);
108375
map.controls[google.maps.ControlPosition.LEFT_BOTTOM].push(form);
109376
map.controls[google.maps.ControlPosition.RIGHT_BOTTOM].push(document.getElementById('feedback'));
377+
map.controls[google.maps.ControlPosition.RIGHT_BOTTOM].push(document.getElementById('card-mode-toggle'));
378+
initEventCard();
110379
// Update the date picker
111380
if (minDate && maxDate) {
112381
form.elements['date'].min = minDate.toLocaleDateString('fr-ca');
@@ -325,6 +594,10 @@ window.filter = async function (filters = {}) {
325594
window.history.replaceState({}, '', '?' + query.join('&'));
326595
form.elements['countEvents'].innerText = count;
327596
form.elements['countCategories'].innerText = categories.length || 'All';
597+
// Update the cached visible events list for card navigation
598+
visibleEventsList = window.events.get()?.filter(e => e.visible && e.title && e.geometry) || [];
599+
// Dismiss the card when filters change since the current event may no longer be visible
600+
hideEventCard();
328601
};
329602

330603
/**
@@ -463,6 +736,7 @@ class Events {
463736
if (!this.cachedInfoWindow)
464737
this.cachedInfoWindow = new google.maps.InfoWindow({});
465738
if (event) {
739+
this.currentEvent = event;
466740
const time = event.time.split(' to ')
467741
const start = new Date(`${event.date_text} ${time[0]}`)
468742
const startDate = start.toLocaleDateString('sv-SE') // outputs yyyy-mm-dd

preview/pr-56/19hz/map.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ const CATEGORY_DELIMITER = '~';
44

55
// Spotify API configuration
66
// For production, these should be loaded from environment variables or a secure backend
7-
const SPOTIFY_CLIENT_ID = '447cbdc30cb443c79ab069b4dec478a8';
8-
const SPOTIFY_CLIENT_SECRET = '48cea91e36d5410297b49763bcb73730';
7+
const SPOTIFY_CLIENT_ID = 'YOUR_SPOTIFY_CLIENT_ID';
8+
const SPOTIFY_CLIENT_SECRET = 'YOUR_SPOTIFY_CLIENT_SECRET';
99

1010
// Cache for Spotify access token
1111
let spotifyAccessToken = null;

0 commit comments

Comments
 (0)