diff --git a/charts/trek/Chart.yaml b/charts/trek/Chart.yaml index b29275f6..e5ca1492 100644 --- a/charts/trek/Chart.yaml +++ b/charts/trek/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v2 name: trek -version: 3.1.2 +version: 3.1.3 description: Minimal Helm chart for TREK app -appVersion: "3.1.2" +appVersion: "3.1.3" diff --git a/charts/trek/templates/configmap.yaml b/charts/trek/templates/configmap.yaml index 62693a5a..5cecc9d8 100644 --- a/charts/trek/templates/configmap.yaml +++ b/charts/trek/templates/configmap.yaml @@ -70,3 +70,9 @@ data: {{- if .Values.env.MCP_RATE_LIMIT }} MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }} {{- end }} + {{- if .Values.env.OVERPASS_URL }} + OVERPASS_URL: {{ .Values.env.OVERPASS_URL | quote }} + {{- end }} + {{- if .Values.env.OVERPASS_TIMEOUT_MS }} + OVERPASS_TIMEOUT_MS: {{ .Values.env.OVERPASS_TIMEOUT_MS | quote }} + {{- end }} diff --git a/charts/trek/values.yaml b/charts/trek/values.yaml index 3143899f..a87a4665 100644 --- a/charts/trek/values.yaml +++ b/charts/trek/values.yaml @@ -67,6 +67,12 @@ env: # Max MCP API requests per user per minute. Defaults to 300. # MCP_MAX_SESSION_PER_USER: "20" # Max concurrent MCP sessions per user. Defaults to 20. + # OVERPASS_URL: "" + # Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled + # public mirrors — point it at an internal/self-hosted Overpass instance when the public mirrors are unreachable + # from the cluster (e.g. locked-down egress). Non-http(s) entries are ignored. + # OVERPASS_TIMEOUT_MS: "12000" + # Per-endpoint timeout (ms) for Overpass POI requests. Raise it for a slow self-hosted Overpass instance. Defaults to 12000. # Secret environment variables stored in a Kubernetes Secret. diff --git a/client/index.html b/client/index.html index 0e50cf50..b52109ea 100644 --- a/client/index.html +++ b/client/index.html @@ -13,7 +13,7 @@ - + diff --git a/client/package.json b/client/package.json index 2d2deafc..c1a9f6d3 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@trek/client", - "version": "3.1.2", + "version": "3.1.3", "private": true, "type": "module", "scripts": { @@ -34,6 +34,7 @@ "leaflet": "^1.9.4", "lucide-react": "^0.344.0", "mapbox-gl": "^3.22.0", + "maplibre-gl": "^5.24.0", "marked": "^18.0.0", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -81,7 +82,7 @@ "tailwindcss": "^3.4.1", "typescript": "^6.0.2", "typescript-eslint": "^8.58.2", - "vite": "^8.0.16", + "vite": "8.1.0", "vite-plugin-pwa": "^1.3.0", "vitest": "^4.1.9" } diff --git a/client/src/api/client.ts b/client/src/api/client.ts index dab6ba43..56ef2ec7 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -100,6 +100,7 @@ const RATE_LIMIT_MESSAGES: Record = { ja: '試行回数が多すぎます。時間をおいて再度お試しください。', ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.', uk: 'Занадто багато спроб. Спробуйте пізніше.', + sv: 'För många försök. Prova igen senare.', } function translateRateLimit(): string { diff --git a/client/src/components/Admin/DefaultUserSettingsTab.tsx b/client/src/components/Admin/DefaultUserSettingsTab.tsx index d984991b..8489b003 100644 --- a/client/src/components/Admin/DefaultUserSettingsTab.tsx +++ b/client/src/components/Admin/DefaultUserSettingsTab.tsx @@ -7,7 +7,16 @@ import Section from '../Settings/Section' import CustomSelect from '../shared/CustomSelect' import { MapView } from '../Map/MapView' import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants' -import type { Place } from '../../types' +import type { DistanceUnit, Place } from '../../types' +import { + MAPBOX_DEFAULT_STYLE, + defaultStyleForProvider, + getStylePresets, + isOpenFreeMapStyle, + normalizeStyleForProvider, + styleSettingKey, + type GlMapProvider, +} from '../Map/glProviders' const MAP_PRESETS = [ { name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' }, @@ -19,6 +28,7 @@ const MAP_PRESETS = [ type Defaults = { temperature_unit?: string + distance_unit?: DistanceUnit dark_mode?: string | boolean time_format?: string default_currency?: string @@ -27,18 +37,22 @@ type Defaults = { map_provider?: string mapbox_access_token?: string mapbox_style?: string + maplibre_style?: string mapbox_3d_enabled?: boolean mapbox_quality_mode?: boolean } -const MAPBOX_STYLE_PRESETS = [ - { name: 'Standard', url: 'mapbox://styles/mapbox/standard' }, - { name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12' }, - { name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12' }, - { name: 'Light', url: 'mapbox://styles/mapbox/light-v11' }, - { name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11' }, - { name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12' }, -] +type MapProvider = 'leaflet' | GlMapProvider + +function normalizeProvider(value: unknown): MapProvider { + return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet' +} + +function styleForProvider(provider: MapProvider, style?: string | null): string { + if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE + if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE + return normalizeStyleForProvider(provider, style) +} function OptionRow({ label, @@ -98,10 +112,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement { useEffect(() => { adminApi.getDefaultUserSettings().then((data: Defaults) => { + const provider = normalizeProvider(data.map_provider) setDefaults(data) setMapTileUrl(data.map_tile_url || '') setMapboxToken(data.mapbox_access_token || '') - setMapboxStyle(data.mapbox_style || '') + setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style)) setLoaded(true) }).catch(() => setLoaded(true)) }, []) @@ -122,7 +137,10 @@ export default function DefaultUserSettingsTab(): React.ReactElement { setDefaults(updated) if (key === 'map_tile_url') setMapTileUrl('') if (key === 'mapbox_access_token') setMapboxToken('') - if (key === 'mapbox_style') setMapboxStyle('') + if (key === 'mapbox_style' || key === 'maplibre_style') { + const provider = normalizeProvider(defaults.map_provider) + setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider)) + } toast.success(t('admin.defaultSettings.reset')) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) @@ -172,6 +190,20 @@ export default function DefaultUserSettingsTab(): React.ReactElement { } const darkMode = defaults.dark_mode + const mapProvider = normalizeProvider(defaults.map_provider) + const glStylePresets = mapProvider === 'leaflet' ? [] : getStylePresets(mapProvider) + const styleKey: keyof Defaults = mapProvider === 'maplibre-gl' ? 'maplibre_style' : 'mapbox_style' + const saveMapProvider = (nextProvider: MapProvider) => { + const patch: Partial = { map_provider: nextProvider } + if (nextProvider !== 'leaflet') { + // Load + save the new provider's own style slot so the other provider's style is kept. + const slot = nextProvider === 'maplibre-gl' ? defaults.maplibre_style : defaults.mapbox_style + const nextStyle = styleForProvider(nextProvider, slot) + setMapboxStyle(nextStyle) + patch[styleSettingKey(nextProvider)] = nextStyle + } + save(patch) + } return (
@@ -212,6 +244,22 @@ export default function DefaultUserSettingsTab(): React.ReactElement { ))} + {/* Distance */} + {t('settings.distance')} }> + {([ + { value: 'metric', label: 'km Metric' }, + { value: 'imperial', label: 'mi Imperial' }, + ] as const).map(opt => ( + save({ distance_unit: opt.value })} + > + {opt.label} + + ))} + + {/* Time Format */} {t('settings.timeFormat')} }> {([ @@ -316,19 +364,21 @@ export default function DefaultUserSettingsTab(): React.ReactElement { {([ { value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') }, { value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') }, + { value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') }, ] as const).map(opt => ( save({ map_provider: opt.value })} + active={mapProvider === opt.value} + onClick={() => saveMapProvider(opt.value)} > {opt.label} ))} - {defaults.map_provider === 'mapbox-gl' && ( + {mapProvider !== 'leaflet' && (
+ {mapProvider === 'mapbox-gl' && (
+ )}
{ if (value) { setMapboxStyle(value); save({ mapbox_style: value }) } }} + onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ [styleKey]: value }) } }} placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')} - options={MAPBOX_STYLE_PRESETS.map(p => ({ value: p.url, label: p.name }))} + options={glStylePresets.map(p => ({ value: p.url, label: p.name }))} size="sm" style={{ marginBottom: 8 }} /> @@ -364,12 +415,18 @@ export default function DefaultUserSettingsTab(): React.ReactElement { type="text" value={mapboxStyle} onChange={(e: React.ChangeEvent) => setMapboxStyle(e.target.value)} - onBlur={() => save({ mapbox_style: mapboxStyle })} - placeholder="mapbox://styles/mapbox/standard" + onBlur={() => { + const nextStyle = normalizeStyleForProvider(mapProvider, mapboxStyle) + setMapboxStyle(nextStyle) + save({ [styleKey]: nextStyle }) + }} + placeholder={defaultStyleForProvider(mapProvider)} 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" />
+ {mapProvider === 'mapbox-gl' && ( + <> {t('admin.defaultSettings.mapbox3d')} }> {([ { value: true, label: t('settings.on') || 'On' }, @@ -391,6 +448,8 @@ export default function DefaultUserSettingsTab(): React.ReactElement { ))} + + )}
)} diff --git a/client/src/components/Journey/JourneyMapAuto.tsx b/client/src/components/Journey/JourneyMapAuto.tsx index 478f5d6f..9d7e68e3 100644 --- a/client/src/components/Journey/JourneyMapAuto.tsx +++ b/client/src/components/Journey/JourneyMapAuto.tsx @@ -1,7 +1,11 @@ -import { forwardRef, useImperativeHandle, useRef } from 'react' +import { forwardRef, lazy, Suspense, useImperativeHandle, useRef } from 'react' import { useSettingsStore } from '../../store/settingsStore' import JourneyMap, { type JourneyMapHandle } from './JourneyMap' -import JourneyMapGL, { type JourneyMapGLHandle } from './JourneyMapGL' +import type { JourneyMapGLHandle } from './JourneyMapGL' + +// Lazy-load the GL renderer (and its ~230 KB gzip engine) so Leaflet-only +// installs never download it — it ships only once a GL provider is picked. +const JourneyMapGL = lazy(() => import('./JourneyMapGL')) // Unified handle — both providers expose the same three methods. export type JourneyMapAutoHandle = JourneyMapHandle @@ -37,8 +41,9 @@ const JourneyMapAuto = forwardRef(function JourneyM const glRef = useRef(null) // Fall back to Leaflet when the user selected Mapbox GL but hasn't - // supplied a token yet — otherwise the map would just show a stub. - const useGL = provider === 'mapbox-gl' && !!token + // supplied a token yet. MapLibre/OpenFreeMap is tokenless. + const useGL = provider === 'maplibre-gl' || (provider === 'mapbox-gl' && !!token) + const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl' : 'mapbox-gl' useImperativeHandle(ref, () => ({ highlightMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.highlightMarker(id), @@ -47,8 +52,12 @@ const JourneyMapAuto = forwardRef(function JourneyM }), [useGL]) if (useGL) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return + return ( + + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + + + ) } // eslint-disable-next-line @typescript-eslint/no-explicit-any return diff --git a/client/src/components/Journey/JourneyMapGL.tsx b/client/src/components/Journey/JourneyMapGL.tsx index 81b8e304..3eb4f1cc 100644 --- a/client/src/components/Journey/JourneyMapGL.tsx +++ b/client/src/components/Journey/JourneyMapGL.tsx @@ -1,8 +1,11 @@ import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react' import mapboxgl from 'mapbox-gl' +import maplibregl from 'maplibre-gl' import 'mapbox-gl/dist/mapbox-gl.css' +import 'maplibre-gl/dist/maplibre-gl.css' import { useSettingsStore } from '../../store/settingsStore' import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup' +import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders' export interface JourneyMapGLHandle { highlightMarker: (id: string | null) => void @@ -32,6 +35,7 @@ interface Props { onMarkerClick?: (id: string, type?: string) => void fullScreen?: boolean paddingBottom?: number + glProvider?: GlMapProvider } interface Item { @@ -95,8 +99,10 @@ function ensureJourneyPopupStyle() { const s = document.createElement('style') s.id = 'trek-journey-popup-style' s.textContent = ` - .mapboxgl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; } - .mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content { + .mapboxgl-popup.trek-journey-popup, + .maplibregl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; } + .mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content, + .maplibregl-popup.trek-journey-popup .maplibregl-popup-content { padding: 9px 14px 10px; border-radius: 14px; background: rgba(255, 255, 255, 0.94); @@ -108,20 +114,24 @@ function ensureJourneyPopupStyle() { min-width: 160px; max-width: 280px; } - .mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-content { + .mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-content, + .maplibregl-popup.trek-journey-popup.trek-dark .maplibregl-popup-content { background: rgba(24, 24, 27, 0.88); border-color: rgba(255, 255, 255, 0.08); color: #FAFAFA; } - .mapboxgl-popup.trek-journey-popup .mapboxgl-popup-tip { + .mapboxgl-popup.trek-journey-popup .mapboxgl-popup-tip, + .maplibregl-popup.trek-journey-popup .maplibregl-popup-tip { border-top-color: rgba(255, 255, 255, 0.94); border-bottom-color: rgba(255, 255, 255, 0.94); } - .mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip { + .mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip, + .maplibregl-popup.trek-journey-popup.trek-dark .maplibregl-popup-tip { border-top-color: rgba(24, 24, 27, 0.88); border-bottom-color: rgba(24, 24, 27, 0.88); } - .mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button { display: none; } + .mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button, + .maplibregl-popup.trek-journey-popup .maplibregl-popup-close-button { display: none; } .trek-journey-popup-title { font-size: 13.5px; font-weight: 600; @@ -132,7 +142,8 @@ function ensureJourneyPopupStyle() { overflow: hidden; text-overflow: ellipsis; } - .mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; } + .mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title, + .maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; } .trek-journey-popup-sub { display: flex; align-items: baseline; @@ -143,7 +154,8 @@ function ensureJourneyPopupStyle() { line-height: 1.35; white-space: nowrap; } - .mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; } + .mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub, + .maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; } .trek-journey-popup-place { min-width: 0; overflow: hidden; @@ -194,20 +206,29 @@ function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): H const EMPTY_TRAIL: { lat: number; lng: number }[] = [] const JourneyMapGL = forwardRef(function JourneyMapGL( - { entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom }, + { entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl' }, ref ) { const stableTrail = trail || EMPTY_TRAIL - const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard') + const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE) + const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '') const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '') const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false) const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true) + const mapLang = useSettingsStore(s => s.settings.language) + const isMapLibre = glProvider === 'maplibre-gl' + const gl = (isMapLibre ? maplibregl : mapboxgl) as any + const glStyle = styleForActiveProvider(glProvider, rawMapboxStyle, rawMaplibreStyle) + const enableMapbox3d = !isMapLibre && mapbox3d const containerRef = useRef(null) - const mapRef = useRef(null) - const markersRef = useRef>(new Map()) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mapRef = useRef(null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const markersRef = useRef>(new Map()) const itemsRef = useRef([]) const highlightedRef = useRef(null) - const popupRef = useRef(null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const popupRef = useRef(null) const onMarkerClickRef = useRef(onMarkerClick) onMarkerClickRef.current = onMarkerClick const darkRef = useRef(dark) @@ -247,7 +268,7 @@ const JourneyMapGL = forwardRef(function JourneyMapGL const el = popupRef.current.getElement() if (el) el.classList.toggle('trek-dark', !!darkRef.current) } else { - popupRef.current = new mapboxgl.Popup({ + popupRef.current = new gl.Popup({ closeButton: false, closeOnClick: false, closeOnMove: false, @@ -260,7 +281,7 @@ const JourneyMapGL = forwardRef(function JourneyMapGL .setHTML(html) .addTo(mapRef.current) } - }, []) + }, [gl]) const hidePopup = useCallback(() => { if (popupRef.current) { @@ -305,11 +326,11 @@ const JourneyMapGL = forwardRef(function JourneyMapGL mapRef.current.flyTo({ center: marker.getLngLat(), zoom: Math.max(mapRef.current.getZoom(), 14), - pitch: mapbox3d ? 45 : 0, + pitch: enableMapbox3d ? 45 : 0, duration: 600, }) } catch { /* map not yet ready */ } - }, [highlightMarker, mapbox3d]) + }, [highlightMarker, enableMapbox3d]) const invalidateSize = useCallback(() => { try { mapRef.current?.resize() } catch { /* map not yet ready */ } @@ -320,39 +341,46 @@ const JourneyMapGL = forwardRef(function JourneyMapGL // Build map once per style/token change. Markers and layers are rebuilt // inside the same effect so they stay in sync with the active style. useEffect(() => { - if (!containerRef.current || !mapboxToken) return - mapboxgl.accessToken = mapboxToken + if (!containerRef.current || (!isMapLibre && !mapboxToken)) return + if (!isMapLibre) mapboxgl.accessToken = mapboxToken const items = buildItems(entries) itemsRef.current = items - const bounds = new mapboxgl.LngLatBounds() + const bounds = new gl.LngLatBounds() items.forEach(i => bounds.extend([i.lng, i.lat])) stableTrail.forEach(p => bounds.extend([p.lng, p.lat])) const hasPoints = items.length > 0 || stableTrail.length > 0 - const map = new mapboxgl.Map({ + const mapOptions: Record = { container: containerRef.current, - style: mapboxStyle, + style: glStyle, center: hasPoints ? bounds.getCenter() : [0, 30], zoom: hasPoints ? 2 : 1, - pitch: mapbox3d && fullScreen ? 45 : 0, + pitch: enableMapbox3d && fullScreen ? 45 : 0, attributionControl: true, antialias: mapboxQuality, - projection: mapboxQuality ? 'globe' : 'mercator', - }) + } + if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator' + + const map = new gl.Map(mapOptions as any) mapRef.current = map map.on('load', () => { - if (mapbox3d) { - if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map) - if (supportsCustom3d(mapboxStyle)) addCustom3dBuildings(map, !!darkRef.current) + if (enableMapbox3d) { + if (!isStandardFamily(glStyle) && wantsTerrain(glStyle)) addTerrainAndSky(map) + if (supportsCustom3d(glStyle)) addCustom3dBuildings(map, !!darkRef.current) } // Flatten Mapbox Standard's built-in DEM so HTML markers (at Z=0) // stay pinned to their coordinates at every zoom and pitch. - if (mapboxStyle === 'mapbox://styles/mapbox/standard') { + if (glStyle === MAPBOX_DEFAULT_STYLE) { try { map.setTerrain(null) } catch { /* noop */ } } + // Pin the basemap label language to the UI language so labels don't fall back to the + // browser/OS locale and stack multiple scripts per place (#1299). + if (!isMapLibre && isStandardFamily(glStyle)) { + try { map.setConfigProperty('basemap', 'language', basemapLanguage(mapLang)) } catch { /* style/SDK may not support it */ } + } // route trail — dashed line connecting entries in time order if (items.length > 1) { @@ -383,7 +411,7 @@ const JourneyMapGL = forwardRef(function JourneyMapGL // markers items.forEach((item) => { const el = markerHtml(item.dayColor, item.dayLabel, false) - const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' }) + const marker = new gl.Marker({ element: el, anchor: 'bottom' }) .setLngLat([item.lng, item.lat]) .addTo(map) el.addEventListener('click', (ev) => { @@ -400,7 +428,7 @@ const JourneyMapGL = forwardRef(function JourneyMapGL map.fitBounds(bounds, { padding: { top: 50, bottom: pb, left: 50, right: 50 }, maxZoom: 16, - pitch: mapbox3d && fullScreen ? 45 : 0, + pitch: enableMapbox3d && fullScreen ? 45 : 0, duration: 0, }) } catch { /* empty bounds */ } @@ -418,7 +446,7 @@ const JourneyMapGL = forwardRef(function JourneyMapGL try { map.remove() } catch { /* noop */ } mapRef.current = null } - }, [entries, stableTrail, mapboxStyle, mapboxToken, mapbox3d, mapboxQuality, fullScreen, paddingBottom]) + }, [entries, stableTrail, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom]) // external activeMarkerId → highlight + flyTo useEffect(() => { @@ -431,15 +459,15 @@ const JourneyMapGL = forwardRef(function JourneyMapGL mapRef.current.flyTo({ center: marker.getLngLat(), zoom: Math.max(mapRef.current.getZoom(), 12), - pitch: mapbox3d && fullScreen ? 45 : 0, + pitch: enableMapbox3d && fullScreen ? 45 : 0, duration: 500, }) } catch { /* map not ready */ } }, 50) return () => clearTimeout(t) - }, [activeMarkerId, highlightMarker, mapbox3d, fullScreen]) + }, [activeMarkerId, highlightMarker, enableMapbox3d, fullScreen]) - if (!mapboxToken) { + if (!isMapLibre && !mapboxToken) { return (
number + on: (type: 'rotate', listener: () => void) => unknown + off: (type: 'rotate', listener: () => void) => unknown + easeTo: (options: { bearing: number; pitch: number; duration: number }) => unknown +} /** - * Round compass pill for the Mapbox planner map. The Mapbox map can be rotated and + * Round compass pill for the GL planner map. The map can be rotated and * pitched, so this shows the current bearing (the arrow points to north) and snaps * the camera back to north + flat on click. Rendered next to the POI "explore" pill - * (Mapbox only) and built as the SAME frosted shell (padding 4 around a 34px button) + * (GL only) and built as the SAME frosted shell (padding 4 around a 34px button) * so its height and transparency match the POI pill exactly. */ -export function MapCompassPill({ map }: { map: mapboxgl.Map }) { +export function MapCompassPill({ map }: { map: CompassMap }) { const [bearing, setBearing] = useState(() => map.getBearing()) useEffect(() => { diff --git a/client/src/components/Map/MapViewAuto.tsx b/client/src/components/Map/MapViewAuto.tsx index a1ce57a1..e6fdc05c 100644 --- a/client/src/components/Map/MapViewAuto.tsx +++ b/client/src/components/Map/MapViewAuto.tsx @@ -1,21 +1,36 @@ +import { lazy, Suspense } from 'react' import { useSettingsStore } from '../../store/settingsStore' import { MapView } from './MapView' -import { MapViewGL } from './MapViewGL' + +// MapLibre/Mapbox pull in a ~230 KB (gzip) GL engine. Lazy-load the GL renderer so +// Leaflet-only installs never download it — it ships only once a GL provider is picked. +const MapViewGL = lazy(() => import('./MapViewGL').then(m => ({ default: m.MapViewGL }))) // Auto-selects the map renderer based on user settings. Keeps the existing // Leaflet MapView untouched so the Mapbox GL variant can mature iteratively // behind a toggle. Atlas is not affected — it imports Leaflet directly. // // Offline maps: only the Leaflet renderer supports full pre-download (raster -// tiles via sync/tilePrefetcher.ts). Mapbox GL is best-effort offline — its +// tiles via sync/tilePrefetcher.ts). GL maps are best-effort offline — their // vector tiles are cached opportunistically by the Service Worker as you view -// them online (see the mapbox-tiles rule in vite.config.js), not prefetched. +// them online (see the GL tile rules in vite.config.js), not prefetched. // eslint-disable-next-line @typescript-eslint/no-explicit-any export function MapViewAuto(props: any) { const provider = useSettingsStore(s => s.settings.map_provider) const token = useSettingsStore(s => s.settings.mapbox_access_token) // Fall back to Leaflet when Mapbox is selected but no token is set, // so trip planner never shows an empty map due to a missing token. - if (provider === 'mapbox-gl' && token) return + const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl' + : provider === 'mapbox-gl' && token ? 'mapbox-gl' + : null + if (glProvider) { + // Render the previous Leaflet map as the fallback so there's no blank flash + // while the GL chunk loads on first use. + return ( + }> + + + ) + } return } diff --git a/client/src/components/Map/MapViewGL.test.tsx b/client/src/components/Map/MapViewGL.test.tsx index a25e8764..227722e8 100644 --- a/client/src/components/Map/MapViewGL.test.tsx +++ b/client/src/components/Map/MapViewGL.test.tsx @@ -58,6 +58,35 @@ vi.mock('mapbox-gl', () => ({ })) vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({})) +vi.mock('maplibre-gl', () => ({ + default: { + Map: vi.fn(function () { + return glMap + }), + Marker: vi.fn(function () { + return { + setLngLat: vi.fn().mockReturnThis(), + addTo: vi.fn().mockReturnThis(), + remove: vi.fn(), + getElement: vi.fn(() => document.createElement('div')), + } + }), + LngLatBounds: vi.fn(function () { + return { extend: vi.fn().mockReturnThis() } + }), + NavigationControl: vi.fn(), + Popup: vi.fn(function () { + return { + setLngLat: vi.fn().mockReturnThis(), + setHTML: vi.fn().mockReturnThis(), + addTo: vi.fn().mockReturnThis(), + remove: vi.fn(), + } + }), + }, +})) +vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({})) + vi.mock('./mapboxSetup', () => ({ isStandardFamily: vi.fn(() => false), supportsCustom3d: vi.fn(() => false), @@ -177,4 +206,25 @@ describe('MapViewGL', () => { await act(async () => {}) expect(glMap.fitBounds.mock.calls.length).toBeGreaterThan(after_first) }) + + it('FE-COMP-MAPVIEWGL-004: renders with the MapLibre provider and no token', async () => { + const mapboxgl = (await import('mapbox-gl')).default + const maplibregl = (await import('maplibre-gl')).default + useSettingsStore.setState({ + settings: { + ...useSettingsStore.getState().settings, + map_provider: 'maplibre-gl', + mapbox_access_token: '', // MapLibre/OpenFreeMap is tokenless — must not short-circuit + maplibre_style: 'https://tiles.openfreemap.org/styles/liberty', + }, + } as any) + const places = [buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 })] + + render() + await act(async () => {}) + + // The MapLibre engine builds the map even without a token; Mapbox is not used. + expect(maplibregl.Map).toHaveBeenCalled() + expect(mapboxgl.Map).not.toHaveBeenCalled() + }) }) diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 8c6ece11..65e790a3 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -1,7 +1,9 @@ import { useEffect, useRef, useMemo, useState, createElement } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import mapboxgl from 'mapbox-gl' +import maplibregl from 'maplibre-gl' import 'mapbox-gl/dist/mapbox-gl.css' +import 'maplibre-gl/dist/maplibre-gl.css' import { useSettingsStore } from '../../store/settingsStore' import { useAuthStore } from '../../store/authStore' import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService' @@ -9,6 +11,7 @@ import { CATEGORY_ICON_MAP } from '../shared/categoryIcons' import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup' import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox' import { ReservationMapboxOverlay } from './reservationsMapbox' +import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders' import LocationButton from './LocationButton' import { useGeolocation } from '../../hooks/useGeolocation' import type { Place, Reservation } from '../../types' @@ -54,7 +57,9 @@ interface Props { pois?: Poi[] onPoiClick?: (poi: Poi) => void onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void - onMapReady?: (map: mapboxgl.Map | null) => void + glProvider?: GlMapProvider + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onMapReady?: (map: any | null) => void } function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement { @@ -91,8 +96,8 @@ function createMarkerElement(place: Place & { category_color?: string; category_ } const wrap = document.createElement('div') - // Do NOT set `position: relative` here — mapbox-gl ships - // `.mapboxgl-marker { position: absolute }` and relies on it. An inline + // Do NOT set `position: relative` here — GL map libraries ship + // marker classes with `position: absolute` and rely on it. An inline // `position: relative` here overrides the class, turns every marker into // a static block element, and stacks them in document order inside the // canvas container. The result looks exactly like "markers drift as the @@ -169,29 +174,40 @@ export function MapViewGL({ pois = [], onPoiClick, onViewportChange, + glProvider = 'mapbox-gl', onMapReady, }: Props) { - const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard') + const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE) + const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '') const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '') const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false) const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true) const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false + const mapLang = useSettingsStore(s => s.settings.language) + const isMapLibre = glProvider === 'maplibre-gl' + const gl = (isMapLibre ? maplibregl : mapboxgl) as any + const glStyle = styleForActiveProvider(glProvider, rawMapboxStyle, rawMaplibreStyle) + const enableMapbox3d = !isMapLibre && mapbox3d const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled) const [photoUrls, setPhotoUrls] = useState>(getAllThumbs) const [mapReady, setMapReady] = useState(false) const containerRef = useRef(null) - const mapRef = useRef(null) - const markersRef = useRef>(new Map()) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mapRef = useRef(null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const markersRef = useRef>(new Map()) const locationMarkerRef = useRef(null) const reservationOverlayRef = useRef(null) // Refs so the reservation overlay always sees the latest callback / // options without forcing a full overlay rebuild on every prop change. const onReservationClickRef = useRef(onReservationClick) onReservationClickRef.current = onReservationClick - const poiMarkersRef = useRef([]) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const poiMarkersRef = useRef([]) // Single reusable hover popup (name/category/address card) shared by planned // places and POI markers — mirrors the Leaflet map's hover tooltip. - const popupRef = useRef(null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const popupRef = useRef(null) const onPoiClickRef = useRef(onPoiClick) onPoiClickRef.current = onPoiClick const onViewportChangeRef = useRef(onViewportChange) @@ -204,23 +220,25 @@ export function MapViewGL({ onClickRefs.current.map = onMapClick onClickRefs.current.context = onMapContextMenu - // Build/rebuild the map on style/token/3d change + // Build/rebuild the map on provider/style/token/3d change useEffect(() => { - if (!containerRef.current || !mapboxToken) return - mapboxgl.accessToken = mapboxToken + if (!containerRef.current || (!isMapLibre && !mapboxToken)) return + if (!isMapLibre) mapboxgl.accessToken = mapboxToken - const map = new mapboxgl.Map({ + const mapOptions: Record = { container: containerRef.current, - style: mapboxStyle, + style: glStyle, center: [center[1], center[0]], zoom, - pitch: mapbox3d ? 45 : 0, + pitch: enableMapbox3d ? 45 : 0, attributionControl: true, antialias: mapboxQuality, - projection: mapboxQuality ? 'globe' : 'mercator', - }) + } + if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator' + + const map = new gl.Map(mapOptions as any) mapRef.current = map - popupRef.current = new mapboxgl.Popup({ + popupRef.current = new gl.Popup({ closeButton: false, closeOnClick: false, offset: 18, @@ -234,12 +252,12 @@ export function MapViewGL({ ;(window as any).__trek_map = map map.on('load', () => { - if (mapbox3d) { + if (enableMapbox3d) { // Terrain is only valuable on satellite styles — on clean vector // styles it makes route lines drift off the HTML markers because // the lines snap to DEM height while markers stay at sea level. - if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map) - if (supportsCustom3d(mapboxStyle)) { + if (!isStandardFamily(glStyle) && wantsTerrain(glStyle)) addTerrainAndSky(map) + if (supportsCustom3d(glStyle)) { const dark = document.documentElement.classList.contains('dark') addCustom3dBuildings(map, dark) } @@ -252,7 +270,7 @@ export function MapViewGL({ // non-satellite Standard style still looks great without terrain, // so flatten it out to keep markers pinned. (Satellite variants // are left alone — the DEM is what gives them their character.) - if (mapboxStyle === 'mapbox://styles/mapbox/standard') { + if (glStyle === MAPBOX_DEFAULT_STYLE) { try { map.setTerrain(null) } catch { /* noop */ } } // initial route source — kept around so updates can setData() cheaply @@ -298,7 +316,7 @@ export function MapViewGL({ map.on('click', (e) => { const t = e.originalEvent.target as HTMLElement - if (t.closest('.mapboxgl-marker')) return // markers handle their own click + if (t.closest('.mapboxgl-marker, .maplibregl-marker')) return // markers handle their own click onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } }) }) // Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore @@ -309,7 +327,7 @@ export function MapViewGL({ } map.on('moveend', emitViewport) map.once('idle', emitViewport) - // In the mapbox-gl map the right mouse button is reserved for the + // In the GL map the right mouse button is reserved for the // built-in rotate/pitch gesture, so we bind the "add place" action // to the middle mouse button (button === 1) instead. const canvas = map.getCanvasContainer() @@ -356,7 +374,9 @@ export function MapViewGL({ const ll = marker.getLngLat() let alt = 0 try { - const e = map.queryTerrainElevation([ll.lng, ll.lat]) + const e = typeof map.queryTerrainElevation === 'function' + ? map.queryTerrainElevation([ll.lng, ll.lat]) + : null if (typeof e === 'number' && Number.isFinite(e)) alt = e } catch { /* terrain not ready */ } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -368,7 +388,9 @@ export function MapViewGL({ } }) } - map.on('render', syncMarkerAltitudes) + // Terrain altitude sync only matters with mapbox 3D/terrain on; skip the per-frame + // listener entirely for MapLibre and flat mapbox styles. + if (enableMapbox3d) map.on('render', syncMarkerAltitudes) return () => { canvas.removeEventListener('mousedown', onAuxDown) @@ -389,7 +411,17 @@ export function MapViewGL({ mapRef.current = null setMapReady(false) } - }, [mapboxStyle, mapboxToken, mapbox3d]) // rebuild on style changes only + }, [glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality]) // rebuild on provider/style changes only + + // Pin the basemap label language to the UI language so labels don't fall back to the + // browser/OS locale and stack multiple scripts per place (e.g. "India/भारत/India", #1299). + // Mapbox Standard exposes this via a basemap config property; classic and MapLibre styles + // are left as-is. Runs on load (mapReady) and whenever the UI language changes. + useEffect(() => { + const map = mapRef.current + if (!map || !mapReady || isMapLibre || !isStandardFamily(glStyle)) return + try { map.setConfigProperty('basemap', 'language', basemapLanguage(mapLang)) } catch { /* style/SDK may not support the basemap language property */ } + }, [mapLang, mapReady, isMapLibre, glStyle]) // Photo loading — mirrors the Leaflet MapView. Updates via RAF to batch // simultaneous thumb arrivals into one re-render. @@ -489,12 +521,12 @@ export function MapViewGL({ // pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain, // but it rotates the element by the pitch angle and visually offsets // the anchor by ~100px at 45° tilt, which caused the observed drift. - const m = new mapboxgl.Marker({ element: el, anchor: 'center' }) + const m = new gl.Marker({ element: el, anchor: 'center' }) .setLngLat([place.lng, place.lat]) .addTo(map) markersRef.current.set(place.id, m) }) - }, [places, selectedPlaceId, dayOrderMap, photoUrls]) + }, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider]) // Reconcile OSM "explore" POI markers (imperative, kept separate from the // planned-place markers so they don't cluster or get confused with them). @@ -511,10 +543,10 @@ export function MapViewGL({ }) el.addEventListener('mouseleave', () => { popupRef.current?.remove() }) el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) }) - const m = new mapboxgl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map) + const m = new gl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map) poiMarkersRef.current.push(m) } - }, [pois, mapReady]) + }, [pois, mapReady, glProvider]) // Update route geojson useEffect(() => { @@ -578,7 +610,7 @@ export function MapViewGL({ showStats: showReservationStats, showEndpointLabels, onEndpointClick: (id) => onReservationClickRef.current?.(id), - }) + }, gl.Marker as any) } reservationOverlayRef.current.update(visibleReservations, { showConnections: true, @@ -586,7 +618,7 @@ export function MapViewGL({ showEndpointLabels, onEndpointClick: (id) => onReservationClickRef.current?.(id), }) - }, [visibleReservations, showReservationStats, showEndpointLabels, mapReady]) + }, [visibleReservations, showReservationStats, showEndpointLabels, mapReady, glProvider]) // Fit bounds on fitKey change — matches the Leaflet BoundsController const paddingOpts = useMemo(() => { @@ -606,14 +638,14 @@ export function MapViewGL({ const target = dayPlaces.length > 0 ? dayPlaces : places const valid = target.filter(p => p.lat && p.lng) if (valid.length === 0) return - const bounds = new mapboxgl.LngLatBounds() + const bounds = new gl.LngLatBounds() valid.forEach(p => bounds.extend([p.lng, p.lat])) const run = () => { try { map.fitBounds(bounds, { padding: paddingOpts, maxZoom: 15, - pitch: mapbox3d ? 45 : 0, + pitch: enableMapbox3d ? 45 : 0, duration: 400, }) } catch { /* noop */ } @@ -632,7 +664,7 @@ export function MapViewGL({ map.flyTo({ center: [target.lng, target.lat], zoom: Math.max(map.getZoom(), 14), - pitch: mapbox3d ? 45 : 0, + pitch: enableMapbox3d ? 45 : 0, duration: 400, // Account for the side panels and the bottom inspector / day-detail panel // so the selected pin lands in the centre of the *visible* map area rather @@ -640,7 +672,7 @@ export function MapViewGL({ padding: paddingOpts, }) } catch { /* noop */ } - }, [selectedPlaceId, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps + }, [selectedPlaceId, enableMapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps // External center/zoom prop changes — jump without animation useEffect(() => { @@ -663,7 +695,7 @@ export function MapViewGL({ } if (!userPosition) return const apply = () => { - if (!locationMarkerRef.current) locationMarkerRef.current = attachLocationMarker(map) + if (!locationMarkerRef.current) locationMarkerRef.current = attachLocationMarker(map, gl.Marker as any) locationMarkerRef.current.update(userPosition) if (trackingMode === 'follow') { // easeTo is gentler than flyTo for continuous updates @@ -679,9 +711,9 @@ export function MapViewGL({ } if (map.loaded()) apply() else map.once('load', apply) - }, [userPosition, trackingMode]) + }, [userPosition, trackingMode, glProvider]) - if (!mapboxToken) { + if (!isMapLibre && !mapboxToken) { return (
diff --git a/client/src/components/Map/RouteCalculator.ts b/client/src/components/Map/RouteCalculator.ts index af86ac01..995e0404 100644 --- a/client/src/components/Map/RouteCalculator.ts +++ b/client/src/components/Map/RouteCalculator.ts @@ -1,4 +1,6 @@ -import type { RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types' +import { useSettingsStore } from '../../store/settingsStore' +import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types' +import { formatDistance } from '../../utils/units' const OSRM_BASE = 'https://router.project-osrm.org/route/v1' @@ -60,7 +62,7 @@ export async function calculateRoute( coordinates, distance, duration, - distanceText: formatDistance(distance), + distanceText: formatRouteDistance(distance), durationText: formatDuration(duration), walkingText: formatDuration(walkingDuration), drivingText: formatDuration(drivingDuration), @@ -218,7 +220,7 @@ export async function calculateSegments( duration: leg.duration, walkingText: formatDuration(walkingDuration), drivingText: formatDuration(leg.duration), - distanceText: formatDistance(leg.distance), + distanceText: formatRouteDistance(leg.distance), } }) } @@ -238,7 +240,9 @@ export async function calculateRouteWithLegs( } const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';') - const cacheKey = `${profile}:${coords}` + // The cached result carries formatted leg distances, so the active distance unit is + // part of the key — otherwise switching km↔mi would return stale text (#1300). + const cacheKey = `${profile}:${getDistanceUnit()}:${coords}` const cached = routeCache.get(cacheKey) if (cached) return cached @@ -265,7 +269,7 @@ export async function calculateRouteWithLegs( duration: leg.duration, walkingText: formatDuration(walkingDuration), drivingText: formatDuration(leg.duration), - distanceText: formatDistance(leg.distance), + distanceText: formatRouteDistance(leg.distance), durationText: formatDuration(leg.duration), } } @@ -280,11 +284,16 @@ export async function calculateRouteWithLegs( return result } -function formatDistance(meters: number): string { - if (meters < 1000) { +function getDistanceUnit(): DistanceUnit { + return useSettingsStore.getState().settings.distance_unit === 'imperial' ? 'imperial' : 'metric' +} + +function formatRouteDistance(meters: number): string { + const unit = getDistanceUnit() + if (unit === 'metric' && meters < 1000) { return `${Math.round(meters)} m` } - return `${(meters / 1000).toFixed(1)} km` + return formatDistance(meters / 1000, unit) } function formatDuration(seconds: number): string { diff --git a/client/src/components/Map/glProviders.test.ts b/client/src/components/Map/glProviders.test.ts new file mode 100644 index 00000000..899c9f83 --- /dev/null +++ b/client/src/components/Map/glProviders.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { + MAPBOX_DEFAULT_STYLE, + OPENFREEMAP_DEFAULT_STYLE, + isOpenFreeMapStyle, + normalizeStyleForProvider, + styleForActiveProvider, + basemapLanguage, +} from './glProviders' + +describe('glProviders', () => { + it('keeps OpenFreeMap styles for MapLibre', () => { + const style = 'https://tiles.openfreemap.org/styles/bright' + + expect(normalizeStyleForProvider('maplibre-gl', style)).toBe(style) + }) + + it('falls back to OpenFreeMap for MapLibre styles outside the CSP allowlist', () => { + expect(normalizeStyleForProvider('maplibre-gl', 'https://demotiles.maplibre.org/style.json')).toBe( + OPENFREEMAP_DEFAULT_STYLE, + ) + expect(normalizeStyleForProvider('maplibre-gl', MAPBOX_DEFAULT_STYLE)).toBe(OPENFREEMAP_DEFAULT_STYLE) + }) + + it('leaves Mapbox styles unchanged for Mapbox GL', () => { + expect(normalizeStyleForProvider('mapbox-gl', MAPBOX_DEFAULT_STYLE)).toBe(MAPBOX_DEFAULT_STYLE) + }) + + it('matches the OpenFreeMap CSP host', () => { + expect(isOpenFreeMapStyle('https://tiles.openfreemap.org/styles/liberty')).toBe(true) + expect(isOpenFreeMapStyle('https://demotiles.maplibre.org/style.json')).toBe(false) + }) + + it('rejects host/userinfo spoofing and http downgrade', () => { + expect(isOpenFreeMapStyle('https://tiles.openfreemap.org.evil.com/styles/x')).toBe(false) + expect(isOpenFreeMapStyle('https://evil.com/@tiles.openfreemap.org/styles/x')).toBe(false) + expect(isOpenFreeMapStyle('http://tiles.openfreemap.org/styles/liberty')).toBe(false) + expect(isOpenFreeMapStyle(' https://tiles.openfreemap.org/styles/liberty ')).toBe(true) + }) + + it('falls back to provider defaults for empty/whitespace styles', () => { + expect(normalizeStyleForProvider('maplibre-gl', '')).toBe(OPENFREEMAP_DEFAULT_STYLE) + expect(normalizeStyleForProvider('maplibre-gl', ' ')).toBe(OPENFREEMAP_DEFAULT_STYLE) + expect(normalizeStyleForProvider('mapbox-gl', '')).toBe(MAPBOX_DEFAULT_STYLE) + expect(normalizeStyleForProvider('mapbox-gl', null)).toBe(MAPBOX_DEFAULT_STYLE) + }) + + it('styleForActiveProvider reads each provider\'s own style slot', () => { + const mb = 'mapbox://styles/me/custom' + const ofm = 'https://tiles.openfreemap.org/styles/bright' + expect(styleForActiveProvider('mapbox-gl', mb, ofm)).toBe(mb) + expect(styleForActiveProvider('maplibre-gl', mb, ofm)).toBe(ofm) + // An empty MapLibre slot falls back to the OpenFreeMap default, leaving mapbox untouched. + expect(styleForActiveProvider('maplibre-gl', mb, '')).toBe(OPENFREEMAP_DEFAULT_STYLE) + }) + + it('basemapLanguage maps TREK UI codes to basemap label codes (#1299)', () => { + // Pass-through for plain ISO 639-1 codes. + expect(basemapLanguage('en')).toBe('en') + expect(basemapLanguage('de')).toBe('de') + expect(basemapLanguage('fr')).toBe('fr') + // TREK-specific overrides. + expect(basemapLanguage('br')).toBe('pt') + expect(basemapLanguage('gr')).toBe('el') + expect(basemapLanguage('zh')).toBe('zh-Hans') + expect(basemapLanguage('zhTw')).toBe('zh-Hant') + expect(basemapLanguage('zh-TW')).toBe('zh-Hant') + // Falls back to English when unset. + expect(basemapLanguage(undefined)).toBe('en') + expect(basemapLanguage('')).toBe('en') + }) +}) diff --git a/client/src/components/Map/glProviders.ts b/client/src/components/Map/glProviders.ts new file mode 100644 index 00000000..05710e5e --- /dev/null +++ b/client/src/components/Map/glProviders.ts @@ -0,0 +1,87 @@ +export type GlMapProvider = 'mapbox-gl' | 'maplibre-gl' + +export interface GlStylePreset { + name: string + url: string + tags?: string[] +} + +export const MAPBOX_DEFAULT_STYLE = 'mapbox://styles/mapbox/standard' +export const OPENFREEMAP_DEFAULT_STYLE = 'https://tiles.openfreemap.org/styles/liberty' + +export const MAPBOX_STYLE_PRESETS: GlStylePreset[] = [ + { name: 'Mapbox Standard', url: MAPBOX_DEFAULT_STYLE, 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'] }, +] + +export const OPENFREEMAP_STYLE_PRESETS: GlStylePreset[] = [ + { name: 'OpenFreeMap Liberty', url: OPENFREEMAP_DEFAULT_STYLE, tags: ['OpenFreeMap', '2D'] }, + { name: 'OpenFreeMap Bright', url: 'https://tiles.openfreemap.org/styles/bright', tags: ['OpenFreeMap', 'Classic'] }, + { name: 'OpenFreeMap Positron', url: 'https://tiles.openfreemap.org/styles/positron', tags: ['OpenFreeMap', 'Minimal'] }, +] + +export function getStylePresets(provider: GlMapProvider): GlStylePreset[] { + return provider === 'maplibre-gl' ? OPENFREEMAP_STYLE_PRESETS : MAPBOX_STYLE_PRESETS +} + +export function defaultStyleForProvider(provider: GlMapProvider): string { + return provider === 'maplibre-gl' ? OPENFREEMAP_DEFAULT_STYLE : MAPBOX_DEFAULT_STYLE +} + +export function isOpenFreeMapStyle(style?: string | null): boolean { + return (style || '').trim().startsWith('https://tiles.openfreemap.org/') +} + +export function normalizeStyleForProvider(provider: GlMapProvider, style?: string | null): string { + const trimmed = (style || '').trim() + if (!trimmed) return defaultStyleForProvider(provider) + if (provider === 'maplibre-gl') { + return isOpenFreeMapStyle(trimmed) ? trimmed : OPENFREEMAP_DEFAULT_STYLE + } + return trimmed +} + +/** The settings key that holds the style for a given GL provider. */ +export function styleSettingKey(provider: GlMapProvider): 'mapbox_style' | 'maplibre_style' { + return provider === 'maplibre-gl' ? 'maplibre_style' : 'mapbox_style' +} + +/** + * Each GL provider keeps its style in its own slot (mapbox_style / maplibre_style), so + * switching providers never overwrites the other one's custom style. Picks and normalizes + * the style for the active provider. + */ +export function styleForActiveProvider( + provider: GlMapProvider, + mapboxStyle?: string | null, + maplibreStyle?: string | null, +): string { + return normalizeStyleForProvider(provider, provider === 'maplibre-gl' ? maplibreStyle : mapboxStyle) +} + +// A few TREK UI language codes differ from what the GL basemap expects for its labels. +const BASEMAP_LANG_OVERRIDES: Record = { + br: 'pt', // TREK 'br' = Brazilian Portuguese + gr: 'el', // TREK 'gr' = Greek + zh: 'zh-Hans', + zhTw: 'zh-Hant', + 'zh-TW': 'zh-Hant', +} + +/** + * Maps a TREK UI language code to the label language the GL basemap expects. Used to pin + * Mapbox Standard's basemap labels to the user's language so they don't fall back to the + * browser/OS locale and stack multiple scripts per place (#1299). + */ +export function basemapLanguage(uiLang: string | undefined): string { + const code = (uiLang || 'en').trim() + return BASEMAP_LANG_OVERRIDES[code] ?? code +} diff --git a/client/src/components/Map/locationMarkerMapbox.ts b/client/src/components/Map/locationMarkerMapbox.ts index 44abe8ae..ff28173f 100644 --- a/client/src/components/Map/locationMarkerMapbox.ts +++ b/client/src/components/Map/locationMarkerMapbox.ts @@ -1,6 +1,13 @@ -import mapboxgl from 'mapbox-gl' +import type mapboxgl from 'mapbox-gl' import type { GeoPosition } from '../../hooks/useGeolocation' +type MarkerConstructor = new (options?: { element?: HTMLElement; anchor?: string }) => { + setLngLat: (lngLat: mapboxgl.LngLatLike) => { addTo: (map: mapboxgl.Map) => unknown } + addTo: (map: mapboxgl.Map) => unknown + remove: () => void + getElement: () => HTMLElement +} + // Build the DOM element that backs the mapbox Marker. We animate the // heading cone via a CSS rotation so the DOM stays stable across updates // and mapbox doesn't get confused about which element to position. @@ -66,10 +73,10 @@ export interface LocationMarkerHandle { // mapbox map. Returns a handle the caller uses to push position updates // and clean up. Keeps its own DOM element and GeoJSON source so it can // coexist with the regular trip markers. -export function attachLocationMarker(map: mapboxgl.Map): LocationMarkerHandle { +export function attachLocationMarker(map: mapboxgl.Map, MarkerCtor: MarkerConstructor): LocationMarkerHandle { ensurePulseStyle() const { root, cone } = buildLocationEl() - const marker = new mapboxgl.Marker({ element: root, anchor: 'center' }) + const marker = new MarkerCtor({ element: root, anchor: 'center' }) const ensureAccuracyLayer = () => { if (map.getSource('trek-location-accuracy')) return diff --git a/client/src/components/Map/reservationsMapbox.ts b/client/src/components/Map/reservationsMapbox.ts index 1722690c..401a4cc8 100644 --- a/client/src/components/Map/reservationsMapbox.ts +++ b/client/src/components/Map/reservationsMapbox.ts @@ -8,7 +8,7 @@ import { createElement } from 'react' import { renderToStaticMarkup } from 'react-dom/server' -import mapboxgl from 'mapbox-gl' +import type mapboxgl from 'mapbox-gl' import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react' import { escapeHtml } from '@trek/shared' import type { Reservation, ReservationEndpoint } from '../../types' @@ -220,18 +220,29 @@ export interface ReservationOverlayOptions { onEndpointClick?: (reservationId: number) => void } +type GlMarker = { + setLngLat: (lngLat: mapboxgl.LngLatLike) => GlMarker + addTo: (map: mapboxgl.Map) => GlMarker + remove: () => void + getElement: () => HTMLElement +} + +type MarkerConstructor = new (options?: { element?: HTMLElement; anchor?: string }) => GlMarker + export class ReservationMapboxOverlay { private map: mapboxgl.Map private items: TransportItem[] = [] private opts: ReservationOverlayOptions - private endpointMarkers: mapboxgl.Marker[] = [] - private statsMarkers: { marker: mapboxgl.Marker; arc: [number, number][] }[] = [] + private MarkerCtor: MarkerConstructor + private endpointMarkers: GlMarker[] = [] + private statsMarkers: { marker: GlMarker; arc: [number, number][] }[] = [] private rerender: () => void private destroyed = false - constructor(map: mapboxgl.Map, opts: ReservationOverlayOptions) { + constructor(map: mapboxgl.Map, opts: ReservationOverlayOptions, MarkerCtor: MarkerConstructor) { this.map = map this.opts = opts + this.MarkerCtor = MarkerCtor this.rerender = () => { if (!this.destroyed) this.render() } this.setupLayer() map.on('zoomend', this.rerender) @@ -350,7 +361,7 @@ export class ReservationMapboxOverlay { this.opts.onEndpointClick?.(item.res.id) }) } - const marker = new mapboxgl.Marker({ element: node, anchor: 'center' }) + const marker = new this.MarkerCtor({ element: node, anchor: 'center' }) .setLngLat([ep.lng, ep.lat]) .addTo(map) this.endpointMarkers.push(marker) diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index 7038db2d..28c37f1b 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -168,6 +168,34 @@ describe('DayPlanSidebar', () => { expect(screen.getByText('D2')).toBeInTheDocument() }) + // ── #1330: route tools for a single optimizable place ─────────────────────── + it('FE-PLANNER-DAYPLAN-005b: route tools show for one located place with a bookend hotel (#1330)', () => { + const place = buildPlace({ name: 'Louvre', lat: 48.86, lng: 2.34 }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const day2 = buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }) + const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) + const accommodations = [{ id: 1, start_day_id: 10, end_day_id: 11, place_lat: 48.85, place_lng: 2.35 }] + render() + // With accommodation optimization on, one located place is routable (hotel → place → hotel), + // so the route tools (here the Google Maps export button) must be visible. + expect(screen.getByRole('button', { name: 'Open in Google Maps' })).toBeInTheDocument() + }) + + it('FE-PLANNER-DAYPLAN-005c: route tools stay hidden for one place with no bookend hotel (#1330 guard)', () => { + const place = buildPlace({ name: 'Louvre', lat: 48.86, lng: 2.34 }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) + render() + // No accommodation to bookend the lone place, so nothing routable — tools stay hidden. + expect(screen.queryByRole('button', { name: 'Open in Google Maps' })).not.toBeInTheDocument() + }) + // ── Day expansion/collapse ────────────────────────────────────────────── it('FE-PLANNER-DAYPLAN-006: days are expanded by default', () => { diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 8c168915..892945a3 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -35,6 +35,7 @@ import { DayPlanSidebarTimeConfirmModal } from './DayPlanSidebarTimeConfirmModal import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDetailModal' import { DayPlanSidebarFooter } from './DayPlanSidebarFooter' import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types' +import { getGoogleMapsUrlForPlace } from './placeGoogleMaps' interface DayPlanSidebarProps { tripId: number @@ -154,6 +155,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { const [routeLegs, setRouteLegs] = useState>({}) const [hotelLegs, setHotelLegs] = useState<{ top?: { seg: RouteSegment; name: string }; bottom?: { seg: RouteSegment; name: string } }>({}) const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation) + // Recompute the hotel/route legs when the user flips km↔mi so the connector + // distances refresh instead of showing stale cached text (#1300). + const distanceUnit = useSettingsStore(s => s.settings.distance_unit) const legsAbortRef = useRef(null) const [draggingId, setDraggingId] = useState(null) const [lockedIds, setLockedIds] = useState(new Set()) @@ -411,25 +415,30 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { // waypoint of the day (morning) and from the last one back to it (evening). Only when // the "optimize from accommodation" setting is on and the day has a hotel. const day = days.find(d => d.id === selectedDayId) - const { morning: startHotel, evening: endHotel } = - day && optimizeFromAccommodation !== false ? getDayBookendHotels(day, days, accommodations) : {} + const bookends = day && optimizeFromAccommodation !== false + ? getDayBookendHotels(day, days, accommodations) + : null + const startHotel = bookends?.morning + const endHotel = bookends?.evening const hotelName = (a: Accommodation) => (a as any).place_name || (a as any).reservation_title || '' // Waypoints include transport endpoints (a car return, a taxi/train arrival), so the hotel - // legs connect even when the day starts or ends with a booking rather than a place. - const wayPts: { lat: number; lng: number }[] = [] + // legs connect even when the day starts or ends with a booking rather than a place. Track + // whether each is a place so we can skip a hotel↔transport leg that isn't real: on a day-1 + // arrival the check-in hotel never drove to the departure airport (#1321). + const wayPts: { lat: number; lng: number; isPlace: boolean }[] = [] for (const it of merged) { if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) { - wayPts.push({ lat: it.data.place.lat, lng: it.data.place.lng }) + wayPts.push({ lat: it.data.place.lat, lng: it.data.place.lng, isPlace: true }) } else if (it.type === 'transport') { const { from, to } = getTransportRouteEndpoints(it.data, selectedDayId) - if (from) wayPts.push({ lat: from.lat, lng: from.lng }) - if (to) wayPts.push({ lat: to.lat, lng: to.lng }) + if (from) wayPts.push({ lat: from.lat, lng: from.lng, isPlace: false }) + if (to) wayPts.push({ lat: to.lat, lng: to.lng, isPlace: false }) } } const firstWay = wayPts[0] const lastWay = wayPts[wayPts.length - 1] - const wantTop = !!(startHotel && firstWay) - const wantBottom = !!(endHotel && lastWay) + const wantTop = !!(startHotel && firstWay && (firstWay.isPlace || bookends?.morningIsSleptHere)) + const wantBottom = !!(endHotel && lastWay && (lastWay.isPlace || bookends?.eveningIsOvernight)) if (runs.length === 0 && !wantTop && !wantBottom) { setRouteLegs({}); setHotelLegs({}); return } @@ -465,7 +474,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { if (!controller.signal.aborted) { setRouteLegs(map); setHotelLegs(hotel) } })() - }, [selectedDayId, routeShown, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation]) + }, [selectedDayId, routeShown, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation, distanceUnit]) const openAddNote = (dayId, e) => { e?.stopPropagation() @@ -1046,6 +1055,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarProps) { const S = useDayPlanSidebar(props) + // Needed by the route-tools visibility gate in the render below (#1330); the hook + // keeps its own copy, so read it reactively here in the component scope too. + const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation) const { tripId, trip, @@ -1231,6 +1243,16 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP const cost = dayTotalCost(day.id, assignments, currency) const formattedDate = formatDate(day.date, locale) const loc = da.find(a => a.place?.lat && a.place?.lng) + // Route tools normally need 2+ stops, but a single located place is still + // routable when accommodation optimization can bookend it with a hotel + // (hotel → place → hotel, the same line the map draws) — otherwise the tools + // vanish on such a day (#1330). Purely additive to the 2+ case. + const routeBookends = optimizeFromAccommodation !== false ? getDayBookendHotels(day, days, accommodations) : null + const hasRouteBookend = !!( + (routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null) || + (routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null) + ) + const routeToolsRoutable = da.length >= 2 || (loc != null && hasRouteBookend) const isDragTarget = dragOverDayId === day.id const merged = mergedItemsMap[day.id] || [] const dayNoteUi = noteUi[day.id] @@ -1595,14 +1617,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP }} onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }} onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }} - onContextMenu={e => ctxMenu.open(e, [ - canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) }, - canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) }, - place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') }, - (place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank') }, - { divider: true }, - canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) }, - ])} + onContextMenu={e => { + const googleMapsUrl = getGoogleMapsUrlForPlace(place) + ctxMenu.open(e, [ + canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) }, + canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) }, + place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') }, + googleMapsUrl && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(googleMapsUrl, '_blank') }, + { divider: true }, + canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) }, + ]) + }} onMouseEnter={e => { if (!isPlaceSelected && !lockedIds.has(assignment.id)) e.currentTarget.style.background = 'var(--bg-hover)' @@ -2151,8 +2176,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP )}
- {/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */} - {(isSelected || (showRouteToolsWhenExpanded && isExpanded)) && getDayAssignments(day.id).length >= 2 && ( + {/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte — oder 1 Ort mit Hotel-Bookend, #1330) */} + {(isSelected || (showRouteToolsWhenExpanded && isExpanded)) && routeToolsRoutable && (
@@ -288,14 +297,10 @@ export default function PlaceInspector({ onAssignToDay(place.id)} variant="primary" icon={} label={t('inspector.addToDay')} /> ) )} - {googleDetails?.google_maps_url && ( - window.open(googleDetails.google_maps_url, '_blank')} variant="ghost" icon={} + {googleMapsUrl && ( + window.open(googleMapsUrl, '_blank')} variant="ghost" icon={} label={{t('inspector.google')}} /> )} - {!googleDetails?.google_maps_url && place.lat && place.lng && ( - window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank')} variant="ghost" icon={} - label={Google Maps} /> - )} {(place.website || googleDetails?.website) && ( window.open(place.website || googleDetails?.website, '_blank')} variant="ghost" icon={} label={{t('inspector.website')}} /> @@ -682,7 +687,7 @@ function PlaceReservationParticipants({ selectedAssignmentId, reservations, assi } function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpanded, timeFormat, t, place, - placeFiles, onFileUpload, filesExpanded, setFilesExpanded, fileInputRef, handleFileUpload, isUploading }: any) { + placeFiles, onFileUpload, filesExpanded, setFilesExpanded, fileInputRef, handleFileUpload, isUploading, distanceUnit }: any) { return (
0 ? 'sm:grid-cols-2' : ''} gap-2`}> {openingHours && openingHours.length > 0 && ( @@ -775,20 +780,20 @@ function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpand
- {distKm < 1 ? `${Math.round(totalDist)} m` : `${distKm.toFixed(1)} km`} + {formatDistance(distKm, distanceUnit)}
{hasEle && ( <>
- {Math.round(maxEle)} m + {formatElevation(maxEle, distanceUnit)}
- {Math.round(minEle)} m + {formatElevation(minEle, distanceUnit)}
- ↑{Math.round(totalUp)} m  ↓{Math.round(totalDown)} m + ↑{formatElevation(totalUp, distanceUnit)}  ↓{formatElevation(totalDown, distanceUnit)}
)} diff --git a/client/src/components/Planner/PlacesSidebar.test.tsx b/client/src/components/Planner/PlacesSidebar.test.tsx index adb2881d..9c108911 100644 --- a/client/src/components/Planner/PlacesSidebar.test.tsx +++ b/client/src/components/Planner/PlacesSidebar.test.tsx @@ -124,6 +124,40 @@ describe('PlacesSidebar', () => { expect(screen.getByText('Central Park')).toBeInTheDocument(); }); + it('FE-COMP-PLACES-009a: selected visible place is scrolled into view', async () => { + const scrollIntoView = Element.prototype.scrollIntoView as unknown as ReturnType; + scrollIntoView.mockClear(); + const places = [ + buildPlace({ id: 10, name: 'First Place' }), + buildPlace({ id: 42, name: 'Map Click Target' }), + ]; + + render(); + + const selectedRow = screen.getByText('Map Click Target').closest('[data-place-id="42"]'); + expect(selectedRow).toHaveAttribute('aria-selected', 'true'); + await waitFor(() => { + expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' }); + }); + }); + + it('FE-COMP-PLACES-009b: selected place hidden by search is not scrolled', async () => { + const user = userEvent.setup(); + const scrollIntoView = Element.prototype.scrollIntoView as unknown as ReturnType; + const places = [ + buildPlace({ id: 10, name: 'Visible Cafe' }), + buildPlace({ id: 42, name: 'Hidden Museum' }), + ]; + const { rerender } = render(); + + await user.type(screen.getByPlaceholderText(/Search places/i), 'Visible'); + scrollIntoView.mockClear(); + rerender(); + + expect(screen.queryByText('Hidden Museum')).not.toBeInTheDocument(); + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + it('FE-COMP-PLACES-010: shows place count', () => { const places = [buildPlace({ name: 'P1' }), buildPlace({ name: 'P2' }), buildPlace({ name: 'P3' })]; render(); diff --git a/client/src/components/Planner/PlacesSidebarList.tsx b/client/src/components/Planner/PlacesSidebarList.tsx index 631eab63..40da9ef4 100644 --- a/client/src/components/Planner/PlacesSidebarList.tsx +++ b/client/src/components/Planner/PlacesSidebarList.tsx @@ -5,7 +5,7 @@ export function PlacesList(S: SidebarState) { const { filtered, scrollContainerRef, onScrollTopChange, filter, t, canEditPlaces, onAddPlace, categories, selectedPlaceId, plannedIds, inDaySet, selectedIds, selectMode, selectedDayId, - isMobile, onPlaceClick, openContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, + isMobile, onPlaceClick, openContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow, } = S return (
onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}> @@ -44,6 +44,7 @@ export function PlacesList(S: SidebarState) { onAssignToDay={onAssignToDay} toggleSelected={toggleSelected} setDayPickerPlace={setDayPickerPlace} + registerPlaceRow={registerPlaceRow} /> ) }) diff --git a/client/src/components/Planner/PlacesSidebarRow.tsx b/client/src/components/Planner/PlacesSidebarRow.tsx index 14131282..6b7388a2 100644 --- a/client/src/components/Planner/PlacesSidebarRow.tsx +++ b/client/src/components/Planner/PlacesSidebarRow.tsx @@ -21,17 +21,21 @@ interface MemoPlaceRowProps { onAssignToDay: (placeId: number, dayId?: number) => void toggleSelected: (id: number) => void setDayPickerPlace: (place: any) => void + registerPlaceRow: (placeId: number, element: HTMLDivElement | null) => void } export const MemoPlaceRow = React.memo(function MemoPlaceRow({ place, category: cat, isSelected, isPlanned, inDay, isChecked, selectMode, selectedDayId, canEditPlaces, isMobile, t, - onPlaceClick, onContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, + onPlaceClick, onContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow, }: MemoPlaceRowProps) { const hasGeometry = Boolean(place.route_geometry) return (
registerPlaceRow(place.id, element)} + aria-selected={isSelected} + data-place-id={place.id} draggable={!selectMode} onDragStart={e => { e.dataTransfer.setData('placeId', String(place.id)) diff --git a/client/src/components/Planner/placeGoogleMaps.test.ts b/client/src/components/Planner/placeGoogleMaps.test.ts new file mode 100644 index 00000000..11f0ae4e --- /dev/null +++ b/client/src/components/Planner/placeGoogleMaps.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest' +import { getGoogleMapsUrlForPlace } from './placeGoogleMaps' + +const base = { name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945, google_place_id: null, google_ftid: null } as any + +describe('getGoogleMapsUrlForPlace', () => { + it('FE-PLACE-GMAPS-001: uses a valid ftid for a precise /place link', () => { + const url = getGoogleMapsUrlForPlace({ ...base, google_ftid: '0x47e66e2964e34e2d:0x8ddca9ee380ef7e0' }) + expect(url).toBe('https://www.google.com/maps/place/?q=Eiffel%20Tower&ftid=0x47e66e2964e34e2d:0x8ddca9ee380ef7e0') + }) + + it('FE-PLACE-GMAPS-002: falls back to query_place_id when there is no ftid', () => { + const url = getGoogleMapsUrlForPlace({ ...base, google_place_id: 'ChIJ123' }) + expect(url).toBe('https://www.google.com/maps/search/?api=1&query=Eiffel%20Tower&query_place_id=ChIJ123') + }) + + it('FE-PLACE-GMAPS-003: ignores a malformed/hostile ftid and falls through to the place id', () => { + const url = getGoogleMapsUrlForPlace({ ...base, google_ftid: '0xAB&q=evil', google_place_id: 'ChIJ123' }) + expect(url).toBe('https://www.google.com/maps/search/?api=1&query=Eiffel%20Tower&query_place_id=ChIJ123') + }) + + it('FE-PLACE-GMAPS-004: uses the details URL when there is no ftid or place id', () => { + const url = getGoogleMapsUrlForPlace(base, 'https://maps.google.com/?cid=123') + expect(url).toBe('https://maps.google.com/?cid=123') + }) + + it('FE-PLACE-GMAPS-005: falls back to coordinates as a last resort', () => { + const url = getGoogleMapsUrlForPlace(base) + expect(url).toBe('https://www.google.com/maps/search/?api=1&query=48.8584,2.2945') + }) + + it('FE-PLACE-GMAPS-006: returns null for no place or no location', () => { + expect(getGoogleMapsUrlForPlace(null)).toBeNull() + expect(getGoogleMapsUrlForPlace({ ...base, lat: null, lng: null })).toBeNull() + }) +}) diff --git a/client/src/components/Planner/placeGoogleMaps.ts b/client/src/components/Planner/placeGoogleMaps.ts new file mode 100644 index 00000000..3d4e619f --- /dev/null +++ b/client/src/components/Planner/placeGoogleMaps.ts @@ -0,0 +1,19 @@ +import type { AssignmentPlace, Place } from '../../types' + +type PlaceLike = Pick +const GOOGLE_FTID_RE = /^0x[0-9a-f]+:0x[0-9a-f]+$/i + +export function getGoogleMapsUrlForPlace(place: PlaceLike | null | undefined, detailsUrl?: string | null): string | null { + if (!place) return null + const ftid = place.google_ftid?.trim() + if (ftid && GOOGLE_FTID_RE.test(ftid)) { + return `https://www.google.com/maps/place/?q=${encodeURIComponent(place.name)}&ftid=${ftid}` + } + const placeId = place.google_place_id?.trim() + if (placeId) { + return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(place.name)}&query_place_id=${encodeURIComponent(placeId)}` + } + if (detailsUrl) return detailsUrl + if (place.lat == null || place.lng == null) return null + return `https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}` +} diff --git a/client/src/components/Planner/usePlacesSidebar.ts b/client/src/components/Planner/usePlacesSidebar.ts index 8518702c..997cc3b9 100644 --- a/client/src/components/Planner/usePlacesSidebar.ts +++ b/client/src/components/Planner/usePlacesSidebar.ts @@ -9,6 +9,7 @@ import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' import { useAuthStore } from '../../store/authStore' import type { Place, Category, Day, AssignmentsMap } from '../../types' +import { getGoogleMapsUrlForPlace } from './placeGoogleMaps' export interface PlacesSidebarProps { tripId: number @@ -59,6 +60,8 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { const [sidebarDragOver, setSidebarDragOver] = useState(false) const sidebarDragCounter = useRef(0) const scrollContainerRef = useRef(null) + const placeRowRefs = useRef(new Map()) + const lastAutoScrolledPlaceIdRef = useRef(null) useLayoutEffect(() => { if (scrollContainerRef.current && initialScrollTop) { scrollContainerRef.current.scrollTop = initialScrollTop @@ -197,6 +200,28 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { return true }), [places, filter, categoryFilters, search, plannedIds]) + const registerPlaceRow = useCallback((placeId: number, element: HTMLDivElement | null) => { + if (element) { + placeRowRefs.current.set(placeId, element) + } else { + placeRowRefs.current.delete(placeId) + } + }, []) + + useEffect(() => { + if (!props.selectedPlaceId) { + lastAutoScrolledPlaceIdRef.current = null + return + } + if (lastAutoScrolledPlaceIdRef.current === props.selectedPlaceId) return + if (!filtered.some(place => place.id === props.selectedPlaceId)) return + + const selectedRow = placeRowRefs.current.get(props.selectedPlaceId) + if (!selectedRow) return + selectedRow.scrollIntoView({ behavior: 'smooth', block: 'center' }) + lastAutoScrolledPlaceIdRef.current = props.selectedPlaceId + }, [filtered, props.selectedPlaceId]) + const isAssignedToSelectedDay = (placeId) => selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId) @@ -210,11 +235,12 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { const openContextMenu = useCallback((e: React.MouseEvent, place: Place) => { const selDayId = selectedDayIdRef.current + const googleMapsUrl = getGoogleMapsUrlForPlace(place) ctxMenu.open(e, [ canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => props.onEditPlace(place) }, selDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => props.onAssignToDay(place.id, selDayId) }, place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') }, - (place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${(place as any).google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + (place as any).google_place_id : place.lat + ',' + place.lng}`, '_blank') }, + googleMapsUrl && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(googleMapsUrl, '_blank') }, { divider: true }, canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => props.onDeletePlace(place.id) }, ]) @@ -234,7 +260,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds, exitSelectMode, toggleSelected, toggleCategoryFilter, dayPickerPlace, setDayPickerPlace, catDropOpen, setCatDropOpen, mobileShowDays, setMobileShowDays, - hasTracks, plannedIds, filtered, isAssignedToSelectedDay, inDaySet, openContextMenu, + hasTracks, plannedIds, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu, } } diff --git a/client/src/components/Settings/DisplaySettingsTab.test.tsx b/client/src/components/Settings/DisplaySettingsTab.test.tsx index 51f1b80b..235d14ad 100644 --- a/client/src/components/Settings/DisplaySettingsTab.test.tsx +++ b/client/src/components/Settings/DisplaySettingsTab.test.tsx @@ -150,6 +150,22 @@ describe('DisplaySettingsTab', () => { expect(updateSetting).toHaveBeenCalledWith('temperature_unit', 'fahrenheit'); }); + it('FE-COMP-DISPLAY-028: metric distance button is active by default', () => { + seedStore(useSettingsStore, { settings: { temperature_unit: 'celsius' } }); + render(); + const metricBtn = screen.getByText('km Metric').closest('button')!; + expect(metricBtn.style.border).toContain('var(--text-primary)'); + }); + + it('FE-COMP-DISPLAY-029: clicking imperial distance calls updateSetting with imperial', async () => { + const user = userEvent.setup(); + const updateSetting = vi.fn().mockResolvedValue(undefined); + seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'metric' }), updateSetting }); + render(); + await user.click(screen.getByText('mi Imperial')); + expect(updateSetting).toHaveBeenCalledWith('distance_unit', 'imperial'); + }); + it('FE-COMP-DISPLAY-020: clicking 24h time format calls updateSetting with 24h', async () => { const user = userEvent.setup(); const updateSetting = vi.fn().mockResolvedValue(undefined); diff --git a/client/src/components/Settings/DisplaySettingsTab.tsx b/client/src/components/Settings/DisplaySettingsTab.tsx index d5cb40ed..164b8751 100644 --- a/client/src/components/Settings/DisplaySettingsTab.tsx +++ b/client/src/components/Settings/DisplaySettingsTab.tsx @@ -6,12 +6,14 @@ import { useToast } from '../shared/Toast' import CustomSelect from '../shared/CustomSelect' import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants' import Section from './Section' +import type { DistanceUnit } from '../../types' export default function DisplaySettingsTab(): React.ReactElement { const { settings, updateSetting } = useSettingsStore() const { t } = useTranslation() const toast = useToast() const [tempUnit, setTempUnit] = useState(settings.temperature_unit || 'celsius') + const [distanceUnit, setDistanceUnit] = useState(settings.distance_unit || 'metric') const [langOpen, setLangOpen] = useState(false) const langDropdownRef = useRef(null) @@ -28,6 +30,10 @@ export default function DisplaySettingsTab(): React.ReactElement { setTempUnit(settings.temperature_unit || 'celsius') }, [settings.temperature_unit]) + useEffect(() => { + setDistanceUnit(settings.distance_unit || 'metric') + }, [settings.distance_unit]) + return (
{/* Display currency */} @@ -200,6 +206,37 @@ export default function DisplaySettingsTab(): React.ReactElement {
+ {/* Distance */} +
+ +
+ {([ + { value: 'metric', label: 'km Metric' }, + { value: 'imperial', label: 'mi Imperial' }, + ] as const).map(opt => ( + + ))} +
+
+ {/* Time Format */}
diff --git a/client/src/components/Settings/MapSettingsTab.tsx b/client/src/components/Settings/MapSettingsTab.tsx index bacae3e9..1f5736f2 100644 --- a/client/src/components/Settings/MapSettingsTab.tsx +++ b/client/src/components/Settings/MapSettingsTab.tsx @@ -1,14 +1,22 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' -import { Map, Save, Layers, Box, ChevronDown, Check } from 'lucide-react' +import { Map, Save, Layers, Box, ChevronDown, Check, Globe2 } 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 GlMapPreview from './MapboxPreview' import Section from './Section' import ToggleSwitch from './ToggleSwitch' import type { Place } from '../../types' +import { + MAPBOX_DEFAULT_STYLE, + defaultStyleForProvider, + getStylePresets, + isOpenFreeMapStyle, + normalizeStyleForProvider, + type GlMapProvider, +} from '../Map/glProviders' interface MapPreset { name: string @@ -23,25 +31,6 @@ const MAP_PRESETS: MapPreset[] = [ { 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 = { @@ -59,6 +48,7 @@ const TAG_STYLES: Record = { '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', + 'OpenFreeMap': 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300', } function TagChip({ tag }: { tag: string }) { @@ -70,10 +60,11 @@ function TagChip({ tag }: { tag: string }) { ) } -function StyleDropdown({ value, onChange }: { value: string; onChange: (v: string) => void }) { +function StyleDropdown({ value, provider, onChange }: { value: string; provider: GlMapProvider; onChange: (v: string) => void }) { const { t } = useTranslation() const [open, setOpen] = useState(false) const ref = useRef(null) + const presets = getStylePresets(provider) useEffect(() => { if (!open) return @@ -84,7 +75,10 @@ function StyleDropdown({ value, onChange }: { value: string; onChange: (v: strin return () => document.removeEventListener('mousedown', onDoc) }, [open]) - const selected = MAPBOX_STYLE_PRESETS.find(p => p.url === value) + const selected = presets.find(p => p.url === value) + const placeholder = provider === 'maplibre-gl' + ? t('settings.mapOpenFreeMapStylePlaceholder') + : t('settings.mapStylePlaceholder') return (
@@ -95,11 +89,11 @@ function StyleDropdown({ value, onChange }: { value: string; onChange: (v: strin > - {selected ? selected.name : t('settings.mapStylePlaceholder')} + {selected ? selected.name : placeholder} {selected && ( - {selected.tags.map(t => )} + {(selected.tags || []).map(t => )} )} @@ -107,7 +101,7 @@ function StyleDropdown({ value, onChange }: { value: string; onChange: (v: strin {open && (
- {MAPBOX_STYLE_PRESETS.map(preset => { + {presets.map(preset => { const isActive = preset.url === value return ( @@ -130,17 +124,34 @@ function StyleDropdown({ value, onChange }: { value: string; onChange: (v: strin ) } -type Provider = 'leaflet' | 'mapbox-gl' +type Provider = 'leaflet' | GlMapProvider + +function normalizeProvider(value: unknown): Provider { + return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet' +} + +function styleForProvider(provider: Provider, style?: string | null): string { + if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE + if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE + return normalizeStyleForProvider(provider, style) +} + +// Each GL provider has its own style slot, so toggling providers never clobbers the +// other one's style. Leaflet/Mapbox use mapbox_style; MapLibre uses maplibre_style. +function slotStyle(provider: Provider, s: { mapbox_style?: string; maplibre_style?: string }): string | undefined { + return provider === 'maplibre-gl' ? s.maplibre_style : s.mapbox_style +} export default function MapSettingsTab(): React.ReactElement { const { settings, updateSettings } = useSettingsStore() const { t } = useTranslation() const toast = useToast() + const initialProvider = normalizeProvider(settings.map_provider) const [saving, setSaving] = useState(false) - const [provider, setProvider] = useState((settings.map_provider as Provider) || 'leaflet') + const [provider, setProvider] = useState(initialProvider) const [mapTileUrl, setMapTileUrl] = useState(settings.map_tile_url || '') const [mapboxToken, setMapboxToken] = useState(settings.mapbox_access_token || '') - const [mapboxStyle, setMapboxStyle] = useState(settings.mapbox_style || 'mapbox://styles/mapbox/standard') + const [mapboxStyle, setMapboxStyle] = useState(styleForProvider(initialProvider, slotStyle(initialProvider, settings))) const [mapbox3d, setMapbox3d] = useState(settings.mapbox_3d_enabled !== false) const [mapboxQuality, setMapboxQuality] = useState(settings.mapbox_quality_mode === true) const [defaultLat, setDefaultLat] = useState(settings.default_lat || 48.8566) @@ -148,10 +159,11 @@ export default function MapSettingsTab(): React.ReactElement { const [defaultZoom, setDefaultZoom] = useState(settings.default_zoom || 10) useEffect(() => { - setProvider((settings.map_provider as Provider) || 'leaflet') + const nextProvider = normalizeProvider(settings.map_provider) + setProvider(nextProvider) setMapTileUrl(settings.map_tile_url || '') setMapboxToken(settings.mapbox_access_token || '') - setMapboxStyle(settings.mapbox_style || 'mapbox://styles/mapbox/standard') + setMapboxStyle(styleForProvider(nextProvider, slotStyle(nextProvider, settings))) setMapbox3d(settings.mapbox_3d_enabled !== false) setMapboxQuality(settings.mapbox_quality_mode === true) setDefaultLat(settings.default_lat || 48.8566) @@ -186,11 +198,15 @@ export default function MapSettingsTab(): React.ReactElement { const saveMapSettings = async (): Promise => { setSaving(true) try { + const glStyle = provider === 'leaflet' ? mapboxStyle : normalizeStyleForProvider(provider, mapboxStyle) + setMapboxStyle(glStyle) + // Save into the active provider's own slot so the other provider's style survives. + const stylePatch = provider === 'maplibre-gl' ? { maplibre_style: glStyle } : { mapbox_style: glStyle } await updateSettings({ map_provider: provider, map_tile_url: mapTileUrl, mapbox_access_token: mapboxToken, - mapbox_style: mapboxStyle, + ...stylePatch, mapbox_3d_enabled: mapbox3d, mapbox_quality_mode: mapboxQuality, default_lat: parseFloat(String(defaultLat)), @@ -208,16 +224,20 @@ export default function MapSettingsTab(): React.ReactElement { // 3D is available on every style now — pure satellite uses the // mapbox-streets-v8 tileset as a fallback building source. const supports3d = true + const changeProvider = (nextProvider: Provider) => { + setProvider(nextProvider) + if (nextProvider !== 'leaflet') setMapboxStyle(styleForProvider(nextProvider, mapboxStyle)) + } return (
{/* Provider picker — big cards so the choice is obvious */}
-
+
+

{t('settings.mapProviderHint')} @@ -281,9 +319,10 @@ export default function MapSettingsTab(): React.ReactElement {

)} - {/* Mapbox GL settings */} - {provider === 'mapbox-gl' && ( + {/* GL settings */} + {provider !== 'leaflet' && (
+ {provider === 'mapbox-gl' && (

+ )}
- +
setMapboxStyle(e.target.value)} - placeholder="mapbox://styles/mapbox/standard" + placeholder={defaultStyleForProvider(provider)} 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" />

- {t('settings.mapStyleHint')} + {provider === 'maplibre-gl' ? t('settings.mapOpenFreeMapStyleHint') : t('settings.mapStyleHint')}

+ {provider === 'mapbox-gl' && ( + <>
{t('settings.mapTipLabel')} {t('settings.mapTip')}
+ + )}
)} @@ -383,8 +427,9 @@ export default function MapSettingsTab(): React.ReactElement {
- {provider === 'mapbox-gl' ? ( - { setDefaultLat(ll.lat); setDefaultLng(ll.lng) }} /> ) : ( diff --git a/client/src/components/Settings/MapboxPreview.tsx b/client/src/components/Settings/MapboxPreview.tsx index 04ca864a..3f336228 100644 --- a/client/src/components/Settings/MapboxPreview.tsx +++ b/client/src/components/Settings/MapboxPreview.tsx @@ -1,10 +1,14 @@ import { useEffect, useRef } from 'react' import mapboxgl from 'mapbox-gl' +import maplibregl from 'maplibre-gl' import 'mapbox-gl/dist/mapbox-gl.css' +import 'maplibre-gl/dist/maplibre-gl.css' import { isStandardFamily, supportsCustom3d, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup' +import { MAPBOX_DEFAULT_STYLE, normalizeStyleForProvider, type GlMapProvider } from '../Map/glProviders' interface Props { - token: string + provider?: GlMapProvider + token?: string style: string lat: number lng: number @@ -14,37 +18,44 @@ interface Props { onClick?: (latlng: { lat: number; lng: number }) => void } -export default function MapboxPreview({ token, style, lat, lng, zoom, enable3d, quality = false, onClick }: Props) { +export default function GlMapPreview({ provider = 'mapbox-gl', token = '', style, lat, lng, zoom, enable3d, quality = false, onClick }: Props) { const containerRef = useRef(null) - const mapRef = useRef(null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mapRef = useRef(null) const onClickRef = useRef(onClick) onClickRef.current = onClick + const isMapLibre = provider === 'maplibre-gl' + const gl = (isMapLibre ? maplibregl : mapboxgl) as any + const glStyle = normalizeStyleForProvider(provider, style) + const enableMapbox3d = !isMapLibre && enable3d useEffect(() => { - if (!containerRef.current || !token) return - mapboxgl.accessToken = token + if (!containerRef.current || (!isMapLibre && !token)) return + if (!isMapLibre) mapboxgl.accessToken = token - const map = new mapboxgl.Map({ + const mapOptions: Record = { container: containerRef.current, - style, + style: glStyle, center: [lng, lat], zoom, - pitch: enable3d ? 45 : 0, + pitch: enableMapbox3d ? 45 : 0, attributionControl: true, antialias: quality, - projection: quality ? 'globe' : 'mercator', - }) + } + if (!isMapLibre) mapOptions.projection = quality ? 'globe' : 'mercator' + + const map = new gl.Map(mapOptions as any) mapRef.current = map map.on('load', () => { - if (enable3d) { - if (!isStandardFamily(style)) addTerrainAndSky(map) - if (supportsCustom3d(style)) { + if (enableMapbox3d) { + if (!isStandardFamily(glStyle)) addTerrainAndSky(map) + if (supportsCustom3d(glStyle)) { const dark = document.documentElement.classList.contains('dark') addCustom3dBuildings(map, dark) } } - if (style === 'mapbox://styles/mapbox/standard') { + if (glStyle === MAPBOX_DEFAULT_STYLE) { try { map.setTerrain(null) } catch { /* noop */ } } }) @@ -57,7 +68,7 @@ export default function MapboxPreview({ token, style, lat, lng, zoom, enable3d, try { map.remove() } catch { /* noop */ } mapRef.current = null } - }, [token, style, enable3d, quality]) + }, [provider, token, glStyle, enableMapbox3d, quality]) // Recenter without rebuilding the map when lat/lng/zoom change externally useEffect(() => { @@ -65,7 +76,7 @@ export default function MapboxPreview({ token, style, lat, lng, zoom, enable3d, try { mapRef.current.jumpTo({ center: [lng, lat], zoom }) } catch { /* noop */ } }, [lat, lng, zoom]) - if (!token) { + if (!isMapLibre && !token) { return (
Enter a Mapbox access token to preview diff --git a/client/src/components/SystemNotices/SystemNoticeBanner.tsx b/client/src/components/SystemNotices/SystemNoticeBanner.tsx index dd7e8631..36c3a8de 100644 --- a/client/src/components/SystemNotices/SystemNoticeBanner.tsx +++ b/client/src/components/SystemNotices/SystemNoticeBanner.tsx @@ -62,16 +62,17 @@ function CTALink({ if (notice.cta.kind === 'nav') { navigate(notice.cta.href); if (notice.dismissible) onDismiss(); + } else if (notice.cta.kind === 'link') { + window.open(notice.cta.href, '_blank', 'noopener,noreferrer'); } else { runNoticeAction(notice.cta.actionId, { navigate }); - const actionCta = notice.cta as { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean }; - if (actionCta.dismissOnAction !== false) onDismiss(); + if (notice.cta.dismissOnAction !== false) onDismiss(); } } if (!notice.cta) return null; - if (notice.cta.kind === 'nav') { + if (notice.cta.kind === 'nav' || notice.cta.kind === 'link') { return ( typeof window !== 'undefined' && (window.matchMedia?.('(max-width: 639px)')?.matches ?? false) + ); + useEffect(() => { + const mq = window.matchMedia?.('(max-width: 639px)'); + if (!mq) return; + const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches); + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, []); + return isMobile; +} + export function SystemNoticeHost() { const { notices, loaded } = useSystemNoticeStore(); + const isMobile = useIsMobile(); // Notices are fetched by authStore after login (see App.tsx / authStore modification). // Cold-session fetch (page reload with valid session) is triggered here: @@ -17,9 +33,12 @@ export function SystemNoticeHost() { if (!loaded) return null; - const modals = notices.filter(n => n.display === 'modal'); - const banners = notices.filter(n => n.display === 'banner'); - const toasts = notices.filter(n => n.display === 'toast'); + // desktopOnly notices (e.g. the thank-you/support modal) are hidden on mobile. + const visible = isMobile ? notices.filter(n => !n.desktopOnly) : notices; + + const modals = visible.filter(n => n.display === 'modal'); + const banners = visible.filter(n => n.display === 'banner'); + const toasts = visible.filter(n => n.display === 'toast'); return ( <> diff --git a/client/src/components/SystemNotices/SystemNoticeModal.tsx b/client/src/components/SystemNotices/SystemNoticeModal.tsx index 93e1d578..a6c797eb 100644 --- a/client/src/components/SystemNotices/SystemNoticeModal.tsx +++ b/client/src/components/SystemNotices/SystemNoticeModal.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { flushSync } from 'react-dom'; import { useNavigate } from 'react-router-dom'; -import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight } from 'lucide-react'; +import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight, Coffee } from 'lucide-react'; import * as LucideIcons from 'lucide-react'; import remarkGfm from 'remark-gfm'; import rehypeSanitize from 'rehype-sanitize'; @@ -36,6 +36,33 @@ const SEVERITY_ACCENT: Record = { critical: 'text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-950', }; +// Real brand marks (simple-icons single-path logos) for the support buttons, so the +// Buy Me a Coffee / Ko-fi buttons carry their actual logo instead of a generic +// lucide glyph. Tinted via currentColor. +const BRAND_ICON_PATHS: Record = { + buymeacoffee: + 'M20.216 6.415l-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 00-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 00-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 01-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 013.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 01-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 01-4.743.295 37.059 37.059 0 01-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0011.343.376.483.483 0 01.535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 01.39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 01-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 01-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 00-1.322-.238c-.826 0-1.491.284-2.26.613z', + kofi: + 'M11.351 2.715c-2.7 0-4.986.025-6.83.26C2.078 3.285 0 5.154 0 8.61c0 3.506.182 6.13 1.585 8.493 1.584 2.701 4.233 4.182 7.662 4.182h.83c4.209 0 6.494-2.234 7.637-4a9.5 9.5 0 0 0 1.091-2.338C21.792 14.688 24 12.22 24 9.208v-.415c0-3.247-2.13-5.507-5.792-5.87-1.558-.156-2.65-.208-6.857-.208m0 1.947c4.208 0 5.09.052 6.571.182 2.624.311 4.13 1.584 4.13 4v.39c0 2.156-1.792 3.844-3.87 3.844h-.935l-.156.649c-.208 1.013-.597 1.818-1.039 2.546-.909 1.428-2.545 3.064-5.922 3.064h-.805c-2.571 0-4.831-.883-6.078-3.195-1.09-2-1.298-4.155-1.298-7.506 0-2.181.857-3.402 3.012-3.714 1.533-.233 3.559-.26 6.39-.26m6.547 2.287c-.416 0-.65.234-.65.546v2.935c0 .311.234.545.65.545 1.324 0 2.051-.754 2.051-2s-.727-2.026-2.052-2.026m-10.39.182c-1.818 0-3.013 1.48-3.013 3.142 0 1.533.858 2.857 1.949 3.897.727.701 1.87 1.429 2.649 1.896a1.47 1.47 0 0 0 1.507 0c.78-.467 1.922-1.195 2.623-1.896 1.117-1.039 1.974-2.364 1.974-3.897 0-1.662-1.247-3.142-3.039-3.142-1.065 0-1.792.545-2.338 1.298-.493-.753-1.246-1.298-2.312-1.298', +}; + +function brandForHref(href?: string): string | null { + if (!href) return null; + if (href.includes('buymeacoffee')) return 'buymeacoffee'; + if (href.includes('ko-fi.com') || href.includes('kofi')) return 'kofi'; + return null; +} + +function BrandIcon({ brand, size = 18, className }: { brand: string; size?: number; className?: string }) { + const d = BRAND_ICON_PATHS[brand]; + if (!d) return null; + return ( + + ); +} + interface Props { notices: SystemNoticeDTO[]; } @@ -46,12 +73,14 @@ interface ContentProps { title: string; body: string; ctaLabel: string | null; + secondaryCtaLabel: string | null; titleId: string; bodyId: string; isDark: boolean; onDismiss: () => void; onDismissAll: () => void; onCTA: () => void; + onSecondaryCTA: () => void; // Pager total: number; currentPage: number; @@ -61,7 +90,7 @@ interface ContentProps { onGoto: (i: number) => void; } -function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark, onDismiss, onDismissAll, onCTA, total, currentPage, canPage, onPrev, onNext, onGoto }: ContentProps) { +function NoticeContent({ notice, title, body, ctaLabel, secondaryCtaLabel, titleId, bodyId, isDark, onDismiss, onDismissAll, onCTA, onSecondaryCTA, total, currentPage, canPage, onPrev, onNext, onGoto }: ContentProps) { const { t } = useTranslation(); const isLastPage = total <= 1 || currentPage === total - 1; @@ -70,6 +99,10 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark, ? ((LucideIcons as Record)[notice.icon] as React.ElementType) ?? DefaultIcon : DefaultIcon; + // Real brand logo for each support button, detected from the link target. + const primaryBrand = notice.cta?.kind === 'link' ? brandForHref(notice.cta.href) : null; + const secondaryBrand = notice.secondaryCta?.kind === 'link' ? brandForHref(notice.secondaryCta.href) : null; + return (
{/* Dismiss X button — only on last page so users read all notices */} @@ -104,17 +137,9 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark, {/* Special warm header for Heart icon (thank-you notice) */} {notice.icon === 'Heart' && !notice.media && ( -
+
-
-
- -
-
-

{title}

-

TREK 3.0

-
-
+

{title}

)} @@ -197,24 +222,27 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
)} - {/* Highlights */} + {/* Highlights — compact pills */} {notice.highlights && notice.highlights.length > 0 && ( -
    +
    {notice.highlights.map((h, i) => { const HIcon: React.ElementType | null = h.iconName ? ((LucideIcons as Record)[h.iconName] as React.ElementType) ?? null : null; return ( -
  • + {HIcon - ? - : + ? + : } {t(h.labelKey)} -
  • + ); })} -
+
)}
@@ -270,16 +298,37 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
)} - {/* CTA + dismiss link */} + {/* CTA(s) + dismiss link */}
{ctaLabel && isLastPage ? ( - +
+ + {secondaryCtaLabel && ( + + )} +
) : (notice.dismissible || isLastPage) && ( - )}
@@ -510,21 +551,22 @@ function useSystemNoticeModal(notices: SystemNoticeDTO[]) { notices.forEach(n => dismiss(n.id)); } - function handleCTA() { - if (!notice) return; - if (!notice.cta) { - handleDismissAll(); - return; - } - if (notice.cta.kind === 'nav') { - navigate(notice.cta.href); - if (notice.dismissible !== false) handleDismissAll(); + function runCta(cta: SystemNoticeDTO['cta']) { + if (!cta) { handleDismissAll(); return; } + if (cta.kind === 'nav') { + navigate(cta.href); + if (notice?.dismissible !== false) handleDismissAll(); + } else if (cta.kind === 'link') { + // External link (e.g. Buy Me a Coffee / Ko-fi): open in a new tab and leave the + // notice open so the user can use the other button too. + window.open(cta.href, '_blank', 'noopener,noreferrer'); } else { - runNoticeAction(notice.cta.actionId, { navigate }); - const actionCta = notice.cta as { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean }; - if (actionCta.dismissOnAction !== false) handleDismissAll(); + runNoticeAction(cta.actionId, { navigate }); + if (cta.dismissOnAction !== false) handleDismissAll(); } } + function handleCTA() { runCta(notice?.cta); } + function handleSecondaryCTA() { runCta(notice?.secondaryCta); } function animatedDismissAll() { const sheet = sheetRef.current; @@ -584,7 +626,7 @@ function useSystemNoticeModal(notices: SystemNoticeDTO[]) { notice, canPage, isLastPage, language, t, dur, ease, touchStartX, touchStartY, dragLockRef, scrollTopAtTouchStart, isPageNavRef, stripRef, sheetRef, prevSlotRef, contentWrapperRef, nextSlotRef, - announceIndex, handleDismiss, handleDismissAll, handleCTA, animatedDismissAll, + announceIndex, handleDismiss, handleDismissAll, handleCTA, handleSecondaryCTA, animatedDismissAll, handlePrev, handleNext, handleGoto, }; } @@ -593,7 +635,7 @@ type NoticeState = ReturnType; // Build the NoticeContent props for a given notice + pager slot index. function makeContentProps(S: NoticeState, n: SystemNoticeDTO, slotIdx: number): ContentProps { - const { t, isDark, canPage, notices, handleDismiss, handleDismissAll, handleCTA, handlePrev, handleNext, handleGoto } = S; + const { t, isDark, canPage, notices, handleDismiss, handleDismissAll, handleCTA, handleSecondaryCTA, handlePrev, handleNext, handleGoto } = S; const rawBody = t(n.bodyKey); const body = n.bodyParams ? Object.entries(n.bodyParams).reduce( @@ -606,12 +648,14 @@ function makeContentProps(S: NoticeState, n: SystemNoticeDTO, slotIdx: number): title: t(n.titleKey), body, ctaLabel: n.cta ? t(n.cta.labelKey) : null, + secondaryCtaLabel: n.secondaryCta ? t(n.secondaryCta.labelKey) : null, titleId: `notice-title-${n.id}`, bodyId: `notice-body-${n.id}`, isDark, onDismiss: handleDismiss, onDismissAll: handleDismissAll, onCTA: handleCTA, + onSecondaryCTA: handleSecondaryCTA, total: notices.length, currentPage: slotIdx, canPage, diff --git a/client/src/hooks/useRouteCalculation.ts b/client/src/hooks/useRouteCalculation.ts index fa55b755..5d487767 100644 --- a/client/src/hooks/useRouteCalculation.ts +++ b/client/src/hooks/useRouteCalculation.ts @@ -25,6 +25,9 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu // Draw the day's accommodation bookend legs (hotel → first stop, last stop → // hotel) unless the user turned the setting off — same gate as the sidebar. const optimizeFromAccommodation = useSettingsStore((s) => s.settings.optimize_from_accommodation) + // Recompute when the user flips km↔mi so leg distances (formatted at compute time) + // refresh instead of showing stale cached text (#1300). + const distanceUnit = useSettingsStore((s) => s.settings.distance_unit) const updateRouteForDay = useCallback(async (dayId: number | null) => { if (routeAbortRef.current) routeAbortRef.current.abort() @@ -105,8 +108,9 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu // getDayBookendHotels returns the morning/evening hotel (they differ only on a // transfer day) and already filters to accommodations that have coordinates. const day = allDays.find(d => d.id === dayId) - const { morning: startHotel, evening: endHotel } = - day && optimizeFromAccommodation !== false ? getDayBookendHotels(day, allDays, accommodations) : {} + const bookends = day && optimizeFromAccommodation !== false + ? getDayBookendHotels(day, allDays, accommodations) + : null const flatPts: { lat: number; lng: number }[] = [] for (const e of entries) { if (e.kind === 'place') flatPts.push({ lat: e.lat, lng: e.lng }) @@ -114,7 +118,35 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu } const hotelPt = (a?: Accommodation) => a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng } : null - const runsWithHotel = withHotelBookends(runs, flatPts[0], flatPts[flatPts.length - 1], hotelPt(startHotel), hotelPt(endHotel)) + // Only draw a hotel bookend when the leg is real. A hotel → first-stop leg holds + // if the first stop is a place, or if you actually slept in that hotel last night; + // on a day-1 arrival the morning hotel is just a check-in fallback and the first + // waypoint is the transport's departure point, so [hotel → departure] is dropped + // (#1321). Symmetrically, [last-stop → hotel] is dropped when you leave on a transport + // in the evening and don't sleep in that hotel tonight. + const contributes = (e: Entry) => e.kind === 'place' || !!e.from || !!e.to + const firstStop = entries.find(contributes) + const lastStop = [...entries].reverse().find(contributes) + const drawMorning = firstStop?.kind === 'place' || !!bookends?.morningIsSleptHere + const drawEvening = lastStop?.kind === 'place' || !!bookends?.eveningIsOvernight + const runsWithHotel = withHotelBookends( + runs, + flatPts[0], + flatPts[flatPts.length - 1], + drawMorning ? hotelPt(bookends?.morning) : null, + drawEvening ? hotelPt(bookends?.evening) : null, + ) + + // Transfer day with no activities: you check out of one accommodation and into + // another, so there are no waypoints for withHotelBookends to attach a leg to. + // Draw the hotel → hotel transfer directly. Gated on both bookends being real + // (drawMorning/drawEvening already exclude the #1321 arrival fallback) and the two + // hotels being distinct, so an ordinary same-hotel rest day still draws nothing. + if (runsWithHotel.length === 0 && drawMorning && drawEvening) { + const m = hotelPt(bookends?.morning) + const e = hotelPt(bookends?.evening) + if (m && e && (m.lat !== e.lat || m.lng !== e.lng)) runsWithHotel.push([m, e]) + } const straightLines = (): [number, number][][] => runsWithHotel.map(r => r.map(p => [p.lat, p.lng] as [number, number])) @@ -146,7 +178,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu // Aborted (day changed) — newer call owns the state. Anything else: keep straight lines. if (!(err instanceof Error) || err.name !== 'AbortError') setRouteSegments([]) } - }, [enabled, profile, accommodations, optimizeFromAccommodation]) + }, [enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit]) // Stable signature for transport reservations on the selected day — changes when a transport // is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders. @@ -170,7 +202,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu if (!selectedDayId) { setRoute(null); setRouteSegments([]); return } updateRouteForDay(selectedDayId) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation]) + }, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit]) return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } } diff --git a/client/src/i18n/TranslationContext.tsx b/client/src/i18n/TranslationContext.tsx index 22a7853c..93788b94 100644 --- a/client/src/i18n/TranslationContext.tsx +++ b/client/src/i18n/TranslationContext.tsx @@ -37,6 +37,7 @@ const localeLoaders: Record Promise<{ default: Tran ko: () => import('@trek/shared/i18n/ko'), uk: () => import('@trek/shared/i18n/uk'), gr: () => import('@trek/shared/i18n/gr'), + sv: () => import('@trek/shared/i18n/sv'), } // Re-export pure helpers that live in shared so downstream consumers can import them diff --git a/client/src/index.css b/client/src/index.css index 710053c5..a7672534 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -35,17 +35,19 @@ body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow color: var(--text-primary) !important; } -/* Mapbox GL hover popup — the name/category/address card on marker hover. +/* GL hover popup — the name/category/address card on marker hover. Matches the Leaflet map's white hover tooltip. pointer-events:none so moving onto the popup never steals the marker's mouseleave and causes flicker. */ .trek-map-popup { pointer-events: none; } -.trek-map-popup .mapboxgl-popup-content { +.trek-map-popup .mapboxgl-popup-content, +.trek-map-popup .maplibregl-popup-content { padding: 7px 10px; border-radius: 10px; background: #fff; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.16); } -.trek-map-popup .mapboxgl-popup-tip { +.trek-map-popup .mapboxgl-popup-tip, +.trek-map-popup .maplibregl-popup-tip { border-top-color: #fff; border-bottom-color: #fff; border-left-color: #fff; diff --git a/client/src/pages/DashboardPage.test.tsx b/client/src/pages/DashboardPage.test.tsx index 73a5e36f..535c1997 100644 --- a/client/src/pages/DashboardPage.test.tsx +++ b/client/src/pages/DashboardPage.test.tsx @@ -4,9 +4,10 @@ import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../../tests/helpers/msw/server'; import { resetAllStores, seedStore } from '../../tests/helpers/store'; -import { buildUser, buildAdmin, buildTrip } from '../../tests/helpers/factories'; +import { buildUser, buildAdmin, buildTrip, buildSettings } from '../../tests/helpers/factories'; import { useAuthStore } from '../store/authStore'; import { usePermissionsStore } from '../store/permissionsStore'; +import { useSettingsStore } from '../store/settingsStore'; import DashboardPage from './DashboardPage'; beforeEach(() => { @@ -798,10 +799,51 @@ describe('DashboardPage', () => { }); }); + describe('FE-PAGE-DASH-033: Atlas distance respects distance unit setting', () => { + const distanceValue = (text: string) => + screen.getByText((_, element) => + element?.classList.contains('value') === true && + element.textContent?.replace(/\s+/g, ' ').trim() === text + ); + + beforeEach(() => { + server.use( + http.get('/api/auth/travel-stats', () => + HttpResponse.json({ + totalTrips: 1, + totalDays: 1, + totalPlaces: 1, + totalDistanceKm: 10, + countries: [], + }) + ), + ); + }); + + it('renders metric atlas distance as kilometers', async () => { + seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'metric' }) }); + + render(); + + await waitFor(() => { + expect(distanceValue('10 km')).toBeInTheDocument(); + }); + }); + + it('renders imperial atlas distance as miles', async () => { + seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'imperial' }) }); + + render(); + + await waitFor(() => { + expect(distanceValue('6.2 mi')).toBeInTheDocument(); + }); + }); + }); + describe('FE-PAGE-DASH-032: Dark mode detection uses window.matchMedia', () => { it('renders without error when dark_mode is set to auto', async () => { // Seed settings with dark_mode = 'auto' to exercise the matchMedia branch - const { useSettingsStore } = await import('../store/settingsStore'); seedStore(useSettingsStore, { settings: { map_tile_url: '', @@ -812,6 +854,7 @@ describe('DashboardPage', () => { default_currency: 'USD', language: 'en', temperature_unit: 'fahrenheit', + distance_unit: 'metric', time_format: '12h', show_place_description: false, blur_booking_codes: false, @@ -831,4 +874,32 @@ describe('DashboardPage', () => { expect(screen.getByText(/my trips/i)).toBeInTheDocument(); }); }); + + describe('FE-PAGE-DASH-034: dashboard widgets persist to settings, not localStorage (#1311)', () => { + it('reads the timezone widget zones from the settings store', async () => { + // A zone that is NOT in the hardcoded default ([home, London, Tokyo]) — its presence + // proves the widget reads the stored preference rather than the old localStorage default. + seedStore(useSettingsStore, { settings: buildSettings({ dashboard_timezones: ['America/New_York'] }), isLoaded: true }); + render(); + await waitFor(() => expect(screen.getByRole('button', { name: /add timezone/i })).toBeInTheDocument()); + expect(screen.getByText('New York')).toBeInTheDocument(); + }); + + it('migrates the pre-3.1.3 localStorage prefs into settings and clears the legacy keys', async () => { + localStorage.setItem('trek_fx_from', 'CAD'); + localStorage.setItem('trek_fx_to', 'CHF'); + localStorage.setItem('trek_dashboard_tz', JSON.stringify(['America/New_York'])); + seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true }); + render(); + // The one-time migration runs on mount (settings already loaded) and removes the keys. + await waitFor(() => { + expect(localStorage.getItem('trek_fx_from')).toBeNull(); + expect(localStorage.getItem('trek_dashboard_tz')).toBeNull(); + }); + const s = useSettingsStore.getState().settings; + expect(s.dashboard_fx_from).toBe('CAD'); + expect(s.dashboard_fx_to).toBe('CHF'); + expect(s.dashboard_timezones).toEqual(['America/New_York']); + }); + }); }); diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index 110ee9a9..62f95353 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -19,6 +19,7 @@ import { LayoutGrid, List, Ticket, X, } from 'lucide-react' import { formatTime, splitReservationDateTime } from '../utils/formatters' +import { convertDistance, getDistanceUnitLabel } from '../utils/units' import { useSettingsStore } from '../store/settingsStore' import '../styles/dashboard.css' @@ -358,12 +359,27 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch } // ── Atlas / stats row ──────────────────────────────────────────────────────── +function formatCompactDistance(value: number): string { + const safeValue = Number.isFinite(value) ? Math.max(0, value) : 0 + // String() keeps a '.' decimal regardless of locale (no "1,5k" in non-English UIs). + if (safeValue >= 1000) { + return `${String(Math.round(safeValue / 100) / 10)}k` + } + const rounded = Math.round(safeValue * 10) / 10 + if (safeValue > 0 && rounded === 0) return '<0.1' + return String(rounded) +} + function AtlasStats({ stats }: { stats: TravelStats | null }): React.ReactElement { const { t } = useTranslation() + const distanceUnit = useSettingsStore(s => s.settings.distance_unit) || 'metric' const countries = stats?.countries || [] const distanceKm = stats?.totalDistanceKm || 0 - const distanceText = distanceKm >= 1000 ? `${(distanceKm / 1000).toFixed(1)}k` : String(distanceKm) - const equatorTimes = (distanceKm / 40075).toFixed(2) + const distance = convertDistance(distanceKm, distanceUnit) + const distanceText = formatCompactDistance(distance) + const equatorDistance = convertDistance(40075, distanceUnit) + const equatorTimes = (distance / equatorDistance).toFixed(2) + const distanceLabel = getDistanceUnitLabel(distanceUnit) return (
@@ -401,7 +417,7 @@ function AtlasStats({ stats }: { stats: TravelStats | null }): React.ReactElemen
{t('dashboard.atlas.distanceFlown')}
-
{distanceText} {t('dashboard.atlas.kmUnit')}
+
{distanceText} {distanceLabel}
{t('dashboard.atlas.aroundEquator', { count: equatorTimes })}
@@ -475,8 +491,12 @@ const FX_FALLBACK = ['EUR', 'USD', 'GBP', 'CHF', 'JPY', 'CAD', 'AUD', 'CNY', 'SE function CurrencyTool(): React.ReactElement { const { t } = useTranslation() - const [from, setFrom] = useState(() => localStorage.getItem('trek_fx_from') || 'EUR') - const [to, setTo] = useState(() => localStorage.getItem('trek_fx_to') || 'USD') + const isLoaded = useSettingsStore(s => s.isLoaded) + const updateSetting = useSettingsStore(s => s.updateSetting) + const from = useSettingsStore(s => s.settings.dashboard_fx_from) || 'EUR' + const to = useSettingsStore(s => s.settings.dashboard_fx_to) || 'USD' + const setFrom = (v: string) => { updateSetting('dashboard_fx_from', v).catch(() => {}) } + const setTo = (v: string) => { updateSetting('dashboard_fx_to', v).catch(() => {}) } const [amount, setAmount] = useState('100') const [rates, setRates] = useState | null>(null) @@ -494,7 +514,18 @@ function CurrencyTool(): React.ReactElement { }, [from]) useEffect(() => { fetchRate() }, [fetchRate]) - useEffect(() => { localStorage.setItem('trek_fx_from', from); localStorage.setItem('trek_fx_to', to) }, [from, to]) + // One-time migration of the pre-3.1.3 localStorage values into the user's settings, + // so a (docker) upgrade no longer resets the widget (#1311). + useEffect(() => { + if (!isLoaded) return + const lf = localStorage.getItem('trek_fx_from') + const lt = localStorage.getItem('trek_fx_to') + if (!lf && !lt) return + if (lf) updateSetting('dashboard_fx_from', lf).catch(() => {}) + if (lt) updateSetting('dashboard_fx_to', lt).catch(() => {}) + localStorage.removeItem('trek_fx_from') + localStorage.removeItem('trek_fx_to') + }, [isLoaded, updateSetting]) const currencies = rates ? Object.keys(rates).sort() : FX_FALLBACK const ccyOptions = currencies.map(c => ({ value: c, label: c })) @@ -549,13 +580,12 @@ function TimezoneTool({ locale }: { locale: string }): React.ReactElement { const { t } = useTranslation() const home = Intl.DateTimeFormat().resolvedOptions().timeZone const [now, setNow] = useState(() => new Date()) - const [zones, setZones] = useState(() => { - try { - const raw = localStorage.getItem('trek_dashboard_tz') - if (raw) return JSON.parse(raw) - } catch { /* ignore malformed storage */ } - return [home, ...DEFAULT_ZONES] - }) + const isLoaded = useSettingsStore(s => s.isLoaded) + const updateSetting = useSettingsStore(s => s.updateSetting) + const stored = useSettingsStore(s => s.settings.dashboard_timezones) + // Unset (never chosen) falls back to home + defaults; an explicit list is honoured. + const zones = stored ?? [home, ...DEFAULT_ZONES] + const setZones = (next: string[]) => { updateSetting('dashboard_timezones', next).catch(() => {}) } const [adding, setAdding] = useState(false) // A minute's resolution is plenty for clocks and keeps re-renders cheap. @@ -564,7 +594,18 @@ function TimezoneTool({ locale }: { locale: string }): React.ReactElement { return () => clearInterval(id) }, []) - useEffect(() => { localStorage.setItem('trek_dashboard_tz', JSON.stringify(zones)) }, [zones]) + // One-time migration of the pre-3.1.3 localStorage value into the user's settings, + // so a (docker) upgrade no longer resets the widget (#1311). + useEffect(() => { + if (!isLoaded) return + const raw = localStorage.getItem('trek_dashboard_tz') + if (!raw) return + try { + const parsed = JSON.parse(raw) + if (Array.isArray(parsed)) updateSetting('dashboard_timezones', parsed).catch(() => {}) + } catch { /* ignore malformed storage */ } + localStorage.removeItem('trek_dashboard_tz') + }, [isLoaded, updateSetting]) const allZones = React.useMemo(() => { const supported = (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf @@ -575,8 +616,8 @@ function TimezoneTool({ locale }: { locale: string }): React.ReactElement { .filter(z => !zones.includes(z)) .map(z => ({ value: z, label: z.replace(/_/g, ' '), searchLabel: z })) - const addZone = (tz: string) => { if (tz) setZones(prev => prev.includes(tz) ? prev : [...prev, tz]); setAdding(false) } - const removeZone = (tz: string) => setZones(prev => prev.filter(z => z !== tz)) + const addZone = (tz: string) => { if (tz && !zones.includes(tz)) setZones([...zones, tz]); setAdding(false) } + const removeZone = (tz: string) => setZones(zones.filter(z => z !== tz)) const timeIn = (tz: string) => now.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: tz }) const offsetLabel = (tz: string) => { diff --git a/client/src/pages/SharedTripPage.test.tsx b/client/src/pages/SharedTripPage.test.tsx index 7b6c9119..84840e50 100644 --- a/client/src/pages/SharedTripPage.test.tsx +++ b/client/src/pages/SharedTripPage.test.tsx @@ -3,7 +3,9 @@ import { render, screen, waitFor, fireEvent } from '../../tests/helpers/render'; import { Routes, Route } from 'react-router-dom'; import { http, HttpResponse } from 'msw'; import { server } from '../../tests/helpers/msw/server'; -import { resetAllStores } from '../../tests/helpers/store'; +import { resetAllStores, seedStore } from '../../tests/helpers/store'; +import { buildSettings } from '../../tests/helpers/factories'; +import { useSettingsStore } from '../store/settingsStore'; import SharedTripPage from './SharedTripPage'; // Mock react-leaflet (SharedTripPage renders a map) @@ -480,4 +482,31 @@ describe('SharedTripPage', () => { expect(screen.getByText(/LH2/)).toBeInTheDocument(); }); }); + + describe('FE-PAGE-SHARED-018: untitled day uses the translated day label (#1296)', () => { + it('renders the day-number label via i18n (German), not a hardcoded English string', async () => { + seedStore(useSettingsStore, { settings: buildSettings({ language: 'de' }) }); + const day = { id: 101, trip_id: 1, day_number: 1, date: '2026-07-01', title: null, notes: null }; + server.use( + http.get('/api/shared/:token', () => HttpResponse.json({ + trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05' }, + days: [day], + assignments: {}, + dayNotes: {}, + places: [], + reservations: [], + accommodations: [], + packing: [], + budget: [], + categories: [], + permissions: { share_bookings: false, share_packing: false, share_budget: false, share_collab: false }, + collab: [], + })), + ); + renderSharedTrip('test-token'); + // The untitled day shows the German label "Tag 1", proving the hardcoded English + // "Day 1" was replaced by the i18n key t('dayplan.dayN'). + await waitFor(() => expect(screen.getByText('Tag 1')).toBeInTheDocument()); + }); + }); }); diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx index ef262b18..09fa6c4a 100644 --- a/client/src/pages/SharedTripPage.tsx +++ b/client/src/pages/SharedTripPage.tsx @@ -196,7 +196,7 @@ export default function SharedTripPage() { style={{ padding: '12px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 }}>
{di + 1}
-
{day.title || `Day ${day.day_number}`}
+
{day.title || t('dayplan.dayN', { n: day.day_number })}
{day.date &&
{new Date(day.date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}
}
{dayAccs.map((acc: any) => ( diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 31767272..50af8c9a 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -5,7 +5,7 @@ import { useTripStore } from '../store/tripStore' import { useCanDo } from '../store/permissionsStore' import { useSettingsStore } from '../store/settingsStore' import { MapViewAuto as MapView } from '../components/Map/MapViewAuto' -import { MapCompassPill } from '../components/Map/MapCompassPill' +import { MapCompassPill, type CompassMap } from '../components/Map/MapCompassPill' import { getCached, fetchPhoto } from '../services/photoService' import DayPlanSidebar from '../components/Planner/DayPlanSidebar' import PlacesSidebar from '../components/Planner/PlacesSidebar' @@ -211,7 +211,7 @@ export default function TripPlannerPage(): React.ReactElement | null { } = useTripPlanner() const poi = usePoiExplore() - const [glMap, setGlMap] = useState(null) + const [glMap, setGlMap] = useState(null) const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false // Costs expense editor opened from a booking modal (save-then-open). Lives at the diff --git a/client/src/store/settingsStore.ts b/client/src/store/settingsStore.ts index b8182811..84090c83 100644 --- a/client/src/store/settingsStore.ts +++ b/client/src/store/settingsStore.ts @@ -30,6 +30,7 @@ export const useSettingsStore = create((set, get) => ({ default_currency: 'USD', language: localStorage.getItem('app_language') || 'en', temperature_unit: 'fahrenheit', + distance_unit: 'metric', time_format: '12h', show_place_description: false, optimize_from_accommodation: true, @@ -37,8 +38,13 @@ export const useSettingsStore = create((set, get) => ({ map_poi_pill_enabled: true, mapbox_access_token: '', mapbox_style: 'mapbox://styles/mapbox/standard', + maplibre_style: '', mapbox_3d_enabled: true, mapbox_quality_mode: false, + dashboard_fx_from: 'EUR', + dashboard_fx_to: 'USD', + // dashboard_timezones is intentionally left unset so the widget can tell "never + // chosen" (fall back to home + defaults) from an explicitly emptied list. }, isLoaded: false, diff --git a/client/src/types.ts b/client/src/types.ts index 4c0191a7..dab19cc1 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -100,6 +100,8 @@ export interface TripFile { url: string } +export type DistanceUnit = 'metric' | 'imperial' + export interface Settings { map_tile_url: string default_lat: number @@ -109,17 +111,23 @@ export interface Settings { default_currency: string language: string temperature_unit: string + distance_unit?: DistanceUnit time_format: string show_place_description: boolean blur_booking_codes?: boolean map_booking_labels?: boolean map_poi_pill_enabled?: boolean optimize_from_accommodation?: boolean - map_provider?: 'leaflet' | 'mapbox-gl' + map_provider?: 'leaflet' | 'mapbox-gl' | 'maplibre-gl' mapbox_access_token?: string mapbox_style?: string + maplibre_style?: string mapbox_3d_enabled?: boolean mapbox_quality_mode?: boolean + // Dashboard widget prefs — persisted server-side so a (docker) upgrade keeps them (#1311). + dashboard_fx_from?: string + dashboard_fx_to?: string + dashboard_timezones?: string[] } export interface AssignmentsMap { diff --git a/client/src/utils/dayOrder.test.ts b/client/src/utils/dayOrder.test.ts index 4aac45a8..ba961596 100644 --- a/client/src/utils/dayOrder.test.ts +++ b/client/src/utils/dayOrder.test.ts @@ -117,4 +117,40 @@ describe('getDayBookendHotels', () => { const h = hotel({ place_lat: null, place_lng: null }) expect(getDayBookendHotels(days[1], days, [h])).toEqual({}) }) + + it('flags an arrival/check-in day as not slept-here in the morning (#1321)', () => { + // Day 1: you arrive from home and check in tonight, so the morning hotel is only a + // check-in fallback — no hotel → departure leg should be drawn. + const into = hotel({ start_day_id: 10, end_day_id: 30, place_lat: 3, place_lng: 4 }) + const r = getDayBookendHotels(days[0], days, [into]) + expect(r.morning).toBe(into) + expect(r.morningIsSleptHere).toBe(false) + expect(r.eveningIsOvernight).toBe(true) + // The optimizer anchor must stay a loop on the check-in day (values unchanged). + expect(getAccommodationAnchors(days[0], days, [into])).toEqual({ start: { lat: 3, lng: 4 }, end: { lat: 3, lng: 4 } }) + }) + + it('flags a mid-stay day as slept-here and overnight', () => { + const h = hotel({ start_day_id: 10, end_day_id: 30 }) + const r = getDayBookendHotels(days[1], days, [h]) + expect(r.morningIsSleptHere).toBe(true) + expect(r.eveningIsOvernight).toBe(true) + }) + + it('an evening departure with no replacement check-in is not overnight (S7 mirror)', () => { + // You woke up here but check out today and board an evening transport — you do not + // sleep here tonight, so the last-stop → hotel leg must be droppable. + const h = hotel({ start_day_id: 10, end_day_id: 20, place_lat: 1, place_lng: 1 }) + const r = getDayBookendHotels(days[1], days, [h]) + expect(r.morningIsSleptHere).toBe(true) + expect(r.eveningIsOvernight).toBe(false) + }) + + it('flags a transfer day as slept-here in the morning and overnight in the evening', () => { + const out = hotel({ start_day_id: 10, end_day_id: 20, place_lat: 1, place_lng: 1 }) + const into = hotel({ start_day_id: 20, end_day_id: 30, place_lat: 9, place_lng: 9 }) + const r = getDayBookendHotels(days[1], days, [out, into]) + expect(r.morningIsSleptHere).toBe(true) + expect(r.eveningIsOvernight).toBe(true) + }) }) diff --git a/client/src/utils/dayOrder.ts b/client/src/utils/dayOrder.ts index 1332a7b0..aa69350f 100644 --- a/client/src/utils/dayOrder.ts +++ b/client/src/utils/dayOrder.ts @@ -12,7 +12,7 @@ export const getDayBookendHotels = ( day: Day, days: Day[], accommodations: Accommodation[], -): { morning?: Accommodation; evening?: Accommodation } => { +): { morning?: Accommodation; evening?: Accommodation; morningIsSleptHere?: boolean; eveningIsOvernight?: boolean } => { const inRange = accommodations.filter(a => a.place_lat != null && a.place_lng != null && isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days), @@ -30,6 +30,13 @@ export const getDayBookendHotels = ( return { morning: sleptHere ?? checkIn ?? inRange[0], evening: checkIn ?? sleptHere ?? inRange[0], + // Provenance for the drawing consumers (map + sidebar). A hotel↔transport bookend + // is only real when you actually used the hotel: morningIsSleptHere is true only + // when you woke up there (not a check-in fallback on an arrival day), and + // eveningIsOvernight is true only when you sleep there tonight (you check in today, + // or an earlier stay continues past today). The optimizer keeps using the values. + morningIsSleptHere: sleptHere != null, + eveningIsOvernight: checkIn != null || (sleptHere != null && orderOf(sleptHere.end_day_id) > dayOrd), } } diff --git a/client/src/utils/units.test.ts b/client/src/utils/units.test.ts new file mode 100644 index 00000000..bea5d380 --- /dev/null +++ b/client/src/utils/units.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { convertDistance, formatDistance, getDistanceUnitLabel } from './units' + +describe('units', () => { + describe('getDistanceUnitLabel', () => { + it('returns km for metric and mi for imperial', () => { + expect(getDistanceUnitLabel('metric')).toBe('km') + expect(getDistanceUnitLabel('imperial')).toBe('mi') + }) + }) + + describe('convertDistance', () => { + it('keeps kilometres for metric', () => { + expect(convertDistance(10, 'metric')).toBe(10) + }) + it('converts kilometres to miles for imperial', () => { + expect(convertDistance(10, 'imperial')).toBeCloseTo(6.21371, 4) + }) + it('clamps negative and non-finite input to 0', () => { + expect(convertDistance(-5, 'imperial')).toBe(0) + expect(convertDistance(NaN, 'metric')).toBe(0) + expect(convertDistance(Infinity, 'metric')).toBe(0) + }) + }) + + describe('formatDistance', () => { + it('shows metres below 1 km for metric', () => { + expect(formatDistance(0.3, 'metric')).toBe('300 m') + expect(formatDistance(0.05, 'metric')).toBe('50 m') + }) + it('shows kilometres at or above 1 km for metric', () => { + expect(formatDistance(1.5, 'metric')).toBe('1.5 km') + expect(formatDistance(10, 'metric')).toBe('10 km') + }) + it('shows miles for imperial', () => { + expect(formatDistance(10, 'imperial')).toBe('6.2 mi') + }) + it('shows <0.1 for a tiny imperial distance', () => { + expect(formatDistance(0.05, 'imperial')).toBe('<0.1 mi') + }) + it('clamps negative and non-finite input to 0', () => { + expect(formatDistance(-1, 'metric')).toBe('0 m') + expect(formatDistance(NaN, 'imperial')).toBe('0 mi') + }) + }) +}) diff --git a/client/src/utils/units.ts b/client/src/utils/units.ts new file mode 100644 index 00000000..bbfdaa75 --- /dev/null +++ b/client/src/utils/units.ts @@ -0,0 +1,35 @@ +import type { DistanceUnit } from '../types' + +const KM_TO_MI = 0.621371 +const M_TO_FT = 3.28084 + +export function getDistanceUnitLabel(unit: DistanceUnit): 'km' | 'mi' { + return unit === 'imperial' ? 'mi' : 'km' +} + +/** Formats an elevation in metres as feet for imperial, so it doesn't mix with mi distances. */ +export function formatElevation(meters: number, unit: DistanceUnit): string { + const safe = Number.isFinite(meters) ? meters : 0 + return unit === 'imperial' ? `${Math.round(safe * M_TO_FT)} ft` : `${Math.round(safe)} m` +} + +export function convertDistance(km: number, unit: DistanceUnit): number { + const safeKm = Number.isFinite(km) ? Math.max(0, km) : 0 + return unit === 'imperial' ? safeKm * KM_TO_MI : safeKm +} + +export function formatDistance(km: number, unit: DistanceUnit): string { + const safeKm = Number.isFinite(km) ? Math.max(0, km) : 0 + // Metric keeps a metres reading below 1 km (e.g. "300 m"), matching the route + // connectors; imperial has no sub-mile unit, so short hops just show "0.x mi". + if (unit === 'metric' && safeKm < 1) { + return `${Math.round(safeKm * 1000)} m` + } + const value = convertDistance(safeKm, unit) + const label = getDistanceUnitLabel(unit) + const rounded = Math.round(value * 10) / 10 + // String() keeps a '.' decimal regardless of locale, matching the rest of the app + // (toFixed elsewhere) and avoiding "1,5 km" in non-English environments. + const text = value > 0 && rounded === 0 ? '<0.1' : String(rounded) + return `${text} ${label}` +} diff --git a/client/tests/integration/hooks/useRouteCalculation.test.ts b/client/tests/integration/hooks/useRouteCalculation.test.ts index 637e39f8..1c6679a7 100644 --- a/client/tests/integration/hooks/useRouteCalculation.test.ts +++ b/client/tests/integration/hooks/useRouteCalculation.test.ts @@ -251,6 +251,126 @@ describe('useRouteCalculation', () => { expect(result.current.routeSegments).toEqual([]); }); + it('FE-HOOK-ROUTE-014: #1321 day-1 arrival draws no check-in-hotel → departure leg', async () => { + // Day 1 = arrival from home: a flight (departure → arrival airport) then two activities, + // checking into a hotel tonight. The morning hotel is only a check-in fallback, so the + // hotel must NOT be bookended to the flight's departure point; the evening leg stays. + const dep = { lat: 50.03, lng: 8.57 }; // home/departure airport + const arr = { lat: 41.30, lng: 2.08 }; // destination airport + const actA = buildPlace({ lat: 41.38, lng: 2.17 }); + const actB = buildPlace({ lat: 41.40, lng: 2.19 }); + const hotel = { lat: 41.39, lng: 2.16 }; + + const flight = { + id: 100, type: 'flight', day_id: 1, end_day_id: 1, day_plan_position: 0, + endpoints: [ + { role: 'from', lat: dep.lat, lng: dep.lng }, + { role: 'to', lat: arr.lat, lng: arr.lng }, + ], + }; + const a1 = buildAssignment({ day_id: 1, order_index: 1, place: actA }); + const a2 = buildAssignment({ day_id: 1, order_index: 2, place: actB }); + const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 2, place_lat: hotel.lat, place_lng: hotel.lng }]; + // A single stable store reference (like buildMockStore) so selectedDayAssignments + // keeps its identity across renders and the effect doesn't loop. + const store = { assignments: { '1': [a1, a2] } } as unknown as TripStoreState; + useTripStore.setState({ + assignments: store.assignments, + reservations: [flight], + days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }], + } as any); + + const { result } = renderHook(() => + useRouteCalculation(store, 1, true, 'driving', accommodations as any) + ); + + await act(async () => {}); + + const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`)); + // The spurious morning bookend [hotel → departure airport] must be gone. + expect(legs).not.toContainEqual([`${hotel.lat},${hotel.lng}`, `${dep.lat},${dep.lng}`]); + // The route starts the day's run at the arrival airport, not the hotel. + expect(result.current.route?.[0]?.[0]).toEqual([arr.lat, arr.lng]); + // The evening leg [last activity → hotel] is still drawn. + expect(legs).toContainEqual([`${actB.lat},${actB.lng}`, `${hotel.lat},${hotel.lng}`]); + }); + + it('FE-HOOK-ROUTE-015: day-1 with no transport keeps the hotel → first-activity leg', async () => { + // Guard against over-suppression: with no arrival transport, the check-in day is a + // home-base loop and the hotel → first-stop leg must remain. + const actA = buildPlace({ lat: 41.38, lng: 2.17 }); + const actB = buildPlace({ lat: 41.40, lng: 2.19 }); + const hotel = { lat: 41.39, lng: 2.16 }; + const a1 = buildAssignment({ day_id: 1, order_index: 0, place: actA }); + const a2 = buildAssignment({ day_id: 1, order_index: 1, place: actB }); + const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 2, place_lat: hotel.lat, place_lng: hotel.lng }]; + const store = { assignments: { '1': [a1, a2] } } as unknown as TripStoreState; + useTripStore.setState({ + assignments: store.assignments, + reservations: [], + days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }], + } as any); + + const { result } = renderHook(() => + useRouteCalculation(store, 1, true, 'driving', accommodations as any) + ); + + await act(async () => {}); + + const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`)); + expect(legs).toContainEqual([`${hotel.lat},${hotel.lng}`, `${actA.lat},${actA.lng}`]); + expect(legs).toContainEqual([`${actB.lat},${actB.lng}`, `${hotel.lat},${hotel.lng}`]); + }); + + it('FE-HOOK-ROUTE-016: #1297 transfer day with no activities draws the hotel → hotel leg', async () => { + // Day 2 is a pure transfer: check out of hotel A (slept there last night) and into + // hotel B tonight, with no activities or transport. The map must still draw A → B. + const hotelA = { lat: 48.86, lng: 2.35 }; + const hotelB = { lat: 45.76, lng: 4.84 }; + const accommodations = [ + { id: 1, start_day_id: 1, end_day_id: 2, place_lat: hotelA.lat, place_lng: hotelA.lng }, + { id: 2, start_day_id: 2, end_day_id: 3, place_lat: hotelB.lat, place_lng: hotelB.lng }, + ]; + const store = { assignments: {} } as unknown as TripStoreState; + useTripStore.setState({ + assignments: {}, + reservations: [], + days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }, { id: 3, day_number: 3 }], + } as any); + + const { result } = renderHook(() => + useRouteCalculation(store, 2, true, 'driving', accommodations as any) + ); + + await act(async () => {}); + + const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`)); + expect(legs).toContainEqual([`${hotelA.lat},${hotelA.lng}`, `${hotelB.lat},${hotelB.lng}`]); + }); + + it('FE-HOOK-ROUTE-017: #1297 rest day in one hotel with no activities draws nothing', async () => { + // Guard against a zero-length loop: morning and evening hotel are the same, no + // activities — no transfer leg should be drawn. + const hotel = { lat: 48.86, lng: 2.35 }; + const accommodations = [ + { id: 1, start_day_id: 1, end_day_id: 4, place_lat: hotel.lat, place_lng: hotel.lng }, + ]; + const store = { assignments: {} } as unknown as TripStoreState; + useTripStore.setState({ + assignments: {}, + reservations: [], + days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }, { id: 3, day_number: 3 }], + } as any); + + const { result } = renderHook(() => + useRouteCalculation(store, 2, true, 'driving', accommodations as any) + ); + + await act(async () => {}); + + expect(result.current.route).toBeNull(); + }); + it('FE-HOOK-ROUTE-012: setRoute and setRouteInfo are exposed', () => { const store = buildMockStore({}); const { result } = renderHook(() => diff --git a/client/tests/unit/i18n/index.test.ts b/client/tests/unit/i18n/index.test.ts index c5d966e7..42c15b31 100644 --- a/client/tests/unit/i18n/index.test.ts +++ b/client/tests/unit/i18n/index.test.ts @@ -91,12 +91,13 @@ describe('isRtlLanguage', () => { describe('SUPPORTED_LANGUAGES', () => { it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => { expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true) - expect(SUPPORTED_LANGUAGES).toHaveLength(20) + expect(SUPPORTED_LANGUAGES).toHaveLength(21) expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'en', label: 'English' })) expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'tr', label: 'Türkçe' })) expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ja', label: '日本語' })) expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ko', label: '한국어' })) expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'uk', label: 'Українська' })) + expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'sv', label: 'Svenska' })) expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ar', label: 'العربية' })) }) }) diff --git a/client/vite.config.js b/client/vite.config.js index b08517b0..35f486b8 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -63,6 +63,18 @@ export default defineConfig({ cacheableResponse: { statuses: [200] }, }, }, + { + // OpenFreeMap MapLibre style, glyphs, sprites and vector tiles. + // Same best-effort offline model as Mapbox GL: viewed resources are + // reused from cache, but the vector tile pipeline is not prefetched. + urlPattern: /^https:\/\/tiles\.openfreemap\.org\/.*/i, + handler: 'StaleWhileRevalidate', + options: { + cacheName: 'openfreemap-tiles', + expiration: { maxEntries: 3000, maxAgeSeconds: 30 * 24 * 60 * 60 }, + cacheableResponse: { statuses: [200] }, + }, + }, { // API calls — network only. We deliberately do NOT cache API // responses in the Service Worker: Workbox keys entries by URL and diff --git a/package-lock.json b/package-lock.json index f1f4c50d..6e3769d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,20 @@ { "name": "@trek/root", - "version": "3.1.2", + "version": "3.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@trek/root", - "version": "3.1.2", + "version": "3.1.3", "workspaces": [ "client", "server", "shared" ], "devDependencies": { - "concurrently": "^10.0.3" + "concurrently": "^10.0.3", + "unrun": "^0.3.1" }, "optionalDependencies": { "@img/sharp-linuxmusl-arm64": "0.35.1", @@ -24,7 +25,7 @@ }, "client": { "name": "@trek/client", - "version": "3.1.2", + "version": "3.1.3", "dependencies": { "@fontsource/geist-sans": "^5.2.5", "@fontsource/poppins": "^5.2.7", @@ -37,6 +38,7 @@ "leaflet": "^1.9.4", "lucide-react": "^0.344.0", "mapbox-gl": "^3.22.0", + "maplibre-gl": "^5.24.0", "marked": "^18.0.0", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -84,11 +86,102 @@ "tailwindcss": "^3.4.1", "typescript": "^6.0.2", "typescript-eslint": "^8.58.2", - "vite": "^8.0.16", + "vite": "8.1.0", "vite-plugin-pwa": "^1.3.0", "vitest": "^4.1.9" } }, + "client/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "client/node_modules/vite": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "~1.1.2", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/@adobe/css-tools": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", @@ -3918,6 +4011,119 @@ "node": ">=8" } }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz", + "integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.2" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.0.tgz", + "integrity": "sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.10.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz", + "integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^1.0.0", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", + "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", + "license": "BSD-2-Clause" + }, + "node_modules/@maplibre/mlt": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz", + "integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz", + "integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.1.0" + } + }, + "node_modules/@maplibre/vt-pbf/node_modules/pbf": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.0.tgz", + "integrity": "sha512-Wv0yo0+uZepnoNEKsquhar1F18LogB8oeEikIhUXG16udbiXG7JecHGySwoo6kuMgjmbQYzdrTZlO+/K9t8eZg==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -6719,7 +6925,6 @@ "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", - "dev": true, "license": "MIT" }, "node_modules/@types/hast": { @@ -9582,6 +9787,12 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -11098,6 +11309,12 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/glob": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", @@ -12568,6 +12785,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -12645,6 +12868,12 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -13266,6 +13495,40 @@ "test/build/typings" ] }, + "node_modules/maplibre-gl": { + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz", + "integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.1.0", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/geojson-vt": "^6.1.0", + "@maplibre/maplibre-gl-style-spec": "^24.8.1", + "@maplibre/mlt": "^1.1.8", + "@maplibre/vt-pbf": "^4.3.0", + "@types/geojson": "^7946.0.16", + "earcut": "^3.0.2", + "gl-matrix": "^3.4.4", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -14529,6 +14792,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/mute-stream": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", @@ -15155,6 +15424,18 @@ "dev": true, "license": "MIT" }, + "node_modules/pbf": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz", + "integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -15461,6 +15742,12 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -15710,6 +15997,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -16038,6 +16331,12 @@ ], "license": "MIT" }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -16585,6 +16884,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/restructure": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", @@ -18263,6 +18571,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/tinyrainbow": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", @@ -19071,6 +19385,33 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/unrun": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/unrun/-/unrun-0.3.1.tgz", + "integrity": "sha512-onIck/oNnCaytwths1ZVp1LK2Gq2hPoyFhiHebObuUXqR3S0uHuLLaBK8K6mRRgV7Ptip8AnNvaUsgzwWwBZuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rolldown": "^1.0.0" + }, + "bin": { + "unrun": "dist/cli.mjs" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/Gugustinette" + }, + "peerDependencies": { + "synckit": "^0.11.11" + }, + "peerDependenciesMeta": { + "synckit": { + "optional": true + } + } + }, "node_modules/until-async": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", @@ -20543,7 +20884,7 @@ }, "server": { "name": "@trek/server", - "version": "3.1.2", + "version": "3.1.3", "dependencies": { "@modelcontextprotocol/sdk": "^1.28.0", "@nestjs/common": "^11.1.24", @@ -20900,7 +21241,7 @@ }, "shared": { "name": "@trek/shared", - "version": "3.1.2", + "version": "3.1.3", "dependencies": { "isomorphic-dompurify": "^3.15.0", "zod": "^4.3.6" diff --git a/package.json b/package.json index c5b99c61..9a77a1cd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@trek/root", "private": true, - "version": "3.1.2", + "version": "3.1.3", "workspaces": [ "client", "server", @@ -25,7 +25,8 @@ "format:check": "npm run format:check --workspace=shared && npm run format:check --workspace=server && npm run format:check --workspace=client" }, "devDependencies": { - "concurrently": "^10.0.3" + "concurrently": "^10.0.3", + "unrun": "^0.3.1" }, "comment:overrides": "Force a single React 19 across the workspace so the test renderer (@testing-library/react) and the app share one react-dom.", "overrides": { @@ -34,9 +35,9 @@ "multer": "^2.2.0" }, "optionalDependencies": { - "@rollup/rollup-linux-x64-musl": "4.62.0", - "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@img/sharp-linuxmusl-arm64": "0.35.1", "@img/sharp-linuxmusl-x64": "0.35.1", - "@img/sharp-linuxmusl-arm64": "0.35.1" + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0" } } diff --git a/server/.env.example b/server/.env.example index c31a2956..06921fbb 100644 --- a/server/.env.example +++ b/server/.env.example @@ -40,6 +40,9 @@ DEMO_MODE=false # Demo mode - resets data hourly # MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300) # MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20) +# OVERPASS_URL= # Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled public mirrors — point it at an internal/self-hosted Overpass instance when the public ones are unreachable from your network. Non-http(s) entries are ignored. If you don't self-host Overpass but the public mirrors throttle you, setting APP_URL also gives outbound requests a unique User-Agent the mirrors rate-limit less. +# OVERPASS_TIMEOUT_MS=12000 # Per-endpoint timeout (ms) for Overpass POI requests; slower endpoints are abandoned so a faster mirror wins. Raise it for a slow self-hosted instance. (default: 12000) + # Initial admin account — only used on first boot when no users exist yet. # If both are set the admin account is created with these credentials. # If either is omitted a random password is generated and printed to the server log. diff --git a/server/package.json b/server/package.json index 4564f65e..091da2bb 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@trek/server", - "version": "3.1.2", + "version": "3.1.3", "main": "src/index.ts", "scripts": { "start": "node --require tsconfig-paths/register dist/index.js", diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index b8e3324e..ad980ae7 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -3054,6 +3054,23 @@ function runMigrations(db: Database.Database): void { if (!err.message?.includes('duplicate column name')) throw err; } }, + // Store Google Maps feature IDs separately from real Google Places API IDs. + () => { + try { + db.exec('ALTER TABLE places ADD COLUMN google_ftid TEXT'); + } catch (err: any) { + if (!err.message?.includes('duplicate column name')) throw err; + } + }, + // Remember the app version a notice was dismissed at, so per-version recurring + // notices (e.g. the thank-you) re-appear on the next install/upgrade. + () => { + try { + db.exec('ALTER TABLE user_notice_dismissals ADD COLUMN dismissed_app_version TEXT'); + } catch (err: any) { + if (!err.message?.includes('duplicate column name')) throw err; + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 71b3ad73..d9930df7 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -138,6 +138,7 @@ function createTables(db: Database.Database): void { notes TEXT, image_url TEXT, google_place_id TEXT, + google_ftid TEXT, website TEXT, phone TEXT, transport_mode TEXT DEFAULT 'walking', diff --git a/server/src/mcp/index.ts b/server/src/mcp/index.ts index 8fa99f58..ea0c9fc2 100644 --- a/server/src/mcp/index.ts +++ b/server/src/mcp/index.ts @@ -48,7 +48,7 @@ You are connected to TREK, a travel planning application. Below is a compact ref **Loading trip context:** Before planning or modifying a trip, call \`get_trip_summary\` once. It returns all days (with assignments and notes), accommodations, budget, packing, reservations, collab notes, and todos in a single round-trip. Use this data to answer follow-up questions without extra tool calls. **Adding a place to the itinerary (correct order):** -1. \`search_place\` — find the real-world POI; note the \`osm_id\` and/or \`google_place_id\` in the result. +1. \`search_place\` — find the real-world POI; note the \`osm_id\`, \`google_place_id\`, and/or \`google_ftid\` in the result. 2. \`create_place\` — add it to the trip's place pool, passing the IDs from step 1 (enables opening hours, ratings, and map linking in the app). 3. \`assign_place_to_day\` — schedule it on the desired day using the dayId from \`get_trip_summary\`. @@ -348,4 +348,4 @@ export function closeMcpSessions(): void { } sessions.clear(); rateLimitMap.clear(); -} \ No newline at end of file +} diff --git a/server/src/mcp/tools/days.ts b/server/src/mcp/tools/days.ts index 4b73b3fa..4ea491d0 100644 --- a/server/src/mcp/tools/days.ts +++ b/server/src/mcp/tools/days.ts @@ -131,6 +131,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri address: z.string().max(500).optional(), category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'), google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'), + google_ftid: z.string().optional().describe('Google Maps feature ID from search_place — enables direct Google Maps links'), osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'), place_notes: z.string().max(2000).optional().describe('Notes for the place'), website: z.string().max(500).optional(), @@ -147,7 +148,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, }, - async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, accommodation_notes, price, currency }) => { + async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, accommodation_notes, price, currency }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied(); @@ -155,7 +156,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true }; try { const run = db.transaction(() => { - const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency }); + const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes: place_notes, website, phone, price, currency }); const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes: accommodation_notes }); return { place, accommodation }; }); diff --git a/server/src/mcp/tools/places.ts b/server/src/mcp/tools/places.ts index 1578b55a..d8f68d2a 100644 --- a/server/src/mcp/tools/places.ts +++ b/server/src/mcp/tools/places.ts @@ -23,7 +23,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st if (W) server.registerTool( 'create_place', { - description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings. Set price + currency to record the cost so it shows on the item.', + description: 'Add a new place/POI to a trip. Set google_place_id, google_ftid, or osm_id (from search_place) so the app can show opening hours, ratings, and direct Google Maps links. Set price + currency to record the cost so it shows on the item.', inputSchema: { tripId: z.number().int().positive(), name: z.string().min(1).max(200), @@ -33,6 +33,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st address: z.string().max(500).optional(), category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'), google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'), + google_ftid: z.string().optional().describe('Google Maps feature ID from search_place — enables direct Google Maps links'), osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345") — enables opening hours if no Google ID'), notes: z.string().max(2000).optional(), website: z.string().max(500).optional(), @@ -42,11 +43,11 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, }, - async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency }) => { + async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, price, currency }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied(); - const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency }); + const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, price, currency }); safeBroadcast(tripId, 'place:created', { place }); return ok({ place }); } @@ -66,6 +67,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st address: z.string().max(500).optional(), category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'), google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'), + google_ftid: z.string().optional().describe('Google Maps feature ID from search_place — enables direct Google Maps links'), osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'), place_notes: z.string().max(2000).optional().describe('Notes for the place'), website: z.string().max(500).optional(), @@ -76,14 +78,14 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, }, - async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, assignment_notes, price, currency }) => { + async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, place_notes, website, phone, assignment_notes, price, currency }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied(); if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; try { const run = db.transaction(() => { - const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency }); + const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes: place_notes, website, phone, price, currency }); const assignment = createAssignment(dayId, place.id, assignment_notes ?? null); return { place, assignment }; }); @@ -121,14 +123,15 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st transport_mode: z.enum(['walking', 'driving', 'cycling', 'transit', 'flight']).optional(), osm_id: z.string().optional().describe('OpenStreetMap ID (e.g. "way:12345")'), google_place_id: z.string().optional().describe('Google Place ID (e.g. "ChIJd8BlQ2BZwokRAFUEcm_qrcA")'), + google_ftid: z.string().optional().describe('Google Maps feature ID (e.g. "0x89c259b7abdd4769:0x103aaf1c8bf8a050")'), }, annotations: TOOL_ANNOTATIONS_WRITE, }, - async ({ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id }) => { + async ({ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id, google_ftid }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied(); - const place = updatePlace(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id }); + const place = updatePlace(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id, google_ftid }); if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; safeBroadcast(tripId, 'place:updated', { place }); return ok({ place }); @@ -196,7 +199,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st if (R) server.registerTool( 'search_place', { - description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id if configured). Use these IDs when calling create_place so the app can display opening hours and ratings.', + description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id/google_ftid if configured). Use these IDs when calling create_place so the app can display opening hours, ratings, and map links.', inputSchema: { query: z.string().min(1).max(500).describe('Place name or address to search for'), }, diff --git a/server/src/middleware/globalMiddleware.ts b/server/src/middleware/globalMiddleware.ts index f9b2e50b..ab502a1f 100644 --- a/server/src/middleware/globalMiddleware.ts +++ b/server/src/middleware/globalMiddleware.ts @@ -114,7 +114,8 @@ export function applyGlobalMiddleware( "https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com", "https://geocoding-api.open-meteo.com", "https://api.frankfurter.dev", "https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/", - "https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com" + "https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com", + "https://tiles.openfreemap.org" ], workerSrc: ["'self'", "blob:"], childSrc: ["'self'", "blob:"], diff --git a/server/src/nest/budget/budget.controller.ts b/server/src/nest/budget/budget.controller.ts index 9d6bc8a3..278cccb8 100644 --- a/server/src/nest/budget/budget.controller.ts +++ b/server/src/nest/budget/budget.controller.ts @@ -136,7 +136,7 @@ export class BudgetController { } @Post() - create( + async create( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: { name?: string; category?: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null; reservation_id?: number }, @@ -147,7 +147,7 @@ export class BudgetController { if (!body.name) { throw new HttpException({ error: 'Name is required' }, 400); } - const item = this.budget.create(tripId, body as { name: string }); + const item = await this.budget.create(tripId, body as { name: string }); this.budget.broadcast(tripId, 'budget:created', { item }, socketId); return { item }; } @@ -181,7 +181,7 @@ export class BudgetController { } @Put(':id') - update( + async update( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @@ -190,7 +190,7 @@ export class BudgetController { ) { const trip = this.requireTrip(tripId, user); this.requireEdit(trip, user); - const updated = this.budget.update(id, tripId, body); + const updated = await this.budget.update(id, tripId, body); if (!updated) { throw new HttpException({ error: 'Budget item not found' }, 404); } diff --git a/server/src/nest/budget/budget.service.ts b/server/src/nest/budget/budget.service.ts index 70bd5c18..d4a37b17 100644 --- a/server/src/nest/budget/budget.service.ts +++ b/server/src/nest/budget/budget.service.ts @@ -41,11 +41,39 @@ export class BudgetService { return svc.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency }); } - create(tripId: string, data: Parameters[1]) { + // Freeze the live FX rate at entry time into budget_items.exchange_rate so a settled + // position isn't re-opened when live rates drift later (#1335). Only for a foreign + // currency with no explicit rate; degrades to live rates if the fetch fails. On update + // it (re)freezes only when the currency changes, so an unrelated edit never moves money. + private async freezeForeignRate( + tripId: string, + data: { currency?: string | null; exchange_rate?: number }, + existingItemId?: string, + ): Promise { + if (data.exchange_rate != null) return; // an explicit rate from the caller wins + const cur = (data.currency || '').toUpperCase(); + if (!cur) return; // currency not being set in this request + if (existingItemId != null) { + const existing = db.prepare('SELECT currency FROM budget_items WHERE id = ?') + .get(existingItemId) as { currency?: string } | undefined; + if (existing && (existing.currency || '').toUpperCase() === cur) return; // currency unchanged + } + const trip = db.prepare('SELECT currency FROM trips WHERE id = ?') + .get(tripId) as { currency?: string } | undefined; + const tripCur = (trip?.currency || 'EUR').toUpperCase(); + if (cur === tripCur) return; // same as the trip currency → no conversion to freeze + const rates = await getRates(tripCur); + const r = rates?.[cur]; + if (r && r > 0) data.exchange_rate = r; + } + + async create(tripId: string, data: Parameters[1]) { + await this.freezeForeignRate(tripId, data); return svc.createBudgetItem(tripId, data); } - update(id: string, tripId: string, data: Parameters[2]) { + async update(id: string, tripId: string, data: Parameters[2]) { + await this.freezeForeignRate(tripId, data, id); return svc.updateBudgetItem(id, tripId, data); } diff --git a/server/src/nest/files/files.controller.ts b/server/src/nest/files/files.controller.ts index cb290c66..50a83ba1 100644 --- a/server/src/nest/files/files.controller.ts +++ b/server/src/nest/files/files.controller.ts @@ -72,6 +72,15 @@ export class FilesController { return trip; } + // A file may only point at reservations/assignments/places from its own trip. + // Reject cross-trip ids before they are stored — the reservation JOIN would + // otherwise leak the foreign reservation's title back to the caller. + private assertLinkTargets(tripId: string, body: { reservation_id?: string | null; assignment_id?: string | null; place_id?: string | null }) { + if (this.files.findForeignLinkTarget(tripId, body)) { + throw new HttpException({ error: 'Linked item does not belong to this trip' }, 400); + } + } + @Get() list(@CurrentUser() user: User, @Param('tripId') tripId: string, @Query('trash') trash?: string) { this.requireTrip(tripId, user); @@ -97,6 +106,7 @@ export class FilesController { if (!file) { throw new HttpException({ error: 'No file uploaded' }, 400); } + this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, place_id: body.place_id }); const created = this.files.createFile(tripId, file, user.id, { place_id: body.place_id, description: body.description, @@ -116,6 +126,7 @@ export class FilesController { if (!file) { throw new HttpException({ error: 'File not found' }, 404); } + this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, place_id: body.place_id }); const updated = this.files.updateFile(id, file, { description: body.description, place_id: body.place_id, reservation_id: body.reservation_id }); this.files.broadcast(tripId, 'file:updated', { file: updated }, socketId); return { file: updated }; @@ -203,6 +214,7 @@ export class FilesController { if (!file) { throw new HttpException({ error: 'File not found' }, 404); } + this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, assignment_id: body.assignment_id, place_id: body.place_id }); const links = this.files.createFileLink(id, { reservation_id: body.reservation_id, assignment_id: body.assignment_id, place_id: body.place_id }); return { success: true, links }; } diff --git a/server/src/nest/files/files.service.ts b/server/src/nest/files/files.service.ts index b21e256b..48ecfeb1 100644 --- a/server/src/nest/files/files.service.ts +++ b/server/src/nest/files/files.service.ts @@ -43,6 +43,7 @@ export class FilesService { restoreFile(id: string) { return svc.restoreFile(id); } permanentDeleteFile(file: TripFile) { return svc.permanentDeleteFile(file); } emptyTrash(tripId: string) { return svc.emptyTrash(tripId); } + findForeignLinkTarget(tripId: string, opts: Parameters[1]) { return svc.findForeignLinkTarget(tripId, opts); } createFileLink(id: string, opts: Parameters[1]) { return svc.createFileLink(id, opts); } deleteFileLink(linkId: string, id: string) { return svc.deleteFileLink(linkId, id); } getFileLinks(id: string) { return svc.getFileLinks(id); } diff --git a/server/src/services/airtrail/airtrailMapper.ts b/server/src/services/airtrail/airtrailMapper.ts index 40c4fdd6..dd300b06 100644 --- a/server/src/services/airtrail/airtrailMapper.ts +++ b/server/src/services/airtrail/airtrailMapper.ts @@ -15,6 +15,15 @@ export function entityCode(e: AirtrailNamedCode | null | undefined): string | nu return e?.icao || e?.iata || null; } +/** + * Human-readable name for an airline/aircraft (e.g. "Lufthansa"), falling back to the + * code when AirTrail doesn't provide a name. Used for what TREK displays/stores; the + * raw code stays available via entityCode for the writeback payload (#1334). + */ +export function entityName(e: AirtrailNamedCode | null | undefined): string | null { + return e?.name || e?.icao || e?.iata || null; +} + /** * Local calendar date + clock time for an instant at a given IANA zone. * AirTrail stores `departure`/`arrival` as instants (ISO w/ offset) plus a local @@ -57,7 +66,7 @@ export function normalizeFlight(raw: AirtrailFlightRaw): AirtrailFlight { date: raw.date ?? null, departure: raw.departureScheduled ?? null, arrival: raw.arrivalScheduled ?? null, - airline: entityCode(raw.airline), + airline: entityName(raw.airline), flightNumber: raw.flightNumber ?? null, aircraft: entityCode(raw.aircraft), seatClass: (raw.seats?.find(s => s.userId) ?? raw.seats?.[0])?.seatClass ?? null, @@ -142,10 +151,14 @@ export function mapFlightToReservation(raw: AirtrailFlightRaw): MappedReservatio } const seat = raw.seats?.find(s => s.userId) ?? raw.seats?.[0]; + const airlineName = entityName(raw.airline); const airlineCode = entityCode(raw.airline); const aircraftCode = entityCode(raw.aircraft); const metadata: Record = {}; - if (airlineCode) metadata.airline = airlineCode; + // Display the airline name; keep the code in airline_code for the AirTrail writeback, + // which expects a code, not a name (#1334 / #1240). + if (airlineName) metadata.airline = airlineName; + if (airlineCode) metadata.airline_code = airlineCode; if (raw.flightNumber) metadata.flight_number = raw.flightNumber; if (aircraftCode) metadata.aircraft = aircraftCode; if (raw.aircraftReg) metadata.aircraft_reg = raw.aircraftReg; diff --git a/server/src/services/airtrail/airtrailSync.ts b/server/src/services/airtrail/airtrailSync.ts index 9e3d164f..bf76b3a8 100644 --- a/server/src/services/airtrail/airtrailSync.ts +++ b/server/src/services/airtrail/airtrailSync.ts @@ -216,9 +216,10 @@ export function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): arrivalScheduledTime: arr.time, // These are AirTrail-owned details TREK doesn't surface in its edit UI — a TREK // edit can leave them out of `metadata`. Preserve AirTrail's current value when - // TREK has none rather than nulling it out (#1240). entityCode mirrors the + // TREK has none rather than nulling it out (#1240). Use airline_code (not the + // display name in metadata.airline, #1334); both it and entityCode mirror the // import/hash code-selection so a writeback stays a no-op for the hash. - airline: meta.airline ?? entityCode(existing.airline) ?? null, + airline: meta.airline_code ?? entityCode(existing.airline) ?? null, flightNumber: meta.flight_number ?? existing.flightNumber ?? null, aircraft: meta.aircraft ?? entityCode(existing.aircraft) ?? null, aircraftReg: meta.aircraft_reg ?? existing.aircraftReg ?? null, diff --git a/server/src/services/assignmentService.ts b/server/src/services/assignmentService.ts index b7579ea9..56bd0f7c 100644 --- a/server/src/services/assignmentService.ts +++ b/server/src/services/assignmentService.ts @@ -9,7 +9,7 @@ export function getAssignmentWithPlace(assignmentId: number | bigint) { COALESCE(da.assignment_time, p.place_time) as place_time, COALESCE(da.assignment_end_time, p.end_time) as end_time, p.duration_minutes, p.notes as place_notes, - p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone, + p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.website, p.phone, c.name as category_name, c.color as category_color, c.icon as category_icon FROM day_assignments da JOIN places p ON da.place_id = p.id @@ -59,6 +59,7 @@ export function getAssignmentWithPlace(assignmentId: number | bigint) { image_url: a.image_url, transport_mode: a.transport_mode, google_place_id: a.google_place_id, + google_ftid: a.google_ftid, website: a.website, phone: a.phone, category: a.category_id ? { @@ -79,7 +80,7 @@ export function listDayAssignments(dayId: string | number) { COALESCE(da.assignment_time, p.place_time) as place_time, COALESCE(da.assignment_end_time, p.end_time) as end_time, p.duration_minutes, p.notes as place_notes, - p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone, + p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.website, p.phone, c.name as category_name, c.color as category_color, c.icon as category_icon FROM day_assignments da JOIN places p ON da.place_id = p.id diff --git a/server/src/services/atlasService.ts b/server/src/services/atlasService.ts index 5577492c..72ee66e7 100644 --- a/server/src/services/atlasService.ts +++ b/server/src/services/atlasService.ts @@ -199,19 +199,74 @@ export async function reverseGeocodeCountry(lat: number, lng: number): Promise= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng) { - const area = (maxLng - minLng) * (maxLat - minLat); - if (area < bestArea) { - bestArea = area; - bestCode = code; - } +// ── Point-in-polygon over the bundled admin0 borders (#1331) ───────────────── + +// Ray-casting (even-odd) test of (lng,lat) against a single GeoJSON ring. +function pointInRing(lng: number, lat: number, ring: number[][]): boolean { + let inside = false; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const xi = ring[i][0], yi = ring[i][1]; + const xj = ring[j][0], yj = ring[j][1]; + if (((yi > lat) !== (yj > lat)) && (lng < ((xj - xi) * (lat - yi)) / (yj - yi) + xi)) { + inside = !inside; } } - return bestCode; + return inside; +} + +// True when (lng,lat) falls inside a Polygon/MultiPolygon, honouring holes. +function pointInGeometry(lng: number, lat: number, geom: { type: string; coordinates: number[][][] | number[][][][] }): boolean { + const polygons = (geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates) as number[][][][]; + for (const poly of polygons) { + if (!pointInRing(lng, lat, poly[0])) continue; + let inHole = false; + for (let h = 1; h < poly.length; h++) { + if (pointInRing(lng, lat, poly[h])) { inHole = true; break; } + } + if (!inHole) return true; + } + return false; +} + +// ISO_A2 → admin0 geometry, built once. Micro-territories (HK, MO, SM, VA, …) aren't +// in admin0, so they stay absent and keep the smallest-box behaviour below. +let countryPolyIndex: Map | null = null; +function getCountryPolyIndex(): Map { + if (countryPolyIndex) return countryPolyIndex; + const idx = new Map(); + for (const f of loadGeoBundle('admin0').features ?? []) { + const code = f.properties?.ISO_A2; + if (code && code !== '-99' && f.geometry) idx.set(String(code).toUpperCase(), f.geometry); + } + countryPolyIndex = idx; + return idx; +} + +export function getCountryFromCoords(lat: number, lng: number): string | null { + // Cheap prefilter: every country whose bounding box contains the point. + const candidates: { code: string; area: number }[] = []; + for (const [code, [minLng, minLat, maxLng, maxLat]] of Object.entries(COUNTRY_BOXES)) { + if (lat >= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng) { + candidates.push({ code, area: (maxLng - minLng) * (maxLat - minLat) }); + } + } + if (candidates.length === 0) return null; + if (candidates.length === 1) return candidates[0].code; + + // Boxes overlap near borders, so a single point can sit in several — picking the + // smallest box then mis-assigns a point just across the border (#1331). Disambiguate + // with the real admin0 polygon: try candidates smallest-box-first and return the one + // whose polygon actually contains the point. A candidate with no admin0 polygon (a + // micro-territory like HK/MO/SM/VA) keeps the smallest-box win. + candidates.sort((a, b) => a.area - b.area); + const polys = getCountryPolyIndex(); + for (const { code } of candidates) { + const poly = polys.get(code); + if (!poly) return code; + if (pointInGeometry(lng, lat, poly)) return code; + } + // No polygon contained the point (coastal slop / data gap) — fall back to smallest box. + return candidates[0].code; } export function getCountryFromAddress(address: string | null): string | null { diff --git a/server/src/services/budgetService.ts b/server/src/services/budgetService.ts index e707c1c6..5383042e 100644 --- a/server/src/services/budgetService.ts +++ b/server/src/services/budgetService.ts @@ -349,9 +349,17 @@ export function calculateSettlement( const rates = opts.rates ?? null; // Amount in some currency → base. Pre-rework rows store currency = NULL, which // means "the trip's own currency". rates[X] = units of X per 1 base. - const toBase = (amount: number, itemCurrency: string | null | undefined): number => { + const toBase = (amount: number, itemCurrency: string | null | undefined, itemRate?: number | null): number => { const cur = (itemCurrency || tripCurrency).toUpperCase(); - if (cur === base || !rates) return amount; + if (cur === base) return amount; + // Prefer the FX rate frozen at entry time (#1335): a settled expense keeps the rate it + // was booked at, so a later live-rate drift doesn't re-open it with a few-cent residual. + // The stored rate is units of item-currency per 1 trip-currency, so it only applies when + // converting to the trip's own currency; otherwise (and for legacy rows) use live rates. + if (base === tripCurrency && itemRate != null && itemRate > 0 && itemRate !== 1) { + return amount / itemRate; + } + if (!rates) return amount; const r = rates[cur]; return r && r > 0 ? amount / r : amount; }; @@ -384,11 +392,11 @@ export function calculateSettlement( const payers = allPayers.filter(p => p.budget_item_id === item.id); if (members.length === 0) continue; // planning-only entry → doesn't affect balances - const paidBase = payers.reduce((a, p) => a + toBase(p.amount > 0 ? p.amount : 0, item.currency), 0); + const paidBase = payers.reduce((a, p) => a + toBase(p.amount > 0 ? p.amount : 0, item.currency, item.exchange_rate), 0); const sharePerMember = paidBase / members.length; // Payers are credited what they actually paid (converted to base)… - for (const p of payers) ensure(p.user_id, p).balance += toBase(p.amount > 0 ? p.amount : 0, item.currency); + for (const p of payers) ensure(p.user_id, p).balance += toBase(p.amount > 0 ? p.amount : 0, item.currency, item.exchange_rate); // …and every split participant owes an equal share of the base total. for (const m of members) ensure(m.user_id, m).balance -= sharePerMember; } diff --git a/server/src/services/dayService.ts b/server/src/services/dayService.ts index d1d0d1e2..e9a01474 100644 --- a/server/src/services/dayService.ts +++ b/server/src/services/dayService.ts @@ -15,7 +15,7 @@ export function getAssignmentsForDay(dayId: number | string) { COALESCE(da.assignment_time, p.place_time) as place_time, COALESCE(da.assignment_end_time, p.end_time) as end_time, p.duration_minutes, p.notes as place_notes, - p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone, + p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.website, p.phone, c.name as category_name, c.color as category_color, c.icon as category_icon FROM day_assignments da JOIN places p ON da.place_id = p.id @@ -54,6 +54,7 @@ export function getAssignmentsForDay(dayId: number | string) { image_url: a.image_url, transport_mode: a.transport_mode, google_place_id: a.google_place_id, + google_ftid: a.google_ftid, website: a.website, phone: a.phone, category: a.category_id ? { @@ -88,7 +89,7 @@ export function listDays(tripId: string | number) { COALESCE(da.assignment_time, p.place_time) as place_time, COALESCE(da.assignment_end_time, p.end_time) as end_time, p.duration_minutes, p.notes as place_notes, - p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone, + p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.website, p.phone, c.name as category_name, c.color as category_color, c.icon as category_icon FROM day_assignments da JOIN places p ON da.place_id = p.id diff --git a/server/src/services/fileService.ts b/server/src/services/fileService.ts index f119344c..f596c4b8 100644 --- a/server/src/services/fileService.ts +++ b/server/src/services/fileService.ts @@ -55,6 +55,34 @@ export function formatFile(file: TripFile & { trip_id?: number; uploaded_by_avat }; } +// --------------------------------------------------------------------------- +// Trip-scoped link validation +// --------------------------------------------------------------------------- + +/** + * A file, and any reservation / day-assignment / place it points at, must all + * live in the same trip. FILE_SELECT and getFileLinks join the reservation and + * return its title, so without this guard a member of trip A could aim a file + * (or a file_link) at trip B's reservation id and read the title back. Returns + * the first field that escapes `tripId`, or null when every supplied id belongs + * to the trip. Absent / null / zero ids are ignored (they clear the link). + */ +export function findForeignLinkTarget( + tripId: string | number, + opts: { reservation_id?: string | number | null; assignment_id?: string | number | null; place_id?: string | number | null } +): 'reservation_id' | 'assignment_id' | 'place_id' | null { + if (opts.reservation_id && !db.prepare('SELECT 1 FROM reservations WHERE id = ? AND trip_id = ?').get(opts.reservation_id, tripId)) { + return 'reservation_id'; + } + if (opts.place_id && !db.prepare('SELECT 1 FROM places WHERE id = ? AND trip_id = ?').get(opts.place_id, tripId)) { + return 'place_id'; + } + if (opts.assignment_id && !db.prepare('SELECT 1 FROM day_assignments a JOIN days d ON a.day_id = d.id WHERE a.id = ? AND d.trip_id = ?').get(opts.assignment_id, tripId)) { + return 'assignment_id'; + } + return null; +} + // --------------------------------------------------------------------------- // File path resolution & validation // --------------------------------------------------------------------------- diff --git a/server/src/services/mapsService.ts b/server/src/services/mapsService.ts index 996b81e0..f050fc6e 100644 --- a/server/src/services/mapsService.ts +++ b/server/src/services/mapsService.ts @@ -45,6 +45,7 @@ interface GooglePlaceResult { websiteUri?: string; nationalPhoneNumber?: string; types?: string[]; + googleMapsUri?: string; } interface GoogleAutocompleteSuggestion { @@ -60,7 +61,6 @@ interface GoogleAutocompleteSuggestion { interface GooglePlaceDetails extends GooglePlaceResult { userRatingCount?: number; regularOpeningHours?: { weekdayDescriptions?: string[]; openNow?: boolean }; - googleMapsUri?: string; editorialSummary?: { text: string }; reviews?: { authorAttribution?: { displayName?: string; photoUri?: string }; rating?: number; text?: { text?: string }; relativePublishTimeDescription?: string }[]; photos?: { name: string; authorAttributions?: { displayName?: string }[] }[]; @@ -68,7 +68,17 @@ interface GooglePlaceDetails extends GooglePlaceResult { // ── Constants ──────────────────────────────────────────────────────────────── -const UA = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)'; +// Overpass, Nominatim and Wikimedia all ask that requests carry a User-Agent that +// uniquely identifies the deploying instance — a shared, generic UA gets rate-limited +// and throttled harder (see #1309). When the instance URL is configured we append it; +// getAppUrl()'s bare http://localhost fallback isn't a useful identifier, so we drop it. +export function buildUserAgent(instanceUrl: string | undefined): string { + const base = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)'; + if (instanceUrl && !instanceUrl.startsWith('http://localhost')) return `${base}; ${instanceUrl}`; + return base; +} +// Computed once at load — getAppUrl() reads only env vars, which don't change at runtime. +const UA = buildUserAgent(getAppUrl()); // TREK's internal language codes mostly coincide with valid BCP-47 codes, but a // couple don't: 'br' is Brazilian Portuguese here (BCP-47 'pt-BR'; bare 'br' is @@ -88,6 +98,23 @@ function toApiLang(lang: string | undefined, fallback = 'en'): string { return API_LANG_OVERRIDES[code] ?? code; } +const GOOGLE_FTID_RE = /^0x[0-9a-f]+:0x[0-9a-f]+$/i; + +// Extracts a Google Maps feature id (ftid, 0x..:0x..) from a URL's ?ftid= param. +// The Places API (New) googleMapsUri is usually a cid-style URL (https://maps.google.com/?cid=NNN) +// with no ftid, so this returns null for most API responses — the precise query_place_id link is +// used instead. It does recover an ftid from a /place/?...&ftid= URL, e.g. a pasted share link +// resolved by resolveGoogleMapsUrl or a Google MyMaps list import. +export function googleFtidFromMapsUrl(url?: string | null): string | null { + if (!url) return null; + try { + const ftid = new URL(url).searchParams.get('ftid')?.trim(); + return ftid && GOOGLE_FTID_RE.test(ftid) ? ftid.toLowerCase() : null; + } catch { + return null; + } +} + // ── Photo cache (disk-backed) ──────────────────────────────────────────────── import * as placePhotoCache from './placePhotoCache'; @@ -145,6 +172,7 @@ export async function searchNominatim(query: string, lang?: string) { const data = await response.json() as NominatimResult[]; return data.map(item => ({ google_place_id: null, + google_ftid: null, osm_id: `${item.osm_type}:${item.osm_id}`, name: item.name || item.display_name?.split(',')[0] || '', address: item.display_name || '', @@ -264,15 +292,39 @@ interface PoiSearchResult { // frequently overloaded (504s) and some community mirrors are unreachable from // certain networks. Racing them means whichever mirror is fastest-reachable for // this user answers, and an overloaded or blocked one never blocks the others. -const OVERPASS_MIRRORS = [ +const DEFAULT_OVERPASS_MIRRORS = [ 'https://overpass-api.de/api/interpreter', 'https://maps.mail.ru/osm/tools/overpass/api/interpreter', 'https://overpass.kumi.systems/api/interpreter', 'https://overpass.private.coffee/api/interpreter', ]; -// Per-mirror cap. Because mirrors race in parallel this is also the worst-case -// total wait before every mirror is given up on and a 502 is returned. -const OVERPASS_TIMEOUT_MS = 12000; + +// Operators behind locked-down egress — or running their own Overpass — can point TREK +// at one or more custom endpoints via OVERPASS_URL (comma-separated). When set it +// REPLACES the public mirrors, so a firewalled cluster never reaches out to them and a +// self-hosted instance is used exclusively (see #1309). Non-http(s) entries are dropped. +export function resolveOverpassEndpoints(raw: string | undefined = process.env.OVERPASS_URL): string[] { + const custom = (raw ?? '') + .split(',') + .map(s => s.trim()) + .filter(s => { + try { const u = new URL(s); return u.protocol === 'http:' || u.protocol === 'https:'; } + catch { return false; } + }); + return custom.length ? custom : DEFAULT_OVERPASS_MIRRORS; +} +const OVERPASS_MIRRORS = resolveOverpassEndpoints(); +// Per-mirror fetch cap. Because mirrors race in parallel this is also the worst-case +// wait before every mirror is given up on and a 502 is returned. Public mirrors answer +// in 1–2s when reachable, so the cap mainly bounds dead/blocked ones; operators with a +// slow self-hosted endpoint can raise it via OVERPASS_TIMEOUT_MS. A non-positive or +// non-numeric value falls back to the default — a 0/negative cap would abort every +// request immediately and 502 the search. +export function resolveOverpassTimeoutMs(raw: string | undefined = process.env.OVERPASS_TIMEOUT_MS): number { + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? n : 12000; +} +const OVERPASS_TIMEOUT_MS = resolveOverpassTimeoutMs(); // Largest viewport side we send to Overpass. A country/continent-sized bbox makes // Overpass scan millions of elements and time out; clamping to a centred window // keeps the query cheap so the explore pill returns fast at ANY zoom level. @@ -324,8 +376,15 @@ async function overpassFetch(query: string): Promise { // Promise.any resolves with the first mirror to return valid JSON, and only // rejects (AggregateError) once every mirror has failed. return await Promise.any(OVERPASS_MIRRORS.map(attempt)); - } catch { - throw Object.assign(new Error('Overpass request failed'), { status: 502 }); + } catch (err) { + // Log WHY every endpoint failed (connection refused, aborted/timed out, non-OSM + // body, …) so an operator can tell blocked egress / a firewall from a transiently + // overloaded mirror — otherwise this is a bare 502 with no breadcrumb (see #1309). + const reasons = err instanceof AggregateError + ? err.errors.map(e => (e instanceof Error ? e.message : String(e))).join(' | ') + : (err instanceof Error ? err.message : String(err)); + console.error(`[Overpass] all ${OVERPASS_MIRRORS.length} endpoint(s) failed — ${reasons}`); + throw Object.assign(new Error('Could not reach any Overpass endpoint'), { status: 502 }); } finally { // Cancel the slower/losing requests — we already have (or have given up on) a result. controllers.forEach(c => { try { c.abort(); } catch { /* noop */ } }); @@ -573,7 +632,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string, headers: { 'Content-Type': 'application/json', 'X-Goog-Api-Key': apiKey, - 'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types', + 'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types,places.googleMapsUri', }, body: JSON.stringify(searchBody), }); @@ -588,6 +647,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string, const places = (data.places || []).map((p: GooglePlaceResult) => ({ google_place_id: p.id, + google_ftid: googleFtidFromMapsUrl(p.googleMapsUri), name: p.displayName?.text || '', address: p.formattedAddress || '', lat: p.location?.latitude || null, @@ -740,6 +800,7 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st const place = { google_place_id: data.id, + google_ftid: googleFtidFromMapsUrl(data.googleMapsUri), name: data.displayName?.text || '', address: data.formattedAddress || '', lat: data.location?.latitude || null, @@ -799,6 +860,7 @@ export async function getPlaceDetailsExpanded(userId: number, placeId: string, l const place = { google_place_id: data.id, + google_ftid: googleFtidFromMapsUrl(data.googleMapsUri), name: data.displayName?.text || '', address: data.formattedAddress || '', lat: data.location?.latitude || null, @@ -983,7 +1045,7 @@ export async function reverseGeocode(lat: string, lng: string, lang?: string): P // ── Resolve Google Maps URL ────────────────────────────────────────────────── -export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number; lng: number; name: string | null; address: string | null }> { +export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number; lng: number; name: string | null; address: string | null; google_ftid: string | null }> { let resolvedUrl = url; // Extract coordinates from a string (URL or page body). Google Maps encodes @@ -1064,5 +1126,5 @@ export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number; const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null; const address = nominatim.display_name || null; - return { lat, lng, name, address }; + return { lat, lng, name, address, google_ftid: googleFtidFromMapsUrl(resolvedUrl) }; } diff --git a/server/src/services/placeEnrichment.ts b/server/src/services/placeEnrichment.ts index fa4be26f..28e56b7b 100644 --- a/server/src/services/placeEnrichment.ts +++ b/server/src/services/placeEnrichment.ts @@ -10,8 +10,8 @@ import { getMapsKey, searchPlaces, getPlacePhoto } from './mapsService'; * open/closed). When the importer opts in and a Google Maps key is configured, * we re-resolve each place by name — biased to and validated against the * imported coordinates — to a real Google place, then fill in the empty fields - * and persist the resolved `google_place_id` (which is what powers on-demand - * opening hours / the proper Maps link going forward). + * and persist the resolved `google_place_id` plus `google_ftid` (which power + * on-demand opening hours and proper Maps links going forward). * * This runs detached from the import request (fire-and-forget) so a long list * never blocks the response, and pushes each enriched row over the websocket so @@ -26,6 +26,7 @@ export interface EnrichablePlace { lat: number; lng: number; google_place_id?: string | null; + google_ftid?: string | null; address?: string | null; website?: string | null; phone?: string | null; @@ -105,18 +106,20 @@ async function enrichOne(tripId: string, userId: number, place: EnrichablePlace, const gpid = str(match.google_place_id); if (!gpid) return; + const gftid = str(match.google_ftid); // COALESCE so enrichment only fills empty columns — never overwrites data the // import already captured (e.g. Naver's address) or anything the user edited. db.prepare( `UPDATE places - SET google_place_id = COALESCE(google_place_id, ?), - address = COALESCE(address, ?), - website = COALESCE(website, ?), - phone = COALESCE(phone, ?), - updated_at = CURRENT_TIMESTAMP + SET google_place_id = COALESCE(google_place_id, ?), + google_ftid = COALESCE(google_ftid, ?), + address = COALESCE(address, ?), + website = COALESCE(website, ?), + phone = COALESCE(phone, ?), + updated_at = CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?`, - ).run(gpid, str(match.address), str(match.website), str(match.phone), place.id, tripId); + ).run(gpid, gftid, str(match.address), str(match.website), str(match.phone), place.id, tripId); // Photo is best-effort: Google often has none, and getPlacePhoto throws 404 in // that case — a missing photo must never abort the rest of the enrichment. diff --git a/server/src/services/placeService.ts b/server/src/services/placeService.ts index d40c08b6..0f8c51a8 100644 --- a/server/src/services/placeService.ts +++ b/server/src/services/placeService.ts @@ -123,27 +123,27 @@ export function createPlace( category_id?: number; price?: number; currency?: string; place_time?: string; end_time?: string; duration_minutes?: number; notes?: string; image_url?: string; - google_place_id?: string; osm_id?: string; website?: string; phone?: string; + google_place_id?: string; google_ftid?: string; osm_id?: string; website?: string; phone?: string; transport_mode?: string; tags?: number[]; }, ) { const { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, - duration_minutes, notes, image_url, google_place_id, osm_id, website, phone, + duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone, transport_mode, tags = [], } = body; const result = db.prepare(` INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, - duration_minutes, notes, image_url, google_place_id, osm_id, website, phone, transport_mode) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone, transport_mode) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( tripId, name, description || null, lat || null, lng || null, address || null, category_id || null, price || null, currency || null, place_time || null, end_time || null, duration_minutes || 60, notes || null, image_url || null, - google_place_id || null, osm_id || null, website || null, phone || null, transport_mode || 'walking', + google_place_id || null, google_ftid || null, osm_id || null, website || null, phone || null, transport_mode || 'walking', ); const placeId = result.lastInsertRowid; @@ -180,7 +180,7 @@ export function updatePlace( category_id?: number; price?: number; currency?: string; place_time?: string; end_time?: string; duration_minutes?: number; notes?: string; image_url?: string; - google_place_id?: string; osm_id?: string; website?: string; phone?: string; + google_place_id?: string; google_ftid?: string; osm_id?: string; website?: string; phone?: string; transport_mode?: string; tags?: number[]; }, ) { @@ -190,7 +190,7 @@ export function updatePlace( const { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, - duration_minutes, notes, image_url, google_place_id, osm_id, website, phone, + duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone, transport_mode, tags, } = body; @@ -210,6 +210,7 @@ export function updatePlace( notes = ?, image_url = ?, google_place_id = ?, + google_ftid = ?, osm_id = ?, website = ?, phone = ?, @@ -231,6 +232,7 @@ export function updatePlace( notes !== undefined ? notes : existingPlace.notes, image_url !== undefined ? image_url : existingPlace.image_url, google_place_id !== undefined ? google_place_id : existingPlace.google_place_id, + google_ftid !== undefined ? google_ftid : existingPlace.google_ftid, osm_id !== undefined ? osm_id : existingPlace.osm_id, website !== undefined ? website : existingPlace.website, phone !== undefined ? phone : existingPlace.phone, @@ -625,6 +627,65 @@ export async function importMapFile(tripId: string, fileBuffer: Buffer, filename // Import Google Maps list // --------------------------------------------------------------------------- +function googleMapsHexId(value: unknown): string | null { + if (typeof value !== 'string' && typeof value !== 'number') return null; + const raw = String(value).trim(); + if (/^0x[0-9a-f]+$/i.test(raw)) return raw.toLowerCase(); + if (!/^-?\d+$/.test(raw)) return null; + try { + const parsed = BigInt(raw); + const unsigned = parsed < 0n ? (1n << 64n) + parsed : parsed; + return `0x${unsigned.toString(16)}`; + } catch { + return null; + } +} + +function googleMapsFeatureIdFromItem(item: unknown): string | null { + if (!Array.isArray(item)) return null; + const candidates = [ + Array.isArray(item[1]) ? item[1][6] : null, + Array.isArray(item[7]) ? item[7][1] : null, + ]; + + for (const ids of candidates) { + if (!Array.isArray(ids) || ids.length < 2) continue; + const first = googleMapsHexId(ids[0]); + const second = googleMapsHexId(ids[1]); + if (first && second) return `${first}:${second}`; + } + + return null; +} + +function findDuplicatePlace( + tripId: string, + place: { name: string | null | undefined; lat: number | null; lng: number | null }, +): { id: number; google_ftid: string | null } | null { + const normalizedName = place.name?.trim().toLowerCase(); + if (normalizedName) { + const duplicate = db.prepare(` + SELECT id, google_ftid FROM places + WHERE trip_id = ? AND lower(trim(name)) = ? + ORDER BY id ASC + LIMIT 1 + `).get(tripId, normalizedName) as { id: number; google_ftid: string | null } | undefined; + if (duplicate) return duplicate; + } + if (place.lat != null && place.lng != null) { + return db.prepare(` + SELECT id, google_ftid FROM places + WHERE trip_id = ? + AND lat IS NOT NULL AND lng IS NOT NULL + AND abs(lat - ?) <= ? + AND abs(lng - ?) <= ? + ORDER BY id ASC + LIMIT 1 + `).get(tripId, place.lat, COORD_DEDUP_TOLERANCE, place.lng, COORD_DEDUP_TOLERANCE) as { id: number; google_ftid: string | null } | undefined || null; + } + return null; +} + export async function importGoogleList(tripId: string, url: string, opts?: ListImportOptions) { let listId: string | null = null; let resolvedUrl = url; @@ -658,6 +719,11 @@ export async function importGoogleList(tripId: string, url: string, opts?: ListI } if (!listId) { + // A single-place share link (…/maps/place/…) carries no list id — point the user at + // the place search box instead of a cryptic "could not extract list ID" (#1304). + if (resolvedUrl.includes('/maps/place/')) { + return { error: 'That link points to a single place, not a list. To add it, paste the link into the place search box instead of using the list import.', status: 400 }; + } return { error: 'Could not extract list ID from URL. Please use a shared Google Maps list link.', status: 400 }; } @@ -689,7 +755,7 @@ export async function importGoogleList(tripId: string, url: string, opts?: ListI } // Parse place data from items - const places: { name: string; lat: number; lng: number; notes: string | null }[] = []; + const places: { name: string; lat: number; lng: number; notes: string | null; googleFtid: string | null }[] = []; for (const item of items) { const coords = item?.[1]?.[5]; const lat = coords?.[2]; @@ -698,7 +764,7 @@ export async function importGoogleList(tripId: string, url: string, opts?: ListI const note = item?.[3] || null; if (name && typeof lat === 'number' && typeof lng === 'number' && !isNaN(lat) && !isNaN(lng)) { - places.push({ name, lat, lng, notes: note || null }); + places.push({ name, lat, lng, notes: note || null, googleFtid: googleMapsFeatureIdFromItem(item) }); } } @@ -708,18 +774,23 @@ export async function importGoogleList(tripId: string, url: string, opts?: ListI const dedup = buildDedupSet(tripId); const insertStmt = db.prepare(` - INSERT INTO places (trip_id, name, lat, lng, notes, transport_mode) - VALUES (?, ?, ?, ?, ?, 'walking') + INSERT INTO places (trip_id, name, lat, lng, notes, google_ftid, transport_mode) + VALUES (?, ?, ?, ?, ?, ?, 'walking') `); + const updateGoogleFtidStmt = db.prepare('UPDATE places SET google_ftid = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'); const created: any[] = []; let skipped = 0; const insertAll = db.transaction(() => { for (const p of places) { if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) { + const duplicate = findDuplicatePlace(tripId, p); + if (duplicate && !duplicate.google_ftid && p.googleFtid) { + updateGoogleFtidStmt.run(p.googleFtid, duplicate.id); + } skipped++; continue; } - const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.notes); + const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.notes, p.googleFtid); const place = getPlaceWithTags(Number(result.lastInsertRowid)); created.push(place); trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup); diff --git a/server/src/services/queryHelpers.ts b/server/src/services/queryHelpers.ts index 76135181..63253e37 100644 --- a/server/src/services/queryHelpers.ts +++ b/server/src/services/queryHelpers.ts @@ -80,6 +80,7 @@ function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial[], parti image_url: a.image_url, transport_mode: a.transport_mode, google_place_id: a.google_place_id, + google_ftid: a.google_ftid, website: a.website, phone: a.phone, category: a.category_id ? { diff --git a/server/src/services/reservationService.ts b/server/src/services/reservationService.ts index fb31daac..39ddfeb9 100644 --- a/server/src/services/reservationService.ts +++ b/server/src/services/reservationService.ts @@ -59,6 +59,34 @@ function resolveDayIdFromTime( return row?.id ?? null; } +// After a trip's date range changes, generateDays positionally re-dates the day rows +// (keeping their ids), so a dated booking's day_id stays glued to a now-re-dated day and +// the booking visually shifts by the offset (#1288). Re-anchor non-hotel bookings to the +// day matching their absolute reservation_time — the same derivation create/updateReservation +// use. Only updates when a matching day exists, so a booking whose date now falls outside +// the new range is left untouched. Hotels keep their range on the linked day_accommodation. +export function resyncReservationDays(tripId: string | number): void { + const rows = db.prepare( + `SELECT id, reservation_time, reservation_end_time, day_id, end_day_id + FROM reservations + WHERE trip_id = ? AND type != 'hotel' AND reservation_time IS NOT NULL`, + ).all(tripId) as { + id: number; reservation_time: string | null; reservation_end_time: string | null; + day_id: number | null; end_day_id: number | null; + }[]; + const update = db.prepare('UPDATE reservations SET day_id = ?, end_day_id = ? WHERE id = ?'); + for (const r of rows) { + const newDayId = resolveDayIdFromTime(tripId, r.reservation_time); + if (newDayId == null) continue; + const newEndDayId = r.reservation_end_time + ? (resolveDayIdFromTime(tripId, r.reservation_end_time) ?? r.end_day_id) + : r.end_day_id; + if (newDayId !== r.day_id || newEndDayId !== r.end_day_id) { + update.run(newDayId, newEndDayId, r.id); + } + } +} + function saveEndpoints(reservationId: number, endpoints: EndpointInput[]): void { // Bind the transaction lazily on each call. Binding at module load time // captures the DB connection that was open then, which becomes invalid diff --git a/server/src/services/settingsService.ts b/server/src/services/settingsService.ts index 905180ed..b3140926 100644 --- a/server/src/services/settingsService.ts +++ b/server/src/services/settingsService.ts @@ -8,6 +8,7 @@ const MASKED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token']); export const DEFAULTABLE_USER_SETTING_KEYS = [ 'temperature_unit', + 'distance_unit', 'dark_mode', 'time_format', // Instance-wide default currency for Costs (new users inherit it until they @@ -15,11 +16,12 @@ export const DEFAULTABLE_USER_SETTING_KEYS = [ 'default_currency', 'blur_booking_codes', 'map_tile_url', - // Instance-wide Mapbox defaults: an admin can set a shared token + style so the - // whole instance uses Mapbox without each user pasting their own key (#920). + // Instance-wide GL map defaults: admins can set Mapbox token/style or + // tokenless MapLibre/OpenFreeMap style defaults for new users (#920). 'map_provider', 'mapbox_access_token', 'mapbox_style', + 'maplibre_style', 'mapbox_3d_enabled', 'mapbox_quality_mode', ] as const; @@ -28,9 +30,10 @@ type DefaultableKey = typeof DEFAULTABLE_USER_SETTING_KEYS[number]; const VALID_VALUES: Partial> = { temperature_unit: ['fahrenheit', 'celsius'], + distance_unit: ['metric', 'imperial'], time_format: ['12h', '24h'], dark_mode: [true, false, 'light', 'dark', 'auto'], - map_provider: ['leaflet', 'mapbox-gl'], + map_provider: ['leaflet', 'mapbox-gl', 'maplibre-gl'], }; const BOOLEAN_KEYS = new Set(['blur_booking_codes', 'mapbox_3d_enabled', 'mapbox_quality_mode']); diff --git a/server/src/services/tripService.ts b/server/src/services/tripService.ts index c1fef7af..3267ff7c 100644 --- a/server/src/services/tripService.ts +++ b/server/src/services/tripService.ts @@ -5,7 +5,7 @@ import { Trip, User } from '../types'; import { listDays, listAccommodations } from './dayService'; import { listBudgetItems } from './budgetService'; import { listItems as listPackingItems } from './packingService'; -import { listReservations, loadEndpointsByTrip } from './reservationService'; +import { listReservations, loadEndpointsByTrip, resyncReservationDays } from './reservationService'; import { listNotes as listCollabNotes } from './collabService'; import { shiftOwnerEntriesForTripWindow } from './vacayService'; @@ -256,8 +256,12 @@ export function updateTrip(tripId: string | number, userId: number, data: Update shiftOwnerEntriesForTripWindow(trip.user_id, trip.start_date, trip.end_date, newStart); const dayCount = data.day_count ? Math.min(Math.max(Number(data.day_count) || 7, 1), MAX_TRIP_DAYS) : undefined; - if (newStart !== trip.start_date || newEnd !== trip.end_date || dayCount) + if (newStart !== trip.start_date || newEnd !== trip.end_date || dayCount) { generateDays(tripId, newStart || null, newEnd || null, undefined, dayCount); + // generateDays re-dates day rows positionally; re-anchor dated bookings to the day + // matching their absolute reservation_time so they don't shift with it (#1288). + resyncReservationDays(tripId); + } const changes: Record = {}; if (title && title !== trip.title) changes.title = title; @@ -632,14 +636,14 @@ export function copyTripById(sourceTripId: string | number, newOwnerId: number, const insertPlace = db.prepare(` INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency, reservation_status, reservation_notes, reservation_datetime, place_time, end_time, - duration_minutes, notes, image_url, google_place_id, website, phone, transport_mode, osm_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + duration_minutes, notes, image_url, google_place_id, google_ftid, website, phone, transport_mode, osm_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); for (const p of oldPlaces) { const r = insertPlace.run(newTripId, p.name, p.description, p.lat, p.lng, p.address, p.category_id, p.price, p.currency, p.reservation_status, p.reservation_notes, p.reservation_datetime, p.place_time, p.end_time, p.duration_minutes, p.notes, p.image_url, p.google_place_id, - p.website, p.phone, p.transport_mode, p.osm_id); + p.google_ftid, p.website, p.phone, p.transport_mode, p.osm_id); placeMap.set(p.id, r.lastInsertRowid); } diff --git a/server/src/systemNotices/registry.ts b/server/src/systemNotices/registry.ts index 4f5320d8..2eb0986b 100644 --- a/server/src/systemNotices/registry.ts +++ b/server/src/systemNotices/registry.ts @@ -11,128 +11,65 @@ registerPredicate('whitespace-collision-detected', () => { * SYSTEM NOTICE REGISTRY * * Rules for authoring: - * - NEVER remove or renumber entries — dismissal tracking is keyed by `id`. + * - NEVER reuse a retired `id` — dismissal tracking is keyed by `id`. Retired ids are + * listed in RETIRED_NOTICE_IDS so they're never accidentally re-used. * - `id` must be globally unique and stable across deployments. * - Title: ≤40 chars, sentence case, no trailing punctuation. * - Body: markdown (modal) or plain text (banner/toast). ≤400/140/80 chars. - * - CTA label: ≤20 chars, a verb. + * - CTA label: ≤20 chars. * - Never hardcode version numbers/dates in translated strings — use bodyParams. - * - See plans/system-notices/00-overview.md for full authoring guidelines. */ + +/** + * Retired notices. Kept out of the active list but their ids stay reserved so a future + * notice never reuses one (dismissals are keyed by id). Do not re-add these ids. + */ +export const RETIRED_NOTICE_IDS = [ + 'v3-thankyou', + 'v3-photos', + 'v3-journey', + 'v3-mcp', + 'v3-features', + 'welcome-v1', +] as const; + export const SYSTEM_NOTICES: SystemNotice[] = [ - // ── 3.0.0 upgrade notices (shown as a multipage modal to pre-3.0 users) ───── - + // ── Thank-you + support the project — shown once per install AND once per upgrade ── + // `recurring: 'per-version'` re-surfaces it whenever the app version moves up. { - // Page 1 — breaking change first (warn → sorts before the two info notices) - id: 'v3-photos', - display: 'modal', - severity: 'warn', - icon: 'ImageOff', - titleKey: 'system_notice.v3_photos.title', - bodyKey: 'system_notice.v3_photos.body', - dismissible: true, - conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }], - publishedAt: '2026-04-16T00:00:00Z', - priority: 90, - minVersion: '3.0.0', - maxVersion: '4.0.0', - }, - - { - // Page 2 — flagship feature (only when Journey addon is enabled) - id: 'v3-journey', - display: 'modal', - severity: 'info', - icon: 'BookOpen', - titleKey: 'system_notice.v3_journey.title', - bodyKey: 'system_notice.v3_journey.body', - highlights: [ - { labelKey: 'system_notice.v3_journey.highlight_timeline', iconName: 'CalendarDays' }, - { labelKey: 'system_notice.v3_journey.highlight_photos', iconName: 'Images' }, - { labelKey: 'system_notice.v3_journey.highlight_share', iconName: 'Globe' }, - { labelKey: 'system_notice.v3_journey.highlight_export', iconName: 'FileText' }, - ], - cta: { - kind: 'nav', - labelKey: 'system_notice.v3_journey.cta_label', - href: '/journey', - }, - dismissible: true, - conditions: [ - { kind: 'existingUserBeforeVersion', version: '3.0.0' }, - { kind: 'addonEnabled', addonId: 'journey' }, - ], - publishedAt: '2026-04-16T00:00:00Z', - priority: 80, - minVersion: '3.0.0', - maxVersion: '4.0.0', - }, - - { - // Page 3 — MCP OAuth 2.1 upgrade (only when MCP addon is enabled) - id: 'v3-mcp', - display: 'modal', - severity: 'warn', - icon: 'Bot', - titleKey: 'system_notice.v3_mcp.title', - bodyKey: 'system_notice.v3_mcp.body', - highlights: [ - { labelKey: 'system_notice.v3_mcp.highlight_oauth', iconName: 'KeyRound' }, - { labelKey: 'system_notice.v3_mcp.highlight_scopes', iconName: 'ShieldCheck' }, - { labelKey: 'system_notice.v3_mcp.highlight_deprecated', iconName: 'AlertTriangle' }, - { labelKey: 'system_notice.v3_mcp.highlight_tools', iconName: 'Wrench' }, - ], - dismissible: true, - conditions: [ - { kind: 'existingUserBeforeVersion', version: '3.0.0' }, - { kind: 'addonEnabled', addonId: 'mcp' }, - ], - publishedAt: '2026-04-16T00:00:00Z', - priority: 75, - minVersion: '3.0.0', - maxVersion: '4.0.0', - }, - - { - // Page 4 — other highlights - id: 'v3-features', - display: 'modal', - severity: 'info', - icon: 'Sparkles', - titleKey: 'system_notice.v3_features.title', - bodyKey: 'system_notice.v3_features.body', - highlights: [ - { labelKey: 'system_notice.v3_features.highlight_dashboard', iconName: 'LayoutDashboard' }, - { labelKey: 'system_notice.v3_features.highlight_offline', iconName: 'WifiOff' }, - { labelKey: 'system_notice.v3_features.highlight_search', iconName: 'Search' }, - { labelKey: 'system_notice.v3_features.highlight_import', iconName: 'FileInput' }, - ], - dismissible: true, - conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }], - publishedAt: '2026-04-16T00:00:00Z', - priority: 70, - minVersion: '3.0.0', - maxVersion: '4.0.0', - }, - - { - // Page 1 — personal thank-you from the creator (shown first) - id: 'v3-thankyou', + id: 'thank-you-support', display: 'modal', severity: 'info', icon: 'Heart', - titleKey: 'system_notice.v3_thankyou.title', - bodyKey: 'system_notice.v3_thankyou.body', + titleKey: 'system_notice.thank_you_support.title', + bodyKey: 'system_notice.thank_you_support.body', + highlights: [ + { labelKey: 'system_notice.thank_you_support.highlight_opensource', iconName: 'Github' }, + { labelKey: 'system_notice.thank_you_support.highlight_free', iconName: 'Infinity' }, + { labelKey: 'system_notice.thank_you_support.highlight_community', iconName: 'Users' }, + ], + cta: { + kind: 'link', + labelKey: 'system_notice.thank_you_support.cta_bmc', + href: 'https://buymeacoffee.com/mauriceboe', + }, + secondaryCta: { + kind: 'link', + labelKey: 'system_notice.thank_you_support.cta_kofi', + href: 'https://ko-fi.com/mauriceboe', + }, dismissible: true, - conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }], - publishedAt: '2026-04-16T00:00:00Z', - priority: 95, - minVersion: '3.0.0', - maxVersion: '4.0.0', + // Desktop-only: the support modal is suppressed on small/mobile viewports. + desktopOnly: true, + conditions: [], + publishedAt: '2026-06-27T00:00:00Z', + priority: 100, + recurring: 'per-version', }, // ── 3.0.14 admin notice — whitespace migration collision ─────────────────── - + // Operational alert (not promo): shown only to admins who upgraded across the + // 3.0.14 boundary AND only when the migration actually renamed colliding accounts. { id: 'v3014-whitespace-collision', display: 'banner', @@ -150,29 +87,4 @@ export const SYSTEM_NOTICES: SystemNotice[] = [ priority: 85, minVersion: '3.0.14', }, - - // ── Onboarding ───────────────────────────────────────────────────────────── - - { - id: 'welcome-v1', - display: 'modal', - severity: 'info', - icon: 'Sparkles', - titleKey: 'system_notice.welcome_v1.title', - bodyKey: 'system_notice.welcome_v1.body', - highlights: [ - { labelKey: 'system_notice.welcome_v1.highlight_plan', iconName: 'Map' }, - { labelKey: 'system_notice.welcome_v1.highlight_share', iconName: 'Users' }, - { labelKey: 'system_notice.welcome_v1.highlight_offline', iconName: 'WifiOff' }, - ], - cta: { - kind: 'action', - labelKey: 'system_notice.welcome_v1.cta_label', - actionId: 'open:trip-create', - }, - dismissible: true, - conditions: [{ kind: 'firstLogin' }], - publishedAt: '2026-04-16T00:00:00Z', - priority: 100, - }, ]; diff --git a/server/src/systemNotices/service.ts b/server/src/systemNotices/service.ts index 1a3a4ac2..3593934b 100644 --- a/server/src/systemNotices/service.ts +++ b/server/src/systemNotices/service.ts @@ -46,19 +46,32 @@ export function getActiveNoticesFor(userId: number): SystemNoticeDTO[] { 'SELECT COUNT(*) AS count FROM trips WHERE user_id = ?' ).get(userId) as { count: number }; - const dismissedIds = new Set( - (db.prepare('SELECT notice_id FROM user_notice_dismissals WHERE user_id = ?') - .all(userId) as Array<{ notice_id: string }>) - .map(r => r.notice_id) + // Dismissals mapped to the app version they were dismissed at (used by per-version notices). + const dismissals = new Map( + (db.prepare('SELECT notice_id, dismissed_app_version FROM user_notice_dismissals WHERE user_id = ?') + .all(userId) as Array<{ notice_id: string; dismissed_app_version: string | null }>) + .map(r => [r.notice_id, r.dismissed_app_version]) ); const now = new Date(); const currentAppVersion = getCurrentAppVersion(); const ctx = { user: { ...user, noTrips: tripCount }, currentAppVersion, now }; + const appVer = semver.coerce(currentAppVersion)?.version ?? '0.0.0'; + + const isStillDismissed = (n: SystemNotice): boolean => { + if (!dismissals.has(n.id)) return false; + if (n.recurring === 'per-version') { + // Re-show once the running app version moves past the version it was last dismissed at, + // so a per-version notice surfaces again on each install/upgrade. + const dismissedVer = semver.coerce(dismissals.get(n.id) ?? '0.0.0')?.version ?? '0.0.0'; + return semver.gte(dismissedVer, appVer); + } + return true; // default: permanent one-time dismissal + }; return SYSTEM_NOTICES .filter(n => { - if (dismissedIds.has(n.id)) return false; + if (isStillDismissed(n)) return false; if (!isNoticeVersionActive(n, currentAppVersion)) return false; return evaluate(n, ctx); }) @@ -69,15 +82,20 @@ export function getActiveNoticesFor(userId: number): SystemNoticeDTO[] { if (sw !== 0) return sw; return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(); }) - .map(({ conditions: _c, publishedAt: _p, minVersion: _mn, maxVersion: _mx, priority: _pr, ...dto }) => dto); + .map(({ conditions: _c, publishedAt: _p, minVersion: _mn, maxVersion: _mx, priority: _pr, recurring: _rc, ...dto }) => dto); } export function dismissNotice(userId: number, noticeId: string): boolean { const exists = SYSTEM_NOTICES.some(n => n.id === noticeId); if (!exists) return false; + // Record the app version at dismissal so per-version notices can re-appear on the next + // upgrade. Upsert (not INSERT OR IGNORE) so re-dismissing after a bump refreshes the version. db.prepare(` - INSERT OR IGNORE INTO user_notice_dismissals (user_id, notice_id, dismissed_at) - VALUES (?, ?, ?) - `).run(userId, noticeId, Date.now()); + INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at, dismissed_app_version) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id, notice_id) DO UPDATE SET + dismissed_at = excluded.dismissed_at, + dismissed_app_version = excluded.dismissed_app_version + `).run(userId, noticeId, Date.now(), getCurrentAppVersion()); return true; } diff --git a/server/src/systemNotices/types.ts b/server/src/systemNotices/types.ts index 1004db03..3c611fa6 100644 --- a/server/src/systemNotices/types.ts +++ b/server/src/systemNotices/types.ts @@ -21,6 +21,7 @@ export interface NoticeMedia { export type NoticeCta = | { kind: 'nav'; labelKey: string; href: string } + | { kind: 'link'; labelKey: string; href: string } // external URL, opens in a new tab | { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean }; export interface SystemNotice { @@ -34,13 +35,19 @@ export interface SystemNotice { media?: NoticeMedia; highlights?: Array<{ labelKey: string; iconName?: string }>; cta?: NoticeCta; + secondaryCta?: NoticeCta; + // Hide this notice on small/mobile viewports (evaluated client-side). + desktopOnly?: boolean; dismissible: boolean; conditions: NoticeCondition[]; publishedAt: string; minVersion?: string; maxVersion?: string; priority?: number; + // 'per-version': re-show on every app version bump (each install + upgrade) instead of + // the default permanent one-time dismissal. + recurring?: 'per-version'; } // DTO sent to client (same shape minus the conditions — server evaluates those) -export type SystemNoticeDTO = Omit; +export type SystemNoticeDTO = Omit; diff --git a/server/src/types.ts b/server/src/types.ts index 1345a624..d032887a 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -67,6 +67,7 @@ export interface Place { notes?: string | null; image_url?: string | null; google_place_id?: string | null; + google_ftid?: string | null; osm_id?: string | null; website?: string | null; phone?: string | null; @@ -323,6 +324,7 @@ export interface AssignmentRow extends DayAssignment { image_url: string | null; transport_mode: string; google_place_id: string | null; + google_ftid: string | null; website: string | null; phone: string | null; category_name: string | null; diff --git a/server/tests/integration/files.test.ts b/server/tests/integration/files.test.ts index 2acff907..c7223c1b 100644 --- a/server/tests/integration/files.test.ts +++ b/server/tests/integration/files.test.ts @@ -54,7 +54,7 @@ import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; -import { createUser, createTrip, createReservation, addTripMember } from '../helpers/factories'; +import { createUser, createTrip, createReservation, createPlace, addTripMember } from '../helpers/factories'; import { authCookie, generateToken } from '../helpers/auth'; let nestApp: INestApplication; @@ -357,6 +357,117 @@ describe('File links', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// Cross-trip link isolation (GHSA — reservation title disclosure) +// +// A file may only point at reservations / assignments / places from its own +// trip. The reservation JOIN returns the reservation title, so a member of one +// trip linking a file to another private trip's reservation id used to read the +// foreign title back. Every write path (link, upload, update) must reject it. +// ───────────────────────────────────────────────────────────────────────────── + +describe('Cross-trip link isolation', () => { + it('SEC-FILE-LINK-001 — linking a file to a reservation from another trip is rejected (no title leak)', async () => { + const { user: attacker } = createUser(testDb); + const { user: victim } = createUser(testDb); + const attackerTrip = createTrip(testDb, attacker.id, { title: 'Attacker Trip' }); + const victimTrip = createTrip(testDb, victim.id, { title: 'Victim Trip' }); + const victimReservation = createReservation(testDb, victimTrip.id, { title: 'Victim Secret Flight', type: 'flight' }); + const upload = await uploadFile(attackerTrip.id, attacker.id, FIXTURE_PDF); + const fileId = upload.body.file.id; + + const link = await request(app) + .post(`/api/trips/${attackerTrip.id}/files/${fileId}/link`) + .set('Cookie', authCookie(attacker.id)) + .send({ reservation_id: victimReservation.id }); + expect(link.status).toBe(400); + + // Nothing was stored, so the title cannot leak back through the links list. + const links = await request(app) + .get(`/api/trips/${attackerTrip.id}/files/${fileId}/links`) + .set('Cookie', authCookie(attacker.id)); + expect(links.status).toBe(200); + expect(JSON.stringify(links.body)).not.toContain('Victim Secret Flight'); + expect((links.body.links as any[]).some((l) => l.reservation_id === victimReservation.id)).toBe(false); + }); + + it('SEC-FILE-LINK-002 — uploading a file with a foreign reservation_id is rejected (no title leak)', async () => { + const { user: attacker } = createUser(testDb); + const { user: victim } = createUser(testDb); + const attackerTrip = createTrip(testDb, attacker.id); + const victimTrip = createTrip(testDb, victim.id); + const victimReservation = createReservation(testDb, victimTrip.id, { title: 'Victim Secret Flight', type: 'flight' }); + + const res = await request(app) + .post(`/api/trips/${attackerTrip.id}/files`) + .set('Cookie', authCookie(attacker.id)) + .field('reservation_id', String(victimReservation.id)) + .attach('file', FIXTURE_PDF); + expect(res.status).toBe(400); + expect(JSON.stringify(res.body)).not.toContain('Victim Secret Flight'); + }); + + it('SEC-FILE-LINK-003 — updating a file with a foreign reservation_id is rejected (no title leak)', async () => { + const { user: attacker } = createUser(testDb); + const { user: victim } = createUser(testDb); + const attackerTrip = createTrip(testDb, attacker.id); + const victimTrip = createTrip(testDb, victim.id); + const victimReservation = createReservation(testDb, victimTrip.id, { title: 'Victim Secret Flight', type: 'flight' }); + const upload = await uploadFile(attackerTrip.id, attacker.id, FIXTURE_PDF); + const fileId = upload.body.file.id; + + const res = await request(app) + .put(`/api/trips/${attackerTrip.id}/files/${fileId}`) + .set('Cookie', authCookie(attacker.id)) + .send({ reservation_id: victimReservation.id }); + expect(res.status).toBe(400); + expect(JSON.stringify(res.body)).not.toContain('Victim Secret Flight'); + }); + + it('SEC-FILE-LINK-004 — linking a file to a place from another trip is rejected', async () => { + const { user: attacker } = createUser(testDb); + const { user: victim } = createUser(testDb); + const attackerTrip = createTrip(testDb, attacker.id); + const victimTrip = createTrip(testDb, victim.id); + const victimPlace = createPlace(testDb, victimTrip.id, { name: 'Victim Secret Place' }); + const upload = await uploadFile(attackerTrip.id, attacker.id, FIXTURE_PDF); + const fileId = upload.body.file.id; + + const link = await request(app) + .post(`/api/trips/${attackerTrip.id}/files/${fileId}/link`) + .set('Cookie', authCookie(attacker.id)) + .send({ place_id: victimPlace.id }); + expect(link.status).toBe(400); + + const links = await request(app) + .get(`/api/trips/${attackerTrip.id}/files/${fileId}/links`) + .set('Cookie', authCookie(attacker.id)); + expect((links.body.links as any[]).some((l) => l.place_id === victimPlace.id)).toBe(false); + }); + + it('SEC-FILE-LINK-005 — same-trip reservation links and uploads still succeed', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const resv = createReservation(testDb, trip.id, { title: 'My Own Flight', type: 'flight' }); + + // Upload carrying the trip's own reservation id is accepted. + const upload = await request(app) + .post(`/api/trips/${trip.id}/files`) + .set('Cookie', authCookie(user.id)) + .field('reservation_id', String(resv.id)) + .attach('file', FIXTURE_PDF); + expect(upload.status).toBe(201); + const fileId = upload.body.file.id; + + // And linking it to the same reservation works. + const link = await request(app) + .post(`/api/trips/${trip.id}/files/${fileId}/link`) + .set('Cookie', authCookie(user.id)) + .send({ reservation_id: resv.id }); + expect(link.status).toBe(200); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // Download // ───────────────────────────────────────────────────────────────────────────── diff --git a/server/tests/integration/systemNotices.test.ts b/server/tests/integration/systemNotices.test.ts index 2f80816c..56a2a954 100644 --- a/server/tests/integration/systemNotices.test.ts +++ b/server/tests/integration/systemNotices.test.ts @@ -92,16 +92,17 @@ describe('GET /api/system-notices/active', () => { expect(res.status).toBe(401); }); - it('returns empty array for non-first-login user with no applicable notices', async () => { + it('returns no login/version-gated notices for an established user', async () => { const { user } = createUser(testDb); - // login_count > 1 means firstLogin condition does not match for any notice; - // first_seen_version >= 3.0.0 means existingUserBeforeVersion('3.0.0') also does not match + // login_count > 1 means firstLogin does not match; first_seen_version >= 3.0.0 means + // existingUserBeforeVersion('3.0.0') does not match either. The always-on thank-you + // notice (no conditions) may still apply, so only filter it out. testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id); const res = await request(app) .get('/api/system-notices/active') .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); - expect(res.body).toEqual([]); + expect(res.body.filter((n: { id: string }) => n.id !== 'thank-you-support')).toEqual([]); }); it('returns firstLogin notice for user with login_count <= 1', async () => { @@ -115,7 +116,7 @@ describe('GET /api/system-notices/active', () => { .get('/api/system-notices/active') .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); - // welcome-v1 is also in the registry and matches firstLogin, so at least TEST_NOTICE is present + // The always-on thank-you notice may also be present, so just assert TEST_NOTICE is there const testNotice = res.body.find((n: { id: string }) => n.id === TEST_NOTICE.id); expect(testNotice).toBeDefined(); // DTO should not expose conditions, publishedAt, minVersion, maxVersion, priority @@ -139,7 +140,7 @@ describe('GET /api/system-notices/active', () => { .get('/api/system-notices/active') .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); - expect(res.body).toEqual([]); + expect(res.body.find((n: { id: string }) => n.id === TEST_NOTICE.id)).toBeUndefined(); } finally { const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE); if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1); @@ -161,7 +162,7 @@ describe('GET /api/system-notices/active', () => { .get('/api/system-notices/active') .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); - // TEST_NOTICE should be filtered out; welcome-v1 may still appear + // TEST_NOTICE should be filtered out; the thank-you notice may still appear const found = res.body.find((n: { id: string }) => n.id === TEST_NOTICE.id); expect(found).toBeUndefined(); } finally { @@ -169,6 +170,35 @@ describe('GET /api/system-notices/active', () => { if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1); } }); + + it('re-surfaces a per-version notice after an upgrade but hides it within the same version', async () => { + const TY = 'thank-you-support'; + const { user } = createUser(testDb); + testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id); + + const shows = async () => { + const res = await request(app) + .get('/api/system-notices/active') + .set('Cookie', authCookie(user.id)); + expect(res.status).toBe(200); + return res.body.some((n: { id: string }) => n.id === TY); + }; + + // Fresh user with no dismissal: the recurring thank-you shows. + expect(await shows()).toBe(true); + + // Dismissed at an old version → it returns once the running version is newer. + testDb.prepare( + 'INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at, dismissed_app_version) VALUES (?, ?, ?, ?)' + ).run(user.id, TY, Date.now(), '0.0.1'); + expect(await shows()).toBe(true); + + // Dismissed at a version >= the running one → stays hidden until the next upgrade. + testDb.prepare( + 'UPDATE user_notice_dismissals SET dismissed_app_version = ? WHERE user_id = ? AND notice_id = ?' + ).run('99.0.0', user.id, TY); + expect(await shows()).toBe(false); + }); }); // ───────────────────────────────────────────────────────────────────────────── diff --git a/server/tests/unit/nest/budget.controller.test.ts b/server/tests/unit/nest/budget.controller.test.ts index 7fa6d8b7..6e2dd0e1 100644 --- a/server/tests/unit/nest/budget.controller.test.ts +++ b/server/tests/unit/nest/budget.controller.test.ts @@ -28,6 +28,17 @@ function thrown(fn: () => unknown): { status: number; body: unknown } { throw new Error('expected the handler to throw'); } +async function thrownAsync(fn: () => Promise): Promise<{ status: number; body: unknown }> { + try { + await fn(); + } catch (err) { + expect(err).toBeInstanceOf(HttpException); + const e = err as HttpException; + return { status: e.getStatus(), body: e.getResponse() }; + } + throw new Error('expected the handler to throw'); +} + describe('BudgetController (parity with the legacy /api/trips/:tripId/budget route)', () => { it('404 when the trip is not accessible', () => { const svc = makeService({ verifyTripAccess: vi.fn().mockReturnValue(undefined) }); @@ -145,51 +156,51 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou }); describe('POST /', () => { - it('403 without budget_edit', () => { + it('403 without budget_edit', async () => { const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) }); - expect(thrown(() => new BudgetController(svc).create(user, '5', { name: 'Hotel' }))).toEqual({ + expect(await thrownAsync(() => new BudgetController(svc).create(user, '5', { name: 'Hotel' }))).toEqual({ status: 403, body: { error: 'No permission' }, }); }); - it('400 when name missing', () => { - expect(thrown(() => new BudgetController(makeService()).create(user, '5', {}))).toEqual({ + it('400 when name missing', async () => { + expect(await thrownAsync(() => new BudgetController(makeService()).create(user, '5', {}))).toEqual({ status: 400, body: { error: 'Name is required' }, }); }); - it('creates and broadcasts', () => { + it('creates and broadcasts', async () => { const create = vi.fn().mockReturnValue({ id: 9, name: 'Hotel' }); const broadcast = vi.fn(); const svc = makeService({ create, broadcast } as Partial); - expect(new BudgetController(svc).create(user, '5', { name: 'Hotel', total_price: 200 }, 'sock')).toEqual({ item: { id: 9, name: 'Hotel' } }); + expect(await new BudgetController(svc).create(user, '5', { name: 'Hotel', total_price: 200 }, 'sock')).toEqual({ item: { id: 9, name: 'Hotel' } }); expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item: { id: 9, name: 'Hotel' } }, 'sock'); }); }); describe('PUT /:id', () => { - it('404 when item missing', () => { + it('404 when item missing', async () => { const svc = makeService({ update: vi.fn().mockReturnValue(null) } as Partial); - expect(thrown(() => new BudgetController(svc).update(user, '5', '9', { name: 'X' }))).toEqual({ + expect(await thrownAsync(() => new BudgetController(svc).update(user, '5', '9', { name: 'X' }))).toEqual({ status: 404, body: { error: 'Budget item not found' }, }); }); - it('syncs the reservation price when a linked item changes total_price', () => { + it('syncs the reservation price when a linked item changes total_price', async () => { const update = vi.fn().mockReturnValue({ id: 9, reservation_id: 42, total_price: 250 }); const syncReservationPrice = vi.fn(); const broadcast = vi.fn(); const svc = makeService({ update, syncReservationPrice, broadcast } as Partial); - new BudgetController(svc).update(user, '5', '9', { total_price: 250 }, 'sock'); + await new BudgetController(svc).update(user, '5', '9', { total_price: 250 }, 'sock'); expect(syncReservationPrice).toHaveBeenCalledWith('5', 42, 250, 'sock'); expect(broadcast).toHaveBeenCalledWith('5', 'budget:updated', { item: { id: 9, reservation_id: 42, total_price: 250 } }, 'sock'); }); - it('does not sync when the item has no linked reservation', () => { + it('does not sync when the item has no linked reservation', async () => { const update = vi.fn().mockReturnValue({ id: 9, reservation_id: null, total_price: 250 }); const syncReservationPrice = vi.fn(); const svc = makeService({ update, syncReservationPrice } as Partial); - new BudgetController(svc).update(user, '5', '9', { total_price: 250 }); + await new BudgetController(svc).update(user, '5', '9', { total_price: 250 }); expect(syncReservationPrice).not.toHaveBeenCalled(); }); }); diff --git a/server/tests/unit/nest/budget.service.test.ts b/server/tests/unit/nest/budget.service.test.ts index cf931f6b..bb0ffce5 100644 --- a/server/tests/unit/nest/budget.service.test.ts +++ b/server/tests/unit/nest/budget.service.test.ts @@ -103,10 +103,10 @@ describe('BudgetService', () => { }); }); - it('create / update / remove / members / paid / payers delegate', () => { - svc().create('5', { name: 'Hotel' } as never); + it('create / update / remove / members / paid / payers delegate', async () => { + await svc().create('5', { name: 'Hotel' } as never); expect(budget.createBudgetItem).toHaveBeenCalledWith('5', { name: 'Hotel' }); - svc().update('9', '5', { name: 'X' }); + await svc().update('9', '5', { name: 'X' }); expect(budget.updateBudgetItem).toHaveBeenCalledWith('9', '5', { name: 'X' }); svc().remove('9', '5'); expect(budget.deleteBudgetItem).toHaveBeenCalledWith('9', '5'); diff --git a/server/tests/unit/nest/files.controller.test.ts b/server/tests/unit/nest/files.controller.test.ts index 20020555..c81fc37a 100644 --- a/server/tests/unit/nest/files.controller.test.ts +++ b/server/tests/unit/nest/files.controller.test.ts @@ -21,6 +21,7 @@ function fsvc(o: Partial = {}): FilesService { return { verifyTripAccess: vi.fn().mockReturnValue({ user_id: 1 }), can: vi.fn().mockReturnValue(true), + findForeignLinkTarget: vi.fn().mockReturnValue(null), broadcast: vi.fn(), ...o, } as unknown as FilesService; diff --git a/server/tests/unit/services/airtrailMapper.test.ts b/server/tests/unit/services/airtrailMapper.test.ts index d3418ad1..10d9b5c5 100644 --- a/server/tests/unit/services/airtrailMapper.test.ts +++ b/server/tests/unit/services/airtrailMapper.test.ts @@ -47,7 +47,7 @@ describe('airtrailMapper.normalizeFlight', () => { fromCode: 'JFK', toCode: 'LHR', date: '2021-09-01', - airline: 'BAW', + airline: 'British Airways', flightNumber: 'BA178', seatClass: 'economy', }); @@ -98,12 +98,19 @@ describe('airtrailMapper.mapFlightToReservation', () => { it('carries flight metadata', () => { const m = mapFlightToReservation(flight()); - expect(m.metadata).toMatchObject({ airline: 'BAW', flight_number: 'BA178', aircraft: 'B772', aircraft_reg: 'G-VIIL', flight_reason: 'leisure', seat: '12A' }); + // #1334: display the airline name, keep the code in airline_code for the writeback. + expect(m.metadata).toMatchObject({ airline: 'British Airways', airline_code: 'BAW', flight_number: 'BA178', aircraft: 'B772', aircraft_reg: 'G-VIIL', flight_reason: 'leisure', seat: '12A' }); expect(m.type).toBe('flight'); expect(m.status).toBe('confirmed'); expect(m.notes).toBe('window seat'); }); + it('#1334 falls back to the airline code when AirTrail provides no name', () => { + const a = { id: 9, icao: 'EWG', iata: 'EW' }; + expect(normalizeFlight(flight({ airline: a })).airline).toBe('EWG'); + expect(mapFlightToReservation(flight({ airline: a })).metadata).toMatchObject({ airline: 'EWG', airline_code: 'EWG' }); + }); + it('uses only the seat number for the seat, not the cabin class (#1246)', () => { // AirTrail often has a class but no seat number until check-in; the class // must not leak into the seat field. diff --git a/server/tests/unit/services/atlasService.test.ts b/server/tests/unit/services/atlasService.test.ts index b3d9cb4d..0d659d07 100644 --- a/server/tests/unit/services/atlasService.test.ts +++ b/server/tests/unit/services/atlasService.test.ts @@ -171,6 +171,23 @@ describe('getCountryFromCoords', () => { const code = getCountryFromCoords(0.0, 0.0); expect(code).toBeNull(); }); + + it('ATLAS-SVC-005b: #1331 a point inside France near the German border resolves to FR, not the smaller overlapping box', () => { + // Strasbourg (48.573, 7.752) sits inside BOTH the FR and DE bounding boxes; the old + // smallest-box rule mis-picked DE (its box is smaller). Point-in-polygon picks FR. + expect(getCountryFromCoords(48.5734, 7.7521)).toBe('FR'); + }); + + it('ATLAS-SVC-005c: #1331 a point inside Germany near the French border resolves to DE', () => { + // Kehl (48.575, 7.815) — the German side of the same border. + expect(getCountryFromCoords(48.5750, 7.8150)).toBe('DE'); + }); + + it('ATLAS-SVC-005d: #1331 a micro-territory without an admin0 polygon keeps the smallest-box win (Hong Kong)', () => { + // HK is not a separate admin0 polygon (it falls inside CN there), so the smallest + // bounding box still wins for it. + expect(getCountryFromCoords(22.30, 114.17)).toBe('HK'); + }); }); // ── getCountryFromAddress ─────────────────────────────────────────────────── diff --git a/server/tests/unit/services/budgetService.test.ts b/server/tests/unit/services/budgetService.test.ts index fd7fe9ca..12e2b7fa 100644 --- a/server/tests/unit/services/budgetService.test.ts +++ b/server/tests/unit/services/budgetService.test.ts @@ -209,6 +209,33 @@ describe('calculateSettlement', () => { expect.objectContaining({ amount: 30, from: expect.objectContaining({ user_id: 1 }), to: expect.objectContaining({ user_id: 2 }) }), ]); }); + + it('#1335 converts a foreign expense with the frozen exchange_rate, not live rates', () => { + // $110 booked at a frozen rate of 1.1 (USD per 1 EUR) = 100 EUR. Live rates have since + // drifted to 1.2, but the converted amount must stay on the frozen rate so an already + // settled position isn't re-opened with a residual. + setupDb( + [{ ...makeItem(1, 110), currency: 'USD', exchange_rate: 1.1 } as BudgetItem], + [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], + [makePayer(1, 1, 110, 'alice')], + ); + const result = calculateSettlement(1, { base: 'EUR', tripCurrency: 'EUR', rates: { EUR: 1, USD: 1.2 } }); + const bob = result.balances.find(b => b.user_id === 2)!; + // 110 / 1.1 = 100 EUR; Bob owes half = 50 (frozen). With the live 1.2 it would be ~45.83. + expect(bob.balance).toBeCloseTo(-50, 2); + }); + + it('#1335 a legacy row (exchange_rate = 1) still converts with live rates', () => { + setupDb( + [{ ...makeItem(1, 120), currency: 'USD', exchange_rate: 1 } as BudgetItem], + [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], + [makePayer(1, 1, 120, 'alice')], + ); + const result = calculateSettlement(1, { base: 'EUR', tripCurrency: 'EUR', rates: { EUR: 1, USD: 1.2 } }); + const bob = result.balances.find(b => b.user_id === 2)!; + // 120 / 1.2 (live) = 100 EUR; Bob owes 50 — unchanged behaviour for pre-#1335 rows. + expect(bob.balance).toBeCloseTo(-50, 2); + }); }); // ── updateSettlement ────────────────────────────────────────────────────────── diff --git a/server/tests/unit/services/mapsService.test.ts b/server/tests/unit/services/mapsService.test.ts index e6098f2e..74902e6d 100644 --- a/server/tests/unit/services/mapsService.test.ts +++ b/server/tests/unit/services/mapsService.test.ts @@ -73,6 +73,11 @@ import { parseOpeningHours, buildOsmDetails, getMapsKey, + googleFtidFromMapsUrl, + buildUserAgent, + resolveOverpassEndpoints, + resolveOverpassTimeoutMs, + searchOverpassPois, } from '../../../src/services/mapsService'; afterEach(() => { @@ -751,13 +756,21 @@ describe('searchPlaces (fetch stubbed)', () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ - places: [{ id: 'gid1', displayName: { text: 'Eiffel Tower' }, formattedAddress: 'Paris', location: { latitude: 48.8, longitude: 2.3 } }], + places: [{ + id: 'gid1', + displayName: { text: 'Eiffel Tower' }, + formattedAddress: 'Paris', + location: { latitude: 48.8, longitude: 2.3 }, + // Real search API returns a cid-style URL with no ftid → google_ftid stays null. + googleMapsUri: 'https://maps.google.com/?cid=10403719659250533155', + }], }), })); const { searchPlaces } = await import('../../../src/services/mapsService'); const result = await searchPlaces(1, 'Eiffel Tower'); expect(result.source).toBe('google'); expect((result.places[0] as any).google_place_id).toBe('gid1'); + expect((result.places[0] as any).google_ftid).toBeNull(); }); it('MAPS-039b: throws with Google error status when Google API returns non-ok', async () => { @@ -813,6 +826,7 @@ describe('searchPlaces (fetch stubbed)', () => { const result = await searchPlaces(1, 'sparse'); const place = result.places[0] as any; expect(place.google_place_id).toBe('gid-sparse'); + expect(place.google_ftid).toBeNull(); expect(place.name).toBe(''); expect(place.address).toBe(''); expect(place.lat).toBeNull(); @@ -1082,7 +1096,9 @@ describe('getPlaceDetails (fetch stubbed)', () => { weekdayDescriptions: ['Monday: 9:00 AM – 12:00 AM'], openNow: true, }, - googleMapsUri: 'https://maps.google.com/?cid=123', + // The Places API returns a cid-style URL with no ftid, so google_ftid stays null + // and the precise query_place_id link is used on the client instead. + googleMapsUri: 'https://maps.google.com/?cid=10403719659250533155', editorialSummary: { text: 'Iconic iron tower.' }, reviews: [ { @@ -1099,6 +1115,7 @@ describe('getPlaceDetails (fetch stubbed)', () => { const result = await getPlaceDetails(1, 'ChIJ123'); const place = result.place as any; expect(place.google_place_id).toBe('ChIJ123'); + expect(place.google_ftid).toBeNull(); expect(place.name).toBe('Eiffel Tower'); expect(place.rating).toBe(4.7); expect(place.rating_count).toBe(200000); @@ -1467,3 +1484,112 @@ describe('getPlacePhoto (fetch stubbed)', () => { expect(mockCachePut).toHaveBeenCalledOnce(); }); }); + +describe('googleFtidFromMapsUrl', () => { + it('MAPS-FTID-001: extracts a valid ftid from a /place/?ftid= URL (resolved share link)', () => { + expect(googleFtidFromMapsUrl('https://www.google.com/maps/place/?q=X&ftid=0x882bf179e806d471:0x8591dde29c821a93')) + .toBe('0x882bf179e806d471:0x8591dde29c821a93'); + }); + it('MAPS-FTID-002: returns null for a cid-style URL (the usual Places API shape)', () => { + expect(googleFtidFromMapsUrl('https://maps.google.com/?cid=10403719659250533155')).toBeNull(); + }); + it('MAPS-FTID-003: rejects malformed / hostile ftid values', () => { + expect(googleFtidFromMapsUrl('https://maps.google.com/?ftid=not-an-ftid')).toBeNull(); + expect(googleFtidFromMapsUrl('https://maps.google.com/?ftid=0xAB%26q%3Devil%3Cscript%3E')).toBeNull(); + expect(googleFtidFromMapsUrl('not a url')).toBeNull(); + expect(googleFtidFromMapsUrl(null)).toBeNull(); + }); +}); + +// ── buildUserAgent (instance-specific UA, #1309) ────────────────────────────── + +describe('buildUserAgent', () => { + const base = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)'; + + it('MAPS-094: returns the bare base UA when no instance URL is configured', () => { + expect(buildUserAgent(undefined)).toBe(base); + expect(buildUserAgent('')).toBe(base); + }); + + it('MAPS-095: appends a configured https instance URL so the deployment is identifiable', () => { + expect(buildUserAgent('https://trek.example.org')).toBe(`${base}; https://trek.example.org`); + }); + + it('MAPS-096: drops the http://localhost fallback — it is not a unique identifier', () => { + expect(buildUserAgent('http://localhost:3001')).toBe(base); + }); +}); + +// ── resolveOverpassEndpoints (OVERPASS_URL override, #1309) ──────────────────── + +describe('resolveOverpassEndpoints', () => { + it('MAPS-097: falls back to the public mirrors when OVERPASS_URL is unset/empty', () => { + expect(resolveOverpassEndpoints(undefined).length).toBeGreaterThan(1); + expect(resolveOverpassEndpoints('').length).toBeGreaterThan(1); + expect(resolveOverpassEndpoints(undefined)[0]).toContain('overpass-api.de'); + }); + + it('MAPS-098: a single custom endpoint REPLACES the public mirrors (locked-down egress)', () => { + expect(resolveOverpassEndpoints('https://overpass.internal/api/interpreter')) + .toEqual(['https://overpass.internal/api/interpreter']); + }); + + it('MAPS-099: parses a comma-separated list and trims whitespace', () => { + expect(resolveOverpassEndpoints(' https://a.test/api , http://b.test/api ')) + .toEqual(['https://a.test/api', 'http://b.test/api']); + }); + + it('MAPS-100: drops non-http(s) / malformed entries, keeping the valid ones', () => { + expect(resolveOverpassEndpoints('https://ok.test/api, ftp://no.test, not a url')) + .toEqual(['https://ok.test/api']); + }); + + it('MAPS-101: falls back to the defaults when every custom entry is invalid', () => { + expect(resolveOverpassEndpoints('not a url, ftp://no.test').length).toBeGreaterThan(1); + }); +}); + +// ── resolveOverpassTimeoutMs (OVERPASS_TIMEOUT_MS override, #1309) ───────────── + +describe('resolveOverpassTimeoutMs', () => { + it('MAPS-104: falls back to the 12s default for unset / empty / non-numeric values', () => { + expect(resolveOverpassTimeoutMs(undefined)).toBe(12000); + expect(resolveOverpassTimeoutMs('')).toBe(12000); + expect(resolveOverpassTimeoutMs('abc')).toBe(12000); + }); + + it('MAPS-105: honours a positive numeric override', () => { + expect(resolveOverpassTimeoutMs('30000')).toBe(30000); + }); + + it('MAPS-106: rejects 0, negative and Infinity — a non-positive cap would 502 every search', () => { + expect(resolveOverpassTimeoutMs('0')).toBe(12000); + expect(resolveOverpassTimeoutMs('-5')).toBe(12000); + expect(resolveOverpassTimeoutMs('Infinity')).toBe(12000); + }); +}); + +// ── searchOverpassPois error path (all endpoints down, #1309) ────────────────── + +describe('searchOverpassPois all-endpoints-down', () => { + const bbox = { south: -41.2, west: 146.31, north: -41.16, east: 146.37 }; + + it('MAPS-102: surfaces a 502 with a clear message when every Overpass endpoint fails', async () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED'))); + await expect(searchOverpassPois('restaurant', bbox)).rejects.toMatchObject({ + status: 502, + message: 'Could not reach any Overpass endpoint', + }); + errSpy.mockRestore(); + }); + + it('MAPS-103: logs each endpoint failure so an operator can diagnose blocked egress', async () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED'))); + await expect(searchOverpassPois('bar', bbox)).rejects.toThrow(); + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('[Overpass] all')); + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('ECONNREFUSED')); + errSpy.mockRestore(); + }); +}); diff --git a/server/tests/unit/services/placeService.test.ts b/server/tests/unit/services/placeService.test.ts index 60a82545..ad0b0016 100644 --- a/server/tests/unit/services/placeService.test.ts +++ b/server/tests/unit/services/placeService.test.ts @@ -416,6 +416,15 @@ describe('importGoogleList', () => { expect(result.status).toBe(400); }); + it('PLACE-SVC-026b — a single-place link gives a guiding error instead of the generic one (#1304)', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const url = 'https://www.google.com/maps/place/Eiffel+Tower/@48.8584,2.2945,17z/data=!3m1'; + const result = await importGoogleList(String(trip.id), url) as any; + expect(result.status).toBe(400); + expect(result.error).toMatch(/single place/i); + }); + it('PLACE-SVC-027 — returns error when Google Maps API responds with non-ok status', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); @@ -449,6 +458,57 @@ describe('importGoogleList', () => { expect(result.places[1].name).toBe('London'); }); + it('PLACE-SVC-028b — stores a Google Maps ftid separately from google_place_id', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const listPayload = [ + [null, null, null, null, 'My Test List', null, null, null, [ + [null, [null, null, null, null, '878 Weber St N', [null, null, 43.5118527, -80.5542617], ['-8634542354666695567', '-8822026229683971437']], "St. Jacobs Farmers' Market"], + ]], + ]; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + text: async () => 'prefix\n' + JSON.stringify(listPayload), + })); + + const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456'; + const result = await importGoogleList(String(trip.id), url) as any; + + expect(result.places).toHaveLength(1); + expect(result.places[0].google_place_id).toBeNull(); + expect(result.places[0].google_ftid).toBe('0x882bf179e806d471:0x8591dde29c821a93'); + }); + + it('PLACE-SVC-028c — backfills google_ftid when re-import skips a duplicate', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const existing = createPlace(testDb, trip.id, { + name: "St. Jacobs Farmers' Market", + lat: 43.5118527, + lng: -80.5542617, + }) as any; + + const listPayload = [ + [null, null, null, null, 'My Test List', null, null, null, [ + [null, [null, null, null, null, '878 Weber St N', [null, null, 43.5118527, -80.5542617], ['-8634542354666695567', '-8822026229683971437']], "St. Jacobs Farmers' Market"], + ]], + ]; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + text: async () => 'prefix\n' + JSON.stringify(listPayload), + })); + + const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456'; + const result = await importGoogleList(String(trip.id), url) as any; + const row = testDb.prepare('SELECT google_place_id, google_ftid FROM places WHERE id = ?').get(existing.id) as any; + + expect(result.places).toHaveLength(0); + expect(result.skipped).toBe(1); + expect(row.google_place_id).toBeNull(); + expect(row.google_ftid).toBe('0x882bf179e806d471:0x8591dde29c821a93'); + }); + it('PLACE-SVC-029 — returns error when list items array is empty', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); diff --git a/server/tests/unit/services/queryHelpers.test.ts b/server/tests/unit/services/queryHelpers.test.ts index 5183c3ee..68c9dfa7 100644 --- a/server/tests/unit/services/queryHelpers.test.ts +++ b/server/tests/unit/services/queryHelpers.test.ts @@ -33,6 +33,7 @@ function makeRow(overrides: Partial = {}): AssignmentRow { image_url: 'https://example.com/img.jpg', transport_mode: 'walk', google_place_id: 'ChIJLU7jZClu5kcR4PcOOO6p3I0', + google_ftid: '0x47e66e2c94e34e2d:0x8ddca9ee380ef7e0', website: 'https://eiffel-tower.com', phone: '+33 1 2345 6789', ...overrides, @@ -66,6 +67,7 @@ describe('formatAssignmentWithPlace', () => { expect(place.image_url).toBe('https://example.com/img.jpg'); expect(place.transport_mode).toBe('walk'); expect(place.google_place_id).toBe('ChIJLU7jZClu5kcR4PcOOO6p3I0'); + expect(place.google_ftid).toBe('0x47e66e2c94e34e2d:0x8ddca9ee380ef7e0'); expect(place.website).toBe('https://eiffel-tower.com'); expect(place.phone).toBe('+33 1 2345 6789'); }); diff --git a/server/tests/unit/services/tripService.test.ts b/server/tests/unit/services/tripService.test.ts index d343846e..06afba63 100644 --- a/server/tests/unit/services/tripService.test.ts +++ b/server/tests/unit/services/tripService.test.ts @@ -34,7 +34,7 @@ import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createReservation, createPlace, createDay, createDayAssignment, createDayNote } from '../../helpers/factories'; -import { exportICS, generateDays, deleteOldCover } from '../../../src/services/tripService'; +import { exportICS, generateDays, deleteOldCover, updateTrip } from '../../../src/services/tripService'; import fs from 'fs'; beforeAll(() => { @@ -476,3 +476,34 @@ describe('deleteOldCover', () => { } }); }); + +describe('resyncReservationDays (#1288)', () => { + const dayFor = (tripId: number, date: string) => + (testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(tripId, date) as { id: number }).id; + const insertDatedReservation = (tripId: number, dayId: number, time: string) => + Number(testDb.prepare( + "INSERT INTO reservations (trip_id, day_id, title, reservation_time, type, status) VALUES (?, ?, 'Dinner', ?, 'restaurant', 'pending')", + ).run(tripId, dayId, time).lastInsertRowid); + + it('TRIP-SVC-018: changing the start date re-anchors a dated reservation to the day matching its time', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' }); + const resId = insertDatedReservation(trip.id, dayFor(trip.id, '2025-06-02'), '2025-06-02T19:00:00'); + // Shift the whole range one day forward (days become 2025-06-02..06). + updateTrip(trip.id, user.id, { start_date: '2025-06-02', end_date: '2025-06-06' }, 'user'); + const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number }; + // The booking stays on its absolute date (2025-06-02) instead of shifting with its old day row. + expect(res.day_id).toBe(dayFor(trip.id, '2025-06-02')); + }); + + it('TRIP-SVC-019: a reservation whose date falls outside the new range keeps its day_id (not nulled)', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' }); + const origDayId = dayFor(trip.id, '2025-06-02'); + const resId = insertDatedReservation(trip.id, origDayId, '2025-06-02T19:00:00'); + // Shift far forward so 2025-06-02 is no longer covered by any day. + updateTrip(trip.id, user.id, { start_date: '2025-06-10', end_date: '2025-06-14' }, 'user'); + const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number }; + expect(res.day_id).toBe(origDayId); + }); +}); diff --git a/shared/package.json b/shared/package.json index 80ec4772..4fecc37a 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@trek/shared", - "version": "3.1.2", + "version": "3.1.3", "private": true, "description": "Shared API contracts (Zod schemas) — single source of truth for TREK server and client.", "type": "module", diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index bd9c2a24..f317b6c5 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -335,6 +335,7 @@ const admin: TranslationStrings = { 'الخريطة الافتراضية لجميع المستخدمين على هذا الخادم. لا يزال بإمكان كل مستخدم تجاوزها في إعداداته الخاصة.', 'admin.defaultSettings.providerLeaflet': 'قياسي (مجاني)', 'admin.defaultSettings.providerMapbox': 'Mapbox (ثلاثي الأبعاد)', + 'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)', 'admin.defaultSettings.mapboxToken': 'رمز Mapbox المشترك', 'admin.defaultSettings.mapboxTokenHint': 'يُستخدم لكل مستخدم لم يُدخل رمزه الخاص — حتى يحصل الخادم بأكمله على Mapbox دون مشاركة المفتاح بشكل فردي. يُخزَّن مشفّرًا.', diff --git a/shared/src/i18n/ar/settings.ts b/shared/src/i18n/ar/settings.ts index c5a7e2a9..6641661e 100644 --- a/shared/src/i18n/ar/settings.ts +++ b/shared/src/i18n/ar/settings.ts @@ -18,6 +18,7 @@ const settings: TranslationStrings = { 'settings.mapProviderHint': 'يؤثر على خرائط Trip Planner و Journey. يستخدم Atlas دائمًا Leaflet.', 'settings.mapLeafletSubtitle': '2D كلاسيكي، أي بلاطات نقطية', 'settings.mapMapboxSubtitle': 'بلاطات متجهية ومبانٍ ثلاثية الأبعاد وتضاريس', + 'settings.mapMapLibreSubtitle': 'بلاطات متجهية من OpenFreeMap، بدون رمز', 'settings.mapExperimental': 'تجريبي', 'settings.mapMapboxToken': 'رمز وصول Mapbox', 'settings.mapMapboxTokenHint': 'الرمز العام (pk.*) من', @@ -25,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapStyle': 'نمط الخريطة', 'settings.mapStylePlaceholder': 'اختر نمط Mapbox', 'settings.mapStyleHint': 'إعداد مسبق أو عنوان URL mapbox://styles/USER/ID خاص بك', + 'settings.mapOpenFreeMapStylePlaceholder': 'اختر نمط OpenFreeMap', + 'settings.mapOpenFreeMapStyleHint': 'إعداد مسبق أو عنوان URL لنمط OpenFreeMap. تعمل أنماط OpenFreeMap بدون رمز.', 'settings.map3dBuildings': 'مبانٍ ثلاثية الأبعاد وتضاريس', 'settings.map3dHint': 'إمالة + مبانٍ ثلاثية الأبعاد حقيقية — يعمل مع كل نمط بما في ذلك الأقمار الصناعية.', 'settings.mapHighQuality': 'وضع الجودة العالية', @@ -51,6 +54,7 @@ const settings: TranslationStrings = { 'settings.auto': 'تلقائي', 'settings.language': 'اللغة', 'settings.temperature': 'وحدة الحرارة', + 'settings.distance': 'وحدة المسافة', 'settings.timeFormat': 'تنسيق الوقت', 'settings.bookingLabels': 'تسميات مسارات الحجوزات', 'settings.bookingLabelsHint': 'عرض أسماء المحطات/المطارات على الخريطة. عند الإيقاف، يتم عرض الرمز فقط.', diff --git a/shared/src/i18n/ar/system_notice.ts b/shared/src/i18n/ar/system_notice.ts index cc3dc54f..7d88c29f 100644 --- a/shared/src/i18n/ar/system_notice.ts +++ b/shared/src/i18n/ar/system_notice.ts @@ -44,6 +44,14 @@ const system_notice: TranslationStrings = { 'system_notice.pager.position': 'الإشعار {current} من {total}', 'system_notice.dev_test_modal.title': '[Dev] Test notice', // en-fallback 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.', // en-fallback + 'system_notice.thank_you_support.title': 'شكرًا لاستخدامك TREK', + 'system_notice.thank_you_support.body': + 'شكرًا سريعًا على تثبيتك TREK — هذا يعني لي الكثير حقًا.\n\nأنا مطوّر منفرد أبني TREK في وقت فراغي. بدأ كأداة صغيرة لرحلاتي الخاصة فحسب، وصدقًا أنا مندهش من الدعم والاهتمام اللذين أبداهما المجتمع منذ ذلك الحين. TREK مصنوع بكثير من الحب من جانبي — ولكن أيضًا بفضل العديد من المساهمين الخارجيين الرائعين الذين ساعدوا في تشكيله.\n\n**TREK مفتوح المصدر ومجاني تمامًا — وسيبقى كذلك إلى الأبد. لا باقات مدفوعة، لا اشتراكات، لا شروط خفية. أعدكم بذلك.**\n\nإذا كان TREK مفيدًا لك وأردت دعم تطويره، فإن فنجان قهوة صغيرًا يساعدني حقًا على مواصلة البناء — لا ضغط على الإطلاق، لكن كل فنجان يبقي الليالي المتأخرة مستمرة.\n\nشكرًا لوجودك هنا.\n\n— Maurice', + 'system_notice.thank_you_support.highlight_opensource': 'مفتوح المصدر 100% على GitHub', + 'system_notice.thank_you_support.highlight_free': 'مجاني للأبد — لا باقات مدفوعة أبدًا', + 'system_notice.thank_you_support.highlight_community': 'مبني بالتعاون مع المجتمع', + 'system_notice.thank_you_support.cta_bmc': 'Buy Me a Coffee', + 'system_notice.thank_you_support.cta_kofi': 'ادعمني على Ko-fi', 'system_notice.pager.counter': '{current} / {total}', // en-fallback }; export default system_notice; diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index 84ed526d..9ba81899 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -342,6 +342,7 @@ const admin: TranslationStrings = { 'O mapa padrão para todos nesta instância. Cada usuário ainda pode substituí-lo nas próprias configurações.', 'admin.defaultSettings.providerLeaflet': 'Padrão (gratuito)', 'admin.defaultSettings.providerMapbox': 'Mapbox (3D)', + 'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)', 'admin.defaultSettings.mapboxToken': 'Token compartilhado do Mapbox', 'admin.defaultSettings.mapboxTokenHint': 'Usado para todos os usuários que não inseriram o próprio token — assim toda a instância usa o Mapbox sem compartilhar a chave individualmente. Armazenado de forma criptografada.', diff --git a/shared/src/i18n/br/settings.ts b/shared/src/i18n/br/settings.ts index 0bbf2a3c..d2070d05 100644 --- a/shared/src/i18n/br/settings.ts +++ b/shared/src/i18n/br/settings.ts @@ -20,6 +20,7 @@ const settings: TranslationStrings = { 'settings.mapProviderHint': 'Afeta os mapas do Planejador de Viagem e Diário. Atlas sempre usa Leaflet.', 'settings.mapLeafletSubtitle': 'Clássico 2D, quaisquer blocos raster', 'settings.mapMapboxSubtitle': 'Blocos vetoriais, prédios 3D & terreno', + 'settings.mapMapLibreSubtitle': 'Blocos vetoriais OpenFreeMap, sem token', 'settings.mapExperimental': 'Experimental', 'settings.mapMapboxToken': 'Token de acesso Mapbox', 'settings.mapMapboxTokenHint': 'Token público (pk.*) de', @@ -27,6 +28,8 @@ const settings: TranslationStrings = { 'settings.mapStyle': 'Estilo do mapa', 'settings.mapStylePlaceholder': 'Selecionar um estilo Mapbox', 'settings.mapStyleHint': 'Preset ou sua própria URL mapbox://styles/USER/ID', + 'settings.mapOpenFreeMapStylePlaceholder': 'Selecionar um estilo OpenFreeMap', + 'settings.mapOpenFreeMapStyleHint': 'Preset ou URL de estilo OpenFreeMap. Os estilos OpenFreeMap funcionam sem token.', 'settings.map3dBuildings': 'Prédios 3D & terreno', 'settings.map3dHint': 'Inclinação + extrusões 3D reais de prédios — funciona em todo estilo, incluindo satélite.', 'settings.mapHighQuality': 'Modo alta qualidade', @@ -54,6 +57,7 @@ const settings: TranslationStrings = { 'settings.auto': 'Automático', 'settings.language': 'Idioma', 'settings.temperature': 'Unidade de temperatura', + 'settings.distance': 'Unidade de distância', 'settings.timeFormat': 'Formato de hora', 'settings.blurBookingCodes': 'Ocultar códigos de reserva', 'settings.optimizeFromAccommodation': 'Otimizar rota a partir da hospedagem', diff --git a/shared/src/i18n/br/system_notice.ts b/shared/src/i18n/br/system_notice.ts index 6640694a..94c4e77e 100644 --- a/shared/src/i18n/br/system_notice.ts +++ b/shared/src/i18n/br/system_notice.ts @@ -11,6 +11,14 @@ const system_notice: TranslationStrings = { 'system_notice.welcome_v1.highlight_offline': 'Funciona offline no celular', 'system_notice.dev_test_modal.title': '[Dev] Test notice', 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.', + 'system_notice.thank_you_support.title': 'Obrigado por usar o TREK', + 'system_notice.thank_you_support.body': + 'Um obrigado rápido por instalar o TREK — isso significa muito para mim, de verdade.\n\nSou um desenvolvedor solo e construo o TREK no meu tempo livre. Tudo começou como uma ferramentinha só para as minhas próprias viagens, e confesso que fico maravilhado com o apoio e o interesse da comunidade desde então. O TREK é feito com muito carinho da minha parte — mas também graças aos muitos colaboradores externos incríveis que ajudaram a moldá-lo.\n\n**O TREK é open source e totalmente gratuito — e vai continuar assim para sempre. Sem planos pagos, sem assinaturas, sem pegadinhas. Eu prometo.**\n\nSe o TREK é útil para você e você quiser apoiar o seu desenvolvimento, um cafezinho ajuda muito a me manter construindo — sem nenhuma pressão, mas cada xícara mantém as noites longas em pé.\n\nObrigado por estar aqui.\n\n— Maurice', + 'system_notice.thank_you_support.highlight_opensource': '100% open source no GitHub', + 'system_notice.thank_you_support.highlight_free': 'Gratuito para sempre — nunca planos pagos', + 'system_notice.thank_you_support.highlight_community': 'Construído junto com a comunidade', + 'system_notice.thank_you_support.cta_bmc': 'Buy Me a Coffee', + 'system_notice.thank_you_support.cta_kofi': 'Apoiar no Ko-fi', 'system_notice.pager.prev': 'Aviso anterior', 'system_notice.pager.next': 'Próximo aviso', 'system_notice.pager.counter': '{current} / {total}', diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index 39dc38d1..a7a27287 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -340,6 +340,7 @@ const admin: TranslationStrings = { 'Výchozí mapa pro všechny uživatele na této instanci. Každý uživatel ji může i nadále změnit ve svém vlastním nastavení.', 'admin.defaultSettings.providerLeaflet': 'Standardní (zdarma)', 'admin.defaultSettings.providerMapbox': 'Mapbox (3D)', + 'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)', 'admin.defaultSettings.mapboxToken': 'Sdílený token Mapbox', 'admin.defaultSettings.mapboxTokenHint': 'Použije se pro každého uživatele, který nezadal vlastní token — takže celá instance získá Mapbox, aniž byste klíč sdíleli s každým zvlášť. Ukládá se šifrovaně.', diff --git a/shared/src/i18n/cs/settings.ts b/shared/src/i18n/cs/settings.ts index efd2424a..3a373a3a 100644 --- a/shared/src/i18n/cs/settings.ts +++ b/shared/src/i18n/cs/settings.ts @@ -20,6 +20,7 @@ const settings: TranslationStrings = { 'settings.mapProviderHint': 'Ovlivňuje mapy v Trip Planneru a Journey. Atlas vždy používá Leaflet.', 'settings.mapLeafletSubtitle': 'Klasické 2D, libovolné rastrové dlaždice', 'settings.mapMapboxSubtitle': 'Vektorové dlaždice, 3D budovy a terén', + 'settings.mapMapLibreSubtitle': 'Vektorové dlaždice OpenFreeMap, bez tokenu', 'settings.mapExperimental': 'Experimentální', 'settings.mapMapboxToken': 'Mapbox přístupový token', 'settings.mapMapboxTokenHint': 'Veřejný token (pk.*) z', @@ -27,6 +28,8 @@ const settings: TranslationStrings = { 'settings.mapStyle': 'Styl mapy', 'settings.mapStylePlaceholder': 'Vyberte styl Mapbox', 'settings.mapStyleHint': 'Preset nebo vaše vlastní URL mapbox://styles/USER/ID', + 'settings.mapOpenFreeMapStylePlaceholder': 'Vyberte styl OpenFreeMap', + 'settings.mapOpenFreeMapStyleHint': 'Preset nebo URL stylu OpenFreeMap. Styly OpenFreeMap fungují bez tokenu.', 'settings.map3dBuildings': '3D budovy a terén', 'settings.map3dHint': 'Náklon + skutečné 3D vyvýšení budov — funguje s každým stylem, včetně satelitu.', 'settings.mapHighQuality': 'Režim vysoké kvality', @@ -53,6 +56,7 @@ const settings: TranslationStrings = { 'settings.auto': 'Automatické', 'settings.language': 'Jazyk', 'settings.temperature': 'Jednotky teploty', + 'settings.distance': 'Jednotky vzdálenosti', 'settings.timeFormat': 'Formát času', 'settings.blurBookingCodes': 'Skrýt rezervační kódy', 'settings.optimizeFromAccommodation': 'Optimalizovat trasu od ubytování', diff --git a/shared/src/i18n/cs/system_notice.ts b/shared/src/i18n/cs/system_notice.ts index e2b96d3c..190b178a 100644 --- a/shared/src/i18n/cs/system_notice.ts +++ b/shared/src/i18n/cs/system_notice.ts @@ -11,6 +11,14 @@ const system_notice: TranslationStrings = { 'system_notice.welcome_v1.highlight_offline': 'Funguje offline na mobilu', 'system_notice.dev_test_modal.title': '[Dev] Test notice', 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.', + 'system_notice.thank_you_support.title': 'Děkuji, že používáte TREK', + 'system_notice.thank_you_support.body': + 'Rychlé poděkování za to, že jste si nainstalovali TREK — upřímně, znamená to pro mě hodně.\n\nJsem jediný vývojář a TREK tvořím ve svém volném čase. Začalo to jako malý nástroj jen pro mé vlastní cesty a od té doby mě podpora a zájem komunity naprosto dostávají. TREK dělám s velkým srdcem — ale také díky mnoha úžasným externím přispěvatelům, kteří ho pomohli utvářet.\n\n**TREK je open source a zcela zdarma — a tak to navždy zůstane. Žádné placené verze, žádná předplatná, žádné háčky. Slibuji.**\n\nPokud je pro vás TREK užitečný a chtěli byste podpořit jeho vývoj, malá káva mi opravdu pomáhá pokračovat ve tvoření — žádný tlak, ale každý šálek mi pomáhá přečkat pozdní noci.\n\nDěkuji, že jste tady.\n\n— Maurice', + 'system_notice.thank_you_support.highlight_opensource': '100% open source na GitHubu', + 'system_notice.thank_you_support.highlight_free': 'Navždy zdarma — nikdy žádné placené verze', + 'system_notice.thank_you_support.highlight_community': 'Tvořeno společně s komunitou', + 'system_notice.thank_you_support.cta_bmc': 'Buy Me a Coffee', + 'system_notice.thank_you_support.cta_kofi': 'Podpořit na Ko-fi', 'system_notice.pager.prev': 'Předchozí oznámení', 'system_notice.pager.next': 'Další oznámení', 'system_notice.pager.counter': '{current} / {total}', diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index 33d2df8d..2f9ccb26 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -345,6 +345,7 @@ const admin: TranslationStrings = { 'Die Standardkarte für alle auf dieser Instanz. Jeder Nutzer kann sie weiterhin in den eigenen Einstellungen überschreiben.', 'admin.defaultSettings.providerLeaflet': 'Standard (kostenlos)', 'admin.defaultSettings.providerMapbox': 'Mapbox (3D)', + 'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)', 'admin.defaultSettings.mapboxToken': 'Gemeinsames Mapbox-Token', 'admin.defaultSettings.mapboxTokenHint': 'Wird für jeden Nutzer verwendet, der kein eigenes Token eingegeben hat — so erhält die gesamte Instanz Mapbox, ohne den Schlüssel einzeln teilen zu müssen. Verschlüsselt gespeichert.', diff --git a/shared/src/i18n/de/settings.ts b/shared/src/i18n/de/settings.ts index 1739c551..0a5b7260 100644 --- a/shared/src/i18n/de/settings.ts +++ b/shared/src/i18n/de/settings.ts @@ -20,6 +20,7 @@ const settings: TranslationStrings = { 'settings.mapProviderHint': 'Gilt für Trip Planner und Journey. Atlas nutzt immer Leaflet.', 'settings.mapLeafletSubtitle': 'Klassisch 2D, beliebige Raster-Kacheln', 'settings.mapMapboxSubtitle': 'Vektor-Kacheln, 3D-Gebäude & Terrain', + 'settings.mapMapLibreSubtitle': 'OpenFreeMap Vektor-Kacheln, kein Token', 'settings.mapExperimental': 'Experimentell', 'settings.mapMapboxToken': 'Mapbox Access Token', 'settings.mapMapboxTokenHint': 'Öffentliches Token (pk.*) von', @@ -27,6 +28,8 @@ const settings: TranslationStrings = { 'settings.mapStyle': 'Kartenstil', 'settings.mapStylePlaceholder': 'Mapbox-Stil wählen', 'settings.mapStyleHint': 'Preset oder eigene mapbox://styles/USER/ID URL', + 'settings.mapOpenFreeMapStylePlaceholder': 'OpenFreeMap-Stil wählen', + 'settings.mapOpenFreeMapStyleHint': 'Preset oder OpenFreeMap-Stil-URL. OpenFreeMap-Stile funktionieren ohne Token.', 'settings.map3dBuildings': '3D-Gebäude & Terrain', 'settings.map3dHint': 'Neigung + echte 3D-Gebäude-Extrusionen — funktioniert mit jedem Stil, auch Satellit.', 'settings.mapHighQuality': 'Hochqualitäts-Modus', @@ -54,6 +57,7 @@ const settings: TranslationStrings = { 'settings.auto': 'Automatisch', 'settings.language': 'Sprache', 'settings.temperature': 'Temperatureinheit', + 'settings.distance': 'Entfernungseinheit', 'settings.timeFormat': 'Zeitformat', 'settings.bookingLabels': 'Orts-Labels auf Buchungsrouten', 'settings.bookingLabelsHint': 'Zeigt Bahnhofs-/Flughafennamen auf der Karte. Wenn aus, wird nur das Icon angezeigt.', diff --git a/shared/src/i18n/de/system_notice.ts b/shared/src/i18n/de/system_notice.ts index d958f2bb..9f012a48 100644 --- a/shared/src/i18n/de/system_notice.ts +++ b/shared/src/i18n/de/system_notice.ts @@ -11,6 +11,15 @@ const system_notice: TranslationStrings = { 'system_notice.welcome_v1.highlight_offline': 'Funktioniert offline auf dem Handy', 'system_notice.dev_test_modal.title': '[Dev] Test notice', 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.', + // Dankeschön + Projektunterstützung (1x pro Installation und pro Update) + 'system_notice.thank_you_support.title': 'Danke, dass du TREK nutzt', + 'system_notice.thank_you_support.body': + 'Ein kurzes Dankeschön, dass du TREK installiert hast — das bedeutet mir wirklich viel.\n\nIch bin Solo-Entwickler und baue TREK in meiner Freizeit. Angefangen hat alles als kleines Tool nur für meine eigenen Reisen, und ich bin ehrlich überwältigt von der Unterstützung und dem Interesse der Community seitdem. In TREK steckt viel Herzblut von meiner Seite — aber auch viele großartige externe Mitwirkende haben es mitgeprägt.\n\n**TREK ist Open Source und vollständig kostenlos — und das bleibt für immer so. Keine Paid Tiers, keine Abos, kein Haken. Versprochen.**\n\nWenn TREK dir nützt und du die Entwicklung unterstützen möchtest, hilft mir ein kleiner Kaffee wirklich beim Weitermachen — überhaupt kein Druck, aber jede Tasse trägt durch die langen Nächte.\n\nDanke, dass du dabei bist.\n\n— Maurice', + 'system_notice.thank_you_support.highlight_opensource': '100% Open Source auf GitHub', + 'system_notice.thank_you_support.highlight_free': 'Für immer kostenlos – keine Paid Tiers', + 'system_notice.thank_you_support.highlight_community': 'Gemeinsam mit der Community gebaut', + 'system_notice.thank_you_support.cta_bmc': 'Buy Me a Coffee', + 'system_notice.thank_you_support.cta_kofi': 'Ko-fi unterstützen', 'system_notice.pager.prev': 'Vorherige Meldung', 'system_notice.pager.next': 'Nächste Meldung', 'system_notice.pager.counter': '{current} / {total}', diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index 2c1bd4d1..8c790c9f 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -184,6 +184,7 @@ const admin: TranslationStrings = { 'The default map for everyone on this instance. Each user can still override it in their own settings.', 'admin.defaultSettings.providerLeaflet': 'Standard (free)', 'admin.defaultSettings.providerMapbox': 'Mapbox (3D)', + 'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)', 'admin.defaultSettings.mapboxToken': 'Shared Mapbox token', 'admin.defaultSettings.mapboxTokenHint': 'Used for every user who has not entered their own token — so the whole instance gets Mapbox without sharing the key individually. Stored encrypted.', diff --git a/shared/src/i18n/en/settings.ts b/shared/src/i18n/en/settings.ts index 1679a182..3072e74b 100644 --- a/shared/src/i18n/en/settings.ts +++ b/shared/src/i18n/en/settings.ts @@ -20,6 +20,7 @@ const settings: TranslationStrings = { 'settings.mapProviderHint': 'Affects Trip Planner and Journey maps. Atlas always uses Leaflet.', 'settings.mapLeafletSubtitle': 'Classic 2D, any raster tiles', 'settings.mapMapboxSubtitle': 'Vector tiles, 3D buildings & terrain', + 'settings.mapMapLibreSubtitle': 'OpenFreeMap vector tiles, no token', 'settings.mapExperimental': 'Experimental', 'settings.mapMapboxToken': 'Mapbox Access Token', 'settings.mapMapboxTokenHint': 'Public token (pk.*) from', @@ -27,6 +28,8 @@ const settings: TranslationStrings = { 'settings.mapStyle': 'Map Style', 'settings.mapStylePlaceholder': 'Select a Mapbox style', 'settings.mapStyleHint': 'Preset or your own mapbox://styles/USER/ID URL', + 'settings.mapOpenFreeMapStylePlaceholder': 'Select an OpenFreeMap style', + 'settings.mapOpenFreeMapStyleHint': 'Preset or OpenFreeMap style URL. OpenFreeMap styles work without a token.', 'settings.map3dBuildings': '3D Buildings & Terrain', 'settings.map3dHint': 'Pitch + real 3D building extrusions — works on every style, including satellite.', 'settings.mapHighQuality': 'High Quality Mode', @@ -53,6 +56,7 @@ const settings: TranslationStrings = { 'settings.auto': 'Auto', 'settings.language': 'Language', 'settings.temperature': 'Temperature Unit', + 'settings.distance': 'Distance Unit', 'settings.timeFormat': 'Time Format', 'settings.bookingLabels': 'Booking route labels', 'settings.bookingLabelsHint': 'Show station / airport names on the map. When off, only the icon is shown.', diff --git a/shared/src/i18n/en/system_notice.ts b/shared/src/i18n/en/system_notice.ts index bdc96d4a..c975fae2 100644 --- a/shared/src/i18n/en/system_notice.ts +++ b/shared/src/i18n/en/system_notice.ts @@ -41,6 +41,15 @@ const system_notice: TranslationStrings = { 'system_notice.welcome_v1.highlight_offline': 'Works offline on mobile', 'system_notice.dev_test_modal.title': '[Dev] Test notice', 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.', + // Thank-you + support the project (shown once per install and once per upgrade) + 'system_notice.thank_you_support.title': 'Thank you for using TREK', + 'system_notice.thank_you_support.body': + "A quick thank-you for installing TREK — it genuinely means a lot.\n\nI'm a solo developer and I build TREK in my spare time. It started as a little tool just for my own trips, and I'm honestly blown away by the support and interest from the community since then. TREK is made with a lot of heart on my side — but also thanks to the many amazing external contributors who've helped shape it.\n\n**TREK is open source and completely free — and it will stay that way forever. No paid tiers, no subscriptions, no catch. I promise.**\n\nIf TREK is useful to you and you'd like to support its development, a small coffee genuinely helps me keep building — no pressure at all, but every cup keeps the late nights going.\n\nThank you for being here.\n\n— Maurice", + 'system_notice.thank_you_support.highlight_opensource': '100% open source on GitHub', + 'system_notice.thank_you_support.highlight_free': 'Free forever — never any paid tiers', + 'system_notice.thank_you_support.highlight_community': 'Built together with the community', + 'system_notice.thank_you_support.cta_bmc': 'Buy Me a Coffee', + 'system_notice.thank_you_support.cta_kofi': 'Support on Ko-fi', 'system_notice.pager.prev': 'Previous notice', 'system_notice.pager.next': 'Next notice', 'system_notice.pager.counter': '{current} / {total}', diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index 33cfcaf0..9f142d7e 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -351,6 +351,7 @@ const admin: TranslationStrings = { 'El mapa predeterminado para todos en esta instancia. Cada usuario puede cambiarlo en sus propios ajustes.', 'admin.defaultSettings.providerLeaflet': 'Estándar (gratis)', 'admin.defaultSettings.providerMapbox': 'Mapbox (3D)', + 'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)', 'admin.defaultSettings.mapboxToken': 'Token de Mapbox compartido', 'admin.defaultSettings.mapboxTokenHint': 'Se usa para cada usuario que no haya introducido su propio token, de modo que toda la instancia obtenga Mapbox sin compartir la clave individualmente. Se almacena cifrado.', diff --git a/shared/src/i18n/es/settings.ts b/shared/src/i18n/es/settings.ts index e4472a7b..3795fa0b 100644 --- a/shared/src/i18n/es/settings.ts +++ b/shared/src/i18n/es/settings.ts @@ -20,6 +20,7 @@ const settings: TranslationStrings = { 'settings.mapProviderHint': 'Afecta a los mapas de Trip Planner y Journey. Atlas siempre usa Leaflet.', 'settings.mapLeafletSubtitle': 'Clásico 2D, cualquier mosaico raster', 'settings.mapMapboxSubtitle': 'Mosaicos vectoriales, edificios 3D y terreno', + 'settings.mapMapLibreSubtitle': 'Mosaicos vectoriales de OpenFreeMap, sin token', 'settings.mapExperimental': 'Experimental', 'settings.mapMapboxToken': 'Token de acceso de Mapbox', 'settings.mapMapboxTokenHint': 'Token público (pk.*) de', @@ -27,6 +28,8 @@ const settings: TranslationStrings = { 'settings.mapStyle': 'Estilo de mapa', 'settings.mapStylePlaceholder': 'Seleccionar un estilo de Mapbox', 'settings.mapStyleHint': 'Preset o tu propia URL mapbox://styles/USER/ID', + 'settings.mapOpenFreeMapStylePlaceholder': 'Seleccionar un estilo de OpenFreeMap', + 'settings.mapOpenFreeMapStyleHint': 'Preset o URL de estilo de OpenFreeMap. Los estilos de OpenFreeMap funcionan sin token.', 'settings.map3dBuildings': 'Edificios 3D y terreno', 'settings.map3dHint': 'Inclinación + extrusiones 3D reales de edificios — funciona con todos los estilos, incluyendo satélite.', @@ -55,6 +58,7 @@ const settings: TranslationStrings = { 'settings.auto': 'Automático', 'settings.language': 'Idioma', 'settings.temperature': 'Unidad de temperatura', + 'settings.distance': 'Unidad de distancia', 'settings.timeFormat': 'Formato de hora', 'settings.blurBookingCodes': 'Difuminar códigos de reserva', 'settings.optimizeFromAccommodation': 'Optimizar la ruta desde el alojamiento', diff --git a/shared/src/i18n/es/system_notice.ts b/shared/src/i18n/es/system_notice.ts index a9f2e998..2c8aba52 100644 --- a/shared/src/i18n/es/system_notice.ts +++ b/shared/src/i18n/es/system_notice.ts @@ -11,6 +11,14 @@ const system_notice: TranslationStrings = { 'system_notice.welcome_v1.highlight_offline': 'Funciona sin conexión en móvil', 'system_notice.dev_test_modal.title': '[Dev] Test notice', 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.', + 'system_notice.thank_you_support.title': 'Gracias por usar TREK', + 'system_notice.thank_you_support.body': + 'Un pequeño agradecimiento por instalar TREK — de verdad significa mucho para mí.\n\nSoy un desarrollador independiente y construyo TREK en mi tiempo libre. Empezó como una pequeña herramienta solo para mis propios viajes, y sinceramente me deja sin palabras todo el apoyo y el interés de la comunidad desde entonces. TREK está hecho con mucho cariño de mi parte — pero también gracias a los muchos colaboradores externos increíbles que han ayudado a darle forma.\n\n**TREK es open source y completamente gratuito — y seguirá siéndolo para siempre. Sin planes de pago, sin suscripciones, sin trampa. Te lo prometo.**\n\nSi TREK te resulta útil y quieres apoyar su desarrollo, un pequeño café me ayuda de verdad a seguir construyendo — sin ninguna presión, pero cada taza mantiene vivas las noches en vela.\n\nGracias por estar aquí.\n\n— Maurice', + 'system_notice.thank_you_support.highlight_opensource': '100% open source en GitHub', + 'system_notice.thank_you_support.highlight_free': 'Gratis para siempre — nunca habrá planes de pago', + 'system_notice.thank_you_support.highlight_community': 'Construido junto a la comunidad', + 'system_notice.thank_you_support.cta_bmc': 'Buy Me a Coffee', + 'system_notice.thank_you_support.cta_kofi': 'Apóyame en Ko-fi', 'system_notice.pager.prev': 'Aviso anterior', 'system_notice.pager.next': 'Siguiente aviso', 'system_notice.pager.counter': '{current} / {total}', diff --git a/shared/src/i18n/externalNotifications/index.ts b/shared/src/i18n/externalNotifications/index.ts index 204fb71f..b3396e16 100644 --- a/shared/src/i18n/externalNotifications/index.ts +++ b/shared/src/i18n/externalNotifications/index.ts @@ -14,6 +14,7 @@ import ko from '../ko/externalNotifications'; import nl from '../nl/externalNotifications'; import pl from '../pl/externalNotifications'; import ru from '../ru/externalNotifications'; +import sv from '../sv/externalNotifications'; import tr from '../tr/externalNotifications'; import uk from '../uk/externalNotifications'; import zhTW from '../zh-TW/externalNotifications'; @@ -49,6 +50,7 @@ const LOCALES = { ko, uk, gr, + sv, } satisfies Record; export const EMAIL_I18N: Record = Object.fromEntries( diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index 58198e00..721cde1f 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -348,6 +348,7 @@ const admin: TranslationStrings = { 'La carte par défaut pour tous les utilisateurs de cette instance. Chaque utilisateur peut toujours la remplacer dans ses propres paramètres.', 'admin.defaultSettings.providerLeaflet': 'Standard (gratuit)', 'admin.defaultSettings.providerMapbox': 'Mapbox (3D)', + 'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)', 'admin.defaultSettings.mapboxToken': 'Jeton Mapbox partagé', 'admin.defaultSettings.mapboxTokenHint': "Utilisé pour chaque utilisateur n'ayant pas saisi son propre jeton — ainsi toute l'instance bénéficie de Mapbox sans partager la clé individuellement. Stocké de façon chiffrée.", diff --git a/shared/src/i18n/fr/settings.ts b/shared/src/i18n/fr/settings.ts index 1eb6b3b3..bc848bcb 100644 --- a/shared/src/i18n/fr/settings.ts +++ b/shared/src/i18n/fr/settings.ts @@ -20,6 +20,7 @@ const settings: TranslationStrings = { 'settings.mapProviderHint': 'Affecte les cartes Trip Planner et Journey. Atlas utilise toujours Leaflet.', 'settings.mapLeafletSubtitle': 'Classique 2D, toutes tuiles raster', 'settings.mapMapboxSubtitle': 'Tuiles vectorielles, bâtiments 3D & terrain', + 'settings.mapMapLibreSubtitle': 'Tuiles vectorielles OpenFreeMap, sans jeton', 'settings.mapExperimental': 'Expérimental', 'settings.mapMapboxToken': "Jeton d'accès Mapbox", 'settings.mapMapboxTokenHint': 'Jeton public (pk.*) depuis', @@ -27,6 +28,8 @@ const settings: TranslationStrings = { 'settings.mapStyle': 'Style de carte', 'settings.mapStylePlaceholder': 'Sélectionner un style Mapbox', 'settings.mapStyleHint': 'Preset ou votre propre URL mapbox://styles/USER/ID', + 'settings.mapOpenFreeMapStylePlaceholder': 'Sélectionner un style OpenFreeMap', + 'settings.mapOpenFreeMapStyleHint': 'Preset ou URL de style OpenFreeMap. Les styles OpenFreeMap fonctionnent sans jeton.', 'settings.map3dBuildings': 'Bâtiments 3D & terrain', 'settings.map3dHint': 'Inclinaison + extrusions 3D réelles des bâtiments — fonctionne avec tous les styles, y compris satellite.', @@ -56,6 +59,7 @@ const settings: TranslationStrings = { 'settings.auto': 'Auto', 'settings.language': 'Langue', 'settings.temperature': 'Unité de température', + 'settings.distance': 'Unité de distance', 'settings.timeFormat': "Format de l'heure", 'settings.blurBookingCodes': 'Masquer les codes de réservation', 'settings.optimizeFromAccommodation': "Optimiser l'itinéraire depuis l'hébergement", diff --git a/shared/src/i18n/fr/system_notice.ts b/shared/src/i18n/fr/system_notice.ts index ae01203e..941139a3 100644 --- a/shared/src/i18n/fr/system_notice.ts +++ b/shared/src/i18n/fr/system_notice.ts @@ -11,6 +11,14 @@ const system_notice: TranslationStrings = { 'system_notice.welcome_v1.highlight_offline': 'Fonctionne hors ligne sur mobile', 'system_notice.dev_test_modal.title': '[Dev] Test notice', 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.', + 'system_notice.thank_you_support.title': "Merci d'utiliser TREK", + 'system_notice.thank_you_support.body': + "Un petit mot pour te remercier d'avoir installé TREK — ça compte vraiment beaucoup pour moi.\n\nJe suis développeur en solo et je construis TREK sur mon temps libre. Au départ, c'était juste un petit outil pour mes propres voyages, et je suis honnêtement bluffé par le soutien et l'intérêt de la communauté depuis. TREK est fait avec beaucoup de cœur de mon côté — mais aussi grâce aux nombreux et formidables contributeurs externes qui ont aidé à lui donner forme.\n\n**TREK est open source et entièrement gratuit — et le restera pour toujours. Pas de formules payantes, pas d'abonnements, aucun piège. Promis.**\n\nSi TREK t'est utile et que tu souhaites soutenir son développement, un petit café m'aide sincèrement à continuer — sans aucune pression, mais chaque tasse fait avancer les nuits blanches.\n\nMerci d'être là.\n\n— Maurice", + 'system_notice.thank_you_support.highlight_opensource': '100 % open source sur GitHub', + 'system_notice.thank_you_support.highlight_free': 'Gratuit pour toujours — jamais de formules payantes', + 'system_notice.thank_you_support.highlight_community': 'Construit avec la communauté', + 'system_notice.thank_you_support.cta_bmc': 'Buy Me a Coffee', + 'system_notice.thank_you_support.cta_kofi': 'Soutenir sur Ko-fi', 'system_notice.pager.prev': 'Avis précédent', 'system_notice.pager.next': 'Avis suivant', 'system_notice.pager.counter': '{current} / {total}', diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index de1edee9..9fad7758 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -355,6 +355,7 @@ const admin: TranslationStrings = { 'Ο προεπιλεγμένος χάρτης για όλους σε αυτή την εγκατάσταση. Κάθε χρήστης μπορεί να τον αλλάξει στις δικές του ρυθμίσεις.', 'admin.defaultSettings.providerLeaflet': 'Τυπικός (δωρεάν)', 'admin.defaultSettings.providerMapbox': 'Mapbox (3D)', + 'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)', 'admin.defaultSettings.mapboxToken': 'Κοινόχρηστο διακριτικό Mapbox', 'admin.defaultSettings.mapboxTokenHint': 'Χρησιμοποιείται για κάθε χρήστη που δεν έχει εισαγάγει το δικό του διακριτικό — έτσι ολόκληρη η εγκατάσταση αποκτά Mapbox χωρίς να μοιράζεται το κλειδί ξεχωριστά. Αποθηκεύεται κρυπτογραφημένο.', diff --git a/shared/src/i18n/gr/settings.ts b/shared/src/i18n/gr/settings.ts index 2dcf6500..01dd1a27 100644 --- a/shared/src/i18n/gr/settings.ts +++ b/shared/src/i18n/gr/settings.ts @@ -21,6 +21,7 @@ const settings: TranslationStrings = { 'Επηρεάζει τους χάρτες του Trip Planner και του Journey. Το Atlas χρησιμοποιεί πάντα Leaflet.', 'settings.mapLeafletSubtitle': 'Κλασικό 2D, οποιαδήποτε raster πλακίδια', 'settings.mapMapboxSubtitle': 'Διανυσματικά πλακίδια, 3D κτίρια & ανάγλυφο', + 'settings.mapMapLibreSubtitle': 'Διανυσματικά πλακίδια OpenFreeMap, χωρίς token', 'settings.mapExperimental': 'Πειραματικό', 'settings.mapMapboxToken': 'Mapbox Access Token', 'settings.mapMapboxTokenHint': 'Δημόσιο token (pk.*) από', @@ -28,6 +29,8 @@ const settings: TranslationStrings = { 'settings.mapStyle': 'Στυλ Χάρτη', 'settings.mapStylePlaceholder': 'Επιλέξτε ένα στυλ Mapbox', 'settings.mapStyleHint': 'Προκαθορισμένο ή δικό σας mapbox://styles/USER/ID URL', + 'settings.mapOpenFreeMapStylePlaceholder': 'Επιλέξτε ένα στυλ OpenFreeMap', + 'settings.mapOpenFreeMapStyleHint': 'Προκαθορισμένο ή URL στυλ OpenFreeMap. Τα στυλ OpenFreeMap λειτουργούν χωρίς token.', 'settings.map3dBuildings': '3D Κτίρια & Ανάγλυφο', 'settings.map3dHint': 'Κλίση + πραγματικές 3D προεξοχές κτιρίων — λειτουργεί σε κάθε στυλ, συμπεριλαμβανομένου του δορυφορικού.', @@ -57,6 +60,7 @@ const settings: TranslationStrings = { 'settings.auto': 'Αυτόματο', 'settings.language': 'Γλώσσα', 'settings.temperature': 'Μονάδα Θερμοκρασίας', + 'settings.distance': 'Μονάδα Απόστασης', 'settings.timeFormat': 'Μορφή Ώρας', 'settings.bookingLabels': 'Ετικέτες διαδρομής κρατήσεων', 'settings.bookingLabelsHint': diff --git a/shared/src/i18n/gr/system_notice.ts b/shared/src/i18n/gr/system_notice.ts index 18862bfc..a68e1549 100644 --- a/shared/src/i18n/gr/system_notice.ts +++ b/shared/src/i18n/gr/system_notice.ts @@ -43,6 +43,14 @@ const system_notice: TranslationStrings = { 'system_notice.welcome_v1.highlight_offline': 'Λειτουργεί εκτός σύνδεσης σε κινητά', 'system_notice.dev_test_modal.title': '[Dev] Δοκιμαστική ειδοποίηση', 'system_notice.dev_test_modal.body': 'Αυτή είναι μια δοκιμαστική ειδοποίηση μόνο για ανάπτυξη.', + 'system_notice.thank_you_support.title': 'Ευχαριστώ που χρησιμοποιείτε το TREK', + 'system_notice.thank_you_support.body': + 'Ένα γρήγορο ευχαριστώ που εγκαταστήσατε το TREK — σημαίνει πραγματικά πολλά για μένα.\n\nΕίμαι ένας μόνος προγραμματιστής και φτιάχνω το TREK στον ελεύθερό μου χρόνο. Ξεκίνησε ως ένα μικρό εργαλείο μόνο για τα δικά μου ταξίδια, και ειλικρινά με συγκλονίζει η στήριξη και το ενδιαφέρον της κοινότητας από τότε. Το TREK φτιάχνεται με πολλή αγάπη από τη δική μου πλευρά — αλλά και χάρη στους πολλούς υπέροχους εξωτερικούς συνεισφέροντες που βοήθησαν να το διαμορφώσουν.\n\n**Το TREK είναι ανοιχτού κώδικα και εντελώς δωρεάν — και θα παραμείνει έτσι για πάντα. Καμία έκδοση επί πληρωμή, καμία συνδρομή, καμία παγίδα. Το υπόσχομαι.**\n\nΑν το TREK σάς είναι χρήσιμο και θέλετε να στηρίξετε την ανάπτυξή του, ένας μικρός καφές με βοηθά πραγματικά να συνεχίζω να φτιάχνω — καμία πίεση, αλλά κάθε φλιτζάνι κρατά ζωντανές τις ξενύχτιες.\n\nΣας ευχαριστώ που είστε εδώ.\n\n— Maurice', + 'system_notice.thank_you_support.highlight_opensource': '100% ανοιχτού κώδικα στο GitHub', + 'system_notice.thank_you_support.highlight_free': 'Δωρεάν για πάντα — ποτέ επί πληρωμή', + 'system_notice.thank_you_support.highlight_community': 'Φτιαγμένο μαζί με την κοινότητα', + 'system_notice.thank_you_support.cta_bmc': 'Buy Me a Coffee', + 'system_notice.thank_you_support.cta_kofi': 'Στηρίξτε στο Ko-fi', 'system_notice.pager.prev': 'Προηγούμενη ειδοποίηση', 'system_notice.pager.next': 'Επόμενη ειδοποίηση', 'system_notice.pager.counter': '{current} / {total}', diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index bd3cc900..e66d62ff 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -347,6 +347,7 @@ const admin: TranslationStrings = { 'Az alapértelmezett térkép mindenkinek ezen a példányon. Minden felhasználó felülírhatja a saját beállításaiban.', 'admin.defaultSettings.providerLeaflet': 'Alapértelmezett (ingyenes)', 'admin.defaultSettings.providerMapbox': 'Mapbox (3D)', + 'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)', 'admin.defaultSettings.mapboxToken': 'Megosztott Mapbox-token', 'admin.defaultSettings.mapboxTokenHint': 'Minden olyan felhasználóhoz használatos, aki nem adta meg a saját tokenjét — így az egész példány eléri a Mapboxot anélkül, hogy egyenként kellene megosztani a kulcsot. Titkosítva tárolódik.', diff --git a/shared/src/i18n/hu/settings.ts b/shared/src/i18n/hu/settings.ts index 46aea86c..bb4dca8a 100644 --- a/shared/src/i18n/hu/settings.ts +++ b/shared/src/i18n/hu/settings.ts @@ -20,6 +20,7 @@ const settings: TranslationStrings = { 'settings.mapProviderHint': 'A Trip Planner és Journey térképekre érvényes. Az Atlas mindig Leafletet használ.', 'settings.mapLeafletSubtitle': 'Klasszikus 2D, bármilyen raszter csempe', 'settings.mapMapboxSubtitle': 'Vektoros csempék, 3D épületek és terep', + 'settings.mapMapLibreSubtitle': 'OpenFreeMap vektoros csempék, token nélkül', 'settings.mapExperimental': 'Kísérleti', 'settings.mapMapboxToken': 'Mapbox hozzáférési token', 'settings.mapMapboxTokenHint': 'Publikus token (pk.*) innen:', @@ -27,6 +28,8 @@ const settings: TranslationStrings = { 'settings.mapStyle': 'Térkép stílus', 'settings.mapStylePlaceholder': 'Válassz Mapbox stílust', 'settings.mapStyleHint': 'Preset vagy saját mapbox://styles/USER/ID URL', + 'settings.mapOpenFreeMapStylePlaceholder': 'Válassz OpenFreeMap stílust', + 'settings.mapOpenFreeMapStyleHint': 'Preset vagy OpenFreeMap stílus URL. Az OpenFreeMap stílusok token nélkül működnek.', 'settings.map3dBuildings': '3D épületek és terep', 'settings.map3dHint': 'Dőlés + valódi 3D épület-kiemelés — minden stílussal működik, beleértve a műholdast.', 'settings.mapHighQuality': 'Magas minőség mód', @@ -54,6 +57,7 @@ const settings: TranslationStrings = { 'settings.auto': 'Automatikus', 'settings.language': 'Nyelv', 'settings.temperature': 'Hőmérséklet egység', + 'settings.distance': 'Távolság egység', 'settings.timeFormat': 'Időformátum', 'settings.blurBookingCodes': 'Foglalási kódok elrejtése', 'settings.optimizeFromAccommodation': 'Útvonal optimalizálása a szállástól', diff --git a/shared/src/i18n/hu/system_notice.ts b/shared/src/i18n/hu/system_notice.ts index 92440129..207ca83b 100644 --- a/shared/src/i18n/hu/system_notice.ts +++ b/shared/src/i18n/hu/system_notice.ts @@ -11,6 +11,14 @@ const system_notice: TranslationStrings = { 'system_notice.welcome_v1.highlight_offline': 'Mobilon offline is működik', 'system_notice.dev_test_modal.title': '[Dev] Test notice', 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.', + 'system_notice.thank_you_support.title': 'Köszönöm, hogy a TREK-et használod', + 'system_notice.thank_you_support.body': + 'Gyors köszönet, hogy telepítetted a TREK-et — őszintén sokat jelent.\n\nEgyedül fejlesztek, és a szabadidőmben építem a TREK-et. Egy kis eszközként indult, csak a saját utazásaimhoz, és azóta őszintén lenyűgöz a közösség támogatása és érdeklődése. A TREK sok szívvel készül a részemről — de annak a sok csodálatos külső közreműködőnek is köszönhetően, akik segítettek formálni.\n\n**A TREK nyílt forráskódú és teljesen ingyenes — és ez örökre így is marad. Nincsenek fizetős csomagok, nincsenek előfizetések, nincs semmi átverés. Ígérem.**\n\nHa a TREK hasznos számodra, és szeretnéd támogatni a fejlesztését, egy kis kávé őszintén segít, hogy tovább építhessem — semmi nyomás, de minden csésze átsegít a késő éjszakákon.\n\nKöszönöm, hogy itt vagy.\n\n— Maurice', + 'system_notice.thank_you_support.highlight_opensource': '100% nyílt forráskódú a GitHubon', + 'system_notice.thank_you_support.highlight_free': 'Örökre ingyenes — soha semmi fizetős csomag', + 'system_notice.thank_you_support.highlight_community': 'A közösséggel együtt épült', + 'system_notice.thank_you_support.cta_bmc': 'Buy Me a Coffee', + 'system_notice.thank_you_support.cta_kofi': 'Támogass a Ko-fi-n', 'system_notice.pager.prev': 'Előző értesítés', 'system_notice.pager.next': 'Következő értesítés', 'system_notice.pager.counter': '{current} / {total}', diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index 66777a58..ec37e9d9 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -343,6 +343,7 @@ const admin: TranslationStrings = { 'Peta default untuk semua orang di instance ini. Setiap pengguna tetap dapat menggantinya di pengaturan masing-masing.', 'admin.defaultSettings.providerLeaflet': 'Standar (gratis)', 'admin.defaultSettings.providerMapbox': 'Mapbox (3D)', + 'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)', 'admin.defaultSettings.mapboxToken': 'Token Mapbox bersama', 'admin.defaultSettings.mapboxTokenHint': 'Digunakan untuk setiap pengguna yang belum memasukkan token mereka sendiri — sehingga seluruh instance mendapatkan Mapbox tanpa perlu membagikan kunci satu per satu. Disimpan dalam bentuk terenkripsi.', diff --git a/shared/src/i18n/id/settings.ts b/shared/src/i18n/id/settings.ts index 587373f4..cabbb603 100644 --- a/shared/src/i18n/id/settings.ts +++ b/shared/src/i18n/id/settings.ts @@ -20,6 +20,7 @@ const settings: TranslationStrings = { 'settings.mapProviderHint': 'Berlaku untuk peta Trip Planner dan Journey. Atlas selalu menggunakan Leaflet.', 'settings.mapLeafletSubtitle': 'Klasik 2D, tile raster apa pun', 'settings.mapMapboxSubtitle': 'Tile vektor, bangunan 3D & medan', + 'settings.mapMapLibreSubtitle': 'Tile vektor OpenFreeMap, tanpa token', 'settings.mapExperimental': 'Eksperimental', 'settings.mapMapboxToken': 'Token akses Mapbox', 'settings.mapMapboxTokenHint': 'Token publik (pk.*) dari', @@ -27,6 +28,8 @@ const settings: TranslationStrings = { 'settings.mapStyle': 'Gaya peta', 'settings.mapStylePlaceholder': 'Pilih gaya Mapbox', 'settings.mapStyleHint': 'Preset atau URL mapbox://styles/USER/ID milikmu', + 'settings.mapOpenFreeMapStylePlaceholder': 'Pilih gaya OpenFreeMap', + 'settings.mapOpenFreeMapStyleHint': 'Preset atau URL gaya OpenFreeMap. Gaya OpenFreeMap berfungsi tanpa token.', 'settings.map3dBuildings': 'Bangunan 3D & medan', 'settings.map3dHint': 'Kemiringan + ekstrusi bangunan 3D nyata — bekerja di semua gaya, termasuk satelit.', 'settings.mapHighQuality': 'Mode kualitas tinggi', @@ -54,6 +57,7 @@ const settings: TranslationStrings = { 'settings.auto': 'Otomatis', 'settings.language': 'Bahasa', 'settings.temperature': 'Satuan Suhu', + 'settings.distance': 'Satuan Jarak', 'settings.timeFormat': 'Format Waktu', 'settings.blurBookingCodes': 'Sembunyikan Kode Pemesanan', 'settings.optimizeFromAccommodation': 'Optimalkan rute dari akomodasi', diff --git a/shared/src/i18n/id/system_notice.ts b/shared/src/i18n/id/system_notice.ts index d534ba61..065e5084 100644 --- a/shared/src/i18n/id/system_notice.ts +++ b/shared/src/i18n/id/system_notice.ts @@ -11,6 +11,14 @@ const system_notice: TranslationStrings = { 'system_notice.welcome_v1.highlight_offline': 'Bekerja offline di ponsel', 'system_notice.dev_test_modal.title': '[Dev] Test notice', 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.', + 'system_notice.thank_you_support.title': 'Terima kasih telah memakai TREK', + 'system_notice.thank_you_support.body': + 'Sekadar ucapan terima kasih singkat karena telah memasang TREK — ini benar-benar berarti banyak bagi saya.\n\nSaya seorang developer solo dan membangun TREK di waktu luang. Awalnya hanya alat kecil untuk perjalanan saya sendiri, dan sejujurnya saya terharu dengan dukungan serta minat dari komunitas sejak saat itu. TREK dibuat dengan sepenuh hati dari saya — tetapi juga berkat banyak kontributor eksternal hebat yang telah membantu membentuknya.\n\n**TREK bersifat open source dan sepenuhnya gratis — dan akan selalu begitu, selamanya. Tanpa paket berbayar, tanpa langganan, tanpa syarat tersembunyi. Saya janji.**\n\nJika TREK bermanfaat bagimu dan kamu ingin mendukung pengembangannya, secangkir kopi kecil sungguh membantu saya untuk terus membangun — sama sekali tanpa paksaan, tapi setiap cangkir membuat malam-malam panjang ini tetap berjalan.\n\nTerima kasih telah berada di sini.\n\n— Maurice', + 'system_notice.thank_you_support.highlight_opensource': '100% open source di GitHub', + 'system_notice.thank_you_support.highlight_free': 'Gratis selamanya — tanpa paket berbayar', + 'system_notice.thank_you_support.highlight_community': 'Dibangun bersama komunitas', + 'system_notice.thank_you_support.cta_bmc': 'Buy Me a Coffee', + 'system_notice.thank_you_support.cta_kofi': 'Dukung di Ko-fi', 'system_notice.pager.prev': 'Pemberitahuan sebelumnya', 'system_notice.pager.next': 'Pemberitahuan berikutnya', 'system_notice.pager.counter': '{current} / {total}', diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index c15d1bcc..120a72cc 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -346,6 +346,7 @@ const admin: TranslationStrings = { 'La mappa predefinita per tutti gli utenti di questa istanza. Ogni utente può comunque sostituirla nelle proprie impostazioni.', 'admin.defaultSettings.providerLeaflet': 'Standard (gratuito)', 'admin.defaultSettings.providerMapbox': 'Mapbox (3D)', + 'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)', 'admin.defaultSettings.mapboxToken': 'Token Mapbox condiviso', 'admin.defaultSettings.mapboxTokenHint': "Usato per ogni utente che non ha inserito un proprio token — così tutta l'istanza ottiene Mapbox senza dover condividere la chiave individualmente. Archiviato in forma crittografata.", diff --git a/shared/src/i18n/it/settings.ts b/shared/src/i18n/it/settings.ts index 4208adf7..4baf4bdf 100644 --- a/shared/src/i18n/it/settings.ts +++ b/shared/src/i18n/it/settings.ts @@ -20,6 +20,7 @@ const settings: TranslationStrings = { 'settings.mapProviderHint': 'Influisce sulle mappe Trip Planner e Journey. Atlas usa sempre Leaflet.', 'settings.mapLeafletSubtitle': 'Classica 2D, qualsiasi tile raster', 'settings.mapMapboxSubtitle': 'Tile vettoriali, edifici 3D e terreno', + 'settings.mapMapLibreSubtitle': 'Tile vettoriali OpenFreeMap, senza token', 'settings.mapExperimental': 'Sperimentale', 'settings.mapMapboxToken': 'Token di accesso Mapbox', 'settings.mapMapboxTokenHint': 'Token pubblico (pk.*) da', @@ -27,6 +28,8 @@ const settings: TranslationStrings = { 'settings.mapStyle': 'Stile mappa', 'settings.mapStylePlaceholder': 'Seleziona uno stile Mapbox', 'settings.mapStyleHint': 'Preset o il tuo URL mapbox://styles/USER/ID', + 'settings.mapOpenFreeMapStylePlaceholder': 'Seleziona uno stile OpenFreeMap', + 'settings.mapOpenFreeMapStyleHint': 'Preset o URL di stile OpenFreeMap. Gli stili OpenFreeMap funzionano senza token.', 'settings.map3dBuildings': 'Edifici 3D e terreno', 'settings.map3dHint': 'Inclinazione + estrusioni 3D reali degli edifici — funziona con ogni stile, incluso satellite.', @@ -55,6 +58,7 @@ const settings: TranslationStrings = { 'settings.auto': 'Automatica', 'settings.language': 'Lingua', 'settings.temperature': 'Unità di Temperatura', + 'settings.distance': 'Unità di Distanza', 'settings.timeFormat': 'Formato Ora', 'settings.blurBookingCodes': 'Nascondi codici di prenotazione', 'settings.optimizeFromAccommodation': "Ottimizza il percorso dall'alloggio", diff --git a/shared/src/i18n/it/system_notice.ts b/shared/src/i18n/it/system_notice.ts index 1474b7ae..9fbd534b 100644 --- a/shared/src/i18n/it/system_notice.ts +++ b/shared/src/i18n/it/system_notice.ts @@ -11,6 +11,14 @@ const system_notice: TranslationStrings = { 'system_notice.welcome_v1.highlight_offline': 'Funziona offline su mobile', 'system_notice.dev_test_modal.title': '[Dev] Test notice', 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.', + 'system_notice.thank_you_support.title': 'Grazie per usare TREK', + 'system_notice.thank_you_support.body': + "Un piccolo grazie per aver installato TREK — significa davvero molto per me.\n\nSono uno sviluppatore indipendente e creo TREK nel mio tempo libero. È nato come un piccolo strumento solo per i miei viaggi, e sono sinceramente sbalordito dal supporto e dall'interesse che la community mi ha dimostrato da allora. TREK è fatto con tanto cuore da parte mia — ma anche grazie ai tanti fantastici collaboratori esterni che hanno contribuito a dargli forma.\n\n**TREK è open source e completamente gratuito — e resterà così per sempre. Nessun piano a pagamento, nessun abbonamento, nessuna fregatura. Te lo prometto.**\n\nSe TREK ti è utile e vuoi sostenerne lo sviluppo, un piccolo caffè mi aiuta davvero a continuare a costruirlo — nessuna pressione, ma ogni tazza tiene vive le notti tarde.\n\nGrazie per essere qui.\n\n— Maurice", + 'system_notice.thank_you_support.highlight_opensource': '100% open source su GitHub', + 'system_notice.thank_you_support.highlight_free': 'Gratis per sempre — mai un piano a pagamento', + 'system_notice.thank_you_support.highlight_community': 'Creato insieme alla community', + 'system_notice.thank_you_support.cta_bmc': 'Buy Me a Coffee', + 'system_notice.thank_you_support.cta_kofi': 'Supporta su Ko-fi', 'system_notice.pager.prev': 'Avviso precedente', 'system_notice.pager.next': 'Avviso successivo', 'system_notice.pager.counter': '{current} / {total}', diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index c0343c70..39dc4f5b 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -331,6 +331,7 @@ const admin: TranslationStrings = { 'このインスタンスの全員に適用される既定の地図です。各ユーザーは自分の設定でこれを上書きできます。', 'admin.defaultSettings.providerLeaflet': '標準(無料)', 'admin.defaultSettings.providerMapbox': 'Mapbox(3D)', + 'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)', 'admin.defaultSettings.mapboxToken': '共有 Mapbox トークン', 'admin.defaultSettings.mapboxTokenHint': '自分のトークンを入力していないすべてのユーザーに使用されます。これにより、キーを個別に共有しなくてもインスタンス全体で Mapbox を利用できます。暗号化して保存されます。', diff --git a/shared/src/i18n/ja/settings.ts b/shared/src/i18n/ja/settings.ts index 39d83cc7..829240e6 100644 --- a/shared/src/i18n/ja/settings.ts +++ b/shared/src/i18n/ja/settings.ts @@ -20,6 +20,7 @@ const settings: TranslationStrings = { 'settings.mapProviderHint': '旅程プランナーと日記地図に影響します。Atlas は常に Leaflet を使用します。', 'settings.mapLeafletSubtitle': 'クラシックな2D、任意のラスタータイル', 'settings.mapMapboxSubtitle': 'ベクタータイル、3D建物・地形', + 'settings.mapMapLibreSubtitle': 'OpenFreeMap ベクタータイル、トークン不要', 'settings.mapExperimental': '実験的', 'settings.mapMapboxToken': 'Mapbox アクセストークン', 'settings.mapMapboxTokenHint': 'mapbox.com の公開トークン(pk.*)', @@ -27,6 +28,8 @@ const settings: TranslationStrings = { 'settings.mapStyle': '地図スタイル', 'settings.mapStylePlaceholder': 'Mapboxスタイルを選択', 'settings.mapStyleHint': 'プリセットまたは mapbox://styles/USER/ID のURL', + 'settings.mapOpenFreeMapStylePlaceholder': 'OpenFreeMapスタイルを選択', + 'settings.mapOpenFreeMapStyleHint': 'プリセットまたは OpenFreeMap スタイルのURL。OpenFreeMap スタイルはトークンなしで動作します。', 'settings.map3dBuildings': '3D建物・地形', 'settings.map3dHint': 'ピッチ+実際の3D押し出し表示。衛星含む全スタイルで動作。', 'settings.mapHighQuality': '高品質モード', @@ -52,6 +55,7 @@ const settings: TranslationStrings = { 'settings.auto': '自動', 'settings.language': '言語', 'settings.temperature': '温度単位', + 'settings.distance': '距離単位', 'settings.timeFormat': '時刻形式', 'settings.bookingLabels': '予約ルートのラベル', 'settings.bookingLabelsHint': '地図に駅・空港名を表示。オフ時はアイコンのみ。', diff --git a/shared/src/i18n/ja/system_notice.ts b/shared/src/i18n/ja/system_notice.ts index 6c5e26dc..f8ca54b2 100644 --- a/shared/src/i18n/ja/system_notice.ts +++ b/shared/src/i18n/ja/system_notice.ts @@ -39,6 +39,14 @@ const system_notice: TranslationStrings = { 'system_notice.welcome_v1.highlight_offline': 'モバイルでオフライン対応', 'system_notice.dev_test_modal.title': '[Dev] テスト通知', 'system_notice.dev_test_modal.body': 'これは開発用テスト通知です。', + 'system_notice.thank_you_support.title': 'TREKを使ってくれてありがとう', + 'system_notice.thank_you_support.body': + 'TREKをインストールしてくれて、ちょっとお礼を言わせてください。本当に、心から嬉しいです。\n\n私は一人で開発をしていて、TREKは空いた時間に作っています。もともとは自分の旅のためだけの小さなツールでしたが、それ以来コミュニティから寄せられる応援や関心に、正直なところ圧倒されています。TREKは私自身がたくさんの思いを込めて作っていますが、それと同時に、形づくるのを手伝ってくれた多くの素晴らしい外部コントリビューターのおかげでもあります。\n\n**TREKはオープンソースで、完全に無料です。そしてこれからもずっと変わりません。有料プランも、サブスクリプションも、隠れた仕掛けも、一切ありません。約束します。**\n\nもしTREKがあなたの役に立っていて、開発を応援したいと思ってもらえたなら、ちょっとしたコーヒー一杯が、これからも作り続ける本当の支えになります。まったく無理はしないでください。でも一杯ごとに、夜なべの作業が続けられます。\n\nここにいてくれて、ありがとう。\n\n— Maurice', + 'system_notice.thank_you_support.highlight_opensource': 'GitHubで100%オープンソース', + 'system_notice.thank_you_support.highlight_free': '永久に無料 — 有料プランは一切なし', + 'system_notice.thank_you_support.highlight_community': 'コミュニティと一緒に作っています', + 'system_notice.thank_you_support.cta_bmc': 'Buy Me a Coffee', + 'system_notice.thank_you_support.cta_kofi': 'Ko-fiで応援する', 'system_notice.pager.prev': '前へ', 'system_notice.pager.next': '次へ', 'system_notice.pager.counter': '{current} / {total}', diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index ee25c52a..8dbbc511 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -334,6 +334,7 @@ const admin: TranslationStrings = { '이 인스턴스의 모든 사용자에게 적용되는 기본 지도입니다. 각 사용자는 자신의 설정에서 이를 변경할 수 있습니다.', 'admin.defaultSettings.providerLeaflet': '표준 (무료)', 'admin.defaultSettings.providerMapbox': 'Mapbox (3D)', + 'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)', 'admin.defaultSettings.mapboxToken': '공유 Mapbox 토큰', 'admin.defaultSettings.mapboxTokenHint': '자신의 토큰을 입력하지 않은 모든 사용자에게 사용됩니다 — 키를 개별적으로 공유하지 않아도 인스턴스 전체에서 Mapbox를 사용할 수 있습니다. 암호화하여 저장됩니다.', diff --git a/shared/src/i18n/ko/settings.ts b/shared/src/i18n/ko/settings.ts index 3eb23957..8c719a18 100644 --- a/shared/src/i18n/ko/settings.ts +++ b/shared/src/i18n/ko/settings.ts @@ -20,6 +20,7 @@ const settings: TranslationStrings = { 'settings.mapProviderHint': '여행 플래너 및 Journey 지도에 영향을 줍니다. Atlas는 항상 Leaflet을 사용합니다.', 'settings.mapLeafletSubtitle': '클래식 2D, 모든 래스터 타일', 'settings.mapMapboxSubtitle': '벡터 타일, 3D 건물 및 지형', + 'settings.mapMapLibreSubtitle': 'OpenFreeMap 벡터 타일, 토큰 불필요', 'settings.mapExperimental': '실험적', 'settings.mapMapboxToken': 'Mapbox 액세스 토큰', 'settings.mapMapboxTokenHint': '공개 토큰 (pk.*) 출처', @@ -27,6 +28,8 @@ const settings: TranslationStrings = { 'settings.mapStyle': '지도 스타일', 'settings.mapStylePlaceholder': 'Mapbox 스타일 선택', 'settings.mapStyleHint': '프리셋 또는 mapbox://styles/USER/ID URL 직접 입력', + 'settings.mapOpenFreeMapStylePlaceholder': 'OpenFreeMap 스타일 선택', + 'settings.mapOpenFreeMapStyleHint': '프리셋 또는 OpenFreeMap 스타일 URL. OpenFreeMap 스타일은 토큰 없이 작동합니다.', 'settings.map3dBuildings': '3D 건물 및 지형', 'settings.map3dHint': '기울기 + 실제 3D 건물 돌출 — 위성 포함 모든 스타일에서 작동합니다.', 'settings.mapHighQuality': '고품질 모드', @@ -53,6 +56,7 @@ const settings: TranslationStrings = { 'settings.auto': '자동', 'settings.language': '언어', 'settings.temperature': '온도 단위', + 'settings.distance': '거리 단위', 'settings.timeFormat': '시간 형식', 'settings.bookingLabels': '예약 경로 레이블', 'settings.bookingLabelsHint': '지도에 역 / 공항 이름을 표시합니다. 끄면 아이콘만 표시됩니다.', diff --git a/shared/src/i18n/ko/system_notice.ts b/shared/src/i18n/ko/system_notice.ts index 9b0f431f..5ab28675 100644 --- a/shared/src/i18n/ko/system_notice.ts +++ b/shared/src/i18n/ko/system_notice.ts @@ -41,6 +41,14 @@ const system_notice: TranslationStrings = { 'system_notice.welcome_v1.highlight_offline': '모바일에서 오프라인으로 작동', 'system_notice.dev_test_modal.title': '[Dev] 테스트 공지', 'system_notice.dev_test_modal.body': '개발 전용 테스트 공지입니다.', + 'system_notice.thank_you_support.title': 'TREK을 사용해 주셔서 감사합니다', + 'system_notice.thank_you_support.body': + 'TREK을 설치해 주셔서 감사하다는 짧은 인사를 전하고 싶습니다 — 정말 큰 힘이 됩니다.\n\n저는 1인 개발자이고, 여가 시간에 TREK을 만들고 있습니다. 처음에는 그저 제 여행을 위한 작은 도구로 시작했는데, 그 이후로 커뮤니티에서 보내주신 응원과 관심에 솔직히 놀라움을 감추지 못하고 있습니다. TREK은 제 온 마음을 담아 만들었지만 — 이 프로젝트를 함께 다듬어 주신 많은 멋진 외부 기여자분들 덕분이기도 합니다.\n\n**TREK은 오픈 소스이며 완전히 무료입니다 — 그리고 앞으로도 영원히 그럴 것입니다. 유료 등급도, 구독도, 숨겨진 조건도 없습니다. 약속드릴게요.**\n\nTREK이 도움이 되셨고 개발을 응원하고 싶으시다면, 작은 커피 한 잔이 제가 계속 만들어 나가는 데 정말 큰 힘이 됩니다 — 전혀 부담 갖지 마세요. 하지만 한 잔 한 잔이 늦은 밤을 버티게 해줍니다.\n\n함께해 주셔서 감사합니다.\n\n— Maurice', + 'system_notice.thank_you_support.highlight_opensource': 'GitHub에서 100% 오픈 소스', + 'system_notice.thank_you_support.highlight_free': '영원히 무료 — 유료 등급 절대 없음', + 'system_notice.thank_you_support.highlight_community': '커뮤니티와 함께 만들어 갑니다', + 'system_notice.thank_you_support.cta_bmc': 'Buy Me a Coffee', + 'system_notice.thank_you_support.cta_kofi': 'Ko-fi에서 후원하기', 'system_notice.pager.prev': '이전 공지', 'system_notice.pager.next': '다음 공지', 'system_notice.pager.counter': '{current} / {total}', diff --git a/shared/src/i18n/languages.ts b/shared/src/i18n/languages.ts index 1b6c802f..557659a2 100644 --- a/shared/src/i18n/languages.ts +++ b/shared/src/i18n/languages.ts @@ -19,6 +19,7 @@ export const SUPPORTED_LANGUAGES = [ { value: 'ko', label: '한국어', locale: 'ko-KR' }, { value: 'uk', label: 'Українська', locale: 'uk-UA' }, { value: 'gr', label: 'Ελληνικά', locale: 'el-GR' }, + { value: 'sv', label: 'Svenska', locale: 'sv-SE' }, ] as const; export type SupportedLanguageCode = (typeof SUPPORTED_LANGUAGES)[number]['value']; diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index 9c537906..76184839 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -72,19 +72,19 @@ const admin: TranslationStrings = { 'admin.tabs.settings': 'Instellingen', 'admin.allowRegistration': 'Registratie toestaan', 'admin.allowRegistrationHint': 'Nieuwe gebruikers kunnen zichzelf registreren', - 'admin.authMethods': 'Authentication Methods', - 'admin.passwordLogin': 'Password Login', - 'admin.passwordLoginHint': 'Allow users to sign in with email and password', - 'admin.passwordRegistration': 'Password Registration', - 'admin.passwordRegistrationHint': 'Allow new users to register with email and password', - 'admin.oidcLogin': 'SSO Login', - 'admin.oidcLoginHint': 'Allow users to sign in with SSO', - 'admin.oidcRegistration': 'SSO Auto-Provisioning', - 'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users', + 'admin.authMethods': 'Authenticatiemethoden', + 'admin.passwordLogin': 'Inloggen met wachtwoord', + 'admin.passwordLoginHint': 'Gebruikers toestaan om in te loggen met e-mailadres en wachtwoord', + 'admin.passwordRegistration': 'Registreren met wachtwoord', + 'admin.passwordRegistrationHint': 'Nieuwe gebruikers toestaan om zich te registreren met e-mailadres en wachtwoord', + 'admin.oidcLogin': 'Inloggen via SSO', + 'admin.oidcLoginHint': 'Gebruikers toestaan om in te loggen via SSO', + 'admin.oidcRegistration': 'Automatische SSO-provisioning', + 'admin.oidcRegistrationHint': 'Automatisch accounts aanmaken voor nieuwe SSO-gebruikers', 'admin.envOverrideHint': - 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.', - 'admin.lockoutWarning': 'At least one login method must remain enabled', - 'admin.requireMfa': 'Tweestapsverificatie (2FA) verplicht stellen', + 'De instellingen voor inloggen met wachtwoord worden beheerd via de omgevingsvariabele OIDC_ONLY en kunnen hier niet worden gewijzigd.', + 'admin.lockoutWarning': 'Er moet minstens één inlogmethode ingeschakeld blijven', + 'admin.requireMfa': 'Tweestapsverificatie (2FA) verplichten', 'admin.requireMfaHint': 'Gebruikers zonder 2FA moeten de installatie in Instellingen voltooien voordat ze de app kunnen gebruiken.', 'admin.apiKeys': 'API-sleutels', @@ -346,6 +346,7 @@ const admin: TranslationStrings = { 'De standaardkaart voor iedereen op deze instantie. Elke gebruiker kan dit nog steeds aanpassen in zijn eigen instellingen.', 'admin.defaultSettings.providerLeaflet': 'Standaard (gratis)', 'admin.defaultSettings.providerMapbox': 'Mapbox (3D)', + 'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)', 'admin.defaultSettings.mapboxToken': 'Gedeeld Mapbox-token', 'admin.defaultSettings.mapboxTokenHint': 'Wordt gebruikt voor elke gebruiker die nog geen eigen token heeft ingevoerd — zo krijgt de hele instantie Mapbox zonder de sleutel apart te delen. Versleuteld opgeslagen.', diff --git a/shared/src/i18n/nl/dashboard.ts b/shared/src/i18n/nl/dashboard.ts index bb1983f9..e8610283 100644 --- a/shared/src/i18n/nl/dashboard.ts +++ b/shared/src/i18n/nl/dashboard.ts @@ -101,7 +101,7 @@ const dashboard: TranslationStrings = { 'dashboard.mobile.inDays': 'Over {count} dagen', 'dashboard.mobile.inMonths': 'Over {count} maanden', 'dashboard.mobile.completed': 'Voltooid', - 'dashboard.mobile.currencyConverter': 'Valutaomrekener', + 'dashboard.mobile.currencyConverter': 'Valutaomzetter', 'dashboard.filter.planned': 'Gepland', 'dashboard.hero.badgeLive': 'NU LIVE', 'dashboard.hero.badgeToday': 'START VANDAAG', diff --git a/shared/src/i18n/nl/packing.ts b/shared/src/i18n/nl/packing.ts index 6c5b928b..2e62c983 100644 --- a/shared/src/i18n/nl/packing.ts +++ b/shared/src/i18n/nl/packing.ts @@ -1,10 +1,10 @@ import type { TranslationStrings } from '../types'; const packing: TranslationStrings = { - 'packing.title': 'Paklijst', - 'packing.empty': 'Paklijst is leeg', + 'packing.title': 'Inpaklijst', + 'packing.empty': 'Inpaklijst is leeg', 'packing.import': 'Importeren', - 'packing.importTitle': 'Paklijst importeren', + 'packing.importTitle': 'Inpaklijst importeren', 'packing.importHint': 'Eén item per regel. Optioneel categorie en aantal gescheiden door komma, puntkomma of tab: Naam, Categorie, Aantal', 'packing.importPlaceholder': 'Tandenborstel\nZonnebrand, Hygiëne\nT-Shirts, Kleding, 5\nPaspoort, Documenten', @@ -25,7 +25,7 @@ const packing: TranslationStrings = { 'packing.filterAll': 'Alle', 'packing.filterOpen': 'Openstaand', 'packing.filterDone': 'Klaar', - 'packing.emptyTitle': 'Paklijst is leeg', + 'packing.emptyTitle': 'Inpaklijst is leeg', 'packing.emptyHint': 'Voeg items toe of gebruik de suggesties', 'packing.emptyFiltered': 'Geen items gevonden voor dit filter', 'packing.menuRename': 'Hernoemen', @@ -42,7 +42,7 @@ const packing: TranslationStrings = { 'packing.templateError': 'Fout bij toepassen van sjabloon', 'packing.saveAsTemplate': 'Opslaan als sjabloon', 'packing.templateName': 'Sjabloonnaam', - 'packing.templateSaved': 'Paklijst opgeslagen als sjabloon', + 'packing.templateSaved': 'Inpaklijst opgeslagen als sjabloon', 'packing.noMembers': 'Geen leden', 'packing.bags': 'Bagage', 'packing.noBag': 'Niet toegewezen', diff --git a/shared/src/i18n/nl/perm.ts b/shared/src/i18n/nl/perm.ts index b219f0ed..680b730d 100644 --- a/shared/src/i18n/nl/perm.ts +++ b/shared/src/i18n/nl/perm.ts @@ -14,7 +14,7 @@ const perm: TranslationStrings = { 'perm.cat.members': 'Ledenbeheer', 'perm.cat.files': 'Bestanden', 'perm.cat.content': 'Inhoud & planning', - 'perm.cat.extras': 'Budget, paklijsten & samenwerking', + 'perm.cat.extras': 'Budget, inpaklijsten & samenwerking', 'perm.action.trip_create': 'Reizen aanmaken', 'perm.action.trip_edit': 'Reisdetails bewerken', 'perm.action.trip_delete': 'Reizen verwijderen', @@ -28,7 +28,7 @@ const perm: TranslationStrings = { 'perm.action.day_edit': 'Dagen, notities & toewijzingen bewerken', 'perm.action.reservation_edit': 'Reserveringen beheren', 'perm.action.budget_edit': 'Budget beheren', - 'perm.action.packing_edit': 'Paklijsten beheren', + 'perm.action.packing_edit': 'Inpaklijsten beheren', 'perm.action.collab_edit': 'Samenwerking (notities, polls, chat)', 'perm.action.share_manage': 'Deellinks beheren', 'perm.actionHint.trip_create': 'Wie kan nieuwe reizen aanmaken', diff --git a/shared/src/i18n/nl/planner.ts b/shared/src/i18n/nl/planner.ts index 23e6684e..8da3bd91 100644 --- a/shared/src/i18n/nl/planner.ts +++ b/shared/src/i18n/nl/planner.ts @@ -3,7 +3,7 @@ import type { TranslationStrings } from '../types'; const planner: TranslationStrings = { 'planner.places': 'Plaatsen', 'planner.bookings': 'Boekingen', - 'planner.packingList': 'Paklijst', + 'planner.packingList': 'Inpaklijst', 'planner.documents': 'Documenten', 'planner.dayPlan': 'Dagplan', 'planner.reservations': 'Reserveringen', diff --git a/shared/src/i18n/nl/register.ts b/shared/src/i18n/nl/register.ts index 0423f6f7..700e2906 100644 --- a/shared/src/i18n/nl/register.ts +++ b/shared/src/i18n/nl/register.ts @@ -10,7 +10,7 @@ const register: TranslationStrings = { 'register.feature2': 'Interactieve kaartweergave', 'register.feature3': 'Beheer plaatsen en categorieën', 'register.feature4': 'Houd reserveringen bij', - 'register.feature5': 'Maak paklijsten', + 'register.feature5': 'Maak inpaklijsten', 'register.feature6': "Bewaar foto's en bestanden", 'register.createAccount': 'Account aanmaken', 'register.startPlanning': 'Begin met het plannen van je reis', diff --git a/shared/src/i18n/nl/reservations.ts b/shared/src/i18n/nl/reservations.ts index 09016be4..011bede0 100644 --- a/shared/src/i18n/nl/reservations.ts +++ b/shared/src/i18n/nl/reservations.ts @@ -156,10 +156,10 @@ const reservations: TranslationStrings = { 'reservations.airtrail.otherFlights': 'Andere vluchten', 'reservations.airtrail.empty': 'Geen vluchten gevonden in je AirTrail-account.', 'reservations.airtrail.importCta': '{count} importeren', - 'reservations.costsLabel': 'Costs', - 'reservations.createExpense': 'Create expense', - 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', - 'reservations.linkedExpense': 'Linked expense', - 'reservations.removeExpense': 'Remove expense', + 'reservations.costsLabel': 'Kosten', + 'reservations.createExpense': 'Kostenpost aanmaken', + 'reservations.createExpenseHint': 'Boeking opslaan en daarna de Onkosteneditor openen.', + 'reservations.linkedExpense': 'Gekoppelde kostenpost', + 'reservations.removeExpense': 'Kostenpost verwijderen', }; export default reservations; diff --git a/shared/src/i18n/nl/settings.ts b/shared/src/i18n/nl/settings.ts index 266dba38..f7a938fa 100644 --- a/shared/src/i18n/nl/settings.ts +++ b/shared/src/i18n/nl/settings.ts @@ -20,6 +20,7 @@ const settings: TranslationStrings = { 'settings.mapProviderHint': 'Geldt voor Trip Planner en Journey kaarten. Atlas gebruikt altijd Leaflet.', 'settings.mapLeafletSubtitle': 'Klassiek 2D, elke raster-tile', 'settings.mapMapboxSubtitle': 'Vector tiles, 3D-gebouwen & terrein', + 'settings.mapMapLibreSubtitle': 'OpenFreeMap vector tiles, geen token', 'settings.mapExperimental': 'Experimenteel', 'settings.mapMapboxToken': 'Mapbox Access Token', 'settings.mapMapboxTokenHint': 'Openbaar token (pk.*) van', @@ -27,6 +28,8 @@ const settings: TranslationStrings = { 'settings.mapStyle': 'Kaartstijl', 'settings.mapStylePlaceholder': 'Kies een Mapbox-stijl', 'settings.mapStyleHint': 'Preset of eigen mapbox://styles/USER/ID URL', + 'settings.mapOpenFreeMapStylePlaceholder': 'Kies een OpenFreeMap-stijl', + 'settings.mapOpenFreeMapStyleHint': 'Preset of OpenFreeMap-stijl-URL. OpenFreeMap-stijlen werken zonder token.', 'settings.map3dBuildings': '3D-gebouwen & terrein', 'settings.map3dHint': 'Kanteling + echte 3D-gebouwenextrusies — werkt op elke stijl, inclusief satelliet.', 'settings.mapHighQuality': 'Hoge kwaliteit modus', @@ -54,6 +57,7 @@ const settings: TranslationStrings = { 'settings.auto': 'Automatisch', 'settings.language': 'Taal', 'settings.temperature': 'Temperatuureenheid', + 'settings.distance': 'Afstandseenheid', 'settings.timeFormat': 'Tijdnotatie', 'settings.blurBookingCodes': 'Boekingscodes vervagen', 'settings.optimizeFromAccommodation': 'Route optimaliseren vanaf accommodatie', @@ -67,7 +71,7 @@ const settings: TranslationStrings = { 'settings.notifyVacayInvite': 'Vacay-fusieuitnodigingen', 'settings.notifyPhotosShared': "Gedeelde foto's (Immich)", 'settings.notifyCollabMessage': 'Chatberichten (Collab)', - 'settings.notifyPackingTagged': 'Paklijst: toewijzingen', + 'settings.notifyPackingTagged': 'Inpaklijst: toewijzingen', 'settings.notifyWebhook': 'Webhook-meldingen', 'settings.notificationsDisabled': 'Meldingen zijn niet geconfigureerd. Vraag een beheerder om e-mail- of webhookmeldingen in te schakelen.', @@ -276,8 +280,8 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', - 'settings.currency': 'Currency', - 'settings.currencyHint': 'All amounts in Costs are converted to and shown in this currency.', + 'settings.currency': 'Valuta', + 'settings.currencyHint': 'Alle bedragen onder Onkosten worden omgerekend naar en weergegeven in deze valuta.', 'settings.passkey.title': 'Passkeys', 'settings.passkey.description': 'Log sneller en phishingbestendig in met een passkey — je vingerafdruk, gezicht, pincode of een hardwaresleutel. Je wachtwoord blijft als back-up bestaan.', diff --git a/shared/src/i18n/nl/share.ts b/shared/src/i18n/nl/share.ts index a3f0a208..6ebf1406 100644 --- a/shared/src/i18n/nl/share.ts +++ b/shared/src/i18n/nl/share.ts @@ -9,7 +9,7 @@ const share: TranslationStrings = { 'share.createError': 'Kon link niet aanmaken', 'share.permMap': 'Kaart en plan', 'share.permBookings': 'Boekingen', - 'share.permPacking': 'Paklijst', + 'share.permPacking': 'Inpaklijst', 'share.permBudget': 'Budget', 'share.permCollab': 'Chat', }; diff --git a/shared/src/i18n/nl/shared.ts b/shared/src/i18n/nl/shared.ts index 308e87fd..8e7b031b 100644 --- a/shared/src/i18n/nl/shared.ts +++ b/shared/src/i18n/nl/shared.ts @@ -6,7 +6,7 @@ const shared: TranslationStrings = { 'shared.readOnly': 'Alleen-lezen weergave', 'shared.tabPlan': 'Plan', 'shared.tabBookings': 'Boekingen', - 'shared.tabPacking': 'Paklijst', + 'shared.tabPacking': 'Inpaklijst', 'shared.tabBudget': 'Budget', 'shared.tabChat': 'Chat', 'shared.days': 'dagen', diff --git a/shared/src/i18n/nl/system_notice.ts b/shared/src/i18n/nl/system_notice.ts index e014e691..9ca85d09 100644 --- a/shared/src/i18n/nl/system_notice.ts +++ b/shared/src/i18n/nl/system_notice.ts @@ -11,6 +11,14 @@ const system_notice: TranslationStrings = { 'system_notice.welcome_v1.highlight_offline': 'Werkt offline op mobiel', 'system_notice.dev_test_modal.title': '[Dev] Test notice', 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.', + 'system_notice.thank_you_support.title': 'Bedankt voor het gebruik van TREK', + 'system_notice.thank_you_support.body': + "Even een kort bedankje dat je TREK hebt geïnstalleerd — het betekent echt veel voor me.\n\nIk ben een solo-ontwikkelaar en bouw TREK in mijn vrije tijd. Het begon als een klein hulpmiddel voor mijn eigen reizen, en ik ben oprecht overweldigd door de steun en de interesse vanuit de community sindsdien. TREK is met heel veel hart gemaakt aan mijn kant — maar ook dankzij de vele geweldige externe bijdragers die hebben geholpen het vorm te geven.\n\n**TREK is open source en volledig gratis — en dat zal het voor altijd blijven. Geen betaalde versies, geen abonnementen, geen addertjes onder het gras. Dat beloof ik.**\n\nAls TREK nuttig voor je is en je de ontwikkeling ervan wilt steunen, helpt een klein kopje koffie me oprecht om te blijven bouwen — absoluut geen druk, maar elk kopje houdt de late avonden gaande.\n\nBedankt dat je er bent.\n\n— Maurice", + 'system_notice.thank_you_support.highlight_opensource': '100% open source op GitHub', + 'system_notice.thank_you_support.highlight_free': 'Voor altijd gratis — nooit betaalde versies', + 'system_notice.thank_you_support.highlight_community': 'Samen met de community gebouwd', + 'system_notice.thank_you_support.cta_bmc': 'Buy Me a Coffee', + 'system_notice.thank_you_support.cta_kofi': 'Steun op Ko-fi', 'system_notice.pager.prev': 'Vorige melding', 'system_notice.pager.next': 'Volgende melding', 'system_notice.pager.counter': '{current} / {total}', diff --git a/shared/src/i18n/nl/todo.ts b/shared/src/i18n/nl/todo.ts index ac17300e..2747a15a 100644 --- a/shared/src/i18n/nl/todo.ts +++ b/shared/src/i18n/nl/todo.ts @@ -1,7 +1,7 @@ import type { TranslationStrings } from '../types'; const todo: TranslationStrings = { - 'todo.subtab.packing': 'Paklijst', + 'todo.subtab.packing': 'Inpaklijst', 'todo.subtab.todo': 'Taken', 'todo.completed': 'voltooid', 'todo.filter.all': 'Alles', diff --git a/shared/src/i18n/nl/trip.ts b/shared/src/i18n/nl/trip.ts index 99a6ad35..9e987428 100644 --- a/shared/src/i18n/nl/trip.ts +++ b/shared/src/i18n/nl/trip.ts @@ -4,12 +4,12 @@ const trip: TranslationStrings = { 'trip.tabs.plan': 'Plan', 'trip.tabs.transports': 'Transport', 'trip.tabs.reservations': 'Boekingen', - 'trip.tabs.reservationsShort': 'Boek', - 'trip.tabs.packing': 'Paklijst', + 'trip.tabs.reservationsShort': 'Boekingen', + 'trip.tabs.packing': 'Inpaklijst', 'trip.tabs.packingShort': 'Inpakken', 'trip.tabs.lists': 'Lijsten', 'trip.tabs.listsShort': 'Lijsten', - 'trip.tabs.budget': 'Costs', + 'trip.tabs.budget': 'Onkosten', 'trip.tabs.files': 'Bestanden', 'trip.loading': 'Reis laden...', 'trip.loadingPhotos': 'Plaatsfoto laden...', diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index 33d76677..4274366b 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -350,6 +350,7 @@ const admin: TranslationStrings = { 'Domyślna mapa dla wszystkich na tej instancji. Każdy użytkownik może ją zmienić we własnych ustawieniach.', 'admin.defaultSettings.providerLeaflet': 'Standardowa (bezpłatna)', 'admin.defaultSettings.providerMapbox': 'Mapbox (3D)', + 'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)', 'admin.defaultSettings.mapboxToken': 'Współdzielony token Mapbox', 'admin.defaultSettings.mapboxTokenHint': 'Używany dla każdego użytkownika, który nie wprowadził własnego tokena — dzięki temu cała instancja korzysta z Mapbox bez udostępniania klucza każdemu z osobna. Przechowywany w postaci zaszyfrowanej.', diff --git a/shared/src/i18n/pl/settings.ts b/shared/src/i18n/pl/settings.ts index e9f9b4dc..da9a42f0 100644 --- a/shared/src/i18n/pl/settings.ts +++ b/shared/src/i18n/pl/settings.ts @@ -20,6 +20,7 @@ const settings: TranslationStrings = { 'settings.mapProviderHint': 'Dotyczy map Trip Planner i Journey. Atlas zawsze używa Leaflet.', 'settings.mapLeafletSubtitle': 'Klasyczne 2D, dowolne kafelki rastrowe', 'settings.mapMapboxSubtitle': 'Kafelki wektorowe, budynki 3D i teren', + 'settings.mapMapLibreSubtitle': 'Kafelki wektorowe OpenFreeMap, bez tokena', 'settings.mapExperimental': 'Eksperymentalne', 'settings.mapMapboxToken': 'Token dostępu Mapbox', 'settings.mapMapboxTokenHint': 'Token publiczny (pk.*) z', @@ -27,6 +28,8 @@ const settings: TranslationStrings = { 'settings.mapStyle': 'Styl mapy', 'settings.mapStylePlaceholder': 'Wybierz styl Mapbox', 'settings.mapStyleHint': 'Preset lub własny URL mapbox://styles/USER/ID', + 'settings.mapOpenFreeMapStylePlaceholder': 'Wybierz styl OpenFreeMap', + 'settings.mapOpenFreeMapStyleHint': 'Preset lub URL stylu OpenFreeMap. Style OpenFreeMap działają bez tokena.', 'settings.map3dBuildings': 'Budynki 3D i teren', 'settings.map3dHint': 'Nachylenie + prawdziwe wytłaczanie budynków 3D — działa w każdym stylu, także satelitarnym.', 'settings.mapHighQuality': 'Tryb wysokiej jakości', @@ -54,6 +57,7 @@ const settings: TranslationStrings = { 'settings.auto': 'Automatyczny', 'settings.language': 'Język', 'settings.temperature': 'Jednostka temperatury', + 'settings.distance': 'Jednostka odległości', 'settings.timeFormat': 'Format czasu', 'settings.blurBookingCodes': 'Rozmyj kody rezerwacji', 'settings.optimizeFromAccommodation': 'Optymalizuj trasę od zakwaterowania', diff --git a/shared/src/i18n/pl/system_notice.ts b/shared/src/i18n/pl/system_notice.ts index 460a99eb..87a22848 100644 --- a/shared/src/i18n/pl/system_notice.ts +++ b/shared/src/i18n/pl/system_notice.ts @@ -11,6 +11,14 @@ const system_notice: TranslationStrings = { 'system_notice.welcome_v1.highlight_offline': 'Działa offline na telefonie', 'system_notice.dev_test_modal.title': '[Dev] Test notice', 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.', + 'system_notice.thank_you_support.title': 'Dziękuję, że korzystasz z TREK', + 'system_notice.thank_you_support.body': + 'Krótkie podziękowanie za zainstalowanie TREK — naprawdę wiele dla mnie znaczy.\n\nJestem samodzielnym programistą i tworzę TREK po godzinach. Wszystko zaczęło się jako małe narzędzie tylko na moje własne podróże, a wsparcie i zainteresowanie ze strony społeczności od tamtej pory szczerze mnie powaliły. TREK powstaje z wielkim sercem z mojej strony — ale także dzięki wielu wspaniałym zewnętrznym współtwórcom, którzy pomogli go ukształtować.\n\n**TREK jest open source i całkowicie darmowy — i tak już zostanie na zawsze. Bez płatnych wersji, bez subskrypcji, bez haczyków. Obiecuję.**\n\nJeśli TREK jest dla Ciebie przydatny i chciałbyś wesprzeć jego rozwój, mała kawa naprawdę pomaga mi tworzyć dalej — bez żadnej presji, ale każda filiżanka pozwala przetrwać kolejne nocne sesje przy kodzie.\n\nDziękuję, że tu jesteś.\n\n— Maurice', + 'system_notice.thank_you_support.highlight_opensource': '100% open source na GitHubie', + 'system_notice.thank_you_support.highlight_free': 'Darmowy na zawsze — nigdy żadnych płatnych wersji', + 'system_notice.thank_you_support.highlight_community': 'Tworzony wspólnie ze społecznością', + 'system_notice.thank_you_support.cta_bmc': 'Buy Me a Coffee', + 'system_notice.thank_you_support.cta_kofi': 'Wesprzyj na Ko-fi', 'system_notice.pager.prev': 'Poprzednie powiadomienie', 'system_notice.pager.next': 'Następne powiadomienie', 'system_notice.pager.counter': '{current} / {total}', diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index c3c6e1e0..850e22f3 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -345,6 +345,7 @@ const admin: TranslationStrings = { 'Карта по умолчанию для всех на этом сервере. Каждый пользователь по-прежнему может изменить её в своих настройках.', 'admin.defaultSettings.providerLeaflet': 'Стандартная (бесплатно)', 'admin.defaultSettings.providerMapbox': 'Mapbox (3D)', + 'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)', 'admin.defaultSettings.mapboxToken': 'Общий токен Mapbox', 'admin.defaultSettings.mapboxTokenHint': 'Используется для каждого пользователя, который не ввёл собственный токен — так весь сервер получает Mapbox без необходимости делиться ключом по отдельности. Хранится в зашифрованном виде.', diff --git a/shared/src/i18n/ru/settings.ts b/shared/src/i18n/ru/settings.ts index 464ca663..6a4bd6f3 100644 --- a/shared/src/i18n/ru/settings.ts +++ b/shared/src/i18n/ru/settings.ts @@ -20,6 +20,7 @@ const settings: TranslationStrings = { 'settings.mapProviderHint': 'Применяется к Trip Planner и Journey. Atlas всегда использует Leaflet.', 'settings.mapLeafletSubtitle': 'Классические 2D, любые растровые тайлы', 'settings.mapMapboxSubtitle': 'Векторные тайлы, 3D-здания и рельеф', + 'settings.mapMapLibreSubtitle': 'Векторные тайлы OpenFreeMap, без токена', 'settings.mapExperimental': 'Экспериментально', 'settings.mapMapboxToken': 'Токен доступа Mapbox', 'settings.mapMapboxTokenHint': 'Публичный токен (pk.*) с', @@ -27,6 +28,8 @@ const settings: TranslationStrings = { 'settings.mapStyle': 'Стиль карты', 'settings.mapStylePlaceholder': 'Выберите стиль Mapbox', 'settings.mapStyleHint': 'Preset или собственный URL mapbox://styles/USER/ID', + 'settings.mapOpenFreeMapStylePlaceholder': 'Выберите стиль OpenFreeMap', + 'settings.mapOpenFreeMapStyleHint': 'Preset или URL стиля OpenFreeMap. Стили OpenFreeMap работают без токена.', 'settings.map3dBuildings': '3D-здания и рельеф', 'settings.map3dHint': 'Наклон + настоящие 3D-здания — работает со всеми стилями, включая спутник.', 'settings.mapHighQuality': 'Режим высокого качества', @@ -53,6 +56,7 @@ const settings: TranslationStrings = { 'settings.auto': 'Авто', 'settings.language': 'Язык', 'settings.temperature': 'Единица температуры', + 'settings.distance': 'Единица расстояния', 'settings.timeFormat': 'Формат времени', 'settings.blurBookingCodes': 'Скрыть коды бронирования', 'settings.optimizeFromAccommodation': 'Оптимизировать маршрут от места проживания', diff --git a/shared/src/i18n/ru/system_notice.ts b/shared/src/i18n/ru/system_notice.ts index 3ff288bd..43cfae0a 100644 --- a/shared/src/i18n/ru/system_notice.ts +++ b/shared/src/i18n/ru/system_notice.ts @@ -11,6 +11,14 @@ const system_notice: TranslationStrings = { 'system_notice.welcome_v1.highlight_offline': 'Работает офлайн на мобильном', 'system_notice.dev_test_modal.title': '[Dev] Test notice', 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.', + 'system_notice.thank_you_support.title': 'Спасибо, что выбрали TREK', + 'system_notice.thank_you_support.body': + 'Небольшое спасибо за то, что установили TREK — для меня это правда очень много значит.\n\nЯ разработчик-одиночка и делаю TREK в свободное время. Всё началось как маленький инструмент для моих собственных поездок, и я, честно говоря, поражён той поддержкой и интересом, которые проявило сообщество с тех пор. TREK создаётся с большой любовью с моей стороны — но также благодаря множеству замечательных внешних участников, которые помогли его сформировать.\n\n**TREK — это открытый исходный код и полностью бесплатно — и так будет всегда. Никаких платных тарифов, никаких подписок, никаких подвохов. Обещаю.**\n\nЕсли TREK вам полезен и вы хотите поддержать его развитие, маленький кофе по-настоящему помогает мне продолжать — без всякого давления, но каждая чашка даёт силы для поздних ночей.\n\nСпасибо, что вы здесь.\n\n— Maurice', + 'system_notice.thank_you_support.highlight_opensource': '100% открытый код на GitHub', + 'system_notice.thank_you_support.highlight_free': 'Бесплатно навсегда — без платных тарифов', + 'system_notice.thank_you_support.highlight_community': 'Создаётся вместе с сообществом', + 'system_notice.thank_you_support.cta_bmc': 'Buy Me a Coffee', + 'system_notice.thank_you_support.cta_kofi': 'Поддержать на Ko-fi', 'system_notice.pager.prev': 'Предыдущее уведомление', 'system_notice.pager.next': 'Следующее уведомление', 'system_notice.pager.counter': '{current} / {total}', diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts new file mode 100644 index 00000000..0d8685b5 --- /dev/null +++ b/shared/src/i18n/sv/admin.ts @@ -0,0 +1,352 @@ +import type { TranslationStrings } from '../types'; + +const admin: TranslationStrings = { + 'admin.notifications.title': 'Meddelanden', + 'admin.notifications.hint': 'Välj en meddelandekanal. Endast en kan vara aktiv åt gången.', + 'admin.notifications.none': 'Avaktiverad', + 'admin.notifications.email': 'E-post (SMTP)', + 'admin.notifications.webhook': 'Webhook', + 'admin.notifications.ntfy': 'Ntfy', + 'admin.ntfy.hint': + 'Låt användarna ställa in sina egna ntfy-ämnen för push-meddelanden. Ange standardservern nedan för att förifyllda användarinställningarna.', + 'admin.notifications.save': 'Spara inställningarna för aviseringar', + 'admin.notifications.saved': 'Meddelandeinställningarna har sparats', + 'admin.notifications.testWebhook': 'Skicka testwebhook', + 'admin.notifications.testWebhookSuccess': 'Testwebhook har skickats utan problem', + 'admin.notifications.testWebhookFailed': 'Test av webhook misslyckades', + 'admin.notifications.testNtfy': 'Skicka test ntfy', + 'admin.notifications.testNtfySuccess': 'Test ntfy skickades utan problem', + 'admin.notifications.testNtfyFailed': 'Test ntfy misslyckades', + 'admin.notifications.emailPanel.title': 'E-post (SMTP)', + 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.inappPanel.title': 'I-App', + 'admin.notifications.inappPanel.hint': 'Meddelanden i appen är alltid aktiva och kan inte inaktiveras generellt.', + 'admin.notifications.adminWebhookPanel.title': 'Webhook för administratörer', + 'admin.notifications.adminWebhookPanel.hint': + 'Denna webhook används uteslutande för administratörsmeddelanden (t.ex. versionsvarningar). Den är separat från användarspecifika webhooks och aktiveras alltid när den är inställd.', + 'admin.notifications.adminWebhookPanel.saved': 'Admin webhook URL sparad', + 'admin.notifications.adminWebhookPanel.testSuccess': 'Test webhook har skickats utan problem', + 'admin.notifications.adminWebhookPanel.testFailed': 'Test webhook misslyckades', + 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Webhook för administratör aktiveras alltid när en URL konfigureras', + 'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy', + 'admin.notifications.adminNtfyPanel.hint': + 'Detta ntfy-ämne används uteslutande för administratörsmeddelanden (t.ex. versionsvarningar). Det är separat från ämnena för enskilda användare och aktiveras alltid när det är konfigurerat.', + 'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy Server URL', + 'admin.notifications.adminNtfyPanel.serverHint': + 'Används även som standardserver för ntfy-meddelanden till användare. Lämna fältet tomt för att använda ntfy.sh som standard. Användare kan ändra detta i sina egna inställningar.', + 'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh', + 'admin.notifications.adminNtfyPanel.topicLabel': 'Admin Ämne', + 'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts', + 'admin.notifications.adminNtfyPanel.tokenLabel': 'Åtkomsttoken (valfritt)', + 'admin.notifications.adminNtfyPanel.tokenCleared': 'Administratörens åtkomsttoken har raderats', + 'admin.notifications.adminNtfyPanel.saved': 'Admin ntfy inställningar sparade', + 'admin.notifications.adminNtfyPanel.test': 'Skicka test ntfy', + 'admin.notifications.adminNtfyPanel.testSuccess': 'Test ntfy skickat utan problem', + 'admin.notifications.adminNtfyPanel.testFailed': 'Test ntfy misslyckades', + 'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin ntfy utlöses alltid när ett ämne konfigureras', + 'admin.notifications.adminNotificationsHint': + 'Ställ in vilka kanaler som ska skicka meddelanden som endast är avsedda för administratörer (t.ex. versionsvarningar).', + 'admin.notifications.tripReminders.title': 'Påminnelser inför resan', + 'admin.notifications.tripReminders.hint': + 'Skicka en påminnelse innan resan börjar (kräver att påminnelsedagar har angetts för resan).', + 'admin.notifications.tripReminders.enabled': 'Resepåminnelser aktiverade', + 'admin.notifications.tripReminders.disabled': 'Resepåminnelser inaktiverade', + 'admin.smtp.title': 'E-post och aviseringar', + 'admin.smtp.hint': 'SMTP inställningar för att skicka e-postmeddelanden.', + 'admin.smtp.testButton': 'Skicka ett testmejl', + 'admin.webhook.hint': 'Låt användarna ställa in sina egna webhook-URL:er för aviseringar (Discord, Slack m.fl.).', + 'admin.smtp.testSuccess': 'Test e-postmeddelandet har skickats', + 'admin.smtp.testFailed': 'Test e-postmeddelandet misslyckades', + 'admin.title': 'Administration', + 'admin.subtitle': 'Användarhantering och systeminställningar', + 'admin.tabs.users': 'Användare', + 'admin.tabs.categories': 'Kategorier', + 'admin.tabs.backup': 'Säkerhetskopia', + 'admin.tabs.notifications': 'Meddelanden', + 'admin.tabs.audit': 'Revision', + 'admin.stats.users': 'Användare', + 'admin.stats.trips': 'Resor', + 'admin.stats.places': 'Platser', + 'admin.stats.photos': 'Foton', + 'admin.stats.files': 'Filer', + 'admin.table.user': 'Användare', + 'admin.table.email': 'E-post', + 'admin.table.role': 'Roll', + 'admin.table.created': 'Skapad', + 'admin.table.lastLogin': 'Senast inloggad', + 'admin.table.actions': 'Åtgärder', + 'admin.you': '(Du)', + 'admin.editUser': 'Redigera anvädnare', + 'admin.newPassword': 'Nytt lösenord', + 'admin.newPasswordHint': 'Lämna fältet tomt för att behålla det nuvarande lösenordet', + 'admin.deleteUser': 'Ta bort användare "{name}"? Alla resor kommer att raderas permanent.', + 'admin.deleteUserTitle': 'Ta bort användare', + 'admin.newPasswordPlaceholder': 'Ange nytt lösenord…', + 'admin.toast.loadError': 'Det gick inte att ladda administratörsdata', + 'admin.toast.userUpdated': 'Användaren har uppdaterats', + 'admin.toast.updateError': 'Uppdateringen misslyckades', + 'admin.toast.userDeleted': 'Användaren har raderats', + 'admin.toast.deleteError': 'Det gick inte att ta bort', + 'admin.toast.cannotDeleteSelf': 'Det går inte att radera sitt eget konto', + 'admin.toast.userCreated': 'Användare skapad', + 'admin.toast.createError': 'Det gick inte att skapa användaren', + 'admin.toast.fieldsRequired': 'Användarnamn, e-postadress och lösenord krävs', + 'admin.createUser': 'Skapa användare', + 'admin.invite.title': 'Inbjudningslänkar', + 'admin.invite.subtitle': 'Skapa engångslänkar för registrering', + 'admin.invite.create': 'Skapa länk', + 'admin.invite.createAndCopy': 'Skapa och kopiera', + 'admin.invite.empty': 'Inga inbjudningslänkar har skapats ännu', + 'admin.invite.maxUses': 'Max. antal användningar', + 'admin.invite.expiry': 'Gäller till', + 'admin.invite.uses': 'använd', + 'admin.invite.expiresAt': 'utgår', + 'admin.invite.createdBy': 'av', + 'admin.invite.active': 'Aktiv', + 'admin.invite.expired': 'Utgått', + 'admin.invite.usedUp': 'Förbrukad', + 'admin.invite.copied': 'Inbjudningslänken har kopierats till urklipp', + 'admin.invite.copyLink': 'Kopiera länken', + 'admin.invite.deleted': 'Inbjudningslänken har tagits bort', + 'admin.invite.createError': 'Det gick inte att skapa en inbjudningslänk', + 'admin.invite.deleteError': 'Det gick inte att ta bort inbjudningslänken', + 'admin.tabs.settings': 'Inställningar', + 'admin.allowRegistration': 'Tillåt registrering', + 'admin.allowRegistrationHint': 'Nya användare kan registrera sig själva', + 'admin.authMethods': 'Autentiseringsmetoder', + 'admin.passwordLogin': 'Inloggning med lösenord', + 'admin.passwordLoginHint': 'Låt användarna logga in med e-postadress och lösenord', + 'admin.passwordRegistration': 'Lösenordsregistrering', + 'admin.passwordRegistrationHint': 'Låt nya användare registrera sig med e-postadress och lösenord', + 'admin.oidcLogin': 'SSO Inloggning', + 'admin.oidcLoginHint': 'Låt användarna logga in med SSO', + 'admin.oidcRegistration': 'SSO Automatisk konfigurering', + 'admin.oidcRegistrationHint': 'Skapa konton automatiskt för nya SSO-användare', + 'admin.envOverrideHint': + 'Inställningarna för inloggning med lösenord styrs av miljövariabeln OIDC_ONLY och kan inte ändras här.', + 'admin.lockoutWarning': 'Minst en inloggningsmetod måste vara aktiverad', + 'admin.requireMfa': 'Kräv tvåfaktorsautentisering (2FA)', + 'admin.requireMfaHint': 'Användare som inte har tvåfaktorsautentisering måste slutföra inställningarna under inställningar innan de använder appen.', + 'admin.apiKeys': 'API Nycklar', + 'admin.apiKeysHint': 'Valfritt. Aktiverar utökade platsuppgifter, såsom foton och väderinformation.', + 'admin.mapsKey': 'Google Maps API Nyckel', + 'admin.mapsKeyHint': 'Krävs för att söka efter platser. Hämta på console.cloud.google.com', + 'admin.mapsKeyHintLong': + 'Utan en API-nyckel används OpenStreetMap för platssökning. Med en Google API-nyckel kan även foton, betyg och öppettider hämtas. Skaffa en på console.cloud.google.com.', + 'admin.recommended': 'Rekommenderat', + 'admin.weatherKey': 'OpenWeatherMap API Nyckel', + 'admin.weatherKeyHint': 'För väderdata. Gratis på openweathermap.org', + 'admin.validateKey': 'Test', + 'admin.keyValid': 'Ansluten', + 'admin.keyInvalid': 'Felaktig', + 'admin.keySaved': 'API nycklar sparade', + 'admin.oidcTitle': 'Enkel inloggning (OIDC)', + 'admin.oidcSubtitle': 'Tillåt inloggning via externa tjänster som Google, Apple, Authentik eller Keycloak.', + 'admin.oidcDisplayName': 'Visningsnamn', + 'admin.oidcIssuer': 'Utfärdarens URL', + 'admin.oidcIssuerHint': 'OpenID Connect Utfärdarens URL hos leverantören. t.ex. https://accounts.google.com', + 'admin.oidcSaved': 'OIDC konfigurationen har sparats', + 'admin.oidcOnlyMode': 'Inaktivera lösenordsautentisering', + 'admin.oidcOnlyModeHint': + 'När funktionen är aktiverad är endast SSO-inloggning tillåten. Lösenordsbaserad inloggning och registrering blockeras.', + 'admin.fileTypes': 'Tillåtna filtyper', + 'admin.fileTypesHint': 'Ställ in vilka filtyper användarna kan ladda upp.', + 'admin.fileTypesFormat': 'Filändelser separerade med kommatecken (t.ex. jpg, png, pdf, doc). Använd * för att tillåta alla filtyper.', + 'admin.fileTypesSaved': 'Inställningar för filtyper har sparats', + 'admin.placesPhotos.title': 'Plats Foton', + 'admin.placesPhotos.subtitle': + 'Hämta bilder från Google Places API. Inaktivera för att spara API-kvoten. Wikimedia-bilder påverkas inte.', + 'admin.placesAutocomplete.title': 'Automatisk komplettering av plats', + 'admin.placesAutocomplete.subtitle': 'Använd Google Places API för sökförslag. Inaktivera funktionen för att spara på API-kvoten.', + 'admin.placesDetails.title': 'Platsinformation', + 'admin.placesDetails.subtitle': + 'Hämta detaljerad information om platsen (öppettider, betyg, webbplats) från Google Places API. Inaktivera funktionen för att spara på API-kvoten.', + 'admin.bagTracking.title': 'Spårning av väskor', + 'admin.bagTracking.subtitle': 'Aktivera vikt- och väskfördelning för packningsartiklar', + 'admin.collab.chat.title': 'Chatt', + 'admin.collab.chat.subtitle': 'Realtidsmeddelanden för samarbete kring resor', + 'admin.collab.notes.title': 'Noteringar', + 'admin.collab.notes.subtitle': 'Delade noteringar och dokument', + 'admin.collab.polls.title': 'Omröstningar', + 'admin.collab.polls.subtitle': 'Grupp omröstningar', + 'admin.collab.whatsnext.title': "Vad händer härnäst?", + 'admin.collab.whatsnext.subtitle': 'Förslag på aktiviteter och nästa steg', + 'admin.tabs.config': 'Anpassning', + 'admin.tabs.defaults': 'Användarinställningar', + 'admin.defaultSettings.title': 'Standardinställningar för användare', + 'admin.defaultSettings.description': + 'Ange standardvärden som gäller för hela instansen. Användare som inte har ändrat en inställning kommer att se dessa värden. Deras egna ändringar har alltid företräde.', + 'admin.defaultSettings.saved': 'Standard sparad', + 'admin.defaultSettings.reset': 'Återställ till inbyggda standardinställningar', + 'admin.defaultSettings.resetToBuiltIn': 'återställ', + 'admin.defaultSettings.mapProvider': 'Kartmotor', + 'admin.defaultSettings.mapProviderHint': + 'Standardkartan för alla användare på denna instans. Varje användare kan fortfarande ändra inställningarna i sina egna inställningar.', + 'admin.defaultSettings.providerLeaflet': 'Standard (gratis)', + 'admin.defaultSettings.providerMapbox': 'Mapbox (3D)', + 'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)', + 'admin.defaultSettings.mapboxToken': 'Delat Mapbox-token', + 'admin.defaultSettings.mapboxTokenHint': + 'Används för alla användare som inte har angett sin egen token – på så sätt får hela instansen tillgång till Mapbox utan att nyckeln behöver delas ut individuellt. Lagras i krypterad form.', + 'admin.defaultSettings.mapboxStyle': 'Kartstil', + 'admin.defaultSettings.mapboxStylePlaceholder': 'Välj en stil…', + 'admin.defaultSettings.mapbox3d': '3D-byggnader och terräng', + 'admin.defaultSettings.mapboxQuality': 'Högkvalitetsläge', + 'admin.tabs.templates': 'Packningsmallar', + 'admin.packingTemplates.title': 'Packningsmallar', + 'admin.packingTemplates.subtitle': 'Skapa återanvändbara packlistor för dina resor', + 'admin.packingTemplates.create': 'Ny mall', + 'admin.packingTemplates.namePlaceholder': 'Mallnamn (t.ex. Strandsemester)', + 'admin.packingTemplates.empty': 'Inga mallar har skapats ännu', + 'admin.packingTemplates.items': 'föremål', + 'admin.packingTemplates.categories': 'kategorier', + 'admin.packingTemplates.itemName': 'Föremålsnamn', + 'admin.packingTemplates.itemCategory': 'Kategori', + 'admin.packingTemplates.categoryName': 'Kategorinamn (t.ex. Kläder)', + 'admin.packingTemplates.addCategory': 'Lägg till kategori', + 'admin.packingTemplates.created': 'Mall skapad', + 'admin.packingTemplates.deleted': 'Mallen har tagits bort', + 'admin.packingTemplates.loadError': 'Det gick inte att ladda mallarna', + 'admin.packingTemplates.createError': 'Det gick inte att skapa mallen', + 'admin.packingTemplates.deleteError': 'Det gick inte att ta bort mallen', + 'admin.packingTemplates.saveError': 'Det gick inte att spara', + 'admin.tabs.addons': 'Tillägg', + 'admin.addons.title': 'Tillägg', + 'admin.addons.subtitle': 'Aktivera eller inaktivera funktioner för att anpassa din TREK-upplevelse.', + 'admin.addons.catalog.packing.name': 'Listor', + 'admin.addons.catalog.packing.description': 'Packlistor och saker att göra inför dina resor', + 'admin.addons.catalog.budget.name': 'Budget', + 'admin.addons.catalog.budget.description': 'Håll koll på utgifterna och planera din resebudget', + 'admin.addons.catalog.documents.name': 'Dokument', + 'admin.addons.catalog.documents.description': 'Spara och hantera resedokument', + 'admin.addons.catalog.vacay.name': 'Vacay', + 'admin.addons.catalog.vacay.description': 'Personlig semesterplanerare med kalendervy', + 'admin.addons.catalog.atlas.name': 'Atlas', + 'admin.addons.catalog.atlas.description': 'Världskarta med besökta länder och resestatistik', + 'admin.addons.catalog.collab.name': 'Samarbete', + 'admin.addons.catalog.collab.description': 'Noteringar, omröstningar och chatt i realtid för resplanering', + 'admin.addons.catalog.memories.name': 'Foton (Immich)', + 'admin.addons.catalog.memories.description': 'Dela resefoton via din Immich-instans', + 'admin.addons.catalog.mcp.name': 'MCP', + 'admin.addons.catalog.mcp.description': 'Modellkontextprotokoll för integration av AI-assistenter', + 'admin.addons.subtitleBefore': 'Aktivera eller inaktivera funktioner för att anpassa din ', + 'admin.addons.subtitleAfter': ' erfarenhet.', + 'admin.addons.enabled': 'Aktiverad', + 'admin.addons.disabled': 'Inaktiverad', + 'admin.addons.type.trip': 'Resa', + 'admin.addons.type.global': 'Global', + 'admin.addons.type.integration': 'Integration', + 'admin.addons.tripHint': 'Finns som en flik i varje resa', + 'admin.addons.globalHint': 'Finns som en fristående sektion i huvudnavigeringen', + 'admin.addons.integrationHint': 'Backend-tjänster och API-integrationer utan egen sida', + 'admin.addons.toast.updated': 'Tillägget har uppdaterats', + 'admin.addons.toast.error': 'Det gick inte att uppdatera tillägget', + 'admin.addons.noAddons': 'Inga tillägg tillgängliga', + 'admin.weather.title': 'Väderdata', + 'admin.weather.badge': 'Sedan den 24 mars 2026', + 'admin.weather.description': + 'TREK använder Open-Meteo som källa för väderdata. Open-Meteo är en kostnadsfri vädertjänst med öppen källkod – ingen API-nyckel krävs.', + 'admin.weather.forecast': '16-dagarsprognos', + 'admin.weather.forecastDesc': 'Tidigare 5 dagar (OpenWeatherMap)', + 'admin.weather.climate': 'Historiska klimatdata', + 'admin.weather.climateDesc': 'Genomsnitt för de senaste 85 åren avseende dagar bortom den 16-dagarsprognosen', + 'admin.weather.requests': '10 000 förfrågningar per dag', + 'admin.weather.requestsDesc': 'Gratis, ingen API-nyckel krävs', + 'admin.weather.locationHint': + 'Väderuppgifterna baseras på den plats med koordinater som anges först för varje dag. Om ingen plats har angetts för en dag används en valfri plats från platslistan som referens.', + 'admin.tabs.mcpTokens': 'MCP Åtkomst', + 'admin.mcpTokens.title': 'MCP Åtkomst', + 'admin.mcpTokens.subtitle': 'Hantera OAuth-sessioner och API-token för alla användare', + 'admin.mcpTokens.sectionTitle': 'API Token', + 'admin.mcpTokens.owner': 'Ägare', + 'admin.mcpTokens.tokenName': 'Token namn', + 'admin.mcpTokens.created': 'Skapad', + 'admin.mcpTokens.lastUsed': 'Senast använd', + 'admin.mcpTokens.never': 'Aldrig', + 'admin.mcpTokens.empty': 'Inga MCP-tokens har skapats ännu', + 'admin.mcpTokens.deleteTitle': 'Ta bort token', + 'admin.mcpTokens.deleteMessage': + 'Detta kommer att återkalla token omedelbart. Användaren kommer att förlora sin MCP-åtkomst via detta token.', + 'admin.mcpTokens.deleteSuccess': 'Token har tagits bort', + 'admin.mcpTokens.deleteError': 'Det gick inte att ta bort token', + 'admin.mcpTokens.loadError': 'Det gick inte att ladda tokens', + 'admin.oauthSessions.sectionTitle': 'OAuth Sessioner', + 'admin.oauthSessions.clientName': 'Klient', + 'admin.oauthSessions.owner': 'Ägare', + 'admin.oauthSessions.scopes': 'Tillämpningsområden', + 'admin.oauthSessions.created': 'Skapad', + 'admin.oauthSessions.empty': 'Inga aktiva OAuth-sessioner', + 'admin.oauthSessions.revokeTitle': 'Återkalla Session', + 'admin.oauthSessions.revokeMessage': + 'Detta kommer att återkalla OAuth-sessionen omedelbart. Klienten kommer att förlora åtkomsten till MCP.', + 'admin.oauthSessions.revokeSuccess': 'Session återkallad', + 'admin.oauthSessions.revokeError': 'Kunde inte återkalla session', + 'admin.oauthSessions.loadError': 'Kunde inte ladda OAuth sessions', + 'admin.tabs.github': 'GitHub', + 'admin.audit.subtitle': 'Säkerhetsrelaterade händelser och administrativa händelser (säkerhetskopieringar, användare, MFA, inställningar).', + 'admin.audit.empty': 'Inga revisionsposter ännu.', + 'admin.audit.refresh': 'Uppdatera', + 'admin.audit.loadMore': 'Ladda fler', + 'admin.audit.showing': '{count} laddad · {total} totalt', + 'admin.audit.col.time': 'Tid', + 'admin.audit.col.user': 'Användare', + 'admin.audit.col.action': 'Åtgärd', + 'admin.audit.col.resource': 'Resurs', + 'admin.audit.col.ip': 'IP', + 'admin.audit.col.details': 'Detaljer', + 'admin.github.title': 'Versionshistorik', + 'admin.github.subtitle': 'Senaste uppdateringarna från {repo}', + 'admin.github.latest': 'Senaste', + 'admin.github.prerelease': 'Förhandsutgåva', + 'admin.github.showDetails': 'Visa detaljer', + 'admin.github.hideDetails': 'Göm detaljer', + 'admin.github.loadMore': 'Ladda fler', + 'admin.github.loading': 'Laddar...', + 'admin.github.error': 'Det gick inte att ladda utgåvorna', + 'admin.github.by': 'av', + 'admin.github.support': 'Hjälper mig att fortsätta bygga TREK', + 'admin.update.available': 'Uppdatering tillgänglig', + 'admin.update.text': 'TREK {version} finns tillgängligt. Du kör {current}.', + 'admin.update.button': 'Visa på GitHub', + 'admin.update.install': 'Installera uppdatering', + 'admin.update.confirmTitle': 'Ska uppdateringen installeras?', + 'admin.update.confirmText': + 'TREK kommer att uppdateras från {current} till {version}. Servern startas om automatiskt därefter.', + 'admin.update.dataInfo': 'Alla dina data (resor, användare, API-nycklar, uppladdningar, Vacay, Atlas, budgetar) kommer att bevaras.', + 'admin.update.warning': 'Appen kommer att vara tillfälligt otillgänglig under omstarten.', + 'admin.update.confirm': 'Uppdatera nu', + 'admin.update.installing': 'Uppdaterar…', + 'admin.update.success': 'Uppdateringen är installerad! Servern startas om…', + 'admin.update.failed': 'Uppdateringen misslyckades', + 'admin.update.backupHint': 'Vi rekommenderar att du gör en säkerhetskopia innan du uppdaterar.', + 'admin.update.backupLink': 'Gå till Säkerhetskopiering', + 'admin.update.howTo': 'Så här uppdaterar du', + 'admin.update.dockerText': + 'Din TREK-instans körs i Docker. För att uppdatera till {version} kör du följande kommandon på din server:', + 'admin.update.nonDockerText': + 'Denna TREK-instans körs inte i Docker. För att uppdatera till {version}, kör installations- eller uppdateringsmetod du använde på nytt — till exempel, i Proxmox Community Scripts kör du uppdateringen från LXC-konsolen:', + 'admin.update.wikiLink': 'Öppna uppdateringsguiden', + 'admin.update.reloadHint': 'Vänligen uppdatera sidan om några sekunder.', + 'admin.tabs.permissions': 'Behörigheter', + 'admin.addons.catalog.journey.name': 'Journey', + 'admin.addons.catalog.journey.description': + 'Resespårning och resedagbok med incheckningar, foton och dagliga inlägg', + 'admin.passkey.title': 'Inloggningsnyckel', + 'admin.passkey.cardHint': 'Låter användare att logga in med inloggningsnyckel (WebAuthn). Avstängt som standard.', + 'admin.passkey.login': 'Aktivera inloggningsnyckel', + 'admin.passkey.loginHint': 'Visa alternativet ”Logga in med en inloggningsnyckel och låt användarna registrera inloggningsnycklar i sina inställningar.', + 'admin.passkey.notConfigured': + 'Det finns ännu ingen WebAuthn-domän för denna distribution. Ange APP_URL eller Relying Party ID nedan – inloggningsnycklar förblir dolda tills dess.', + 'admin.passkey.rpId': 'Relying Party ID (domain)', + 'admin.passkey.rpIdHint': + 'De rena domän inloggningsnycklarna är kopplade till t.ex. trek.example.org. Lämna fältet tomt om du vill att nyckeln ska härledas från APP_URL. Om du ändrar detta senare blir befintliga nycklar ogiltiga.', + 'admin.passkey.origins': 'Allowed origins', + 'admin.passkey.originsHint': + 'Fullständiga ursprungsadresser separerade med kommatecken, t.ex. https://trek.example.org. Lämna fältet tomt för att använda APP_URL.', + 'admin.passkey.reset': 'Återställ inloggningsnycklar', + 'admin.passkey.resetHint': + "Ta bort alla den här användarens inloggningsnycklar (t.ex. om enheten har försvunnit). Användaren kan fortfarande logga in med sitt lösenord.", + 'admin.passkey.resetConfirm': 'Ta bort alla åtkomstnycklar för {name}?', + 'admin.passkey.resetDone': 'Tog bort {count} inloggningsnycklar', +}; +export default admin; diff --git a/shared/src/i18n/sv/airport.ts b/shared/src/i18n/sv/airport.ts new file mode 100644 index 00000000..b50dd552 --- /dev/null +++ b/shared/src/i18n/sv/airport.ts @@ -0,0 +1,6 @@ +import type { TranslationStrings } from '../types'; + +const airport: TranslationStrings = { + 'airport.searchPlaceholder': 'Flygplatskod eller ort (t.ex. FRA)', +}; +export default airport; diff --git a/shared/src/i18n/sv/atlas.ts b/shared/src/i18n/sv/atlas.ts new file mode 100644 index 00000000..5c43c9a1 --- /dev/null +++ b/shared/src/i18n/sv/atlas.ts @@ -0,0 +1,58 @@ +import type { TranslationStrings } from '../types'; + +const atlas: TranslationStrings = { + 'atlas.subtitle': 'Ditt resefotavtryck runt om i världen', + 'atlas.countries': 'Länder', + 'atlas.trips': 'Resor', + 'atlas.places': 'Platser', + 'atlas.unmark': 'Ta bort', + 'atlas.confirmMark': 'Ska jag markera det här landet som besökt?', + 'atlas.confirmUnmark': 'Vill du ta bort det här landet från listan över besökta länder?', + 'atlas.confirmUnmarkRegion': 'Vill du ta bort denna region från listan över besökta platser?', + 'atlas.markVisited': 'Markera som besökt', + 'atlas.markVisitedHint': 'Lägg till detta land i listan över besökta länder', + 'atlas.markRegionVisitedHint': 'Lägg till denna region i din lista över besökta platser', + 'atlas.addToBucket': 'Lägg till på bucketlistan', + 'atlas.addPoi': 'Lägg till plats', + 'atlas.searchCountry': 'Sök efter ett land...', + 'atlas.bucketNamePlaceholder': 'Namn (land, stad, ort...)', + 'atlas.month': 'Månad', + 'atlas.year': 'År', + 'atlas.addToBucketHint': 'Spara som en plats du vill besöka', + 'atlas.bucketWhen': 'När har du tänkt att komma på besök?', + 'atlas.statsTab': 'Statistik', + 'atlas.bucketTab': 'Bucketlista', + 'atlas.addBucket': 'Lägg till i bucketlistan', + 'atlas.bucketNotesPlaceholder': 'Notering (valfritt)', + 'atlas.bucketEmpty': 'Din bucketlista är tom', + 'atlas.bucketEmptyHint': 'Lägg till platser du drömmer om att besöka', + 'atlas.days': 'Dagar', + 'atlas.visitedCountries': 'Besökta länder', + 'atlas.cities': 'Städer', + 'atlas.noData': 'Inga resedata ännu', + 'atlas.noDataHint': 'Skapa en resa och lägg till platser på din världskarta', + 'atlas.lastTrip': 'Den senaste resan', + 'atlas.nextTrip': 'Nästa resa', + 'atlas.daysLeft': 'dagar kvar', + 'atlas.streak': 'Streak', + 'atlas.years': 'år', + 'atlas.yearInRow': 'år i rad', + 'atlas.yearsInRow': 'år i rad', + 'atlas.tripIn': 'resa inom', + 'atlas.tripsIn': 'resor inom', + 'atlas.since': 'sedan', + 'atlas.europe': 'Europa', + 'atlas.asia': 'Asien', + 'atlas.northAmerica': 'N. Amerika', + 'atlas.southAmerica': 'S. Amerika', + 'atlas.africa': 'Afrika', + 'atlas.oceania': 'Oceanien', + 'atlas.other': 'Andra', + 'atlas.firstVisit': 'Första resan', + 'atlas.lastVisitLabel': 'Den senaste resan', + 'atlas.tripSingular': 'Resa', + 'atlas.tripPlural': 'Resor', + 'atlas.placeVisited': 'Plats besökt', + 'atlas.placesVisited': 'Platser besökta', +}; +export default atlas; diff --git a/shared/src/i18n/sv/backup.ts b/shared/src/i18n/sv/backup.ts new file mode 100644 index 00000000..8512ef78 --- /dev/null +++ b/shared/src/i18n/sv/backup.ts @@ -0,0 +1,72 @@ +import type { TranslationStrings } from '../types'; + +const backup: TranslationStrings = { + 'backup.title': 'Databackup', + 'backup.subtitle': 'Databasen och alla uppladdade filer', + 'backup.refresh': 'Uppdatera', + 'backup.upload': 'Ladda upp säkerhetskopia', + 'backup.uploading': 'Laddar upp…', + 'backup.create': 'Skapa säkerhetskopia', + 'backup.creating': 'Skapar…', + 'backup.empty': 'Inga säkerhetskopior ännu', + 'backup.createFirst': 'Skapa den första säkerhetskopian', + 'backup.download': 'Ladda ner', + 'backup.restore': 'Återställ', + 'backup.confirm.restore': 'Återställ säkerhetskopia "{name}"?\n\nAlla aktuella data kommer att ersättas med säkerhetskopian.', + 'backup.confirm.uploadRestore': 'Ladda upp och återställ säkerhetskopian "{name}"?\n\nAlla befintliga data kommer att skrivas över.', + 'backup.confirm.delete': 'Ta bort säkerhetskopia "{name}"?', + 'backup.toast.loadError': 'Det gick inte att ladda säkerhetskopiorna', + 'backup.toast.created': 'Säkerhetskopian har skapats', + 'backup.toast.createError': 'Det gick inte att skapa en säkerhetskopia', + 'backup.toast.restored': 'Säkerhetskopian har återställts. Sidan kommer att laddas om…', + 'backup.toast.restoreError': 'Återställningen misslyckades', + 'backup.toast.uploadError': 'Det gick inte att ladda upp', + 'backup.toast.deleted': 'Säkerhetskopian har raderats', + 'backup.toast.deleteError': 'Det gick inte att ta bort', + 'backup.toast.downloadError': 'Nedladdningen misslyckades', + 'backup.toast.settingsSaved': 'Inställningarna för automatisk säkerhetskopiering har sparats', + 'backup.toast.settingsError': 'Det gick inte att spara inställningarna', + 'backup.auto.title': 'Automatisk säkerhetskopiering', + 'backup.auto.subtitle': 'Automatisk säkerhetskopiering enligt ett schema', + 'backup.auto.enable': 'Aktivera automatisk säkerhetskopiering', + 'backup.auto.enableHint': 'Säkerhetskopior kommer att skapas automatiskt enligt det valda schemat', + 'backup.auto.interval': 'Intervall', + 'backup.auto.hour': 'Kör varje timme', + 'backup.auto.hourHint': 'Serverens lokala tid ({format} format)', + 'backup.auto.dayOfWeek': 'Veckodag', + 'backup.auto.dayOfMonth': 'Dag i månaden', + 'backup.auto.dayOfMonthHint': 'Begränsat till 1–28 för att passa alla månader', + 'backup.auto.scheduleSummary': 'Schema', + 'backup.auto.summaryDaily': 'Varje dag kl. {hour}:00', + 'backup.auto.summaryWeekly': 'Varje {day} vid {hour}:00', + 'backup.auto.summaryMonthly': 'Dag {day} varje månad kl. {hour}:00', + 'backup.auto.envLocked': 'Docker', + 'backup.auto.envLockedHint': + 'Automatisk säkerhetskopiering konfigureras via Docker-miljövariabler. För att ändra dessa inställningar ska du uppdatera filen docker-compose.yml och starta om containern.', + 'backup.auto.copyEnv': 'Kopiera Docker-miljövariabler', + 'backup.auto.envCopied': 'Docker-miljövariabler kopierade till urklipp', + 'backup.auto.keepLabel': 'Ta bort gamla säkerhetskopior efter', + 'backup.dow.sunday': 'Sön', + 'backup.dow.monday': 'Mån', + 'backup.dow.tuesday': 'Tis', + 'backup.dow.wednesday': 'Ons', + 'backup.dow.thursday': 'Tor', + 'backup.dow.friday': 'Fre', + 'backup.dow.saturday': 'Lör', + 'backup.interval.hourly': 'Per timme', + 'backup.interval.daily': 'Dagligen', + 'backup.interval.weekly': 'Varje vecka', + 'backup.interval.monthly': 'Månadsvis', + 'backup.keep.1day': '1 dag', + 'backup.keep.3days': '3 dagar', + 'backup.keep.7days': '7 dagar', + 'backup.keep.14days': '14 dagar', + 'backup.keep.30days': '30 dagar', + 'backup.keep.forever': 'Behåll för alltid', + 'backup.restoreConfirmTitle': 'Återställa säkerhetskopia?', + 'backup.restoreWarning': + 'Alla aktuella data (resor, platser, användare, uppladdningar) kommer att ersättas permanent med säkerhetskopian. Denna åtgärd går inte att ångra.', + 'backup.restoreTip': 'Tips: Gör en säkerhetskopia av det aktuella läget innan du återställer.', + 'backup.restoreConfirm': 'Ja, återställ', +}; +export default backup; diff --git a/shared/src/i18n/sv/budget.ts b/shared/src/i18n/sv/budget.ts new file mode 100644 index 00000000..24e92922 --- /dev/null +++ b/shared/src/i18n/sv/budget.ts @@ -0,0 +1,121 @@ +import type { TranslationStrings } from '../types'; + +const budget: TranslationStrings = { + 'budget.title': 'Budget', + 'budget.exportCsv': 'Exportera CSV', + 'budget.emptyTitle': 'Ingen budget har skapats ännu', + 'budget.emptyText': 'Skapa kategorier och poster för att planera din resebudget', + 'budget.emptyPlaceholder': 'Ange kategorinamn...', + 'budget.createCategory': 'Skapa kategori', + 'budget.category': 'Kategori', + 'budget.categoryName': 'Kategorinamn', + 'budget.table.name': 'Namnn', + 'budget.table.total': 'Totalt', + 'budget.table.persons': 'Personer', + 'budget.table.days': 'Dagar', + 'budget.table.perPerson': 'Per Person', + 'budget.table.perDay': 'Per Dag', + 'budget.table.perPersonDay': 'P. p / Dag', + 'budget.table.note': 'Notering', + 'budget.table.date': 'Datum', + 'budget.newEntry': 'Nytt inlägg', + 'budget.defaultEntry': 'Nytt inlägg', + 'budget.defaultCategory': 'Ny kategori', + 'budget.total': 'Totalt', + 'budget.totalBudget': 'Total Budget', + 'budget.byCategory': 'Efter kategori', + 'budget.editTooltip': 'Klicka för att redigera', + 'budget.linkedToReservation': 'Kopplat till en bokning – redigera namnet där', + 'budget.confirm.deleteCategory': 'Är du säker på att du vill ta bort kategorin "{name}" med {count} inlägg?', + 'budget.deleteCategory': 'Ta bort kategori', + 'budget.perPerson': 'Per Person', + 'budget.paid': 'Betald', + 'budget.open': 'Öppen', + 'budget.noMembers': 'Inga medlemmar har tilldelats', + 'budget.settlement': 'Uppgörelse', + 'budget.settlementInfo': + 'Klicka på en medlems avatar vid en budgetpost för att markera den med grönt – det betyder att personen har betalat. Avräkningen visar sedan vem som är skyldig vem och hur mycket.', + 'budget.netBalances': 'Nettosaldon', + 'budget.categoriesLabel': 'kategorier', + 'costs.you': 'Du', + 'costs.youShort': 'D', + 'costs.youLower': 'du', + 'costs.youOwe': 'Du är skyldig', + 'costs.youOweSub': 'Du bör betala andra', + 'costs.youreOwed': "Du är skyldig", + 'costs.youreOwedSub': 'Andra borde betala dig', + 'costs.totalSpend': 'Totala resekostnader', + 'costs.totalSpendSub': 'Bland alla resenärer', + 'costs.to': 'Till', + 'costs.from': 'Från', + 'costs.allSettled': "Allt är nu reglerat", + 'costs.nothingOwed': 'Ingenting är skyldig dig', + 'costs.yourShare': 'Din andel', + 'costs.youPaid': 'Du betalade', + 'costs.expenses': 'Kostnader', + 'costs.entries': '{count} inlägg', + 'costs.searchPlaceholder': 'Sök kostnader…', + 'costs.filter.all': 'Alla', + 'costs.filter.mine': 'Betalat av mig', + 'costs.filter.owed': "Jag är skyldig", + 'costs.addExpense': 'Lägg till utgift', + 'costs.editExpense': 'Redigera utgift', + 'costs.noMatch': 'Inga utgifter stämmer överens med din sökning.', + 'costs.emptyText': 'Inga utgifter ännu. Lägg till din första.', + 'costs.spent': '{amount} spenderat', + 'costs.noDate': 'Inget datum', + 'costs.noOnePaid': 'Ingen har betalat än', + 'costs.youLent': 'du lånade ut {amount}', + 'costs.youBorrowed': 'du lånade {amount}', + 'costs.settleUp': 'Betala', + 'costs.history': 'Historik', + 'costs.everyoneSquare': "Alla är likadana", + 'costs.nothingOutstanding': 'Det finns inga utestående betalningar just nu.', + 'costs.pay': 'betala', + 'costs.pays': 'betalar', + 'costs.settle': 'Lösa', + 'costs.balances': 'Balanser', + 'costs.byCategory': 'Via kategori', + 'costs.noCategories': 'Inga utgifter än.', + 'costs.settleHistory': 'Historik över reglering', + 'costs.noSettlements': 'Inga betalningar har ännu reglerats.', + 'costs.paymentsSettled': '{count} betalningar som har reglerats', + 'costs.paid': 'betald', + 'costs.undo': 'Ångra', + 'costs.whatFor': 'Vad var det till för?', + 'costs.namePlaceholder': 't.ex. middag, souvenirer, bensin…', + 'costs.totalAmount': 'Totalt belopp', + 'costs.currency': 'Valuta', + 'costs.day': 'Dag', + 'costs.rateLabel': '1 {from} i {to}', + 'costs.category': 'Kategori', + 'costs.whoPaid': 'Vem betalade?', + 'costs.splitBetween': 'Fördela jämnt mellan', + 'costs.pickSomeone': 'Välj minst en person att dela med.', + 'costs.splitSummary': 'Dela {count} sätt · {amount} var och en', + 'costs.cat.accommodation': 'Boende', + 'costs.cat.food': 'Mat och dryck', + 'costs.cat.groceries': 'Livsmedel', + 'costs.cat.transport': 'Transport', + 'costs.cat.flights': 'Flyg', + 'costs.cat.activities': 'Aktiviteter', + 'costs.cat.sightseeing': 'Sevärdheter', + 'costs.cat.shopping': 'Shopping', + 'costs.cat.fees': 'Avgifter och biljetter', + 'costs.cat.health': 'Hälsa', + 'costs.cat.tips': 'Dricks', + 'costs.cat.other': 'Annat', + 'costs.daysCount': '{count} dagar', + 'costs.travelers': '{count} resenärer', + 'costs.liveRate': 'realtidspris', + 'costs.settleAll': 'Reglera allt', + 'costs.payment': 'Betalning', + 'costs.editPayment': 'Redigera betalning', + 'costs.addPayment': 'Lägg till betalning', + 'costs.unfinished': 'Oavslutad', + 'costs.unfinishedHint': 'Endast totalt – ännu inte reglerat', + 'costs.tapToInclude': 'Tryck för att inkludera', + 'costs.amount': 'Summa', +}; + +export default budget; diff --git a/shared/src/i18n/sv/categories.ts b/shared/src/i18n/sv/categories.ts new file mode 100644 index 00000000..d831caab --- /dev/null +++ b/shared/src/i18n/sv/categories.ts @@ -0,0 +1,25 @@ +import type { TranslationStrings } from '../types'; + +const categories: TranslationStrings = { + 'categories.title': 'Kategorier', + 'categories.subtitle': 'Hantera kategorier för platser', + 'categories.new': 'Ny kategori', + 'categories.empty': 'Inga kategorier ännu', + 'categories.namePlaceholder': 'Kategorinamn', + 'categories.icon': 'Ikon', + 'categories.color': 'Färg', + 'categories.customColor': 'Välj egen färg', + 'categories.preview': 'Förhandsgranskning', + 'categories.defaultName': 'Kategori', + 'categories.update': 'Uppdatera', + 'categories.create': 'Skapa', + 'categories.confirm.delete': 'Ta bort kategorin? Platser i denna kategori kommer inte att tas bort.', + 'categories.toast.loadError': 'Det gick inte att ladda kategorierna', + 'categories.toast.nameRequired': 'Ange ett namn', + 'categories.toast.updated': 'Kategorin har uppdaterats', + 'categories.toast.created': 'Kategori skapad', + 'categories.toast.saveError': 'Det gick inte att spara', + 'categories.toast.deleted': 'Kategorin har tagits bort', + 'categories.toast.deleteError': 'Det gick inte att ta bort', +}; +export default categories; diff --git a/shared/src/i18n/sv/collab.ts b/shared/src/i18n/sv/collab.ts new file mode 100644 index 00000000..ec5f78a3 --- /dev/null +++ b/shared/src/i18n/sv/collab.ts @@ -0,0 +1,75 @@ +import type { TranslationStrings } from '../types'; + +const collab: TranslationStrings = { + 'collab.tabs.chat': 'Chatt', + 'collab.tabs.notes': 'Noteringar', + 'collab.tabs.polls': 'Omröstningar', + 'collab.whatsNext.title': "Vad händer härnäst?", + 'collab.whatsNext.today': 'Idag', + 'collab.whatsNext.tomorrow': 'I morgon', + 'collab.whatsNext.empty': 'Inga kommande aktiviteter', + 'collab.whatsNext.until': 'till', + 'collab.whatsNext.emptyHint': 'Här visas aktiviteter med angivna tider', + 'collab.chat.send': 'Skicka', + 'collab.chat.placeholder': 'Skriv ett meddelande...', + 'collab.chat.empty': 'Inled konversationen', + 'collab.chat.emptyHint': 'Meddelanden delas med alla deltagare i resan', + 'collab.chat.emptyDesc': 'Dela idéer, planer och nyheter med din resegrupp', + 'collab.chat.today': 'Idag', + 'collab.chat.yesterday': 'Igår', + 'collab.chat.deletedMessage': 'raderade ett meddelande', + 'collab.chat.reply': 'Svara', + 'collab.chat.loadMore': 'Ladda äldre meddelanden', + 'collab.chat.justNow': 'just nu', + 'collab.chat.minutesAgo': '{n}m sedan', + 'collab.chat.hoursAgo': '{n}h sedan', + 'collab.notes.title': 'Noteringar', + 'collab.notes.new': 'Ny notering', + 'collab.notes.empty': 'Inga anteckningar ännu', + 'collab.notes.emptyHint': 'Börja samla in idéer och planer', + 'collab.notes.all': 'Alla', + 'collab.notes.titlePlaceholder': 'Noterings titel', + 'collab.notes.contentPlaceholder': 'Skriv något...', + 'collab.notes.categoryPlaceholder': 'Kategori', + 'collab.notes.newCategory': 'Ny kategori...', + 'collab.notes.category': 'Kategori', + 'collab.notes.noCategory': 'Ingen kategori', + 'collab.notes.color': 'Färg', + 'collab.notes.save': 'Spara', + 'collab.notes.cancel': 'Avbryt', + 'collab.notes.edit': 'Redigera', + 'collab.notes.delete': 'Radera', + 'collab.notes.confirmDeleteTitle': 'Radera notering?', + 'collab.notes.confirmDeleteBody': 'Denna notering kommer raderas permanent', + 'collab.notes.pin': 'Fäst', + 'collab.notes.unpin': 'Ta bort fästningen', + 'collab.notes.daysAgo': '{n}d sedan', + 'collab.notes.categorySettings': 'Hantera kategorier', + 'collab.notes.create': 'Skapa', + 'collab.notes.website': 'Hemsida', + 'collab.notes.websitePlaceholder': 'https://...', + 'collab.notes.attachFiles': 'Bifoga filer', + 'collab.notes.noCategoriesYet': 'Inga kategorier ännu', + 'collab.notes.emptyDesc': 'Skapa en anteckning för att komma igång', + 'collab.polls.title': 'Omröstning', + 'collab.polls.new': 'Ny omröstning', + 'collab.polls.empty': 'Inga omröstningar ännu', + 'collab.polls.emptyHint': 'Fråga gruppen och rösta tillsammans', + 'collab.polls.question': 'Fråga', + 'collab.polls.questionPlaceholder': 'Vad ska vi göra?', + 'collab.polls.addOption': '+ Lägg till alternativ', + 'collab.polls.optionPlaceholder': 'Alternativ {n}', + 'collab.polls.create': 'Skapa omröstning', + 'collab.polls.close': 'Stäng', + 'collab.polls.closed': 'Stängd', + 'collab.polls.votes': '{n} röster', + 'collab.polls.vote': '{n} rösta', + 'collab.polls.multipleChoice': 'Flera val', + 'collab.polls.multiChoice': 'Flera val', + 'collab.polls.deadline': 'Tidsfrist', + 'collab.polls.option': 'Val', + 'collab.polls.options': 'Val', + 'collab.polls.delete': 'Ta bort', + 'collab.polls.closedSection': 'Stängd', +}; +export default collab; diff --git a/shared/src/i18n/sv/common.ts b/shared/src/i18n/sv/common.ts new file mode 100644 index 00000000..cd3ab0d9 --- /dev/null +++ b/shared/src/i18n/sv/common.ts @@ -0,0 +1,54 @@ +import type { TranslationStrings } from '../types'; + +const common: TranslationStrings = { + 'common.save': 'Spara', + 'common.showMore': 'Visa mer', + 'common.showLess': 'Visa mindre', + 'common.cancel': 'Avbryt', + 'common.clear': 'Rensa', + 'common.delete': 'Ta bort', + 'common.edit': 'Redigera', + 'common.add': 'Lägg till', + 'common.loading': 'Laddar...', + 'common.import': 'Importera', + 'common.select': 'Välj', + 'common.selectAll': 'Välj alla', + 'common.deselectAll': 'Avmarkera alla', + 'common.error': 'Fel', + 'common.unknownError': 'Okänt fel', + 'common.tooManyAttempts': 'För många försök. Försök igen senare.', + 'common.back': 'Tillbaka', + 'common.all': 'Alla', + 'common.close': 'Stäng', + 'common.open': 'Öppen', + 'common.upload': 'Ladda upp', + 'common.search': 'Sök', + 'common.confirm': 'Godkänn', + 'common.ok': 'OK', + 'common.yes': 'Ja', + 'common.no': 'Nej', + 'common.or': 'eller', + 'common.none': 'Ingen', + 'common.date': 'Datum', + 'common.rename': 'Döp om', + 'common.discardChanges': 'Ångra ändringar', + 'common.discard': 'Ångra', + 'common.name': 'Namn', + 'common.email': 'E-post', + 'common.password': 'Lösenord', + 'common.saving': 'Sparar...', + 'common.justNow': 'just nu', + 'common.hoursAgo': '{count}h sedan', + 'common.daysAgo': '{count}d sedan', + 'common.saved': 'Sparad', + 'common.update': 'Uppdatera', + 'common.change': 'Ändra', + 'common.uploading': 'Laddar upp…', + 'common.backToPlanning': 'Tillbaka till planering', + 'common.reset': 'Återställ', + 'common.expand': 'Expandera', + 'common.collapse': 'Dölj', + 'common.copy': 'Kopiera', + 'common.copied': 'Kopierad', +}; +export default common; diff --git a/shared/src/i18n/sv/dashboard.ts b/shared/src/i18n/sv/dashboard.ts new file mode 100644 index 00000000..42b1834f --- /dev/null +++ b/shared/src/i18n/sv/dashboard.ts @@ -0,0 +1,166 @@ +import type { TranslationStrings } from '../types'; + +const dashboard: TranslationStrings = { + 'dashboard.title': 'Mina resor', + 'dashboard.subtitle.loading': 'Laddar resor...', + 'dashboard.subtitle.trips': '{count} resor ({archived} arkiverad)', + 'dashboard.subtitle.empty': 'Påbörja din första resa', + 'dashboard.subtitle.activeOne': '{count} aktiv resa', + 'dashboard.subtitle.activeMany': '{count} aktiva resor', + 'dashboard.subtitle.archivedSuffix': ' · {count} arkiverad', + 'dashboard.newTrip': 'Ny resa', + 'dashboard.newTripSub': 'Planera en ny resa från grunden', + 'dashboard.gridView': 'Rutnätsvy', + 'dashboard.listView': 'Listvy', + 'dashboard.currency': 'Valuta', + 'dashboard.timezone': 'Tidszoner', + 'dashboard.localTime': 'Lokal', + 'dashboard.timezoneCustomTitle': 'Anpassad tidszon', + 'dashboard.timezoneCustomLabelPlaceholder': 'Etikett (valfritt)', + 'dashboard.timezoneCustomTzPlaceholder': 't.ex. America/New_York', + 'dashboard.timezoneCustomAdd': 'Lägg till', + 'dashboard.timezoneCustomErrorEmpty': 'Ange en tidszonskod', + 'dashboard.timezoneCustomErrorInvalid': 'Ogiltig tidszon. Använd ett format som Europe/Berlin', + 'dashboard.timezoneCustomErrorDuplicate': 'Redan lagts till', + 'dashboard.emptyTitle': 'Inga resor ännu', + 'dashboard.emptyText': 'Skapa din första resa och börja planera!', + 'dashboard.emptyButton': 'Skapa första resan', + 'dashboard.nextTrip': 'Nästa resa', + 'dashboard.shared': 'Delad', + 'dashboard.sharedBy': 'Delad av {name}', + 'dashboard.days': 'Dagar', + 'dashboard.places': 'Platser', + 'dashboard.members': 'Vänner', + 'dashboard.archive': 'Arkiv', + 'dashboard.copyTrip': 'Kopiera', + 'dashboard.copySuffix': 'kopiera', + 'dashboard.restore': 'Återställ', + 'dashboard.archived': 'Arkiverad', + 'dashboard.status.ongoing': 'Pågående', + 'dashboard.status.today': 'Idag', + 'dashboard.status.tomorrow': 'I morgon', + 'dashboard.status.past': 'Tidigare', + 'dashboard.status.daysLeft': '{count} dagar kvar', + 'dashboard.toast.loadError': 'Det gick inte att ladda resor', + 'dashboard.loadErrorBanner': "Det gick inte att ansluta till servern. Dina resor är säkra – försök igen.", + 'dashboard.retry': 'Försök igen', + 'dashboard.toast.created': 'Resan har skapats!', + 'dashboard.toast.createError': 'Det gick inte att skapa resan', + 'dashboard.toast.updated': 'Resan har uppdaterats!', + 'dashboard.toast.updateError': 'Det gick inte att uppdatera resan', + 'dashboard.toast.deleted': 'Resan har raderats', + 'dashboard.toast.deleteError': 'Det gick inte att ta bort resan', + 'dashboard.toast.archived': 'Resan har arkiverats', + 'dashboard.toast.archiveError': 'Det gick inte att arkivera resan', + 'dashboard.toast.restored': 'Resan har återställts', + 'dashboard.toast.restoreError': 'Det gick inte att återställa resan', + 'dashboard.toast.copied': 'Resan har kopierats!', + 'dashboard.toast.copyError': 'Det gick inte att kopiera resan', + 'dashboard.confirm.delete': 'Ta bort resa "{title}"? Alla platser och planer kommer att raderas permanent.', + 'dashboard.confirm.copy.title': 'Vill du kopiera den här resan?', + 'dashboard.confirm.copy.willCopy': 'Kommer att kopieras', + 'dashboard.confirm.copy.will1': 'Dagar, platser och dagsuppgifter', + 'dashboard.confirm.copy.will2': 'Boende och bokningar', + 'dashboard.confirm.copy.will3': 'Budgetposter och ordning på kategorierna', + 'dashboard.confirm.copy.will4': 'Packlistor (okontrollerade)', + 'dashboard.confirm.copy.will5': 'Uppgifter (ej tilldelade och ej avklarade)', + 'dashboard.confirm.copy.will6': 'Dagliga anteckningar', + 'dashboard.confirm.copy.wontCopy': "Kommer inte att kopieras", + 'dashboard.confirm.copy.wont1': 'Samarbetspartners och medlemmarnas uppdrag', + 'dashboard.confirm.copy.wont2': 'Samarbetsanteckningar, omröstningar och meddelanden', + 'dashboard.confirm.copy.wont3': 'Filer och foton', + 'dashboard.confirm.copy.wont4': 'Dela tokens', + 'dashboard.confirm.copy.confirm': 'Kopiera resan', + 'dashboard.editTrip': 'Redigera resa', + 'dashboard.createTrip': 'Skapa ny resa', + 'dashboard.tripTitle': 'Titel', + 'dashboard.tripTitlePlaceholder': 't.ex. Sommar i Japan', + 'dashboard.tripDescription': 'Beskrivning', + 'dashboard.tripDescriptionPlaceholder': 'Vad handlar den här resan om?', + 'dashboard.startDate': 'Startdatum', + 'dashboard.endDate': 'Slutdatum', + 'dashboard.dayCount': 'Antal dagar', + 'dashboard.dayCountHint': 'Hur många dagar ska man räkna med när inga resedatum är fastställda?', + 'dashboard.noDateHint': 'Inget datum angivet — 7 standarddagar kommer att skapas. Du kan ändra detta när som helst.', + 'dashboard.coverImage': 'Omslagsbild', + 'dashboard.addCoverImage': 'Lägg till omslagsbild (eller dra och släpp)', + 'dashboard.addMembers': 'Resekompisar', + 'dashboard.addMember': 'Lägg till medlem', + 'dashboard.coverSaved': 'Omslagsbild sparad', + 'dashboard.coverUploadError': 'Det gick inte att ladda upp', + 'dashboard.coverRemoveError': 'Det gick inte att ta bort', + 'dashboard.titleRequired': 'Titel är obligatoriskt', + 'dashboard.endDateError': 'Slutdatumet måste ligga efter startdatumet', + 'dashboard.greeting.morning': 'God morgon,', + 'dashboard.greeting.afternoon': 'God eftermiddag,', + 'dashboard.greeting.evening': 'God kväll,', + 'dashboard.mobile.liveNow': 'Lev nu', + 'dashboard.mobile.tripProgress': 'Resans förlopp', + 'dashboard.mobile.daysLeft': '{count} dagar kvar', + 'dashboard.mobile.places': 'Platser', + 'dashboard.mobile.buddies': 'Kompisar', + 'dashboard.mobile.newTrip': 'Ny resa', + 'dashboard.mobile.currency': 'Valuta', + 'dashboard.mobile.timezone': 'Tidszone', + 'dashboard.mobile.upcomingTrips': 'Kommande resor', + 'dashboard.mobile.yourTrips': 'Dina resor', + 'dashboard.mobile.trips': 'resor', + 'dashboard.mobile.starts': 'Börjar', + 'dashboard.mobile.duration': 'Längd', + 'dashboard.mobile.day': 'dag', + 'dashboard.mobile.days': 'dagar', + 'dashboard.mobile.ongoing': 'Pågår', + 'dashboard.mobile.startsToday': 'Börjar idag', + 'dashboard.mobile.tomorrow': 'I morgon', + 'dashboard.mobile.inDays': 'Inom {count} dagar', + 'dashboard.mobile.inMonths': 'Inom {count} månader', + 'dashboard.mobile.completed': 'Slutförd', + 'dashboard.mobile.currencyConverter': 'Valutaomvandlare', + 'dashboard.filter.planned': 'Planerad', + 'dashboard.hero.badgeLive': 'LEV NU', + 'dashboard.hero.badgeToday': 'BÖRJAR IDAG', + 'dashboard.hero.badgeTomorrow': 'I MORGON', + 'dashboard.hero.badgeNext': 'NÄSTA', + 'dashboard.hero.badgeRecent': 'SENASTE', + 'dashboard.hero.tripDates': 'Resedatum', + 'dashboard.hero.noDates': 'Tillgängliga datum', + 'dashboard.hero.travelerOne': '{count} resenär', + 'dashboard.hero.travelerMany': '{count} resenärer', + 'dashboard.hero.destinationOne': '{count} destionation', + 'dashboard.hero.destinationMany': '{count} destinationer', + 'dashboard.hero.dayUnitOne': 'dag', + 'dashboard.hero.dayUnitMany': 'dagar', + 'dashboard.hero.dayLeft': 'Dag kvar', + 'dashboard.hero.daysLeft': 'Dagar kvar', + 'dashboard.hero.lastDay': 'Sista dagen', + 'dashboard.hero.untilStart': 'Fram till start', + 'dashboard.hero.startsIn': 'Resan börjar i', + 'dashboard.atlas.countriesVisited': 'Atlas · Besökta länder', + 'dashboard.atlas.ofTotal': 'av {total}', + 'dashboard.atlas.tripsTotal': 'Resor totalt', + 'dashboard.atlas.placesMapped': '{count} platser kartlagda', + 'dashboard.atlas.daysTraveled': 'Dagar rest', + 'dashboard.atlas.daysUnit': 'dagar', + 'dashboard.atlas.acrossAllTrips': 'för alla resor', + 'dashboard.atlas.distanceFlown': 'Flygsträcka', + 'dashboard.atlas.kmUnit': 'km', + 'dashboard.atlas.aroundEquator': '≈ {count}× runt ekvatorn', + 'dashboard.card.idea': 'Idé', + 'dashboard.card.buddyOne': 'Kompis', + 'dashboard.fx.from': 'Från', + 'dashboard.fx.to': 'Till', + 'dashboard.fx.unavailable': 'Pris ej tillgängligt', + 'dashboard.tz.searchPlaceholder': 'Sök efter tidszon…', + 'dashboard.tz.empty': 'Inga andra tidszoner än så länge — lägg till en med +', + 'dashboard.upcoming.title': 'Kommande bokningar', + 'dashboard.upcoming.empty': 'Inget är bokat än.', + 'dashboard.aria.toggleView': 'Växla vy', + 'dashboard.aria.filter': 'Filtrera', + 'dashboard.aria.duplicate': 'Duplicera', + 'dashboard.aria.refreshRates': 'Uppdatera kurser', + 'dashboard.aria.swapCurrencies': 'Växla valutor', + 'dashboard.aria.addTimezone': 'Lägg till tidszon', + 'dashboard.aria.removeTimezone': 'Ta bort {city}', + 'dashboard.dayCountRequired': 'Antalet dagar måste anges', +}; +export default dashboard; diff --git a/shared/src/i18n/sv/day.ts b/shared/src/i18n/sv/day.ts new file mode 100644 index 00000000..56d9b92a --- /dev/null +++ b/shared/src/i18n/sv/day.ts @@ -0,0 +1,25 @@ +import type { TranslationStrings } from '../types'; + +const day: TranslationStrings = { + 'day.precipProb': 'Sannolikhet för regn', + 'day.precipitation': 'Nederbörd', + 'day.wind': 'Vind', + 'day.sunrise': 'Soluppgång', + 'day.sunset': 'Solnedgång', + 'day.hourlyForecast': 'Prognos per timme', + 'day.climateHint': 'Historiska medelvärden — prognos i realtid tillgänglig inom 16 dagar från detta datum.', + 'day.noWeather': 'Det finns inga väderuppgifter tillgängliga. Lägg till en plats med koordinater.', + 'day.overview': 'Daglig översikt', + 'day.accommodation': 'Boende', + 'day.addAccommodation': 'Lägg till boende', + 'day.hotelDayRange': 'Tillämpa på dagar', + 'day.noPlacesForHotel': 'Lägg först till platser i din resa', + 'day.allDays': 'Alla', + 'day.checkIn': 'Incheckning', + 'day.checkInUntil': 'Tills', + 'day.checkOut': 'Utcheckning', + 'day.confirmation': 'Bekräftelse', + 'day.editAccommodation': 'Redigera boende', + 'day.reservations': 'Bokningar', +}; +export default day; diff --git a/shared/src/i18n/sv/dayplan.ts b/shared/src/i18n/sv/dayplan.ts new file mode 100644 index 00000000..7561a2c3 --- /dev/null +++ b/shared/src/i18n/sv/dayplan.ts @@ -0,0 +1,55 @@ +import type { TranslationStrings } from '../types'; + +const dayplan: TranslationStrings = { + 'dayplan.icsTooltip': 'Exportera kalender (ICS)', + 'dayplan.emptyDay': 'Inga platser planerade för denna dag', + 'dayplan.cannotReorderTransport': 'Bokningar med fast tid kan inte omordnas', + 'dayplan.confirmRemoveTimeTitle': 'Ta bort tid?', + 'dayplan.confirmRemoveTimeBody': + 'Denna plats har fasta tider ({time}). Om du flyttar den tas tidsangivelsen bort och du kan sortera fritt.', + 'dayplan.confirmRemoveTimeAction': 'Ta bort tid och flytta', + 'dayplan.confirmDeleteNoteTitle': 'Ta bort notering?', + 'dayplan.confirmDeleteNoteBody': 'Denna anteckning kommer att raderas permanent.', + 'dayplan.cannotDropOnTimed': 'Objekt kan inte placeras mellan tidsbundna poster', + 'dayplan.cannotBreakChronology': 'Detta skulle störa den kronologiska ordningen för tidsbestämda poster och bokningar', + 'dayplan.addNote': 'Lägg till notering', + 'dayplan.expandAll': 'Expandera alla dagar', + 'dayplan.collapseAll': 'Dölj alla dagar', + 'dayplan.editNote': 'Redigera notering', + 'dayplan.noteAdd': 'Lägg till notering', + 'dayplan.noteEdit': 'Redigera notering', + 'dayplan.noteTitle': 'Notering', + 'dayplan.noteSubtitle': 'Daglig notering', + 'dayplan.totalCost': 'Totala kostnaden', + 'dayplan.days': 'Dagar', + 'dayplan.dayN': 'Dag {n}', + 'dayplan.calculating': 'Beräknar...', + 'dayplan.route': 'Rutt', + 'dayplan.optimize': 'Optimera', + 'dayplan.optimized': 'Rutt optimerad', + 'dayplan.routeError': 'Det gick inte att beräkna rutten', + 'dayplan.toast.needTwoPlaces': 'Minst två platser krävs för ruttoptimering', + 'dayplan.toast.routeOptimized': 'Ruttoptimerad', + 'dayplan.toast.routeOptimizedFromHotel': 'Ruttoptimerad utifrån ditt boende', + 'dayplan.toast.noGeoPlaces': 'Inga platser med koordinater hittades för ruttberäkningen', + 'dayplan.confirmed': 'Bekräftat', + 'dayplan.pendingRes': 'Pendlande', + 'dayplan.pdf': 'PDF', + 'dayplan.pdfTooltip': 'Exportera dagsplanen som PDF', + 'dayplan.pdfError': 'Kunde inte exportera PDF', + 'dayplan.mobile.addPlace': 'Lägg itll plats', + 'dayplan.mobile.searchPlaces': 'Sök platser...', + 'dayplan.mobile.allAssigned': 'Alla platser tilldelade', + 'dayplan.mobile.noMatch': 'Inga träffar', + 'dayplan.mobile.createNew': 'Skapa ny plats', + 'dayplan.reorderDays': 'Sortera om dagar', + 'dayplan.reorderTitle': 'Sortera om dagar', + 'dayplan.reorderHint': "Dagens platser, anteckningar och bokningar följer med.", + 'dayplan.addDay': 'Lägg till dag', + 'dayplan.moveUp': 'Flytta upp', + 'dayplan.moveDown': 'Flytta ner', + 'dayplan.reorderUndo': 'Sortera om dagar', + 'dayplan.reorderError': 'Kunde inte sortera om dagar', + 'dayplan.addDayError': 'Kunde inte lägga till dag', +}; +export default dayplan; diff --git a/shared/src/i18n/sv/externalNotifications.ts b/shared/src/i18n/sv/externalNotifications.ts new file mode 100644 index 00000000..cf25830a --- /dev/null +++ b/shared/src/i18n/sv/externalNotifications.ts @@ -0,0 +1,62 @@ +import type { NotificationLocale } from '../externalNotifications/types'; + +const en: NotificationLocale = { + email: { + footer: 'Du har fått detta eftersom du har aktiverat aviseringar i TREK.', + manage: 'Hantera egenskaper under Inställningar', + madeWith: 'Gjorde med', + openTrek: 'Öppna TREK', + }, + events: { + trip_invite: (p) => ({ + title: `Reseinbjudan: "${p.trip}"`, + body: `${p.actor} bjöd in ${p.invitee || 'en medlem'} till resan "${p.trip}".`, + }), + booking_change: (p) => ({ + title: `Ny bokning: ${p.booking}`, + body: `${p.actor} la till en ny ${p.type} "${p.booking}" till "${p.trip}".`, + }), + trip_reminder: (p) => ({ + title: `Resepåminnelse: ${p.trip}`, + body: `Din resa "${p.trip}" kommer snart!`, + }), + todo_due: (p) => ({ + title: `Uppgift förfaller: ${p.todo}`, + body: `"${p.todo}" i "${p.trip}" förfaller den ${p.due}.`, + }), + vacay_invite: (p) => ({ + title: 'Vacay sammanslagnings inbjudan', + body: `${p.actor} bjöd in dig att slå samman semesterplaner. Öppna TREK för att acceptera eller avvisa.`, + }), + photos_shared: (p) => ({ + title: `${p.count} foton delade`, + body: `${p.actor} delade ${p.count} foto(n) i "${p.trip}".`, + }), + collab_message: (p) => ({ + title: `Nytt meddelande i "${p.trip}"`, + body: `${p.actor}: ${p.preview}`, + }), + packing_tagged: (p) => ({ + title: `Packning: ${p.category}`, + body: `${p.actor} tilldelade dig till "${p.category}" packning kategori i "${p.trip}".`, + }), + version_available: (p) => ({ + title: 'Ny TREK version tillgänglig', + body: `TREK ${p.version} är nu tillgänglig. Gå till adminpanelen för att uppdatera.`, + }), + synology_session_cleared: () => ({ + title: 'Synology session rensad', + body: 'Ditt Synology-konto eller din webbadress har ändrats. Du har loggats ut från Synology Photos.', + }), + }, + passwordReset: { + subject: 'Återställ ditt lösenord', + greeting: 'Hej', + body: 'Vi har fått en begäran om att återställa lösenordet till ditt TREK konto. Klicka på knappen nedan för att ange ett nytt lösenord.', + ctaIntro: 'Återställ lösenord', + expiry: 'Den här länken upphör att gälla om 60 minuter.', + ignore: "Om du inte har begärt detta kan du lugnt strunta i det här e-postmeddelandet – ditt lösenord kommer inte att ändras.", + }, +}; + +export default en; diff --git a/shared/src/i18n/sv/files.ts b/shared/src/i18n/sv/files.ts new file mode 100644 index 00000000..c4778446 --- /dev/null +++ b/shared/src/i18n/sv/files.ts @@ -0,0 +1,59 @@ +import type { TranslationStrings } from '../types'; + +const files: TranslationStrings = { + 'files.title': 'Filer', + 'files.pageTitle': 'Filer & Dokument', + 'files.subtitle': '{count} filer för {trip}', + 'files.download': 'Ladda ner', + 'files.openError': 'Kunde inte öppna fil', + 'files.downloadPdf': 'Ladda ner PDF', + 'files.count': '{count} filer', + 'files.countSingular': '1 fil', + 'files.uploaded': '{count} uppladdade', + 'files.uploadError': 'Uppladdning misslyckades', + 'files.dropzone': 'Släpp filer här', + 'files.dropzoneHint': 'eller klicka för att bläddra', + 'files.allowedTypes': 'Foton, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Högst 50 MB', + 'files.uploading': 'Laddar upp...', + 'files.filterAll': 'Alla', + 'files.filterPdf': 'PDFs', + 'files.filterImages': 'Bilder', + 'files.filterDocs': 'Dokument', + 'files.filterCollab': 'Samarbetsanteckningar', + 'files.sourceCollab': 'Från samarbetsanteckningar', + 'files.empty': 'Inga filer ännu', + 'files.emptyHint': 'Ladda upp filer för att bifoga dem till din resa', + 'files.openTab': 'Öppna i ny flik', + 'files.confirm.delete': 'Är du säker på att du vill ta bort den här filen?', + 'files.toast.deleted': 'Fil borttagen', + 'files.toast.deleteError': 'Kunde inte ta bort filen', + 'files.sourcePlan': 'Dagsplan', + 'files.sourceBooking': 'Bokning', + 'files.sourceTransport': 'Transport', + 'files.attach': 'Bifoga', + 'files.pasteHint': 'Du kan också klistra in foton från urklipp (Ctrl+V)', + 'files.trash': 'Papperskorgen', + 'files.trashEmpty': 'papperskorgen är tom', + 'files.emptyTrash': 'Töm papperskorgen', + 'files.restore': 'Återställ', + 'files.star': 'Stjärnmarkera', + 'files.unstar': 'Ta bort stjärnmarkering', + 'files.assign': 'Tilldela', + 'files.assignTitle': 'Tilldela fil', + 'files.assignPlace': 'Plats', + 'files.assignBooking': 'Bokning', + 'files.assignTransport': 'Transport', + 'files.unassigned': 'Ej tilldelad', + 'files.unlink': 'Ta bort länk', + 'files.toast.trashed': 'Flyttad till papperskorgen', + 'files.toast.restored': 'Fil återställd', + 'files.toast.trashEmptied': 'papperskorgen tömd', + 'files.toast.assigned': 'Fil tilldelad', + 'files.toast.assignError': 'Tilldelning misslyckades', + 'files.toast.restoreError': 'Återställning misslyckades', + 'files.confirm.permanentDelete': 'Vill du ta bort den här filen permanent? Detta går inte att ångra.', + 'files.confirm.emptyTrash': 'Vill du radera alla filer i papperskorgen permanent? Detta går inte att ångra.', + 'files.noteLabel': 'Notering', + 'files.notePlaceholder': 'Lägg till en notering...', +}; +export default files; diff --git a/shared/src/i18n/sv/index.ts b/shared/src/i18n/sv/index.ts new file mode 100644 index 00000000..77aeb6a0 --- /dev/null +++ b/shared/src/i18n/sv/index.ts @@ -0,0 +1,86 @@ +import admin from './admin'; +import airport from './airport'; +import atlas from './atlas'; +import backup from './backup'; +import budget from './budget'; +import categories from './categories'; +import collab from './collab'; +import common from './common'; +import dashboard from './dashboard'; +import day from './day'; +import dayplan from './dayplan'; +import files from './files'; +import inspector from './inspector'; +import journey from './journey'; +import login from './login'; +import map from './map'; +import members from './members'; +import memories from './memories'; +import nav from './nav'; +import notif from './notif'; +import notifications from './notifications'; +import oauth from './oauth'; +import packing from './packing'; +import pdf from './pdf'; +import perm from './perm'; +import photos from './photos'; +import places from './places'; +import planner from './planner'; +import register from './register'; +import reservations from './reservations'; +import settings from './settings'; +import share from './share'; +import shared from './shared'; +import stats from './stats'; +import system_notice from './system_notice'; +import todo from './todo'; +import transport from './transport'; +import trip from './trip'; +import trips from './trips'; +import undo from './undo'; +import vacay from './vacay'; + +const locale = { + ...common, + ...trips, + ...nav, + ...dashboard, + ...settings, + ...admin, + ...dayplan, + ...share, + ...shared, + ...login, + ...register, + ...vacay, + ...atlas, + ...trip, + ...places, + ...inspector, + ...reservations, + ...airport, + ...map, + ...budget, + ...files, + ...packing, + ...members, + ...categories, + ...backup, + ...photos, + ...pdf, + ...planner, + ...stats, + ...day, + ...memories, + ...collab, + ...perm, + ...undo, + ...notifications, + ...todo, + ...notif, + ...journey, + ...oauth, + ...system_notice, + ...transport, +}; +export default locale; diff --git a/shared/src/i18n/sv/inspector.ts b/shared/src/i18n/sv/inspector.ts new file mode 100644 index 00000000..787d2c9f --- /dev/null +++ b/shared/src/i18n/sv/inspector.ts @@ -0,0 +1,22 @@ +import type { TranslationStrings } from '../types'; + +const inspector: TranslationStrings = { + 'inspector.opened': 'Öppen', + 'inspector.closed': 'Stängd', + 'inspector.openingHours': 'Öppettider', + 'inspector.showHours': 'Visa öppettider', + 'inspector.files': 'Filer', + 'inspector.filesCount': '{count} filer', + 'inspector.remove': 'Ta bort', + 'inspector.removeFromDay': 'Ta bort från dag', + 'inspector.addToDay': 'Lägg till i dagen', + 'inspector.confirmedRes': 'Bekräftad bokning', + 'inspector.pendingRes': 'Pendlande bokning', + 'inspector.google': 'Öppna i Google Maps', + 'inspector.website': 'Öppna hemsida', + 'inspector.addRes': 'Bokning', + 'inspector.editRes': 'Redigera bokning', + 'inspector.participants': 'Deltagare', + 'inspector.trackStats': 'Spåra statistik', +}; +export default inspector; diff --git a/shared/src/i18n/sv/journey.ts b/shared/src/i18n/sv/journey.ts new file mode 100644 index 00000000..2856db61 --- /dev/null +++ b/shared/src/i18n/sv/journey.ts @@ -0,0 +1,231 @@ +import type { TranslationStrings } from '../types'; + +const journey: TranslationStrings = { + 'journey.search.placeholder': 'Sök journeys…', + 'journey.search.noResults': 'Inga journeys matchar "{query}"', + 'journey.title': 'Journey', + 'journey.subtitle': 'Följ dina resor i realtid', + 'journey.new': 'Ny Journey', + 'journey.create': 'Skapa', + 'journey.titlePlaceholder': 'Vart ska du?', + 'journey.empty': 'Inga journeys än', + 'journey.emptyHint': 'Börja dokumentera din nästa resa', + 'journey.deleted': 'Journey borttagen', + 'journey.createError': 'Kunde inte skapa journey', + 'journey.deleteError': 'Kunde inte ta bort journey', + 'journey.deleteConfirmTitle': 'Ta bort', + 'journey.deleteConfirmMessage': 'Ta bort "{title}"? Detta kan inte ångras.', + 'journey.deleteConfirmGeneric': 'Är du säker på att du vill ta bort denna?', + 'journey.notFound': 'Journey hittades inte', + 'journey.photos': 'Foton', + 'journey.timelineEmpty': 'Inga stopp än så länge', + 'journey.timelineEmptyHint': 'Lägg till en incheckning eller skriv ett dagboksinlägg för att komma igång', + 'journey.status.draft': 'Utkast', + 'journey.status.active': 'Aktiv', + 'journey.status.completed': 'Slutförd', + 'journey.status.upcoming': 'Kommande', + 'journey.status.archived': 'Arkiverat', + 'journey.checkin.add': 'Checka in', + 'journey.checkin.namePlaceholder': 'Platsnamn', + 'journey.checkin.notesPlaceholder': 'Noteringar (valfritt)', + 'journey.checkin.save': 'Spara', + 'journey.checkin.error': 'Kunde inte spara incheckning', + 'journey.entry.add': 'Dagboksinlägg', + 'journey.entry.edit': 'Redigera inlägg', + 'journey.entry.titlePlaceholder': 'Titel (valfritt)', + 'journey.entry.bodyPlaceholder': 'Vad hände idag?', + 'journey.entry.save': 'Spara', + 'journey.entry.error': 'Kunde inte spara inlägg', + 'journey.photo.add': 'Foto', + 'journey.photo.uploadError': 'Uppladdning misslyckades', + 'journey.share.share': 'Dela', + 'journey.share.public': 'Offentlig', + 'journey.share.linkCopied': 'Offentlig länk kopierad', + 'journey.share.disabled': 'Offentlig delning avaktiverad', + 'journey.editor.titlePlaceholder': 'Ge det här ögonblicket ett namn...', + 'journey.editor.bodyPlaceholder': 'Berätta om den här dagen...', + 'journey.editor.placePlaceholder': 'Plats (valfritt)', + 'journey.editor.tagsPlaceholder': 'Taggar: en dold pärla, den bästa måltiden, måste återvända...', + 'journey.visibility.private': 'Privat', + 'journey.visibility.shared': 'Delad', + 'journey.visibility.public': 'Offentlig', + 'journey.emptyState.title': 'Din berättelse börjar här', + 'journey.emptyState.subtitle': 'Checka in på en plats eller skriv ditt första dagboksinlägg', + 'journey.frontpage.subtitle': "Förvandla dina resor till minnen du aldrig kommer att glömma", + 'journey.frontpage.createJourney': 'Skapa Journey', + 'journey.frontpage.activeJourney': 'Aktiv Journey', + 'journey.frontpage.allJourneys': 'Alla Journeys', + 'journey.frontpage.journeys': 'journeys', + 'journey.frontpage.createNew': 'Skapa en ny Journey', + 'journey.frontpage.createNewSub': 'Välj resor, skriv berättelser, dela med dig av dina äventyr', + 'journey.frontpage.live': 'Live', + 'journey.frontpage.synced': 'Synkad', + 'journey.frontpage.continueWriting': 'Fortsätt skriva', + 'journey.frontpage.updated': 'Uppdaterad {time}', + 'journey.frontpage.suggestionLabel': 'Resan har just avslutats', + 'journey.frontpage.suggestionText': 'Förvandla {title} till en Journey', + 'journey.frontpage.dismiss': 'Stäng', + 'journey.frontpage.journeyName': 'Journey namn', + 'journey.frontpage.namePlaceholder': 't.ex. Sydostasien 2026', + 'journey.frontpage.selectTrips': 'Välj resor', + 'journey.frontpage.tripsSelected': 'resor markerade', + 'journey.frontpage.trips': 'resor', + 'journey.frontpage.placesImported': 'platser kommer att importeras', + 'journey.frontpage.places': 'platser', + 'journey.detail.backToJourney': 'Tillbaka till Journey', + 'journey.detail.syncedWithTrips': 'Synkroniserat med resor', + 'journey.detail.addEntry': 'Lägg till inlägg', + 'journey.detail.newEntry': 'Ny inlägg', + 'journey.detail.editEntry': 'Redigera inlägg', + 'journey.detail.noEntries': 'Inga inlägg än så länge', + 'journey.detail.noEntriesHint': 'Lägg till en resa för att komma igång med utkast', + 'journey.detail.noPhotos': 'Inga foton ännu', + 'journey.detail.noPhotosHint': 'Ladda upp foton till inlägg eller bläddra i ditt Immich/Synology-bibliotek', + 'journey.detail.journeyTab': 'Journey', + 'journey.detail.journeyStats': 'Journey statistik', + 'journey.detail.syncedTrips': 'Synkroniserade resor', + 'journey.detail.noTripsLinked': 'Inga resor har kopplats ännu', + 'journey.detail.contributors': 'Medverkande', + 'journey.detail.readMore': 'Läs mer', + 'journey.detail.prosCons': 'För- och nackdelar', + 'journey.detail.photos': 'foton', + 'journey.detail.day': 'Dag {number}', + 'journey.detail.places': 'platser', + 'journey.stats.days': 'Dagar', + 'journey.stats.cities': 'Städer', + 'journey.stats.entries': 'Inlägg', + 'journey.stats.photos': 'Foton', + 'journey.stats.places': 'Platser', + 'journey.skeletons.show': 'Visa förslag', + 'journey.skeletons.hide': 'Dölj förslag', + 'journey.verdict.lovedIt': 'Älskade det', + 'journey.verdict.couldBeBetter': 'Kan bli bättre', + 'journey.synced.places': 'platser', + 'journey.synced.synced': 'synkade', + 'journey.editor.discardChangesConfirm': 'Du har ändringar som inte har sparats. Vill du avbryta dem?', + 'journey.editor.uploadFailed': 'Foto uppladdning misslyckades', + 'journey.editor.uploadPhotos': 'Ladda upp foton', + 'journey.editor.uploading': 'Laddar upp...', + 'journey.editor.uploadingProgress': 'Laddar upp {done}/{total}…', + 'journey.editor.uploadPartialFailed': '{failed} av {total} foton misslyckades — spara igen för att försoka igen', + 'journey.editor.fromGallery': 'Från album', + 'journey.editor.allPhotosAdded': 'Alla foton är redan tillagda', + 'journey.editor.writeStory': 'Skriv din berättelse...', + 'journey.editor.prosCons': 'För- och nackdelar', + 'journey.editor.pros': 'Fördelar', + 'journey.editor.cons': 'Nackdelar', + 'journey.editor.proPlaceholder': 'Något fantastiskt...', + 'journey.editor.conPlaceholder': 'Inte så bra...', + 'journey.editor.addAnother': 'Lägg till en annan', + 'journey.editor.date': 'Datum', + 'journey.editor.location': 'Plats', + 'journey.editor.searchLocation': 'Sök plats...', + 'journey.editor.mood': 'Humör', + 'journey.editor.weather': 'Väder', + 'journey.editor.photoFirst': '1:a', + 'journey.editor.makeFirst': 'Gör 1:a', + 'journey.editor.searching': 'Söker...', + 'journey.mood.amazing': 'Fantastiskt', + 'journey.mood.good': 'Bra', + 'journey.mood.neutral': 'Neutral', + 'journey.mood.rough': 'Grov', + 'journey.weather.sunny': 'Soligt', + 'journey.weather.partly': 'Delvis molnigt', + 'journey.weather.cloudy': 'Molnigt', + 'journey.weather.rainy': 'Regnigt', + 'journey.weather.stormy': 'Stormigt', + 'journey.weather.cold': 'Snöigt', + 'journey.trips.linkTrip': 'Länka resa', + 'journey.trips.searchTrip': 'Sök resa', + 'journey.trips.searchPlaceholder': 'Resans namn eller resmål...', + 'journey.trips.noTripsAvailable': 'Inga resor tillgängliga', + 'journey.trips.link': 'Länka', + 'journey.trips.tripLinked': 'Resa länkat', + 'journey.trips.linkFailed': 'Kunde inte länka resa', + 'journey.trips.addTrip': 'Lägg till resa', + 'journey.trips.unlinkTrip': 'Koppla ifrån resa', + 'journey.trips.unlinkMessage': + 'Koppla ifrån "{title}"? Alla synkroniserade poster och foton från den här resan kommer att raderas permanent. Detta går inte att ångra.', + 'journey.trips.unlink': 'Koppla ifrån', + 'journey.trips.tripUnlinked': 'Resa frånkopplad', + 'journey.trips.unlinkFailed': 'Det gick inte att ta bort kopplingen till resan', + 'journey.trips.noTripsLinkedSettings': 'Inga resor kopplade', + 'journey.contributors.invite': 'Bjud in medverkande', + 'journey.contributors.searchUser': 'Sök användare', + 'journey.contributors.searchPlaceholder': 'Användarnamn eller epost...', + 'journey.contributors.noUsers': 'Inga användare hittades', + 'journey.contributors.role': 'Roll', + 'journey.contributors.added': 'Medverkare tillagd', + 'journey.contributors.addFailed': 'Kunde inte lägga till medverkare', + 'journey.contributors.remove': 'Ta bort medverkare', + 'journey.contributors.removeConfirm': 'Ta bort {username} från denna journey?', + 'journey.contributors.removed': 'Medverkare borttagen', + 'journey.contributors.removeFailed': 'Kunde inte ta bort medverkare', + 'journey.share.publicShare': 'Offentlig delning', + 'journey.share.createLink': 'Skapa en delningslänk', + 'journey.share.linkCreated': 'Länk för delning har skapats', + 'journey.share.createFailed': 'Kunde inte skapa en delningslänk', + 'journey.share.copy': 'Kopiera', + 'journey.share.copied': 'Kopierad!', + 'journey.share.timeline': 'Tidslinje', + 'journey.share.gallery': 'Album', + 'journey.share.map': 'Karta', + 'journey.share.removeLink': 'Ta bort delningslänk', + 'journey.share.linkDeleted': 'Delningslänk borttagen', + 'journey.share.deleteFailed': 'Kunde inte ta bort', + 'journey.share.updateFailed': 'Kunde inte uppdatera', + 'journey.invite.role': 'Roll', + 'journey.invite.viewer': 'Visare', + 'journey.invite.editor': 'Redaktör', + 'journey.invite.invite': 'Bjud in', + 'journey.invite.inviting': 'Bjuder in...', + 'journey.settings.title': 'Journey Inställningar', + 'journey.settings.coverImage': 'Omslagsbild', + 'journey.settings.changeCover': 'Ändra omslag', + 'journey.settings.addCover': 'Lägg till omslagsbild', + 'journey.settings.name': 'Namn', + 'journey.settings.subtitle': 'Undertext', + 'journey.settings.subtitlePlaceholder': 't.ex. Thailand, Vietnam och Kambodja', + 'journey.settings.endJourney': 'Arkivera Journey', + 'journey.settings.reopenJourney': 'Återställ Journey', + 'journey.settings.archived': 'Journey arkiverad', + 'journey.settings.reopened': 'Journey återöppnad', + 'journey.settings.endDescription': 'Döljer Live märket. Du kan öppna det igen när som helst.', + 'journey.settings.delete': 'Ta bort', + 'journey.settings.deleteJourney': 'Ta bort Journey', + 'journey.settings.deleteMessage': 'Ta bort "{title}"? Alla inlägg och foton kommer att raderas.', + 'journey.settings.saved': 'Inställningar sparade', + 'journey.settings.saveFailed': 'Kunde inte spara', + 'journey.settings.coverUpdated': 'Omslag uppdaterad', + 'journey.settings.coverFailed': 'Uppladdning misslyckades', + 'journey.settings.failedToDelete': 'Kunde inte ta bort', + 'journey.entries.deleteTitle': 'Ta bort inlägg', + 'journey.photosUploaded': '{count} foton uppladdade', + 'journey.photosUploadFailed': 'Några foton kunde inte laddas upp', + 'journey.photosAdded': '{count} foton tillagd', + 'journey.public.notFound': 'Hittades inte', + 'journey.public.notFoundMessage': "Denna journey existerar inte eller länken har utgått.", + 'journey.public.readOnly': 'Skrivskyddad · Offentlig Journey', + 'journey.public.tagline': 'Reseresurser och upptäcktspaket', + 'journey.public.sharedVia': 'Dela via', + 'journey.public.madeWith': 'Gjord med', + 'journey.pdf.journeyBook': 'Journey bok', + 'journey.pdf.madeWith': 'Gjord med TREK', + 'journey.pdf.day': 'Dag', + 'journey.pdf.theEnd': 'Slut', + 'journey.pdf.saveAsPdf': 'Spara som PDF', + 'journey.pdf.pages': 'sidor', + 'journey.picker.tripPeriod': 'Reseperiod', + 'journey.picker.dateRange': 'Datumintervall', + 'journey.picker.allPhotos': 'Alla foton', + 'journey.picker.albums': 'Album', + 'journey.picker.selected': 'markerade', + 'journey.picker.addTo': 'Lägg till i', + 'journey.picker.newGallery': 'Nytt album', + 'journey.picker.selectAll': 'Markera alla', + 'journey.picker.deselectAll': 'Avmarkera alla', + 'journey.picker.noAlbums': 'Inga album hittades', + 'journey.picker.selectDate': 'Välj datum', + 'journey.picker.search': 'Sök', +}; +export default journey; diff --git a/shared/src/i18n/sv/login.ts b/shared/src/i18n/sv/login.ts new file mode 100644 index 00000000..fbe6fdbc --- /dev/null +++ b/shared/src/i18n/sv/login.ts @@ -0,0 +1,89 @@ +import type { TranslationStrings } from '../types'; + +const login: TranslationStrings = { + 'login.error': 'Inloggningen misslyckades. Kontrollera dina inloggningsuppgifter.', + 'login.tagline': 'Dina resor.\nDin plan.', + 'login.description': 'Planera resor tillsammans med hjälp av interaktiva kartor, budgetar och synkronisering i realtid.', + 'login.features.maps': 'Interaktiva kartor', + 'login.features.mapsDesc': 'Google Places, rutter och klusterbildning', + 'login.features.realtime': 'Synkronisering i realtid', + 'login.features.realtimeDesc': 'Planera tillsammans via WebSocket', + 'login.features.budget': 'Budgetuppföljning', + 'login.features.budgetDesc': 'Kategorier, diagram och kostnader per person', + 'login.features.collab': 'Samarbete', + 'login.features.collabDesc': 'Flera användare med delade resor', + 'login.features.packing': 'Packlistor', + 'login.features.packingDesc': 'Kategorier, framsteg och förslag', + 'login.features.bookings': 'Bokningar', + 'login.features.bookingsDesc': 'Flyg, hotell, restauranger och mycket mer', + 'login.features.files': 'Dokument', + 'login.features.filesDesc': 'Ladda upp och hantera dokument', + 'login.features.routes': 'Smarta rutter', + 'login.features.routesDesc': 'Automatisk optimering och export till Google Maps', + 'login.selfHosted': 'Egenhostad · Öppen källkod · Dina data förblir dina', + 'login.title': 'Logga in', + 'login.subtitle': 'Välkommen tillbaka', + 'login.signingIn': 'Loggar in…', + 'login.signIn': 'Logga in', + 'login.createAdmin': 'Skapa administratörskonto', + 'login.createAdminHint': 'Skapa det första administratörskontot för TREK.', + 'login.setNewPassword': 'Ställ in nytt lösenord', + 'login.setNewPasswordHint': 'Du måste byta lösenord innan du fortsätter.', + 'login.createAccount': 'Skapa konto', + 'login.createAccountHint': 'Skapa ett nytt konto.', + 'login.creating': 'Skapar…', + 'login.noAccount': "Har du inget konto?", + 'login.hasAccount': 'Har du redan ett konto?', + 'login.register': 'Registrera dig', + 'login.emailPlaceholder': 'din@epost.com', + 'login.username': 'Användarnamn', + 'login.oidc.registrationDisabled': 'Registreringen är inaktiverad. Kontakta din administratör.', + 'login.oidc.noEmail': 'Inget e-postmeddelande har mottagits från leverantören.', + 'login.oidc.tokenFailed': 'Autentiseringen misslyckades.', + 'login.oidc.invalidState': 'Ogiltig session. Försök igen.', + 'login.demoFailed': 'Inloggningen för demoversionen misslyckades', + 'login.oidcSignIn': 'Logga in med {name}', + 'login.oidcOnly': 'Lösenordsautentisering är inaktiverad. Logga in via din SSO-leverantör.', + 'login.oidcLoggedOut': 'Du har loggats ut. Logga in igen via din SSO-leverantör.', + 'login.demoHint': 'Prova demoversionen – ingen registrering krävs', + 'login.mfaTitle': 'Tvåfaktorsautentisering', + 'login.mfaSubtitle': 'Ange den 6-siffriga koden från din autentiseringsapp.', + 'login.mfaCodeLabel': 'Verifieringskod', + 'login.mfaCodeRequired': 'Ange koden från din autentiseringsapp.', + 'login.mfaHint': 'Öppna Google Authenticator, Authy eller någon annan TOTP-app.', + 'login.mfaBack': '← Tillbaka till inloggningen', + 'login.mfaVerify': 'Verifiera', + 'login.invalidInviteLink': 'Ogiltig eller utgången inbjudningslänk', + 'login.oidcFailed': 'Inloggningen på OIDC misslyckades', + 'login.usernameRequired': 'Användarnamn krävs', + 'login.passwordMinLength': 'Lösenordet måste bestå av minst 8 tecken', + 'login.forgotPassword': 'Har du glömt lösenordet?', + 'login.rememberMe': 'Kom ihåg mig', + 'login.forgotPasswordTitle': 'Återställ ditt lösenord', + 'login.forgotPasswordBody': + "Ange den e-postadress du använde när du registrerade dig. Om det finns ett konto skickar vi en länk för att återställa lösenordet.", + 'login.forgotPasswordSubmit': 'Skicka återställningslänk', + 'login.forgotPasswordSentTitle': 'Kolla din e-post', + 'login.forgotPasswordSentBody': + 'Om det finns ett konto kopplat till den e-postadressen är en återställningslänk på väg. Den går ut om 60 minuter.', + 'login.forgotPasswordSmtpHintOff': + "Observera: din administratör har inte konfigurerat SMTP, så återställningslänken kommer att skrivas ut på serverkonsolen istället för att skickas via e-post.", + 'login.backToLogin': 'Tillbaka till inloggningen', + 'login.newPassword': 'Nytt lösenord', + 'login.confirmPassword': 'Godkänn nytt lösenord', + 'login.passwordsDontMatch': "Lösenorden matchar inte", + 'login.mfaCode': '2FA kod', + 'login.resetPasswordTitle': 'Ange ett nytt lösenord', + 'login.resetPasswordBody': 'Välj ett starkt lösenord som du inte har använt här tidigare. Minst 8 tecken.', + 'login.resetPasswordMfaBody': 'Ange din 2FA-kod eller en reservkod för att slutföra återställningen.', + 'login.resetPasswordSubmit': 'Återställ lösenord', + 'login.resetPasswordVerify': 'Verifiera och återställ', + 'login.resetPasswordSuccessTitle': 'Lösenordet har uppdaterats', + 'login.resetPasswordSuccessBody': 'Nu kan du logga in med ditt nya lösenord.', + 'login.resetPasswordInvalidLink': 'Ogiltig återställningslänk', + 'login.resetPasswordInvalidLinkBody': 'Den här länken saknas eller fungerar inte. Begär en ny för att kunna fortsätta.', + 'login.resetPasswordFailed': 'Återställningen misslyckades. Länken kan ha gått ut.', + 'login.passkey.signIn': 'Logga in med en inloggningsnyckel', + 'login.passkey.failed': 'Inloggningsnyckel misslyckades. Försök igen.', +}; +export default login; diff --git a/shared/src/i18n/sv/map.ts b/shared/src/i18n/sv/map.ts new file mode 100644 index 00000000..ace7aeed --- /dev/null +++ b/shared/src/i18n/sv/map.ts @@ -0,0 +1,17 @@ +import type { TranslationStrings } from '../types'; + +const map: TranslationStrings = { + 'map.connections': 'Anslutningar', + 'map.showConnections': 'Visa bokningsvägar', + 'map.hideConnections': 'Dölj bokningsvägar', + 'poi.searchThisArea': 'Sök i detta område', + 'poi.cat.restaurants': 'Restauranger', + 'poi.cat.cafes': 'Kaféer', + 'poi.cat.bars': 'Barer och nattliv', + 'poi.cat.hotels': 'Boende', + 'poi.cat.sights': 'Sevärdheter', + 'poi.cat.museums': 'Museer och kultur', + 'poi.cat.nature': 'Natur och parker', + 'poi.cat.activities': 'Aktiviteter', +}; +export default map; diff --git a/shared/src/i18n/sv/members.ts b/shared/src/i18n/sv/members.ts new file mode 100644 index 00000000..0877937f --- /dev/null +++ b/shared/src/i18n/sv/members.ts @@ -0,0 +1,24 @@ +import type { TranslationStrings } from '../types'; + +const members: TranslationStrings = { + 'members.shareTrip': 'Dela resan', + 'members.inviteUser': 'Bjud in användare', + 'members.selectUser': 'Välj användare…', + 'members.invite': 'Bjud in', + 'members.allHaveAccess': 'Alla användare har redan åtkomst.', + 'members.access': 'Åtkomst', + 'members.person': 'person', + 'members.persons': 'personer', + 'members.you': 'du', + 'members.owner': 'Ägare', + 'members.leaveTrip': 'Lämna resan', + 'members.removeAccess': 'Ta bort åtkomst', + 'members.confirmLeave': 'Lämna resan? Då förlorar du åtkomsten.', + 'members.confirmRemove': 'Ska åtkomsten för den här användaren tas bort?', + 'members.loadError': 'Det gick inte att ladda medlemmarna', + 'members.added': 'tillagd', + 'members.addError': 'Kunde inte lägga till', + 'members.removed': 'Medlem borttagen', + 'members.removeError': 'Kunde inte ta bort', +}; +export default members; diff --git a/shared/src/i18n/sv/memories.ts b/shared/src/i18n/sv/memories.ts new file mode 100644 index 00000000..a3afdfda --- /dev/null +++ b/shared/src/i18n/sv/memories.ts @@ -0,0 +1,75 @@ +import type { TranslationStrings } from '../types'; + +const memories: TranslationStrings = { + 'memories.title': 'Foton', + 'memories.notConnected': '{provider_name} inte ansluten', + 'memories.notConnectedHint': 'Anslut din {provider_name} instans i inställningar för att kunna lägga till foton till denna resa.', + 'memories.notConnectedMultipleHint': + 'Anslut någon av följande fotoleverantörer: {provider_names} i Inställningar för att kunna lägga till foton till den här resan.', + 'memories.noDates': 'Ange datum för din resa för att ladda foton.', + 'memories.noPhotos': 'Inga foton hittades', + 'memories.noPhotosHint': "Inga foton hittades i {provider_name} för tidsperioden för denna resa.", + 'memories.photosFound': 'foton', + 'memories.fromOthers': 'från andra', + 'memories.sharePhotos': 'Dela foton', + 'memories.sharing': 'Delar', + 'memories.reviewTitle': 'Granska dina foton', + 'memories.reviewHint': 'Klicka på fotona för att undanta dem från delningen.', + 'memories.shareCount': 'Dela {count} foton', + 'memories.providerUrl': 'Server URL', + 'memories.providerApiKey': 'API nyckel', + 'memories.providerUsername': 'Användarnamn', + 'memories.providerPassword': 'Lösenord', + 'memories.providerOTP': 'MFA kod (om aktiverat)', + 'memories.skipSSLVerification': 'Hoppa över verifieringen av SSL-certifikatet', + 'memories.immichAutoUpload': 'Synkronisera journey bilder till Immich vid uppladdning', + 'memories.providerUrlHintSynology': 'Inkludera Photos appsökväg i länken, t.ex. https://nas:5001/photo', + 'memories.testConnection': 'Testa anslutning', + 'memories.testShort': 'Testa', + 'memories.testFirst': 'Testa anslutning först', + 'memories.connected': 'Ansluten', + 'memories.disconnected': 'Inte ansluten', + 'memories.connectionSuccess': 'Ansluten till {provider_name}', + 'memories.connectionError': 'Kunde inte ansluta till {provider_name}', + 'memories.saved': '{provider_name} inställningar sparade', + 'memories.providerDisconnectedBanner': + 'Din {provider_name} anslutningen har brutits. Återanslut i Inställningar för att kunna se fotona.', + 'memories.saveError': 'Kunde inte spara {provider_name} inställningar', + 'memories.addPhotos': 'Lägg till foton', + 'memories.linkAlbum': 'Länka album', + 'memories.selectAlbum': 'Välj {provider_name} album', + 'memories.selectAlbumMultiple': 'Välj album', + 'memories.noAlbums': 'Inga album hittades', + 'memories.syncAlbum': 'Synkronisera album', + 'memories.unlinkAlbum': 'Ta bort länken till albumet', + 'memories.photos': 'foton', + 'memories.selectPhotos': 'Välj foton från {provider_name}', + 'memories.selectPhotosMultiple': 'Välj foton', + 'memories.selectHint': 'Tryck på fotona för att markera dem.', + 'memories.selected': 'valda', + 'memories.addSelected': 'Lägg till {count} foton', + 'memories.alreadyAdded': 'Tillagd', + 'memories.private': 'Privat', + 'memories.stopSharing': 'Sluta dela', + 'memories.oldest': 'Äldsta först', + 'memories.newest': 'Nyaste först', + 'memories.allLocations': 'Alla platser', + 'memories.tripDates': 'Resedatum', + 'memories.allPhotos': 'Alla foton', + 'memories.confirmShareTitle': 'Dela med resedeltagarna?', + 'memories.confirmShareHint': + '{count} foton kommer att vara synliga för alla deltagare i resan. Du kan senare göra enskilda foton privata.', + 'memories.confirmShareButton': 'Dela foton', + 'memories.error.loadAlbums': 'Det gick inte att ladda albumen', + 'memories.error.linkAlbum': 'Det gick inte att länka albumet', + 'memories.error.unlinkAlbum': 'Det gick inte att ta bort länken till albumet', + 'memories.error.syncAlbum': 'Det gick inte att synkronisera albumet', + 'memories.error.loadPhotos': 'Det gick inte att ladda fotona', + 'memories.error.addPhotos': 'Det gick inte att lägga till foton', + 'memories.error.removePhoto': 'Det gick inte att ta bort fotot', + 'memories.error.toggleSharing': 'Det gick inte att uppdatera delningen', + 'memories.saveRouteNotConfigured': 'Funktionen spara rutt är inte konfigurerad för denna leverantör', + 'memories.testRouteNotConfigured': 'Testrutten är inte konfigurerad för denna leverantör', + 'memories.fillRequiredFields': 'Fyll i alla obligatoriska fält', +}; +export default memories; diff --git a/shared/src/i18n/sv/nav.ts b/shared/src/i18n/sv/nav.ts new file mode 100644 index 00000000..3deb452f --- /dev/null +++ b/shared/src/i18n/sv/nav.ts @@ -0,0 +1,20 @@ +import type { TranslationStrings } from '../types'; + +const nav: TranslationStrings = { + 'nav.trip': 'Resa', + 'nav.share': 'Dela', + 'nav.settings': 'Inställningar', + 'nav.admin': 'Admin', + 'nav.logout': 'Logga ut', + 'nav.lightMode': 'Ljust läge', + 'nav.darkMode': 'Mörkt läge', + 'nav.autoMode': 'Auto läge', + 'nav.administrator': 'Administratör', + 'nav.myTrips': 'Mina resor', + 'nav.profile': 'Profil', + 'nav.bottomSettings': 'Inställningar', + 'nav.bottomAdmin': 'Admin Inställningar', + 'nav.bottomLogout': 'Logga ut', + 'nav.bottomAdminBadge': 'Admin', +}; +export default nav; diff --git a/shared/src/i18n/sv/notif.ts b/shared/src/i18n/sv/notif.ts new file mode 100644 index 00000000..dc75c5c4 --- /dev/null +++ b/shared/src/i18n/sv/notif.ts @@ -0,0 +1,40 @@ +import type { TranslationStrings } from '../types'; + +const notif: TranslationStrings = { + 'notif.test.title': '[Test] Meddelande', + 'notif.test.simple.text': 'Detta är ett enkelt testmeddelande.', + 'notif.test.boolean.text': 'Godkänner du detta testmeddelande?', + 'notif.test.navigate.text': 'Klicka nedan för att gå till instrumentpanelen.', + 'notif.trip_invite.title': 'Inbjudan till resa', + 'notif.trip_invite.text': '{actor} bjöd in dig till {trip}', + 'notif.booking_change.title': 'Bokningen har uppdaterats', + 'notif.booking_change.text': '{actor} uppdaterat en bokning i {trip}', + 'notif.trip_reminder.title': 'Påminnelse om resan', + 'notif.trip_reminder.text': 'Din resa {trip} kommer snart!', + 'notif.todo_due.title': 'Uppgift förfaller', + 'notif.todo_due.text': '{todo} i {trip} förfaller den {due}', + 'notif.vacay_invite.title': 'Vacay sammanslagnings inbjudan', + 'notif.vacay_invite.text': '{actor} bjöd in dig till sammanslagning av semester planer', + 'notif.photos_shared.title': 'Delade foton', + 'notif.photos_shared.text': '{actor} delade {count} foto(n) i {trip}', + 'notif.collab_message.title': 'Nytt meddelande', + 'notif.collab_message.text': '{actor} skickade ett meddelande i {trip}', + 'notif.packing_tagged.title': 'Packningsuppdrag', + 'notif.packing_tagged.text': '{actor} har tilldelat dig {category} i {trip}', + 'notif.version_available.title': 'Ny version tillgänglig', + 'notif.version_available.text': 'TREK {version} finns nu tillgängligt', + 'notif.action.view_trip': 'Visa resan', + 'notif.action.view_collab': 'Visa meddelanden', + 'notif.action.view_packing': 'Visa förpackning', + 'notif.action.view_photos': 'Visa bilder', + 'notif.action.view_vacay': 'Visa Vacay', + 'notif.action.view_admin': 'Gå till Admin', + 'notif.action.view': 'Visa', + 'notif.action.accept': 'Godkänn', + 'notif.action.decline': 'Neka', + 'notif.generic.title': 'Meddelande', + 'notif.generic.text': 'Du har fått ett nytt meddelande', + 'notif.dev.unknown_event.title': '[DEV] Okänd händelse', + 'notif.dev.unknown_event.text': 'Evenemang typ "{event}" är inte registrerad i EVENT_NOTIFICATION_CONFIG', +}; +export default notif; diff --git a/shared/src/i18n/sv/notifications.ts b/shared/src/i18n/sv/notifications.ts new file mode 100644 index 00000000..7c24973c --- /dev/null +++ b/shared/src/i18n/sv/notifications.ts @@ -0,0 +1,36 @@ +import type { TranslationStrings } from '../types'; + +const notifications: TranslationStrings = { + 'notifications.title': 'Meddelanden', + 'notifications.markAllRead': 'Markera alla som lästa', + 'notifications.deleteAll': 'Ta bort alla', + 'notifications.showAll': 'Visa alla meddelanden', + 'notifications.empty': 'Inga meddelanden', + 'notifications.emptyDescription': "Nu är du helt uppdaterad!", + 'notifications.all': 'Alla', + 'notifications.unreadOnly': 'Olästa', + 'notifications.markRead': 'Markera som läst', + 'notifications.markUnread': 'Markera som oläst', + 'notifications.delete': 'Radera', + 'notifications.system': 'System', + 'notifications.synologySessionCleared.title': 'Synology Photos har kopplats bort', + 'notifications.synologySessionCleared.text': + 'Din server eller ditt konto har ändrats – gå till Inställningar för att testa din anslutning igen.', + 'notifications.versionAvailable.title': 'Uppdatering tillgänglig', + 'notifications.versionAvailable.text': 'TREK {version} finns nu tillgängligt.', + 'notifications.versionAvailable.button': 'Visa detaljer', + 'notifications.test.title': 'Testmeddelande från {actor}', + 'notifications.test.text': 'Detta är ett enkelt testmeddelande.', + 'notifications.test.booleanTitle': '{actor} ber om ditt godkännande', + 'notifications.test.booleanText': 'Detta är ett testmeddelande av typen boolean. Välj en åtgärd nedan.', + 'notifications.test.accept': 'Godkänn', + 'notifications.test.decline': 'Neka', + 'notifications.test.navigateTitle': 'Kolla in det här', + 'notifications.test.navigateText': 'Detta är ett testmeddelande om navigering.', + 'notifications.test.goThere': 'Gå dit', + 'notifications.test.adminTitle': 'Meddelande från administratören', + 'notifications.test.adminText': '{actor} skickade ett testmeddelande till alla administratörer.', + 'notifications.test.tripTitle': '{actor} publicerat i din resa', + 'notifications.test.tripText': 'Testmeddelande för resan "{trip}".', +}; +export default notifications; diff --git a/shared/src/i18n/sv/oauth.ts b/shared/src/i18n/sv/oauth.ts new file mode 100644 index 00000000..fa7f8d76 --- /dev/null +++ b/shared/src/i18n/sv/oauth.ts @@ -0,0 +1,91 @@ +import type { TranslationStrings } from '../types'; + +const oauth: TranslationStrings = { + 'oauth.scope.group.trips': 'Resor', + 'oauth.scope.group.places': 'Platser', + 'oauth.scope.group.atlas': 'Atlas', + 'oauth.scope.group.packing': 'Packning', + 'oauth.scope.group.todos': 'Att göra', + 'oauth.scope.group.budget': 'Budget', + 'oauth.scope.group.reservations': 'Bokningar', + 'oauth.scope.group.collab': 'Samarbete', + 'oauth.scope.group.notifications': 'Meddelanden', + 'oauth.scope.group.vacay': 'Semester', + 'oauth.scope.group.geo': 'Geo', + 'oauth.scope.group.weather': 'Väder', + 'oauth.scope.group.journey': 'Journey', + 'oauth.scope.trips:read.label': 'Visa resor och resplaner', + 'oauth.scope.trips:read.description': 'Läs om resor, dagar, daganteckningar och medlemmar', + 'oauth.scope.trips:write.label': 'Redigera resor och resplaner', + 'oauth.scope.trips:write.description': 'Skapa och uppdatera resor, dagar och anteckningar samt hantera medlemmar', + 'oauth.scope.trips:delete.label': 'Ta bort resor', + 'oauth.scope.trips:delete.description': 'Ta bort hela resor permanent – denna åtgärd går inte att ångra', + 'oauth.scope.trips:share.label': 'Hantera delningslänkar', + 'oauth.scope.trips:share.description': 'Skapa, uppdatera och återkalla offentliga delningslänkar för resor', + 'oauth.scope.places:read.label': 'Visa platser och kartdata', + 'oauth.scope.places:read.description': 'Läsa platser, dagsuppdrag, taggar och kategorier', + 'oauth.scope.places:write.label': 'Hantera platser', + 'oauth.scope.places:write.description': 'Skapa, uppdatera och ta bort platser, uppdrag och taggar', + 'oauth.scope.atlas:read.label': 'Visa Atlas', + 'oauth.scope.atlas:read.description': 'Läs om besökta länder, regioner och bucketlist', + 'oauth.scope.atlas:write.label': 'Hantera Atlas', + 'oauth.scope.atlas:write.description': 'Markera besökta länder och regioner, hantera bucketlist', + 'oauth.scope.packing:read.label': 'Visa packlistor', + 'oauth.scope.packing:read.description': 'Läs om packningsföremål, väskor och kategoritilldelningar', + 'oauth.scope.packing:write.label': 'Hantera packlistor', + 'oauth.scope.packing:write.description': 'Lägg till, uppdatera, ta bort, växla mellan och ordna om packningsföremål och väskor', + 'oauth.scope.todos:read.label': 'Visa uppgiftslistor', + 'oauth.scope.todos:read.description': 'Läs uppgifter som ska göras under resan och vilka som är ansvariga för respektive kategori', + 'oauth.scope.todos:write.label': 'Hantera uppgiftslistor', + 'oauth.scope.todos:write.description': 'Skapa, uppdatera, aktivera/inaktivera, ta bort och ändra ordningen på uppgifter', + 'oauth.scope.budget:read.label': 'Visa budget', + 'oauth.scope.budget:read.description': 'Läs om budgetposter och kostnadsfördelning', + 'oauth.scope.budget:write.label': 'Hantera budgeten', + 'oauth.scope.budget:write.description': 'Skapa, uppdatera och ta bort budgetposter', + 'oauth.scope.reservations:read.label': 'Visa bokningar', + 'oauth.scope.reservations:read.description': 'Läs information om bokningar och boende', + 'oauth.scope.reservations:write.label': 'Hantera bokningar', + 'oauth.scope.reservations:write.description': 'Skapa, uppdatera, ta bort och ändra ordningen på bokningar', + 'oauth.scope.collab:read.label': 'Visa samarbete', + 'oauth.scope.collab:read.description': 'Läs samarbetsanteckningar, omröstningar och meddelanden', + 'oauth.scope.collab:write.label': 'Hantera samarbete', + 'oauth.scope.collab:write.description': 'Skapa, uppdatera och ta bort samarbetsanteckningar, omröstningar och meddelanden', + 'oauth.scope.notifications:read.label': 'Visa meddelanden', + 'oauth.scope.notifications:read.description': 'Läsa meddelanden i appen och antalet olästa meddelanden', + 'oauth.scope.notifications:write.label': 'Hantera aviseringar', + 'oauth.scope.notifications:write.description': 'Markera meddelanden som lästa och svara på dem', + 'oauth.scope.vacay:read.label': 'Visa semesterplaner', + 'oauth.scope.vacay:read.description': 'Läs information om semesterplanering, inlägg och statistik', + 'oauth.scope.vacay:write.label': 'Hantera semesterplaner', + 'oauth.scope.vacay:write.description': 'Skapa och hantera semesterposter, helgdagar och teamplaner', + 'oauth.scope.geo:read.label': 'Kartor och geokodning', + 'oauth.scope.geo:read.description': 'Sök efter platser, omvandla kart-URL:er och utför omvänd geokodning av koordinater', + 'oauth.scope.weather:read.label': 'Väderprognoser', + 'oauth.scope.weather:read.description': 'Hämta väderprognoser för resmål och datum', + 'oauth.scope.journey:read.label': 'Visa journeys', + 'oauth.scope.journey:read.description': 'Läs journeys, inlägg och författarlistan', + 'oauth.scope.journey:write.label': 'Hantera journeys', + 'oauth.scope.journey:write.description': 'Skapa, uppdatera och ta bort journeys och deras poster', + 'oauth.scope.journey:share.label': 'Hantera journey länkar', + 'oauth.scope.journey:share.description': 'Skapa, uppdatera och återkalla offentliga delningslänkar för journeys', + 'oauth.authorize.authorizing': 'Autentiserar…', + 'oauth.authorize.loading': 'Laddar…', + 'oauth.authorize.errorTitle': 'Auktoriseringsfel', + 'oauth.authorize.loginTitle': 'Logga in för att fortsätta', + 'oauth.authorize.loginDescription': '{client} vill ha åtkomst till ditt TREK konto. Logga in först.', + 'oauth.authorize.loginButton': 'Logga in på TREK', + 'oauth.authorize.requestLabel': 'Begäran om behörighet', + 'oauth.authorize.requestDescription': 'Den här applikationen begär åtkomst till ditt TREK konto.', + 'oauth.authorize.trustNote': 'Ge endast åtkomst till applikationer som du litar på. Dina data förblir på din server.', + 'oauth.authorize.selectScope': 'Välj minst ett tillämpningsområde', + 'oauth.authorize.approveOneScope': 'Godkänn ({count} tillämpningsområde)', + 'oauth.authorize.approveManyScopes': 'Godkänn ({count} tillämpningsområden)', + 'oauth.authorize.approveAccess': 'Godkänn åtkomst', + 'oauth.authorize.deny': 'Neka', + 'oauth.authorize.choosePermissions': 'Välj vilka behörigheter du vill bevilja', + 'oauth.authorize.permissionsRequested': 'Begärda behörigheter', + 'oauth.authorize.alwaysIncluded': 'Ingår alltid', + 'oauth.authorize.alwaysTool.listTrips': 'Lista dina resor så att AI:n kan identifiera resenummer', + 'oauth.authorize.alwaysTool.getTripSummary': 'Läs en översikt över resan som krävs för att kunna använda något annat verktyg', +}; +export default oauth; diff --git a/shared/src/i18n/sv/packing.ts b/shared/src/i18n/sv/packing.ts new file mode 100644 index 00000000..7116a256 --- /dev/null +++ b/shared/src/i18n/sv/packing.ts @@ -0,0 +1,184 @@ +import type { TranslationStrings } from '../types'; + +const packing: TranslationStrings = { + 'packing.title': 'Packlista', + 'packing.empty': 'Packlistan är tom', + 'packing.import': 'Importera', + 'packing.importTitle': 'Importera packlista', + 'packing.importHint': + 'Ett föremål per rad. Format: Kategori, Namn, Vikt i g (valfritt), Påse (valfritt)', + 'packing.importPlaceholder': + 'Hygiene, tandborste\nKläder, T-shirts, 200\nDokument, pass, handbagage\nElektronik, laddare, 50, resväska', + 'packing.importCsv': 'Ladda CSV/TXT', + 'packing.importAction': 'Importera {count}', + 'packing.importSuccess': '{count} föremål imported', + 'packing.importError': 'Importen misslyckades', + 'packing.importEmpty': 'Inga föremål att importera', + 'packing.progress': '{packed} av {total} packade ({percent}%)', + 'packing.clearChecked': 'Ta bort {count} markerade', + 'packing.clearCheckedShort': 'Ta bort {count}', + 'packing.suggestions': 'Förslag', + 'packing.suggestionsTitle': 'Lägg till förslag', + 'packing.allSuggested': 'All förslag tillagd', + 'packing.allPacked': 'Allt packat!', + 'packing.addPlaceholder': 'Lägg till nytt föremål', + 'packing.categoryPlaceholder': 'Kategori...', + 'packing.filterAll': 'Alla', + 'packing.filterOpen': 'Öppna', + 'packing.filterDone': 'Klar', + 'packing.emptyTitle': 'Packlistan är tom', + 'packing.emptyHint': 'Lägg till föremål eller använd förslagen', + 'packing.emptyFiltered': 'Inga föremål matchar detta filter', + 'packing.menuRename': 'Döp om', + 'packing.menuCheckAll': 'Markera alla', + 'packing.menuUncheckAll': 'Avmarkera alla', + 'packing.menuDeleteCat': 'Ta bort kategori', + 'packing.noMembers': 'Inga resedeltagare', + 'packing.addItem': 'Lägg till föremål', + 'packing.addItemPlaceholder': 'Föremålsnamn...', + 'packing.addCategory': 'Lägg till kategori', + 'packing.newCategoryPlaceholder': 'Kategorinamn (t.ex. kläder)', + 'packing.applyTemplate': 'Använd mall', + 'packing.template': 'Mall', + 'packing.templateApplied': '{count} föremål tillagda från mallen', + 'packing.templateError': 'Det gick inte att tillämpa mallen', + 'packing.saveAsTemplate': 'Spara som mall', + 'packing.templateName': 'Mallnamn', + 'packing.templateSaved': 'Packlista sparad som mall', + 'packing.bags': 'Väskor', + 'packing.noBag': 'Ej tilldelad', + 'packing.totalWeight': 'Totalvikt', + 'packing.bagName': 'Väskans namn...', + 'packing.addBag': 'Lägg till väska', + 'packing.changeCategory': 'Ändra kategori', + 'packing.confirm.clearChecked': 'Är du säker att du vill ta bort {count} markerade föremål?', + 'packing.confirm.deleteCat': 'Är du säker att du vill ta bort kategori "{name}" med {count} föremål?', + 'packing.defaultCategory': 'Övrigt', + 'packing.toast.saveError': 'Kunde inte spara', + 'packing.toast.deleteError': 'Kunde inte ta bort', + 'packing.toast.renameError': 'Kunde inte döpa om', + 'packing.toast.addError': 'Kunde inte lägga till', + 'packing.suggestions.items': [ + { + name: 'Pass', + category: 'Dokument', + }, + { + name: 'ID-kort', + category: 'Dokument', + }, + { + name: 'Reseförsäkring', + category: 'Dokument', + }, + { + name: 'Flygbiljetter', + category: 'Dokument', + }, + { + name: 'Kreditkort', + category: 'Ekonomi', + }, + { + name: 'Kontanter', + category: 'Ekonomi', + }, + { + name: 'Visum', + category: 'Dokument', + }, + { + name: 'T-shirts', + category: 'Kläder', + }, + { + name: 'Byxor', + category: 'Kläder', + }, + { + name: 'Underkläder', + category: 'Kläder', + }, + { + name: 'Strumpor', + category: 'Kläder', + }, + { + name: 'Jacka', + category: 'Kläder', + }, + { + name: 'Nattkläder', + category: 'Kläder', + }, + { + name: 'Badkläder', + category: 'Kläder', + }, + { + name: 'Regnjacka', + category: 'Kläder', + }, + { + name: 'Bekväma skor', + category: 'Kläder', + }, + { + name: 'Tandborste', + category: 'Toalettartiklar', + }, + { + name: 'Tandkräm', + category: 'Toalettartiklar', + }, + { + name: 'Schampo', + category: 'Toalettartiklar', + }, + { + name: 'Deodorant', + category: 'Toalettartiklar', + }, + { + name: 'Solskyddsmedel', + category: 'Toalettartiklar', + }, + { + name: 'Rakhyvel', + category: 'Toalettartiklar', + }, + { + name: 'Laddare', + category: 'Elektronik', + }, + { + name: 'Powerbank', + category: 'Elektronik', + }, + { + name: 'Hörlurar', + category: 'Elektronik', + }, + { + name: 'Resadapter', + category: 'Elektronik', + }, + { + name: 'Kamera', + category: 'Elektronik', + }, + { + name: 'Smärtstillande läkemedel', + category: 'Hälsa', + }, + { + name: 'Plåster', + category: 'Hälsa', + }, + { + name: 'Desinfektionsmedel', + category: 'Hälsa', + }, + ], +}; +export default packing; diff --git a/shared/src/i18n/sv/pdf.ts b/shared/src/i18n/sv/pdf.ts new file mode 100644 index 00000000..56756ce0 --- /dev/null +++ b/shared/src/i18n/sv/pdf.ts @@ -0,0 +1,10 @@ +import type { TranslationStrings } from '../types'; + +const pdf: TranslationStrings = { + 'pdf.travelPlan': 'Resplan', + 'pdf.planned': 'Planerat', + 'pdf.costLabel': 'Kostnad EUR', + 'pdf.preview': 'Förhandsgranskning av PDF', + 'pdf.saveAsPdf': 'Spara som PDF', +}; +export default pdf; diff --git a/shared/src/i18n/sv/perm.ts b/shared/src/i18n/sv/perm.ts new file mode 100644 index 00000000..9dfa8284 --- /dev/null +++ b/shared/src/i18n/sv/perm.ts @@ -0,0 +1,51 @@ +import type { TranslationStrings } from '../types'; + +const perm: TranslationStrings = { + 'perm.title': 'Behörighetsinställningar', + 'perm.subtitle': 'Styr vem som kan utföra åtgärder i hela applikationen', + 'perm.saved': 'Behörighetsinställningarna har sparats', + 'perm.resetDefaults': 'Återställ till standardinställningarna', + 'perm.customized': 'skräddarsydd', + 'perm.level.admin': 'Admin endast', + 'perm.level.tripOwner': 'Researrangör', + 'perm.level.tripMember': 'Resedeltagare', + 'perm.level.everybody': 'Alla', + 'perm.cat.trip': 'Resahantering', + 'perm.cat.members': 'Medlemshantering', + 'perm.cat.files': 'Filer', + 'perm.cat.content': 'Innehåll och schema', + 'perm.cat.extras': 'Budget, packning och samarbete', + 'perm.action.trip_create': 'Skapa resor', + 'perm.action.trip_edit': 'Redigera resedetaljer', + 'perm.action.trip_delete': 'Ta bort resor', + 'perm.action.trip_archive': 'Arkivera/ta bort resor från arkivet', + 'perm.action.trip_cover_upload': 'Ladda upp omslagsbild', + 'perm.action.member_manage': 'Lägg till/ta bort medlemmar', + 'perm.action.file_upload': 'Ladda upp filer', + 'perm.action.file_edit': 'Redigera filens metadata', + 'perm.action.file_delete': 'Ta bort filer', + 'perm.action.place_edit': 'Lägg till / redigera / ta bort platser', + 'perm.action.day_edit': 'Redigera dagar, anteckningar och uppgifter', + 'perm.action.reservation_edit': 'Hantera bokningar', + 'perm.action.budget_edit': 'Hantera budgeten', + 'perm.action.packing_edit': 'Hantera packlistor', + 'perm.action.collab_edit': 'Samarbete (anteckningar, omröstningar, chatt)', + 'perm.action.share_manage': 'Hantera delningslänkar', + 'perm.actionHint.trip_create': 'Vem kan skapa nya resor', + 'perm.actionHint.trip_edit': 'Vem kan ändra resans namn, datum, beskrivning och valuta', + 'perm.actionHint.trip_delete': 'Vem kan radera en resa permanent', + 'perm.actionHint.trip_archive': 'Vem kan lägga till en resa i arkivet eller ta bort den därifrån', + 'perm.actionHint.trip_cover_upload': 'Vem kan ladda upp eller ändra omslagsbilden', + 'perm.actionHint.member_manage': 'Vem kan bjuda in eller ta bort deltagare i resan', + 'perm.actionHint.file_upload': 'Vem kan ladda upp filer till en resa', + 'perm.actionHint.file_edit': 'Vem kan redigera filbeskrivningar och länkar', + 'perm.actionHint.file_delete': 'Vem kan flytta filer till papperskorgen eller radera dem permanent', + 'perm.actionHint.place_edit': 'Vem kan lägga till, redigera eller ta bort platser', + 'perm.actionHint.day_edit': 'Vem kan redigera dagar, daganteckningar och platsuppdrag', + 'perm.actionHint.reservation_edit': 'Vem kan skapa, redigera eller ta bort bokningar', + 'perm.actionHint.budget_edit': 'Vem kan skapa, redigera eller ta bort budgetposter', + 'perm.actionHint.packing_edit': 'Vem kan sköta packningen av föremål och väskor', + 'perm.actionHint.collab_edit': 'Vem kan skapa anteckningar, omröstningar och skicka meddelanden', + 'perm.actionHint.share_manage': 'Vem kan skapa eller ta bort länkar till offentliga delningar', +}; +export default perm; diff --git a/shared/src/i18n/sv/photos.ts b/shared/src/i18n/sv/photos.ts new file mode 100644 index 00000000..9f399139 --- /dev/null +++ b/shared/src/i18n/sv/photos.ts @@ -0,0 +1,25 @@ +import type { TranslationStrings } from '../types'; + +const photos: TranslationStrings = { + 'photos.title': 'Foton', + 'photos.subtitle': '{count} foton för {trip}', + 'photos.dropHere': 'Släpp foton här...', + 'photos.dropHereActive': 'Släpp foton här', + 'photos.captionForAll': 'Fototext (för alla)', + 'photos.captionPlaceholder': 'Valfri fototext...', + 'photos.addCaption': 'Lägg till fototext...', + 'photos.allDays': 'Alla dagar', + 'photos.noPhotos': 'Inga foton ännu', + 'photos.uploadHint': 'Ladda upp dina resefoton', + 'photos.clickToSelect': 'eller klicka för att välja', + 'photos.linkPlace': 'Länkplats', + 'photos.noPlace': 'Ingen plats', + 'photos.uploadN': '{n} foto(n) uppladdad', + 'photos.linkDay': 'Länka dag', + 'photos.noDay': 'Ingen dag', + 'photos.dayLabel': 'Dag {number}', + 'photos.photoSelected': 'Foton markerade', + 'photos.photosSelected': 'Foton markerade', + 'photos.fileTypeHint': 'JPG, PNG, WebP · max. 10 MB · upp till 30 foton', +}; +export default photos; diff --git a/shared/src/i18n/sv/places.ts b/shared/src/i18n/sv/places.ts new file mode 100644 index 00000000..b3daa309 --- /dev/null +++ b/shared/src/i18n/sv/places.ts @@ -0,0 +1,90 @@ +import type { TranslationStrings } from '../types'; + +const places: TranslationStrings = { + 'places.addPlace': 'Lägg till plats/aktivitet', + 'places.importFile': 'Importera fil', + 'places.sidebarDrop': 'Släpp för att importera', + 'places.importFileHint': + 'Importera .gpx-, .kml- eller .kmz-filer från verktyg som Google My Maps, Google Earth eller en GPS-spårare.', + 'places.importFileDropHere': 'Klicka för att välja en fil eller dra och släpp här', + 'places.importFileDropActive': 'Släpp filen för att välja den', + 'places.importFileUnsupported': 'Filtypen stöds inte. Använd .gpx, .kml eller .kmz.', + 'places.importFileTooLarge': 'Filen är för stor. Den maximala storleken för uppladdning är {maxMb} MB.', + 'places.importFileError': 'Importen misslyckades', + 'places.importAllSkipped': 'Alla platser ingick redan i resan.', + 'places.gpxImported': '{count} platser som importerats från GPX', + 'places.gpxImportTypes': 'Vad vill du importera?', + 'places.gpxImportWaypoints': 'Vägpunkter', + 'places.gpxImportRoutes': 'Rutter', + 'places.gpxImportTracks': 'Spår (med spårgeometri)', + 'places.gpxImportNoneSelected': 'Välj minst en typ att importera.', + 'places.kmlImportTypes': 'Vad vill du importera?', + 'places.kmlImportPoints': 'Punkter (platsmarkeringar)', + 'places.kmlImportPaths': 'Stigar (LineStrings)', + 'places.kmlImportNoneSelected': 'Välj minst en typ att importera.', + 'places.selectionCount': '{count} markerade', + 'places.deleteSelected': 'Ta bort det markerade', + 'places.kmlKmzImported': '{count} platser som importerats från KMZ/KML', + 'places.urlResolved': 'Plats importerad från URL', + 'places.importList': 'Importera lista', + 'places.kmlKmzSummaryValues': 'Platsmarkeringar: {total} • Importerat: {created} • Hoppat över: {skipped}', + 'places.importGoogleList': 'Google-lista', + 'places.importNaverList': 'Naver-lista', + 'places.googleListHint': 'Klistra in en länk till en delad lista i Google Maps för att importera alla platser.', + 'places.googleListImported': '{count} platser som importerats från "{list}"', + 'places.googleListError': 'Det gick inte att importera listan från Google Maps', + 'places.naverListHint': 'Klistra in en länk till en delad lista på Naver Maps för att importera alla platser.', + 'places.naverListImported': '{count} platser som importerats från "{list}"', + 'places.naverListError': 'Det gick inte att importera listan från Naver Maps', + 'places.viewDetails': 'Visa detaljer', + 'places.assignToDay': 'Lägg till vilken dag?', + 'places.all': 'Alla', + 'places.unplanned': 'Oplanerat', + 'places.filterTracks': 'Spår', + 'places.search': 'Sök efter platser...', + 'places.allCategories': 'Alla kategorier', + 'places.categoriesSelected': 'kategorier', + 'places.clearFilter': 'Rensa filter', + 'places.count': '{count} platser', + 'places.countSingular': '1 plats', + 'places.allPlanned': 'Alla platser är planerade', + 'places.noneFound': 'Inga platser hittades', + 'places.editPlace': 'Redigera plats', + 'places.formName': 'Namn', + 'places.formNamePlaceholder': 't.ex. Eiffeltornet', + 'places.formDescription': 'Beskrivning', + 'places.formDescriptionPlaceholder': 'Kort beskrivning...', + 'places.formAddress': 'Adress', + 'places.formAddressPlaceholder': 'Gata, stad, land', + 'places.formLat': 'Latitud (t.ex. 48.8566)', + 'places.formLng': 'Longitud (t.ex. 2.3522)', + 'places.formCategory': 'Kategori', + 'places.noCategory': 'Ingen kategori', + 'places.categoryNamePlaceholder': 'Kategorinamn', + 'places.formTime': 'Tid', + 'places.startTime': 'Börjar', + 'places.endTime': 'Slutar', + 'places.endTimeBeforeStart': 'Sluttiden infaller före starttiden', + 'places.timeCollision': 'Tidsöverlappning med:', + 'places.formWebsite': 'Hemsida', + 'places.formNotes': 'Noteringar', + 'places.formNotesPlaceholder': 'Personliga anteckningar...', + 'places.formReservation': 'Bokning', + 'places.reservationNotesPlaceholder': 'Bokningsinformation, bekräftelsenummer...', + 'places.mapsSearchPlaceholder': 'Sök efter platser...', + 'places.mapsSearchError': 'Sökningen efter plats misslyckades.', + 'places.loadingDetails': 'Hämtar information om platsen…', + 'places.osmHint': + 'Använder OpenStreetMap-sökningen (inga bilder, öppettider eller betyg). Lägg till en Google API-nyckel i inställningarna för fullständig information.', + 'places.osmActive': + 'Sök via OpenStreetMap (inga bilder, betyg eller öppettider). Lägg till en Google API-nyckel under Inställningar för utökad information.', + 'places.categoryCreateError': 'Det gick inte att skapa kategorin', + 'places.nameRequired': 'Ange ett namn', + 'places.saveError': 'Det gick inte att spara', + 'places.duplicateExists': "'{name}' ingår redan i resan.", + 'places.addAnyway': 'Lägg till ändå', + 'places.enrichOnImport': 'Berika platser via Google', + 'places.enrichOnImportHint': + 'Sök upp varje importerad plats för att fylla i bilder, adress och kontaktuppgifter. Använder din Google Maps-nyckel.', +}; +export default places; diff --git a/shared/src/i18n/sv/planner.ts b/shared/src/i18n/sv/planner.ts new file mode 100644 index 00000000..ca4847b4 --- /dev/null +++ b/shared/src/i18n/sv/planner.ts @@ -0,0 +1,66 @@ +import type { TranslationStrings } from '../types'; + +const planner: TranslationStrings = { + 'planner.places': 'Platser', + 'planner.bookings': 'Bokningar', + 'planner.packingList': 'Packlista', + 'planner.documents': 'Dokument', + 'planner.dayPlan': 'Dagsplan', + 'planner.reservations': 'Bokningar', + 'planner.minTwoPlaces': 'Minst 2 platser med koordinater krävs', + 'planner.noGeoPlaces': 'Inga platser med tillgängliga koordinater', + 'planner.routeCalculated': 'Rutt beräknad', + 'planner.routeCalcFailed': 'Rutt kunde inte beräknas', + 'planner.routeError': 'Fel vid beräkning av rutten', + 'planner.icsExportFailed': 'ICS-exporten misslyckades', + 'planner.routeOptimized': 'Ruttoptimerad', + 'planner.reservationUpdated': 'Bokningen har uppdaterats', + 'planner.reservationAdded': 'Bokning tillagd', + 'planner.confirmDeleteReservation': 'Ta bort bokningen?', + 'planner.reservationDeleted': 'Bokningen har raderats', + 'planner.days': 'Dagar', + 'planner.allPlaces': 'Alla platser', + 'planner.totalPlaces': '{n} platser totalt', + 'planner.noDaysPlanned': 'Inga dagar har planerats ännu', + 'planner.editTrip': 'Redigera resa →', + 'planner.placeOne': '1 plats', + 'planner.placeN': '{n} platser', + 'planner.addNote': 'Lägg till notering', + 'planner.noEntries': 'Inga inlägg för denna dag', + 'planner.addPlace': 'Lägg till plats/aktivitet', + 'planner.addPlaceShort': '+ Lägg till plats/aktivitet', + 'planner.resPending': 'Bokningen väntar på bekräftelse · ', + 'planner.resConfirmed': 'Bokningen bekräftad · ', + 'planner.notePlaceholder': 'Notering...', + 'planner.noteTimePlaceholder': 'Tid (valfritt)', + 'planner.noteExamplePlaceholder': 't.ex. S3 kl. 14.30 från centralstationen, färja från kaj 7, lunchpaus…', + 'planner.totalCost': 'Total kostnad', + 'planner.searchPlaces': 'Sök efter platser…', + 'planner.allCategories': 'Alla kategorier', + 'planner.noPlacesFound': 'Inga platser hittades', + 'planner.addFirstPlace': 'Lägg till första platsen', + 'planner.noReservations': 'Inga bokningar', + 'planner.addFirstReservation': 'Lägg till första bokningen', + 'planner.new': 'Ny', + 'planner.addToDay': '+ Dag', + 'planner.calculating': 'Beräknar…', + 'planner.route': 'Rutt', + 'planner.optimize': 'Optimera', + 'planner.openGoogleMaps': 'Öppna i Google Maps', + 'planner.selectDayHint': 'Välj en dag i listan till vänster för att se dagsplanen', + 'planner.noPlacesForDay': 'Det finns inga platser för denna dagen ännu', + 'planner.addPlacesLink': 'Lägg till platser →', + 'planner.minTotal': 'min. totalt', + 'planner.noReservation': 'Ingen bokning', + 'planner.removeFromDay': 'Ta bort från dagen', + 'planner.addToThisDay': 'Lägg till i denna dag', + 'planner.overview': 'Översikt', + 'planner.noDays': 'Inga dagar ännu', + 'planner.editTripToAddDays': 'Redigera resan för att lägga till dagar', + 'planner.dayCount': '{n} Dagar', + 'planner.clickToUnlock': 'Klicka för att låsa upp', + 'planner.keepPosition': 'Behåll positionen under ruttoptimeringen', + 'planner.dayDetails': 'Information om dagen', + 'planner.dayN': 'Dag {n}', +}; +export default planner; diff --git a/shared/src/i18n/sv/register.ts b/shared/src/i18n/sv/register.ts new file mode 100644 index 00000000..bef4366d --- /dev/null +++ b/shared/src/i18n/sv/register.ts @@ -0,0 +1,25 @@ +import type { TranslationStrings } from '../types'; + +const register: TranslationStrings = { + 'register.passwordMismatch': 'Lösenorden stämmer inte överens', + 'register.passwordTooShort': 'Lösenordet måste bestå av minst 8 tecken', + 'register.failed': 'Registreringen misslyckades', + 'register.getStarted': 'Kom igång', + 'register.subtitle': 'Skapa ett konto och börja planera dina drömresor.', + 'register.feature1': 'Resplaner utan begränsningar', + 'register.feature2': 'Interaktiv kartvy', + 'register.feature3': 'Hantera platser och kategorier', + 'register.feature4': 'Spåra bokningar', + 'register.feature5': 'Skapa packlistor', + 'register.feature6': 'Spara foton och filer', + 'register.createAccount': 'Skapa konto', + 'register.startPlanning': 'Börja planera din resa', + 'register.minChars': 'Minst 6 tecken', + 'register.confirmPassword': 'Bekräfta lösenord', + 'register.repeatPassword': 'Upprepa lösenordet', + 'register.registering': 'Registrerar...', + 'register.register': 'Registrera', + 'register.hasAccount': 'Har du redan ett konto?', + 'register.signIn': 'Logga in', +}; +export default register; diff --git a/shared/src/i18n/sv/reservations.ts b/shared/src/i18n/sv/reservations.ts new file mode 100644 index 00000000..1c2ca1dd --- /dev/null +++ b/shared/src/i18n/sv/reservations.ts @@ -0,0 +1,162 @@ +import type { TranslationStrings } from '../types'; + +const reservations: TranslationStrings = { + 'reservations.title': 'Bokningar', + 'reservations.empty': 'Inga bokningar än', + 'reservations.emptyHint': 'Lägg till bokningar av flyg, hotell och annat', + 'reservations.add': 'Lägg till bokning', + 'reservations.addManual': 'Manuell bokning', + 'reservations.placeHint': 'Tips: Det är bäst att göra bokningar direkt från en plats för att koppla dem till din dagsplan.', + 'reservations.confirmed': 'Bekräftat', + 'reservations.pending': 'Väntar på beslut', + 'reservations.summary': '{confirmed} bekräftat, {pending} väntar på beslut', + 'reservations.fromPlan': 'Från plan', + 'reservations.showFiles': 'Visa filer', + 'reservations.editTitle': 'Redigera reservation', + 'reservations.status': 'Status', + 'reservations.datetime': 'Datum & Tid', + 'reservations.startTime': 'Starttid', + 'reservations.endTime': 'Sluttid', + 'reservations.date': 'Datum', + 'reservations.time': 'Tid', + 'reservations.timeAlt': 'Tid (alternativ, t.ex. 19:30)', + 'reservations.notes': 'Noteringar', + 'reservations.notesPlaceholder': 'Ytterligare noteringar...', + 'reservations.meta.airline': 'Flygbolag', + 'reservations.meta.flightNumber': 'Flygnummer', + 'reservations.meta.from': 'Från', + 'reservations.meta.to': 'Till', + 'reservations.layover.route': 'Rutt', + 'reservations.layover.stop': 'Stopp', + 'reservations.layover.addStop': 'Lägg till stopp', + 'reservations.layover.connection': 'Anslutning', + 'reservations.layover.layover': 'Mellanlandning', + 'reservations.needsReview': 'Granska', + 'reservations.needsReviewHint': 'Flygplatsen kunde inte matchas automatiskt – bekräfta platsen.', + 'reservations.searchLocation': 'Sök efter station, hamn, adress…', + 'reservations.meta.trainNumber': 'Tågnummer', + 'reservations.meta.platform': 'Plattform', + 'reservations.meta.seat': 'Säte', + 'reservations.meta.checkIn': 'Incheckning', + 'reservations.meta.checkInUntil': 'Incheckning fram till', + 'reservations.meta.checkOut': 'Utcheckning', + 'reservations.meta.linkAccommodation': 'Boende', + 'reservations.meta.pickAccommodation': 'Länk till boende', + 'reservations.meta.noAccommodation': 'Inget', + 'reservations.meta.hotelPlace': 'Boende', + 'reservations.meta.pickHotel': 'Välj boende', + 'reservations.meta.fromDay': 'Från', + 'reservations.meta.toDay': 'Till', + 'reservations.meta.selectDay': 'Välj dag', + 'reservations.type.flight': 'Flygning', + 'reservations.type.hotel': 'Boende', + 'reservations.type.restaurant': 'Restaurang', + 'reservations.type.train': 'Tåg', + 'reservations.type.car': 'Bil', + 'reservations.type.cruise': 'Kryssning', + 'reservations.type.event': 'Evenemang', + 'reservations.type.tour': 'Rundtur', + 'reservations.type.other': 'Övrigt', + 'reservations.type.bus': 'Buss', + 'reservations.type.ferry': 'Färja', + 'reservations.type.bicycle': 'Cyckel', + 'reservations.type.taxi': 'Taxi', + 'reservations.type.transport_other': 'Annat', + 'reservations.confirm.delete': 'Är du säker på att du vill ta bort bokningen "{name}"?', + 'reservations.confirm.deleteTitle': 'Ta bort bokningen?', + 'reservations.confirm.deleteBody': '"{name}" kommer att raderas permanent.', + 'reservations.toast.updated': 'Bokning uppdaterad', + 'reservations.toast.removed': 'Bokning borttagen', + 'reservations.toast.fileUploaded': 'Fil uppladdad', + 'reservations.toast.uploadError': 'Det gick inte att ladda upp', + 'reservations.newTitle': 'Ny bokning', + 'reservations.bookingType': 'Bokningstyp', + 'reservations.titleLabel': 'Titel', + 'reservations.titlePlaceholder': 't.ex. Lufthansa LH123, Hotel Adlon, ...', + 'reservations.locationAddress': 'Plats / Adress', + 'reservations.locationPlaceholder': 'Adress, flygplats, hotell...', + 'reservations.confirmationCode': 'Bokningskod', + 'reservations.confirmationPlaceholder': 't.ex. ABC12345', + 'reservations.day': 'Dag', + 'reservations.noDay': 'Ingen dag', + 'reservations.place': 'Plats', + 'reservations.noPlace': 'Ingen Plats', + 'reservations.pendingSave': 'kommer att sparas...', + 'reservations.uploading': 'Laddar upp...', + 'reservations.attachFile': 'Bifoga fil', + 'reservations.linkExisting': 'Länka till befintlig fil', + 'reservations.toast.saveError': 'Det gick inte att spara', + 'reservations.toast.updateError': 'Uppdateringen misslyckades', + 'reservations.toast.deleteError': 'Det gick inte att ta bort', + 'reservations.confirm.remove': 'Ta bort bokningen för "{name}"?', + 'reservations.linkAssignment': 'Länk till dagsuppgift', + 'reservations.pickAssignment': 'Välj en uppgift från din plan...', + 'reservations.noAssignment': 'Ingen länk (fristående)', + 'reservations.price': 'Pris', + 'reservations.budgetCategory': 'Budgetkategori', + 'reservations.budgetCategoryPlaceholder': 't.ex. transport, boende', + 'reservations.budgetCategoryAuto': 'Auto (från bokningstyp)', + 'reservations.budgetHint': 'En budgetpost skapas automatiskt när du sparar.', + 'reservations.departureDate': 'Avgång', + 'reservations.arrivalDate': 'Ankomst', + 'reservations.departureTime': 'Avgångstid', + 'reservations.arrivalTime': 'Ankomsttid', + 'reservations.pickupDate': 'Upphämtning', + 'reservations.returnDate': 'Återlämning', + 'reservations.pickupTime': 'Upphämtningstid', + 'reservations.returnTime': 'Återlämningstid', + 'reservations.endDate': 'Slutdatum', + 'reservations.meta.departureTimezone': 'Avgång Tidzon', + 'reservations.meta.arrivalTimezone': 'Ankomst Tidzon', + 'reservations.span.departure': 'Avgång', + 'reservations.span.arrival': 'Ankomst', + 'reservations.span.inTransit': 'Under transport', + 'reservations.span.pickup': 'Upphämtning', + 'reservations.span.return': 'Återlämning', + 'reservations.span.active': 'Aktiv', + 'reservations.span.start': 'Start', + 'reservations.span.end': 'Slut', + 'reservations.span.ongoing': 'Pågår', + 'reservations.validation.endBeforeStart': 'Slutdatum och -tid måste ligga efter startdatum och -tid', + 'reservations.addBooking': 'Lägg till bokning', + 'reservations.import.title': 'Importera bokningsbekräftelser', + 'reservations.import.cta': 'Importera från fil', + 'reservations.import.dropHere': 'Dra och släpp bokningsbekräftelser här, eller klicka för att välja', + 'reservations.import.dropActive': 'Dra och släpp filerna som ska importeras', + 'reservations.import.acceptedFormats': 'Godkända filformat: EML, PDF, PKPass, HTML, TXT (högst 10 MB per fil, upp till 5 filer)', + 'reservations.import.parsing': 'Analyserar filer…', + 'reservations.import.previewHeading': '{count} reservation(er) hittades', + 'reservations.import.previewEmpty': 'Inga bokningar kunde extraheras från de uppladdade filerna.', + 'reservations.import.removeItem': 'Ta bort', + 'reservations.import.confirm': 'Importera {count} reservation(er)', + 'reservations.import.back': 'Tillbaka', + 'reservations.import.success': '{count} reservation(er) importerades', + 'reservations.import.partialFailure': '{created} importerades, {failed} misslyckades', + 'reservations.import.error': 'Analysen misslyckades. Kontrollera att filen är en giltig bokningsbekräftelse.', + 'reservations.import.unavailable': 'Import av bokningar är inte tillgängligt på den här servern.', + 'reservations.import.unsupportedFormat': 'Filformatet stöds inte. Använd EML, PDF, PKPass, HTML eller TXT.', + 'reservations.import.fileTooLarge': 'Filen "{name}" överskrider gränsen på 10 MB.', + 'reservations.airtrail.title': 'Importera från AirTrail', + 'reservations.airtrail.cta': 'AirTrail', + 'reservations.airtrail.synced': 'AirTrail', + 'reservations.airtrail.syncedHint': 'Synkroniserat från AirTrail – ändringarna synkroniseras åt båda hållen.', + 'reservations.airtrail.notSynced': 'Ej synkroniserad', + 'reservations.airtrail.notSyncedHint': 'Denna flygning har tagits bort i AirTrail och synkroniseras inte längre.', + 'reservations.airtrail.loadError': 'Det gick inte att hämta dina AirTrail-flygningar.', + 'reservations.airtrail.imported': '{count} flygning(ar) importerades', + 'reservations.airtrail.skippedDuplicate': '{count} redan under den här resan, hoppade över', + 'reservations.airtrail.nothingImported': 'Inget att importera.', + 'reservations.airtrail.importError': 'Importen misslyckades. Försök igen.', + 'reservations.airtrail.undo': 'Importera från AirTrail', + 'reservations.airtrail.alreadyImported': 'Importerad', + 'reservations.airtrail.duringTrip': 'Under denna resa', + 'reservations.airtrail.otherFlights': 'Övriga flygningar', + 'reservations.airtrail.empty': 'Inga flygningar hittades i ditt AirTrail-konto.', + 'reservations.airtrail.importCta': 'Importera {count}', + 'reservations.costsLabel': 'Kostnader', + 'reservations.createExpense': 'Skapa utgift', + 'reservations.createExpenseHint': 'Sparar bokningen och öppnar sedan kostnadsredigeraren.', + 'reservations.linkedExpense': 'Relaterade kostnader', + 'reservations.removeExpense': 'Ta bort utgiften', +}; +export default reservations; diff --git a/shared/src/i18n/sv/settings.ts b/shared/src/i18n/sv/settings.ts new file mode 100644 index 00000000..5b3dc7cf --- /dev/null +++ b/shared/src/i18n/sv/settings.ts @@ -0,0 +1,327 @@ +import type { TranslationStrings } from '../types'; + +const settings: TranslationStrings = { + 'settings.title': 'Inställningar', + 'settings.subtitle': 'Konfigurera dina personliga inställningar', + 'settings.tabs.display': 'Visning', + 'settings.tabs.map': 'Karta', + 'settings.tabs.notifications': 'Meddelanden', + 'settings.tabs.integrations': 'Integrationer', + 'settings.tabs.account': 'Konto', + 'settings.tabs.offline': 'Offline', + 'settings.tabs.about': 'Om', + 'settings.map': 'Karta', + 'settings.mapTemplate': 'Kartmall', + 'settings.mapTemplatePlaceholder.select': 'Välj mall...', + 'settings.mapDefaultHint': 'Lämna fältet tomt för OpenStreetMap (standard)', + 'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + 'settings.mapHint': 'URL-mall för kartrutor', + 'settings.mapProvider': 'Kartleverantör', + 'settings.mapProviderHint': 'Påverkar resplaneraren och resedagbokens kartor. Atlas använder alltid Leaflet.', + 'settings.mapLeafletSubtitle': 'Klassisk 2D, valfria rasterplattor', + 'settings.mapMapboxSubtitle': 'Vektorplattor, 3D-byggnader och terräng', + 'settings.mapMapLibreSubtitle': 'OpenFreeMap-vektorplattor, ingen token', + 'settings.mapOpenFreeMapStylePlaceholder': 'Välj en OpenFreeMap-stil', + 'settings.mapOpenFreeMapStyleHint': 'Förinställning eller OpenFreeMap-stil-URL. OpenFreeMap-stilar fungerar utan token.', + 'settings.mapExperimental': 'Experimentell', + 'settings.mapMapboxToken': 'Mapbox-åtkomsttoken', + 'settings.mapMapboxTokenHint': 'Offentlig token (pk.*) från', + 'settings.mapMapboxTokenLink': 'mapbox.com → Åtkomsttoken', + 'settings.mapStyle': 'Kartstil', + 'settings.mapStylePlaceholder': 'Välj en Mapbox-stil', + 'settings.mapStyleHint': 'Förinställda eller egna mapbox://styles/USER/ID länk', + 'settings.map3dBuildings': '3D-byggnader och terräng', + 'settings.map3dHint': 'Vinkel + verklighetstrogna 3D-byggnadsextrusioner — fungerar för alla stilar, inklusive satellitstil.', + 'settings.mapHighQuality': 'Högkvalitetsläge', + 'settings.mapHighQualityHint': 'Antialiasing + globprojektion för skarpare kanter och en realistisk världsbild.', + 'settings.mapHighQualityWarning': 'Kan påverka prestandan på enheter i lägre enheter.', + 'settings.mapTipLabel': 'Tips:', + 'settings.mapTip': + 'högerklicka och dra för att rotera eller luta kartan. Mittklicka för att lägga till en plats (högerklick är reserverat för rotation).', + 'settings.latitude': 'Latitud', + 'settings.longitude': 'Longitud', + 'settings.saveMap': 'Spara karta', + 'settings.apiKeys': 'API nycklar', + 'settings.mapsKey': 'Google Maps API nyckel', + 'settings.mapsKeyHint': 'För platssökning. Kräver Places API (nytt). Hämta på console.cloud.google.com', + 'settings.weatherKey': 'OpenWeatherMap API nyckel', + 'settings.weatherKeyHint': 'För väderdata. Gratis på openweathermap.org/api', + 'settings.keyPlaceholder': 'Ange nyckel...', + 'settings.configured': 'Inställd', + 'settings.saveKeys': 'Spara nycklar', + 'settings.display': 'Visning', + 'settings.colorMode': 'Färgläge', + 'settings.light': 'Ljust', + 'settings.dark': 'Mörkt', + 'settings.auto': 'Auto', + 'settings.language': 'Språk', + 'settings.temperature': 'Temperaturenhet', + 'settings.distance': 'Avståndsenhet', + 'settings.timeFormat': 'Tidsformat', + 'settings.bookingLabels': 'Etiketter för bokningsrutter', + 'settings.bookingLabelsHint': 'Visa stations- och flygplatsnamn på kartan. När funktionen är avstängd visas endast ikonen.', + 'settings.mapPoiPill': 'Utforska platser på kartan', + 'settings.mapPoiPillHint': + 'Visa en kategoriknapp på resekartan för att hitta restauranger, hotell och annat i närheten från OpenStreetMap.', + 'settings.blurBookingCodes': 'Blurra bokningskoder', + 'settings.optimizeFromAccommodation': 'Optimera rutten från boendet', + 'settings.optimizeFromAccommodationHint': + 'När du planerar en dag på bästa sätt bör du börja rutten vid det hotell där du vaknar och avsluta den vid det hotell där du checkar in samma kväll.', + 'settings.notifications': 'Meddelanden', + 'settings.notifyTripInvite': 'Inbjudningar till resor', + 'settings.notifyBookingChange': 'Ändringar i bokningen', + 'settings.notifyTripReminder': 'Påminnelser inför resan', + 'settings.notifyTodoDue': 'Att göra ska göras snart', + 'settings.notifyVacayInvite': 'Vacay samanslagnings inbjudningar', + 'settings.notifyPhotosShared': 'Delade foton (Immich)', + 'settings.notifyCollabMessage': 'Chatt meddelande (Samarbete)', + 'settings.notifyPackingTagged': 'Packningslista: uppgifter', + 'settings.notifyWebhook': 'Webhook meddelanden', + 'settings.notifyVersionAvailable': 'Ny version tillgänglig', + 'settings.notificationPreferences.email': 'E-post', + 'settings.notificationPreferences.webhook': 'Webhook', + 'settings.notificationPreferences.inapp': 'I appen', + 'settings.notificationPreferences.ntfy': 'Ntfy', + 'settings.notificationPreferences.noChannels': + 'Inga meddelandekanaler har konfigurerats. Be en administratör att ställa in e-post- eller webhook-meddelanden.', + 'settings.webhookUrl.label': 'Webhook URL', + 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', + 'settings.webhookUrl.hint': 'Ange din Discord-, Slack- eller anpassade webhook-URL för att få aviseringar.', + 'settings.webhookUrl.saved': 'Webhook URL sparad', + 'settings.webhookUrl.test': 'Test', + 'settings.webhookUrl.testSuccess': 'Test av webhook skickades utan problem', + 'settings.webhookUrl.testFailed': 'Test av webhook misslyckades', + 'settings.ntfyUrl.topicLabel': 'Ntfy Ämne', + 'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts', + 'settings.ntfyUrl.serverLabel': 'Ntfy Server URL (valfritt)', + 'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh', + 'settings.ntfyUrl.hint': + 'Ange ditt ntfy-ämne för att få push-meddelanden. Lämna fältet ”server” tomt om du vill använda standardinställningen som din administratör har konfigurerat.', + 'settings.ntfyUrl.tokenLabel': 'Åtkomsttoken (valfritt)', + 'settings.ntfyUrl.tokenHint': 'Krävs för lösenordsskyddade ämnen.', + 'settings.ntfyUrl.saved': 'Ntfy inställningar sparade', + 'settings.ntfyUrl.test': 'Test', + 'settings.ntfyUrl.testSuccess': 'Test av ntfy meddelande skickades utan problem', + 'settings.ntfyUrl.testFailed': 'Test av ntfy meddelande misslyckades', + 'settings.ntfyUrl.tokenCleared': 'Åtkomsttoken har raderats', + 'settings.notificationsDisabled': + 'Meddelanden är inte konfigurerade. Be en administratör att aktivera e-post- eller webhook-meddelanden.', + 'settings.notificationsActive': 'Aktiv kanal', + 'settings.notificationsManagedByAdmin': 'Meddelandehändelser konfigureras av din administratör.', + 'settings.on': 'På', + 'settings.off': 'Av', + 'settings.mcp.title': 'MCP-konfiguration', + 'settings.mcp.endpoint': 'MCP-ändpunkt', + 'settings.mcp.clientConfig': 'Klientkonfiguration', + 'settings.mcp.clientConfigHint': + 'Ersätt med ett API-token från listan nedan. Sökvägen till npx kan behöva anpassas efter ditt system (t.ex. C:\\PROGRA~1\\nodejs\\npx.cmd i Windows).', + 'settings.mcp.clientConfigHintOAuth': + 'Ersätt och med de autentiseringsuppgifter som visas i den OAuth 2.1-klient du skapade ovan. mcp-remote öppnar din webbläsare för att slutföra auktoriseringen första gången du ansluter. Sökvägen till npx kan behöva anpassas efter ditt system (t.ex. C:PROGRA~1\nodejs\npx.cmd i Windows).', + 'settings.mcp.copy': 'Kopiera', + 'settings.mcp.copied': 'Kopierad!', + 'settings.mcp.apiTokens': 'API Tokens', + 'settings.mcp.createToken': 'Skapa ny token', + 'settings.mcp.noTokens': 'Inga tokens ännu. Skapa en för att ansluta MCP-klienter.', + 'settings.mcp.tokenCreatedAt': 'Skapad', + 'settings.mcp.tokenUsedAt': 'Använt', + 'settings.mcp.deleteTokenTitle': 'Ta bort token', + 'settings.mcp.deleteTokenMessage': + 'Denna token kommer att sluta fungera omedelbart. Alla MCP-klienter som använder den kommer att förlora åtkomsten.', + 'settings.mcp.modal.createTitle': 'Skapa API-token', + 'settings.mcp.modal.tokenName': 'Token namn', + 'settings.mcp.modal.tokenNamePlaceholder': 't.ex. Claude Desktop, Arbetsdator', + 'settings.mcp.modal.creating': 'Skapar...', + 'settings.mcp.modal.create': 'Skapa token', + 'settings.mcp.modal.createdTitle': 'Token skapad', + 'settings.mcp.modal.createdWarning': + 'Denna token visas endast en gång. Kopiera och spara den nu – den går inte att återställa.', + 'settings.mcp.modal.done': 'Klar', + 'settings.mcp.toast.created': 'Token skapad', + 'settings.mcp.toast.createError': 'Misslyckades att skapa token', + 'settings.mcp.toast.deleted': 'Token raderad', + 'settings.mcp.toast.deleteError': 'Misslyckades att radera token', + 'settings.mcp.apiTokensDeprecated': + 'API Tokens är föråldrade och kommer att tas bort i en framtida version. Använd istället OAuth 2.1-klienter.', + 'settings.oauth.clients': 'OAuth 2.1 Klienter', + 'settings.oauth.clientsHint': + 'Registrera OAuth 2.1-klienter så att MCP-applikationer från tredje part (Claude Web, Cursor m.fl.) kan ansluta utan statiska token.', + 'settings.oauth.createClient': 'Ny klient', + 'settings.oauth.noClients': 'Inga OAuth klienter registrerade.', + 'settings.oauth.clientId': 'Klient ID', + 'settings.oauth.clientSecret': 'Klient hemlighet', + 'settings.oauth.deleteClient': 'Radera klient', + 'settings.oauth.deleteClientMessage': + 'Denna klient och alla aktiva sessioner kommer att tas bort permanent. Alla program som använder den kommer omedelbart att förlora åtkomsten.', + 'settings.oauth.rotateSecret': 'Rotera hemlighet', + 'settings.oauth.rotateSecretMessage': + 'En ny klientnyckel kommer att genereras och alla befintliga sessioner kommer omedelbart att ogiltigförklaras. Uppdatera din applikation innan du stänger den här dialogrutan.', + 'settings.oauth.rotateSecretConfirm': 'Rotera', + 'settings.oauth.rotateSecretConfirming': 'Roterar...', + 'settings.oauth.rotateSecretDoneTitle': 'Ny hemlighet genererad', + 'settings.oauth.rotateSecretDoneWarning': + 'Denna hemlighet visas endast en gång. Kopiera den nu och uppdatera din applikation – alla tidigare sessioner har ogiltigförklarats.', + 'settings.oauth.activeSessions': 'Aktiva OAuth-sessioner', + 'settings.oauth.sessionScopes': 'Tillämpningsområden', + 'settings.oauth.sessionExpires': 'Utgår', + 'settings.oauth.revoke': 'Återkalla', + 'settings.oauth.revokeSession': 'Återkalla session', + 'settings.oauth.revokeSessionMessage': 'Detta kommer omedelbart att återkalla åtkomsten för denna OAuth-session.', + 'settings.oauth.modal.createTitle': 'Registrera OAuth-klient', + 'settings.oauth.modal.presets': 'Snabba förinställningar', + 'settings.oauth.modal.clientName': 'Programnamn', + 'settings.oauth.modal.clientNamePlaceholder': 't.ex. Claude Web, Min MCP App', + 'settings.oauth.modal.redirectUris': 'Omdirigerings-URI:er', + 'settings.oauth.modal.redirectUrisPlaceholder': 'https://din-app.com/callback\nhttps://din-app.com/auth', + 'settings.oauth.modal.redirectUrisHint': 'En URI per rad. HTTPS krävs (med undantag för localhost). Exakt matchning krävs.', + 'settings.oauth.modal.scopes': 'Tillåtna tillämpningsområden', + 'settings.oauth.modal.scopesHint': + 'list_trips och get_trip_summary är alltid tillgängliga – inget tillämpningsområden krävs. De gör det möjligt för AI:n att hämta de res-ID:n som behövs för att använda andra verktyg.', + 'settings.oauth.modal.selectAll': 'Välj alla', + 'settings.oauth.modal.deselectAll': 'Avmarkera alla', + 'settings.oauth.modal.creating': 'Registrerar...', + 'settings.oauth.modal.create': 'Registrerar klient', + 'settings.oauth.modal.createdTitle': 'Klient registrerad', + 'settings.oauth.modal.createdWarning': 'Klienthemligheten visas endast en gång. Kopiera den nu – den går inte att återställa.', + 'settings.oauth.toast.createError': 'Det gick inte att registrera OAuth-klienten', + 'settings.oauth.toast.deleted': 'OAuth-klienten har tagits bort', + 'settings.oauth.toast.deleteError': 'Det gick inte att ta bort OAuth-klienten', + 'settings.oauth.toast.revoked': 'Sessionen har återkallats', + 'settings.oauth.toast.revokeError': 'Det gick inte att återkalla sessionen', + 'settings.oauth.toast.rotateError': 'Det gick inte att rotera klienthemligheten', + 'settings.oauth.modal.machineClient': 'Maskinklient (ingen inloggning via webbläsare)', + 'settings.oauth.modal.machineClientHint': + 'Använd behörighetsmodellen client_credentials — inga omdirigerings-URI:er behövs. Tokenet utfärdas direkt via client_id + client_secret och agerar som dig inom de valda tillämpningsområdena.', + 'settings.oauth.modal.machineClientUsage': + 'Få en token: POST /oauth/token med grant_type=client_credentials, client_id, och client_secret. Ingen webbläsare, inget uppdateringstoken.', + 'settings.oauth.badge.machine': 'maskin', + 'settings.account': 'Konto', + 'settings.about': 'Om', + 'settings.about.reportBug': 'Rapportera en bugg', + 'settings.about.reportBugHint': 'Har du upptäckt ett problem? Meddela oss', + 'settings.about.featureRequest': 'Förslag på ny funktion', + 'settings.about.featureRequestHint': 'Föreslå en ny funktion', + 'settings.about.wikiHint': 'Dokumentation och handledningar', + 'settings.about.supporters.badge': 'Månatliga bidragsgivare', + 'settings.about.supporters.title': 'Resekompisar för TREK', + 'settings.about.supporters.subtitle': + "Medan du planerar din nästa rutt bidrar dessa personer till att forma TREK:s framtid. Deras månatliga bidrag går direkt till utveckling och faktisk arbetstid – så att TREK förblir öppen källkod.", + 'settings.about.supporters.since': 'bidragsgivare sedan {date}', + 'settings.about.supporters.tierEmpty': 'Var först', + 'settings.about.supporter.tier.noReturnTicket': 'Ingen returbiljett', + 'settings.about.supporter.tier.lostLuggageVip': 'VIP-tjänst för förlorat bagage', + 'settings.about.supporter.tier.businessClassDreamer': 'Business Class-drömmaren', + 'settings.about.supporter.tier.budgetTraveller': 'Budgetresenären', + 'settings.about.supporter.tier.hostelBunkmate': 'Rumskamrat på vandrarhem', + 'settings.about.description': + 'TREK är en resplanerare som du själv kan driva och som hjälper dig att organisera dina resor från den första idén till det sista minnet. Dagsplanering, budget, packlistor, foton och mycket mer – allt på ett och samma ställe, på din egen server.', + 'settings.about.madeWith': 'Gjord med', + 'settings.about.madeBy': 'av Maurice och en växande open source-gemenskap.', + 'settings.username': 'Användarnamn', + 'settings.email': 'E-post', + 'settings.role': 'Roll', + 'settings.roleAdmin': 'Administratör', + 'settings.oidcLinked': 'Länkad med', + 'settings.changePassword': 'Ändra lösenord', + 'settings.currentPassword': 'Nuvarande lösenord', + 'settings.currentPasswordRequired': 'Nuvarande lösenord krävs', + 'settings.newPassword': 'Nytt lösenord', + 'settings.confirmPassword': 'Godkänn nya lösenordet', + 'settings.updatePassword': 'Uppdatera lösenord', + 'settings.passwordRequired': 'Ange nuvarande och nytt lösenord', + 'settings.passwordTooShort': 'Lösenordet måste bestå av minst 8 tecken', + 'settings.passwordMismatch': 'Lösenorden stämmer inte överens', + 'settings.passwordWeak': 'Lösenordet måste innehålla versaler, gemener, en siffra och ett specialtecken', + 'settings.passwordChanged': 'Lösenordet har ändrats', + 'settings.mustChangePassword': + 'Du måste byta lösenord innan du kan fortsätta. Välj ett nytt lösenord nedan.', + 'settings.deleteAccount': 'Ta bort konto', + 'settings.deleteAccountTitle': 'Ta bort ditt konto?', + 'settings.deleteAccountWarning': + 'Ditt konto samt alla dina resor, platser och filer kommer att raderas permanent. Denna åtgärd går inte att ångra.', + 'settings.deleteAccountConfirm': 'Radera permanent', + 'settings.deleteBlockedTitle': 'Det går inte att radera', + 'settings.deleteBlockedMessage': + 'Du är den enda administratören. Befordra en annan användare till administratör innan du raderar ditt konto.', + 'settings.roleUser': 'Användare', + 'settings.saveProfile': 'Spara profil', + 'settings.toast.mapSaved': 'Kartinställningarna har sparats', + 'settings.toast.keysSaved': 'API nycklar sparade', + 'settings.toast.displaySaved': 'Visningsinställningarna har sparats', + 'settings.toast.profileSaved': 'Profil sparad', + 'settings.uploadAvatar': 'Ladda upp profilbild', + 'settings.removeAvatar': 'Ta bort profilbild', + 'settings.avatarUploaded': 'Profilbilden har uppdaterats', + 'settings.avatarRemoved': 'Profilbilden har tagits bort', + 'settings.avatarError': 'Uppladdning misslyckades', + 'settings.mfa.title': 'Tvåfaktorsautentisering (2FA)', + 'settings.mfa.description': + 'Lägger till ett extra steg när du loggar in med e-postadress och lösenord. Använd en autentiseringsapp (Google Authenticator, Authy m.fl.).', + 'settings.mfa.requiredByPolicy': + 'Din administratör kräver tvåfaktorsautentisering. Konfigurera en autentiseringsapp nedan innan du fortsätter.', + 'settings.mfa.backupTitle': 'Säkerhetskoder', + 'settings.mfa.backupDescription': 'Använd dessa engångskoder för säkerhetskopiering om du förlorar åtkomsten till din autentiseringsapp.', + 'settings.mfa.backupWarning': 'Spara dessa koder nu. Varje kod kan endast användas en gång.', + 'settings.mfa.backupCopy': 'Copy codes', + 'settings.mfa.backupDownload': 'Ladda ner TXT', + 'settings.mfa.backupPrint': 'Skriv ut / PDF', + 'settings.mfa.backupCopied': 'Säkerhetskoder har kopierats', + 'settings.mfa.enabled': '2FA är aktiverat på ditt konto.', + 'settings.mfa.disabled': '2FA är inte aktiverat.', + 'settings.mfa.setup': 'Konfigurera autentiseringsappen', + 'settings.mfa.scanQr': 'Skanna den här QR-koden med din app, eller ange koden manuellt.', + 'settings.mfa.secretLabel': 'Hemlig nyckel (manuell inmatning)', + 'settings.mfa.codePlaceholder': '6-siffrig kod', + 'settings.mfa.enable': 'Aktivera 2FA', + 'settings.mfa.cancelSetup': 'Avbryt', + 'settings.mfa.disableTitle': 'Inaktivera 2FA', + 'settings.mfa.disableHint': 'Ange ditt kontolösenord och en aktuell kod från din autentiseringsapp.', + 'settings.mfa.disable': 'Inaktivera 2FA', + 'settings.mfa.toastEnabled': 'Tvåfaktorsautentisering är aktiverad', + 'settings.mfa.toastDisabled': 'Tvåfaktorsautentisering är inaktiverad', + 'settings.mfa.demoBlocked': 'Finns inte i demoläge', + 'settings.currency': 'Valuta', + 'settings.currencyHint': 'Alla belopp under kostnader omräknas till och redovisas i denna valuta.', + 'settings.passkey.title': 'Inloggningsnycklar', + 'settings.passkey.description': + 'Logga in snabbare och med bättre skydd mot nätfiske med en inloggningsnyckel – ditt fingeravtryck, ditt ansikte, din PIN-kod eller en hårdvarunyckel. Ditt lösenord finns kvar som reserv.', + 'settings.passkey.notConfigured': + 'Inloggningsnycklar är aktiverade men ännu inte fullständigt konfigurerade på den här servern. Be din administratör att ställa in WebAuthn-domänen.', + 'settings.passkey.add': 'Lägg till en inloggningsnyckel', + 'settings.passkey.addTitle': 'Lägg till en inloggningsnyckel', + 'settings.passkey.passwordPrompt': 'Bekräfta ditt nuvarande lösenord och följ sedan anvisningarna på enheten.', + 'settings.passkey.passwordRequired': 'Du måste ange ditt nuvarande lösenord.', + 'settings.passkey.namePlaceholder': 'Namn (valfritt, t.ex. ”iPhone”)', + 'settings.passkey.addedToast': 'Inloggningsnyckel tillagd', + 'settings.passkey.added': 'Tillagd', + 'settings.passkey.addError': 'Kunde inte lägga till inloggningsnyckel', + 'settings.passkey.cancelled': 'Konfigurationen av inloggningsnyckel har avbrutits', + 'settings.passkey.deleted': 'Inloggningsnyckel borttagen', + 'settings.passkey.deleteConfirm': 'Ta bort denna inloggningsnyckel? Godkänn med ditt lösenord', + 'settings.passkey.rename': 'Döp om', + 'settings.passkey.defaultName': 'Inloggningsnyckel', + 'settings.passkey.synced': 'Synkroniserad', + 'settings.passkey.deviceBound': 'Denna enhet', + 'settings.passkey.lastUsed': 'Senast använd', + 'settings.passkey.neverUsed': 'Aldrig använd', + 'settings.airtrail.title': 'AirTrail', + 'settings.airtrail.hint': + 'Anslut din egenhostade AirTrail för att importera och synkronisera flygningar. Skapa en API-nyckel i AirTrail under Inställningar → Säkerhet.', + 'settings.airtrail.url': 'Instans URL', + 'settings.airtrail.apiKey': 'API nyckel', + 'settings.airtrail.apiKeyPlaceholder': 'API-nyckel för innehavare', + 'settings.airtrail.apiKeyHint': 'Skapad i AirTrail under Inställningar → Säkerhet. Lagras i krypterad form.', + 'settings.airtrail.allowInsecureTls': 'Tillåt självsignerade certifikat', + 'settings.airtrail.allowInsecureTlsHint': 'Aktivera endast för en betrodd instans i ditt eget nätverk.', + 'settings.airtrail.writeBack': 'Skriv tillbaka ändringarna till AirTrail', + 'settings.airtrail.writeBackHint': + 'Avstängt som standard: AirTrail är den primära källan och TREK läser endast från den. Aktivera funktionen för att skicka ändringar som gjorts i TREK tillbaka till AirTrail.', + 'settings.airtrail.connected': 'Ansluten', + 'settings.airtrail.notConnected': 'Inte ansluten', + 'settings.airtrail.toast.saved': 'AirTrail anslutning sparad', + 'settings.airtrail.toast.saveError': 'Det gick inte att spara anslutningen', + 'settings.airtrail.test.button': 'Testa anslutningen', + 'settings.airtrail.test.success': 'Ansluten — {count} flygning(ar) hittades', + 'settings.airtrail.test.failed': 'Anslutning misslyckades', +}; + +export default settings; diff --git a/shared/src/i18n/sv/share.ts b/shared/src/i18n/sv/share.ts new file mode 100644 index 00000000..6c104ecc --- /dev/null +++ b/shared/src/i18n/sv/share.ts @@ -0,0 +1,16 @@ +import type { TranslationStrings } from '../types'; + +const share: TranslationStrings = { + 'share.linkTitle': 'Allmän länk', + 'share.linkHint': + 'Skapa en länk som vem som helst kan använda för att se den här resan utan att logga in. Endast läsbehörighet – redigering är inte möjlig.', + 'share.createLink': 'Skapa länk', + 'share.deleteLink': 'Radera länk', + 'share.createError': 'Kunde inte skapa länk', + 'share.permMap': 'Karta & Plan', + 'share.permBookings': 'Bokningar', + 'share.permPacking': 'Packning', + 'share.permBudget': 'Budget', + 'share.permCollab': 'Chatt', +}; +export default share; diff --git a/shared/src/i18n/sv/shared.ts b/shared/src/i18n/sv/shared.ts new file mode 100644 index 00000000..2b4fb7af --- /dev/null +++ b/shared/src/i18n/sv/shared.ts @@ -0,0 +1,21 @@ +import type { TranslationStrings } from '../types'; + +const shared: TranslationStrings = { + 'shared.expired': 'Länken har gått ut eller är ogiltig', + 'shared.expiredHint': 'Den här länken till den delade resan är inte längre aktiv.', + 'shared.readOnly': 'Delad vy i skrivskyddat läge', + 'shared.tabPlan': 'Plan', + 'shared.tabBookings': 'Bokningar', + 'shared.tabPacking': 'Packning', + 'shared.tabBudget': 'Budget', + 'shared.tabChat': 'Chatt', + 'shared.days': 'dagar', + 'shared.places': 'platser', + 'shared.other': 'Andra', + 'shared.totalBudget': 'Total Budget', + 'shared.messages': 'meddelanden', + 'shared.sharedVia': 'Delad via', + 'shared.confirmed': 'Godkänt', + 'shared.pending': 'Pendlande', +}; +export default shared; diff --git a/shared/src/i18n/sv/stats.ts b/shared/src/i18n/sv/stats.ts new file mode 100644 index 00000000..5f48bdec --- /dev/null +++ b/shared/src/i18n/sv/stats.ts @@ -0,0 +1,13 @@ +import type { TranslationStrings } from '../types'; + +const stats: TranslationStrings = { + 'stats.countries': 'Länder', + 'stats.cities': 'Städer', + 'stats.trips': 'Resor', + 'stats.places': 'Platser', + 'stats.worldProgress': 'Världsliga framsteg', + 'stats.visited': 'besökta', + 'stats.remaining': 'återstår', + 'stats.visitedCountries': 'Besökta länder', +}; +export default stats; diff --git a/shared/src/i18n/sv/system_notice.ts b/shared/src/i18n/sv/system_notice.ts new file mode 100644 index 00000000..c6ef3b32 --- /dev/null +++ b/shared/src/i18n/sv/system_notice.ts @@ -0,0 +1,58 @@ +import type { TranslationStrings } from '../types'; + +const system_notice: TranslationStrings = { + 'system_notice.v3_photos.title': 'Bilderna har flyttats i version 3.0', + 'system_notice.v3_photos.body': + '**Bilder** i resplaneraren har tagits bort. Dina bilder är i säkerhet – TREK har aldrig ändrat ditt Immich- eller Synology-bibliotek.\n\nBilderna finns nu i tillägget **Journey**. Journey är valfritt – om det ännu inte är tillgängligt kan du be din administratör att aktivera det under Admin → Tillägg.', + 'system_notice.v3_journey.title': 'Upptäck Journey – resedagbok', + 'system_notice.v3_journey.body': + 'Dokumentera dina resor som innehållsrika reseskildringar med tidslinjer, fotoalbum och interaktiva kartor.', + 'system_notice.v3_journey.cta_label': 'Öppna Journey', + 'system_notice.v3_journey.highlight_timeline': 'Dag-för-dag-tidslinje och fotoalbum', + 'system_notice.v3_journey.highlight_photos': 'Importera från Immich eller Synology', + 'system_notice.v3_journey.highlight_share': 'Dela offentligt – ingen inloggning krävs', + 'system_notice.v3_journey.highlight_export': 'Exportera som en fotobok i PDF-format', + 'system_notice.v3_features.title': 'Fler höjdpunkter i version 3.0', + 'system_notice.v3_features.body': 'Några ytterligare saker som är bra att veta om den här utgåvan.', + 'system_notice.v3_features.highlight_dashboard': 'Omdesign av instrumentpanelen med fokus på mobilanvändning', + 'system_notice.v3_features.highlight_offline': 'Fullständigt offline-läge som PWA', + 'system_notice.v3_features.highlight_search': 'Automatisk komplettering vid platssökning i realtid', + 'system_notice.v3_features.highlight_import': 'Importera platser från KMZ-/KML-filer', + 'system_notice.v3_mcp.title': 'MCP: OAuth 2.1 uppgradering', + 'system_notice.v3_mcp.body': + 'MCP-integrationen har genomgått en fullständig omarbetning. OAuth 2.1 är nu den rekommenderade autentiseringsmetoden. De äldre statiska tokenen (trek_…) är utfasade och kommer att tas bort i en kommande version.', + 'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 rekommenderas (mcp-remote)', + 'system_notice.v3_mcp.highlight_scopes': '24 detaljerade behörighetsområden', + 'system_notice.v3_mcp.highlight_deprecated': 'Statiska trek_-token är utfasade', + 'system_notice.v3_mcp.highlight_tools': 'Utökad verktygslåda och uppmaningar', + 'system_notice.v3_thankyou.title': 'Ett personligt meddelande från mig', + 'system_notice.v3_thankyou.body': + "Innan du går – vill jag ta en stund.\n\nTREK började som ett sidoprojekt som jag skapade för mina egna resor. Jag hade aldrig kunnat föreställa mig att det skulle växa till något som 4 000 av er nu litar på för att planera era äventyr. Varje stjärna, varje problem, varje önskemål om funktioner – jag läser dem alla, och de är det som håller mig igång under sena nätter mellan mitt heltidsjobb och universitetet.\n\nJag vill att ni ska veta: TREK kommer alltid att vara öppen källkod, alltid självhostat, alltid ert. Ingen spårning, inga prenumerationer, inga förbehåll. Bara ett verktyg skapat av någon som älskar att resa lika mycket som ni.\n\nEtt särskilt tack till [jubnl](https://github.com/jubnl) – du har blivit en fantastisk samarbetspartner. Så mycket av det som gör 3.0 så bra bär dina avtryck. Tack för att du trodde på det här projektet när det fortfarande var lite ojämnt i kanterna.\n\nOch till var och en av er som rapporterade ett fel, översatte en sträng, delade TREK med en vän eller helt enkelt använde det för att planera en resa – **tack**. Ni är anledningen till att det här finns.\n\nSkål för många fler äventyr tillsammans.\n\n— Maurice\n\n---\n\n[Gå med i communityn på Discord](https://discord.gg/7Q6M6jDwzf)\n\nOm TREK gör dina resor bättre, så håller en [liten kaffe](https://ko-fi.com/mauriceboe) alltid lamporna tända.", + 'system_notice.v3014_whitespace_collision.title': 'Åtgärd krävs: konflikt mellan användarkonton', + 'system_notice.v3014_whitespace_collision.body': + 'Uppgraderingen till version 3.0.14 upptäckte en eller flera konflikter mellan användarnamn eller e-postadresser som orsakades av blanksteg i början eller slutet av lagrade konton. De berörda kontona döptes om automatiskt. Kontrollera serverloggarna efter rader som börjar med **[migration] WHITESPACE COLLISION** för att identifiera vilka konton som behöver granskas.', + 'system_notice.welcome_v1.title': 'Välkommen till TREK', + 'system_notice.welcome_v1.body': + 'Din allt-i-ett-resplanerare. Skapa resplaner, dela resor med vänner och håll ordning på allt – både online och offline.', + 'system_notice.welcome_v1.cta_label': 'Planera en resa', + 'system_notice.welcome_v1.hero_alt': 'Ett naturskönt resmål med TREK planering UI-överlagring', + 'system_notice.welcome_v1.highlight_plan': 'Dag-för-dag-resplaner för alla typer av resor', + 'system_notice.welcome_v1.highlight_share': 'Samarbeta med resepartners', + 'system_notice.welcome_v1.highlight_offline': 'Fungerar offline på mobilen', + 'system_notice.dev_test_modal.title': '[Dev] Meddelande om test', + 'system_notice.dev_test_modal.body': 'Detta är ett testmeddelande avsett endast för utvecklare.', + 'system_notice.thank_you_support.title': 'Tack för att du använder TREK', + 'system_notice.thank_you_support.body': + 'Ett snabbt tack för att du installerade TREK – det betyder verkligen mycket.\n\nJag är en ensam utvecklare och bygger TREK på min fritid. Det började som ett litet verktyg bara för mina egna resor, och jag är ärligt talat överväldigad av allt stöd och intresse från communityn sedan dess. TREK är skapat med mycket hjärta från min sida – men också tack vare de många fantastiska externa bidragsgivare som har hjälpt till att forma det.\n\n**TREK är öppen källkod och helt gratis – och kommer alltid att förbli så. Inga betalnivåer, inga prenumerationer, inga förbehåll. Jag lovar.**\n\nOm TREK är användbart för dig och du vill stödja utvecklingen, så hjälper en liten kaffe mig verkligen att fortsätta bygga – ingen press alls, men varje kopp håller de sena nätterna igång.\n\nTack för att du är här.\n\n— Maurice', + 'system_notice.thank_you_support.highlight_opensource': '100 % öppen källkod på GitHub', + 'system_notice.thank_you_support.highlight_free': 'Gratis för alltid – aldrig några betalnivåer', + 'system_notice.thank_you_support.highlight_community': 'Byggt tillsammans med communityn', + 'system_notice.thank_you_support.cta_bmc': 'Buy Me a Coffee', + 'system_notice.thank_you_support.cta_kofi': 'Stöd på Ko-fi', + 'system_notice.pager.prev': 'Tidigare meddelande', + 'system_notice.pager.next': 'Nästa meddelande', + 'system_notice.pager.counter': '{current} / {total}', + 'system_notice.pager.goto': 'Gå till meddelandet {n}', + 'system_notice.pager.position': 'Meddlenade {current} av {total}', +}; +export default system_notice; diff --git a/shared/src/i18n/sv/todo.ts b/shared/src/i18n/sv/todo.ts new file mode 100644 index 00000000..001b30c6 --- /dev/null +++ b/shared/src/i18n/sv/todo.ts @@ -0,0 +1,40 @@ +import type { TranslationStrings } from '../types'; + +const todo: TranslationStrings = { + 'todo.subtab.packing': 'Packlista', + 'todo.subtab.todo': 'Att göra', + 'todo.completed': 'slutförd', + 'todo.filter.all': 'Alla', + 'todo.filter.open': 'Öppna', + 'todo.filter.done': 'Slutförda', + 'todo.uncategorized': 'Okategoriserat', + 'todo.namePlaceholder': 'Uppgiftsnamn', + 'todo.descriptionPlaceholder': 'Beskrivning (valfritt)', + 'todo.unassigned': 'Ej tilldelad', + 'todo.noCategory': 'Ingen kategori', + 'todo.hasDescription': 'Har beskrivning', + 'todo.addItem': 'Lägg till ny uppgift', + 'todo.sidebar.sortBy': 'Sortera efter', + 'todo.priority': 'Prioritet', + 'todo.newCategoryLabel': 'ny', + 'todo.newCategory': 'Kategori namn', + 'todo.addCategory': 'Lägg till kategori', + 'todo.newItem': 'Ny uppgift', + 'todo.empty': 'Ingen uppgifter. Lägg till en uppgift för att komma igång!', + 'todo.filter.my': 'Mina uppgifter', + 'todo.filter.overdue': 'Förfallen', + 'todo.sidebar.tasks': 'Uppgifter', + 'todo.sidebar.categories': 'Kategorier', + 'todo.detail.title': 'Uppgift', + 'todo.detail.description': 'Beskrivning', + 'todo.detail.category': 'Kategori', + 'todo.detail.dueDate': 'Förfallodag', + 'todo.detail.assignedTo': 'Tilldelad till', + 'todo.detail.delete': 'Radera', + 'todo.detail.save': 'Spara ändringar', + 'todo.sortByPrio': 'Prioritet', + 'todo.detail.priority': 'Prioritet', + 'todo.detail.noPriority': 'Ingen', + 'todo.detail.create': 'Skapa uppgift', +}; +export default todo; diff --git a/shared/src/i18n/sv/transport.ts b/shared/src/i18n/sv/transport.ts new file mode 100644 index 00000000..b568c48a --- /dev/null +++ b/shared/src/i18n/sv/transport.ts @@ -0,0 +1,10 @@ +import type { TranslationStrings } from '../types'; + +const transport: TranslationStrings = { + 'transport.addTransport': 'Lägg till transport', + 'transport.modalTitle.create': 'Lägg till transport', + 'transport.modalTitle.edit': 'Redigera transport', + 'transport.title': 'Transporter', + 'transport.addManual': 'Manuell Transport', +}; +export default transport; diff --git a/shared/src/i18n/sv/trip.ts b/shared/src/i18n/sv/trip.ts new file mode 100644 index 00000000..fcd40dce --- /dev/null +++ b/shared/src/i18n/sv/trip.ts @@ -0,0 +1,31 @@ +import type { TranslationStrings } from '../types'; + +const trip: TranslationStrings = { + 'trip.tabs.plan': 'Plan', + 'trip.tabs.transports': 'Transporter', + 'trip.tabs.reservations': 'Bokningar', + 'trip.tabs.reservationsShort': 'Bokningar', + 'trip.tabs.packing': 'Packlista', + 'trip.tabs.packingShort': 'Packning', + 'trip.tabs.lists': 'Listor', + 'trip.tabs.listsShort': 'Listor', + 'trip.tabs.budget': 'Kostnader', + 'trip.tabs.files': 'Filer', + 'trip.loading': 'Laddar resa...', + 'trip.loadingPhotos': 'Laddar plats foton...', + 'trip.mobilePlan': 'Plan', + 'trip.mobilePlaces': 'Platser', + 'trip.toast.placeUpdated': 'Plats uppdaterad', + 'trip.toast.placeAdded': 'Plats tillagd', + 'trip.toast.placeDeleted': 'Plats raderad', + 'trip.toast.selectDay': 'Vänligen välj en dag först', + 'trip.toast.assignedToDay': 'Plats tilldelad till dag', + 'trip.toast.reorderError': 'Misslyckades att ordna om', + 'trip.toast.reservationUpdated': 'Reservation uppdaterad', + 'trip.toast.reservationAdded': 'Reservation tillagd', + 'trip.toast.deleted': 'Raderad', + 'trip.confirm.deletePlace': 'Är du säker på att du vill radera denna plats?', + 'trip.confirm.deletePlaces': 'Radera {count} platser?', + 'trip.toast.placesDeleted': '{count} platser raderade', +}; +export default trip; diff --git a/shared/src/i18n/sv/trips.ts b/shared/src/i18n/sv/trips.ts new file mode 100644 index 00000000..c4ddd337 --- /dev/null +++ b/shared/src/i18n/sv/trips.ts @@ -0,0 +1,16 @@ +import type { TranslationStrings } from '../types'; + +const trips: TranslationStrings = { + 'trips.memberRemoved': '{username} borttagen', + 'trips.memberRemoveError': 'Det gick inte att ta bort', + 'trips.memberAdded': '{username} tillagd', + 'trips.memberAddError': 'Det gick inte att lägga till', + 'trips.reminder': 'Påminnelse', + 'trips.reminderNone': 'Ingen', + 'trips.reminderDay': 'dag', + 'trips.reminderDays': 'dagar', + 'trips.reminderCustom': 'Anpassad', + 'trips.reminderDaysBefore': 'dagar innan avresa', + 'trips.reminderDisabledHint': 'Resepåminnelser är inaktiverade. Aktivera dem under Admin > Inställningar > Meddelanden.', +}; +export default trips; diff --git a/shared/src/i18n/sv/undo.ts b/shared/src/i18n/sv/undo.ts new file mode 100644 index 00000000..57295c05 --- /dev/null +++ b/shared/src/i18n/sv/undo.ts @@ -0,0 +1,22 @@ +import type { TranslationStrings } from '../types'; + +const undo: TranslationStrings = { + 'undo.button': 'Ångra', + 'undo.tooltip': 'Ångra: {action}', + 'undo.assignPlace': 'Plats tilldelad dagen', + 'undo.removeAssignment': 'Plats har tagits bort från dagen', + 'undo.reorder': 'Platserna har ordnats om', + 'undo.optimize': 'Rutt optimerad', + 'undo.deletePlace': 'Plats borttagen', + 'undo.deletePlaces': 'Platser borttagna', + 'undo.moveDay': 'Plats har flyttats till en annan dag', + 'undo.lock': 'Plats lås växlad', + 'undo.importGpx': 'GPX importering', + 'undo.importKeyholeMarkup': 'KMZ/KML importering', + 'undo.importGoogleList': 'Google Maps importering', + 'undo.importNaverList': 'Naver Maps importering', + 'undo.importBooking': 'Boknings godkännande importering', + 'undo.addPlace': 'Plats tillagd', + 'undo.done': 'Återställd: {action}', +}; +export default undo; diff --git a/shared/src/i18n/sv/vacay.ts b/shared/src/i18n/sv/vacay.ts new file mode 100644 index 00000000..b28c3182 --- /dev/null +++ b/shared/src/i18n/sv/vacay.ts @@ -0,0 +1,93 @@ +import type { TranslationStrings } from '../types'; + +const vacay: TranslationStrings = { + 'vacay.subtitle': 'Planera och hantera semesterdagar', + 'vacay.settings': 'Inställningar', + 'vacay.year': 'År', + 'vacay.addYear': 'Lägg till nästa år', + 'vacay.addPrevYear': 'Lägg till föregående år', + 'vacay.removeYear': 'Ta bort år', + 'vacay.removeYearConfirm': 'Ta bort {year}?', + 'vacay.removeYearHint': 'Alla semesterposter och företagshelger för i år kommer att raderas permanent.', + 'vacay.remove': 'Ta bort', + 'vacay.persons': 'Personer', + 'vacay.noPersons': 'Inga personer tillagda', + 'vacay.addPerson': 'Lägg till person', + 'vacay.editPerson': 'Redigera person', + 'vacay.removePerson': 'Radera person', + 'vacay.removePersonConfirm': 'Radera {name}?', + 'vacay.removePersonHint': 'Alla semesterposter för den här personen kommer att raderas permanent.', + 'vacay.personName': 'Namn', + 'vacay.personNamePlaceholder': 'Ange namn', + 'vacay.color': 'Färg', + 'vacay.add': 'Lägg till', + 'vacay.legend': 'Legend', + 'vacay.publicHoliday': 'Helgdag', + 'vacay.companyHoliday': 'Företagshelg', + 'vacay.weekend': 'Helgen', + 'vacay.modeVacation': 'Semester', + 'vacay.modeCompany': 'Företagshelg', + 'vacay.entitlement': 'Rättighet', + 'vacay.entitlementDays': 'Dagar', + 'vacay.used': 'Använt', + 'vacay.remaining': 'Kvar', + 'vacay.carriedOver': 'från {year}', + 'vacay.blockWeekends': 'Blockera Helger', + 'vacay.blockWeekendsHint': 'Förhindra att semesterdagar läggs in på helgdagar', + 'vacay.weekendDays': 'Helgdagar', + 'vacay.mon': 'Mån', + 'vacay.tue': 'Tis', + 'vacay.wed': 'Ons', + 'vacay.thu': 'Tor', + 'vacay.fri': 'Fre', + 'vacay.sat': 'Lör', + 'vacay.sun': 'Sön', + 'vacay.publicHolidays': 'Allmänna helgdagar', + 'vacay.publicHolidaysHint': 'Markera allmäna helgdagar i kalendern', + 'vacay.selectCountry': 'Välj land', + 'vacay.selectRegion': 'Välj region (valfritt)', + 'vacay.addCalendar': 'Lägg till kalender', + 'vacay.calendarLabel': 'Etikett (valfritt)', + 'vacay.calendarColor': 'Färg', + 'vacay.noCalendars': 'Inga helgdagskalendrar har ännu tillagts', + 'vacay.companyHolidays': 'Företagshelger', + 'vacay.companyHolidaysHint': 'Tillåt markering av helgdagar som gäller för hela företaget', + 'vacay.companyHolidaysNoDeduct': 'Företagets helgdagar räknas inte in i semesterdagarna.', + 'vacay.weekStart': 'Vecka börjar på', + 'vacay.weekStartHint': 'Välj om kalenderveckan ska börja på måndag eller söndag', + 'vacay.carryOver': 'Överföring', + 'vacay.carryOverHint': 'Överför automatiskt återstående semesterdagar till nästa år', + 'vacay.sharing': 'Dela', + 'vacay.sharingHint': 'Dela dina semesterplaner med andra TREK användare', + 'vacay.owner': 'Ägare', + 'vacay.shareEmailPlaceholder': 'E-postadress för TREK användare', + 'vacay.shareSuccess': 'Planen har delats utan problem', + 'vacay.shareError': 'Kunde inte dela planen', + 'vacay.dissolve': 'Upplös Sammanslagning', + 'vacay.dissolveHint': 'Återigen separata kalendrar. Dina inlägg kommer att sparas.', + 'vacay.dissolveAction': 'Upplös', + 'vacay.dissolved': 'Kalendrar separerade', + 'vacay.fusedWith': 'I kombination med', + 'vacay.you': 'dig', + 'vacay.noData': 'Ingen data', + 'vacay.changeColor': 'Ändra färg', + 'vacay.inviteUser': 'Bjud in användare', + 'vacay.inviteHint': 'Bjud in en annan TREK användare att dela en gemensam semesterkalender.', + 'vacay.selectUser': 'Välj användare', + 'vacay.sendInvite': 'Skicka inbjudan', + 'vacay.inviteSent': 'Inbjudan skickad', + 'vacay.inviteError': 'Kunde inte skicka inbjudan', + 'vacay.pending': 'väntande', + 'vacay.noUsersAvailable': 'Inga användare tillgängliga', + 'vacay.accept': 'Godkänn', + 'vacay.decline': 'Neka', + 'vacay.acceptFusion': 'Godkänn & Slå samman', + 'vacay.inviteTitle': 'Sammanslagning Begäran', + 'vacay.inviteWantsToFuse': 'vill dela en semesterkalender med dig.', + 'vacay.fuseInfo1': 'Ni båda kommer att kunna se alla semesteranteckningar i en gemensam kalender.', + 'vacay.fuseInfo2': 'Båda parter kan skapa och redigera poster åt varandra.', + 'vacay.fuseInfo3': 'Båda parter kan radera poster och ändra semesterrättigheter.', + 'vacay.fuseInfo4': 'Inställningar som allmänna helgdagar och företagshelgdagar delas.', + 'vacay.fuseInfo5': 'Sammanslagningen kan när som helst upphävas av endera parten. Dina uppgifter kommer att bevaras.', +}; +export default vacay; diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts index 2d55ed3d..69c83b05 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -349,6 +349,7 @@ const admin: TranslationStrings = { 'Bu örnekteki herkes için varsayılan harita. Her kullanıcı bunu yine de kendi ayarlarında değiştirebilir.', 'admin.defaultSettings.providerLeaflet': 'Standart (ücretsiz)', 'admin.defaultSettings.providerMapbox': 'Mapbox (3D)', + 'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)', 'admin.defaultSettings.mapboxToken': 'Paylaşılan Mapbox jetonu', 'admin.defaultSettings.mapboxTokenHint': "Kendi jetonunu girmemiş her kullanıcı için kullanılır — böylece anahtarı tek tek paylaşmadan tüm örnek Mapbox'ı kullanır. Şifrelenmiş olarak saklanır.", diff --git a/shared/src/i18n/tr/settings.ts b/shared/src/i18n/tr/settings.ts index 87ace1f0..8dfb9c77 100644 --- a/shared/src/i18n/tr/settings.ts +++ b/shared/src/i18n/tr/settings.ts @@ -20,6 +20,7 @@ const settings: TranslationStrings = { 'settings.mapProviderHint': 'Seyahat planlayıcı ve Journey haritalarını etkiler. Atlas her zaman Leaflet kullanır.', 'settings.mapLeafletSubtitle': 'Klasik 2D, herhangi bir raster kutucuk', 'settings.mapMapboxSubtitle': 'Vektör kutucuklar, 3D binalar ve arazi', + 'settings.mapMapLibreSubtitle': 'OpenFreeMap vektör kutucuklar, anahtar gerekmez', 'settings.mapExperimental': 'Deneysel', 'settings.mapMapboxToken': 'Mapbox Erişim Anahtarı', 'settings.mapMapboxTokenHint': 'Genel anahtar (pk.*) kaynağı:', @@ -27,6 +28,8 @@ const settings: TranslationStrings = { 'settings.mapStyle': 'Harita Stili', 'settings.mapStylePlaceholder': 'Bir Mapbox stili seçin', 'settings.mapStyleHint': 'Ön ayar veya kendi mapbox://styles/KULLANICI/ID adresiniz', + 'settings.mapOpenFreeMapStylePlaceholder': 'Bir OpenFreeMap stili seçin', + 'settings.mapOpenFreeMapStyleHint': "Ön ayar veya OpenFreeMap stil URL'si. OpenFreeMap stilleri anahtar gerektirmeden çalışır.", 'settings.map3dBuildings': '3D Binalar ve Arazi', 'settings.map3dHint': 'Eğim + gerçek 3D bina çıkıntıları — uydu dahil her stilde çalışır.', 'settings.mapHighQuality': 'Yüksek Kalite Modu', @@ -53,6 +56,7 @@ const settings: TranslationStrings = { 'settings.auto': 'Otomatik', 'settings.language': 'Dil', 'settings.temperature': 'Sıcaklık Birimi', + 'settings.distance': 'Mesafe Birimi', 'settings.timeFormat': 'Saat Biçimi', 'settings.bookingLabels': 'Rezervasyon rota etiketleri', 'settings.bookingLabelsHint': 'Haritada istasyon / havalimanı adlarını göster. Kapalıyken yalnızca simge görünür.', diff --git a/shared/src/i18n/tr/system_notice.ts b/shared/src/i18n/tr/system_notice.ts index 261652da..378318e1 100644 --- a/shared/src/i18n/tr/system_notice.ts +++ b/shared/src/i18n/tr/system_notice.ts @@ -41,6 +41,14 @@ const system_notice: TranslationStrings = { 'system_notice.welcome_v1.highlight_offline': 'Mobilde çevrimdışı çalışır', 'system_notice.dev_test_modal.title': '[Dev] Test bildirimi', 'system_notice.dev_test_modal.body': 'Bu yalnızca geliştirme ortamına özel bir test bildirimidir.', + 'system_notice.thank_you_support.title': "TREK'i kullandığınız için teşekkürler", + 'system_notice.thank_you_support.body': + "TREK'i yüklediğin için kısaca teşekkür etmek istiyorum — bu benim için gerçekten çok değerli.\n\nTek başına çalışan bir geliştiriciyim ve TREK'i boş zamanlarımda geliştiriyorum. Başlangıçta yalnızca kendi seyahatlerim için yaptığım küçük bir araçtı; o günden beri topluluktan gelen destek ve ilgi beni gerçekten hayrete düşürdü. TREK'i kendi adıma büyük bir sevgiyle hazırlıyorum — ama ona şekil vermeye yardım eden onca harika dış katkıcının da büyük payı var.\n\n**TREK açık kaynaklı ve tamamen ücretsiz — ve sonsuza dek böyle kalacak. Ücretli paketler yok, abonelikler yok, gizli bir şart yok. Söz veriyorum.**\n\nTREK işine yarıyorsa ve gelişimine destek olmak istersen, küçük bir kahve geliştirmeye devam etmeme cidden yardımcı oluyor — hiçbir baskı yok ama her fincan, o geç saatlere kadar süren çalışmaları ayakta tutuyor.\n\nBurada olduğun için teşekkür ederim.\n\n— Maurice", + 'system_notice.thank_you_support.highlight_opensource': "GitHub'da %100 açık kaynak", + 'system_notice.thank_you_support.highlight_free': 'Sonsuza dek ücretsiz — hiçbir ücretli plan yok', + 'system_notice.thank_you_support.highlight_community': 'Toplulukla birlikte geliştirildi', + 'system_notice.thank_you_support.cta_bmc': 'Buy Me a Coffee', + 'system_notice.thank_you_support.cta_kofi': "Ko-fi'de Destek Ol", 'system_notice.pager.prev': 'Önceki bildirim', 'system_notice.pager.next': 'Sonraki bildirim', 'system_notice.pager.counter': '{güncel} / {toplam}', diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index 88371a2c..556202c2 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -345,6 +345,7 @@ const admin: TranslationStrings = { 'Карта за замовчуванням для всіх на цьому екземплярі. Кожен користувач може змінити її у власних налаштуваннях.', 'admin.defaultSettings.providerLeaflet': 'Стандартна (безкоштовна)', 'admin.defaultSettings.providerMapbox': 'Mapbox (3D)', + 'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)', 'admin.defaultSettings.mapboxToken': 'Спільний токен Mapbox', 'admin.defaultSettings.mapboxTokenHint': 'Використовується для кожного користувача, який не ввів власний токен — щоб увесь екземпляр отримав Mapbox без потреби ділитися ключем окремо. Зберігається в зашифрованому вигляді.', diff --git a/shared/src/i18n/uk/settings.ts b/shared/src/i18n/uk/settings.ts index 322294af..f9396cb7 100644 --- a/shared/src/i18n/uk/settings.ts +++ b/shared/src/i18n/uk/settings.ts @@ -20,6 +20,7 @@ const settings: TranslationStrings = { 'settings.mapProviderHint': 'Застосовується до Trip Planner та Journey. Atlas завжди використовує Leaflet.', 'settings.mapLeafletSubtitle': 'Класичні 2D, будь-які растрові тайли', 'settings.mapMapboxSubtitle': 'Векторні тайли, 3D-будинки та рельєф', + 'settings.mapMapLibreSubtitle': 'Векторні тайли OpenFreeMap, без токена', 'settings.mapExperimental': 'Експериментально', 'settings.mapMapboxToken': 'Токен доступу Mapbox', 'settings.mapMapboxTokenHint': 'Публічний токен (pk.*) з', @@ -27,6 +28,8 @@ const settings: TranslationStrings = { 'settings.mapStyle': 'Стиль карти', 'settings.mapStylePlaceholder': 'Виберіть стиль Mapbox', 'settings.mapStyleHint': 'Preset або власний URL mapbox://styles/USER/ID', + 'settings.mapOpenFreeMapStylePlaceholder': 'Виберіть стиль OpenFreeMap', + 'settings.mapOpenFreeMapStyleHint': 'Preset або URL стилю OpenFreeMap. Стилі OpenFreeMap працюють без токена.', 'settings.map3dBuildings': '3D-будинки та рельєф', 'settings.map3dHint': 'Нахил + справжні 3D-будинки — працює з усіма стилями, включаючи супутник.', 'settings.mapHighQuality': 'Режим високої якості', @@ -54,6 +57,7 @@ const settings: TranslationStrings = { 'settings.auto': 'Авто', 'settings.language': 'Мова', 'settings.temperature': 'Одиниця температури', + 'settings.distance': 'Одиниця відстані', 'settings.timeFormat': 'Формат часу', 'settings.blurBookingCodes': 'Приховати коди бронювання', 'settings.optimizeFromAccommodation': 'Оптимізувати маршрут від житла', diff --git a/shared/src/i18n/uk/system_notice.ts b/shared/src/i18n/uk/system_notice.ts index 93839959..2fe5febf 100644 --- a/shared/src/i18n/uk/system_notice.ts +++ b/shared/src/i18n/uk/system_notice.ts @@ -11,6 +11,14 @@ const system_notice: TranslationStrings = { 'system_notice.welcome_v1.highlight_offline': 'Працює офлайн на мобільному', 'system_notice.dev_test_modal.title': '[Dev] Test notice', 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.', + 'system_notice.thank_you_support.title': 'Дякую, що користуєтесь TREK', + 'system_notice.thank_you_support.body': + 'Невелика подяка за те, що встановили TREK — для мене це справді багато значить.\n\nЯ розробник-одинак і створюю TREK у вільний час. Усе почалося як маленький інструмент для моїх власних поїздок, і відтоді я щиро вражений підтримкою та інтересом спільноти. TREK зроблено з великою любов’ю з мого боку — але також завдяки багатьом чудовим зовнішнім контриб’юторам, які допомогли його сформувати.\n\n**TREK має відкритий код і повністю безкоштовний — і таким залишиться назавжди. Жодних платних тарифів, жодних підписок, жодних підводних каменів. Обіцяю.**\n\nЯкщо TREK корисний для вас і ви хочете підтримати його розробку, невелика кава справді допомагає мені продовжувати — жодного тиску, але кожна чашка підтримує ці пізні ночі.\n\nДякую, що ви тут.\n\n— Maurice', + 'system_notice.thank_you_support.highlight_opensource': '100% відкритий код на GitHub', + 'system_notice.thank_you_support.highlight_free': 'Безкоштовно назавжди — жодних платних тарифів', + 'system_notice.thank_you_support.highlight_community': 'Створено разом зі спільнотою', + 'system_notice.thank_you_support.cta_bmc': 'Buy Me a Coffee', + 'system_notice.thank_you_support.cta_kofi': 'Підтримати на Ko-fi', 'system_notice.pager.prev': 'Попереднє повідомлення', 'system_notice.pager.next': 'Наступне повідомлення', 'system_notice.pager.counter': '{current} / {total}', diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index 6386353a..9c3d5a60 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -327,6 +327,7 @@ const admin: TranslationStrings = { 'admin.defaultSettings.mapProviderHint': '此執行個體上所有人的預設地圖。每位使用者仍可在自己的設定中覆寫此項。', 'admin.defaultSettings.providerLeaflet': '標準(免費)', 'admin.defaultSettings.providerMapbox': 'Mapbox(3D)', + 'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)', 'admin.defaultSettings.mapboxToken': '共用的 Mapbox 權杖', 'admin.defaultSettings.mapboxTokenHint': '用於每一位尚未輸入自己權杖的使用者 — 如此整個執行個體都能使用 Mapbox,而無需個別共享金鑰。以加密方式儲存。', diff --git a/shared/src/i18n/zh-TW/settings.ts b/shared/src/i18n/zh-TW/settings.ts index a4bc5372..c2e16081 100644 --- a/shared/src/i18n/zh-TW/settings.ts +++ b/shared/src/i18n/zh-TW/settings.ts @@ -20,6 +20,7 @@ const settings: TranslationStrings = { 'settings.mapProviderHint': '影響行程規劃和旅程地圖。Atlas 始終使用 Leaflet。', 'settings.mapLeafletSubtitle': '經典 2D,任何柵格瓦片', 'settings.mapMapboxSubtitle': '向量瓦片、3D 建築和地形', + 'settings.mapMapLibreSubtitle': 'OpenFreeMap 向量瓦片,無需權杖', 'settings.mapExperimental': '實驗性', 'settings.mapMapboxToken': 'Mapbox 存取權杖', 'settings.mapMapboxTokenHint': '公開權杖 (pk.*) 來自', @@ -27,6 +28,8 @@ const settings: TranslationStrings = { 'settings.mapStyle': '地圖樣式', 'settings.mapStylePlaceholder': '選擇 Mapbox 樣式', 'settings.mapStyleHint': '預設或您自己的 mapbox://styles/USER/ID URL', + 'settings.mapOpenFreeMapStylePlaceholder': '選擇 OpenFreeMap 樣式', + 'settings.mapOpenFreeMapStyleHint': '預設或 OpenFreeMap 樣式 URL。OpenFreeMap 樣式無需權杖即可使用。', 'settings.map3dBuildings': '3D 建築和地形', 'settings.map3dHint': '傾斜 + 真實 3D 建築拉伸 — 適用於所有樣式,包括衛星。', 'settings.mapHighQuality': '高畫質模式', @@ -52,6 +55,7 @@ const settings: TranslationStrings = { 'settings.auto': '自動', 'settings.language': '語言', 'settings.temperature': '溫度單位', + 'settings.distance': '距離單位', 'settings.timeFormat': '時間格式', 'settings.blurBookingCodes': '模糊預訂程式碼', 'settings.optimizeFromAccommodation': '從住宿地點最佳化路線', diff --git a/shared/src/i18n/zh-TW/system_notice.ts b/shared/src/i18n/zh-TW/system_notice.ts index 6f973415..b03e33fd 100644 --- a/shared/src/i18n/zh-TW/system_notice.ts +++ b/shared/src/i18n/zh-TW/system_notice.ts @@ -11,6 +11,14 @@ const system_notice: TranslationStrings = { 'system_notice.welcome_v1.highlight_offline': '行動裝置支援離線使用', 'system_notice.dev_test_modal.title': '[Dev] Test notice', 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.', + 'system_notice.thank_you_support.title': '感謝你使用 TREK', + 'system_notice.thank_you_support.body': + '想簡單地對你說聲謝謝——謝謝你安裝了 TREK,這對我來說真的意義重大。\n\n我是一名獨立開發者,利用業餘時間打造 TREK。它最初只是我為自己的旅行做的一個小工具,而自那以後社群給予的支持與關注,老實說讓我感到無比驚喜。TREK 是我傾注了許多心血做出來的——但也要感謝許多了不起的外部貢獻者,是他們一起塑造了它。\n\n**TREK 是開源且完全免費的——而且永遠都會如此。沒有付費方案,沒有訂閱,沒有任何附加條件。我保證。**\n\n如果 TREK 對你有幫助,而你願意支持它的開發,一杯小小的咖啡真的能幫助我繼續做下去——完全不必有任何壓力,但每一杯都讓那些熬夜的時光更有動力。\n\n謝謝你來到這裡。\n\n— Maurice', + 'system_notice.thank_you_support.highlight_opensource': '在 GitHub 上 100% 開源', + 'system_notice.thank_you_support.highlight_free': '永遠免費 — 絕無任何付費方案', + 'system_notice.thank_you_support.highlight_community': '與社群一起攜手打造', + 'system_notice.thank_you_support.cta_bmc': 'Buy Me a Coffee', + 'system_notice.thank_you_support.cta_kofi': '在 Ko-fi 上支持我', 'system_notice.pager.prev': '上一則通知', 'system_notice.pager.next': '下一則通知', 'system_notice.pager.counter': '{current} / {total}', diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index 93446e28..2985843c 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -325,6 +325,7 @@ const admin: TranslationStrings = { 'admin.defaultSettings.mapProviderHint': '本实例中所有用户的默认地图。每位用户仍可在自己的设置中更改此项。', 'admin.defaultSettings.providerLeaflet': '标准(免费)', 'admin.defaultSettings.providerMapbox': 'Mapbox(3D)', + 'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)', 'admin.defaultSettings.mapboxToken': '共享 Mapbox 令牌', 'admin.defaultSettings.mapboxTokenHint': '用于所有未输入自己令牌的用户 — 这样无需逐个分享密钥,整个实例即可使用 Mapbox。以加密方式存储。', diff --git a/shared/src/i18n/zh/settings.ts b/shared/src/i18n/zh/settings.ts index dd4e6297..1667f3e9 100644 --- a/shared/src/i18n/zh/settings.ts +++ b/shared/src/i18n/zh/settings.ts @@ -20,6 +20,7 @@ const settings: TranslationStrings = { 'settings.mapProviderHint': '影响行程规划和旅程地图。Atlas 始终使用 Leaflet。', 'settings.mapLeafletSubtitle': '经典 2D,任何栅格瓦片', 'settings.mapMapboxSubtitle': '矢量瓦片、3D 建筑和地形', + 'settings.mapMapLibreSubtitle': 'OpenFreeMap 矢量瓦片,无需令牌', 'settings.mapExperimental': '实验性', 'settings.mapMapboxToken': 'Mapbox 访问令牌', 'settings.mapMapboxTokenHint': '公共令牌 (pk.*) 来自', @@ -27,6 +28,8 @@ const settings: TranslationStrings = { 'settings.mapStyle': '地图样式', 'settings.mapStylePlaceholder': '选择 Mapbox 样式', 'settings.mapStyleHint': '预设或您自己的 mapbox://styles/USER/ID URL', + 'settings.mapOpenFreeMapStylePlaceholder': '选择 OpenFreeMap 样式', + 'settings.mapOpenFreeMapStyleHint': '预设或 OpenFreeMap 样式 URL。OpenFreeMap 样式无需令牌即可使用。', 'settings.map3dBuildings': '3D 建筑和地形', 'settings.map3dHint': '倾斜 + 真实 3D 建筑拉伸 — 适用于所有样式,包括卫星。', 'settings.mapHighQuality': '高画质模式', @@ -52,6 +55,7 @@ const settings: TranslationStrings = { 'settings.auto': '自动', 'settings.language': '语言', 'settings.temperature': '温度单位', + 'settings.distance': '距离单位', 'settings.timeFormat': '时间格式', 'settings.blurBookingCodes': '模糊预订代码', 'settings.optimizeFromAccommodation': '从住宿地优化路线', diff --git a/shared/src/i18n/zh/system_notice.ts b/shared/src/i18n/zh/system_notice.ts index 7267634e..d2a8695d 100644 --- a/shared/src/i18n/zh/system_notice.ts +++ b/shared/src/i18n/zh/system_notice.ts @@ -10,6 +10,14 @@ const system_notice: TranslationStrings = { 'system_notice.welcome_v1.highlight_offline': '移动端支持离线使用', 'system_notice.dev_test_modal.title': '[Dev] Test notice', 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.', + 'system_notice.thank_you_support.title': '感谢你使用 TREK', + 'system_notice.thank_you_support.body': + '想跟你说声谢谢——谢谢你安装了 TREK,这对我来说真的意义非凡。\n\n我是一名独立开发者,TREK 是我利用业余时间打造的。它最初只是我为自己的旅行做的一个小工具,而社区一路以来给予的支持和关注,老实说让我感到无比惊喜。TREK 是我用满满的热爱做出来的——但也离不开许多了不起的外部贡献者,是他们一起塑造了今天的它。\n\n**TREK 是开源的,完全免费——而且永远都会如此。没有付费档位,没有订阅,没有任何套路。我保证。**\n\n如果 TREK 对你有帮助,并且你愿意支持它的开发,请我喝一杯小小的咖啡,真的能帮我把它继续做下去——完全没有任何压力,但每一杯都让那些挑灯夜战的夜晚有了坚持的动力。\n\n谢谢你来到这里。\n\n— Maurice', + 'system_notice.thank_you_support.highlight_opensource': '在 GitHub 上 100% 开源', + 'system_notice.thank_you_support.highlight_free': '永久免费——绝无付费档位', + 'system_notice.thank_you_support.highlight_community': '与社区一起共建', + 'system_notice.thank_you_support.cta_bmc': 'Buy Me a Coffee', + 'system_notice.thank_you_support.cta_kofi': '在 Ko-fi 上支持', 'system_notice.pager.prev': '上一条通知', 'system_notice.pager.next': '下一条通知', 'system_notice.pager.counter': '{current} / {total}', diff --git a/shared/src/maps/maps.schema.ts b/shared/src/maps/maps.schema.ts index 8477f548..9e3318d7 100644 --- a/shared/src/maps/maps.schema.ts +++ b/shared/src/maps/maps.schema.ts @@ -84,5 +84,6 @@ export const mapsResolveUrlResultSchema = z.object({ lng: z.number(), name: z.string().nullable(), address: z.string().nullable(), + google_ftid: z.string().nullable().optional(), }); export type MapsResolveUrlResult = z.infer; diff --git a/shared/src/place/place.schema.ts b/shared/src/place/place.schema.ts index 4045bb05..6bf947cc 100644 --- a/shared/src/place/place.schema.ts +++ b/shared/src/place/place.schema.ts @@ -58,6 +58,7 @@ export const placeSchema = z.object({ notes: z.string().nullable().optional(), image_url: z.string().nullable().optional(), google_place_id: z.string().nullable().optional(), + google_ftid: z.string().nullable().optional(), osm_id: z.string().nullable().optional(), route_geometry: z.string().nullable().optional(), website: z.string().nullable().optional(), @@ -93,6 +94,7 @@ export const assignmentPlaceSchema = z.object({ image_url: z.string().nullable().optional(), transport_mode: z.string().nullable().optional(), google_place_id: z.string().nullable().optional(), + google_ftid: z.string().nullable().optional(), website: z.string().nullable().optional(), phone: z.string().nullable().optional(), category: placeCategorySchema.optional(), diff --git a/shared/src/system-notice/system-notice.schema.ts b/shared/src/system-notice/system-notice.schema.ts index 6192a88d..21aa1a09 100644 --- a/shared/src/system-notice/system-notice.schema.ts +++ b/shared/src/system-notice/system-notice.schema.ts @@ -30,9 +30,10 @@ const noticeHighlightSchema = z.object({ iconName: z.string().optional(), }); -/** Call-to-action: either a navigation link or an in-app action. */ +/** Call-to-action: an internal nav, an external link (new tab), or an in-app action. */ const noticeCtaSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('nav'), labelKey: z.string(), href: z.string() }), + z.object({ kind: z.literal('link'), labelKey: z.string(), href: z.string() }), z.object({ kind: z.literal('action'), labelKey: z.string(), @@ -53,6 +54,8 @@ export const systemNoticeDtoSchema = z.object({ media: noticeMediaSchema.optional(), highlights: z.array(noticeHighlightSchema).optional(), cta: noticeCtaSchema.optional(), + secondaryCta: noticeCtaSchema.optional(), + desktopOnly: z.boolean().optional(), dismissible: z.boolean(), }); export type SystemNoticeDto = z.infer; diff --git a/wiki/Environment-Variables.md b/wiki/Environment-Variables.md index 32729e16..5c933fc0 100644 --- a/wiki/Environment-Variables.md +++ b/wiki/Environment-Variables.md @@ -214,6 +214,8 @@ when the binary is found, and the Import button in the Reservations panel is hid | Variable | Description | Default | |---------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------| | `IDEMPOTENCY_TTL_SECONDS` | How long (in seconds) stored idempotency keys are kept before garbage collection. The offline client replays queued mutations with their `X-Idempotency-Key` on reconnect, so this must exceed the longest expected offline window or a replay could create a duplicate. Invalid values silently fall back to the default. | `2592000` (30 days) | +| `OVERPASS_URL` | Custom [Overpass API](https://wiki.openstreetmap.org/wiki/Overpass_API) endpoint(s) used by the map's POI "explore" search, comma-separated. When set it **replaces** the bundled public mirrors — point it at an internal or self-hosted Overpass instance when the public mirrors are unreachable from your network (e.g. firewalled/locked-down egress in a Kubernetes cluster). Entries that aren't valid `http(s)` URLs are ignored. If you don't run your own Overpass but the public mirrors throttle TREK, first make sure `APP_URL` (or `ALLOWED_ORIGINS`) is set: that alone gives outbound Overpass/Nominatim requests a unique User-Agent, which the public mirrors rate-limit far less. | bundled public mirrors | +| `OVERPASS_TIMEOUT_MS` | Per-endpoint timeout (in milliseconds) for Overpass POI requests. Endpoints race in parallel and one that hasn't answered within this window is abandoned so a faster mirror can win. Raise it if you run a slow self-hosted Overpass instance. Invalid values fall back to the default. | `12000` | ---