feat: add lazy loading, skeleton cards, and pull-to-refresh

This commit is contained in:
2026-02-04 11:53:12 +01:00
parent 2ffbfdf3e1
commit 3643bed7ca
6 changed files with 199 additions and 13 deletions

View File

@@ -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 {

View File

@@ -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)

View 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)

View File

@@ -25,7 +25,8 @@
"addFavorite": "Zu Favoriten hinzufügen",
"removeFavorite": "Aus Favoriten entfernen",
"noListings": "Keine Anzeigen gefunden",
"loadMore": "Mehr laden"
"loadMore": "Mehr laden",
"pullToRefresh": "Ziehen zum Aktualisieren"
},
"common": {
"loading": "Laden...",

View File

@@ -25,7 +25,8 @@
"addFavorite": "Add to favorites",
"removeFavorite": "Remove from favorites",
"noListings": "No listings found",
"loadMore": "Load more"
"loadMore": "Load more",
"pullToRefresh": "Pull to refresh"
},
"common": {
"loading": "Loading...",

View File

@@ -25,7 +25,8 @@
"addFavorite": "Ajouter aux favoris",
"removeFavorite": "Retirer des favoris",
"noListings": "Aucune annonce trouvée",
"loadMore": "Charger plus"
"loadMore": "Charger plus",
"pullToRefresh": "Tirer pour actualiser"
},
"common": {
"loading": "Chargement...",