/** * @fileoverview Rant Feed Component for Rantii * @author retoor * @description Infinite scrolling list of rants * @keywords feed, list, rants, infinite, scroll */ import { BaseComponent } from './base-component.js'; class RantFeed extends BaseComponent { static get observedAttributes() { return ['sort', 'feed-type']; } init() { this.rants = []; this.skip = 0; this.limit = 20; this.isLoading = false; this.hasMore = true; this.sort = this.getAttr('sort') || 'recent'; this.feedType = this.getAttr('feed-type') || 'rants'; this.blockChangeHandler = () => this.render(); this.stateKey = this.buildStateKey(); this.pendingRestore = null; this.isRestoring = false; this.scrollHandler = this.debounce(() => this.onScroll(), 200); this.render(); this.bindEvents(); } buildStateKey() { const feedType = this.getAttr('feed-type') || 'rants'; if (feedType === 'search') { return null; } if (feedType === 'rants') { return 'feed_state_home'; } return 'feed_state_' + feedType; } setStateKey(key) { this.stateKey = key; } onScroll() { if (this.isRestoring || this.isLoading) return; this.saveScrollState(); } getTopVisibleRant() { const cards = this.$$('rant-card'); if (!cards || cards.length === 0) return null; for (const card of cards) { const rect = card.getBoundingClientRect(); if (rect.top >= -rect.height && rect.top < window.innerHeight) { const rantId = card.getRantId(); const offsetFromTop = rect.top; return { rantId, offsetFromTop }; } } return null; } saveScrollState() { if (!this.stateKey) return; const topRant = this.getTopVisibleRant(); if (!topRant) return; const state = { anchorRantId: topRant.rantId, offsetFromTop: topRant.offsetFromTop, skip: this.skip, sort: this.sort }; this.getStorage()?.set(this.stateKey, state); } clearScrollState() { if (!this.stateKey) return; this.getStorage()?.remove(this.stateKey); } async restoreScrollState() { if (!this.stateKey) return false; const state = this.getStorage()?.get(this.stateKey); if (!state || state.sort !== this.sort) { this.clearScrollState(); return false; } this.isRestoring = true; this.pendingRestore = state; await this.loadWithLimit(state.skip || this.limit); return true; } scrollToAnchor() { if (!this.pendingRestore) { this.isRestoring = false; return; } const { anchorRantId, offsetFromTop } = this.pendingRestore; this.pendingRestore = null; requestAnimationFrame(() => { const card = this.$(`rant-card[rant-id="${anchorRantId}"]`); if (card) { const rect = card.getBoundingClientRect(); const scrollY = window.scrollY + rect.top - offsetFromTop; window.scrollTo(0, Math.max(0, scrollY)); } setTimeout(() => { this.isRestoring = false; }, 100); }); } async loadWithLimit(limit) { if (this.isLoading) return; this.rants = []; this.skip = 0; this.hasMore = true; this.isLoading = true; this.updateLoadingState(); try { let result; const api = this.getApi(); switch (this.feedType) { case 'weekly': result = await api?.getWeeklyRants(this.sort, limit, 0); break; case 'collabs': result = await api?.getCollabs(this.sort, limit, 0); break; case 'stories': result = await api?.getStories(this.sort, limit, 0); break; case 'search': break; default: result = await api?.getRants(this.sort, limit, 0); } if (result?.success) { const newRants = result.rants || []; this.rants = newRants; this.skip = newRants.length; this.hasMore = newRants.length >= limit; } else { this.hasMore = false; } } catch (error) { this.hasMore = false; } finally { this.isLoading = false; this.render(); this.scrollToAnchor(); } } getBlock() { return window.app?.block; } getFilteredRants() { const block = this.getBlock(); if (!block) return this.rants; return block.filterRants(this.rants); } async load(reset = false) { if (this.isLoading) return; if (!reset && !this.hasMore) return; if (reset) { this.rants = []; this.skip = 0; this.hasMore = true; } this.isLoading = true; this.updateLoadingState(); try { let result; const api = this.getApi(); switch (this.feedType) { case 'weekly': result = await api?.getWeeklyRants(this.sort, this.limit, this.skip); break; case 'collabs': result = await api?.getCollabs(this.sort, this.limit, this.skip); break; case 'stories': result = await api?.getStories(this.sort, this.limit, this.skip); break; case 'search': break; default: result = await api?.getRants(this.sort, this.limit, this.skip); } if (result?.success) { const newRants = result.rants || []; this.rants = [...this.rants, ...newRants]; this.skip += newRants.length; this.hasMore = newRants.length >= this.limit; } else { this.hasMore = false; } } catch (error) { this.hasMore = false; } finally { this.isLoading = false; this.render(); } } async search(term) { if (this.isLoading) return; this.isLoading = true; this.rants = []; this.updateLoadingState(); try { const result = await this.getApi()?.search(term); if (result?.success) { this.rants = result.rants || []; } this.hasMore = false; } catch (error) { this.rants = []; } finally { this.isLoading = false; this.render(); } } render() { this.addClass('rant-feed'); const filteredRants = this.getFilteredRants(); if (filteredRants.length === 0 && !this.isLoading) { this.setHtml(`

No rants found

`); return; } this.setHtml(`
${filteredRants.map(rant => ` `).join('')}
${this.isLoading ? `
` : ''} ${this.hasMore && !this.isLoading ? `
` : ''} `); this.initRantCards(filteredRants); } initRantCards(filteredRants) { const cards = this.$$('rant-card'); cards.forEach((card, index) => { if (filteredRants[index]) { card.setRant(filteredRants[index]); } }); } updateLoadingState() { const loadingEl = this.$('.feed-loading'); if (this.isLoading && !loadingEl && this.rants.length > 0) { const loadMore = this.$('.feed-loadmore'); if (loadMore) { loadMore.innerHTML = ''; } } } bindEvents() { this.on(this, 'click', this.handleClick); this.setupInfiniteScroll(); window.addEventListener('rantii:block-change', this.blockChangeHandler); window.addEventListener('scroll', this.scrollHandler); } handleClick(e) { const sortTab = e.target.closest('.sort-tab'); const loadMoreBtn = e.target.closest('.load-more-btn'); if (sortTab) { const newSort = sortTab.dataset.sort; if (newSort !== this.sort) { this.sort = newSort; this.setAttr('sort', newSort); this.load(true); } return; } if (loadMoreBtn) { this.load(); } } setupInfiniteScroll() { const observer = new IntersectionObserver( (entries) => { entries.forEach(entry => { if (entry.isIntersecting && !this.isLoading && this.hasMore) { this.load(); } }); }, { rootMargin: '200px' } ); this.intersectionObserver = observer; } async onConnected() { const restored = await this.restoreScrollState(); if (!restored) { this.load(true); } } onDisconnected() { window.removeEventListener('scroll', this.scrollHandler); if (this.intersectionObserver) { this.intersectionObserver.disconnect(); } this.isLoading = false; this.hasMore = true; window.removeEventListener('rantii:block-change', this.blockChangeHandler); } onAttributeChanged(name, oldValue, newValue) { if (name === 'sort' && oldValue !== newValue) { this.sort = newValue; this.clearScrollState(); this.load(true); } if (name === 'feed-type' && oldValue !== newValue) { this.feedType = newValue; this.clearScrollState(); this.load(true); } } setSort(sort) { this.sort = sort; this.setAttr('sort', sort); this.clearScrollState(); this.load(true); } setFeedType(type) { this.feedType = type; this.setAttr('feed-type', type); this.clearScrollState(); this.load(true); } refresh() { this.load(true); } getRants() { return this.rants; } } customElements.define('rant-feed', RantFeed); export { RantFeed };