Explore places on the map, planner route fixes, and instance-wide Mapbox (#1147)

* feat(maps): add an OSM POI search endpoint (category within a viewport)

New /api/maps/pois queries OpenStreetMap via Overpass for places of a category
(restaurants, cafes, hotels, sights, …) inside a bounding box. OSM-only by design
— it never calls Google, even when a Google key is configured.

* feat(map): explore nearby places on the trip map (OSM category pill)

A floating, icon-only pill over the planner map lets you toggle a POI category and
see those OpenStreetMap places in the current view; clicking a marker opens the
add-place form pre-filled (name, address, website, phone). Single-select with a
'search this area' action after the map moves. Renders on both the Leaflet and
Mapbox maps, and can be turned off in settings (discussion #841).

* fix(planner): anchor timed places when optimising and route transports by location

- The day optimiser no longer reshuffles places that have a set time — they stay
  anchored to their time, like locked places.
- The route now uses a transport's departure/arrival location as a waypoint when it
  has one (e.g. a flight's airport), instead of breaking the route at every booking;
  transports without a location are ignored for routing but still show their leg's
  distance/duration under the booking.

* feat(admin): instance-wide Mapbox defaults in default user settings

Admins can set a shared Mapbox token (plus style, 3D and quality) as instance
defaults, so the whole instance can use Mapbox without each user pasting their own
key. Users without their own value inherit it via the existing admin-defaults
merge; the shared token is stored encrypted (discussion #920).
This commit is contained in:
Maurice
2026-06-11 23:42:16 +02:00
committed by GitHub
parent bb477645a3
commit 1378c95078
79 changed files with 1118 additions and 20 deletions
+3
View File
@@ -557,6 +557,9 @@ export const mapsApi = {
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => checkInDev(mapsPlacePhotoResultSchema, r.data, 'maps.placePhoto')),
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
// OSM-only POI explore: places of a category within the current map viewport bbox.
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, signal?: AbortSignal) =>
apiClient.get('/maps/pois', { params: { category, ...bbox }, signal }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean }),
}
export const airportsApi = {
@@ -22,8 +22,22 @@ type Defaults = {
time_format?: string
blur_booking_codes?: boolean
map_tile_url?: string
map_provider?: string
mapbox_access_token?: string
mapbox_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' },
]
function OptionRow({
label,
hint,
@@ -77,11 +91,15 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const [defaults, setDefaults] = useState<Defaults>({})
const [loaded, setLoaded] = useState(false)
const [mapTileUrl, setMapTileUrl] = useState('')
const [mapboxToken, setMapboxToken] = useState('')
const [mapboxStyle, setMapboxStyle] = useState('')
useEffect(() => {
adminApi.getDefaultUserSettings().then((data: Defaults) => {
setDefaults(data)
setMapTileUrl(data.map_tile_url || '')
setMapboxToken(data.mapbox_access_token || '')
setMapboxStyle(data.mapbox_style || '')
setLoaded(true)
}).catch(() => setLoaded(true))
}, [])
@@ -101,6 +119,8 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const updated = await adminApi.updateDefaultUserSettings({ [key]: null })
setDefaults(updated)
if (key === 'map_tile_url') setMapTileUrl('')
if (key === 'mapbox_access_token') setMapboxToken('')
if (key === 'mapbox_style') setMapboxStyle('')
toast.success(t('admin.defaultSettings.reset'))
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.error'))
@@ -267,6 +287,94 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
})}
</div>
</div>
{/* ── Map provider / instance-wide Mapbox ───────────────────────── */}
<div style={{ borderTop: '1px solid var(--border-primary)', paddingTop: 20, marginTop: 4 }}>
<OptionRow
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
hint={t('admin.defaultSettings.mapProviderHint')}
>
{([
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
] as const).map(opt => (
<OptionButton
key={opt.value}
active={(defaults.map_provider || 'leaflet') === opt.value}
onClick={() => save({ map_provider: opt.value })}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{defaults.map_provider === 'mapbox-gl' && (
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('admin.defaultSettings.mapboxToken')}
<ResetButton field="mapbox_access_token" />
</label>
<input
type="text"
value={mapboxToken}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxToken(e.target.value)}
onBlur={() => save({ mapbox_access_token: mapboxToken })}
placeholder="pk.eyJ…"
spellCheck={false}
autoComplete="off"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.mapboxTokenHint')}</p>
</div>
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('admin.defaultSettings.mapboxStyle')}
<ResetButton field="mapbox_style" />
</label>
<CustomSelect
value={mapboxStyle}
onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ mapbox_style: value }) } }}
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
options={MAPBOX_STYLE_PRESETS.map(p => ({ value: p.url, label: p.name }))}
size="sm"
style={{ marginBottom: 8 }}
/>
<input
type="text"
value={mapboxStyle}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
onBlur={() => save({ mapbox_style: mapboxStyle })}
placeholder="mapbox://styles/mapbox/standard"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
<OptionRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
{([
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
] as const).map(opt => (
<OptionButton key={String(opt.value)} active={(defaults.mapbox_3d_enabled ?? true) === opt.value} onClick={() => save({ mapbox_3d_enabled: opt.value })}>
{opt.label}
</OptionButton>
))}
</OptionRow>
<OptionRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}>
{([
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
] as const).map(opt => (
<OptionButton key={String(opt.value)} active={(defaults.mapbox_quality_mode ?? false) === opt.value} onClick={() => save({ mapbox_quality_mode: opt.value })}>
{opt.label}
</OptionButton>
))}
</OptionRow>
</div>
)}
</div>
</Section>
)
}
+57 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react'
import DOM from 'react-dom'
import { renderToStaticMarkup } from 'react-dom/server'
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster'
import L from 'leaflet'
import 'leaflet.markercluster/dist/MarkerCluster.css'
@@ -10,6 +10,7 @@ import { mapsApi } from '../../api/client'
import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import ReservationOverlay from './ReservationOverlay'
import type { Reservation } from '../../types'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
@@ -118,6 +119,44 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
return fallbackIcon
}
// Small coloured pin for an OSM "explore" POI — distinct from the photo-circle
// markers of planned places; the colour matches its pill category.
const poiIconCache = new Map<string, L.DivIcon>()
function createPoiIcon(category: string) {
const cached = poiIconCache.get(category)
if (cached) return cached
const cat = POI_CATEGORY_BY_KEY[category]
const color = cat?.color || '#6b7280'
const svg = cat ? renderToStaticMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
const icon = L.divIcon({
className: '',
html: `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;">${svg}</div>`,
iconSize: [26, 26],
iconAnchor: [13, 13],
tooltipAnchor: [0, -14],
})
poiIconCache.set(category, icon)
return icon
}
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
// OSM places for the visible area.
function ViewportController({ onViewportChange }: { onViewportChange?: (b: { south: number; west: number; north: number; east: number }) => void }) {
const map = useMap()
useEffect(() => {
if (!onViewportChange) return
const emit = () => {
const b = map.getBounds()
onViewportChange({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
}
map.whenReady(emit) // ensure the first bbox is captured once the map is laid out
map.on('moveend', emit)
map.on('zoomend', emit)
return () => { map.off('moveend', emit); map.off('zoomend', emit) }
}, [map, onViewportChange])
return null
}
interface SelectionControllerProps {
places: Place[]
selectedPlaceId: number | null
@@ -367,7 +406,21 @@ export const MapView = memo(function MapView({
showReservationStats = false,
visibleConnectionIds = [] as number[],
onReservationClick,
pois = [] as Poi[],
onPoiClick,
onViewportChange,
}: any) {
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
<Marker
key={`poi-${poi.osm_id}`}
position={[poi.lat, poi.lng]}
icon={createPoiIcon(poi.category)}
zIndexOffset={500}
eventHandlers={{ click: () => onPoiClick?.(poi) }}
>
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip">{poi.name}</Tooltip>
</Marker>
)), [pois, onPoiClick])
const visibleReservations = useMemo(() => {
if (!visibleConnectionIds || visibleConnectionIds.length === 0) return []
const set = new Set(visibleConnectionIds)
@@ -543,6 +596,7 @@ export const MapView = memo(function MapView({
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
<ViewportController onViewportChange={onViewportChange} />
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
<MarkerClusterGroup
@@ -583,6 +637,8 @@ export const MapView = memo(function MapView({
showStats={showReservationStats}
onEndpointClick={onReservationClick}
/>
{poiMarkers}
</MapContainer>
{isMobile && <LocationButton
mode={trackingMode}
+47
View File
@@ -12,6 +12,7 @@ import { ReservationMapboxOverlay } from './reservationsMapbox'
import LocationButton from './LocationButton'
import { useGeolocation } from '../../hooks/useGeolocation'
import type { Place, Reservation } from '../../types'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
@@ -49,6 +50,9 @@ interface Props {
visibleConnectionIds?: number[]
showReservationStats?: boolean
onReservationClick?: (reservationId: number) => void
pois?: Poi[]
onPoiClick?: (poi: Poi) => void
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void
}
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
@@ -128,6 +132,17 @@ function createMarkerElement(place: Place & { category_color?: string; category_
return wrap
}
// Small coloured pin for an OSM "explore" POI (matches the pill category colour).
function createPoiMarkerElement(category: string): HTMLDivElement {
const cat = POI_CATEGORY_BY_KEY[category]
const color = cat?.color || '#6b7280'
const svg = cat ? renderToStaticMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
const el = document.createElement('div')
el.style.cssText = 'width:26px;height:26px;cursor:pointer;'
el.innerHTML = `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;box-sizing:border-box;">${svg}</div>`
return el
}
export function MapViewGL({
places = [],
dayPlaces = [],
@@ -149,6 +164,9 @@ export function MapViewGL({
visibleConnectionIds = [],
showReservationStats = false,
onReservationClick,
pois = [],
onPoiClick,
onViewportChange,
}: Props) {
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
@@ -167,6 +185,11 @@ export function MapViewGL({
// options without forcing a full overlay rebuild on every prop change.
const onReservationClickRef = useRef(onReservationClick)
onReservationClickRef.current = onReservationClick
const poiMarkersRef = useRef<mapboxgl.Marker[]>([])
const onPoiClickRef = useRef(onPoiClick)
onPoiClickRef.current = onPoiClick
const onViewportChangeRef = useRef(onViewportChange)
onViewportChangeRef.current = onViewportChange
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
onClickRefs.current.marker = onMarkerClick
@@ -260,6 +283,14 @@ export function MapViewGL({
if (t.closest('.mapboxgl-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
// pill can fetch OSM places for the visible area.
const emitViewport = () => {
const b = map.getBounds()
onViewportChangeRef.current?.({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
}
map.on('moveend', emitViewport)
map.once('idle', emitViewport)
// In the mapbox-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.
@@ -435,6 +466,22 @@ export function MapViewGL({
})
}, [places, selectedPlaceId, dayOrderMap, photoUrls])
// Reconcile OSM "explore" POI markers (imperative, kept separate from the
// planned-place markers so they don't cluster or get confused with them).
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
poiMarkersRef.current.forEach(m => m.remove())
poiMarkersRef.current = []
for (const poi of (pois as Poi[])) {
const el = createPoiMarkerElement(poi.category)
el.title = poi.name
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)
poiMarkersRef.current.push(m)
}
}, [pois, mapReady])
// Update route geojson
useEffect(() => {
const map = mapRef.current
@@ -0,0 +1,87 @@
import { RotateCw } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Tooltip } from '../shared/Tooltip'
import { POI_CATEGORIES } from './poiCategories'
interface Props {
active: Set<string>
onToggle: (key: string) => void
loadingKeys?: Set<string>
/** true when the map moved since the last search → offer "search this area" */
moved?: boolean
onSearchArea?: () => void
}
// Frosted, icon-only segmented control that floats over the map. Active segments
// fill with the category colour (matching their markers); the label shows in a
// custom tooltip on hover so the pill stays compact and never needs to scroll.
export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved, onSearchArea }: Props) {
const { t } = useTranslation()
const frosted: React.CSSProperties = {
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))',
}
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 2, padding: 4, borderRadius: 999, pointerEvents: 'auto', ...frosted }}>
{POI_CATEGORIES.map(cat => {
const on = active.has(cat.key)
const loading = loadingKeys?.has(cat.key)
return (
<Tooltip key={cat.key} label={t(cat.labelKey)} placement="bottom">
<button
type="button"
onClick={() => onToggle(cat.key)}
aria-pressed={on}
aria-label={t(cat.labelKey)}
className={on ? '' : 'text-content-muted'}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: on ? cat.color : 'transparent',
color: on ? '#fff' : undefined,
transition: 'background 0.14s, color 0.14s',
}}
onMouseEnter={e => { if (!on) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!on) e.currentTarget.style.background = 'transparent' }}
>
{loading ? (
<span
className="animate-spin"
style={{
width: 14, height: 14, borderRadius: 999, display: 'inline-block',
border: '2px solid', borderColor: on ? 'rgba(255,255,255,0.45)' : 'var(--border-primary)',
borderTopColor: on ? '#fff' : 'var(--text-muted)',
}}
/>
) : (
<cat.Icon size={16} strokeWidth={2} />
)}
</button>
</Tooltip>
)
})}
</div>
{moved && active.size > 0 && (
<button
type="button"
onClick={onSearchArea}
className="text-content"
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '6px 13px', borderRadius: 999, border: 'none', cursor: 'pointer',
fontSize: 12, fontWeight: 600, fontFamily: 'inherit', pointerEvents: 'auto',
...frosted,
}}
>
<RotateCw size={13} strokeWidth={2.4} /> {t('poi.searchThisArea')}
</button>
)}
</div>
)
}
@@ -0,0 +1,43 @@
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, type LucideIcon } from 'lucide-react'
// The POI categories shown in the map "explore" pill. The `key` is the contract
// with the server (CATEGORY_OSM_FILTERS in mapsService.ts) — the OSM tag mapping
// lives there; label/icon/colour live here. `color` doubles as the active-pill
// fill AND the marker colour, so the pill and the map agree visually.
export interface PoiCategory {
key: string
labelKey: string
Icon: LucideIcon
color: string
}
export const POI_CATEGORIES: PoiCategory[] = [
{ key: 'restaurant', labelKey: 'poi.cat.restaurants', Icon: Utensils, color: '#EF4444' },
{ key: 'cafe', labelKey: 'poi.cat.cafes', Icon: Coffee, color: '#B45309' },
{ key: 'bar', labelKey: 'poi.cat.bars', Icon: Wine, color: '#A855F7' },
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: '#2563EB' },
{ key: 'sights', labelKey: 'poi.cat.sights', Icon: Camera, color: '#EC4899' },
{ key: 'museum', labelKey: 'poi.cat.museums', Icon: Landmark, color: '#6366F1' },
{ key: 'nature', labelKey: 'poi.cat.nature', Icon: Trees, color: '#16A34A' },
{ key: 'activity', labelKey: 'poi.cat.activities', Icon: Ticket, color: '#F59E0B' },
]
export const POI_CATEGORY_BY_KEY: Record<string, PoiCategory> = Object.fromEntries(
POI_CATEGORIES.map(c => [c.key, c]),
)
// One POI result from /api/maps/pois (mirror of the server's OverpassPoi).
export interface Poi {
osm_id: string
name: string
lat: number
lng: number
category: string
poi_type: string
address: string | null
website: string | null
phone: string | null
opening_hours: string | null
cuisine: string | null
source: 'openstreetmap'
}
@@ -0,0 +1,76 @@
import { useState, useRef, useCallback, useMemo } from 'react'
import { mapsApi } from '../../api/client'
import type { Poi } from './poiCategories'
export interface Bbox { south: number; west: number; north: number; east: number }
/**
* State for the map POI "explore" pill. Toggling a category fetches its OSM POIs
* for the current viewport; panning/zooming does NOT auto-refetch — it just marks
* the results stale (`moved`) so the pill can offer "search this area". This keeps
* Overpass load (and visual churn) down.
*/
export function usePoiExplore() {
const [active, setActive] = useState<Set<string>>(() => new Set())
const [byCat, setByCat] = useState<Record<string, Poi[]>>({})
const [loadingKeys, setLoadingKeys] = useState<Set<string>>(() => new Set())
const [moved, setMoved] = useState(false)
const bboxRef = useRef<Bbox | null>(null)
// activeRef always mirrors the latest active set so async callbacks (fetch
// completions) can check whether a category is still wanted.
const activeRef = useRef(active)
activeRef.current = active
const setLoading = useCallback((key: string, on: boolean) => setLoadingKeys(prev => {
const next = new Set(prev)
if (on) next.add(key); else next.delete(key)
return next
}), [])
const fetchCat = useCallback(async (key: string, bbox: Bbox) => {
setLoading(key, true)
try {
const res = await mapsApi.pois(key, bbox)
// Drop the result if the user toggled this category off while the (slow)
// Overpass request was in flight — otherwise stale results re-appear.
setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: res.pois } : prev))
} catch {
setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: [] } : prev))
} finally {
setLoading(key, false)
}
}, [setLoading])
const onViewportChange = useCallback((bbox: Bbox) => {
bboxRef.current = bbox
if (activeRef.current.size > 0) setMoved(true)
}, [])
// Single-select: clicking a category switches to it (dropping the previous one
// and its markers immediately) and fetches it for the current viewport; clicking
// the already-active category turns it off.
const toggle = useCallback((key: string) => {
const isOnlyActive = activeRef.current.has(key) && activeRef.current.size === 1
setMoved(false)
if (isOnlyActive) {
setActive(new Set())
setByCat({})
return
}
setActive(new Set([key]))
setByCat({})
if (bboxRef.current) fetchCat(key, bboxRef.current)
}, [fetchCat])
const searchArea = useCallback(() => {
const bbox = bboxRef.current
if (!bbox) return
setMoved(false)
activeRef.current.forEach(key => fetchCat(key, bbox))
}, [fetchCat])
const pois = useMemo(() => Object.values(byCat).flat(), [byCat])
return { active, pois, loadingKeys, moved, toggle, searchArea, onViewportChange }
}
@@ -376,14 +376,30 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
if (legsAbortRef.current) legsAbortRef.current.abort()
if (!selectedDayId || !routeShown) { setRouteLegs({}); return }
const merged = mergedItemsMap[selectedDayId] || []
const epLoc = (r: any, role: 'from' | 'to'): { lat: number; lng: number } | null => {
const e = (r.endpoints || []).find((x: any) => x.role === role)
return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null
}
const runs: { id: number; lat: number; lng: number }[][] = []
let cur: { id: number; lat: number; lng: number }[] = []
for (const it of merged) {
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng })
} else if (it.type === 'transport') {
if (cur.length >= 2) runs.push(cur)
cur = []
const r = it.data
const from = epLoc(r, 'from'), to = epLoc(r, 'to')
if (from || to) {
// Located transport: route to its departure point, break the run (the
// flight/train itself isn't driven), and let its arrival start the next.
if (from) cur.push({ id: r.id, lat: from.lat, lng: from.lng })
if (cur.length >= 2) runs.push(cur)
cur = []
if (to) cur.push({ id: r.id, lat: to.lat, lng: to.lng })
} else if (cur.length > 0) {
// No location: ignore for routing, but attribute the through-leg to the
// booking so its distance/duration shows under it (purely cosmetic).
cur[cur.length - 1] = { ...cur[cur.length - 1], id: r.id }
}
}
}
if (cur.length >= 2) runs.push(cur)
@@ -731,11 +747,13 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const prevIds = da.map(a => a.id)
// Separate locked (stay at their index) and unlocked assignments
// Separate fixed (stay at their index) and movable assignments. A place is
// fixed if it's locked OR has a set time — timed places are anchored by their
// time, so the optimizer must not reshuffle them.
const locked = new Map() // index -> assignment
const unlocked = []
da.forEach((a, i) => {
if (lockedIds.has(a.id)) locked.set(i, a)
if (lockedIds.has(a.id) || a.place?.place_time) locked.set(i, a)
else unlocked.push(a)
})
@@ -1917,6 +1935,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
)
})()}
</div>
{routeLegs[res.id] && <RouteConnector seg={routeLegs[res.id]} profile={routeProfile} />}
</React.Fragment>
)
}
@@ -27,7 +27,7 @@ interface PlaceFormModalProps {
onClose: () => void
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<void> | void
place: Place | null
prefillCoords?: { lat: number; lng: number; name?: string; address?: string } | null
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string } | null
tripId: number
categories: Category[]
onCategoryCreated: (category: { name: string; color?: string; icon?: string }) => Promise<Category> | undefined
@@ -86,6 +86,9 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
lng: String(prefillCoords.lng),
name: prefillCoords.name || '',
address: prefillCoords.address || '',
website: prefillCoords.website || '',
phone: prefillCoords.phone || '',
osm_id: prefillCoords.osm_id,
})
} else {
setForm(DEFAULT_FORM)
@@ -262,6 +262,37 @@ export default function DisplaySettingsTab(): React.ReactElement {
<p className="text-xs mt-1 text-content-faint">{t('settings.bookingLabelsHint')}</p>
</div>
{/* Explore places on the map (POI category pill) */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.mapPoiPill')}</label>
<div className="flex gap-3">
{[
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
].map(opt => (
<button
key={String(opt.value)}
onClick={async () => {
try { await updateSetting('map_poi_pill_enabled', opt.value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
border: (settings.map_poi_pill_enabled !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: (settings.map_poi_pill_enabled !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
<p className="text-xs mt-1 text-content-faint">{t('settings.mapPoiPillHint')}</p>
</div>
{/* Blur Booking Codes */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.blurBookingCodes')}</label>
+25 -8
View File
@@ -53,29 +53,44 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
return pos != null
})
// Build a unified list of places + transports sorted by effective position,
// then derive segments by resetting whenever a transport appears — mirrors getMergedItems order.
type Entry = { kind: 'place'; lat: number; lng: number } | { kind: 'transport' }
const entries: (Entry & { pos: number })[] = [
// The departure/arrival coordinate of a transport, if its endpoints carry one.
const epLoc = (r: any, role: 'from' | 'to'): { lat: number; lng: number } | null => {
const e = (r.endpoints || []).find((x: any) => x.role === role)
return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null
}
// Build a unified list of places + transports sorted by effective position.
type Entry =
| { kind: 'place'; lat: number; lng: number; pos: number }
| { kind: 'transport'; from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null; pos: number }
const entries: Entry[] = [
...da.filter(a => a.place?.lat && a.place?.lng).map(a => ({
kind: 'place' as const, lat: a.place.lat!, lng: a.place.lng!, pos: a.order_index,
})),
...dayTransports.map(r => ({
kind: 'transport' as const,
from: epLoc(r, 'from'),
to: epLoc(r, 'to'),
pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number,
})),
].sort((a, b) => a.pos - b.pos)
// Group consecutive located places into runs, resetting whenever a transport
// appears (you don't drive between a flight's endpoints) — mirrors getMergedItems order.
// Group located places into driving runs.
// - A transport WITH a location anchors the route to its departure point (you
// travel there), then breaks the run (you don't drive the flight/train); its
// arrival point starts the next run.
// - A transport WITHOUT a location is ignored entirely — the places around it
// connect directly, as if the booking weren't there.
const runs: { lat: number; lng: number }[][] = []
let currentRun: { lat: number; lng: number }[] = []
for (const entry of entries) {
if (entry.kind === 'place') {
currentRun.push({ lat: entry.lat, lng: entry.lng })
} else {
} else if (entry.from || entry.to) {
if (entry.from) currentRun.push(entry.from)
if (currentRun.length >= 2) runs.push(currentRun)
currentRun = []
if (entry.to) currentRun.push(entry.to)
}
}
if (currentRun.length >= 2) runs.push(currentRun)
@@ -120,7 +135,9 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
.filter(r => TRANSPORT_TYPES.includes(r.type))
.map(r => {
const pos = r.day_positions?.[selectedDayId] ?? r.day_positions?.[String(selectedDayId)] ?? r.day_plan_position
return `${r.id}:${r.day_id ?? ''}:${r.end_day_id ?? ''}:${r.reservation_time ?? ''}:${pos ?? ''}`
// Include endpoints so adding/moving a departure/arrival location re-routes.
const eps = (r.endpoints || []).map(e => `${e.role}@${e.lat ?? ''},${e.lng ?? ''}`).join(';')
return `${r.id}:${r.day_id ?? ''}:${r.end_day_id ?? ''}:${r.reservation_time ?? ''}:${pos ?? ''}:${eps}`
})
.sort()
.join('|')
+14 -1
View File
@@ -42,6 +42,8 @@ import { usePlannerHistory } from '../hooks/usePlannerHistory'
import type { Accommodation, TripMember, Day, Place, Reservation, PackingItem, TodoItem } from '../types'
import { ListTodo, Upload, Plus, Trash2, FolderPlus } from 'lucide-react'
import { useTripPlanner } from './tripPlanner/useTripPlanner'
import { usePoiExplore } from '../components/Map/usePoiExplore'
import PoiCategoryPill from '../components/Map/PoiCategoryPill'
function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; packingItems: PackingItem[]; todoItems: TodoItem[] }) {
const [subTab, setSubTab] = useState<'packing' | 'todo'>(() => {
@@ -195,7 +197,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
isMobile, mapCategoryFilter, setMapCategoryFilter, mapPlacesFilter, setMapPlacesFilter,
expandedDayIds, setExpandedDayIds, mapPlaces,
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu,
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
handleAssignToDay, handleRemoveAssignment, handleReorder, handleUpdateDayTitle,
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
@@ -203,6 +205,9 @@ export default function TripPlannerPage(): React.ReactElement | null {
mapTileUrl, defaultCenter, defaultZoom, fontStyle, splashDone,
} = useTripPlanner()
const poi = usePoiExplore()
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
if (isLoading || !splashDone) {
return (
<div className="bg-surface" style={{
@@ -300,8 +305,16 @@ export default function TripPlannerPage(): React.ReactElement | null {
const r = reservations.find(x => x.id === rid)
if (r) setMapTransportDetail(r)
}}
pois={poi.pois}
onPoiClick={openAddPlaceFromPoi}
onViewportChange={poi.onViewportChange}
/>
{poiPillEnabled && (
<div className="hidden md:flex" style={{ position: 'absolute', top: 14, left: '50%', transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none' }}>
<PoiCategoryPill active={poi.active} onToggle={poi.toggle} loadingKeys={poi.loadingKeys} moved={poi.moved} onSearchArea={poi.searchArea} />
</div>
)}
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
<button onClick={() => setLeftCollapsed(c => !c)}
+20 -2
View File
@@ -123,7 +123,7 @@ export function useTripPlanner() {
const [dayDetailCollapsed, setDayDetailCollapsed] = useState(false)
const [showPlaceForm, setShowPlaceForm] = useState<boolean>(false)
const [editingPlace, setEditingPlace] = useState<Place | null>(null)
const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string } | null>(null)
const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string } | null>(null)
const [editingAssignmentId, setEditingAssignmentId] = useState<number | null>(null)
const [searchParams, setSearchParams] = useSearchParams()
@@ -356,6 +356,24 @@ export function useTripPlanner() {
} catch { /* best effort */ }
}, [language])
// Open the Add-Place form pre-filled from an OSM "explore" POI marker — all the
// data already comes from the POI, so no reverse-geocode is needed.
const openAddPlaceFromPoi = useCallback((poi: { lat: number; lng: number; name: string; address: string | null; website: string | null; phone: string | null; osm_id: string }) => {
if (!can('place_edit', trip)) return
setPrefillCoords({
lat: poi.lat,
lng: poi.lng,
name: poi.name,
address: poi.address || '',
website: poi.website || undefined,
phone: poi.phone || undefined,
osm_id: poi.osm_id,
})
setEditingPlace(null)
setEditingAssignmentId(null)
setShowPlaceForm(true)
}, [trip])
const handleSavePlace = useCallback(async (data) => {
const pendingFiles = data._pendingFiles
delete data._pendingFiles
@@ -641,7 +659,7 @@ export function useTripPlanner() {
isMobile, mapCategoryFilter, setMapCategoryFilter, mapPlacesFilter, setMapPlacesFilter,
expandedDayIds, setExpandedDayIds, mapPlaces,
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu,
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
handleAssignToDay, handleRemoveAssignment, handleReorder, handleUpdateDayTitle,
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
+1
View File
@@ -34,6 +34,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
show_place_description: false,
optimize_from_accommodation: true,
map_provider: 'leaflet',
map_poi_pill_enabled: true,
mapbox_access_token: '',
mapbox_style: 'mapbox://styles/mapbox/standard',
mapbox_3d_enabled: true,
+1
View File
@@ -113,6 +113,7 @@ export interface Settings {
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'
mapbox_access_token?: string
+21
View File
@@ -67,6 +67,27 @@ export class MapsController {
}
}
// OSM-only POI explore: places of a category within the current map viewport.
@Get('pois')
async pois(
@Query('category') category?: string,
@Query('south') south?: string,
@Query('west') west?: string,
@Query('north') north?: string,
@Query('east') east?: string,
) {
if (!category) throw new HttpException({ error: 'A category is required' }, 400);
const bbox = { south: Number(south), west: Number(west), north: Number(north), east: Number(east) };
if (Object.values(bbox).some(v => !Number.isFinite(v))) {
throw new HttpException({ error: 'A valid bbox (south, west, north, east) is required' }, 400);
}
try {
return await this.maps.pois(category, bbox);
} catch (err: unknown) {
throw toHttpException(err, 'POI search error', 500);
}
}
@Post('autocomplete')
@HttpCode(200)
async autocomplete(
+6
View File
@@ -16,6 +16,7 @@ import {
getPlacePhoto,
reverseGeocode,
resolveGoogleMapsUrl,
searchOverpassPois,
} from '../../services/mapsService';
import { serveFilePath } from '../../services/placePhotoCache';
@@ -86,4 +87,9 @@ export class MapsService {
resolveUrl(url: string): Promise<MapsResolveUrlResult> {
return resolveGoogleMapsUrl(url) as Promise<MapsResolveUrlResult>;
}
// OSM-only POI search by category within a viewport bbox (never calls Google).
pois(category: string, bbox: { south: number; west: number; north: number; east: number }) {
return searchOverpassPois(category, bbox);
}
}
+108
View File
@@ -204,6 +204,114 @@ export async function fetchOverpassDetails(osmType: string, osmId: string): Prom
} catch { return null; }
}
// ── Overpass POI search (by category within a viewport bbox) ─────────────────
// Powers the "explore places on the map" pill. OSM-ONLY by design — this never
// calls Google, even when a Google key is configured.
export interface OverpassPoi {
osm_id: string; // 'node:123' | 'way:123' | 'relation:123' (matches the placeId format elsewhere)
name: string;
lat: number;
lng: number;
category: string; // the requested pill category key, e.g. 'restaurant'
poi_type: string; // the raw OSM tag that matched, e.g. 'amenity=restaurant'
address: string | null;
website: string | null;
phone: string | null;
opening_hours: string | null;
cuisine: string | null;
source: 'openstreetmap';
}
// Each pill category → the OSM tag selectors it searches. Keys here are the
// contract with the client's POI_CATEGORIES (same keys, label/icon/colour live
// client-side).
const CATEGORY_OSM_FILTERS: Record<string, string[]> = {
restaurant: ['amenity=restaurant', 'amenity=fast_food'],
cafe: ['amenity=cafe'],
bar: ['amenity=bar', 'amenity=pub', 'amenity=nightclub'],
hotel: ['tourism=hotel', 'tourism=hostel', 'tourism=guest_house', 'tourism=apartment', 'tourism=motel'],
sights: ['tourism=attraction', 'tourism=viewpoint', 'historic=monument', 'historic=castle', 'historic=memorial', 'historic=ruins'],
museum: ['tourism=museum', 'tourism=gallery', 'tourism=artwork', 'amenity=theatre'],
nature: ['leisure=park', 'leisure=garden', 'natural=beach', 'natural=peak'],
activity: ['tourism=theme_park', 'tourism=zoo', 'tourism=aquarium', 'leisure=water_park'],
shopping: ['shop=mall', 'shop=department_store', 'amenity=marketplace'],
supermarket: ['shop=supermarket', 'shop=convenience'],
};
export const POI_CATEGORY_KEYS = Object.keys(CATEGORY_OSM_FILTERS);
interface OverpassPoiElement {
type: string;
id: number;
lat?: number;
lon?: number;
center?: { lat: number; lon: number };
tags?: Record<string, string>;
}
export async function searchOverpassPois(
category: string,
bbox: { south: number; west: number; north: number; east: number },
limit = 60,
): Promise<{ pois: OverpassPoi[]; source: 'openstreetmap'; truncated: boolean }> {
const filters = CATEGORY_OSM_FILTERS[category];
if (!filters) throw Object.assign(new Error('Unknown POI category'), { status: 400 });
// Overpass wants the box as (south,west,north,east) = (minLat,minLng,maxLat,maxLng).
const box = `(${bbox.south},${bbox.west},${bbox.north},${bbox.east})`;
const selectors = filters.map(f => {
const [k, v] = f.split('=');
return ` nwr["${k}"="${v}"]${box};`;
}).join('\n');
// `out center tags <n>` returns ways/relations with a computed center and caps
// the result count in one round-trip.
const query = `[out:json][timeout:25];\n(\n${selectors}\n);\nout center tags ${limit + 25};`;
let elements: OverpassPoiElement[] = [];
try {
const res = await fetch('https://overpass-api.de/api/interpreter', {
method: 'POST',
headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' },
body: `data=${encodeURIComponent(query)}`,
});
if (!res.ok) throw Object.assign(new Error('Overpass request failed'), { status: 502 });
const data = await res.json() as { elements?: OverpassPoiElement[] };
elements = data.elements || [];
} catch (err: any) {
if (err?.status) throw err;
throw Object.assign(new Error('Overpass request failed'), { status: 502 });
}
const pois: OverpassPoi[] = [];
for (const el of elements) {
const tags = el.tags || {};
const name = tags.name || tags['name:en'] || tags.brand || null;
if (!name) continue; // unnamed POIs aren't useful to add to a plan
const lat = el.lat ?? el.center?.lat;
const lng = el.lon ?? el.center?.lon;
if (lat == null || lng == null) continue;
const matched = filters.find(f => { const [k, v] = f.split('='); return tags[k] === v; }) || filters[0];
const addr = [tags['addr:street'], tags['addr:housenumber'], tags['addr:postcode'], tags['addr:city']].filter(Boolean).join(' ') || null;
pois.push({
osm_id: `${el.type}:${el.id}`,
name,
lat,
lng,
category,
poi_type: matched,
address: addr,
website: tags.website || tags['contact:website'] || null,
phone: tags.phone || tags['contact:phone'] || null,
opening_hours: tags.opening_hours || null,
cuisine: tags.cuisine || null,
source: 'openstreetmap',
});
}
const truncated = pois.length > limit;
return { pois: pois.slice(0, limit), source: 'openstreetmap', truncated };
}
// ── Opening hours parsing ────────────────────────────────────────────────────
export function parseOpeningHours(ohString: string): { weekdayDescriptions: string[]; openNow: boolean | null } {
+20 -3
View File
@@ -12,6 +12,13 @@ export const DEFAULTABLE_USER_SETTING_KEYS = [
'time_format',
'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).
'map_provider',
'mapbox_access_token',
'mapbox_style',
'mapbox_3d_enabled',
'mapbox_quality_mode',
] as const;
type DefaultableKey = typeof DEFAULTABLE_USER_SETTING_KEYS[number];
@@ -20,9 +27,10 @@ const VALID_VALUES: Partial<Record<DefaultableKey, unknown[]>> = {
temperature_unit: ['fahrenheit', 'celsius'],
time_format: ['12h', '24h'],
dark_mode: [true, false, 'light', 'dark', 'auto'],
map_provider: ['leaflet', 'mapbox-gl'],
};
const BOOLEAN_KEYS = new Set<DefaultableKey>(['blur_booking_codes']);
const BOOLEAN_KEYS = new Set<DefaultableKey>(['blur_booking_codes', 'mapbox_3d_enabled', 'mapbox_quality_mode']);
function parseValue(raw: string): unknown {
try { return JSON.parse(raw); } catch { return raw; }
@@ -35,7 +43,11 @@ export function getAdminUserDefaults(): Record<string, unknown> {
const defaults: Record<string, unknown> = {};
for (const row of rows) {
const settingKey = row.key.slice('default_user_setting_'.length);
defaults[settingKey] = parseValue(row.value);
if (ENCRYPTED_SETTING_KEYS.has(settingKey)) {
defaults[settingKey] = row.value ? (decrypt_api_key(row.value) ?? '') : '';
} else {
defaults[settingKey] = parseValue(row.value);
}
}
return defaults;
}
@@ -70,7 +82,12 @@ export function setAdminUserDefaults(partial: Record<string, unknown>): void {
throw new Error(`Invalid value for ${key}: ${value}`);
}
upsert.run(appKey, JSON.stringify(value));
// Encrypt sensitive defaults (the shared Mapbox token) at rest, like the
// per-user equivalents; everything else is stored as plain JSON.
const stored = ENCRYPTED_SETTING_KEYS.has(key)
? (maybe_encrypt_api_key(String(value)) ?? String(value))
: JSON.stringify(value);
upsert.run(appKey, stored);
}
db.exec('COMMIT');
} catch (err) {
+10
View File
@@ -360,5 +360,15 @@ const admin: TranslationStrings = {
'إزالة جميع مفاتيح المرور لهذا المستخدم (مثلًا عند فقدان جهاز). سيظل بإمكانه تسجيل الدخول بكلمة المرور.',
'admin.passkey.resetConfirm': 'إزالة جميع مفاتيح المرور لـ {name}؟',
'admin.passkey.resetDone': 'تمت إزالة {count} من مفاتيح المرور',
'admin.defaultSettings.mapProvider': 'محرك الخرائط',
'admin.defaultSettings.mapProviderHint': 'الخريطة الافتراضية لجميع المستخدمين على هذا الخادم. لا يزال بإمكان كل مستخدم تجاوزها في إعداداته الخاصة.',
'admin.defaultSettings.providerLeaflet': 'قياسي (مجاني)',
'admin.defaultSettings.providerMapbox': 'Mapbox (ثلاثي الأبعاد)',
'admin.defaultSettings.mapboxToken': 'رمز Mapbox المشترك',
'admin.defaultSettings.mapboxTokenHint': 'يُستخدم لكل مستخدم لم يُدخل رمزه الخاص — حتى يحصل الخادم بأكمله على Mapbox دون مشاركة المفتاح بشكل فردي. يُخزَّن مشفّرًا.',
'admin.defaultSettings.mapboxStyle': 'نمط الخريطة',
'admin.defaultSettings.mapboxStylePlaceholder': 'اختر نمطًا…',
'admin.defaultSettings.mapbox3d': 'المباني والتضاريس ثلاثية الأبعاد',
'admin.defaultSettings.mapboxQuality': 'وضع الجودة العالية',
};
export default admin;
+9
View File
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
'map.connections': 'الاتصالات',
'map.showConnections': 'عرض مسارات الحجوزات',
'map.hideConnections': 'إخفاء مسارات الحجوزات',
'poi.searchThisArea': 'البحث في هذه المنطقة',
'poi.cat.restaurants': 'مطاعم',
'poi.cat.cafes': 'مقاهٍ',
'poi.cat.bars': 'حانات وحياة ليلية',
'poi.cat.hotels': 'أماكن إقامة',
'poi.cat.sights': 'معالم',
'poi.cat.museums': 'متاحف وثقافة',
'poi.cat.nature': 'طبيعة وحدائق',
'poi.cat.activities': 'أنشطة',
};
export default map;
+2
View File
@@ -317,6 +317,8 @@ const settings: TranslationStrings = {
'settings.passkey.deviceBound': 'هذا الجهاز',
'settings.passkey.lastUsed': 'آخر استخدام',
'settings.passkey.neverUsed': 'لم يُستخدم قط',
'settings.mapPoiPill': 'استكشاف الأماكن على الخريطة',
'settings.mapPoiPillHint': 'أظهر شريط فئات على خريطة الرحلة للعثور على المطاعم والفنادق والمزيد القريبة من OpenStreetMap.',
};
export default settings;
+10
View File
@@ -382,5 +382,15 @@ const admin: TranslationStrings = {
'Remove todas as passkeys deste usuário (ex.: em caso de perda do dispositivo). Ele ainda poderá entrar com a senha.',
'admin.passkey.resetConfirm': 'Remover todas as passkeys de {name}?',
'admin.passkey.resetDone': 'Removida(s) {count} passkey(s)',
'admin.defaultSettings.mapProvider': 'Motor de mapas',
'admin.defaultSettings.mapProviderHint': '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.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.',
'admin.defaultSettings.mapboxStyle': 'Estilo do mapa',
'admin.defaultSettings.mapboxStylePlaceholder': 'Escolha um estilo…',
'admin.defaultSettings.mapbox3d': 'Edifícios & relevo em 3D',
'admin.defaultSettings.mapboxQuality': 'Modo de alta qualidade',
};
export default admin;
+9
View File
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
'map.connections': 'Conexões',
'map.showConnections': 'Mostrar rotas de reservas',
'map.hideConnections': 'Ocultar rotas de reservas',
'poi.searchThisArea': 'Pesquisar nesta área',
'poi.cat.restaurants': 'Restaurantes',
'poi.cat.cafes': 'Cafés',
'poi.cat.bars': 'Bares e vida noturna',
'poi.cat.hotels': 'Hospedagem',
'poi.cat.sights': 'Pontos turísticos',
'poi.cat.museums': 'Museus e cultura',
'poi.cat.nature': 'Natureza e parques',
'poi.cat.activities': 'Atividades',
};
export default map;
+2
View File
@@ -323,6 +323,8 @@ const settings: TranslationStrings = {
'settings.passkey.deviceBound': 'Este dispositivo',
'settings.passkey.lastUsed': 'Último uso',
'settings.passkey.neverUsed': 'Nunca usada',
'settings.mapPoiPill': 'Explorar lugares no mapa',
'settings.mapPoiPillHint': 'Mostrar uma etiqueta de categoria no mapa da viagem para encontrar restaurantes, hotéis e mais por perto a partir do OpenStreetMap.',
};
export default settings;
+10
View File
@@ -377,5 +377,15 @@ const admin: TranslationStrings = {
'Odebere všechny přístupové klíče tohoto uživatele (např. při ztrátě zařízení). Stále se může přihlásit svým heslem.',
'admin.passkey.resetConfirm': 'Odebrat všechny přístupové klíče uživatele {name}?',
'admin.passkey.resetDone': 'Odebráno {count} přístupových klíčů',
'admin.defaultSettings.mapProvider': 'Mapový engine',
'admin.defaultSettings.mapProviderHint': '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.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ě.',
'admin.defaultSettings.mapboxStyle': 'Styl mapy',
'admin.defaultSettings.mapboxStylePlaceholder': 'Vyberte styl…',
'admin.defaultSettings.mapbox3d': '3D budovy & terén',
'admin.defaultSettings.mapboxQuality': 'Režim vysoké kvality',
};
export default admin;
+9
View File
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
'map.connections': 'Spojení',
'map.showConnections': 'Zobrazit trasy rezervací',
'map.hideConnections': 'Skrýt trasy rezervací',
'poi.searchThisArea': 'Hledat v této oblasti',
'poi.cat.restaurants': 'Restaurace',
'poi.cat.cafes': 'Kavárny',
'poi.cat.bars': 'Bary a noční život',
'poi.cat.hotels': 'Ubytování',
'poi.cat.sights': 'Památky',
'poi.cat.museums': 'Muzea a kultura',
'poi.cat.nature': 'Příroda a parky',
'poi.cat.activities': 'Aktivity',
};
export default map;
+2
View File
@@ -324,6 +324,8 @@ const settings: TranslationStrings = {
'settings.passkey.deviceBound': 'Toto zařízení',
'settings.passkey.lastUsed': 'Naposledy použito',
'settings.passkey.neverUsed': 'Nikdy nepoužito',
'settings.mapPoiPill': 'Objevovat místa na mapě',
'settings.mapPoiPillHint': 'Zobrazit na mapě výletu kategorie pro hledání restaurací, hotelů a dalšího v okolí z OpenStreetMap.',
};
export default settings;
+10
View File
@@ -380,5 +380,15 @@ const admin: TranslationStrings = {
'Entfernt alle Passkeys dieses Benutzers (z. B. bei einem verlorenen Gerät). Die Anmeldung mit Passwort bleibt weiterhin möglich.',
'admin.passkey.resetConfirm': 'Alle Passkeys von {name} entfernen?',
'admin.passkey.resetDone': '{count} Passkey(s) entfernt',
'admin.defaultSettings.mapProvider': 'Kartendienst',
'admin.defaultSettings.mapProviderHint': '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.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.',
'admin.defaultSettings.mapboxStyle': 'Kartenstil',
'admin.defaultSettings.mapboxStylePlaceholder': 'Stil auswählen…',
'admin.defaultSettings.mapbox3d': '3D-Gebäude & Gelände',
'admin.defaultSettings.mapboxQuality': 'Hochqualitätsmodus',
};
export default admin;
+9
View File
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
'map.connections': 'Verbindungen',
'map.showConnections': 'Buchungsrouten anzeigen',
'map.hideConnections': 'Buchungsrouten ausblenden',
'poi.searchThisArea': 'Diesen Bereich durchsuchen',
'poi.cat.restaurants': 'Restaurants',
'poi.cat.cafes': 'Cafés',
'poi.cat.bars': 'Bars & Nachtleben',
'poi.cat.hotels': 'Unterkünfte',
'poi.cat.sights': 'Sehenswürdigkeiten',
'poi.cat.museums': 'Museen & Kultur',
'poi.cat.nature': 'Natur & Parks',
'poi.cat.activities': 'Aktivitäten',
};
export default map;
+2
View File
@@ -327,6 +327,8 @@ const settings: TranslationStrings = {
'settings.passkey.deviceBound': 'Dieses Gerät',
'settings.passkey.lastUsed': 'Zuletzt verwendet',
'settings.passkey.neverUsed': 'Noch nie verwendet',
'settings.mapPoiPill': 'Orte auf der Karte entdecken',
'settings.mapPoiPillHint': 'Zeigt auf der Reisekarte eine Kategorie-Pille an, um Restaurants, Hotels und mehr aus OpenStreetMap in der Nähe zu finden.',
};
export default settings;
+12
View File
@@ -198,6 +198,18 @@ const admin: TranslationStrings = {
'admin.defaultSettings.saved': 'Default saved',
'admin.defaultSettings.reset': 'Reset to built-in default',
'admin.defaultSettings.resetToBuiltIn': 'reset',
'admin.defaultSettings.mapProvider': 'Map engine',
'admin.defaultSettings.mapProviderHint':
'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.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.',
'admin.defaultSettings.mapboxStyle': 'Map style',
'admin.defaultSettings.mapboxStylePlaceholder': 'Choose a style…',
'admin.defaultSettings.mapbox3d': '3D buildings & terrain',
'admin.defaultSettings.mapboxQuality': 'High-quality mode',
'admin.tabs.templates': 'Packing Templates',
'admin.packingTemplates.title': 'Packing Templates',
'admin.packingTemplates.subtitle':
+9
View File
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
'map.connections': 'Connections',
'map.showConnections': 'Show booking routes',
'map.hideConnections': 'Hide booking routes',
'poi.searchThisArea': 'Search this area',
'poi.cat.restaurants': 'Restaurants',
'poi.cat.cafes': 'Cafés',
'poi.cat.bars': 'Bars & nightlife',
'poi.cat.hotels': 'Accommodation',
'poi.cat.sights': 'Sights',
'poi.cat.museums': 'Museums & culture',
'poi.cat.nature': 'Nature & parks',
'poi.cat.activities': 'Activities',
};
export default map;
+3
View File
@@ -63,6 +63,9 @@ const settings: TranslationStrings = {
'settings.bookingLabels': 'Booking route labels',
'settings.bookingLabelsHint':
'Show station / airport names on the map. When off, only the icon is shown.',
'settings.mapPoiPill': 'Explore places on the map',
'settings.mapPoiPillHint':
'Show a category pill on the trip map to find nearby restaurants, hotels and more from OpenStreetMap.',
'settings.blurBookingCodes': 'Blur Booking Codes',
'settings.optimizeFromAccommodation': 'Optimize route from accommodation',
'settings.optimizeFromAccommodationHint':
+10
View File
@@ -388,5 +388,15 @@ const admin: TranslationStrings = {
'Elimina todas las passkeys de este usuario (p. ej. tras perder un dispositivo). Aún podrá iniciar sesión con su contraseña.',
'admin.passkey.resetConfirm': '¿Eliminar todas las passkeys de {name}?',
'admin.passkey.resetDone': 'Se eliminaron {count} passkey(s)',
'admin.defaultSettings.mapProvider': 'Motor de mapas',
'admin.defaultSettings.mapProviderHint': '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.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.',
'admin.defaultSettings.mapboxStyle': 'Estilo de mapa',
'admin.defaultSettings.mapboxStylePlaceholder': 'Elige un estilo…',
'admin.defaultSettings.mapbox3d': 'Edificios y terreno en 3D',
'admin.defaultSettings.mapboxQuality': 'Modo de alta calidad',
};
export default admin;
+9
View File
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
'map.connections': 'Conexiones',
'map.showConnections': 'Mostrar rutas de reservas',
'map.hideConnections': 'Ocultar rutas de reservas',
'poi.searchThisArea': 'Buscar en esta zona',
'poi.cat.restaurants': 'Restaurantes',
'poi.cat.cafes': 'Cafés',
'poi.cat.bars': 'Bares y ocio nocturno',
'poi.cat.hotels': 'Alojamiento',
'poi.cat.sights': 'Lugares de interés',
'poi.cat.museums': 'Museos y cultura',
'poi.cat.nature': 'Naturaleza y parques',
'poi.cat.activities': 'Actividades',
};
export default map;
+2
View File
@@ -324,6 +324,8 @@ const settings: TranslationStrings = {
'settings.passkey.deviceBound': 'Este dispositivo',
'settings.passkey.lastUsed': 'Último uso',
'settings.passkey.neverUsed': 'Nunca usada',
'settings.mapPoiPill': 'Explorar lugares en el mapa',
'settings.mapPoiPillHint': 'Muestra una píldora de categorías en el mapa del viaje para encontrar restaurantes, alojamientos y más cerca, desde OpenStreetMap.',
};
export default settings;
+10
View File
@@ -388,5 +388,15 @@ const admin: TranslationStrings = {
"Supprime toutes les passkeys de cet utilisateur (ex. en cas d'appareil perdu). Il pourra toujours se connecter avec son mot de passe.",
'admin.passkey.resetConfirm': 'Supprimer toutes les passkeys de {name} ?',
'admin.passkey.resetDone': '{count} passkey(s) supprimée(s)',
'admin.defaultSettings.mapProvider': 'Moteur cartographique',
'admin.defaultSettings.mapProviderHint': '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.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.',
'admin.defaultSettings.mapboxStyle': 'Style de carte',
'admin.defaultSettings.mapboxStylePlaceholder': 'Choisissez un style…',
'admin.defaultSettings.mapbox3d': 'Bâtiments & terrain en 3D',
'admin.defaultSettings.mapboxQuality': 'Mode haute qualité',
};
export default admin;
+9
View File
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
'map.connections': 'Connexions',
'map.showConnections': 'Afficher les itinéraires',
'map.hideConnections': 'Masquer les itinéraires',
'poi.searchThisArea': 'Rechercher dans cette zone',
'poi.cat.restaurants': 'Restaurants',
'poi.cat.cafes': 'Cafés',
'poi.cat.bars': 'Bars & vie nocturne',
'poi.cat.hotels': 'Hébergement',
'poi.cat.sights': 'Sites touristiques',
'poi.cat.museums': 'Musées & culture',
'poi.cat.nature': 'Nature & parcs',
'poi.cat.activities': 'Activités',
};
export default map;
+2
View File
@@ -329,6 +329,8 @@ const settings: TranslationStrings = {
'settings.passkey.deviceBound': 'Cet appareil',
'settings.passkey.lastUsed': 'Dernière utilisation',
'settings.passkey.neverUsed': 'Jamais utilisée',
'settings.mapPoiPill': 'Explorer les lieux sur la carte',
'settings.mapPoiPillHint': 'Afficher une pastille de catégorie sur la carte du voyage pour trouver à proximité des restaurants, hébergements et plus encore depuis OpenStreetMap.',
};
export default settings;
+10
View File
@@ -389,5 +389,15 @@ const admin: TranslationStrings = {
'Αφαιρέστε όλα τα passkeys αυτού του χρήστη (π.χ. σε περίπτωση χαμένης συσκευής). Μπορούν ακόμη να συνδεθούν με τον κωδικό τους.',
'admin.passkey.resetConfirm': 'Αφαίρεση όλων των passkeys για τον/την {name};',
'admin.passkey.resetDone': 'Αφαιρέθηκαν {count} passkey(s)',
'admin.defaultSettings.mapProvider': 'Μηχανή χάρτη',
'admin.defaultSettings.mapProviderHint': 'Ο προεπιλεγμένος χάρτης για όλους σε αυτή την εγκατάσταση. Κάθε χρήστης μπορεί να τον αλλάξει στις δικές του ρυθμίσεις.',
'admin.defaultSettings.providerLeaflet': 'Τυπικός (δωρεάν)',
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
'admin.defaultSettings.mapboxToken': 'Κοινόχρηστο διακριτικό Mapbox',
'admin.defaultSettings.mapboxTokenHint': 'Χρησιμοποιείται για κάθε χρήστη που δεν έχει εισαγάγει το δικό του διακριτικό — έτσι ολόκληρη η εγκατάσταση αποκτά Mapbox χωρίς να μοιράζεται το κλειδί ξεχωριστά. Αποθηκεύεται κρυπτογραφημένο.',
'admin.defaultSettings.mapboxStyle': 'Στυλ χάρτη',
'admin.defaultSettings.mapboxStylePlaceholder': 'Επιλέξτε ένα στυλ…',
'admin.defaultSettings.mapbox3d': 'Κτίρια & ανάγλυφο 3D',
'admin.defaultSettings.mapboxQuality': 'Λειτουργία υψηλής ποιότητας',
};
export default admin;
+9
View File
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
'map.connections': 'Συνδέσεις',
'map.showConnections': 'Εμφάνιση διαδρομών κρατήσεων',
'map.hideConnections': 'Απόκρυψη διαδρομών κρατήσεων',
'poi.searchThisArea': 'Αναζήτηση σε αυτήν την περιοχή',
'poi.cat.restaurants': 'Εστιατόρια',
'poi.cat.cafes': 'Καφέ',
'poi.cat.bars': 'Μπαρ & νυχτερινή ζωή',
'poi.cat.hotels': 'Διαμονή',
'poi.cat.sights': 'Αξιοθέατα',
'poi.cat.museums': 'Μουσεία & πολιτισμός',
'poi.cat.nature': 'Φύση & πάρκα',
'poi.cat.activities': 'Δραστηριότητες',
};
export default map;
+2
View File
@@ -330,6 +330,8 @@ const settings: TranslationStrings = {
'settings.passkey.deviceBound': 'Αυτή η συσκευή',
'settings.passkey.lastUsed': 'Τελευταία χρήση',
'settings.passkey.neverUsed': 'Δεν χρησιμοποιήθηκε ποτέ',
'settings.mapPoiPill': 'Εξερεύνηση μερών στον χάρτη',
'settings.mapPoiPillHint': 'Εμφάνιση ετικέτας κατηγορίας στον χάρτη του ταξιδιού για εύρεση κοντινών εστιατορίων, ξενοδοχείων και άλλων από το OpenStreetMap.',
};
export default settings;
+10
View File
@@ -381,5 +381,15 @@ const admin: TranslationStrings = {
'Eltávolítja a felhasználó összes passkey-jét (pl. elveszett eszköz esetén). A jelszavukkal továbbra is be tudnak jelentkezni.',
'admin.passkey.resetConfirm': 'Eltávolítod {name} összes passkey-jét?',
'admin.passkey.resetDone': '{count} passkey eltávolítva',
'admin.defaultSettings.mapProvider': 'Térképmotor',
'admin.defaultSettings.mapProviderHint': '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.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.',
'admin.defaultSettings.mapboxStyle': 'Térképstílus',
'admin.defaultSettings.mapboxStylePlaceholder': 'Válassz stílust…',
'admin.defaultSettings.mapbox3d': '3D épületek & domborzat',
'admin.defaultSettings.mapboxQuality': 'Kiváló minőségű mód',
};
export default admin;
+9
View File
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
'map.connections': 'Kapcsolatok',
'map.showConnections': 'Foglalási útvonalak megjelenítése',
'map.hideConnections': 'Foglalási útvonalak elrejtése',
'poi.searchThisArea': 'Keresés ezen a területen',
'poi.cat.restaurants': 'Éttermek',
'poi.cat.cafes': 'Kávézók',
'poi.cat.bars': 'Bárok és éjszakai élet',
'poi.cat.hotels': 'Szállás',
'poi.cat.sights': 'Látnivalók',
'poi.cat.museums': 'Múzeumok és kultúra',
'poi.cat.nature': 'Természet és parkok',
'poi.cat.activities': 'Programok',
};
export default map;
+2
View File
@@ -326,6 +326,8 @@ const settings: TranslationStrings = {
'settings.passkey.deviceBound': 'Ez az eszköz',
'settings.passkey.lastUsed': 'Utoljára használva',
'settings.passkey.neverUsed': 'Még nem használt',
'settings.mapPoiPill': 'Helyek felfedezése a térképen',
'settings.mapPoiPillHint': 'Megjelenít egy kategóriasávot az utazási térképen, hogy az OpenStreetMap segítségével közeli éttermeket, szállásokat és továbbiakat találj.',
};
export default settings;
+10
View File
@@ -376,5 +376,15 @@ const admin: TranslationStrings = {
'Hapus semua passkey pengguna ini (mis. saat perangkat hilang). Mereka tetap bisa masuk dengan kata sandi mereka.',
'admin.passkey.resetConfirm': 'Hapus semua passkey untuk {name}?',
'admin.passkey.resetDone': 'Menghapus {count} passkey',
'admin.defaultSettings.mapProvider': 'Mesin peta',
'admin.defaultSettings.mapProviderHint': '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.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.',
'admin.defaultSettings.mapboxStyle': 'Gaya peta',
'admin.defaultSettings.mapboxStylePlaceholder': 'Pilih gaya…',
'admin.defaultSettings.mapbox3d': 'Bangunan & medan 3D',
'admin.defaultSettings.mapboxQuality': 'Mode kualitas tinggi',
};
export default admin;
+9
View File
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
'map.connections': 'Koneksi',
'map.showConnections': 'Tampilkan rute pemesanan',
'map.hideConnections': 'Sembunyikan rute pemesanan',
'poi.searchThisArea': 'Cari di area ini',
'poi.cat.restaurants': 'Restoran',
'poi.cat.cafes': 'Kafe',
'poi.cat.bars': 'Bar & hiburan malam',
'poi.cat.hotels': 'Penginapan',
'poi.cat.sights': 'Tempat wisata',
'poi.cat.museums': 'Museum & budaya',
'poi.cat.nature': 'Alam & taman',
'poi.cat.activities': 'Aktivitas',
};
export default map;
+2
View File
@@ -324,6 +324,8 @@ const settings: TranslationStrings = {
'settings.passkey.deviceBound': 'Perangkat ini',
'settings.passkey.lastUsed': 'Terakhir digunakan',
'settings.passkey.neverUsed': 'Belum pernah digunakan',
'settings.mapPoiPill': 'Jelajahi tempat di peta',
'settings.mapPoiPillHint': 'Tampilkan pil kategori di peta perjalanan untuk menemukan restoran, hotel, dan lainnya di sekitar dari OpenStreetMap.',
};
export default settings;
+10
View File
@@ -384,5 +384,15 @@ const admin: TranslationStrings = {
"Rimuovi tutte le passkey di questo utente (es. in caso di dispositivo smarrito). Potrà comunque accedere con la sua password.",
'admin.passkey.resetConfirm': 'Rimuovere tutte le passkey di {name}?',
'admin.passkey.resetDone': 'Rimosse {count} passkey',
'admin.defaultSettings.mapProvider': 'Motore mappe',
'admin.defaultSettings.mapProviderHint': '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.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.',
'admin.defaultSettings.mapboxStyle': 'Stile mappa',
'admin.defaultSettings.mapboxStylePlaceholder': 'Scegli uno stile…',
'admin.defaultSettings.mapbox3d': 'Edifici & terreno in 3D',
'admin.defaultSettings.mapboxQuality': 'Modalità alta qualità',
};
export default admin;
+9
View File
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
'map.connections': 'Connessioni',
'map.showConnections': 'Mostra percorsi prenotati',
'map.hideConnections': 'Nascondi percorsi prenotati',
'poi.searchThisArea': 'Cerca in questa zona',
'poi.cat.restaurants': 'Ristoranti',
'poi.cat.cafes': 'Caffè',
'poi.cat.bars': 'Bar e vita notturna',
'poi.cat.hotels': 'Alloggi',
'poi.cat.sights': 'Attrazioni',
'poi.cat.museums': 'Musei e cultura',
'poi.cat.nature': 'Natura e parchi',
'poi.cat.activities': 'Attività',
};
export default map;
+2
View File
@@ -323,6 +323,8 @@ const settings: TranslationStrings = {
'settings.passkey.deviceBound': 'Questo dispositivo',
'settings.passkey.lastUsed': 'Ultimo utilizzo',
'settings.passkey.neverUsed': 'Mai usata',
'settings.mapPoiPill': 'Esplora luoghi sulla mappa',
'settings.mapPoiPillHint': 'Mostra un selettore di categorie sulla mappa del viaggio per trovare ristoranti, hotel e altro nelle vicinanze da OpenStreetMap.',
};
export default settings;
+10
View File
@@ -355,5 +355,15 @@ const admin: TranslationStrings = {
'このユーザーのパスキーをすべて削除します(例:デバイスを紛失した場合)。パスワードでのサインインは引き続き可能です。',
'admin.passkey.resetConfirm': '{name} のパスキーをすべて削除しますか?',
'admin.passkey.resetDone': '{count} 件のパスキーを削除しました',
'admin.defaultSettings.mapProvider': '地図エンジン',
'admin.defaultSettings.mapProviderHint': 'このインスタンスの全員に適用される既定の地図です。各ユーザーは自分の設定でこれを上書きできます。',
'admin.defaultSettings.providerLeaflet': '標準(無料)',
'admin.defaultSettings.providerMapbox': 'Mapbox3D',
'admin.defaultSettings.mapboxToken': '共有 Mapbox トークン',
'admin.defaultSettings.mapboxTokenHint': '自分のトークンを入力していないすべてのユーザーに使用されます。これにより、キーを個別に共有しなくてもインスタンス全体で Mapbox を利用できます。暗号化して保存されます。',
'admin.defaultSettings.mapboxStyle': '地図スタイル',
'admin.defaultSettings.mapboxStylePlaceholder': 'スタイルを選択…',
'admin.defaultSettings.mapbox3d': '3D の建物と地形',
'admin.defaultSettings.mapboxQuality': '高品質モード',
};
export default admin;
+9
View File
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
'map.connections': '接続',
'map.showConnections': '予約ルートを表示',
'map.hideConnections': '予約ルートを非表示',
'poi.searchThisArea': 'このエリアを検索',
'poi.cat.restaurants': 'レストラン',
'poi.cat.cafes': 'カフェ',
'poi.cat.bars': 'バー・ナイトライフ',
'poi.cat.hotels': '宿泊施設',
'poi.cat.sights': '観光スポット',
'poi.cat.museums': '美術館・文化施設',
'poi.cat.nature': '自然・公園',
'poi.cat.activities': 'アクティビティ',
};
export default map;
+2
View File
@@ -303,6 +303,8 @@ const settings: TranslationStrings = {
'settings.passkey.deviceBound': 'このデバイス',
'settings.passkey.lastUsed': '最終使用',
'settings.passkey.neverUsed': '未使用',
'settings.mapPoiPill': '地図でスポットを探す',
'settings.mapPoiPillHint': '旅行の地図にカテゴリピルを表示して、OpenStreetMapから近くのレストランや宿泊施設などを見つけられます。',
};
export default settings;
+10
View File
@@ -368,5 +368,15 @@ const admin: TranslationStrings = {
'이 사용자의 모든 패스키를 삭제합니다 (예: 기기 분실 시). 사용자는 비밀번호로 계속 로그인할 수 있습니다.',
'admin.passkey.resetConfirm': '{name}의 모든 패스키를 삭제할까요?',
'admin.passkey.resetDone': '패스키 {count}개를 삭제했습니다',
'admin.defaultSettings.mapProvider': '지도 엔진',
'admin.defaultSettings.mapProviderHint': '이 인스턴스의 모든 사용자에게 적용되는 기본 지도입니다. 각 사용자는 자신의 설정에서 이를 변경할 수 있습니다.',
'admin.defaultSettings.providerLeaflet': '표준 (무료)',
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
'admin.defaultSettings.mapboxToken': '공유 Mapbox 토큰',
'admin.defaultSettings.mapboxTokenHint': '자신의 토큰을 입력하지 않은 모든 사용자에게 사용됩니다 — 키를 개별적으로 공유하지 않아도 인스턴스 전체에서 Mapbox를 사용할 수 있습니다. 암호화하여 저장됩니다.',
'admin.defaultSettings.mapboxStyle': '지도 스타일',
'admin.defaultSettings.mapboxStylePlaceholder': '스타일을 선택하세요…',
'admin.defaultSettings.mapbox3d': '3D 건물 & 지형',
'admin.defaultSettings.mapboxQuality': '고품질 모드',
};
export default admin;
+9
View File
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
'map.connections': '연결',
'map.showConnections': '예약 경로 표시',
'map.hideConnections': '예약 경로 숨기기',
'poi.searchThisArea': '이 지역 검색',
'poi.cat.restaurants': '음식점',
'poi.cat.cafes': '카페',
'poi.cat.bars': '바 & 나이트라이프',
'poi.cat.hotels': '숙소',
'poi.cat.sights': '명소',
'poi.cat.museums': '박물관 & 문화',
'poi.cat.nature': '자연 & 공원',
'poi.cat.activities': '액티비티',
};
export default map;
+2
View File
@@ -320,6 +320,8 @@ const settings: TranslationStrings = {
'settings.passkey.deviceBound': '이 기기',
'settings.passkey.lastUsed': '마지막 사용',
'settings.passkey.neverUsed': '사용한 적 없음',
'settings.mapPoiPill': '지도에서 장소 탐색',
'settings.mapPoiPillHint': '여행 지도에 카테고리 칩을 표시하여 OpenStreetMap에서 주변 음식점, 숙소 등을 찾아보세요.',
};
export default settings;
+10
View File
@@ -379,5 +379,15 @@ const admin: TranslationStrings = {
'Verwijder alle passkeys van deze gebruiker (bijv. bij een verloren apparaat). Ze kunnen nog steeds inloggen met hun wachtwoord.',
'admin.passkey.resetConfirm': 'Alle passkeys voor {name} verwijderen?',
'admin.passkey.resetDone': '{count} passkey(s) verwijderd',
'admin.defaultSettings.mapProvider': 'Kaartmotor',
'admin.defaultSettings.mapProviderHint': '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.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.',
'admin.defaultSettings.mapboxStyle': 'Kaartstijl',
'admin.defaultSettings.mapboxStylePlaceholder': 'Kies een stijl…',
'admin.defaultSettings.mapbox3d': '3D-gebouwen & terrein',
'admin.defaultSettings.mapboxQuality': 'Hogekwaliteitsmodus',
};
export default admin;
+9
View File
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
'map.connections': 'Verbindingen',
'map.showConnections': 'Boekingsroutes tonen',
'map.hideConnections': 'Boekingsroutes verbergen',
'poi.searchThisArea': 'Dit gebied doorzoeken',
'poi.cat.restaurants': 'Restaurants',
'poi.cat.cafes': 'Cafés',
'poi.cat.bars': 'Bars & uitgaan',
'poi.cat.hotels': 'Accommodatie',
'poi.cat.sights': 'Bezienswaardigheden',
'poi.cat.museums': 'Musea & cultuur',
'poi.cat.nature': 'Natuur & parken',
'poi.cat.activities': 'Activiteiten',
};
export default map;
+2
View File
@@ -323,6 +323,8 @@ const settings: TranslationStrings = {
'settings.passkey.deviceBound': 'Dit apparaat',
'settings.passkey.lastUsed': 'Laatst gebruikt',
'settings.passkey.neverUsed': 'Nooit gebruikt',
'settings.mapPoiPill': 'Plaatsen op de kaart ontdekken',
'settings.mapPoiPillHint': 'Toon een categorielabel op de reiskaart om restaurants, hotels en meer in de buurt te vinden via OpenStreetMap.',
};
export default settings;
+10
View File
@@ -381,5 +381,15 @@ const admin: TranslationStrings = {
'Usuń wszystkie klucze dostępu tego użytkownika (np. po utracie urządzenia). Nadal będzie mógł logować się hasłem.',
'admin.passkey.resetConfirm': 'Usunąć wszystkie klucze dostępu dla {name}?',
'admin.passkey.resetDone': 'Usunięto {count} kluczy dostępu',
'admin.defaultSettings.mapProvider': 'Silnik map',
'admin.defaultSettings.mapProviderHint': '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.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.',
'admin.defaultSettings.mapboxStyle': 'Styl mapy',
'admin.defaultSettings.mapboxStylePlaceholder': 'Wybierz styl…',
'admin.defaultSettings.mapbox3d': 'Budynki i teren 3D',
'admin.defaultSettings.mapboxQuality': 'Tryb wysokiej jakości',
};
export default admin;
+9
View File
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
'map.connections': 'Połączenia',
'map.showConnections': 'Pokaż trasy rezerwacji',
'map.hideConnections': 'Ukryj trasy rezerwacji',
'poi.searchThisArea': 'Szukaj w tym obszarze',
'poi.cat.restaurants': 'Restauracje',
'poi.cat.cafes': 'Kawiarnie',
'poi.cat.bars': 'Bary i życie nocne',
'poi.cat.hotels': 'Noclegi',
'poi.cat.sights': 'Atrakcje',
'poi.cat.museums': 'Muzea i kultura',
'poi.cat.nature': 'Przyroda i parki',
'poi.cat.activities': 'Aktywności',
};
export default map;
+2
View File
@@ -325,6 +325,8 @@ const settings: TranslationStrings = {
'settings.passkey.deviceBound': 'To urządzenie',
'settings.passkey.lastUsed': 'Ostatnio użyty',
'settings.passkey.neverUsed': 'Nigdy nieużywany',
'settings.mapPoiPill': 'Odkrywaj miejsca na mapie',
'settings.mapPoiPillHint': 'Pokaż na mapie wyprawy pasek z kategoriami, aby znaleźć pobliskie restauracje, hotele i więcej z OpenStreetMap.',
};
export default settings;
+10
View File
@@ -385,5 +385,15 @@ const admin: TranslationStrings = {
'Удалить все passkeys этого пользователя (напр. при потере устройства). Он по-прежнему сможет войти по паролю.',
'admin.passkey.resetConfirm': 'Удалить все passkeys пользователя {name}?',
'admin.passkey.resetDone': 'Удалено passkeys: {count}',
'admin.defaultSettings.mapProvider': 'Картографический движок',
'admin.defaultSettings.mapProviderHint': 'Карта по умолчанию для всех на этом сервере. Каждый пользователь по-прежнему может изменить её в своих настройках.',
'admin.defaultSettings.providerLeaflet': 'Стандартная (бесплатно)',
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
'admin.defaultSettings.mapboxToken': 'Общий токен Mapbox',
'admin.defaultSettings.mapboxTokenHint': 'Используется для каждого пользователя, который не ввёл собственный токен — так весь сервер получает Mapbox без необходимости делиться ключом по отдельности. Хранится в зашифрованном виде.',
'admin.defaultSettings.mapboxStyle': 'Стиль карты',
'admin.defaultSettings.mapboxStylePlaceholder': 'Выберите стиль…',
'admin.defaultSettings.mapbox3d': '3D-здания и рельеф',
'admin.defaultSettings.mapboxQuality': 'Режим высокого качества',
};
export default admin;
+9
View File
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
'map.connections': 'Соединения',
'map.showConnections': 'Показать маршруты бронирований',
'map.hideConnections': 'Скрыть маршруты бронирований',
'poi.searchThisArea': 'Искать в этой области',
'poi.cat.restaurants': 'Рестораны',
'poi.cat.cafes': 'Кафе',
'poi.cat.bars': 'Бары и ночная жизнь',
'poi.cat.hotels': 'Жильё',
'poi.cat.sights': 'Достопримечательности',
'poi.cat.museums': 'Музеи и культура',
'poi.cat.nature': 'Природа и парки',
'poi.cat.activities': 'Развлечения',
};
export default map;
+2
View File
@@ -323,6 +323,8 @@ const settings: TranslationStrings = {
'settings.passkey.deviceBound': 'Это устройство',
'settings.passkey.lastUsed': 'Последнее использование',
'settings.passkey.neverUsed': 'Не использовался',
'settings.mapPoiPill': 'Поиск мест на карте',
'settings.mapPoiPillHint': 'Показывать на карте поездки кнопку категорий, чтобы находить рядом рестораны, отели и другие места из OpenStreetMap.',
};
export default settings;
+10
View File
@@ -382,5 +382,15 @@ const admin: TranslationStrings = {
'Bu kullanıcının tüm passkeylerini kaldırın (ör. kaybolan bir cihazda). Yine de şifreleriyle oturum açabilirler.',
'admin.passkey.resetConfirm': '{name} için tüm passkeyler kaldırılsın mı?',
'admin.passkey.resetDone': '{count} passkey kaldırıldı',
'admin.defaultSettings.mapProvider': 'Harita motoru',
'admin.defaultSettings.mapProviderHint': '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.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.',
'admin.defaultSettings.mapboxStyle': 'Harita stili',
'admin.defaultSettings.mapboxStylePlaceholder': 'Bir stil seçin…',
'admin.defaultSettings.mapbox3d': '3D binalar & arazi',
'admin.defaultSettings.mapboxQuality': 'Yüksek kalite modu',
};
export default admin;
+9
View File
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
'map.connections': 'Bağlantılar',
'map.showConnections': 'Rezervasyon rotalarını göster',
'map.hideConnections': 'Rezervasyon rotalarını gizle',
'poi.searchThisArea': 'Bu alanda ara',
'poi.cat.restaurants': 'Restoranlar',
'poi.cat.cafes': 'Kafeler',
'poi.cat.bars': 'Bar ve gece hayatı',
'poi.cat.hotels': 'Konaklama',
'poi.cat.sights': 'Gezilecek yerler',
'poi.cat.museums': 'Müzeler ve kültür',
'poi.cat.nature': 'Doğa ve parklar',
'poi.cat.activities': 'Aktiviteler',
};
export default map;
+2
View File
@@ -324,6 +324,8 @@ const settings: TranslationStrings = {
'settings.passkey.deviceBound': 'Bu cihaz',
'settings.passkey.lastUsed': 'Son kullanım',
'settings.passkey.neverUsed': 'Hiç kullanılmadı',
'settings.mapPoiPill': 'Haritada yerleri keşfet',
'settings.mapPoiPillHint': 'Yakındaki restoranları, otelleri ve daha fazlasını OpenStreetMap\'ten bulmak için gezi haritasında bir kategori etiketi göster.',
};
export default settings;
+10
View File
@@ -389,5 +389,15 @@ const admin: TranslationStrings = {
'Видалити всі passkeys цього користувача (напр. у разі втрати пристрою). Він зможе входити за допомогою свого пароля.',
'admin.passkey.resetConfirm': 'Видалити всі passkeys для {name}?',
'admin.passkey.resetDone': 'Видалено passkeys: {count}',
'admin.defaultSettings.mapProvider': 'Картографічний рушій',
'admin.defaultSettings.mapProviderHint': 'Карта за замовчуванням для всіх на цьому екземплярі. Кожен користувач може змінити її у власних налаштуваннях.',
'admin.defaultSettings.providerLeaflet': 'Стандартна (безкоштовна)',
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
'admin.defaultSettings.mapboxToken': 'Спільний токен Mapbox',
'admin.defaultSettings.mapboxTokenHint': 'Використовується для кожного користувача, який не ввів власний токен — щоб увесь екземпляр отримав Mapbox без потреби ділитися ключем окремо. Зберігається в зашифрованому вигляді.',
'admin.defaultSettings.mapboxStyle': 'Стиль карти',
'admin.defaultSettings.mapboxStylePlaceholder': 'Виберіть стиль…',
'admin.defaultSettings.mapbox3d': '3D-будівлі та рельєф',
'admin.defaultSettings.mapboxQuality': 'Режим високої якості',
};
export default admin;
+9
View File
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
'map.connections': 'Сполучення',
'map.showConnections': 'Показати маршрути бронювань',
'map.hideConnections': 'Приховати маршрути бронювань',
'poi.searchThisArea': 'Шукати в цій області',
'poi.cat.restaurants': 'Ресторани',
'poi.cat.cafes': 'Кафе',
'poi.cat.bars': 'Бари та нічне життя',
'poi.cat.hotels': 'Житло',
'poi.cat.sights': 'Визначні місця',
'poi.cat.museums': 'Музеї та культура',
'poi.cat.nature': 'Природа та парки',
'poi.cat.activities': 'Активності',
};
export default map;
+2
View File
@@ -322,6 +322,8 @@ const settings: TranslationStrings = {
'settings.passkey.deviceBound': 'Цей пристрій',
'settings.passkey.lastUsed': 'Останнє використання',
'settings.passkey.neverUsed': 'Не використовувався',
'settings.mapPoiPill': 'Досліджуйте місця на карті',
'settings.mapPoiPillHint': 'Показувати на карті подорожі плашку категорій, щоб знаходити поблизу ресторани, готелі та інше з OpenStreetMap.',
};
export default settings;
+10
View File
@@ -345,5 +345,15 @@ const admin: TranslationStrings = {
'移除此使用者的所有 Passkey(例如裝置遺失時)。他們仍可使用密碼登入。',
'admin.passkey.resetConfirm': '要移除 {name} 的所有 Passkey 嗎?',
'admin.passkey.resetDone': '已移除 {count} 個 Passkey',
'admin.defaultSettings.mapProvider': '地圖引擎',
'admin.defaultSettings.mapProviderHint': '此執行個體上所有人的預設地圖。每位使用者仍可在自己的設定中覆寫此項。',
'admin.defaultSettings.providerLeaflet': '標準(免費)',
'admin.defaultSettings.providerMapbox': 'Mapbox3D',
'admin.defaultSettings.mapboxToken': '共用的 Mapbox 權杖',
'admin.defaultSettings.mapboxTokenHint': '用於每一位尚未輸入自己權杖的使用者 — 如此整個執行個體都能使用 Mapbox,而無需個別共享金鑰。以加密方式儲存。',
'admin.defaultSettings.mapboxStyle': '地圖樣式',
'admin.defaultSettings.mapboxStylePlaceholder': '選擇樣式…',
'admin.defaultSettings.mapbox3d': '3D 建築物與地形',
'admin.defaultSettings.mapboxQuality': '高品質模式',
};
export default admin;
+9
View File
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
'map.connections': '連接',
'map.showConnections': '顯示預訂路線',
'map.hideConnections': '隱藏預訂路線',
'poi.searchThisArea': '搜尋此區域',
'poi.cat.restaurants': '餐廳',
'poi.cat.cafes': '咖啡廳',
'poi.cat.bars': '酒吧與夜生活',
'poi.cat.hotels': '住宿',
'poi.cat.sights': '景點',
'poi.cat.museums': '博物館與文化',
'poi.cat.nature': '自然與公園',
'poi.cat.activities': '活動',
};
export default map;
+2
View File
@@ -309,6 +309,8 @@ const settings: TranslationStrings = {
'settings.passkey.deviceBound': '此裝置',
'settings.passkey.lastUsed': '上次使用',
'settings.passkey.neverUsed': '從未使用',
'settings.mapPoiPill': '在地圖上探索地點',
'settings.mapPoiPillHint': '在行程地圖上顯示分類標籤,透過 OpenStreetMap 尋找附近的餐廳、住宿等地點。',
};
export default settings;
+10
View File
@@ -344,5 +344,15 @@ const admin: TranslationStrings = {
'移除该用户的所有通行密钥(如设备丢失时)。他们仍可使用密码登录。',
'admin.passkey.resetConfirm': '移除 {name} 的所有通行密钥?',
'admin.passkey.resetDone': '已移除 {count} 个通行密钥',
'admin.defaultSettings.mapProvider': '地图引擎',
'admin.defaultSettings.mapProviderHint': '本实例中所有用户的默认地图。每位用户仍可在自己的设置中更改此项。',
'admin.defaultSettings.providerLeaflet': '标准(免费)',
'admin.defaultSettings.providerMapbox': 'Mapbox3D',
'admin.defaultSettings.mapboxToken': '共享 Mapbox 令牌',
'admin.defaultSettings.mapboxTokenHint': '用于所有未输入自己令牌的用户 — 这样无需逐个分享密钥,整个实例即可使用 Mapbox。以加密方式存储。',
'admin.defaultSettings.mapboxStyle': '地图样式',
'admin.defaultSettings.mapboxStylePlaceholder': '选择一种样式…',
'admin.defaultSettings.mapbox3d': '3D 建筑与地形',
'admin.defaultSettings.mapboxQuality': '高质量模式',
};
export default admin;
+9
View File
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
'map.connections': '连接',
'map.showConnections': '显示预订路线',
'map.hideConnections': '隐藏预订路线',
'poi.searchThisArea': '搜索此区域',
'poi.cat.restaurants': '餐厅',
'poi.cat.cafes': '咖啡馆',
'poi.cat.bars': '酒吧与夜生活',
'poi.cat.hotels': '住宿',
'poi.cat.sights': '景点',
'poi.cat.museums': '博物馆与文化',
'poi.cat.nature': '自然与公园',
'poi.cat.activities': '活动',
};
export default map;
+2
View File
@@ -308,6 +308,8 @@ const settings: TranslationStrings = {
'settings.passkey.deviceBound': '此设备',
'settings.passkey.lastUsed': '上次使用',
'settings.passkey.neverUsed': '从未使用',
'settings.mapPoiPill': '在地图上探索地点',
'settings.mapPoiPillHint': '在行程地图上显示分类标签,从 OpenStreetMap 查找附近的餐厅、酒店等。',
};
export default settings;