feat: add lazy loading, skeleton cards, and pull-to-refresh
This commit is contained in:
@@ -93,8 +93,10 @@ class ListingCard extends HTMLElement {
|
||||
|
||||
this.innerHTML = /* html */`
|
||||
<a href="#/listing/${escapeHTML(id)}" class="listing-link">
|
||||
<div class="listing-image" ${image ? `style="background-image: url('${escapeHTML(image)}')"` : ''}>
|
||||
${!image ? placeholderSvg : ''}
|
||||
<div class="listing-image">
|
||||
${image
|
||||
? `<img src="${escapeHTML(image)}" alt="${escapeHTML(title)}" loading="lazy">`
|
||||
: placeholderSvg}
|
||||
</div>
|
||||
<div class="listing-info">
|
||||
<h3 class="listing-title">${escapeHTML(title)}</h3>
|
||||
@@ -174,11 +176,16 @@ style.textContent = /* css */`
|
||||
listing-card .listing-image {
|
||||
aspect-ratio: 1;
|
||||
background: var(--color-bg-tertiary);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
listing-card .listing-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
listing-card .listing-image .placeholder-icon {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { listingsService } from '../../services/listings.js'
|
||||
import { directus } from '../../services/directus.js'
|
||||
import { locationsService } from '../../services/locations.js'
|
||||
import '../listing-card.js'
|
||||
import '../skeleton-card.js'
|
||||
import '../search-box.js'
|
||||
|
||||
class PageHome extends HTMLElement {
|
||||
@@ -31,6 +32,7 @@ class PageHome extends HTMLElement {
|
||||
this.parseUrlParams()
|
||||
this.render()
|
||||
this.setupEventListeners()
|
||||
this.setupPullToRefresh()
|
||||
this.loadListings()
|
||||
this.unsubscribe = i18n.subscribe(() => {
|
||||
this.render()
|
||||
@@ -261,6 +263,65 @@ class PageHome extends HTMLElement {
|
||||
})
|
||||
}
|
||||
|
||||
setupPullToRefresh() {
|
||||
let startY = 0
|
||||
let pulling = false
|
||||
const threshold = 80
|
||||
|
||||
const indicator = document.createElement('div')
|
||||
indicator.className = 'pull-indicator'
|
||||
indicator.innerHTML = `<div class="pull-spinner"></div><span>${t('home.pullToRefresh')}</span>`
|
||||
this.prepend(indicator)
|
||||
|
||||
this.addEventListener('touchstart', (e) => {
|
||||
if (window.scrollY === 0) {
|
||||
startY = e.touches[0].clientY
|
||||
pulling = true
|
||||
}
|
||||
}, { passive: true })
|
||||
|
||||
this.addEventListener('touchmove', (e) => {
|
||||
if (!pulling) return
|
||||
|
||||
const currentY = e.touches[0].clientY
|
||||
const diff = currentY - startY
|
||||
|
||||
if (diff > 0 && diff < threshold * 2) {
|
||||
indicator.style.transform = `translateY(${Math.min(diff / 2, threshold)}px)`
|
||||
indicator.style.opacity = Math.min(diff / threshold, 1)
|
||||
|
||||
if (diff > threshold) {
|
||||
indicator.classList.add('ready')
|
||||
} else {
|
||||
indicator.classList.remove('ready')
|
||||
}
|
||||
}
|
||||
}, { passive: true })
|
||||
|
||||
this.addEventListener('touchend', () => {
|
||||
if (!pulling) return
|
||||
pulling = false
|
||||
|
||||
if (indicator.classList.contains('ready')) {
|
||||
indicator.classList.add('refreshing')
|
||||
indicator.classList.remove('ready')
|
||||
|
||||
// Refresh
|
||||
this.page = 1
|
||||
this.listings = []
|
||||
this.hasMore = true
|
||||
this.loadListings().then(() => {
|
||||
indicator.classList.remove('refreshing')
|
||||
indicator.style.transform = ''
|
||||
indicator.style.opacity = ''
|
||||
})
|
||||
} else {
|
||||
indicator.style.transform = ''
|
||||
indicator.style.opacity = ''
|
||||
}
|
||||
}, { passive: true })
|
||||
}
|
||||
|
||||
updateListingsSection() {
|
||||
const listingsContainer = this.querySelector('#listings-container')
|
||||
if (listingsContainer) {
|
||||
@@ -360,12 +421,8 @@ class PageHome extends HTMLElement {
|
||||
|
||||
renderListings() {
|
||||
if (this.loading) {
|
||||
return /* html */`
|
||||
<div class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>${t('common.loading')}</p>
|
||||
</div>
|
||||
`
|
||||
// Show skeleton cards while loading
|
||||
return Array(8).fill(0).map(() => '<skeleton-card></skeleton-card>').join('')
|
||||
}
|
||||
|
||||
if (this.error) {
|
||||
@@ -592,5 +649,45 @@ style.textContent = /* css */`
|
||||
height: 30px;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Pull to Refresh */
|
||||
page-home .pull-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-60px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 100;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
page-home .pull-indicator.ready {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
page-home .pull-indicator.ready span::after {
|
||||
content: ' ↓';
|
||||
}
|
||||
|
||||
page-home .pull-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
page-home .pull-indicator.refreshing .pull-spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
|
||||
79
js/components/skeleton-card.js
Normal file
79
js/components/skeleton-card.js
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Skeleton Card Component
|
||||
* Shows a loading placeholder for listing cards
|
||||
*/
|
||||
|
||||
class SkeletonCard extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this.render()
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = /* html */`
|
||||
<div class="skeleton-image"></div>
|
||||
<div class="skeleton-info">
|
||||
<div class="skeleton-title"></div>
|
||||
<div class="skeleton-price"></div>
|
||||
<div class="skeleton-location"></div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('skeleton-card', SkeletonCard)
|
||||
|
||||
const style = document.createElement('style')
|
||||
style.textContent = /* css */`
|
||||
skeleton-card {
|
||||
display: block;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
skeleton-card .skeleton-image {
|
||||
aspect-ratio: 1;
|
||||
background: var(--color-bg-tertiary);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
skeleton-card .skeleton-info {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
skeleton-card .skeleton-title,
|
||||
skeleton-card .skeleton-price,
|
||||
skeleton-card .skeleton-location {
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
skeleton-card .skeleton-title {
|
||||
height: 1rem;
|
||||
width: 80%;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
skeleton-card .skeleton-price {
|
||||
height: 1rem;
|
||||
width: 50%;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
skeleton-card .skeleton-location {
|
||||
height: 0.75rem;
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
Reference in New Issue
Block a user