perf: lighthouse optimizations - inline critical CSS, lazy-load routes, WebP images, fix CLS and contrast
This commit is contained in:
@@ -30,7 +30,10 @@ async function initApp() {
|
||||
|
||||
await import('./components/app-shell.js')
|
||||
|
||||
document.getElementById('app').innerHTML = '<app-shell></app-shell>'
|
||||
const appEl = document.getElementById('app')
|
||||
if (!appEl.querySelector('app-shell')) {
|
||||
appEl.innerHTML = '<app-shell></app-shell>'
|
||||
}
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
try {
|
||||
|
||||
@@ -9,7 +9,10 @@ class AppFooter extends HTMLElement {
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
this.render()
|
||||
if (!this.querySelector('.footer-inner')) {
|
||||
this.render()
|
||||
}
|
||||
this.updateTextContent()
|
||||
await this.loadXmrRates()
|
||||
window.addEventListener('currency-changed', this.handleCurrencyChange)
|
||||
}
|
||||
@@ -65,8 +68,14 @@ class AppFooter extends HTMLElement {
|
||||
`
|
||||
}
|
||||
|
||||
updateTextContent() {
|
||||
this.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
el.textContent = t(el.dataset.i18n)
|
||||
})
|
||||
}
|
||||
|
||||
updateTranslations() {
|
||||
this.render()
|
||||
this.updateTextContent()
|
||||
if (this.rates) this.updateRateDisplay()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,20 +3,6 @@ import { i18n } from '../i18n.js'
|
||||
import { auth } from '../services/auth.js'
|
||||
import './app-header.js'
|
||||
import './app-footer.js'
|
||||
import './auth-modal.js'
|
||||
import './pages/page-home.js'
|
||||
import './pages/page-listing.js'
|
||||
import './pages/page-create.js'
|
||||
import './pages/page-favorites.js'
|
||||
import './pages/page-my-listings.js'
|
||||
import './pages/page-messages.js'
|
||||
import './pages/page-settings.js'
|
||||
import './pages/page-notifications.js'
|
||||
import './pages/page-not-found.js'
|
||||
import './pages/page-privacy.js'
|
||||
import './pages/page-terms.js'
|
||||
import './pages/page-about.js'
|
||||
import './pages/page-contact.js'
|
||||
|
||||
class AppShell extends HTMLElement {
|
||||
constructor() {
|
||||
@@ -25,8 +11,17 @@ class AppShell extends HTMLElement {
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render()
|
||||
if (!this.querySelector('#router-outlet')) {
|
||||
this.innerHTML = /* html */`
|
||||
<app-header></app-header>
|
||||
<main class="container" id="router-outlet"></main>
|
||||
<app-footer></app-footer>
|
||||
<div id="auth-modal-slot"></div>
|
||||
`
|
||||
}
|
||||
this.main = this.querySelector('#router-outlet')
|
||||
this.setupRouter()
|
||||
this.loadAuthModal()
|
||||
|
||||
i18n.subscribe(() => {
|
||||
this.querySelector('app-header').updateTranslations()
|
||||
@@ -34,35 +29,32 @@ class AppShell extends HTMLElement {
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = /* html */`
|
||||
<app-header></app-header>
|
||||
<main class="container" id="router-outlet"></main>
|
||||
<app-footer></app-footer>
|
||||
<auth-modal hidden></auth-modal>
|
||||
`
|
||||
|
||||
this.main = this.querySelector('#router-outlet')
|
||||
async loadAuthModal() {
|
||||
await import('./auth-modal.js')
|
||||
this.querySelector('#auth-modal-slot').innerHTML = '<auth-modal hidden></auth-modal>'
|
||||
}
|
||||
|
||||
setupRouter() {
|
||||
router.setOutlet(this.main)
|
||||
|
||||
const lazy = (path) => () => import(path)
|
||||
|
||||
router.setNotFoundLoader(lazy('./pages/page-not-found.js'))
|
||||
router
|
||||
.register('/', 'page-home')
|
||||
.register('/search', 'page-home') // Redirect search to home
|
||||
.register('/listing/:id', 'page-listing')
|
||||
.register('/create', 'page-create')
|
||||
.register('/edit/:id', 'page-create')
|
||||
.register('/favorites', 'page-favorites')
|
||||
.register('/my-listings', 'page-my-listings')
|
||||
.register('/messages', 'page-messages')
|
||||
.register('/settings', 'page-settings')
|
||||
.register('/notifications', 'page-notifications')
|
||||
.register('/privacy', 'page-privacy')
|
||||
.register('/terms', 'page-terms')
|
||||
.register('/about', 'page-about')
|
||||
.register('/contact', 'page-contact')
|
||||
.register('/', 'page-home', lazy('./pages/page-home.js'))
|
||||
.register('/search', 'page-home', lazy('./pages/page-home.js'))
|
||||
.register('/listing/:id', 'page-listing', lazy('./pages/page-listing.js'))
|
||||
.register('/create', 'page-create', lazy('./pages/page-create.js'))
|
||||
.register('/edit/:id', 'page-create', lazy('./pages/page-create.js'))
|
||||
.register('/favorites', 'page-favorites', lazy('./pages/page-favorites.js'))
|
||||
.register('/my-listings', 'page-my-listings', lazy('./pages/page-my-listings.js'))
|
||||
.register('/messages', 'page-messages', lazy('./pages/page-messages.js'))
|
||||
.register('/settings', 'page-settings', lazy('./pages/page-settings.js'))
|
||||
.register('/notifications', 'page-notifications', lazy('./pages/page-notifications.js'))
|
||||
.register('/privacy', 'page-privacy', lazy('./pages/page-privacy.js'))
|
||||
.register('/terms', 'page-terms', lazy('./pages/page-terms.js'))
|
||||
.register('/about', 'page-about', lazy('./pages/page-about.js'))
|
||||
.register('/contact', 'page-contact', lazy('./pages/page-contact.js'))
|
||||
|
||||
router.handleRouteChange()
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ let cachedRates = null
|
||||
|
||||
class ListingCard extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ['listing-id', 'title', 'price', 'currency', 'location', 'image', 'owner-id', 'payment-status', 'status']
|
||||
return ['listing-id', 'title', 'price', 'currency', 'location', 'image', 'owner-id', 'payment-status', 'status', 'priority']
|
||||
}
|
||||
|
||||
constructor() {
|
||||
@@ -141,7 +141,7 @@ class ListingCard extends HTMLElement {
|
||||
<${linkTag} ${linkAttr} class="listing-link">
|
||||
<div class="listing-image">
|
||||
${image
|
||||
? `<img src="${escapeHTML(image)}" alt="${escapeHTML(title)}" loading="lazy">`
|
||||
? `<img src="${escapeHTML(image)}" alt="${escapeHTML(title)}"${this.hasAttribute('priority') ? ' fetchpriority="high"' : ' loading="lazy"'}>`
|
||||
: placeholderSvg}
|
||||
${paymentBadge}
|
||||
</div>
|
||||
|
||||
@@ -105,7 +105,7 @@ class PageFavorites extends HTMLElement {
|
||||
|
||||
return this.listings.map(listing => {
|
||||
const imageId = listing.images?.[0]?.directus_files_id?.id || listing.images?.[0]?.directus_files_id
|
||||
const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 300) : ''
|
||||
const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 180) : ''
|
||||
const locationName = listing.location?.name || ''
|
||||
|
||||
return /* html */`
|
||||
|
||||
@@ -243,6 +243,7 @@ class PageHome extends HTMLElement {
|
||||
|
||||
if (this.page === 1) {
|
||||
this.listings = newItems
|
||||
this.preloadLcpImage(newItems)
|
||||
} else {
|
||||
this.listings = [...this.listings, ...newItems]
|
||||
}
|
||||
@@ -261,6 +262,21 @@ class PageHome extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
preloadLcpImage(listings) {
|
||||
const first = listings[0]
|
||||
if (!first) return
|
||||
const imageId = first.images?.[0]?.directus_files_id?.id || first.images?.[0]?.directus_files_id
|
||||
if (!imageId) return
|
||||
const url = directus.getThumbnailUrl(imageId, 180)
|
||||
if (document.querySelector(`link[rel="preload"][href="${url}"]`)) return
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'preload'
|
||||
link.as = 'image'
|
||||
link.href = url
|
||||
link.fetchPriority = 'high'
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
|
||||
sortByDistance(listings) {
|
||||
if (!this.hasUserLocation()) return listings
|
||||
|
||||
@@ -419,6 +435,7 @@ class PageHome extends HTMLElement {
|
||||
${showFilters ? `<h2 class="listings-title">${this.getListingsTitle()}</h2>` : ''}
|
||||
<div class="listings-toolbar">
|
||||
<div class="sort-inline">
|
||||
<label for="sort-select" class="sr-only">${t('search.sortBy')}</label>
|
||||
<select id="sort-select">
|
||||
${this.hasUserLocation() ? `<option value="distance" ${this.sort === 'distance' ? 'selected' : ''}>${t('search.sortDistance')}</option>` : ''}
|
||||
<option value="newest" ${this.sort === 'newest' ? 'selected' : ''}>${t('search.sortNewest')}</option>
|
||||
@@ -503,9 +520,9 @@ class PageHome extends HTMLElement {
|
||||
`
|
||||
}
|
||||
|
||||
const listingsHtml = this.listings.map(listing => {
|
||||
const listingsHtml = this.listings.map((listing, index) => {
|
||||
const imageId = listing.images?.[0]?.directus_files_id?.id || listing.images?.[0]?.directus_files_id
|
||||
const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 300) : ''
|
||||
const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 180) : ''
|
||||
const locationName = listing.location?.name || ''
|
||||
|
||||
return /* html */`
|
||||
@@ -517,6 +534,7 @@ class PageHome extends HTMLElement {
|
||||
location="${escapeHTML(locationName)}"
|
||||
image="${imageUrl}"
|
||||
owner-id="${listing.user_created || ''}"
|
||||
${index < 4 ? 'priority' : ''}
|
||||
></listing-card>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
@@ -416,7 +416,7 @@ class PageListing extends HTMLElement {
|
||||
|
||||
renderListingCard(listing) {
|
||||
const imageId = listing.images?.[0]?.directus_files_id?.id || listing.images?.[0]?.directus_files_id
|
||||
const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 300) : ''
|
||||
const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 180) : ''
|
||||
const locationName = listing.location?.name || ''
|
||||
|
||||
return /* html */`
|
||||
|
||||
@@ -237,7 +237,7 @@ class PageMyListings extends HTMLElement {
|
||||
|
||||
const html = this.listings.map(listing => {
|
||||
const imageId = listing.images?.[0]?.directus_files_id?.id || listing.images?.[0]?.directus_files_id
|
||||
const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 300) : ''
|
||||
const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 180) : ''
|
||||
const locationName = listing.location?.name || ''
|
||||
const statusBadge = this.getStatusBadge(listing)
|
||||
|
||||
|
||||
@@ -336,6 +336,7 @@ class SearchBox extends HTMLElement {
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
|
||||
<circle cx="12" cy="10" r="3"></circle>
|
||||
</svg>
|
||||
<label for="country-select-mobile" class="sr-only">${t('search.currentLocation')}</label>
|
||||
<select id="country-select-mobile">
|
||||
<option value="current" ${this.useCurrentLocation ? 'selected' : ''}>
|
||||
📍 ${t('search.currentLocation')}
|
||||
|
||||
31
js/router.js
31
js/router.js
@@ -42,10 +42,11 @@ class Router {
|
||||
* Register a route
|
||||
* @param {string} path - Route path (e.g., '/listing/:id')
|
||||
* @param {string} componentTag - Web component tag name
|
||||
* @param {Function} [loader] - Optional dynamic import function for lazy loading
|
||||
* @returns {Router} this for chaining
|
||||
*/
|
||||
register(path, componentTag) {
|
||||
this.routes.set(path, componentTag)
|
||||
register(path, componentTag, loader) {
|
||||
this.routes.set(path, { componentTag, loader })
|
||||
return this
|
||||
}
|
||||
|
||||
@@ -70,10 +71,11 @@ class Router {
|
||||
*/
|
||||
matchRoute(path) {
|
||||
if (this.routes.has(path)) {
|
||||
return { componentTag: this.routes.get(path), params: {} }
|
||||
const { componentTag, loader } = this.routes.get(path)
|
||||
return { componentTag, loader, params: {} }
|
||||
}
|
||||
|
||||
for (const [routePath, componentTag] of this.routes) {
|
||||
for (const [routePath, route] of this.routes) {
|
||||
const routeParts = routePath.split('/')
|
||||
const pathParts = path.split('/')
|
||||
|
||||
@@ -92,7 +94,7 @@ class Router {
|
||||
}
|
||||
|
||||
if (match) {
|
||||
return { componentTag, params }
|
||||
return { componentTag: route.componentTag, loader: route.loader, params }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +119,11 @@ class Router {
|
||||
return
|
||||
}
|
||||
|
||||
const { componentTag, params: routeParams } = match
|
||||
const { componentTag, loader, params: routeParams } = match
|
||||
|
||||
if (loader && !customElements.get(componentTag)) {
|
||||
await loader()
|
||||
}
|
||||
|
||||
this.currentRoute = {
|
||||
path,
|
||||
@@ -171,14 +177,25 @@ class Router {
|
||||
}
|
||||
|
||||
/** @private */
|
||||
renderNotFound() {
|
||||
async renderNotFound() {
|
||||
if (!this.outlet) return
|
||||
|
||||
if (!customElements.get('page-not-found') && this._notFoundLoader) {
|
||||
await this._notFoundLoader()
|
||||
}
|
||||
this.outlet.innerHTML = ''
|
||||
const notFound = document.createElement('page-not-found')
|
||||
this.outlet.appendChild(notFound)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the loader for the 404 page
|
||||
* @param {Function} loader - Dynamic import function
|
||||
*/
|
||||
setNotFoundLoader(loader) {
|
||||
this._notFoundLoader = loader
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a path
|
||||
* @param {string} path - Target path
|
||||
|
||||
@@ -816,7 +816,7 @@ class DirectusService {
|
||||
}
|
||||
|
||||
getThumbnailUrl(fileId, size = 300) {
|
||||
return this.getFileUrl(fileId, { width: size, height: size, fit: 'cover' })
|
||||
return this.getFileUrl(fileId, { width: size, height: size, fit: 'cover', format: 'webp', quality: 80 })
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user