diff --git a/.env.example b/.env.example index 7067e659..22a81829 100644 --- a/.env.example +++ b/.env.example @@ -29,12 +29,14 @@ KITHE_BRIDGE_VERIFY_SSL=true # ============================================================================= APPLICATION_URL=http://localhost:8000/api/v1/ +# Host port published by the Docker Compose frontend service. +FRONTEND_PORT=5173 # Base URL for the Geoportal (resource pages). Used for citation URLs. # When unset, derived from APPLICATION_URL by stripping /api/v1. -# GEOPORTAL_BASE_URL=http://localhost:3000 +# GEOPORTAL_BASE_URL=http://localhost:5173 LOG_PATH=logs -# Comma-separated CORS origins (use * for all). Local dev: 3000 (React Router) or 5173 (Vite) -CORS_ORIGINS=http://localhost:3000,http://localhost:5173 +# Comma-separated CORS origins (use * for all). Local dev: 5173 (Docker/Vite) or 3000 (direct npm) +CORS_ORIGINS=http://localhost:5173,http://localhost:3000 # Enable crawler indexing and advertise the sitemap in robots.txt. # Keep this false for local/dev/staging environments. SEARCH_ENGINE_INDEXING_ENABLED=false diff --git a/README.md b/README.md index 5b3001e0..c803ade0 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ workflows, and future agent-facing discovery tools. ## Local Proxy The local browser-facing proxy is provided by the React Router frontend dev -server at `http://localhost:3000`. It is not a separate service: start the +server at `http://localhost:5173`. It is not a separate service: start the normal Docker stack and open the frontend URL. ```bash @@ -74,18 +74,18 @@ but the frontend uses same-origin proxy routes for request paths that should run through server-side loaders, carry the server-side API key, or avoid browser CORS/rate-limit surprises. Common local proxy routes include: -- `http://localhost:3000/search/results` -> `/api/v1/search` -- `http://localhost:3000/search/facets/:facetName` -> +- `http://localhost:5173/search/results` -> `/api/v1/search` +- `http://localhost:5173/search/facets/:facetName` -> `/api/v1/search/facets/:facetName` -- `http://localhost:3000/map/h3` -> `/api/v1/map/h3` -- `http://localhost:3000/home/blog-posts` -> `/api/v1/home/blog-posts` -- `http://localhost:3000/places/suggest` -> `/api/v1/places/suggest` -- `http://localhost:3000/resources/:id/thumbnail` -> +- `http://localhost:5173/map/h3` -> `/api/v1/map/h3` +- `http://localhost:5173/home/blog-posts` -> `/api/v1/home/blog-posts` +- `http://localhost:5173/places/suggest` -> `/api/v1/places/suggest` +- `http://localhost:5173/resources/:id/thumbnail` -> `/api/v1/resources/:id/thumbnail` -- `http://localhost:3000/resources/:id/static-map` -> +- `http://localhost:5173/resources/:id/static-map` -> `/api/v1/resources/:id/static-map` -- `http://localhost:3000/static-maps/...` and - `http://localhost:3000/thumbnails/...` for generated map and thumbnail assets +- `http://localhost:5173/static-maps/...` and + `http://localhost:5173/thumbnails/...` for generated map and thumbnail assets There are two API base URLs to keep straight in local development: diff --git a/docker-compose.yml b/docker-compose.yml index 4f5b392d..b21ffd46 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -104,7 +104,7 @@ services: - ./frontend/eslint.config.js:/app/eslint.config.js - ./frontend/vitest.config.ts:/app/vitest.config.ts ports: - - "3000:3000" + - "${FRONTEND_PORT:-5173}:3000" depends_on: api: condition: service_started diff --git a/docs/README.md b/docs/README.md index c6a3011c..79feef70 100644 --- a/docs/README.md +++ b/docs/README.md @@ -93,7 +93,7 @@ docker compose down Local URLs: -- Frontend: `http://localhost:3000` +- Frontend: `http://localhost:5173` - API docs: `http://localhost:8000/api/docs` - OpenAPI JSON: `http://localhost:8000/api/openapi.json` - Flower: `http://localhost:5555` diff --git a/docs/backend/turnstile.md b/docs/backend/turnstile.md index 30018fc6..36b85338 100644 --- a/docs/backend/turnstile.md +++ b/docs/backend/turnstile.md @@ -97,7 +97,7 @@ traffic can still use the server-side API key without receiving `turnstile_required`. The frontend stores the returned session token in `sessionStorage` so local -cross-origin dev traffic from `localhost:3000` to `localhost:8000` can carry +cross-origin Docker traffic from `localhost:5173` to `localhost:8000` can carry `X-Turnstile-Session` without relying on cross-origin cookies. Production same-origin traffic also receives an HttpOnly cookie. @@ -105,7 +105,7 @@ To review the branded gate shell without Cloudflare keys or backend Turnstile verification, run the Vite dev server and open: ```text -http://localhost:3000/turnstile-preview +http://localhost:5173/turnstile-preview ``` This route only works in Vite dev mode. It renders the same BTAA Geoportal gate diff --git a/docs/development.md b/docs/development.md index 3aa49a43..ef43d698 100644 --- a/docs/development.md +++ b/docs/development.md @@ -26,7 +26,7 @@ with one command. 4. Open the local services: - - Website: `http://localhost:3000` + - Website: `http://localhost:5173` - API docs: `http://localhost:8000/api/docs` - Flower: `http://localhost:5555` @@ -43,7 +43,7 @@ The local stack is defined in `docker-compose.yml`. | Service | Container name | Purpose | | --- | --- | --- | | `api` | `btaa-geospatial-api-app` | FastAPI app on port 8000 | -| `frontend` | `btaa-geospatial-api-frontend` | Vite/React dev server on port 3000 | +| `frontend` | `btaa-geospatial-api-frontend` | Vite/React dev server published on port 5173 | | `elasticsearch` | `btaa-geospatial-api-elasticsearch` | Elasticsearch on port 9200 | | `paradedb` | `btaa-geospatial-api-paradedb` | PostgreSQL/ParadeDB | | `redis` | `btaa-geospatial-api-redis` | Redis cache and Celery broker | diff --git a/docs/frontend/README.md b/docs/frontend/README.md index 4916dff4..d38819d6 100644 --- a/docs/frontend/README.md +++ b/docs/frontend/README.md @@ -1,8 +1,8 @@ # Frontend Documentation The frontend is the React/TypeScript public Geoportal interface for this -repository. It runs locally as the `frontend` Docker Compose service on port -3000, or directly with npm from `frontend/`. +repository. It runs locally as the `frontend` Docker Compose service on host +port 5173, or directly with npm on port 3000 from `frontend/`. ## Available Documentation diff --git a/frontend/src/__tests__/geoblacklight/layer_index_map.test.ts b/frontend/src/__tests__/geoblacklight/layer_index_map.test.ts new file mode 100644 index 00000000..e4108c1f --- /dev/null +++ b/frontend/src/__tests__/geoblacklight/layer_index_map.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + availabilityStyle, + normalizeIndexMapProperties, + updateInformation, +} from '../../geoblacklight/layer_index_map'; + +describe('OpenIndexMap v1 information', () => { + beforeEach(() => { + document.body.innerHTML = '
'; + }); + + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('normalizes v1 link, thumbnail, and identifier property names', () => { + expect( + normalizeIndexMapProperties({ + thumbUrl: 'https://example.com/thumb.jpg', + download: 'https://example.com/map.tif', + recId: 'map-27', + }) + ).toMatchObject({ + thumbnailUrl: 'https://example.com/thumb.jpg', + downloadUrl: 'https://example.com/map.tif', + recordIdentifier: 'map-27', + }); + }); + + it('renders v1 thumbnails and links for a selected feature', async () => { + await updateInformation({ + title: 'Jaguaribe', + label: 'SB 24', + websiteUrl: 'https://example.com/item', + download: 'https://example.com/map.tif', + digHold: 'https://example.com/digital-holdings', + physHold: 'Held in the map library', + iiifUrl: 'https://example.com/manifest.json', + thumbUrl: 'https://example.com/thumb.jpg', + recId: 'am002175', + }); + + const information = document.querySelector('.viewer-information'); + const image = information?.querySelector('img'); + const links = Array.from(information?.querySelectorAll('a') || []); + + expect(image).toHaveAttribute('src', 'https://example.com/thumb.jpg'); + expect(image).toHaveAttribute('alt', 'Thumbnail for Jaguaribe'); + expect(links.map((link) => link.getAttribute('href'))).toEqual( + expect.arrayContaining([ + 'https://example.com/item', + 'https://example.com/map.tif', + 'https://example.com/digital-holdings', + 'https://example.com/manifest.json', + ]) + ); + expect(information).toHaveTextContent('Record identifier'); + expect(information).toHaveTextContent('am002175'); + expect(information).toHaveTextContent('Held in the map library'); + }); + + it('renders digHold when it is the feature’s only item link', async () => { + await updateInformation({ + label: '19,20', + digHold: 'https://collections.example.edu/digital/item/25010', + recId: 'am002722', + }); + + const information = document.querySelector('.viewer-information'); + const link = information?.querySelector('a'); + + expect(information).toHaveTextContent('Digital holdings'); + expect(link).toHaveAttribute( + 'href', + 'https://collections.example.edu/digital/item/25010' + ); + }); + + it('continues to support legacy OpenIndexMap property names', () => { + expect( + normalizeIndexMapProperties({ + thumbnailUrl: 'https://example.com/legacy-thumb.jpg', + downloadUrl: 'https://example.com/legacy-map.tif', + recordIdentifier: 'legacy-1', + }) + ).toMatchObject({ + thumbnailUrl: 'https://example.com/legacy-thumb.jpg', + downloadUrl: 'https://example.com/legacy-map.tif', + recordIdentifier: 'legacy-1', + }); + }); + + it('uses a IIIF manifest thumbnail when thumbUrl is absent', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + thumbnail: [{ id: 'https://example.com/iiif-thumb.jpg' }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ); + + await updateInformation({ + label: '27', + iiifUrl: 'https://example.com/manifest.json', + }); + + expect(document.querySelector('.viewer-information img')).toHaveAttribute( + 'src', + 'https://example.com/iiif-thumb.jpg' + ); + }); + + it('escapes remote text and does not link unsafe URL schemes', async () => { + await updateInformation({ + title: '