Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' | |
| import { Map, Save, Layers, Box, ChevronDown, Check } from 'lucide-react' | |
| import { useTranslation } from '../../i18n' | |
| import { useSettingsStore } from '../../store/settingsStore' | |
| import { useToast } from '../shared/Toast' | |
| import CustomSelect from '../shared/CustomSelect' | |
| import { MapView } from '../Map/MapView' | |
| import MapboxPreview from './MapboxPreview' | |
| import Section from './Section' | |
| import ToggleSwitch from './ToggleSwitch' | |
| import type { Place } from '../../types' | |
| interface MapPreset { | |
| name: string | |
| url: string | |
| } | |
| const MAP_PRESETS: MapPreset[] = [ | |
| { name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' }, | |
| { name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' }, | |
| { name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' }, | |
| { name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' }, | |
| { name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' }, | |
| ] | |
| interface StylePreset { | |
| name: string | |
| url: string | |
| tags: string[] | |
| } | |
| const MAPBOX_STYLE_PRESETS: StylePreset[] = [ | |
| { name: 'Mapbox Standard', url: 'mapbox://styles/mapbox/standard', tags: ['3D', 'Apple-like'] }, | |
| { name: 'Standard Satellite', url: 'mapbox://styles/mapbox/standard-satellite', tags: ['3D', 'Satellite'] }, | |
| { name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12', tags: ['3D', 'Classic'] }, | |
| { name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12', tags: ['3D', 'Terrain'] }, | |
| { name: 'Light', url: 'mapbox://styles/mapbox/light-v11', tags: ['3D', 'Minimal'] }, | |
| { name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11', tags: ['3D', 'Dark'] }, | |
| { name: 'Satellite', url: 'mapbox://styles/mapbox/satellite-v9', tags: ['3D', 'Satellite'] }, | |
| { name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12', tags: ['3D', 'Satellite'] }, | |
| { name: 'Navigation Day', url: 'mapbox://styles/mapbox/navigation-day-v1', tags: ['3D', 'Apple-like'] }, | |
| { name: 'Navigation Night', url: 'mapbox://styles/mapbox/navigation-night-v1', tags: ['3D', 'Dark'] }, | |
| ] | |
| // Tag → chip color mapping. Keeps the dropdown readable at a glance so a | |
| // user scanning the list can spot 3D / Satellite / Apple-like styles. | |
| const TAG_STYLES: Record<string, string> = { | |
| '3D': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300', | |
| '2D': 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300', | |
| 'Satellite': 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300', | |
| 'Apple-like': 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300', | |
| 'Modern': 'bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300', | |
| 'Dark': 'bg-zinc-800 text-zinc-100 dark:bg-zinc-900 dark:text-zinc-300', | |
| 'Minimal': 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300', | |
| 'Hillshading': 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300', | |
| 'Terrain': 'bg-lime-100 text-lime-800 dark:bg-lime-900/40 dark:text-lime-300', | |
| 'Realistic': 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300', | |
| 'Navigation': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300', | |
| 'Classic': 'bg-stone-100 text-stone-700 dark:bg-stone-800 dark:text-stone-300', | |
| 'Hybrid': 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300', | |
| 'No labels': 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300', | |
| } | |
| function TagChip({ tag }: { tag: string }) { | |
| const cls = TAG_STYLES[tag] || 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300' | |
| return ( | |
| <span className={`text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded leading-none ${cls}`}> | |
| {tag} | |
| </span> | |
| ) | |
| } | |
| function StyleDropdown({ value, onChange }: { value: string; onChange: (v: string) => void }) { | |
| const { t } = useTranslation() | |
| const [open, setOpen] = useState(false) | |
| const ref = useRef<HTMLDivElement>(null) | |
| useEffect(() => { | |
| if (!open) return | |
| const onDoc = (e: MouseEvent) => { | |
| if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) | |
| } | |
| document.addEventListener('mousedown', onDoc) | |
| return () => document.removeEventListener('mousedown', onDoc) | |
| }, [open]) | |
| const selected = MAPBOX_STYLE_PRESETS.find(p => p.url === value) | |
| return ( | |
| <div ref={ref} className="relative"> | |
| <button | |
| type="button" | |
| onClick={() => setOpen(v => !v)} | |
| className="w-full flex items-center justify-between gap-2 px-3 py-2 border border-slate-300 dark:border-slate-700 rounded-lg text-sm bg-white dark:bg-slate-900 hover:border-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent" | |
| > | |
| <span className="flex items-center gap-2 min-w-0"> | |
| <span className="text-slate-900 dark:text-white truncate"> | |
| {selected ? selected.name : t('settings.mapStylePlaceholder')} | |
| </span> | |
| {selected && ( | |
| <span className="flex items-center gap-1 flex-shrink-0"> | |
| {selected.tags.map(t => <TagChip key={t} tag={t} />)} | |
| </span> | |
| )} | |
| </span> | |
| <ChevronDown size={14} className="flex-shrink-0 text-slate-400" /> | |
| </button> | |
| {open && ( | |
| <div className="absolute z-20 mt-1 w-full max-h-80 overflow-auto rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-lg py-1"> | |
| {MAPBOX_STYLE_PRESETS.map(preset => { | |
| const isActive = preset.url === value | |
| return ( | |
| <button | |
| key={preset.url} | |
| type="button" | |
| onClick={() => { onChange(preset.url); setOpen(false) }} | |
| className={`w-full flex items-center justify-between gap-2 px-3 py-2 text-left text-sm hover:bg-slate-50 dark:hover:bg-slate-800 ${isActive ? 'bg-slate-50 dark:bg-slate-800' : ''}`} | |
| > | |
| <span className="flex items-center gap-2 flex-wrap"> | |
| <span className="text-slate-900 dark:text-white font-medium">{preset.name}</span> | |
| {preset.tags.map(t => <TagChip key={t} tag={t} />)} | |
| </span> | |
| {isActive && <Check size={14} className="flex-shrink-0 text-slate-900 dark:text-white" />} | |
| </button> | |
| ) | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| type Provider = 'leaflet' | 'mapbox-gl' | |
| export default function MapSettingsTab(): React.ReactElement { | |
| const { settings, updateSettings } = useSettingsStore() | |
| const { t } = useTranslation() | |
| const toast = useToast() | |
| const [saving, setSaving] = useState(false) | |
| const [provider, setProvider] = useState<Provider>((settings.map_provider as Provider) || 'leaflet') | |
| const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '') | |
| const [mapboxToken, setMapboxToken] = useState<string>(settings.mapbox_access_token || '') | |
| const [mapboxStyle, setMapboxStyle] = useState<string>(settings.mapbox_style || 'mapbox://styles/mapbox/standard') | |
| const [mapbox3d, setMapbox3d] = useState<boolean>(settings.mapbox_3d_enabled !== false) | |
| const [mapboxQuality, setMapboxQuality] = useState<boolean>(settings.mapbox_quality_mode === true) | |
| const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566) | |
| const [defaultLng, setDefaultLng] = useState<number | string>(settings.default_lng || 2.3522) | |
| const [defaultZoom, setDefaultZoom] = useState<number | string>(settings.default_zoom || 10) | |
| useEffect(() => { | |
| setProvider((settings.map_provider as Provider) || 'leaflet') | |
| setMapTileUrl(settings.map_tile_url || '') | |
| setMapboxToken(settings.mapbox_access_token || '') | |
| setMapboxStyle(settings.mapbox_style || 'mapbox://styles/mapbox/standard') | |
| setMapbox3d(settings.mapbox_3d_enabled !== false) | |
| setMapboxQuality(settings.mapbox_quality_mode === true) | |
| setDefaultLat(settings.default_lat || 48.8566) | |
| setDefaultLng(settings.default_lng || 2.3522) | |
| setDefaultZoom(settings.default_zoom || 10) | |
| }, [settings]) | |
| const handleMapClick = useCallback((mapInfo) => { | |
| setDefaultLat(mapInfo.latlng.lat) | |
| setDefaultLng(mapInfo.latlng.lng) | |
| }, []) | |
| const mapPlaces = useMemo((): Place[] => [{ | |
| id: 1, | |
| trip_id: 1, | |
| name: 'Default map center', | |
| description: '', | |
| lat: defaultLat as number, | |
| lng: defaultLng as number, | |
| address: '', | |
| category_id: 0, | |
| price: null, | |
| image_url: null, | |
| google_place_id: null, | |
| osm_id: null, | |
| route_geometry: null, | |
| place_time: null, | |
| end_time: null, | |
| created_at: Date(), | |
| }], [defaultLat, defaultLng]) | |
| const saveMapSettings = async (): Promise<void> => { | |
| setSaving(true) | |
| try { | |
| await updateSettings({ | |
| map_provider: provider, | |
| map_tile_url: mapTileUrl, | |
| mapbox_access_token: mapboxToken, | |
| mapbox_style: mapboxStyle, | |
| mapbox_3d_enabled: mapbox3d, | |
| mapbox_quality_mode: mapboxQuality, | |
| default_lat: parseFloat(String(defaultLat)), | |
| default_lng: parseFloat(String(defaultLng)), | |
| default_zoom: parseInt(String(defaultZoom)), | |
| }) | |
| toast.success(t('settings.toast.mapSaved')) | |
| } catch (err: unknown) { | |
| toast.error(err instanceof Error ? err.message : t('common.error')) | |
| } finally { | |
| setSaving(false) | |
| } | |
| } | |
| // 3D is available on every style now — pure satellite uses the | |
| // mapbox-streets-v8 tileset as a fallback building source. | |
| const supports3d = true | |
| return ( | |
| <Section title={t('settings.map')} icon={Map}> | |
| {/* Provider picker — big cards so the choice is obvious */} | |
| <div> | |
| <label className="block text-sm font-medium text-slate-700 mb-2">{t('settings.mapProvider')}</label> | |
| <div className="grid grid-cols-2 gap-2"> | |
| <button | |
| type="button" | |
| onClick={() => setProvider('leaflet')} | |
| className={`flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${ | |
| provider === 'leaflet' | |
| ? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200' | |
| : 'border-slate-200 hover:border-slate-400 dark:border-slate-700' | |
| }`} | |
| > | |
| <Layers size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" /> | |
| <div> | |
| <div className="text-sm font-medium text-slate-900 dark:text-white">Leaflet</div> | |
| <div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapLeafletSubtitle')}</div> | |
| </div> | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => setProvider('mapbox-gl')} | |
| className={`relative flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${ | |
| provider === 'mapbox-gl' | |
| ? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200' | |
| : 'border-slate-200 hover:border-slate-400 dark:border-slate-700' | |
| }`} | |
| > | |
| <Box size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" /> | |
| <div className="min-w-0"> | |
| <div className="text-sm font-medium text-slate-900 dark:text-white"> | |
| <span className="sm:hidden">Mapbox</span> | |
| <span className="hidden sm:inline">Mapbox GL</span> | |
| </div> | |
| <div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapboxSubtitle')}</div> | |
| </div> | |
| {/* Experimental badge only on ≥sm; on mobile there's no room next to the title. */} | |
| <span className="hidden sm:inline-block absolute top-2 right-2 text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none"> | |
| {t('settings.mapExperimental')} | |
| </span> | |
| </button> | |
| </div> | |
| <p className="text-xs text-slate-400 mt-2"> | |
| {t('settings.mapProviderHint')} | |
| </p> | |
| </div> | |
| {/* Leaflet settings */} | |
| {provider === 'leaflet' && ( | |
| <div> | |
| <label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapTemplate')}</label> | |
| <CustomSelect | |
| value={mapTileUrl} | |
| onChange={(value: string) => { if (value) setMapTileUrl(value) }} | |
| placeholder={t('settings.mapTemplatePlaceholder.select')} | |
| options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))} | |
| size="sm" | |
| style={{ marginBottom: 8 }} | |
| /> | |
| <input | |
| type="text" | |
| value={mapTileUrl} | |
| onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)} | |
| placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" | |
| 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 text-slate-400 mt-1">{t('settings.mapDefaultHint')}</p> | |
| </div> | |
| )} | |
| {/* Mapbox GL settings */} | |
| {provider === 'mapbox-gl' && ( | |
| <div className="space-y-3"> | |
| <div> | |
| <label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapMapboxToken')}</label> | |
| <input | |
| type="text" | |
| value={mapboxToken} | |
| onChange={(e) => setMapboxToken(e.target.value)} | |
| placeholder="pk.eyJ1Ijoi..." | |
| 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.mapMapboxTokenHint')}{' '} | |
| <a href="https://account.mapbox.com/access-tokens/" target="_blank" rel="noreferrer" className="underline"> | |
| {t('settings.mapMapboxTokenLink')} | |
| </a> | |
| </p> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapStyle')}</label> | |
| <div className="mb-2"> | |
| <StyleDropdown value={mapboxStyle} onChange={setMapboxStyle} /> | |
| </div> | |
| <input | |
| type="text" | |
| value={mapboxStyle} | |
| onChange={(e) => setMapboxStyle(e.target.value)} | |
| placeholder="mapbox://styles/mapbox/standard" | |
| 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.mapStyleHint')} | |
| </p> | |
| </div> | |
| <div className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${ | |
| supports3d | |
| ? 'border-slate-200 dark:border-slate-700' | |
| : 'border-slate-200 opacity-60 dark:border-slate-700' | |
| }`}> | |
| <div className="flex-1"> | |
| <div className="text-sm font-medium text-slate-900 dark:text-white">{t('settings.map3dBuildings')}</div> | |
| <div className="text-xs text-slate-500 mt-0.5"> | |
| {t('settings.map3dHint')} | |
| </div> | |
| </div> | |
| <ToggleSwitch | |
| on={mapbox3d && supports3d} | |
| onToggle={() => { if (supports3d) setMapbox3d(!mapbox3d) }} | |
| /> | |
| </div> | |
| <div className="flex items-start gap-3 p-3 rounded-lg border border-slate-200 dark:border-slate-700"> | |
| <div className="flex-1"> | |
| <div className="text-sm font-medium text-slate-900 dark:text-white flex flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-2"> | |
| <span className="order-2 sm:order-1">{t('settings.mapHighQuality')}</span> | |
| <span className="order-1 sm:order-2 text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none"> | |
| {t('settings.mapExperimental')} | |
| </span> | |
| </div> | |
| <div className="text-xs text-slate-500 mt-0.5"> | |
| {t('settings.mapHighQualityHint')}{' '} | |
| <span className="text-amber-600 dark:text-amber-400">{t('settings.mapHighQualityWarning')}</span> | |
| </div> | |
| </div> | |
| <ToggleSwitch on={mapboxQuality} onToggle={() => setMapboxQuality(!mapboxQuality)} /> | |
| </div> | |
| <div className="text-xs text-slate-400 p-3 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700"> | |
| <strong className="text-slate-600 dark:text-slate-300">{t('settings.mapTipLabel')}</strong> {t('settings.mapTip')} | |
| </div> | |
| </div> | |
| )} | |
| {/* Default map position — applies regardless of provider */} | |
| <div className="grid grid-cols-2 gap-3"> | |
| <div> | |
| <label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.latitude')}</label> | |
| <input | |
| type="number" | |
| step="any" | |
| value={defaultLat} | |
| onChange={(e) => setDefaultLat(e.target.value)} | |
| 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" | |
| /> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.longitude')}</label> | |
| <input | |
| type="number" | |
| step="any" | |
| value={defaultLng} | |
| onChange={(e) => setDefaultLng(e.target.value)} | |
| 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" | |
| /> | |
| </div> | |
| </div> | |
| <div> | |
| <div style={{ position: 'relative', inset: 0, height: '200px', width: '100%' }}> | |
| {provider === 'mapbox-gl' ? ( | |
| <MapboxPreview | |
| token={mapboxToken} | |
| style={mapboxStyle} | |
| lat={parseFloat(String(defaultLat)) || 48.8566} | |
| lng={parseFloat(String(defaultLng)) || 2.3522} | |
| // Zoom in close so the style's character (3D buildings, | |
| // satellite texture, label density) is immediately visible. | |
| zoom={Math.max(parseInt(String(defaultZoom)) || 10, 16)} | |
| enable3d={mapbox3d && supports3d} | |
| quality={mapboxQuality} | |
| onClick={(ll) => { setDefaultLat(ll.lat); setDefaultLng(ll.lng) }} | |
| /> | |
| ) : ( | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| React.createElement(MapView as any, { | |
| places: mapPlaces, | |
| dayPlaces: [], | |
| route: null, | |
| routeSegments: null, | |
| selectedPlaceId: null, | |
| onMarkerClick: null, | |
| onMapClick: handleMapClick, | |
| onMapContextMenu: null, | |
| center: [settings.default_lat, settings.default_lng], | |
| zoom: defaultZoom, | |
| tileUrl: mapTileUrl, | |
| fitKey: null, | |
| dayOrderMap: [], | |
| leftWidth: 0, | |
| rightWidth: 0, | |
| hasInspector: false, | |
| }) | |
| )} | |
| </div> | |
| </div> | |
| <button | |
| onClick={saveMapSettings} | |
| disabled={saving} | |
| className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400" | |
| > | |
| {saving ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />} | |
| {t('settings.saveMap')} | |
| </button> | |
| </Section> | |
| ) | |
| } | |