Files
TREK/client/src/components/Admin/DefaultUserSettingsTab.tsx
T
Maurice 24461ecf76 fix(admin): the default map template offers the Amap presets users can pick
In the admin's default user settings, the map template dropdown listed
seven tile presets, while every user's own map settings listed nine: the
same seven plus Amap and Amap Satellite. An admin running an instance for
people in mainland China could not pick the one basemap that works well
there as the default, short of pasting its URL by hand.

The Amap presets (57eac32f9) were added to the two user preset copies and
not to the admin's separate copy; nothing in that change or later gives a
reason, and the server stores any template as a default. The later
refactor kept the gap as DEFAULT_MAP_PRESETS beside MAP_PRESETS. There is
now one MAP_PRESETS list, read by the user settings and the admin defaults
on both desktop and phone.

Regression test FE-ADMIN-DUS-029 (DefaultUserSettingsTab.test.tsx) was red
on the old code: "Unable to find role="button" and name "高德地图 (Amap)"".

Changed assertions:
- useMapSettingsForm.test.ts FE-COMP-MAPFORM-001: it expected the admin list
  DEFAULT_MAP_PRESETS to hold 7 presets and the user list to add Amap on
  top; it now expects the one list to hold 9 presets including both Amap
  templates.
2026-10-09 19:00:47 +02:00

367 lines
15 KiB
TypeScript

