PageSourceSearch

https://milestonerunning.com/assets/admin/scripts/growler.js?v=1.2.0

js milestonerunning.com collected 2026-09-26 11:19:11 UTC 17,190 bytes, 471 lines download raw bytes

1
2var growler = {
3	debug: true,
4	timeoutId: null,
5	queue: [],
6	isShowing: false,
7	time: 4000, // Default timeout (4 seconds)
8	timeouts: {
9		success: 4000,  // 4 seconds
10		error: 5000,    // 5 seconds
11		warning: 5000,  // 5 seconds
12		info: 4000,     // 4 seconds
13		loading: 0      // Indefinite (until task completes)
14	},
15	richColors: true, // Enable rich colors by default
16	showCloseButton: true, // Show close button by default
17	activeToasts: [], // Track active toast elements
18	VISIBLE_TOASTS: 4, // Maximum number of visible toasts (from Sonner pattern)
19	
20	// Swipe constants (from Sonner)
21	SWIPE_THRESHOLD: 45, // pixels to trigger dismiss
22	VELOCITY_THRESHOLD: 0.11, // velocity in px/ms to trigger dismiss
23	
24	icons: {
25		success: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" /></svg>',
26		error: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" /></svg>',
27		warning: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" /></svg>',
28		info: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd" /></svg>',
29		loading: '<div class="sonner-loading-wrapper" data-visible="true"><div class="sonner-spinner"><div class="sonner-loading-bar"></div><div class="sonner-loading-bar"></div><div class="sonner-loading-bar"></div><div class="sonner-loading-bar"></div><div class="sonner-loading-bar"></div><div class="sonner-loading-bar"></div><div class="sonner-loading-bar"></div><div class="sonner-loading-bar"></div><div class="sonner-loading-bar"></div><div class="sonner-loading-bar"></div><div class="sonner-loading-bar"></div><div class="sonner-loading-bar"></div></div></div>'
30	},
31	
32	init: function() {
33		const html = '<div id="GrowlerContainer"></div>';
34		$("body").append(html);
35		
36		// Add close button handler for dynamically created toasts
37		if (growler.showCloseButton) {
38			$(document).on('click', '.growler-toast .growler-close', function() {
39				const toastId = $(this).closest('.growler-toast').data('toast-id');
40				growler.dismissToast(toastId);
41			});
42		}
43	},
44	
45	attachSwipeHandlers: function($toast) {
46		const toastElement = $toast[0];
47		
48		// Attach pointer event handlers directly to the DOM element (like Sonner does)
49		toastElement.addEventListener('pointerdown', (e) => growler.handleSwipeStart(e, $toast), false);
50		toastElement.addEventListener('pointermove', (e) => growler.handleSwipeMove(e, $toast), false);
51		toastElement.addEventListener('pointerup', (e) => growler.handleSwipeEnd(e, $toast), false);
52		toastElement.addEventListener('dragend', (e) => growler.handleSwipeEnd(e, $toast), false);
53	},
54	
55	handleSwipeStart: function(e, $toast) {
56		// Don't start swipe on right click or buttons
57		if (e.button === 2) return; // Right click
58		if ($(e.target).closest('.growler-close').length) return;
59		if ($(e.target).is('button')) return;
60		
61		// Store swipe data on the toast element
62		const swipeData = {
63			startX: e.clientX,
64			startY: e.clientY,
65			startTime: Date.now(),
66			swipeDirection: null,
67			isSwiping: false
68		};
69		
70		$toast.data('swipe', swipeData);
71		$toast.attr('data-swiping', 'false');
72		
73		// Critical: Set pointer capture to track movement even outside toast
74		e.target.setPointerCapture(e.pointerId);
75	},
76	
77	handleSwipeMove: function(e, $toast) {
78		const swipeData = $toast.data('swipe');
79		if (!swipeData) return;
80		
81		// Ignore if text is selected
82		const selection = window.getSelection();
83		if (selection && selection.toString().length > 0) return;
84		
85		const deltaX = e.clientX - swipeData.startX;
86		const deltaY = e.clientY - swipeData.startY;
87		
88		// Determine swipe direction if not already locked
89		if (!swipeData.swipeDirection && (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1)) {
90			swipeData.swipeDirection = Math.abs(deltaX) > Math.abs(deltaY) ? 'x' : 'y';
91			$toast.data('swipe', swipeData);
92		}
93		
94		let swipeAmount = { x: 0, y: 0 };
95		
96		// Dampening function for wrong-direction movement
97		const getDampening = (delta) => {
98			const factor = Math.abs(delta) / 20;
99			return 1 / (1.5 + factor);
100		};
101		
102		// Apply swipe only in locked direction
103		if (swipeData.swipeDirection === 'y') {
104			if (deltaY > 0) {
105				swipeAmount.y = deltaY;
106			} else {
107				const dampenedDelta = deltaY * getDampening(deltaY);
108				swipeAmount.y = Math.abs(dampenedDelta) < Math.abs(deltaY) ? dampenedDelta : deltaY;
109			}
110		} else if (swipeData.swipeDirection === 'x') {
111			swipeAmount.x = deltaX;
112		}
113		
114		// Mark as swiping if movement detected
115		if (Math.abs(swipeAmount.x) > 0 || Math.abs(swipeAmount.y) > 0) {
116			if (!swipeData.isSwiping) {
117				swipeData.isSwiping = true;
118				$toast.data('swipe', swipeData);
119				$toast.attr('data-swiping', 'true');
120			}
121		}
122		
123		// Apply transform using CSS variables
124		$toast.css({
125			'--swipe-amount-x': `${swipeAmount.x}px`,
126			'--swipe-amount-y': `${swipeAmount.y}px`
127		});
128	},
129	
130	handleSwipeEnd: function(e, $toast) {
131		const swipeData = $toast.data('swipe');
132		if (!swipeData) return;
133		
134		// Calculate final swipe amount and velocity
135		const swipeAmountX = parseFloat($toast.css('--swipe-amount-x')) || 0;
136		const swipeAmountY = parseFloat($toast.css('--swipe-amount-y')) || 0;
137		const timeTaken = Date.now() - swipeData.startTime;
138		
139		const swipeAmount = swipeData.swipeDirection === 'x' ? swipeAmountX : swipeAmountY;
140		const velocity = Math.abs(swipeAmount) / timeTaken;
141		
142		// Dismiss if threshold met or velocity high enough
143		if (Math.abs(swipeAmount) >= growler.SWIPE_THRESHOLD || velocity > growler.VELOCITY_THRESHOLD) {
144			// Determine swipe-out direction
145			let swipeOutDirection;
146			if (swipeData.swipeDirection === 'x') {
147				swipeOutDirection = swipeAmountX > 0 ? 'right' : 'left';
148			} else {
149				swipeOutDirection = swipeAmountY > 0 ? 'down' : 'up';
150			}
151			
152			$toast.attr('data-swipe-out', 'true');
153			$toast.attr('data-swipe-direction', swipeOutDirection);
154			
155			// Dismiss after animation
156			const toastId = $toast.data('toast-id');
157			growler.dismissToast(toastId);
158		} else {
159			// Snap back - reset transform
160			$toast.css({
161				'--swipe-amount-x': '0px',
162				'--swipe-amount-y': '0px'
163			});
164		}
165		
166		// Clean up
167		$toast.attr('data-swiping', 'false');
168		$toast.removeData('swipe');
169	},
170	
171	showNext: function() {
172		if (growler.queue.length === 0) return;
173
174	let {type, title, message, action, promiseId, uploadId, progress} = growler.queue.shift();
175		
176		// Create unique ID for this toast
177		const toastId = 'toast-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
178		growler.activeToasts.push(toastId);
179		
180		// Build action button HTML if provided
181		let actionHtml = '';
182		if (action && action.label) {
183			actionHtml = `<button class="growler-action" data-toast-id="${toastId}">${action.label}</button>`;
184		}
185		
186		// Create toast element (force loading type for promise toasts)
187		const toastType = promiseId ? 'loading' : type;
188	
189	// Build progress bar HTML for upload toasts
190	let progressHtml = '';
191	if (uploadId !== undefined) {
192		const currentProgress = progress || 0;
193		progressHtml = `
194			<div class="growler-progress-container">
195				<div class="growler-progress-header">
196					<span class="growler-progress-status">${message}</span>
197					<span class="growler-progress-text">${currentProgress}%</span>
198				</div>
199				<div class="growler-progress-bar">
200					<div class="growler-progress-fill" style="width: ${currentProgress}%"></div>
201				</div>
202			</div>
203		`;
204	}
205	
206	const toastHtml = `
207		<div class="growler-toast ${toastType}" 
208		     data-toast-id="${toastId}" 
209		     data-rich-colors="${growler.richColors}"
210		     ${promiseId ? `data-promise-id="${promiseId}" data-loading="true"` : ''}
211		     ${uploadId ? `data-upload-id="${uploadId}"` : ''}>
212			<div class="growler-content">
213				<div class="growler-icon">${promiseId ? growler.icons.loading : (growler.icons[toastType] || '')}</div>
214				<div class="growler-text">
215					<div class="title">${title}</div>
216					${uploadId ? progressHtml : `<div class="message">${message}</div>`}
217					${actionHtml ? `<div class="growler-actions">${actionHtml}</div>` : ''}
218				</div>
219				${growler.showCloseButton ? '<button class="growler-close" aria-label="Close">&times;</button>' : ''}
220			</div>
221		</div>
222	`;
223	
224	$("#GrowlerContainer").append(toastHtml);
225		
226		// Get the toast element and attach swipe handlers directly
227		const $toast = $(`.growler-toast[data-toast-id="${toastId}"]`);
228		growler.attachSwipeHandlers($toast);
229		
230		// Attach action button handler if provided
231		if (action && action.label && action.onClick) {
232			$toast.find('.growler-action').on('click', function() {
233				action.onClick();
234				if (action.dismissOnClick !== false) {
235					growler.dismissToast(toastId);
236				}
237			});
238		}
239		
240		// Trigger animation and update indices after DOM settles
241		setTimeout(() => {
242			$toast.addClass("open");
243			// Update indices after toast is added and sized
244			setTimeout(() => {
245				growler.updateIndices();
246			}, 50);
247		}, 10);
248		
249		// Auto-dismiss based on type (skip for loading/promise toasts and action toasts)
250		if (!promiseId && !uploadId && !action) {
251			const timeout = growler.timeouts[toastType] || growler.time;
252			if (timeout > 0) {
253				const timeoutId = setTimeout(function() {
254					growler.dismissToast(toastId);
255				}, timeout);
256				
257				// Store timeout ID on the toast element
258				$toast.data('timeoutId', timeoutId);
259			}
260		}
261		
262		return toastId;
263	},
264	
265	updateIndices: function() {
266		// Update all toast data attributes when stack changes
267		// With column-reverse, the LAST element (highest index) is visually on top (front)
268		const toasts = $('.growler-toast');
269		const totalToasts = toasts.length;
270		
271		let frontToastHeight = 0;
272		
273		toasts.each(function(index) {
274			// Reverse index so the last toast (visually on top) has index 0
275			const reverseIndex = totalToasts - 1 - index;
276			const isFront = reverseIndex === 0;
277			// Sonner pattern: isVisible = index + 1 <= visibleToasts (but we use 0-based index)
278			const isVisible = reverseIndex < growler.VISIBLE_TOASTS;
279			
280			$(this).attr('data-index', reverseIndex);
281			$(this).attr('data-front', isFront);
282			$(this).attr('data-visible', isVisible);
283			$(this).css('--index', reverseIndex); // Update CSS variable for calc()
284			
285			// Get height of front toast (index 0)
286			if (isFront) {
287				frontToastHeight = $(this).outerHeight();
288			}
289		});
290		
291		// Update CSS variable for front toast height
292		$('#GrowlerContainer').css('--front-toast-height', frontToastHeight + 'px');
293	},
294	
295	dismissToast: function(toastId) {
296		const $toast = $(`.growler-toast[data-toast-id="${toastId}"]`);
297		if ($toast.length === 0) return;
298		
299		// Clear any pending timeout
300		const timeoutId = $toast.data('timeoutId');
301		if (timeoutId) clearTimeout(timeoutId);
302		
303		// Remove from active toasts
304		growler.activeToasts = growler.activeToasts.filter(id => id !== toastId);
305		
306		// Animate out
307		$toast.removeClass('open');
308		
309		setTimeout(() => {
310			$toast.remove();
311			// Update indices for remaining toasts
312			growler.updateIndices();
313		}, 300);
314	},
315	
316	enqueue: function(type, title, message, options = {}) {
317		growler.queue.push({
318			type, 
319			title, 
320			message, 
321			action: options.action,
322			promiseId: options.promiseId,
323			uploadId: options.uploadId,
324			progress: options.progress
325		});
326		return growler.showNext();
327	},
328	
329	upload: function(title, filename = '') {
330		const uploadId = 'upload-' + Date.now();
331		const message = filename ? `Uploading ${filename}...` : 'Uploading...';
332		growler.enqueue('loading', title, message, { uploadId, progress: 0 });
333		return uploadId;
334	},
335	
336	updateUpload: function(uploadId, progress, statusText = '') {
337		const $toast = $(`.growler-toast[data-upload-id="${uploadId}"]`);
338		if (!$toast.length) return;
339		
340		// Update progress bar
341		$toast.find('.growler-progress-fill').css('width', `${progress}%`);
342		$toast.find('.growler-progress-text').text(`${progress}%`);
343		
344		// Update status text if provided
345		if (statusText) {
346			$toast.find('.growler-progress-status').text(statusText);
347		}
348		
349		// Auto-transition to success at 100%
350		if (progress >= 100) {
351			setTimeout(() => {
352				if (!$toast.length || $toast.hasClass('removing')) return;
353				
354				// Get toast ID before removing attributes
355				const toastId = $toast.data('toast-id');
356				
357				$toast.removeClass('loading').addClass('success');
358				$toast.find('.title').text('Upload Complete');
359				$toast.find('.growler-icon').html(growler.icons.success);
360				$toast.find('.growler-progress-container').remove();
361				$toast.find('.growler-text').append(`<div class="message">All set!</div>`);
362				$toast.removeAttr('data-upload-id');
363				
364				// Auto-dismiss after 4 seconds (success timeout)
365				const timeoutId = setTimeout(() => {
366					growler.dismissToast(toastId);
367				}, 4000);
368				$toast.data('timeoutId', timeoutId);
369			}, 500);
370		}
371	},
372	
373	failUpload: function(uploadId, errorMessage = 'Upload failed') {
374		const $toast = $(`.growler-toast[data-upload-id="${uploadId}"]`);
375		if ($toast.length === 0) return;
376		
377		const toastId = $toast.data('toast-id');
378		
379		// Transition to error state
380		$toast.removeClass('loading').addClass('error');
381		$toast.find('.title').text('Upload Failed');
382		$toast.find('.growler-icon').html(growler.icons.error);
383		$toast.find('.growler-progress-container').remove();
384		$toast.find('.growler-text').append(`<div class="message">${errorMessage}</div>`);
385		$toast.removeAttr('data-upload-id');
386		
387		// Auto-dismiss after 6 seconds (error timeout)
388		const timeoutId = setTimeout(() => {
389			growler.dismissToast(toastId);
390		}, 6000);
391		$toast.data('timeoutId', timeoutId);
392	},
393	
394	error: function(message) {
395		growler.enqueue("error","An Error Occurred",message);
396	},
397	
398	success: function(message) {
399		growler.enqueue("success","Success",message);
400	},
401	
402	info: function(message) {
403		growler.enqueue("info","Information",message);
404	},
405	
406	warning: function(message) {
407		growler.enqueue("warning","Warning",message);
408	},
409	
410	action: function(message, action) {
411		growler.enqueue("info", "Action Required", message, { action });
412	},
413	
414	promise: function(promise, { loading, success, error }) {
415		// Create unique promise ID
416		const promiseId = 'promise-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
417		
418		// Show loading toast
419		const toastId = growler.enqueue(
420			"loading",
421			loading.title || "Loading",
422			loading.message || "Please wait...",
423			{ promiseId }
424		);
425		
426		// Handle promise resolution
427		promise
428			.then((data) => {
429				// Update toast to success
430				growler.updatePromiseToast(toastId, {
431					type: "success",
432					title: success.title || "Success",
433					message: typeof success.message === 'function' ? success.message(data) : success.message || "Operation completed"
434				});
435			})
436			.catch((err) => {
437				// Update toast to error
438				growler.updatePromiseToast(toastId, {
439					type: "error",
440					title: error.title || "Error",
441					message: typeof error.message === 'function' ? error.message(err) : error.message || "Operation failed"
442				});
443			});
444		
445		return promise;
446	},
447	
448	updatePromiseToast: function(toastId, { type, title, message }) {
449		const $toast = $(`.growler-toast[data-toast-id="${toastId}"]`);
450		if ($toast.length === 0) return;
451		
452		// Update class and icon
453		$toast.removeClass('loading success error warning info')
454		      .addClass(type);
455		$toast.find('.growler-icon').html(growler.icons[type] || '');
456		
457		// Update text
458		$toast.find('.title').text(title);
459		$toast.find('.message').text(message);
460		
461		// Remove promise ID
462		$toast.removeAttr('data-promise-id');
463		
464		// Set auto-dismiss timeout
465		const timeoutId = setTimeout(function() {
466			growler.dismissToast(toastId);
467		}, growler.time);
468		
469		$toast.data('timeoutId', timeoutId);
470	}
471};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.