diff --git a/.forgejo/workflows/docker-publish.yml b/.forgejo/workflows/docker-publish.yml index 3a58c2d..542abe0 100644 --- a/.forgejo/workflows/docker-publish.yml +++ b/.forgejo/workflows/docker-publish.yml @@ -12,6 +12,13 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + # The product-demo videos and their poster JPGs live in Git LFS + # (see .gitattributes). Without this, checkout writes ~130-byte LFS + # pointer text files, the Docker build copies those stubs into + # frontend/dist/video/*, and the server serves text as video/mp4 — + # so the videos never load in production. Smudge the real binaries. + with: + lfs: true - name: Build and publish main image uses: http://forgejo:3000/andras/ci-actions/docker-publish@main diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 97eabf6..54a90df 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -209,6 +209,11 @@ export default function App() { }, []); const initialRoute = useMemo(() => pathToPage(window.location.pathname), []); const [mapUrlState, setMapUrlState] = useState(urlState); + // IP-derived initial map centre (fetched from /api/geo on a fresh visit). Null + // until resolved; `geoChecked` flips once the lookup settles (or times out) so + // the dashboard map mounts with the right centre instead of a London flash. + const [ipViewState, setIpViewState] = useState(null); + const [geoChecked, setGeoChecked] = useState(() => urlState.hasExplicitView); const [dashboardRouteKey, setDashboardRouteKey] = useState(() => window.location.pathname === '/dashboard' ? window.location.search : '' ); @@ -220,8 +225,11 @@ export default function App() { ); const activePageRef = useRef('home'); const initialViewState = useMemo( - () => mapUrlState.viewState || INITIAL_VIEW_STATE, - [mapUrlState.viewState] + () => + mapUrlState.hasExplicitView + ? mapUrlState.viewState + : (ipViewState ?? INITIAL_VIEW_STATE), + [mapUrlState.hasExplicitView, mapUrlState.viewState, ipViewState] ); const isScreenshotMode = useMemo(() => { @@ -416,6 +424,44 @@ export default function App() { return () => controller.abort(); }, []); + // On a fresh visit (no view in the URL), centre the initial map on the visitor's + // approximate location from their IP. Falls back to INITIAL_VIEW_STATE (inner + // London) when geolocation is unavailable. Skipped when the URL already carries a + // view (shared/dashboard links) so we never override it. `geoChecked` gates the + // dashboard map render so it mounts once with the resolved centre. + useEffect(() => { + if (urlState.hasExplicitView) return; // geoChecked already initialised true + let cancelled = false; + const controller = new AbortController(); + const finish = () => { + if (!cancelled) setGeoChecked(true); + }; + // Never block the map for more than this if the lookup is slow/hangs. + const timeout = window.setTimeout(finish, 1500); + fetch(apiUrl('geo'), { signal: controller.signal }) + .then((res) => (res.ok ? res.json() : null)) + .then((data: { center?: { lat: number; lon: number } } | null) => { + if (cancelled || !data?.center) return; + setIpViewState({ + ...INITIAL_VIEW_STATE, + latitude: data.center.lat, + longitude: data.center.lon, + }); + }) + .catch(() => {}) + .finally(() => { + window.clearTimeout(timeout); + finish(); + }); + return () => { + cancelled = true; + window.clearTimeout(timeout); + controller.abort(); + }; + // Mount-only: resolve the IP-based initial view exactly once. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const navigateTo = useCallback( (page: Page, hash?: string, infoFeature?: string) => { const targetHash = normalizeHash(hash); @@ -723,6 +769,8 @@ export default function App() { refreshAuth(); }} /> + ) : !geoChecked ? ( + ) : (