import React from 'react'
import { Loader2, Map as MapIcon, Settings2 } from 'lucide-react'
import { useTranslation } from '../../i18n'
import Section from '../Settings/Section'
import { SettingRows, SettingsHint } from '../Settings/settingsKit'
import { fs } from '../shared/DialogShell'
import { EditorField, INPUT, Segmented } from '../shared/dialogParts'
import CustomSelect from '../shared/CustomSelect'
import { MapView } from '../Map/MapView'
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
import type { DistanceUnit, WeekStart } from '../../types'
import { weekStartOptions } from '../../utils/calendarWeek'
import { withTileApiKey } from '../../utils/tileUrl'
import { defaultStyleForProvider } from '../Map/glProviders'
import { MAP_PRESETS, type MapProvider } from '../Settings/mapSettingsModel'
import RoutingInstanceFields from './RoutingInstanceFields'
import { useDefaultUserSettings, type Defaults } from './useDefaultUserSettings'
/**
* One default as a row of the card's white box: its name (with the reset link
* once it is set) on the left, the choice on the right. `stacked` puts a wide
* choice and a hint under the name. The label sits straight in the row, so the
* row is the nearest box around both the name and its choice.
*/
function ChoiceRow({
label,
hint,
stacked = false,
children,
}: {
label: React.ReactNode
hint?: string
stacked?: boolean
children: React.ReactNode
}) {
return (
<div className={stacked ? 'flex flex-col gap-2.5 px-3.5 py-3' : 'flex flex-wrap items-center gap-x-4 gap-y-2 px-3.5 py-3'}>
<label className={`block font-medium text-content ${stacked ? '' : 'min-w-0 flex-1 basis-40'}`} style={fs(13, 'body')}>
{label}
</label>
{hint && <p className="-mt-2 m-0 leading-snug text-content-faint" style={fs(11.5)}>{hint}</p>}
<div className={stacked ? 'min-w-0' : 'flex min-w-0 flex-none items-center'}>{children}</div>
</div>
)
}
/** A wide field box inside the white rows, for the map card's token and style. */
const FIELD_ROW = 'px-3.5 py-3'
/** On/Off for a boolean default; neither is pressed while it is unset. */
function onOffValue(value: boolean | undefined): string {
if (value === true) return 'on'
if (value === false) return 'off'
return ''
}
/** The stored colour mode as one of the three choices; a legacy boolean still picks light or dark. */
function colorModeValue(value: string | boolean | undefined): string {
if (value === true) return 'dark'
if (value === false) return 'light'
return value ?? ''
}
/** A failed save names the error it threw, or falls back to the generic one. */
function errorText(err: unknown, fallback: string): string {
return err instanceof Error ? err.message : fallback
}
export default function DefaultUserSettingsTab(): React.ReactElement {
const { t, locale } = useTranslation()
const {
defaults, loaded, managed, isSet, save, reset, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
cartoKey, setCartoKey, mapboxStyle, setMapboxStyle, mapPreviewPlaces, mapProvider, glStylePresets, styleKey,
saveMapProvider, pickTilePreset, pickStylePreset, commitStyle,
} = useDefaultUserSettings({ errorMessage: errorText, previewCreatedAt: () => String(new Date()) })
// Inside the row's own <label>, after the name: the link only shows once the
// default is set, and the name stays the label's text.
const ResetButton = ({ field }: { field: keyof Defaults }) =>
isSet(field) ? (
<button type="button"
onClick={() => reset(field)}
className="ms-2 align-baseline font-geist font-medium normal-case tracking-normal text-content-muted underline decoration-edge underline-offset-2 hover:text-content"
style={fs(11)}
>
{t('admin.defaultSettings.resetToBuiltIn')}
</button>
) : null
if (!loaded) {
return (
<div className="flex items-center gap-2.5 rounded-2xl border border-edge-faint bg-surface-secondary px-4 py-5 text-content-faint" style={fs(12.5, 'body')}>
<Loader2 size={15} className="animate-spin" />
Loading…
</div>
)
}
const darkMode = defaults.dark_mode
return (
<div>
<SettingsHint className="mb-4">
{t('admin.defaultSettings.description')}
</SettingsHint>
{/* Two columns from xl up, the same idea as the Settings tab: as one flat list
the map fields sat a screen below the units while the right half of the page
stayed empty. Grouped by subject rather than by height — what a user ends up
seeing on the left, everything that configures the map on the right.
The columns are deliberately uneven: the left one never needs more than the
widest option row, while the right one holds four text fields whose values
are 60-character tile URLs. Only the column gap is set, because Section
carries its own bottom margin and a row gap would double it once the grid
collapses. Two cards can sit straight in the grid; a third would need the
explicit per-column stacks the Settings tab uses, or it leaves a hole. */}
<div className="grid grid-cols-1 gap-x-5 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)] xl:items-start">
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
<SettingRows>
{/* Color Mode */}
<ChoiceRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
<Segmented<string>
label={t('settings.colorMode')}
value={colorModeValue(darkMode)}
onChange={value => save({ dark_mode: value })}
options={[
{ value: 'light', label: t('settings.light') },
{ value: 'dark', label: t('settings.dark') },
{ value: 'auto', label: t('settings.auto') },
]}
/>
</ChoiceRow>
{/* Temperature */}
<ChoiceRow label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
<Segmented<string>
label={t('settings.temperature')}
value={defaults.temperature_unit ?? ''}
onChange={value => save({ temperature_unit: value })}
options={[
{ value: 'celsius', label: '°C Celsius' },
{ value: 'fahrenheit', label: '°F Fahrenheit' },
]}
/>
</ChoiceRow>
{/* Distance */}
<ChoiceRow label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
<Segmented<DistanceUnit | ''>
label={t('settings.distance')}
value={defaults.distance_unit ?? ''}
onChange={value => { if (value) void save({ distance_unit: value }) }}
options={[
{ value: 'metric', label: 'km Metric' },
{ value: 'imperial', label: 'mi Imperial' },
]}
/>
</ChoiceRow>
{/* Time Format */}
<ChoiceRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
<Segmented<string>
label={t('settings.timeFormat')}
value={defaults.time_format ?? ''}
onChange={value => save({ time_format: value })}
options={[
{ value: '24h', label: '24h (14:30)' },
{ value: '12h', label: '12h (2:30 PM)' },
]}
/>
</ChoiceRow>
{/* Week start: the first column of every date picker (#2029) */}
<ChoiceRow label={<>{t('settings.weekStart')} <ResetButton field="week_start" /></>}>
<Segmented<WeekStart | ''>
label={t('settings.weekStart')}
value={defaults.week_start ?? ''}
onChange={value => { if (value) void save({ week_start: value }) }}
options={weekStartOptions(locale)}
/>
</ChoiceRow>
{/* Default Currency */}
<ChoiceRow label={<>{t('settings.currency')} <ResetButton field="default_currency" /></>}>
<div className="w-[220px] max-w-full">
<CustomSelect
value={defaults.default_currency || ''}
onChange={(value: string) => { if (value) void save({ default_currency: value }) }}
placeholder={t('settings.currency')}
searchable
options={currenciesWith(defaults.default_currency).map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
/>
</div>
</ChoiceRow>
{/* Blur Booking Codes */}
<ChoiceRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
<Segmented<string>
label={t('settings.blurBookingCodes')}
value={onOffValue(defaults.blur_booking_codes)}
onChange={value => save({ blur_booking_codes: value === 'on' })}
options={[
{ value: 'on', label: t('settings.on') || 'On' },
{ value: 'off', label: t('settings.off') || 'Off' },
]}
/>
</ChoiceRow>
</SettingRows>
<SettingsHint className="-mt-2">{t('settings.currencyHint')}</SettingsHint>
</Section>
<Section title={t('settings.map')} icon={MapIcon}>
{/* Map Tile URL */}
<EditorField label={<>{t('settings.mapTemplate')}<ResetButton field="map_tile_url" /></>} hint={t('settings.mapDefaultHint')}>
<div className="flex flex-col gap-2">
<CustomSelect
value={mapTileUrl}
onChange={pickTilePreset}
placeholder={t('settings.mapTemplatePlaceholder.select')}
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
/>
<input
type="text"
value={mapTileUrl}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
onBlur={() => save({ map_tile_url: mapTileUrl })}
placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
className={`${INPUT} font-geist`}
/>
</div>
</EditorField>
{/* 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 && (
<EditorField label={<>{t('admin.defaultSettings.cartoKey')}<ResetButton field="carto_api_key" /></>} hint={t('admin.defaultSettings.cartoKeyHint')}>
<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={`${INPUT} font-geist`}
/>
</EditorField>
)}
{!managed && <RoutingInstanceFields defaults={defaults} onSave={save} onReset={reset} />}
<div className="relative h-[200px] w-full overflow-hidden rounded-[12px] border border-edge-faint">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{React.createElement(MapView as any, {
places: mapPreviewPlaces,
dayPlaces: [],
route: null,
routeSegments: null,
selectedPlaceId: null,
onMarkerClick: null,
onMapClick: null,
onMapContextMenu: null,
center: [48.8566, 2.3522],
zoom: 10,
// Same as the user-facing map tab: the field holds what is being
// edited, so the key goes back on before the preview resolves it.
tileUrl: withTileApiKey(mapTileUrl, cartoKey),
fitKey: null,
dayOrderMap: [],
leftWidth: 0,
rightWidth: 0,
hasInspector: false,
})}
</div>
{/* ── Map provider / instance-wide Mapbox ───────────────────────── */}
<SettingRows>
<ChoiceRow
stacked
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
hint={t('admin.defaultSettings.mapProviderHint')}
>
<Segmented<MapProvider>
fill
label={t('admin.defaultSettings.mapProvider')}
value={mapProvider}
onChange={saveMapProvider}
options={[
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
]}
/>
</ChoiceRow>
{/* The token 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. */}
{mapProvider === 'mapbox-gl' && !managed && (
<div className={FIELD_ROW}>
<EditorField label={<>{t('admin.defaultSettings.mapboxToken')}<ResetButton field="mapbox_access_token" /></>} hint={t('admin.defaultSettings.mapboxTokenHint')}>
<input
type="text"
value={mapboxToken}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxToken(e.target.value)}
onBlur={() => save({ mapbox_access_token: mapboxToken })}
placeholder="pk.eyJ…"
spellCheck={false}
autoComplete="off"
className={`${INPUT} font-geist`}
/>
</EditorField>
</div>
)}
{mapProvider !== 'leaflet' && (
<div className={FIELD_ROW}>
<EditorField label={<>{t('admin.defaultSettings.mapboxStyle')}<ResetButton field={styleKey} /></>}>
<div className="flex flex-col gap-2">
<CustomSelect
value={mapboxStyle}
onChange={pickStylePreset}
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
options={glStylePresets.map(p => ({ value: p.url, label: p.name }))}
/>
<input
type="text"
value={mapboxStyle}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
onBlur={commitStyle}
placeholder={defaultStyleForProvider(mapProvider)}
className={`${INPUT} font-geist`}
/>
</div>
</EditorField>
</div>
)}
{mapProvider === 'mapbox-gl' && (
<>
<ChoiceRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
<Segmented<string>
label={t('admin.defaultSettings.mapbox3d')}
value={onOffValue(defaults.mapbox_3d_enabled ?? true)}
onChange={value => save({ mapbox_3d_enabled: value === 'on' })}
options={[
{ value: 'on', label: t('settings.on') || 'On' },
{ value: 'off', label: t('settings.off') || 'Off' },
]}
/>
</ChoiceRow>
<ChoiceRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}>
<Segmented<string>
label={t('admin.defaultSettings.mapboxQuality')}
value={onOffValue(defaults.mapbox_quality_mode ?? false)}
onChange={value => save({ mapbox_quality_mode: value === 'on' })}
options={[
{ value: 'on', label: t('settings.on') || 'On' },
{ value: 'off', label: t('settings.off') || 'Off' },
]}
/>
</ChoiceRow>
</>
)}
</SettingRows>
</Section>
</div>
</div>
)
}