mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
fix(maps): let users and admins enter a CARTO API key (#2060)
CARTO stamps an "API KEY REQUIRED" watermark onto every basemap tile fetched without a key since 26.08.2026, and both CartoDB presets plus the built-in fallback are CARTO. The key is free and needs no account, so add it as a setting rather than dropping the provider. carto_api_key sits next to mapbox_access_token: per user in Settings/Map, instance-wide under Admin/User Defaults, encrypted at rest, injected from CARTO_API_KEY on managed instances. Unlike Mapbox it never switches the map provider, since a raster key says nothing about the renderer. The key is appended as ?key= at render time instead of being stored in the template, so a saved URL stays portable and survives a rotation. That is also what reaches the four maps that pinned CARTO and ignored the user's tile setting: atlas, collections, the public trip share and the public journey. The two share pages read it from their token-gated payload, the same way baseCurrency already travels. Also lifts the release notice above page chrome. Its overlay sat at z-index 50, under the Vacay mode toolbar at 61, so the dialog was neither on top nor modal. Adds a six-step layer scale to index.css and moves the three notice shells onto it. Closes #2054 Closes #2052
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-025
|
||||
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-027
|
||||
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -63,6 +63,11 @@ function hasResetLink(label: string): boolean {
|
||||
return !!el && within(el).queryByRole('button') !== null;
|
||||
}
|
||||
|
||||
/** The CARTO key input sits under its label inside the field's own wrapper. */
|
||||
function cartoInput(): HTMLInputElement {
|
||||
return within(screen.getByText('Shared CARTO key').closest('div') as HTMLElement).getByRole('textbox');
|
||||
}
|
||||
|
||||
/** Opens a CustomSelect by its trigger label and picks an option from the portal. */
|
||||
async function pickFromSelect(user: ReturnType<typeof userEvent.setup>, trigger: string, option: string) {
|
||||
await user.click(screen.getByRole('button', { name: trigger }));
|
||||
@@ -419,4 +424,32 @@ describe('DefaultUserSettingsTab', () => {
|
||||
expect(await screen.findByText(/Request failed with status code 503/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('Reset to built-in default')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-026: the shared CARTO key is stored on blur and cleared by its reset link', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ carto_api_key: 'ck.old' });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Shared CARTO key');
|
||||
|
||||
const input = cartoInput();
|
||||
expect(input).toHaveValue('ck.old');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'ck.new');
|
||||
fireEvent.blur(input);
|
||||
await waitFor(() => expect(puts).toEqual([{ carto_api_key: 'ck.new' }]));
|
||||
|
||||
// Clicking the reset link also blurs the field again, so only the last PUT is checked
|
||||
await user.click(resetLink('Shared CARTO key'));
|
||||
await waitFor(() => expect(puts[puts.length - 1]).toEqual({ carto_api_key: null }));
|
||||
await waitFor(() => expect(cartoInput()).toHaveValue(''));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-027: a managed instance hides the shared CARTO key field', async () => {
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), managed: true });
|
||||
stubDefaults({ carto_api_key: 'ck.hoster' });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
expect(screen.queryByText('Shared CARTO key')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ type Defaults = {
|
||||
default_currency?: string
|
||||
blur_booking_codes?: boolean
|
||||
map_tile_url?: string
|
||||
carto_api_key?: string
|
||||
map_provider?: string
|
||||
mapbox_access_token?: string
|
||||
mapbox_style?: string
|
||||
@@ -111,6 +112,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const [mapTileUrl, setMapTileUrl] = useState('')
|
||||
const managed = useAuthStore((s) => s.managed)
|
||||
const [mapboxToken, setMapboxToken] = useState('')
|
||||
const [cartoKey, setCartoKey] = useState('')
|
||||
const [mapboxStyle, setMapboxStyle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -119,6 +121,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
setDefaults(data)
|
||||
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
|
||||
setMapboxToken(data.mapbox_access_token || '')
|
||||
setCartoKey(data.carto_api_key || '')
|
||||
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
|
||||
setLoaded(true)
|
||||
}).catch(() => setLoaded(true))
|
||||
@@ -140,6 +143,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
setDefaults(updated)
|
||||
if (key === 'map_tile_url') setMapTileUrl('')
|
||||
if (key === 'mapbox_access_token') setMapboxToken('')
|
||||
if (key === 'carto_api_key') setCartoKey('')
|
||||
if (key === 'mapbox_style' || key === 'maplibre_style') {
|
||||
const provider = normalizeProvider(defaults.map_provider)
|
||||
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
|
||||
@@ -335,6 +339,26 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.mapDefaultHint')}</p>
|
||||
{/* The key comes with the instance on a managed install, injected when the
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{!managed && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.cartoKey')}
|
||||
<ResetButton field="carto_api_key" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cartoKey}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCartoKey(e.target.value)}
|
||||
onBlur={() => save({ carto_api_key: cartoKey })}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.cartoKeyHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
|
||||
@@ -2,6 +2,8 @@ import React from 'react'
|
||||
import { MapViewAuto } from '../Map/MapViewAuto'
|
||||
import type { CollectionPlace } from '@trek/shared'
|
||||
import { mappablePlaces } from '../../pages/collections/collectionsModel'
|
||||
import { useTileUrl } from '../../hooks/useTileUrl'
|
||||
import { CARTO_DARK, CARTO_LIGHT } from '../../constants/mapDefaults'
|
||||
|
||||
interface CollectionMapProps {
|
||||
places: CollectionPlace[]
|
||||
@@ -20,9 +22,7 @@ interface CollectionMapProps {
|
||||
*/
|
||||
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark }: CollectionMapProps): React.ReactElement {
|
||||
const pts = mappablePlaces(places)
|
||||
const tileUrl = dark
|
||||
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||
const tileUrl = useTileUrl(dark ? CARTO_DARK : CARTO_LIGHT)
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%' }}>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'react'
|
||||
import L from 'leaflet'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useCartoApiKey } from '../../hooks/useTileUrl'
|
||||
import { resolveTileUrl } from '../../utils/tileUrl'
|
||||
import { CARTO_DARK, CARTO_VOYAGER } from '../../constants/mapDefaults'
|
||||
import { escapeHtml, type JourneyTrack } from '@trek/shared'
|
||||
|
||||
export interface MapMarkerItem {
|
||||
@@ -94,6 +97,8 @@ interface Props {
|
||||
onMarkerClick?: (id: string, type?: string) => void
|
||||
fullScreen?: boolean
|
||||
paddingBottom?: number
|
||||
/** CARTO key from the share payload: the public journey has no settings store to read. */
|
||||
cartoApiKey?: string
|
||||
}
|
||||
|
||||
function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
||||
@@ -142,11 +147,13 @@ const EMPTY_TRACKS: JourneyTrack[] = []
|
||||
const TRACK_FALLBACK_COLOR = '#4f46e5'
|
||||
|
||||
function JourneyMap(
|
||||
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, ref }: Props,
|
||||
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, cartoApiKey, ref }: Props,
|
||||
) {
|
||||
const stableTrail = trail || EMPTY_TRAIL
|
||||
const stableTracks = tracks || EMPTY_TRACKS
|
||||
const mapTileUrl = useSettingsStore(s => s.settings.map_tile_url)
|
||||
const storedCartoKey = useCartoApiKey()
|
||||
const cartoKey = cartoApiKey || storedCartoKey
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const mapRef = useRef<L.Map | null>(null)
|
||||
const markersRef = useRef<Map<string, L.Marker>>(new Map())
|
||||
@@ -225,10 +232,7 @@ function JourneyMap(
|
||||
})
|
||||
mapRef.current = map
|
||||
|
||||
const defaultTile = dark
|
||||
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||
: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png'
|
||||
L.tileLayer(mapTileUrl || defaultTile, {
|
||||
L.tileLayer(resolveTileUrl(mapTileUrl, dark ? CARTO_DARK : CARTO_VOYAGER, cartoKey), {
|
||||
maxZoom: 18,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
referrerPolicy: 'strict-origin-when-cross-origin',
|
||||
@@ -335,7 +339,7 @@ function JourneyMap(
|
||||
mapRef.current = null
|
||||
markersRef.current.clear()
|
||||
}
|
||||
}, [entries, stableTrail, stableTracks, dark, mapTileUrl, fullScreen, paddingBottom])
|
||||
}, [entries, stableTrail, stableTracks, dark, mapTileUrl, cartoKey, fullScreen, paddingBottom])
|
||||
|
||||
// Photo layer (#1614). Its own effect on purpose: photos arriving must not tear
|
||||
// down and rebuild the map the way the entry effect does. Redrawn on zoom and
|
||||
@@ -378,7 +382,7 @@ function JourneyMap(
|
||||
photoLayerRef.current?.remove()
|
||||
photoLayerRef.current = null
|
||||
}
|
||||
}, [photos, entries, stableTrail, stableTracks, dark, mapTileUrl, fullScreen, paddingBottom])
|
||||
}, [photos, entries, stableTrail, stableTracks, dark, mapTileUrl, cartoKey, fullScreen, paddingBottom])
|
||||
|
||||
// react to activeMarkerId prop changes — runs after map is built
|
||||
useEffect(() => {
|
||||
|
||||
@@ -27,6 +27,8 @@ interface Props {
|
||||
onAddEntry?: () => void
|
||||
publicPhotoUrl?: (photoId: number) => string
|
||||
carouselBottom?: string
|
||||
/** CARTO key from the share payload, forwarded to the map (#2054). */
|
||||
cartoApiKey?: string
|
||||
}
|
||||
|
||||
export default function MobileMapTimeline({
|
||||
@@ -40,6 +42,7 @@ export default function MobileMapTimeline({
|
||||
onAddEntry,
|
||||
publicPhotoUrl,
|
||||
carouselBottom = 'calc(var(--bottom-nav-h, 84px) + 8px)',
|
||||
cartoApiKey,
|
||||
}: Props) {
|
||||
const mapRef = useRef<JourneyMapHandle>(null)
|
||||
const carouselRef = useRef<HTMLDivElement>(null)
|
||||
@@ -168,6 +171,7 @@ export default function MobileMapTimeline({
|
||||
dark={dark}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
fullScreen
|
||||
cartoApiKey={cartoApiKey}
|
||||
/>
|
||||
{!readOnly && onAddEntry && (
|
||||
<div className="fixed right-4 z-30" style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 16px)' }}>
|
||||
@@ -201,6 +205,7 @@ export default function MobileMapTimeline({
|
||||
onMarkerClick={handleMarkerClick}
|
||||
fullScreen
|
||||
paddingBottom={200}
|
||||
cartoApiKey={cartoApiKey}
|
||||
/>
|
||||
|
||||
{/* Bottom carousel */}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { escapeHtml } from '@trek/shared'
|
||||
import type { Day, Reservation, RouteVia } from '../../types'
|
||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
|
||||
import { CARTO_LIGHT, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { MapLayerSwitcher } from './MapLayerSwitcher'
|
||||
import { computeMapViewport, TILE_SIZE_RASTER, type ViewportPadding } from '../../utils/mapViewport'
|
||||
@@ -515,7 +515,9 @@ export const MapView = memo(function MapView({
|
||||
onMapContextMenu = null,
|
||||
center = DEFAULT_MAP_CENTER,
|
||||
zoom = DEFAULT_MAP_ZOOM,
|
||||
tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
|
||||
// Callers hand down a URL that already carries the CARTO key; this is only
|
||||
// the shape a caller without one gets.
|
||||
tileUrl = CARTO_LIGHT,
|
||||
fitKey = 0,
|
||||
dayOrderMap = {},
|
||||
leftWidth = 0,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-MAP-001 to FE-COMP-MAP-030
|
||||
// FE-COMP-MAP-001 to FE-COMP-MAP-035
|
||||
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
@@ -350,3 +350,72 @@ describe('MapSettingsTab – GL providers', () => {
|
||||
expect(screen.getByDisplayValue('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── CARTO key (031–035) ─────────────────────────────────────────────
|
||||
|
||||
const CARTO_DARK_TILES = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
|
||||
|
||||
/** The key input sits under its label inside the field's own wrapper. */
|
||||
function cartoInput(): HTMLInputElement {
|
||||
return within(screen.getByText('CARTO API key').closest('div') as HTMLElement).getByRole('textbox');
|
||||
}
|
||||
|
||||
describe('MapSettingsTab – CARTO key', () => {
|
||||
it('FE-COMP-MAP-031: the key field belongs to Leaflet and goes away with the GL providers', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MapSettingsTab />);
|
||||
|
||||
expect(screen.getByText('CARTO API key')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText('Mapbox GL'));
|
||||
|
||||
expect(screen.queryByText('CARTO API key')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-032: a managed instance brings its own key, so the field is hidden', () => {
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, managed: true });
|
||||
render(<MapSettingsTab />);
|
||||
|
||||
expect(screen.queryByText('CARTO API key')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-033: the typed key is part of the save patch', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSettings = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings({ map_tile_url: CARTO_DARK_TILES }),
|
||||
updateSettings,
|
||||
});
|
||||
render(<MapSettingsTab />);
|
||||
|
||||
await user.type(cartoInput(), 'demo-key');
|
||||
await user.click(screen.getByText('Save Map'));
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ carto_api_key: 'demo-key' }));
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-034: a CARTO template without a key explains the watermark until one is typed', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings({ map_tile_url: CARTO_DARK_TILES }),
|
||||
updateSettings: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
render(<MapSettingsTab />);
|
||||
|
||||
expect(screen.getByText(/API KEY REQUIRED/)).toBeInTheDocument();
|
||||
|
||||
await user.type(cartoInput(), 'demo-key');
|
||||
|
||||
expect(screen.queryByText(/API KEY REQUIRED/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-035: a template on another host never gets that notice', () => {
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings({ map_tile_url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' }),
|
||||
updateSettings: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
render(<MapSettingsTab />);
|
||||
|
||||
expect(screen.queryByText(/API KEY REQUIRED/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,6 +164,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
|
||||
const managed = useAuthStore((s) => s.managed)
|
||||
const [mapboxToken, setMapboxToken] = useState<string>(settings.mapbox_access_token || '')
|
||||
const [cartoKey, setCartoKey] = useState<string>(settings.carto_api_key || '')
|
||||
const [mapboxStyle, setMapboxStyle] = useState<string>(styleForProvider(initialProvider, slotStyle(initialProvider, settings)))
|
||||
const [mapbox3d, setMapbox3d] = useState<boolean>(settings.mapbox_3d_enabled !== false)
|
||||
const [mapboxQuality, setMapboxQuality] = useState<boolean>(settings.mapbox_quality_mode === true)
|
||||
@@ -175,6 +176,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
setProvider(nextProvider)
|
||||
setMapTileUrl(settings.map_tile_url || '')
|
||||
setMapboxToken(settings.mapbox_access_token || '')
|
||||
setCartoKey(settings.carto_api_key || '')
|
||||
setMapboxStyle(styleForProvider(nextProvider, slotStyle(nextProvider, settings)))
|
||||
setMapbox3d(settings.mapbox_3d_enabled !== false)
|
||||
setMapboxQuality(settings.mapbox_quality_mode === true)
|
||||
@@ -209,6 +211,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
map_provider: provider,
|
||||
map_tile_url: mapTileUrl,
|
||||
mapbox_access_token: mapboxToken,
|
||||
carto_api_key: cartoKey,
|
||||
...stylePatch,
|
||||
mapbox_3d_enabled: mapbox3d,
|
||||
mapbox_quality_mode: mapboxQuality,
|
||||
@@ -230,6 +233,8 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
setProvider(nextProvider)
|
||||
if (nextProvider !== 'leaflet') setMapboxStyle(styleForProvider(nextProvider, mapboxStyle))
|
||||
}
|
||||
// Only CARTO burns a watermark into keyless tiles, so the nudge is scoped to its hosts.
|
||||
const cartoNeedsKey = mapTileUrl.includes('basemaps.cartocdn.com') && !cartoKey.trim()
|
||||
|
||||
return (
|
||||
<Section title={t('settings.map')} icon={Map}>
|
||||
@@ -321,6 +326,30 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Same deal as the Mapbox token: a managed install brings its own key. */}
|
||||
{provider === 'leaflet' && !managed && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapCartoKey')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cartoKey}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCartoKey(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
{t('settings.mapCartoKeyHint')}{' '}
|
||||
<a href="https://carto.com/basemaps/apikey/" target="_blank" rel="noreferrer" className="underline">
|
||||
{t('settings.mapCartoKeyLink')}
|
||||
</a>
|
||||
</p>
|
||||
{cartoNeedsKey && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1">{t('settings.mapCartoKeyMissing')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GL settings */}
|
||||
{provider !== 'leaflet' && (
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Swatches } from './StudioSwatches'
|
||||
import { BOOK_FONTS, BOOK_FONT_ORDER, hasWeight, nearestWeight } from './bookFonts'
|
||||
import { sourceIdOf, useMapSources } from './mapSources'
|
||||
import { printDpi, tileView } from './mapTiles'
|
||||
import { useCartoApiKey } from '../../hooks/useTileUrl'
|
||||
import { routeFor } from './travelRefresh'
|
||||
import { Choice, Line, Picks, Section, Switch } from './StudioControls'
|
||||
import { fetchRoads } from './roadRoute'
|
||||
@@ -217,6 +218,7 @@ function ListProps({ el, set, t }: Props<BookListElement>) {
|
||||
}
|
||||
|
||||
function MapProps({ el, set, t, stats }: Props<BookMapElement> & { stats: JourneyStats | null }) {
|
||||
const cartoKey = useCartoApiKey()
|
||||
const [roadState, setRoadState] = useState<'idle' | 'busy'>('idle')
|
||||
const [roadDone, setRoadDone] = useState(0)
|
||||
const hasRoads = (el.roads ?? []).some(r => r && r.length > 1)
|
||||
@@ -248,7 +250,7 @@ function MapProps({ el, set, t, stats }: Props<BookMapElement> & { stats: Journe
|
||||
* say what it will print at. Same call the renderer makes, same answer.
|
||||
*/
|
||||
const view = el.source === 'tiles' && el.tileUrl
|
||||
? tileView(el.points, { w: el.frame.w, h: el.frame.h }, el.tileUrl, el.zoom)
|
||||
? tileView(el.points, { w: el.frame.w, h: el.frame.h }, el.tileUrl, el.zoom, undefined, 'print', cartoKey)
|
||||
: null
|
||||
const dpi = view ? printDpi(view, el.tileUrl) : null
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { fontStack } from './bookFonts'
|
||||
import { brightness } from './folioColour'
|
||||
import { COUNTRY_SHAPES, countryParts, countryWorldPath, projectMercator, unprojectMercator } from './countryShapes'
|
||||
import { projectOntoTiles, tileView, usableStaticUrl } from './mapTiles'
|
||||
import { useCartoApiKey } from '../../hooks/useTileUrl'
|
||||
import { FLAG_H, FLAG_W, flagBands, flagDisc, flagSpec } from './flags'
|
||||
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -200,6 +201,7 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
/** False in a thumbnail, where a page of imagery would be a page of waste. */
|
||||
big?: boolean
|
||||
}) {
|
||||
const cartoKey = useCartoApiKey()
|
||||
const palette = MAP_PALETTES[el.style]
|
||||
// Per element: two cut maps on one spread sharing a stencil would both be cut
|
||||
// to whichever of them rendered last.
|
||||
@@ -591,7 +593,7 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
maxLat: topLeft.lat,
|
||||
maxLng: bottomRight.lng,
|
||||
minLat: bottomRight.lat,
|
||||
}, big ? 'print' : 'preview')
|
||||
}, big ? 'print' : 'preview', cartoKey)
|
||||
: null
|
||||
|
||||
/*
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
* placed so the renderer cannot lose it.
|
||||
*/
|
||||
|
||||
import { withTileApiKey } from '../../utils/tileUrl'
|
||||
|
||||
/** A slippy-map tile, in the standard XYZ scheme. */
|
||||
export interface Tile {
|
||||
x: number
|
||||
@@ -168,6 +170,14 @@ export function tileView(
|
||||
* tiles, deliberately soft, because nobody prints the pages rail.
|
||||
*/
|
||||
quality: 'print' | 'preview' = 'print',
|
||||
/**
|
||||
* The instance's CARTO key, for a book still on a CARTO template.
|
||||
*
|
||||
* Applied here rather than stored: keyless CARTO tiles carry a watermark
|
||||
* since 26.08.2026 (#2054), and a key frozen into the document would be
|
||||
* printed into every export and would outlive the key itself.
|
||||
*/
|
||||
cartoKey?: string,
|
||||
): TileView | null {
|
||||
if (!template || (!points.length && !extent)) return null
|
||||
|
||||
@@ -290,6 +300,7 @@ export function tileView(
|
||||
const originY = frame.h / 2 - (cy * scale - y0) * size
|
||||
|
||||
const tiles: Tile[] = []
|
||||
const source = withTileApiKey(template, cartoKey)
|
||||
const wrap = (n: number) => ((n % scale) + scale) % scale
|
||||
for (let y = y0; y <= y1; y++) {
|
||||
// Past the poles there is no tile — the row simply does not exist.
|
||||
@@ -299,7 +310,7 @@ export function tileView(
|
||||
x: x - x0,
|
||||
y: y - y0,
|
||||
z: zoom,
|
||||
url: template
|
||||
url: source
|
||||
.replace('{z}', String(zoom))
|
||||
// Longitude wraps, so a route across the antimeridian keeps its tiles.
|
||||
.replace('{x}', String(wrap(x)))
|
||||
|
||||
@@ -670,7 +670,7 @@ function MobileNoticeSheet(S: NoticeState) {
|
||||
const nextNotice = notices[idx + 1] ?? null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50" role="presentation">
|
||||
<div className="fixed inset-0 z-[var(--z-notice)]" role="presentation">
|
||||
{/* Screen-reader page announcements */}
|
||||
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{pageAnnouncement}</span>
|
||||
{/* Backdrop */}
|
||||
@@ -842,7 +842,7 @@ function DesktopNoticeModal(S: NoticeState) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 z-50 bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
|
||||
className={`fixed inset-0 z-[var(--z-notice)] bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
|
||||
role="presentation"
|
||||
>
|
||||
{/* Screen-reader page announcements */}
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
/* Above the navbar, the page-bound sticky bars and the page-owned
|
||||
overlays: the notice is app-wide, so nothing a page draws may cover it. */
|
||||
z-index: var(--z-notice, 99998);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
// Nothing in a rendered test can see the notice's stacking order: jsdom applies
|
||||
// no author stylesheet and resolves no custom properties, so the two real files
|
||||
// are the only place the answer lives. Issue #2052 is exactly that gap, the
|
||||
// overlay sat at a bare 50 and the Vacay mode bar covered it.
|
||||
describe('release notice css', () => {
|
||||
// Vitest runs with the client package as its root, so cwd is stable here.
|
||||
const read = (rel: string): string => readFileSync(resolve(process.cwd(), rel), 'utf8')
|
||||
const notice = read('src/components/SystemNotices/releaseNotice.css')
|
||||
const tokens = read('src/index.css')
|
||||
|
||||
const block = (selector: string): string => {
|
||||
const at = notice.indexOf(selector)
|
||||
expect(at, `${selector} missing from releaseNotice.css`).toBeGreaterThan(-1)
|
||||
return notice.slice(at, notice.indexOf('}', at) + 1)
|
||||
}
|
||||
|
||||
const step = (name: string): number => {
|
||||
const found = new RegExp(`${name}:\\s*(\\d+);`).exec(tokens)
|
||||
expect(found, `${name} missing from index.css`).not.toBeNull()
|
||||
return Number(found![1])
|
||||
}
|
||||
|
||||
it('FE-COMP-RELEASENOTICECSS-001: the overlay stacks from the token, not a bare 50', () => {
|
||||
const overlay = block('.rn-overlay {')
|
||||
expect(overlay).toMatch(/z-index:\s*var\(--z-notice/)
|
||||
expect(overlay).not.toMatch(/z-index:\s*50\b/)
|
||||
})
|
||||
|
||||
it('FE-COMP-RELEASENOTICECSS-002: the notice step clears the navbar and the Vacay toolbar', () => {
|
||||
// The Vacay calendar's mode bar is sticky at 61 and no stacking context
|
||||
// separates the two, so it comes down to a plain number comparison.
|
||||
expect(step('--z-notice')).toBeGreaterThan(step('--z-nav'))
|
||||
expect(step('--z-notice')).toBeGreaterThan(61)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user