diff --git a/js/components/listing-card.js b/js/components/listing-card.js index 89669a3..5270167 100644 --- a/js/components/listing-card.js +++ b/js/components/listing-card.js @@ -93,8 +93,10 @@ class ListingCard extends HTMLElement { this.innerHTML = /* html */` -
- ${!image ? placeholderSvg : ''} +
+ ${image + ? `${escapeHTML(title)}` + : placeholderSvg}

${escapeHTML(title)}

@@ -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 { diff --git a/js/components/pages/page-home.js b/js/components/pages/page-home.js index 5dbc33b..89f5028 100644 --- a/js/components/pages/page-home.js +++ b/js/components/pages/page-home.js @@ -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 = `
${t('home.pullToRefresh')}` + 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 */` -
-
-

${t('common.loading')}

-
- ` + // Show skeleton cards while loading + return Array(8).fill(0).map(() => '').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) diff --git a/js/components/skeleton-card.js b/js/components/skeleton-card.js new file mode 100644 index 0000000..c6f9c3f --- /dev/null +++ b/js/components/skeleton-card.js @@ -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 */` +
+
+
+
+
+
+ ` + } +} + +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) diff --git a/locales/de.json b/locales/de.json index a2b904e..7d2540f 100644 --- a/locales/de.json +++ b/locales/de.json @@ -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...", diff --git a/locales/en.json b/locales/en.json index 47f5bfc..184648d 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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...", diff --git a/locales/fr.json b/locales/fr.json index 435a8c6..137faee 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -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...",