wrolpi/app/src/components/MapSearchView.js
Roland 6297507859
ui: widen a card's track by 25%
`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>
2026-08-08 18:05:51 -06:00

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>;
}