From 2411017c3390aab7b7de93f7bb1dc6504850dd0d Mon Sep 17 00:00:00 2001 From: Anton Tranelis <31516529+antontranelis@users.noreply.github.com> Date: Mon, 13 Oct 2025 13:10:38 +0200 Subject: [PATCH 1/2] feat(lib): maplibre (#425) --- app/src/pages/MapContainer.tsx | 4 + app/vite.config.ts | 17 +- .../snapshot/fields/maps/maplibre.json | 46 +++ .../snapshot/fields/maps/maplibre_style.json | 45 +++ .../snapshot/fields/maps/raster_tiles.json | 46 +++ .../fields/maps/tile_server_attribution.json | 8 +- .../snapshot/fields/maps/tile_server_url.json | 8 +- .../snapshot/fields/maps/tile_size.json | 43 +++ .../snapshot/fields/maps/tiles_type.json | 54 +++ .../snapshot/fields/maps/zoom_offset.json | 45 +++ lib/package.json | 4 +- .../Map/Subcomponents/MapLibreLayer.tsx | 53 +++ lib/src/Components/Map/UtopiaMap.tsx | 16 + lib/src/Components/Map/UtopiaMapInner.tsx | 31 +- package-lock.json | 312 +++++++++++++++++- 15 files changed, 698 insertions(+), 34 deletions(-) create mode 100644 backend/directus-config/development/snapshot/fields/maps/maplibre.json create mode 100644 backend/directus-config/development/snapshot/fields/maps/maplibre_style.json create mode 100644 backend/directus-config/development/snapshot/fields/maps/raster_tiles.json create mode 100644 backend/directus-config/development/snapshot/fields/maps/tile_size.json create mode 100644 backend/directus-config/development/snapshot/fields/maps/tiles_type.json create mode 100644 backend/directus-config/development/snapshot/fields/maps/zoom_offset.json create mode 100644 lib/src/Components/Map/Subcomponents/MapLibreLayer.tsx diff --git a/app/src/pages/MapContainer.tsx b/app/src/pages/MapContainer.tsx index b30c1cb7..0211ab05 100644 --- a/app/src/pages/MapContainer.tsx +++ b/app/src/pages/MapContainer.tsx @@ -86,7 +86,11 @@ function MapContainer({ layers, map }: { layers: LayerProps[]; map: any }) { expandLayerControl={map.expand_layer_control} tileServerUrl={map.tile_server_url} tileServerAttribution={map.tile_server_attribution} + tilesType={map.tiles_type} + maplibreStyle={map.maplibre_style} showFullscreenControl={map.show_fullscreen_control} + zoomOffset={map.zoom_offset} + tileSize={map.tile_size} > {layers && apis && diff --git a/app/vite.config.ts b/app/vite.config.ts index 5fff90b7..fa2b8910 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -22,10 +22,21 @@ export default defineConfig({ plugins: [react(), tailwindcss(), tsConfigPaths()], build: { sourcemap: true, + modulePreload: { + // Don't preload maplibre chunks - only load when actually needed + resolveDependencies: (_filename, deps) => { + return deps.filter((dep) => !dep.includes('maplibre')) + }, + }, rollupOptions: { output: { manualChunks: (id) => { - if (id.includes('lib/src')) { + // Handle lib source (dev) or dist (prod) + if (id.includes('lib/src') || id.includes('lib/dist')) { + // Separate chunk for MapLibre components + if (id.includes('MapLibre')) { + return 'maplibre-layer' + } return 'utopia-ui' } if (id.includes('node_modules')) { @@ -38,6 +49,10 @@ export default defineConfig({ if (id.includes('leaflet')) { return 'leaflet' } + // Separate chunk for maplibre-gl + if (id.includes('maplibre-gl')) { + return 'maplibre-gl' + } return 'vendor' } }, diff --git a/backend/directus-config/development/snapshot/fields/maps/maplibre.json b/backend/directus-config/development/snapshot/fields/maps/maplibre.json new file mode 100644 index 00000000..5f4ab619 --- /dev/null +++ b/backend/directus-config/development/snapshot/fields/maps/maplibre.json @@ -0,0 +1,46 @@ +{ + "collection": "maps", + "field": "maplibre", + "type": "alias", + "meta": { + "collection": "maps", + "conditions": [ + { + "hidden": false, + "name": "Show when maplibre tiles selected", + "options": null, + "rule": { + "_and": [ + { + "tiles_type": { + "_eq": "maplibre" + } + } + ] + } + } + ], + "display": null, + "display_options": null, + "field": "maplibre", + "group": "tile_server", + "hidden": true, + "interface": "group-detail", + "note": "Configuration for MapLibre GL vector tiles", + "options": { + "start": "open" + }, + "readonly": false, + "required": false, + "sort": 3, + "special": [ + "alias", + "no-data", + "group" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + } +} diff --git a/backend/directus-config/development/snapshot/fields/maps/maplibre_style.json b/backend/directus-config/development/snapshot/fields/maps/maplibre_style.json new file mode 100644 index 00000000..cd5f0c89 --- /dev/null +++ b/backend/directus-config/development/snapshot/fields/maps/maplibre_style.json @@ -0,0 +1,45 @@ +{ + "collection": "maps", + "field": "maplibre_style", + "type": "string", + "meta": { + "collection": "maps", + "conditions": null, + "display": null, + "display_options": null, + "field": "maplibre_style", + "group": "maplibre", + "hidden": false, + "interface": "input", + "note": "MapLibre style URL (default: OpenFreeMap Liberty style)", + "options": { + "placeholder": "https://tiles.openfreemap.org/styles/liberty" + }, + "readonly": false, + "required": false, + "sort": 1, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "maplibre_style", + "table": "maps", + "data_type": "character varying", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/backend/directus-config/development/snapshot/fields/maps/raster_tiles.json b/backend/directus-config/development/snapshot/fields/maps/raster_tiles.json new file mode 100644 index 00000000..abab9b57 --- /dev/null +++ b/backend/directus-config/development/snapshot/fields/maps/raster_tiles.json @@ -0,0 +1,46 @@ +{ + "collection": "maps", + "field": "raster_tiles", + "type": "alias", + "meta": { + "collection": "maps", + "conditions": [ + { + "hidden": false, + "name": "Show when raster tiles selected", + "options": null, + "rule": { + "_and": [ + { + "tiles_type": { + "_eq": "raster" + } + } + ] + } + } + ], + "display": null, + "display_options": null, + "field": "raster_tiles", + "group": "tile_server", + "hidden": true, + "interface": "group-detail", + "note": "Configuration for raster tile layers", + "options": { + "start": "open" + }, + "readonly": false, + "required": false, + "sort": 2, + "special": [ + "alias", + "no-data", + "group" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + } +} diff --git a/backend/directus-config/development/snapshot/fields/maps/tile_server_attribution.json b/backend/directus-config/development/snapshot/fields/maps/tile_server_attribution.json index dd6e5403..1f566cfb 100644 --- a/backend/directus-config/development/snapshot/fields/maps/tile_server_attribution.json +++ b/backend/directus-config/development/snapshot/fields/maps/tile_server_attribution.json @@ -11,11 +11,13 @@ "group": "tile_server", "hidden": false, "interface": "input", - "note": null, - "options": null, + "note": "Attribution text for raster tiles", + "options": { + "placeholder": "© OpenStreetMap" + }, "readonly": false, "required": false, - "sort": 2, + "sort": 4, "special": null, "translations": null, "validation": null, diff --git a/backend/directus-config/development/snapshot/fields/maps/tile_server_url.json b/backend/directus-config/development/snapshot/fields/maps/tile_server_url.json index cd7aea26..15bdb814 100644 --- a/backend/directus-config/development/snapshot/fields/maps/tile_server_url.json +++ b/backend/directus-config/development/snapshot/fields/maps/tile_server_url.json @@ -8,11 +8,13 @@ "display": null, "display_options": null, "field": "tile_server_url", - "group": "tile_server", + "group": "raster_tiles", "hidden": false, "interface": "input", - "note": null, - "options": null, + "note": "Raster tile server URL template (e.g., https://tile.osmand.net/hd/{z}/{x}/{y}.png)", + "options": { + "placeholder": "https://tile.osmand.net/hd/{z}/{x}/{y}.png" + }, "readonly": false, "required": false, "sort": 1, diff --git a/backend/directus-config/development/snapshot/fields/maps/tile_size.json b/backend/directus-config/development/snapshot/fields/maps/tile_size.json new file mode 100644 index 00000000..5e497e78 --- /dev/null +++ b/backend/directus-config/development/snapshot/fields/maps/tile_size.json @@ -0,0 +1,43 @@ +{ + "collection": "maps", + "field": "tile_size", + "type": "integer", + "meta": { + "collection": "maps", + "conditions": null, + "display": null, + "display_options": null, + "field": "tile_size", + "group": "raster_tiles", + "hidden": false, + "interface": "input", + "note": null, + "options": null, + "readonly": false, + "required": false, + "sort": 2, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "tile_size", + "table": "maps", + "data_type": "integer", + "default_value": 256, + "max_length": null, + "numeric_precision": 32, + "numeric_scale": 0, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/backend/directus-config/development/snapshot/fields/maps/tiles_type.json b/backend/directus-config/development/snapshot/fields/maps/tiles_type.json new file mode 100644 index 00000000..c75b3f39 --- /dev/null +++ b/backend/directus-config/development/snapshot/fields/maps/tiles_type.json @@ -0,0 +1,54 @@ +{ + "collection": "maps", + "field": "tiles_type", + "type": "string", + "meta": { + "collection": "maps", + "conditions": null, + "display": null, + "display_options": null, + "field": "tiles_type", + "group": "tile_server", + "hidden": false, + "interface": "select-dropdown", + "note": "Choose between raster tiles or vector tiles (MapLibre GL)", + "options": { + "choices": [ + { + "text": "Raster Tiles", + "value": "raster" + }, + { + "text": "Vector Tiles (MapLibre GL)", + "value": "maplibre" + } + ] + }, + "readonly": false, + "required": false, + "sort": 1, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "tiles_type", + "table": "maps", + "data_type": "character varying", + "default_value": "raster", + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/backend/directus-config/development/snapshot/fields/maps/zoom_offset.json b/backend/directus-config/development/snapshot/fields/maps/zoom_offset.json new file mode 100644 index 00000000..39200271 --- /dev/null +++ b/backend/directus-config/development/snapshot/fields/maps/zoom_offset.json @@ -0,0 +1,45 @@ +{ + "collection": "maps", + "field": "zoom_offset", + "type": "integer", + "meta": { + "collection": "maps", + "conditions": null, + "display": null, + "display_options": null, + "field": "zoom_offset", + "group": "raster_tiles", + "hidden": false, + "interface": "input", + "note": null, + "options": { + "min": 0 + }, + "readonly": false, + "required": false, + "sort": 3, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "zoom_offset", + "table": "maps", + "data_type": "integer", + "default_value": -1, + "max_length": null, + "numeric_precision": 32, + "numeric_scale": 0, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/lib/package.json b/lib/package.json index 6cb120b2..df6b3908 100644 --- a/lib/package.json +++ b/lib/package.json @@ -77,7 +77,7 @@ "eslint-plugin-react-refresh": "^0.4.18", "eslint-plugin-security": "^3.0.1", "eslint-plugin-yml": "^1.14.0", - "happy-dom": "^16.8.1", + "happy-dom": "^20.0.0", "postcss": "^8.4.21", "prettier": "^3.3.3", "react": "^18.3.1", @@ -102,6 +102,7 @@ }, "dependencies": { "@heroicons/react": "^2.0.17", + "@maplibre/maplibre-gl-leaflet": "^0.1.3", "@tanstack/react-query": "^5.17.8", "@tiptap/core": "^3.6.5", "@tiptap/extension-bubble-menu": "^3.6.5", @@ -119,6 +120,7 @@ "date-fns": "^3.3.1", "leaflet": "^1.9.4", "leaflet.locatecontrol": "^0.79.0", + "maplibre-gl": "^5.9.0", "radash": "^12.1.0", "react-colorful": "^5.6.1", "react-dropzone": "^14.3.8", diff --git a/lib/src/Components/Map/Subcomponents/MapLibreLayer.tsx b/lib/src/Components/Map/Subcomponents/MapLibreLayer.tsx new file mode 100644 index 00000000..92b55e5e --- /dev/null +++ b/lib/src/Components/Map/Subcomponents/MapLibreLayer.tsx @@ -0,0 +1,53 @@ +/* eslint-disable import/no-unassigned-import */ +import L from 'leaflet' +import { useEffect } from 'react' +import { useMap } from 'react-leaflet' + +import '@maplibre/maplibre-gl-leaflet' +import 'maplibre-gl/dist/maplibre-gl.css' + +declare module 'leaflet' { + interface MapLibreGLOptions { + style: string + attribution?: string + } + + interface MapLibreGLLayer extends Layer { + addTo(map: Map): this + } + + function maplibreGL(options: MapLibreGLOptions): MapLibreGLLayer +} + +/** + * MapLibreLayer component for rendering vector tiles with MapLibre GL + * Integrates MapLibre GL with Leaflet using the maplibre-gl-leaflet bridge + * + * @param styleUrl - URL to the MapLibre style JSON (default: OpenFreeMap Liberty style) + * @param attribution - Attribution text for the map tiles + */ +export function MapLibreLayer({ + styleUrl = 'https://tiles.openfreemap.org/styles/liberty', + attribution = '© OpenStreetMap', +}: { + styleUrl?: string + attribution?: string +}) { + const map = useMap() + + useEffect(() => { + const mapLibreLayer = L.maplibreGL({ + style: styleUrl, + attribution, + }) + + mapLibreLayer.addTo(map) + + // Cleanup function to remove layer when component unmounts + return () => { + map.removeLayer(mapLibreLayer) + } + }, [map, styleUrl, attribution]) + + return null +} diff --git a/lib/src/Components/Map/UtopiaMap.tsx b/lib/src/Components/Map/UtopiaMap.tsx index 939cf424..ebf80cbf 100644 --- a/lib/src/Components/Map/UtopiaMap.tsx +++ b/lib/src/Components/Map/UtopiaMap.tsx @@ -59,6 +59,10 @@ function UtopiaMap({ expandLayerControl, tileServerUrl, tileServerAttribution, + tilesType = 'raster', + maplibreStyle, + zoomOffset, + tileSize, }: { /** height of the map (default '500px') */ height?: string @@ -94,6 +98,14 @@ function UtopiaMap({ tileServerUrl?: string /** configure a custom tile server attribution */ tileServerAttribution?: string + /** tiles type: 'raster' or 'maplibre' (default 'raster') */ + tilesType?: 'raster' | 'maplibre' + /** MapLibre style URL for vector tiles (default: OpenFreeMap Liberty) */ + maplibreStyle?: string + /** zoom offset which is needed for some raster tile provider (eg. Mapbox) */ + zoomOffset?: number + /** tile size (default 256) */ + tileSize?: number }) { return ( @@ -116,6 +128,10 @@ function UtopiaMap({ expandLayerControl={expandLayerControl} tileServerUrl={tileServerUrl} tileServerAttribution={tileServerAttribution} + tilesType={tilesType} + maplibreStyle={maplibreStyle} + zoomOffset={zoomOffset} + tileSize={tileSize} > {children} diff --git a/lib/src/Components/Map/UtopiaMapInner.tsx b/lib/src/Components/Map/UtopiaMapInner.tsx index 9b102a93..f3760b82 100644 --- a/lib/src/Components/Map/UtopiaMapInner.tsx +++ b/lib/src/Components/Map/UtopiaMapInner.tsx @@ -42,6 +42,7 @@ import { LayerControl } from './Subcomponents/Controls/LayerControl' import { SearchControl } from './Subcomponents/Controls/SearchControl' import { TagsControl } from './Subcomponents/Controls/TagsControl' import { TextView } from './Subcomponents/ItemPopupComponents/TextView' +import { MapLibreLayer } from './Subcomponents/MapLibreLayer' import { SelectPosition } from './Subcomponents/SelectPosition' import type { Feature, Geometry as GeoJSONGeometry, GeoJsonObject } from 'geojson' @@ -59,6 +60,10 @@ export function UtopiaMapInner({ expandLayerControl, tileServerUrl, tileServerAttribution, + tilesType, + maplibreStyle, + zoomOffset = 0, + tileSize = 256, }: { children?: React.ReactNode geo?: GeoJsonObject @@ -72,6 +77,10 @@ export function UtopiaMapInner({ expandLayerControl?: boolean tileServerUrl?: string tileServerAttribution?: string + tilesType?: 'raster' | 'maplibre' + maplibreStyle?: string + zoomOffset?: number + tileSize?: number }) { const selectNewItemPosition = useSelectPosition() const setSelectNewItemPosition = useSetSelectPosition() @@ -284,14 +293,20 @@ export function UtopiaMapInner({ {showLayerControl && } {showGratitudeControl && } - OpenStreetMap' - } - url={tileServerUrl ?? 'https://tile.osmand.net/hd/{z}/{x}/{y}.png'} - /> + {tilesType === 'raster' ? ( + OpenStreetMap' + } + url={tileServerUrl ?? 'https://tile.osmand.net/hd/{z}/{x}/{y}.png'} + /> + ) : ( + + )} setClusterRef(r as any)} showCoverageOnHover diff --git a/package-lock.json b/package-lock.json index ba67dc6a..9e2d3df8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,6 +67,7 @@ "license": "GPL-3.0-only", "dependencies": { "@heroicons/react": "^2.0.17", + "@maplibre/maplibre-gl-leaflet": "^0.1.3", "@tanstack/react-query": "^5.17.8", "@tiptap/core": "^3.6.5", "@tiptap/extension-bubble-menu": "^3.6.5", @@ -84,6 +85,7 @@ "date-fns": "^3.3.1", "leaflet": "^1.9.4", "leaflet.locatecontrol": "^0.79.0", + "maplibre-gl": "^5.9.0", "radash": "^12.1.0", "react-colorful": "^5.6.1", "react-dropzone": "^14.3.8", @@ -135,7 +137,7 @@ "eslint-plugin-react-refresh": "^0.4.18", "eslint-plugin-security": "^3.0.1", "eslint-plugin-yml": "^1.14.0", - "happy-dom": "^16.8.1", + "happy-dom": "^20.0.0", "postcss": "^8.4.21", "prettier": "^3.3.3", "react": "^18.3.1", @@ -2670,6 +2672,123 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/geojson-rewind": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", + "license": "ISC", + "dependencies": { + "get-stream": "^6.0.1", + "minimist": "^1.2.6" + }, + "bin": { + "geojson-rewind": "geojson-rewind" + } + }, + "node_modules/@mapbox/geojson-rewind/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "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.0.7", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", + "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", + "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.4", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz", + "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.1" + } + }, + "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/maplibre-gl-leaflet": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-leaflet/-/maplibre-gl-leaflet-0.1.3.tgz", + "integrity": "sha512-9+hp1PSJcxuuj5/Zta9zbQ8+ZvN4doWXPtlY7ikNtUZY1VbkamY0uTqzHp9kxRPqpgeKGrI7MjzXvwzU88wWCw==", + "license": "ISC", + "peerDependencies": { + "@types/leaflet": "^1.9.0", + "leaflet": "^1.9.3", + "maplibre-gl": "^2.4.0 || ^3.3.1 || ^4.3.2 || ^5.0.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.2.0.tgz", + "integrity": "sha512-cE80g83fRcBbZbQC70siOUxUK6YJ/5ZkClDZbmm+hzrUbv+J6yntkMmcpdz9DbOrWOM7FHKR5rruc6Q/hWx5cA==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "rw": "^1.3.3", + "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/vt-pbf": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.0.3.tgz", + "integrity": "sha512-YsW99BwnT+ukJRkseBcLuZHfITB4puJoxnqPVjo72rhW/TaawVYsgQHcqWLzTxqknttYoDpgyERzWSa/XrETdA==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/vector-tile": "^2.0.4", + "@types/geojson-vt": "3.2.5", + "@types/supercluster": "^7.1.3", + "geojson-vt": "^4.0.2", + "pbf": "^4.0.1", + "supercluster": "^8.0.1" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -4497,6 +4616,15 @@ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", "license": "MIT" }, + "node_modules/@types/geojson-vt": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", + "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -4524,7 +4652,6 @@ "version": "1.9.20", "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.20.tgz", "integrity": "sha512-rooalPMlk61LCaLOvBF2VIf9M47HgMQqi5xQ9QRi7c8PkdIe0WrIi5IxXUXQjAdL0c+vcQ01mYWbthzmp9GHWw==", - "dev": true, "license": "MIT", "dependencies": { "@types/geojson": "*" @@ -4640,6 +4767,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -4659,6 +4795,13 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -7225,6 +7368,12 @@ "node": ">= 0.4" } }, + "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/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -8797,6 +8946,12 @@ "node": ">=6.9.0" } }, + "node_modules/geojson-vt": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz", + "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==", + "license": "ISC" + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -8908,6 +9063,12 @@ "assert-plus": "^1.0.0" } }, + "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": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -9045,17 +9206,28 @@ "license": "MIT" }, "node_modules/happy-dom": { - "version": "16.8.1", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-16.8.1.tgz", - "integrity": "sha512-n0QrmT9lD81rbpKsyhnlz3DgnMZlaOkJPpgi746doA+HvaMC79bdWkwjrNnGJRvDrWTI8iOcJiVTJ5CdT/AZRw==", + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.0.tgz", + "integrity": "sha512-GkWnwIFxVGCf2raNrxImLo397RdGhLapj5cT3R2PT7FwL62Ze1DROhzmYW7+J3p9105DYMVenEejEbnq5wA37w==", "dev": true, "license": "MIT", "dependencies": { - "webidl-conversions": "^7.0.0", + "@types/node": "^20.0.0", + "@types/whatwg-mimetype": "^3.0.2", "whatwg-mimetype": "^3.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/@types/node": { + "version": "20.19.20", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.20.tgz", + "integrity": "sha512-2Q7WS25j4pS1cS8yw3d6buNCVJukOTeQ39bAnwR6sOJbaxvyCGebzTMypDFN82CxBLnl+lSWVdCCWbRY6y9yZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" } }, "node_modules/has-bigints": { @@ -10250,6 +10422,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/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -10332,6 +10510,12 @@ "node": ">=4.0" } }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -10940,6 +11124,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/maplibre-gl": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.9.0.tgz", + "integrity": "sha512-YxW9glb/YrDXGDhqy1u+aG113+L86ttAUpTd6sCkGHyUKMXOX8qbGHJQVqxOczy+4CtRKnqcCfSura2MzB0nQA==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.0.7", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/maplibre-gl-style-spec": "^24.2.0", + "@maplibre/vt-pbf": "^4.0.3", + "@types/geojson": "^7946.0.16", + "@types/geojson-vt": "3.2.5", + "@types/supercluster": "^7.1.3", + "earcut": "^3.0.2", + "geojson-vt": "^4.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", + "supercluster": "^8.0.1", + "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-it": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", @@ -11724,7 +11945,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -11757,6 +11977,12 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "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/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -12299,6 +12525,18 @@ "node": ">= 14.16" } }, + "node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -12983,6 +13221,12 @@ "dev": true, "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/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -13307,6 +13551,12 @@ "prosemirror-transform": "^1.1.0" } }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -13386,6 +13636,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/radash": { "version": "12.1.1", "resolved": "https://registry.npmjs.org/radash/-/radash-12.1.1.tgz", @@ -13926,6 +14182,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/restore-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", @@ -14155,6 +14420,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -14929,6 +15200,15 @@ "postcss": "^8.2.15" } }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -15251,6 +15531,12 @@ "node": "^18.0.0 || >=20.0.0" } }, + "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": "2.0.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", @@ -16354,16 +16640,6 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", From 15fbd3e6ce2abf8409186cac9df744e39e537ce0 Mon Sep 17 00:00:00 2001 From: Anton Tranelis <31516529+antontranelis@users.noreply.github.com> Date: Mon, 13 Oct 2025 13:15:06 +0200 Subject: [PATCH 2/2] fix(lib): improved item header (#383) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/src/pages/MapContainer.tsx | 2 +- .../development/collections/settings.json | 2 + .../snapshot/fields/types/Header.json | 32 +++ .../snapshot/fields/types/Profile.json | 5 +- .../fields/types/cta_button_label.json | 59 +++++ .../fields/types/header_elements.json | 29 +++ .../fields/types/show_cta_button.json | 45 ++++ .../fields/types/show_navigation_button.json | 45 ++++ .../snapshot/fields/types/show_qr_button.json | 45 ++++ .../fields/types/show_share_button.json | 45 ++++ .../snapshot/fields/types/small_form.json | 5 +- .../snapshot/fields/types/small_view.json | 5 +- .../snapshot/fields/types/subtitle_label.json | 74 ++++++ .../snapshot/fields/types/subtitle_mode.json | 58 +++++ lib/src/Components/Item/PopupView.tsx | 2 +- .../Subcomponents/Controls/SearchControl.tsx | 2 +- .../Map/Subcomponents/ItemFormPopup.tsx | 2 +- .../ItemPopupComponents/HeaderView.tsx | 223 +----------------- .../HeaderView/ActionButtons.tsx | 42 ++++ .../HeaderView/ConnectionStatus.tsx | 46 ++++ .../HeaderView/DeleteModal.tsx | 37 +++ .../HeaderView/EditMenu.tsx | 112 +++++++++ .../HeaderView/ItemAvatar.tsx | 73 ++++++ .../HeaderView/ItemTitle.tsx | 132 +++++++++++ .../HeaderView/QRModal.tsx | 44 ++++ .../HeaderView/ShareButton.tsx | 168 +++++++++++++ .../ItemPopupComponents/HeaderView/hooks.ts | 77 ++++++ .../ItemPopupComponents/HeaderView/index.tsx | 91 +++++++ .../ItemPopupComponents/HeaderView/types.ts | 31 +++ .../ItemPopupComponents/StartEndView.tsx | 22 +- .../Map/Subcomponents/ItemViewPopup.tsx | 6 +- lib/src/Components/Map/UtopiaMapInner.tsx | 2 +- .../Components/Map/hooks/useGeoDistance.tsx | 56 +++++ .../Components/Map/hooks/useReverseGeocode.ts | 108 +++++++++ lib/src/Components/Profile/ProfileView.tsx | 9 +- .../Profile/Subcomponents/ActionsButton.tsx | 2 +- .../Profile/Subcomponents/FormHeader.tsx | 32 +-- .../Subcomponents/GroupSubHeaderView.tsx | 2 +- .../Subcomponents/LinkedItemsHeaderView.tsx | 2 +- .../Subcomponents/ProfileStartEndView.tsx | 2 +- lib/src/Components/Templates/DialogModal.tsx | 6 +- lib/src/types/Item.d.ts | 2 +- lib/src/types/ItemType.d.ts | 10 +- 43 files changed, 1521 insertions(+), 273 deletions(-) create mode 100644 backend/directus-config/development/snapshot/fields/types/Header.json create mode 100644 backend/directus-config/development/snapshot/fields/types/cta_button_label.json create mode 100644 backend/directus-config/development/snapshot/fields/types/header_elements.json create mode 100644 backend/directus-config/development/snapshot/fields/types/show_cta_button.json create mode 100644 backend/directus-config/development/snapshot/fields/types/show_navigation_button.json create mode 100644 backend/directus-config/development/snapshot/fields/types/show_qr_button.json create mode 100644 backend/directus-config/development/snapshot/fields/types/show_share_button.json create mode 100644 backend/directus-config/development/snapshot/fields/types/subtitle_label.json create mode 100644 backend/directus-config/development/snapshot/fields/types/subtitle_mode.json create mode 100644 lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/ActionButtons.tsx create mode 100644 lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/ConnectionStatus.tsx create mode 100644 lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/DeleteModal.tsx create mode 100644 lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/EditMenu.tsx create mode 100644 lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/ItemAvatar.tsx create mode 100644 lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/ItemTitle.tsx create mode 100644 lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/QRModal.tsx create mode 100644 lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/ShareButton.tsx create mode 100644 lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/hooks.ts create mode 100644 lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/index.tsx create mode 100644 lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/types.ts create mode 100644 lib/src/Components/Map/hooks/useGeoDistance.tsx create mode 100644 lib/src/Components/Map/hooks/useReverseGeocode.ts diff --git a/app/src/pages/MapContainer.tsx b/app/src/pages/MapContainer.tsx index 0211ab05..d3317a0a 100644 --- a/app/src/pages/MapContainer.tsx +++ b/app/src/pages/MapContainer.tsx @@ -124,7 +124,7 @@ function MapContainer({ layers, map }: { layers: LayerProps[]; map: any }) { parameterField={ layer.itemType.custom_profile_url ? 'extended.external_profile_id' : 'id' } - text={layer.itemType.botton_label ?? 'Profile'} + text={layer.itemType.button_label ?? 'Profile'} target={layer.itemType.custom_profile_url ? '_blank' : '_self'} /> )} diff --git a/backend/directus-config/development/collections/settings.json b/backend/directus-config/development/collections/settings.json index 3de70c72..09d708cf 100644 --- a/backend/directus-config/development/collections/settings.json +++ b/backend/directus-config/development/collections/settings.json @@ -60,6 +60,8 @@ "public_registration_role": null, "public_registration_email_filter": null, "visual_editor_urls": null, + "accepted_terms": true, + "project_id": "0199aa52-4dd7-7293-984a-f2af93b5f8fd", "_syncId": "55f04445-0c26-4201-ab9c-d6e0fbadf6bf" } ] diff --git a/backend/directus-config/development/snapshot/fields/types/Header.json b/backend/directus-config/development/snapshot/fields/types/Header.json new file mode 100644 index 00000000..7c1d379b --- /dev/null +++ b/backend/directus-config/development/snapshot/fields/types/Header.json @@ -0,0 +1,32 @@ +{ + "collection": "types", + "field": "Header", + "type": "alias", + "meta": { + "collection": "types", + "conditions": null, + "display": null, + "display_options": null, + "field": "Header", + "group": null, + "hidden": false, + "interface": "group-detail", + "note": null, + "options": { + "headerIcon": "credit_card", + "start": "closed" + }, + "readonly": false, + "required": false, + "sort": 7, + "special": [ + "alias", + "no-data", + "group" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + } +} diff --git a/backend/directus-config/development/snapshot/fields/types/Profile.json b/backend/directus-config/development/snapshot/fields/types/Profile.json index 4ffa0164..a4d2ed72 100644 --- a/backend/directus-config/development/snapshot/fields/types/Profile.json +++ b/backend/directus-config/development/snapshot/fields/types/Profile.json @@ -13,11 +13,12 @@ "interface": "group-detail", "note": null, "options": { - "headerIcon": "lab_profile" + "headerIcon": "lab_profile", + "start": "closed" }, "readonly": false, "required": false, - "sort": 9, + "sort": 10, "special": [ "alias", "no-data", diff --git a/backend/directus-config/development/snapshot/fields/types/cta_button_label.json b/backend/directus-config/development/snapshot/fields/types/cta_button_label.json new file mode 100644 index 00000000..c5a375d8 --- /dev/null +++ b/backend/directus-config/development/snapshot/fields/types/cta_button_label.json @@ -0,0 +1,59 @@ +{ + "collection": "types", + "field": "cta_button_label", + "type": "string", + "meta": { + "collection": "types", + "conditions": [ + { + "hidden": true, + "name": "show cta button", + "readonly": false, + "required": false, + "rule": { + "_and": [ + { + "show_cta_button": { + "_eq": false + } + } + ] + } + } + ], + "display": null, + "display_options": null, + "field": "cta_button_label", + "group": "header_elements", + "hidden": false, + "interface": "input", + "note": null, + "options": null, + "readonly": false, + "required": false, + "sort": 5, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "cta_button_label", + "table": "types", + "data_type": "character varying", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/backend/directus-config/development/snapshot/fields/types/header_elements.json b/backend/directus-config/development/snapshot/fields/types/header_elements.json new file mode 100644 index 00000000..0df97f33 --- /dev/null +++ b/backend/directus-config/development/snapshot/fields/types/header_elements.json @@ -0,0 +1,29 @@ +{ + "collection": "types", + "field": "header_elements", + "type": "alias", + "meta": { + "collection": "types", + "conditions": null, + "display": null, + "display_options": null, + "field": "header_elements", + "group": "Header", + "hidden": false, + "interface": "group-raw", + "note": null, + "options": null, + "readonly": false, + "required": false, + "sort": 3, + "special": [ + "alias", + "no-data", + "group" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + } +} diff --git a/backend/directus-config/development/snapshot/fields/types/show_cta_button.json b/backend/directus-config/development/snapshot/fields/types/show_cta_button.json new file mode 100644 index 00000000..35ceacc9 --- /dev/null +++ b/backend/directus-config/development/snapshot/fields/types/show_cta_button.json @@ -0,0 +1,45 @@ +{ + "collection": "types", + "field": "show_cta_button", + "type": "boolean", + "meta": { + "collection": "types", + "conditions": null, + "display": null, + "display_options": null, + "field": "show_cta_button", + "group": "header_elements", + "hidden": false, + "interface": "boolean", + "note": null, + "options": null, + "readonly": false, + "required": false, + "sort": 4, + "special": [ + "cast-boolean" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "show_cta_button", + "table": "types", + "data_type": "boolean", + "default_value": false, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/backend/directus-config/development/snapshot/fields/types/show_navigation_button.json b/backend/directus-config/development/snapshot/fields/types/show_navigation_button.json new file mode 100644 index 00000000..ac51b712 --- /dev/null +++ b/backend/directus-config/development/snapshot/fields/types/show_navigation_button.json @@ -0,0 +1,45 @@ +{ + "collection": "types", + "field": "show_navigation_button", + "type": "boolean", + "meta": { + "collection": "types", + "conditions": null, + "display": null, + "display_options": null, + "field": "show_navigation_button", + "group": "header_elements", + "hidden": false, + "interface": "boolean", + "note": null, + "options": null, + "readonly": false, + "required": false, + "sort": 2, + "special": [ + "cast-boolean" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "show_navigation_button", + "table": "types", + "data_type": "boolean", + "default_value": false, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/backend/directus-config/development/snapshot/fields/types/show_qr_button.json b/backend/directus-config/development/snapshot/fields/types/show_qr_button.json new file mode 100644 index 00000000..5f95f7e0 --- /dev/null +++ b/backend/directus-config/development/snapshot/fields/types/show_qr_button.json @@ -0,0 +1,45 @@ +{ + "collection": "types", + "field": "show_qr_button", + "type": "boolean", + "meta": { + "collection": "types", + "conditions": null, + "display": null, + "display_options": null, + "field": "show_qr_button", + "group": "header_elements", + "hidden": false, + "interface": "boolean", + "note": null, + "options": null, + "readonly": false, + "required": false, + "sort": 1, + "special": [ + "cast-boolean" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "show_qr_button", + "table": "types", + "data_type": "boolean", + "default_value": false, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/backend/directus-config/development/snapshot/fields/types/show_share_button.json b/backend/directus-config/development/snapshot/fields/types/show_share_button.json new file mode 100644 index 00000000..50fab53c --- /dev/null +++ b/backend/directus-config/development/snapshot/fields/types/show_share_button.json @@ -0,0 +1,45 @@ +{ + "collection": "types", + "field": "show_share_button", + "type": "boolean", + "meta": { + "collection": "types", + "conditions": null, + "display": null, + "display_options": null, + "field": "show_share_button", + "group": "header_elements", + "hidden": false, + "interface": "boolean", + "note": null, + "options": null, + "readonly": false, + "required": false, + "sort": 3, + "special": [ + "cast-boolean" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "show_share_button", + "table": "types", + "data_type": "boolean", + "default_value": false, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/backend/directus-config/development/snapshot/fields/types/small_form.json b/backend/directus-config/development/snapshot/fields/types/small_form.json index 064a143f..61675926 100644 --- a/backend/directus-config/development/snapshot/fields/types/small_form.json +++ b/backend/directus-config/development/snapshot/fields/types/small_form.json @@ -13,11 +13,12 @@ "interface": "group-detail", "note": null, "options": { - "headerIcon": "edit_square" + "headerIcon": "edit_square", + "start": "closed" }, "readonly": false, "required": false, - "sort": 8, + "sort": 9, "special": [ "alias", "no-data", diff --git a/backend/directus-config/development/snapshot/fields/types/small_view.json b/backend/directus-config/development/snapshot/fields/types/small_view.json index 4dddfbc2..6685a81a 100644 --- a/backend/directus-config/development/snapshot/fields/types/small_view.json +++ b/backend/directus-config/development/snapshot/fields/types/small_view.json @@ -13,11 +13,12 @@ "interface": "group-detail", "note": null, "options": { - "headerIcon": "wysiwyg" + "headerIcon": "wysiwyg", + "start": "closed" }, "readonly": false, "required": false, - "sort": 7, + "sort": 8, "special": [ "alias", "no-data", diff --git a/backend/directus-config/development/snapshot/fields/types/subtitle_label.json b/backend/directus-config/development/snapshot/fields/types/subtitle_label.json new file mode 100644 index 00000000..03f0ba86 --- /dev/null +++ b/backend/directus-config/development/snapshot/fields/types/subtitle_label.json @@ -0,0 +1,74 @@ +{ + "collection": "types", + "field": "subtitle_label", + "type": "string", + "meta": { + "collection": "types", + "conditions": [ + { + "hidden": false, + "name": "subtitle=custom", + "readonly": false, + "required": true, + "rule": { + "_and": [ + { + "subtitle_mode": { + "_eq": "custom" + } + } + ] + } + }, + { + "hidden": true, + "name": "subtitle != custom", + "readonly": true, + "required": false, + "rule": { + "_and": [ + { + "subtitle_mode": { + "_neq": "custom" + } + } + ] + } + } + ], + "display": null, + "display_options": null, + "field": "subtitle_label", + "group": "Header", + "hidden": false, + "interface": "input", + "note": null, + "options": null, + "readonly": false, + "required": false, + "sort": 2, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "subtitle_label", + "table": "types", + "data_type": "character varying", + "default_value": "Subname", + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/backend/directus-config/development/snapshot/fields/types/subtitle_mode.json b/backend/directus-config/development/snapshot/fields/types/subtitle_mode.json new file mode 100644 index 00000000..507c912e --- /dev/null +++ b/backend/directus-config/development/snapshot/fields/types/subtitle_mode.json @@ -0,0 +1,58 @@ +{ + "collection": "types", + "field": "subtitle_mode", + "type": "string", + "meta": { + "collection": "types", + "conditions": null, + "display": null, + "display_options": null, + "field": "subtitle_mode", + "group": "Header", + "hidden": false, + "interface": "select-dropdown", + "note": null, + "options": { + "choices": [ + { + "text": "address", + "value": "address" + }, + { + "text": "custom", + "value": "custom" + }, + { + "text": "none", + "value": "none" + } + ] + }, + "readonly": false, + "required": false, + "sort": 1, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "subtitle_mode", + "table": "types", + "data_type": "character varying", + "default_value": "address", + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/lib/src/Components/Item/PopupView.tsx b/lib/src/Components/Item/PopupView.tsx index 7c096262..98640f38 100644 --- a/lib/src/Components/Item/PopupView.tsx +++ b/lib/src/Components/Item/PopupView.tsx @@ -173,7 +173,7 @@ export const PopupView = ({ children }: { children?: React.ReactNode }) => { - {item.name || item.layer?.item_default_name} + {item.name ?? item.layer?.item_default_name} diff --git a/lib/src/Components/Map/Subcomponents/Controls/SearchControl.tsx b/lib/src/Components/Map/Subcomponents/Controls/SearchControl.tsx index fb3d33d8..8f50b056 100644 --- a/lib/src/Components/Map/Subcomponents/Controls/SearchControl.tsx +++ b/lib/src/Components/Map/Subcomponents/Controls/SearchControl.tsx @@ -79,7 +79,7 @@ export const SearchControl = () => { items.filter((item) => { return ( value.length > 2 && - ((item.layer?.listed && item.name.toLowerCase().includes(value.toLowerCase())) || + ((item.layer?.listed && item.name?.toLowerCase().includes(value.toLowerCase())) || item.text?.toLowerCase().includes(value.toLowerCase())) ) }), diff --git a/lib/src/Components/Map/Subcomponents/ItemFormPopup.tsx b/lib/src/Components/Map/Subcomponents/ItemFormPopup.tsx index cbc9ee71..9e0ab2a1 100644 --- a/lib/src/Components/Map/Subcomponents/ItemFormPopup.tsx +++ b/lib/src/Components/Map/Subcomponents/ItemFormPopup.tsx @@ -146,7 +146,7 @@ export function ItemFormPopup(props: Props) { (i) => i.user_created?.id === user?.id && i.layer === popupForm.layer, ) - const itemName = formItem.name || user?.first_name + const itemName = formItem.name ?? user?.first_name if (!itemName) { toast.error('Name must be defined') return false diff --git a/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView.tsx b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView.tsx index 2c0d49f0..21219a4a 100644 --- a/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView.tsx +++ b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView.tsx @@ -1,222 +1 @@ -/* eslint-disable @typescript-eslint/no-unnecessary-condition */ -/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */ -/* eslint-disable @typescript-eslint/no-misused-promises */ -/* eslint-disable @typescript-eslint/require-await */ -/* eslint-disable @typescript-eslint/restrict-template-expressions */ -/* eslint-disable @typescript-eslint/restrict-plus-operands */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-return */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import EllipsisVerticalIcon from '@heroicons/react/16/solid/EllipsisVerticalIcon' -import PencilIcon from '@heroicons/react/24/solid/PencilIcon' -import TrashIcon from '@heroicons/react/24/solid/TrashIcon' -import { useState } from 'react' -import SVG from 'react-inlinesvg' -import { useNavigate } from 'react-router-dom' - -import TargetDotSVG from '#assets/targetDot.svg' -import { useAppState } from '#components/AppShell/hooks/useAppState' -import { useHasUserPermission } from '#components/Map/hooks/usePermissions' -import DialogModal from '#components/Templates/DialogModal' - -import type { Item } from '#types/Item' -import type { ItemsApi } from '#types/ItemsApi' - -export function HeaderView({ - item, - api, - editCallback, - deleteCallback, - setPositionCallback, - loading, - hideMenu = false, - big = false, - truncateSubname = true, - hideSubname = false, - showAddress = false, -}: { - item?: Item - api?: ItemsApi - editCallback?: any - deleteCallback?: any - setPositionCallback?: any - loading?: boolean - hideMenu?: boolean - big?: boolean - hideSubname?: boolean - truncateSubname?: boolean - showAddress?: boolean -}) { - const [modalOpen, setModalOpen] = useState(false) - - const hasUserPermission = useHasUserPermission() - const navigate = useNavigate() - const appState = useAppState() - - const [imageLoaded, setImageLoaded] = useState(false) - - const avatar = - (item?.image && appState.assetsApi.url + item.image + '?width=160&heigth=160') || - item?.image_external - const title = item?.name ?? item?.layer?.item_default_name - const subtitle = item?.subname - - const [address] = useState('') - - const params = new URLSearchParams(window.location.search) - - const openDeleteModal = async (event: React.MouseEvent) => { - setModalOpen(true) - event.stopPropagation() - } - if (!item) return null - return ( - <> -
-
-
- {avatar && ( -
-
- {item.name setImageLoaded(true)} - onError={() => setImageLoaded(false)} - style={{ display: imageLoaded ? 'block' : 'none' }} - /> - {!imageLoaded && ( -
- )} -
-
- )} -
-
- {title} -
- {showAddress && address && !hideSubname && ( -
- {address} -
- )} - {subtitle && !hideSubname && ( -
- {subtitle} -
- )} -
-
-
-
e.stopPropagation()} className={`${big ? 'tw:mt-5' : 'tw:mt-1'}`}> - {(api?.deleteItem || item.layer?.api?.updateItem) && - (hasUserPermission(api?.collectionName!, 'delete', item) || - hasUserPermission(api?.collectionName!, 'update', item)) && - !hideMenu && ( -
- - -
- )} -
-
- setModalOpen(false)} - > -
e.stopPropagation()}> - - Do you want to delete {item.name}? - -
-
- - -
-
-
-
- - ) -} +export { HeaderView } from './HeaderView/index' diff --git a/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/ActionButtons.tsx b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/ActionButtons.tsx new file mode 100644 index 00000000..e36e70fe --- /dev/null +++ b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/ActionButtons.tsx @@ -0,0 +1,42 @@ +import { LuNavigation } from 'react-icons/lu' + +import { useMyProfile } from '#components/Map/hooks/useMyProfile' + +import { useNavigationUrl } from './hooks' +import { ShareButton } from './ShareButton' + +import type { Item } from '#types/Item' + +interface ActionButtonsProps { + item: Item +} + +export function ActionButtons({ item }: ActionButtonsProps) { + const myProfile = useMyProfile() + const { getNavigationUrl, isMobile, isIOS } = useNavigationUrl( + item.position?.coordinates as [number, number] | undefined, + ) + + const showNavigationButton = item.layer?.itemType.show_navigation_button ?? true + const showShareButton = item.layer?.itemType.show_share_button ?? true + const isOtherProfile = myProfile.myProfile?.id !== item.id + + return ( + <> + {item.position?.coordinates && isOtherProfile && showNavigationButton && ( + + + + )} + {isOtherProfile && showShareButton && } + + ) +} diff --git a/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/ConnectionStatus.tsx b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/ConnectionStatus.tsx new file mode 100644 index 00000000..42a21e0d --- /dev/null +++ b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/ConnectionStatus.tsx @@ -0,0 +1,46 @@ +import { FaPlus } from 'react-icons/fa6' + +import { useMyProfile } from '#components/Map/hooks/useMyProfile' +import { useGetItemTags } from '#components/Map/hooks/useTags' + +import type { Item } from '#types/Item' + +interface ConnectionStatusProps { + item: Item +} + +export function ConnectionStatus({ item }: ConnectionStatusProps) { + const myProfile = useMyProfile() + const getItemTags = useGetItemTags() + + if (myProfile.myProfile?.id === item.id) { + return null + } + + const isConnected = item.relations?.some( + (r) => + r.type === item.layer?.itemType.cta_relation && + r.related_items_id === myProfile.myProfile?.id, + ) + + if (!item.layer?.itemType.show_cta_button) { + return null + } + + if (isConnected) { + return

✅ Connected

+ } + + const tags = getItemTags(item) + return ( + + ) +} diff --git a/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/DeleteModal.tsx b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/DeleteModal.tsx new file mode 100644 index 00000000..c721ba84 --- /dev/null +++ b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/DeleteModal.tsx @@ -0,0 +1,37 @@ +import DialogModal from '#components/Templates/DialogModal' + +import type { Item } from '#types/Item' + +interface DeleteModalProps { + item: Item + isOpen: boolean + onClose: () => void + onConfirm: (e: React.MouseEvent) => void +} + +export function DeleteModal({ item, isOpen, onClose, onConfirm }: DeleteModalProps) { + const handleConfirm = (e: React.MouseEvent) => { + onConfirm(e) + onClose() + } + + return ( + +
e.stopPropagation()}> + + Do you want to delete {item.name}? + +
+
+ + +
+
+
+
+ ) +} diff --git a/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/EditMenu.tsx b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/EditMenu.tsx new file mode 100644 index 00000000..ade02db1 --- /dev/null +++ b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/EditMenu.tsx @@ -0,0 +1,112 @@ +import EllipsisVerticalIcon from '@heroicons/react/16/solid/EllipsisVerticalIcon' +import PencilIcon from '@heroicons/react/24/solid/PencilIcon' +import TrashIcon from '@heroicons/react/24/solid/TrashIcon' +import SVG from 'react-inlinesvg' +import { useNavigate } from 'react-router-dom' + +import TargetDotSVG from '#assets/targetDot.svg' +import { useHasUserPermission } from '#components/Map/hooks/usePermissions' + +import type { Item } from '#types/Item' +import type { ItemsApi } from '#types/ItemsApi' + +interface EditMenuProps { + item: Item + api?: ItemsApi + editCallback?: (e: React.MouseEvent) => void + deleteCallback?: (e: React.MouseEvent) => void + setPositionCallback?: () => void + loading?: boolean + hideMenu?: boolean + big?: boolean + onDeleteModalOpen: () => void +} + +export function EditMenu({ + item, + api, + editCallback, + deleteCallback, + setPositionCallback, + loading = false, + hideMenu = false, + big = false, + onDeleteModalOpen, +}: EditMenuProps) { + const hasUserPermission = useHasUserPermission() + const navigate = useNavigate() + + const params = new URLSearchParams(window.location.search) + + const handleDeleteClick = (event: React.MouseEvent) => { + onDeleteModalOpen() + event.stopPropagation() + } + + if (hideMenu) return null + + const hasDeletePermission = + api?.deleteItem && api.collectionName && hasUserPermission(api.collectionName, 'delete', item) + const hasUpdatePermission = + api?.updateItem && api.collectionName && hasUserPermission(api.collectionName, 'update', item) + + if (!hasDeletePermission && !hasUpdatePermission) return null + + return ( +
e.stopPropagation()} className={`${big ? 'tw:mt-5' : 'tw:mt-1'}`}> +
+ + +
+
+ ) +} diff --git a/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/ItemAvatar.tsx b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/ItemAvatar.tsx new file mode 100644 index 00000000..90fa7f07 --- /dev/null +++ b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/ItemAvatar.tsx @@ -0,0 +1,73 @@ +import { QrCodeIcon } from '@heroicons/react/24/solid' +import { useState } from 'react' + +import { useAppState } from '#components/AppShell/hooks/useAppState' + +import type { Item } from '#types/Item' + +interface ItemAvatarProps { + item: Item + big?: boolean + extraLarge?: boolean + showQrButton?: boolean + onQrClick?: () => void +} + +export function ItemAvatar({ + item, + big = false, + extraLarge = false, + showQrButton = false, + onQrClick, +}: ItemAvatarProps) { + const appState = useAppState() + const [imageLoaded, setImageLoaded] = useState(false) + + const imageSize = extraLarge ? 320 : 160 + const avatar = + (item.image && + appState.assetsApi.url + item.image + `?width=${imageSize}&height=${imageSize}`) ?? + item.image_external + + const hasAvatar = !!avatar + + // If no avatar but QR button should be shown, show only the QR button + if (!hasAvatar && showQrButton) { + return ( + + ) + } + + if (!hasAvatar) return null + + const avatarSize = extraLarge ? 'tw:w-32' : big ? 'tw:w-16' : 'tw:w-10' + + return ( +
+
+ {(item.name setImageLoaded(true)} + onError={() => setImageLoaded(false)} + style={{ display: imageLoaded ? 'block' : 'none' }} + /> + {!imageLoaded &&
} +
+ {showQrButton && ( + + )} +
+ ) +} diff --git a/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/ItemTitle.tsx b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/ItemTitle.tsx new file mode 100644 index 00000000..713f3373 --- /dev/null +++ b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/ItemTitle.tsx @@ -0,0 +1,132 @@ +import { MapPinIcon } from '@heroicons/react/24/solid' +import { useEffect, useRef, useState } from 'react' + +import { useGeoDistance } from '#components/Map/hooks/useGeoDistance' +import { useReverseGeocode } from '#components/Map/hooks/useReverseGeocode' + +import { useFormatDistance } from './hooks' + +import type { Item } from '#types/Item' + +interface ItemTitleProps { + item: Item + big?: boolean + truncateSubname?: boolean + subtitleMode?: 'address' | 'custom' | 'none' + hasAvatar?: boolean +} + +export function ItemTitle({ + item, + big = false, + truncateSubname = true, + subtitleMode = 'address', + hasAvatar = false, +}: ItemTitleProps) { + const { distance } = useGeoDistance(item.position ?? undefined) + const { formatDistance } = useFormatDistance() + const titleRef = useRef(null) + const containerRef = useRef(null) + const [fontSize, setFontSize] = useState('tw:text-xl') + + const { address } = useReverseGeocode( + item.position?.coordinates as [number, number] | undefined, + subtitleMode === 'address', + 'municipality', + ) + + const title = item.name ?? item.layer?.item_default_name + const subtitle = item.subname + + useEffect(() => { + if (!containerRef.current || !title) { + return + } + + const calculateFontSize = () => { + const container = containerRef.current + if (!container) return + + const containerWidth = container.offsetWidth + + // Create temporary element to measure text width + const measureElement = document.createElement('span') + measureElement.style.position = 'absolute' + measureElement.style.visibility = 'hidden' + measureElement.style.whiteSpace = 'nowrap' + measureElement.style.fontWeight = '700' // font-bold + measureElement.textContent = title + document.body.appendChild(measureElement) + + // Measure at different font sizes - include larger sizes only if big is true + const fontSizes = big + ? [ + { class: 'tw:text-2xl', pixels: 24 }, + { class: 'tw:text-xl', pixels: 20 }, + { class: 'tw:text-lg', pixels: 18 }, + ] + : [ + { class: 'tw:text-xl', pixels: 20 }, + { class: 'tw:text-lg', pixels: 18 }, + ] + + let selectedSize = 'tw:text-lg' + + for (const size of fontSizes) { + measureElement.style.fontSize = `${size.pixels}px` + const textWidth = measureElement.offsetWidth + + if (textWidth <= containerWidth) { + selectedSize = size.class + break + } + } + + document.body.removeChild(measureElement) + setFontSize(selectedSize) + } + + // Initial calculation + calculateFontSize() + + // Watch for container size changes + const resizeObserver = new ResizeObserver(calculateFontSize) + resizeObserver.observe(containerRef.current) + + return () => { + resizeObserver.disconnect() + } + }, [title, big]) + + return ( +
+
+ {title} +
+ {subtitleMode === 'address' && address && ( +
+ + + {address} + {distance && distance >= 0.1 ? ` (${formatDistance(distance) ?? ''})` : ''} + +
+ )} + {subtitleMode === 'custom' && subtitle && ( +
+ {subtitle} +
+ )} +
+ ) +} diff --git a/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/QRModal.tsx b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/QRModal.tsx new file mode 100644 index 00000000..66f93d12 --- /dev/null +++ b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/QRModal.tsx @@ -0,0 +1,44 @@ +import QRCode from 'react-qr-code' + +import DialogModal from '#components/Templates/DialogModal' + +import { useShareLogic } from './hooks' +import { ItemAvatar } from './ItemAvatar' +import { ShareButton } from './ShareButton' + +import type { Item } from '#types/Item' + +interface QRModalProps { + item: Item + isOpen: boolean + onClose: () => void +} + +export function QRModal({ item, isOpen, onClose }: QRModalProps) { + const { inviteLink } = useShareLogic(item) + + return ( + +
e.stopPropagation()} className='tw:text-center tw:p-4'> +

Share your Profile to expand your Network!

+ +
+ +
+ +
+
+ +
+ {inviteLink} + +
+
+
+ ) +} diff --git a/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/ShareButton.tsx b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/ShareButton.tsx new file mode 100644 index 00000000..b1605066 --- /dev/null +++ b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/ShareButton.tsx @@ -0,0 +1,168 @@ +import { ShareIcon } from '@heroicons/react/24/solid' +import { useRef } from 'react' + +import ChevronSVG from '#assets/chevron.svg' +import ClipboardSVG from '#assets/share/clipboard.svg' +import FacebookSVG from '#assets/share/facebook.svg' +import LinkedinSVG from '#assets/share/linkedin.svg' +import TelegramSVG from '#assets/share/telegram.svg' +import TwitterSVG from '#assets/share/twitter.svg' +import WhatsappSVG from '#assets/share/whatsapp.svg' +import XingSVG from '#assets/share/xing.svg' + +import { useShareLogic } from './hooks' + +import type { Item } from '#types/Item' +import type { PlatformConfig, SharePlatformConfigs } from './types' + +interface ShareButtonProps { + item: Item + dropdownDirection?: 'up' | 'down' +} + +export function ShareButton({ item, dropdownDirection = 'down' }: ShareButtonProps) { + const { shareUrl, shareTitle, copyLink, getShareUrl } = useShareLogic(item) + const detailsRef = useRef(null) + + const closeDropdown = () => { + if (detailsRef.current) { + detailsRef.current.open = false + } + } + + const handleCopyLink = () => { + copyLink() + closeDropdown() + } + + const canUseNativeShare = + typeof navigator !== 'undefined' && typeof navigator.share !== 'undefined' + + const handleNativeShare = () => { + void navigator + .share({ + title: shareTitle, + url: shareUrl, + }) + .then(closeDropdown) + .catch(() => { + // User cancelled or error occurred - ignore + }) + } + + const platformConfigs: SharePlatformConfigs = { + facebook: { + shareUrl: 'https://www.facebook.com/sharer/sharer.php?u={url}', + icon: Facebook, + label: 'Facebook', + bgColor: '#3b5998', + }, + twitter: { + shareUrl: 'https://twitter.com/intent/tweet?text={title}:%20{url}', + icon: Twitter, + label: 'Twitter', + bgColor: '#55acee', + }, + linkedin: { + shareUrl: 'http://www.linkedin.com/shareArticle?mini=true&url={url}&title={title}', + icon: Linkedin, + label: 'LinkedIn', + bgColor: '#4875b4', + }, + whatsapp: { + shareUrl: 'https://api.whatsapp.com/send?text={title}%20{url}', + icon: Whatsapp, + label: 'WhatsApp', + bgColor: '#25D366', + }, + telegram: { + shareUrl: 'https://t.me/share/url?url={url}&text={title}', + icon: Telegram, + label: 'Telegram', + bgColor: '#0088cc', + }, + xing: { + shareUrl: 'https://www.xing-share.com/app/user?op=share;sc_p=xing-share;url={url}', + icon: Xing, + label: 'Xing', + bgColor: '#026466', + }, + } + + const dropdownClass = dropdownDirection === 'up' ? 'tw:dropdown-top' : '' + + // If native share is available, render a simple button instead of dropdown + if (canUseNativeShare) { + return ( + + ) + } + + // Otherwise, render the dropdown with manual share options + return ( +
+ + + + +
+ ) +} diff --git a/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/hooks.ts b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/hooks.ts new file mode 100644 index 00000000..5d13af9b --- /dev/null +++ b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/hooks.ts @@ -0,0 +1,77 @@ +import { toast } from 'react-toastify' + +import type { Item } from '#types/Item' +import type { SharePlatformConfigs } from './types' + +export const useNavigationUrl = (coordinates?: [number, number]) => { + const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) + const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( + navigator.userAgent, + ) + + const getNavigationUrl = () => { + if (!coordinates) return '' + + const [longitude, latitude] = coordinates + + if (isIOS) { + return `https://maps.apple.com/?daddr=${latitude},${longitude}` + } else if (isMobile) { + return `geo:${latitude},${longitude}` + } else { + return `https://www.google.com/maps/dir/?api=1&destination=${latitude},${longitude}` + } + } + + return { + getNavigationUrl, + isMobile, + isIOS, + } +} + +export const useShareLogic = (item?: Item) => { + const shareUrl = window.location.href + const shareTitle = item?.name ?? 'Utopia Map Item' + const inviteLink = shareUrl + + const copyLink = () => { + navigator.clipboard + .writeText(inviteLink) + .then(() => { + toast.success('Link copied to clipboard') + return null + }) + .catch(() => { + toast.error('Error copying link') + }) + } + + const getShareUrl = ( + platform: keyof SharePlatformConfigs, + platformConfigs: SharePlatformConfigs, + ) => { + // eslint-disable-next-line security/detect-object-injection + const config = platformConfigs[platform] + return config.shareUrl + .replace('{url}', encodeURIComponent(shareUrl)) + .replace('{title}', encodeURIComponent(shareTitle)) + } + + return { + shareUrl, + shareTitle, + inviteLink, + copyLink, + getShareUrl, + } +} + +export const useFormatDistance = () => { + const formatDistance = (dist: number | null): string | null => { + if (!dist) return null + return dist < 10 ? `${dist.toFixed(1)} km` : `${Math.round(dist)} km` + } + + return { formatDistance } +} diff --git a/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/index.tsx b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/index.tsx new file mode 100644 index 00000000..1befab1c --- /dev/null +++ b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/index.tsx @@ -0,0 +1,91 @@ +import { useState } from 'react' + +import { useMyProfile } from '#components/Map/hooks/useMyProfile' + +import { ActionButtons } from './ActionButtons' +import { ConnectionStatus } from './ConnectionStatus' +import { DeleteModal } from './DeleteModal' +import { EditMenu } from './EditMenu' +import { ItemAvatar } from './ItemAvatar' +import { ItemTitle } from './ItemTitle' +import { QRModal } from './QRModal' + +import type { HeaderViewProps } from './types' + +export function HeaderView({ + item, + api, + editCallback, + deleteCallback, + setPositionCallback, + loading, + hideMenu = false, + big = false, + truncateSubname = true, + showAddress = true, +}: HeaderViewProps) { + const [modalOpen, setModalOpen] = useState(false) + const [qrModalOpen, setQrModalOpen] = useState(false) + const myProfile = useMyProfile() + + if (!item) return null + + const hasAvatar = !!(item.image ?? item.image_external) + const isMyProfile = myProfile.myProfile?.id === item.id + const showQrButton = big && isMyProfile && (item.layer?.itemType.show_qr_button ?? true) + const subtitleMode = item.layer?.itemType.subtitle_mode ?? (showAddress ? 'address' : 'none') + + return ( + <> +
+
+
+ setQrModalOpen(true)} + /> + +
+
+ setModalOpen(true)} + /> +
+ + {big && ( +
+
+
+ + +
+
+ )} + + setModalOpen(false)} + onConfirm={deleteCallback ?? (() => undefined)} + /> + + setQrModalOpen(false)} /> + + ) +} diff --git a/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/types.ts b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/types.ts new file mode 100644 index 00000000..6f95df4a --- /dev/null +++ b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/HeaderView/types.ts @@ -0,0 +1,31 @@ +import type { Item } from '#types/Item' +import type { ItemsApi } from '#types/ItemsApi' + +export interface HeaderViewProps { + item?: Item + api?: ItemsApi + editCallback?: (e: React.MouseEvent) => void + deleteCallback?: (e: React.MouseEvent) => void + setPositionCallback?: () => void + loading?: boolean + hideMenu?: boolean + big?: boolean + truncateSubname?: boolean + showAddress?: boolean +} + +export interface PlatformConfig { + shareUrl: string + icon: JSX.Element + label: string + bgColor: string +} + +export interface SharePlatformConfigs { + facebook: PlatformConfig + twitter: PlatformConfig + linkedin: PlatformConfig + whatsapp: PlatformConfig + telegram: PlatformConfig + xing: PlatformConfig +} diff --git a/lib/src/Components/Map/Subcomponents/ItemPopupComponents/StartEndView.tsx b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/StartEndView.tsx index 0855d2c2..7402053d 100644 --- a/lib/src/Components/Map/Subcomponents/ItemPopupComponents/StartEndView.tsx +++ b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/StartEndView.tsx @@ -8,25 +8,19 @@ import type { Item } from '#types/Item' */ export const StartEndView = ({ item }: { item?: Item }) => { return ( -
-
- -