mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
`CardGroup`'s default `minWidth` goes from 200 to 250, and the one call site that names its
own -- the map's search results -- from 220 to 275, keeping the 10% it sits above the default.
A card wall read too dense: at a 1280px window the grid ran five cards to a row where the
pre-migration Semantic build ran four, and a video title had about two words per line. At 250
it runs four cards of 300px, which is what Semantic measured at that width (four cards, 290px
each) -- so this lands on the old layout rather than near it.
Raising the default rather than each page is the point of it being a default: videos, archives,
docs, files and the gallery all take it.
Measured across the range, and no width regresses:
grid width cards/row card width poster side gap
430 1 430 0
570 2 277 0
600 2 292 0
900 3 290 0
1245 4 300 0
1600 5 308 0
A phone is unaffected -- one column below about 570px of content width either way -- and the
poster still fills the card at every width, since its cap is a fraction of the card rather than
a fixed height.
Verified: 1143 jest tests / 65 suites, clean build, screenshotted at 1280px.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
131 lines
4.5 KiB
JavaScript
131 lines
4.5 KiB
JavaScript
import React, {useEffect, useState} from "react";
|
|
import {Link, useNavigate} from "react-router";
|
|
import {getMapSearchStatus, searchMap} from "../api";
|
|
import {Card, CardGroup, Header, Loading, Panel} from "./ui";
|
|
import {Paginator} from "./Common";
|
|
import {useLatestRequest, usePages} from "../hooks/customHooks";
|
|
import {QueryContext} from "../contexts/contexts";
|
|
import {MAP_VIEWER_URI} from "./Vars";
|
|
|
|
function formatPopulation(pop) {
|
|
if (!pop) return null;
|
|
if (pop >= 1_000_000) return `${(pop / 1_000_000).toFixed(1)}M`;
|
|
if (pop >= 1_000) return `${Math.round(pop / 1_000)}K`;
|
|
return `${pop}`;
|
|
}
|
|
|
|
function MapPlaceCard({place, index}) {
|
|
const navigate = useNavigate();
|
|
const [iframeSrc, setIframeSrc] = useState(null);
|
|
|
|
const zoom = place.min_zoom ? Math.max(8, Math.round(13 - place.min_zoom / 3)) : 11;
|
|
const embedSrc = `${MAP_VIEWER_URI}/embed.html#${place.lat},${place.lon},${zoom}`;
|
|
|
|
// Stagger iframe loading to avoid overwhelming the map viewer with simultaneous requests.
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => setIframeSrc(embedSrc), 100 + index * 200);
|
|
return () => clearTimeout(timer);
|
|
}, [embedSrc, index]);
|
|
|
|
const handleClick = () => {
|
|
navigate(`/map?lat=${place.lat}&lon=${place.lon}&z=${zoom}`);
|
|
};
|
|
|
|
const subtitle = [];
|
|
if (place.region) subtitle.push(place.region);
|
|
const detail = place.kind_detail || place.kind || "";
|
|
if (detail && !place.region) subtitle.push(detail.replace(/_/g, " "));
|
|
const pop = formatPopulation(place.population);
|
|
if (pop) subtitle.push(`pop. ${pop}`);
|
|
|
|
const media = <div style={{height: 150, overflow: "hidden", position: "relative", background: "var(--head)"}}>
|
|
{iframeSrc && <iframe
|
|
src={iframeSrc}
|
|
title={place.name}
|
|
loading="lazy"
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
border: "none",
|
|
pointerEvents: "none",
|
|
}}
|
|
/>}
|
|
</div>;
|
|
|
|
return <Card
|
|
onClick={handleClick}
|
|
media={media}
|
|
title={place.name}
|
|
meta={subtitle.length > 0 ? subtitle.join(" · ") : undefined}
|
|
>
|
|
<div>{place.lat.toFixed(4)}, {place.lon.toFixed(4)}</div>
|
|
</Card>;
|
|
}
|
|
|
|
export function MapSearchView() {
|
|
const {searchParams} = React.useContext(QueryContext);
|
|
const searchStr = searchParams.get("q") || "";
|
|
const {offset, limit, activePage, setPage, totalPages, setTotal} = usePages(12);
|
|
// useLatestRequest discards stale responses and catches errors, so rapid pagination
|
|
// can't show out-of-order results and a network failure can't leave the spinner stuck.
|
|
const {data, sendRequest, loading} = useLatestRequest(300);
|
|
const [hasMaps, setHasMaps] = useState(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
(async () => {
|
|
const status = await getMapSearchStatus();
|
|
if (!cancelled) {
|
|
const installed = (status.indexed || []).length + (status.missing || []).length;
|
|
setHasMaps(installed > 0);
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!searchStr || searchStr.length < 2) {
|
|
setTotal(0);
|
|
return;
|
|
}
|
|
sendRequest(async () => await searchMap(searchStr, limit, offset));
|
|
}, [searchStr, offset, limit, sendRequest]);
|
|
|
|
const results = (data && data.results) || [];
|
|
useEffect(() => {
|
|
setTotal((data && data.total) || 0);
|
|
}, [data, setTotal]);
|
|
|
|
if (hasMaps === false) {
|
|
return <Panel>
|
|
<Header as="h4">No maps are installed.</Header>
|
|
<p>Subscribe to a map region to search for places.</p>
|
|
<Link to="/map/manage">Go to Map Manager</Link>
|
|
</Panel>;
|
|
}
|
|
|
|
if (loading) {
|
|
return <Panel><Loading/></Panel>;
|
|
}
|
|
|
|
if (!searchStr) {
|
|
return <Panel><Header as="h4">Enter a search query to find map locations.</Header></Panel>;
|
|
}
|
|
|
|
if (results.length === 0) {
|
|
return <Panel><Header as="h4">No map locations found for "{searchStr}".</Header></Panel>;
|
|
}
|
|
|
|
return <React.Fragment>
|
|
<CardGroup minWidth={275}>
|
|
{results.map((place, i) =>
|
|
<MapPlaceCard key={`${place.name}-${place.lat}-${place.lon}-${i}`} place={place} index={i}/>
|
|
)}
|
|
</CardGroup>
|
|
<div style={{marginTop: '2em'}}>
|
|
<Paginator activePage={activePage} totalPages={totalPages} onPageChange={setPage}/>
|
|
</div>
|
|
</React.Fragment>;
|
|
}
|