feat: polish latest listings carousel and stabilize map

This commit is contained in:
Tero Halla-aho 2025-11-24 21:11:07 +02:00
parent 86de2cabdf
commit 1257042a66
5 changed files with 82 additions and 31 deletions

View file

@ -229,6 +229,28 @@ p {
font-size: 13px;
}
.dot-row {
display: flex;
gap: 6px;
align-items: center;
justify-content: center;
margin-top: 4px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.4);
cursor: pointer;
transition: transform 120ms ease, background 120ms ease;
}
.dot.active {
background: var(--accent);
transform: scale(1.2);
}
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));

View file

@ -47,33 +47,24 @@ function haversineKm(a: LatLng, b: LatLng) {
return 2 * R * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
}
function loadLeaflet(): Promise<any> {
async function loadLeaflet(): Promise<any> {
if (typeof window === 'undefined') return Promise.reject();
if ((window as any).L) return Promise.resolve((window as any).L);
return new Promise((resolve, reject) => {
const existingStyle = document.querySelector('link[data-leaflet-style]');
if (!existingStyle) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
link.setAttribute('data-leaflet-style', 'true');
document.head.appendChild(link);
}
const existingScript = document.querySelector('script[data-leaflet]');
if (existingScript) {
existingScript.addEventListener('load', () => resolve((window as any).L));
existingScript.addEventListener('error', reject);
return;
}
const script = document.createElement('script');
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
script.async = true;
script.setAttribute('data-leaflet', 'true');
script.onload = () => resolve((window as any).L);
script.onerror = reject;
document.body.appendChild(script);
});
if ((window as any).L) return (window as any).L;
const linkId = 'leaflet-css';
if (!document.getElementById(linkId)) {
const link = document.createElement('link');
link.id = linkId;
link.rel = 'stylesheet';
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
document.head.appendChild(link);
}
try {
const mod = await import('leaflet');
(window as any).L = mod;
return mod;
} catch (err) {
return Promise.reject(err);
}
}
function ListingsMap({
@ -93,6 +84,7 @@ function ListingsMap({
const mapRef = useRef<any>(null);
const markersRef = useRef<any[]>([]);
const [ready, setReady] = useState(false);
const [mapError, setMapError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
@ -100,6 +92,7 @@ function ListingsMap({
.then((L) => {
if (cancelled) return;
setReady(true);
setMapError(null);
if (!mapContainerRef.current) return;
if (!mapRef.current) {
mapRef.current = L.map(mapContainerRef.current).setView([64.5, 26], 5);
@ -131,8 +124,10 @@ function ListingsMap({
mapRef.current.fitBounds(group.getBounds().pad(0.25));
}
})
.catch(() => {
.catch((err) => {
console.error('Leaflet load failed', err);
setReady(false);
setMapError('Map could not be loaded right now.');
});
return () => {
@ -151,6 +146,7 @@ function ListingsMap({
return (
<div className="map-frame">
{!ready ? <div className="map-placeholder">{loadingText}</div> : null}
{mapError ? <div className="map-placeholder">{mapError}</div> : null}
<div ref={mapContainerRef} style={{ width: '100%', height: '100%' }} />
</div>
);

View file

@ -40,23 +40,24 @@ export default function HomePage() {
const [latest, setLatest] = useState<LatestListing[]>([]);
const [activeIndex, setActiveIndex] = useState(0);
const [loadingLatest, setLoadingLatest] = useState(false);
const [paused, setPaused] = useState(false);
useEffect(() => {
setLoadingLatest(true);
fetch('/api/listings?limit=8', { cache: 'no-store' })
.then((res) => res.json())
.then((data) => setLatest(data.listings ?? []))
.then((data) => setLatest((data.listings ?? []).slice(0, 5)))
.catch(() => setLatest([]))
.finally(() => setLoadingLatest(false));
}, []);
useEffect(() => {
if (!latest.length) return;
if (!latest.length || paused) return;
const id = setInterval(() => {
setActiveIndex((prev) => (prev + 1) % latest.length);
}, 4200);
return () => clearInterval(id);
}, [latest]);
}, [latest, paused]);
useEffect(() => {
if (activeIndex >= latest.length) {
@ -126,7 +127,7 @@ export default function HomePage() {
) : !activeListing ? (
<p style={{ color: '#cbd5e1', marginTop: 10 }}>{t('mapNoResults')}</p>
) : (
<div className="latest-grid">
<div className="latest-grid" onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}>
<div className="latest-card">
{activeListing.coverImage ? (
<img src={activeListing.coverImage} alt={activeListing.title} className="latest-cover" />
@ -162,6 +163,11 @@ export default function HomePage() {
</div>
</button>
))}
<div className="dot-row">
{latest.map((_, idx) => (
<span key={idx} className={`dot ${idx === activeIndex ? 'active' : ''}`} onClick={() => setActiveIndex(idx)} />
))}
</div>
</div>
</div>
)}

25
package-lock.json generated
View file

@ -12,6 +12,7 @@
"@prisma/client": "^7.0.0",
"bcryptjs": "^3.0.3",
"jose": "^6.1.2",
"leaflet": "^1.9.4",
"next": "^14.2.32",
"nodemailer": "^7.0.10",
"pg": "^8.16.3",
@ -20,6 +21,7 @@
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/leaflet": "^1.9.21",
"@types/node": "^20.12.7",
"@types/nodemailer": "^7.0.4",
"@types/pg": "^8.15.6",
@ -2117,6 +2119,13 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json5": {
"version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
@ -2124,6 +2133,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": {
"version": "20.12.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz",
@ -5449,6 +5468,12 @@
"node": ">=0.10"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",

View file

@ -15,6 +15,7 @@
"@prisma/client": "^7.0.0",
"bcryptjs": "^3.0.3",
"jose": "^6.1.2",
"leaflet": "^1.9.4",
"next": "^14.2.32",
"nodemailer": "^7.0.10",
"pg": "^8.16.3",
@ -23,6 +24,7 @@
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/leaflet": "^1.9.21",
"@types/node": "^20.12.7",
"@types/nodemailer": "^7.0.4",
"@types/pg": "^8.15.6",