open-likes
Version:
Open source web component for reactions with Supabase backend
766 lines (728 loc) • 27.9 kB
JavaScript
// 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()}
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)); }
}
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;
}
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};