Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95a5783da1 |
@@ -75,31 +75,6 @@ test('school hero map opens fullscreen on mobile without the Fullscreen API', as
|
|||||||
await expect(openMap).toBeVisible();
|
await expect(openMap).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('results map fullscreen falls back to an overlay on iOS', async ({ page }) => {
|
|
||||||
// Same iOS gap as the hero map: no Element.requestFullscreen, so the results
|
|
||||||
// map's fullscreen button must fall back to a CSS overlay.
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
// @ts-expect-error deliberate API removal
|
|
||||||
delete Element.prototype.requestFullscreen;
|
|
||||||
});
|
|
||||||
|
|
||||||
await searchByName(page, 'B1 1BB');
|
|
||||||
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
// Switch to the map view, then open the map fullscreen.
|
|
||||||
await page.getByRole('button', { name: 'Map', exact: true }).click();
|
|
||||||
const openFs = page.getByRole('button', { name: 'View map fullscreen' });
|
|
||||||
await expect(openFs).toBeVisible({ timeout: 15_000 });
|
|
||||||
await openFs.click();
|
|
||||||
|
|
||||||
// The button flips to its exit state once the overlay is up.
|
|
||||||
const exitFs = page.getByRole('button', { name: 'Exit fullscreen' });
|
|
||||||
await expect(exitFs).toBeVisible();
|
|
||||||
await exitFs.click();
|
|
||||||
await expect(openFs).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('comparing two schools shows both side by side', async ({ page }) => {
|
test('comparing two schools shows both side by side', async ({ page }) => {
|
||||||
// Collect two school URNs from search results, then load the share URL
|
// Collect two school URNs from search results, then load the share URL
|
||||||
await searchByName(page, 'primary');
|
await searchByName(page, 'primary');
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ COPY . .
|
|||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
# Build argument for FastAPI URL (used by Next.js rewrites at build time)
|
# Default backend URL for any server-side fetch during `next build`. The
|
||||||
|
# runtime /api proxy reads FASTAPI_URL per request (see app/api/[...path]),
|
||||||
|
# so the deployed container's env is what actually routes traffic.
|
||||||
ARG FASTAPI_URL=http://backend:80/api
|
ARG FASTAPI_URL=http://backend:80/api
|
||||||
ENV FASTAPI_URL=${FASTAPI_URL}
|
ENV FASTAPI_URL=${FASTAPI_URL}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Runtime proxy for /api/* → the FastAPI backend.
|
||||||
|
*
|
||||||
|
* This replaces the old next.config.js `rewrites()` proxy, whose destination
|
||||||
|
* was baked into the build (routes-manifest.json) from FASTAPI_URL at build
|
||||||
|
* time. Because one frontend image is promoted staging→prod, a baked hostname
|
||||||
|
* forced every environment to name the backend identically; a mismatch (e.g.
|
||||||
|
* a `backend_stg` service) produced `getaddrinfo ENOTFOUND backend`.
|
||||||
|
*
|
||||||
|
* A route handler reads process.env.FASTAPI_URL on each request, so the same
|
||||||
|
* image adapts to whatever the backend is called in each environment.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
// FASTAPI_URL already includes the `/api` suffix (e.g. http://backend:80/api).
|
||||||
|
function backendBase(): string {
|
||||||
|
return process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hop-by-hop / length headers must not be copied across a proxy — undici has
|
||||||
|
// already decoded the body, so a stale content-encoding/length corrupts it.
|
||||||
|
const STRIPPED_RESPONSE_HEADERS = ['content-encoding', 'content-length', 'transfer-encoding', 'connection'];
|
||||||
|
const METHODS_WITH_BODY = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||||
|
|
||||||
|
async function handler(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||||
|
const { path } = await ctx.params;
|
||||||
|
const target = `${backendBase()}/${path.join('/')}${req.nextUrl.search}`;
|
||||||
|
|
||||||
|
const headers = new Headers(req.headers);
|
||||||
|
headers.delete('host');
|
||||||
|
headers.delete('connection');
|
||||||
|
|
||||||
|
const init: RequestInit & { duplex?: 'half' } = {
|
||||||
|
method: req.method,
|
||||||
|
headers,
|
||||||
|
redirect: 'manual',
|
||||||
|
cache: 'no-store',
|
||||||
|
};
|
||||||
|
if (METHODS_WITH_BODY.has(req.method)) {
|
||||||
|
init.body = req.body;
|
||||||
|
init.duplex = 'half';
|
||||||
|
}
|
||||||
|
|
||||||
|
let upstream: Response;
|
||||||
|
try {
|
||||||
|
upstream = await fetch(target, init);
|
||||||
|
} catch (err) {
|
||||||
|
// e.g. DNS failure or connection refused — surface a clean 502 instead of
|
||||||
|
// an opaque proxy crash so callers can degrade gracefully.
|
||||||
|
return NextResponse.json({ detail: 'Upstream request failed' }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseHeaders = new Headers(upstream.headers);
|
||||||
|
for (const h of STRIPPED_RESPONSE_HEADERS) responseHeaders.delete(h);
|
||||||
|
|
||||||
|
return new NextResponse(upstream.body, {
|
||||||
|
status: upstream.status,
|
||||||
|
statusText: upstream.statusText,
|
||||||
|
headers: responseHeaders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
handler as GET,
|
||||||
|
handler as HEAD,
|
||||||
|
handler as POST,
|
||||||
|
handler as PUT,
|
||||||
|
handler as PATCH,
|
||||||
|
handler as DELETE,
|
||||||
|
handler as OPTIONS,
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Runtime proxy for /sitemap.xml → the FastAPI backend's generated sitemap.
|
||||||
|
*
|
||||||
|
* Like the /api/* proxy, this reads FASTAPI_URL at request time rather than
|
||||||
|
* baking the backend host into the build, so one image works in every
|
||||||
|
* environment. robots.ts points crawlers here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
function backendOrigin(): string {
|
||||||
|
const base = process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';
|
||||||
|
return base.replace(/\/api$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
let upstream: Response;
|
||||||
|
try {
|
||||||
|
upstream = await fetch(`${backendOrigin()}/sitemap.xml`, { cache: 'no-store' });
|
||||||
|
} catch {
|
||||||
|
return new NextResponse('Sitemap temporarily unavailable', { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await upstream.text();
|
||||||
|
return new NextResponse(body, {
|
||||||
|
status: upstream.status,
|
||||||
|
headers: { 'content-type': upstream.headers.get('content-type') || 'application/xml' },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,15 +10,6 @@
|
|||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fallback fullscreen (iOS Safari — no Element.requestFullscreen): the API
|
|
||||||
can't promote the element, so pin it over the page ourselves. Above the
|
|
||||||
comparison toast (3000) and the bottom nav; below modals (9999+). */
|
|
||||||
.mapWrapper.fsFallback {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 5000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullscreenBtn {
|
.fullscreenBtn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.625rem;
|
top: 0.625rem;
|
||||||
|
|||||||
@@ -33,52 +33,22 @@ interface SchoolMapProps {
|
|||||||
|
|
||||||
export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: SchoolMapProps) {
|
export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: SchoolMapProps) {
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
const [nativeFullscreen, setNativeFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
// iOS Safari has no Element.requestFullscreen — fall back to a fixed-position
|
|
||||||
// overlay driven by state instead of the Fullscreen API.
|
|
||||||
const [fallbackFullscreen, setFallbackFullscreen] = useState(false);
|
|
||||||
const isFullscreen = nativeFullscreen || fallbackFullscreen;
|
|
||||||
|
|
||||||
// Sync state with browser fullscreen events (e.g. Escape key)
|
// Sync state with browser fullscreen events (e.g. Escape key)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onFsChange = () => setNativeFullscreen(!!document.fullscreenElement);
|
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||||
document.addEventListener('fullscreenchange', onFsChange);
|
document.addEventListener('fullscreenchange', onFsChange);
|
||||||
return () => document.removeEventListener('fullscreenchange', onFsChange);
|
return () => document.removeEventListener('fullscreenchange', onFsChange);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Lock body scroll while the fallback overlay is up.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!fallbackFullscreen) return;
|
|
||||||
const prev = document.body.style.overflow;
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
return () => { document.body.style.overflow = prev; };
|
|
||||||
}, [fallbackFullscreen]);
|
|
||||||
|
|
||||||
// Leaflet re-measures on window resize (trackResize). Native fullscreen fires
|
|
||||||
// one; the CSS fallback overlay changes size without a resize event, so nudge
|
|
||||||
// Leaflet after the layout settles or the map fills only part of the screen.
|
|
||||||
useEffect(() => {
|
|
||||||
const id = requestAnimationFrame(() => window.dispatchEvent(new Event('resize')));
|
|
||||||
return () => cancelAnimationFrame(id);
|
|
||||||
}, [isFullscreen]);
|
|
||||||
|
|
||||||
const toggleFullscreen = useCallback(() => {
|
const toggleFullscreen = useCallback(() => {
|
||||||
if (document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
document.exitFullscreen().catch(() => {});
|
wrapperRef.current?.requestFullscreen();
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (fallbackFullscreen) {
|
|
||||||
setFallbackFullscreen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const el = wrapperRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
if (el.requestFullscreen) {
|
|
||||||
el.requestFullscreen().catch(() => setFallbackFullscreen(true));
|
|
||||||
} else {
|
} else {
|
||||||
setFallbackFullscreen(true);
|
document.exitFullscreen();
|
||||||
}
|
}
|
||||||
}, [fallbackFullscreen]);
|
}, []);
|
||||||
|
|
||||||
// Calculate center if not provided
|
// Calculate center if not provided
|
||||||
const mapCenter: [number, number] = center || (() => {
|
const mapCenter: [number, number] = center || (() => {
|
||||||
@@ -94,7 +64,7 @@ export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarker
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapperRef} className={`${styles.mapWrapper} ${isFullscreen ? styles.fullscreen : ''} ${fallbackFullscreen ? styles.fsFallback : ''}`}>
|
<div ref={wrapperRef} className={`${styles.mapWrapper} ${isFullscreen ? styles.fullscreen : ''}`}>
|
||||||
<button
|
<button
|
||||||
className={styles.fullscreenBtn}
|
className={styles.fullscreenBtn}
|
||||||
onClick={toggleFullscreen}
|
onClick={toggleFullscreen}
|
||||||
|
|||||||
@@ -3,21 +3,10 @@ const nextConfig = {
|
|||||||
// Enable standalone output for Docker
|
// Enable standalone output for Docker
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
|
|
||||||
// API Proxy to FastAPI backend
|
// The /api/* and /sitemap.xml proxies to the FastAPI backend are route
|
||||||
async rewrites() {
|
// handlers (app/api/[...path]/route.ts, app/sitemap.xml/route.ts) rather
|
||||||
const apiUrl = process.env.FASTAPI_URL || 'http://localhost:8000/api';
|
// than rewrites, so the backend host is read from FASTAPI_URL at runtime
|
||||||
const backendUrl = apiUrl.replace(/\/api$/, '');
|
// instead of being baked into the build.
|
||||||
return [
|
|
||||||
{
|
|
||||||
source: '/api/:path*',
|
|
||||||
destination: `${apiUrl}/:path*`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: '/sitemap.xml',
|
|
||||||
destination: `${backendUrl}/sitemap.xml`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
|
|
||||||
// Image optimization
|
// Image optimization
|
||||||
images: {
|
images: {
|
||||||
|
|||||||
Reference in New Issue
Block a user