UNPKG

open-likes

Version:

Open source web component for reactions with Supabase backend

766 lines (728 loc) 27.9 kB
// Web Component - Refactored with separated concerns // Coordinates between state management, rendering, and animation classes // Import all our dependencies // Utility functions for Open Likes component function $2e6f12cfe07f95cc$export$c6d3bcc72d75615e(url = window.location.href) { try { const urlObj = new URL(url); // Return URL without search params and hash fragments return `${urlObj.protocol}//${urlObj.host}${urlObj.pathname}`; } catch (error) { // Fallback for invalid URLs return url || 'unknown'; } } function $2e6f12cfe07f95cc$export$61fc7d43ac8f84b0(func, wait) { let timeoutId; return (...args)=>{ // Clear the previous timeout if (timeoutId !== undefined) clearTimeout(timeoutId); // Set a new timeout timeoutId = setTimeout(()=>{ func(...args); }, wait); }; } // localStorage wrapper for tracking user reactions // Used in non-multitap mode to prevent duplicate reactions const $eee610f081cb21aa$var$STORAGE_PREFIX = 'open-likes-reaction-'; class $eee610f081cb21aa$export$9d5059ce50eebfb7 { available = null; /** * Check if localStorage is available and working * Caches result to avoid repeated checks */ isStorageAvailable() { if (this.available !== null) return this.available; try { const testKey = '__open_likes_test__'; const testValue = 'test'; localStorage.setItem(testKey, testValue); const retrieved = localStorage.getItem(testKey); localStorage.removeItem(testKey); this.available = retrieved === testValue; } catch (error) { this.available = false; } return this.available; } /** * Generate storage key for a given ID */ getStorageKey(id) { return $eee610f081cb21aa$var$STORAGE_PREFIX + id; } /** * Check if user has already reacted to this ID * Returns false if localStorage unavailable (allowing reaction) */ async hasReacted(id) { if (!this.isStorageAvailable()) return false; // Allow reaction if storage unavailable try { const key = this.getStorageKey(id); return localStorage.getItem(key) !== null; } catch (error) { return false; // Allow reaction on error } } /** * Mark ID as reacted * Fails silently if localStorage unavailable */ async markAsReacted(id) { if (!this.isStorageAvailable()) return; // Fail silently try { const key = this.getStorageKey(id); localStorage.setItem(key, 'true'); } catch (error) { // localStorage quota exceeded or other error - fail silently console.warn('Failed to save reaction to localStorage:', error); } } } const $eee610f081cb21aa$export$ddcffe0146c8f882 = new $eee610f081cb21aa$export$9d5059ce50eebfb7(); // API client for Supabase RPC functions // Handles communication with get_likes and increment_likes functions class $881efe2f5fb16f94$export$7fe86891bdcfefea { config = null; constructor(config){ if (config) this.config = config; else // Try to get config from window this.config = this.getConfigFromWindow(); } /** * Get Supabase configuration from window globals */ getConfigFromWindow() { if (typeof window === 'undefined') return null; const url = window.OPEN_LIKES_SUPABASE_URL; const anonKey = window.OPEN_LIKES_SUPABASE_ANON_KEY; if (url && anonKey) return { url: url, anonKey: anonKey }; return null; } /** * Validate current Supabase configuration */ validateConfig() { const result = { isValid: false, isConfigured: false, errors: [], warnings: [] }; // Check Supabase configuration if (!this.config) { result.errors.push('No Supabase configuration found. Set OPEN_LIKES_SUPABASE_URL and OPEN_LIKES_SUPABASE_ANON_KEY on window.'); return result; } result.isConfigured = true; // Validate URL if (!this.config.url || typeof this.config.url !== 'string') result.errors.push('Supabase URL is required and must be a string'); else if (!this.config.url.includes('supabase.co') && !this.config.url.includes('localhost')) result.warnings.push('Supabase URL does not appear to be a valid Supabase instance'); // Validate anon key if (!this.config.anonKey || typeof this.config.anonKey !== 'string') result.errors.push('Supabase anon key is required and must be a string'); else if (this.config.anonKey.length < 20) result.warnings.push('Supabase anon key appears to be too short'); result.isValid = result.errors.length === 0; return result; } /** * Make RPC call to Supabase function */ async callRpc(functionName, args) { if (!this.config) throw new Error('Supabase configuration not available'); const url = `${this.config.url}/rest/v1/rpc/${functionName}`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'apikey': this.config.anonKey, 'Authorization': `Bearer ${this.config.anonKey}` }, body: JSON.stringify(args) }); if (!response.ok) { let errorMessage = `RPC call failed: ${response.status} ${response.statusText}`; try { const errorData = await response.json(); if (errorData.message) errorMessage += ` - ${errorData.message}`; } catch { // Ignore JSON parsing errors for error response } throw new Error(errorMessage); } return await response.json(); } /** * Get current likes count for an ID * Returns 0 if the ID doesn't exist or on error */ async getLikes(id) { try { const result = await this.callRpc('get_likes', { target_id: id }); // Ensure we return a valid number if (typeof result === 'number' && !isNaN(result) && result >= 0) return result; return 0; } catch (error) { console.warn('Failed to get likes count:', error); return 0; } } /** * Increment likes count for an ID * Returns new total count or throws error */ async incrementLikes(id, count) { // Validate input if (!id || typeof id !== 'string') throw new Error('ID is required and must be a string'); if (!Number.isInteger(count) || count < 1 || count > 100) throw new Error('Count must be an integer between 1 and 100'); try { const result = await this.callRpc('increment_likes', { target_id: id, increment_by: count }); // Ensure we return a valid number if (typeof result === 'number' && !isNaN(result) && result >= 0) return result; throw new Error('Invalid response from server'); } catch (error) { // Re-throw with more context if (error instanceof Error) throw new Error(`Failed to increment likes: ${error.message}`); throw new Error('Failed to increment likes: Unknown error'); } } } const $881efe2f5fb16f94$export$644d8ea042df96a6 = new $881efe2f5fb16f94$export$7fe86891bdcfefea(); function $881efe2f5fb16f94$export$8744222f7aa763e1(config) { return new $881efe2f5fb16f94$export$7fe86891bdcfefea(config); } class $e5e17c1f77d56623$export$ce32f8c493118dd6 { // State variables _currentLikes = 0; _isLoading = false; _pendingLikes = 0; _hasUserReacted = false; _isAnimating = false; _hasInteracted = false; _pendingServerCount = null; // Current context for API calls _currentLikesId = ''; _currentMultitap = true; // Dependencies api; storage; // Debounced functions debouncedIncrement; debouncedServerUpdate; // Callback for UI updates onStateChange; constructor(api, storage, onStateChange){ this.api = api; this.storage = storage; this.onStateChange = onStateChange; this.debouncedIncrement = (0, $2e6f12cfe07f95cc$export$61fc7d43ac8f84b0)(this.submitLikes.bind(this), 300); this.debouncedServerUpdate = (0, $2e6f12cfe07f95cc$export$61fc7d43ac8f84b0)(this.updateToServerCount.bind(this), 3000); } // Getters for state get currentLikes() { return this._currentLikes; } get isLoading() { return this._isLoading; } get pendingLikes() { return this._pendingLikes; } get hasUserReacted() { return this._hasUserReacted; } get isAnimating() { return this._isAnimating; } get hasInteracted() { return this._hasInteracted; } get totalDisplayLikes() { return this._currentLikes + this._pendingLikes; } // State setters that trigger UI updates setState(updates) { if (updates.currentLikes !== undefined) this._currentLikes = updates.currentLikes; if (updates.isLoading !== undefined) this._isLoading = updates.isLoading; if (updates.pendingLikes !== undefined) this._pendingLikes = updates.pendingLikes; if (updates.hasUserReacted !== undefined) this._hasUserReacted = updates.hasUserReacted; if (updates.isAnimating !== undefined) this._isAnimating = updates.isAnimating; if (updates.hasInteracted !== undefined) this._hasInteracted = updates.hasInteracted; this.onStateChange(); } // Load initial data from API async loadInitialData(likesId, multitap) { try { this.setState({ isLoading: true }); const [likes, hasReacted] = await Promise.all([ this.api.getLikes(likesId), multitap ? Promise.resolve(false) : this.storage.hasReacted(likesId) ]); this.setState({ currentLikes: likes, hasUserReacted: hasReacted, isLoading: false }); } catch (error) { console.warn('Failed to load initial likes:', error); this.setState({ isLoading: false }); } } // Handle user click async handleClick(likesId, multitap) { // Store current context for debounced calls this._currentLikesId = likesId; this._currentMultitap = multitap; // Don't block clicks during API calls for multitap mode if (this._isLoading && !multitap) return; // Check if user already reacted (non-multitap mode) if (!multitap && this._hasUserReacted) return; // Mark as interacted (for persistent filled state) this.setState({ hasInteracted: true }); // Reset server update timer if user is still clicking this.resetServerUpdateTimer(); // Trigger click animation this.setState({ isAnimating: true, pendingLikes: this._pendingLikes + 1 }); // Remove animation after it completes setTimeout(()=>{ this.setState({ isAnimating: false }); }, 600); if (multitap) // Use debounced submission for multitap this.debouncedIncrement(); else // Immediate submission for single tap await this.submitLikes(); } // Update display to show server's authoritative count (debounced) updateToServerCount() { if (this._pendingServerCount !== null) { this.setState({ currentLikes: this._pendingServerCount }); this._pendingServerCount = null; } } // Reset server update timer (called on each new click) resetServerUpdateTimer() { if (this._pendingServerCount !== null) this.debouncedServerUpdate(); } // Submit accumulated likes to API async submitLikes() { if (this._pendingLikes === 0) return; // If already loading, let the current call finish and queue another if (this._isLoading) { setTimeout(()=>this.submitLikes(), 100); return; } const likesToSubmit = this._pendingLikes; try { this.setState({ isLoading: true }); // Immediately apply optimistic update this.setState({ currentLikes: this._currentLikes + likesToSubmit, pendingLikes: 0 }); // Submit to API using stored context const newTotal = await this.api.incrementLikes(this._currentLikesId, likesToSubmit); // Mark as reacted in localStorage for non-multitap mode if (!this._currentMultitap) { await this.storage.markAsReacted(this._currentLikesId); this.setState({ hasUserReacted: true }); } this.setState({ isLoading: false }); // Store server count and start debounced update this._pendingServerCount = newTotal; this.debouncedServerUpdate(); } catch (error) { console.error('Failed to submit likes:', error); // Rollback: restore the likes that failed to submit this.setState({ pendingLikes: likesToSubmit, currentLikes: this._currentLikes - likesToSubmit, isLoading: false }); } } } // SVG Icon definitions using Phosphor icon set // All icons are 256x256 viewBox with stroke styling const $77a198311d5b2a87$export$df03f54e09e486fa = { heart: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path d="M128,224l89.36-90.64a50,50,0,1,0-70.72-70.72L128,80,109.36,62.64a50,50,0,0,0-70.72,70.72Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>`, thumbs: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path d="M32,104H80a0,0,0,0,1,0,0V208a0,0,0,0,1,0,0H32a8,8,0,0,1-8-8V112A8,8,0,0,1,32,104Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M80,104l40-80a32,32,0,0,1,32,32V80h64a16,16,0,0,1,15.87,18l-12,96A16,16,0,0,1,204,208H80" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>`, star: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path d="M128,189.09l54.72,33.65a8.4,8.4,0,0,0,12.52-9.17l-14.88-62.79,48.7-42A8.46,8.46,0,0,0,224.27,94L160.36,88.8,135.74,29.2a8.36,8.36,0,0,0-15.48,0L95.64,88.8,31.73,94a8.46,8.46,0,0,0-4.79,14.83l48.7,42L60.76,213.57a8.4,8.4,0,0,0,12.52,9.17Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>` }; function $77a198311d5b2a87$export$16b26cd5e98d9100(iconName, iconStyle) { const iconSvg = $77a198311d5b2a87$export$df03f54e09e486fa[iconName]; // Replace the large 256x256 viewBox with 24x24 for consistent sizing and add class/style return iconSvg.replace('viewBox="0 0 256 256"', 'viewBox="0 0 256 256" width="24" height="24"').replace('<svg xmlns="http://www.w3.org/2000/svg"', `<svg xmlns="http://www.w3.org/2000/svg" fill="none" style="${iconStyle}" class="like-icon"`); } const $5e1f1d479f92c08d$var$MAX_CONSECUTIVE_TAPS = 10; function $5e1f1d479f92c08d$export$98b279ae74798a9() { let styles = ''; for(let i = 1; i <= $5e1f1d479f92c08d$var$MAX_CONSECUTIVE_TAPS; i++){ const scale = 1 + i * 0.05; styles += `.like-icon.tap-${i} { transform: scale(${scale}); }\n `; } return styles; } function $5e1f1d479f92c08d$export$1e0c6a48b8f1583d(color) { return ` :host { display: inline-block; user-select: none; } .like-button { display: flex; align-items: center; justify-content: center; background: transparent; border: none; padding: 8px; cursor: pointer; position: relative; gap: 8px; color: ${color}; } .like-button.vertical { flex-direction: column; } .like-button.horizontal { flex-direction: row; } .like-button.top { flex-direction: column-reverse; } .like-button.left { flex-direction: row-reverse; } .like-icon { transition: all 0.3s ease; transform-origin: center; fill: none; } .like-icon.filled { fill: currentColor; } .like-icon.animate { animation: pulse 0.6s ease; } /* Progressive scaling for consecutive taps */ ${$5e1f1d479f92c08d$export$98b279ae74798a9()} @keyframes pulse { 0% { transform: scale(var(--current-scale, 1)); } 30% { transform: scale(calc(var(--current-scale, 1) + 0.3)); } 60% { transform: scale(calc(var(--current-scale, 1) + 0.1)); } 100% { transform: scale(var(--current-scale, 1)); } } @keyframes resetSize { from { transform: scale(var(--current-scale, 1)); } to { transform: scale(1); } } .icon { color: inherit; position: relative; display: flex; align-items: center; justify-content: center; } .count { font-size: 14px; font-weight: bold; color: inherit; min-height: 20px; display: flex; align-items: center; white-space: nowrap; } .count.hidden { display: none; } .loading { opacity: 0.6; } /* Particle animation */ .particles { position: relative; overflow: visible; } .particle { position: absolute; width: 6px; height: 6px; background: ${color}; border-radius: 50%; pointer-events: none; animation: particleFloat 1s ease-out forwards; } @keyframes particleFloat { 0% { opacity: 1; transform: translate(0, 0) scale(1); } 100% { opacity: 0; transform: translate(var(--dx), var(--dy)) scale(0); } } `; } class $4c498db2ab55da01$export$4f0519332fb94dea { shadowRoot; isInitialRender = true; constructor(shadowRoot){ this.shadowRoot = shadowRoot; } /** * Render the component (initial or update) */ render(attributes, state) { if (this.isInitialRender) { this.initialRender(attributes, state); this.isInitialRender = false; return; } // Update only specific elements for subsequent renders this.updateElements(state); } /** * Force a full re-render (used when structural attributes change) */ forceReRender(attributes, state) { this.isInitialRender = true; this.render(attributes, state); } /** * Initial render - sets up the complete DOM structure */ initialRender(attributes, state) { const iconSvg = (0, $77a198311d5b2a87$export$16b26cd5e98d9100)(attributes.icon, `color: ${attributes.color}`); const styles = (0, $5e1f1d479f92c08d$export$1e0c6a48b8f1583d)(attributes.color); const counterClass = attributes.counter === 'hidden' ? 'hidden' : ''; const layoutClass = [ 'top', 'bottom' ].includes(attributes.counter) ? 'vertical' : 'horizontal'; const positionClass = [ 'top', 'left' ].includes(attributes.counter) ? attributes.counter : ''; this.shadowRoot.innerHTML = ` <style>${styles}</style> <button class="like-button particles ${layoutClass} ${positionClass}"> <div class="icon"> ${iconSvg} </div> <div class="count ${counterClass}"> ${state.currentLikes + state.pendingLikes} </div> </button> `; // Update all dynamic elements after initial render this.updateElements(state); } /** * Update only the dynamic parts of the component */ updateElements(state) { const button = this.shadowRoot.querySelector('.like-button'); const countElement = this.shadowRoot.querySelector('.count'); if (!button || !countElement) return; // Update counter value countElement.textContent = String(state.currentLikes + state.pendingLikes); // Update button disabled state - only disable during loading in single-tap mode button.disabled = state.isLoading && !state.multitap; } /** * Get the button element for event listener attachment */ getButton() { return this.shadowRoot.querySelector('.like-button'); } /** * Check if this is the first render */ get isFirstRender() { return this.isInitialRender; } } /** * Manages animations for the likes component * Handles particle burst effects and other visual animations */ class $27e0d3357d2aa2a2$export$42d239a7180b47de { shadowRoot; constructor(shadowRoot){ this.shadowRoot = shadowRoot; } /** * Create particle burst animation from the icon */ createParticles() { const button = this.shadowRoot.querySelector('.like-button'); const iconContainer = this.shadowRoot.querySelector('.icon'); if (!button || !iconContainer) return; // Create 8 particles for a nice burst effect const particleCount = 8; for(let i = 0; i < particleCount; i++){ const particle = document.createElement('div'); particle.className = 'particle'; // Calculate random direction for each particle (360 degrees / 8 particles) const angle = i / particleCount * Math.PI * 2 + (Math.random() - 0.5) * 0.5; const distance = 30 + Math.random() * 20; // 30-50px distance const dx = Math.cos(angle) * distance; const dy = Math.sin(angle) * distance; // Set CSS custom properties for animation particle.style.setProperty('--dx', `${dx}px`); particle.style.setProperty('--dy', `${dy}px`); // Position particle at the center of the icon container const buttonRect = button.getBoundingClientRect(); const iconRect = iconContainer.getBoundingClientRect(); // Calculate icon center relative to button const iconCenterX = iconRect.left + iconRect.width / 2 - buttonRect.left; const iconCenterY = iconRect.top + iconRect.height / 2 - buttonRect.top; particle.style.left = `${iconCenterX}px`; particle.style.top = `${iconCenterY}px`; particle.style.transform = 'translate(-50%, -50%)'; button.appendChild(particle); // Remove particle after animation completes setTimeout(()=>{ if (particle.parentNode) particle.parentNode.removeChild(particle); }, 1000); } } /** * Update icon animation classes based on state */ updateIconAnimation(hasInteracted, hasUserReacted, isAnimating) { const svgElement = this.shadowRoot.querySelector('.like-icon'); if (!svgElement) return; const iconClasses = [ 'like-icon' ]; // Icon stays filled after first interaction in this session if (hasInteracted || hasUserReacted) iconClasses.push('filled'); if (isAnimating) iconClasses.push('animate'); svgElement.setAttribute('class', iconClasses.join(' ')); } } const $a0ad654d2c773ae3$export$83d89fbfd8236492 = '1.0.0'; /** * OpenLikes web component - Refactored implementation * Coordinates between state management, rendering, and animation */ class $a0ad654d2c773ae3$var$OpenLikesComponent extends HTMLElement { // Helper classes for separated concerns state; renderer; animator; constructor(){ super(); this.attachShadow({ mode: 'open' }); // Initialize dependencies const storage = new (0, $eee610f081cb21aa$export$9d5059ce50eebfb7)(); const api = new (0, $881efe2f5fb16f94$export$7fe86891bdcfefea)(); // Initialize helper classes this.state = new (0, $e5e17c1f77d56623$export$ce32f8c493118dd6)(api, storage, ()=>this.onStateChange()); this.renderer = new (0, $4c498db2ab55da01$export$4f0519332fb94dea)(this.shadowRoot); this.animator = new (0, $27e0d3357d2aa2a2$export$42d239a7180b47de)(this.shadowRoot); } // Lifecycle methods async connectedCallback() { this.render(); this.attachEventListeners(); await this.state.loadInitialData(this.likesId, this.multitap); } disconnectedCallback() { // Clean up event listeners if needed } attributeChangedCallback(name, oldValue, newValue) { if (oldValue !== newValue) { // For structural changes (icon, layout), trigger full re-render if ([ 'icon', 'color', 'counter' ].includes(name)) this.renderer.forceReRender(this.getAttributes(), this.getRenderState()); else this.render(); } } static get observedAttributes() { return [ 'id', 'icon', 'color', 'counter', 'multitap' ]; } // Attribute getters with defaults get likesId() { return this.getAttribute('id') || (0, $2e6f12cfe07f95cc$export$c6d3bcc72d75615e)(); } get icon() { const icon = this.getAttribute('icon'); return icon || 'heart'; } get color() { return this.getAttribute('color') || 'currentColor'; } get counter() { const position = this.getAttribute('counter'); return position || 'right'; } get multitap() { const attr = this.getAttribute('multitap'); return attr !== 'false'; // Default to true, only false when explicitly set to 'false' } // Helper methods for component attributes and state getAttributes() { return { icon: this.icon, color: this.color, counter: this.counter }; } getRenderState() { return { currentLikes: this.state.currentLikes, pendingLikes: this.state.pendingLikes, isLoading: this.state.isLoading, multitap: this.multitap }; } // Render the component render() { if (!this.shadowRoot) return; this.renderer.render(this.getAttributes(), this.getRenderState()); } // Called when state changes to update UI onStateChange() { this.render(); // Update icon animation state this.animator.updateIconAnimation(this.state.hasInteracted, this.state.hasUserReacted, this.state.isAnimating); } // Attach click event listeners attachEventListeners() { const button = this.renderer.getButton(); if (button) button.addEventListener('click', this.handleClick.bind(this)); } // Handle button click async handleClick() { // Create particle burst animation this.animator.createParticles(); // Delegate to state management await this.state.handleClick(this.likesId, this.multitap); } } // Register the custom element customElements.define('open-likes', $a0ad654d2c773ae3$var$OpenLikesComponent); // Log to verify the module loads console.log(`Open Likes v${$a0ad654d2c773ae3$export$83d89fbfd8236492} loaded - Component ready`); export {$a0ad654d2c773ae3$export$83d89fbfd8236492 as version};