@lad-sapienza/scms-core 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +73 -0
  3. package/components/BSNavbar.tsx +264 -0
  4. package/components/DataTb/DataTb.astro +33 -0
  5. package/components/DataTb/DataTb.tsx +340 -0
  6. package/components/DataTb/DataTbMdx.tsx +75 -0
  7. package/components/DataTb/README.md +275 -0
  8. package/components/DataTb/index.ts +36 -0
  9. package/components/DataTb/sources/ApiSource.tsx +89 -0
  10. package/components/DataTb/sources/CsvSource.tsx +69 -0
  11. package/components/DataTb/sources/DirectusSource.tsx +98 -0
  12. package/components/DataTb/sources/JsonSource.tsx +65 -0
  13. package/components/DataTb/sources/index.ts +17 -0
  14. package/components/DataTb/types.ts +215 -0
  15. package/components/DataTb/utils.ts +186 -0
  16. package/components/Gallery/Gallery.astro +65 -0
  17. package/components/Gallery/Gallery.tsx +136 -0
  18. package/components/Gallery/README.md +118 -0
  19. package/components/Gallery/__tests__/galleryUtils.test.ts +178 -0
  20. package/components/Gallery/galleryUtils.ts +186 -0
  21. package/components/Gallery/index.ts +7 -0
  22. package/components/Gallery/react-photoswipe-gallery.d.ts +26 -0
  23. package/components/Gallery/types.ts +46 -0
  24. package/components/Gallery/virtual-gallery.d.ts +15 -0
  25. package/components/Map/LayerControl.tsx +368 -0
  26. package/components/Map/Map.tsx +735 -0
  27. package/components/Map/MapMdx.tsx +37 -0
  28. package/components/Map/RasterLayerLibre.tsx +33 -0
  29. package/components/Map/Search/SearchOperators.ts +31 -0
  30. package/components/Map/Search/SearchUI.tsx +75 -0
  31. package/components/Map/Search/SearchUIAdvanced.tsx +289 -0
  32. package/components/Map/Search/SearchUISimple.tsx +115 -0
  33. package/components/Map/Search/index.ts +7 -0
  34. package/components/Map/defaultBasemaps.ts +88 -0
  35. package/components/Map/index.ts +6 -0
  36. package/components/Map/types.ts +179 -0
  37. package/components/Map/utils.ts +367 -0
  38. package/components/README.md +62 -0
  39. package/components/Record/Field.tsx +35 -0
  40. package/components/Record/Image.tsx +114 -0
  41. package/components/Record/RecordFetcher.tsx +127 -0
  42. package/components/Record/context.tsx +14 -0
  43. package/components/Record/index.ts +11 -0
  44. package/components/Record/useRecordFetcher.ts +104 -0
  45. package/components/Record/utils.ts +13 -0
  46. package/components/SEO/SEO.astro +132 -0
  47. package/components/TableOfContents/TableOfContents.astro +86 -0
  48. package/components/ZoteroGeoViewer/README.md +195 -0
  49. package/components/ZoteroGeoViewer/ZoteroGeoViewer.astro +26 -0
  50. package/components/ZoteroGeoViewer/ZoteroGeoViewer.tsx +483 -0
  51. package/components/ZoteroGeoViewer/ZoteroRecordsPreview.tsx +185 -0
  52. package/components/ZoteroGeoViewer/index.ts +16 -0
  53. package/components/ZoteroGeoViewer/types.ts +123 -0
  54. package/index.ts +26 -0
  55. package/integrations/__tests__/galleryIntegration.test.ts +70 -0
  56. package/integrations/contentAssetsIntegration.ts +132 -0
  57. package/integrations/directusLoader.ts +98 -0
  58. package/integrations/galleryIntegration.ts +78 -0
  59. package/package.json +56 -0
  60. package/scms.ts +95 -0
  61. package/utils/content.ts +88 -0
  62. package/utils/data-fetcher.ts +128 -0
  63. package/utils/directus-config.ts +160 -0
  64. package/utils/record-fetcher.ts +132 -0
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Utility functions for Gallery component
3
+ */
4
+ import { z } from 'zod';
5
+ import * as manifest from 'virtual:scms/galleries';
6
+ import type { GalleryImage } from './types';
7
+
8
+ /**
9
+ * Extract filename without extension from filepath
10
+ */
11
+ export function getFileName(filepath: string): string {
12
+ const parts = filepath.split('/');
13
+ const filename = parts[parts.length - 1];
14
+ return filename.replace(/\.[^/.]+$/, '');
15
+ }
16
+
17
+ /**
18
+ * Format filename into readable caption
19
+ * e.g., "my-image-file" -> "My Image File"
20
+ */
21
+ export function formatFilename(filename: string): string {
22
+ return filename
23
+ .split('-')
24
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
25
+ .join(' ');
26
+ }
27
+
28
+ /**
29
+ * Extract base filename without resolution suffix for fuzzy matching
30
+ * Examples:
31
+ * - "children-593313_1280.jpg" → "children-593313"
32
+ * - "coffee-2306471_1920.jpg" → "coffee-2306471"
33
+ */
34
+ function getBaseFilename(filename: string): string {
35
+ // Remove extension first
36
+ const withoutExt = filename.replace(/\.[^.]+$/, '');
37
+ // Remove resolution suffix (e.g., _1280, _1920, _640x480)
38
+ return withoutExt.replace(/[_-]\d+(?:x\d+)?$/, '');
39
+ }
40
+
41
+ /**
42
+ * Build a caption lookup with multiple keys for fuzzy matching (exact
43
+ * filename, filename without extension, filename without a resolution
44
+ * suffix), keyed by the *source* filename as written in captions.json.
45
+ */
46
+ function buildCaptionLookup(captions: Record<string, string>): Map<string, string> {
47
+ const lookup = new Map<string, string>();
48
+ Object.entries(captions).forEach(([key, value]) => {
49
+ lookup.set(getBaseFilename(key), value);
50
+ lookup.set(key, value);
51
+ lookup.set(key.replace(/\.[^.]+$/, ''), value);
52
+ });
53
+ return lookup;
54
+ }
55
+
56
+ /**
57
+ * Look up a caption for a source filename (exact → no ext → fuzzy base match).
58
+ *
59
+ * Must be matched against the *original* source filename (from the
60
+ * import.meta.glob key), not the Astro-processed `src` — Astro renames
61
+ * output files to `name.HASH.ext`, which would never match a captions.json
62
+ * key written against the original filename.
63
+ */
64
+ function lookupCaption(sourceFilename: string, lookup: Map<string, string>): string | undefined {
65
+ const noExt = sourceFilename.replace(/\.[^.]+$/, '');
66
+ const base = getBaseFilename(sourceFilename);
67
+ return lookup.get(sourceFilename) ?? lookup.get(noExt) ?? lookup.get(base);
68
+ }
69
+
70
+ /**
71
+ * Sort images by filename
72
+ */
73
+ export function sortImages(images: GalleryImage[], reverse: boolean = false): GalleryImage[] {
74
+ const sorted = [...images].sort((a, b) => {
75
+ const nameA = getFileName(a.src);
76
+ const nameB = getFileName(b.src);
77
+ return nameB.localeCompare(nameA); // DESC by default
78
+ });
79
+
80
+ return reverse ? sorted.reverse() : sorted;
81
+ }
82
+
83
+ const captionsSchema = z.record(z.string(), z.string());
84
+
85
+ /**
86
+ * Validate a raw captions.json payload. Logs a warning and returns undefined
87
+ * (falling back to filename-based captions) rather than throwing, so a
88
+ * malformed captions.json degrades gracefully instead of breaking the build.
89
+ */
90
+ function parseCaptions(raw: unknown, source: string): Record<string, string> | undefined {
91
+ const result = captionsSchema.safeParse(raw);
92
+ if (!result.success) {
93
+ console.warn(`[Gallery] Invalid captions.json at "${source}" — ignoring custom captions.`, result.error.message);
94
+ return undefined;
95
+ }
96
+ return result.data;
97
+ }
98
+
99
+ function unwrapModule<T>(mod: { default: T } | T): T {
100
+ return (mod as { default: T }).default ?? (mod as T);
101
+ }
102
+
103
+ function rawFileName(filepath: string): string {
104
+ return filepath.split('/').pop() ?? filepath;
105
+ }
106
+
107
+ function moduleToGalleryImage(
108
+ filepath: string,
109
+ mod: { default: { src: string; width: number; height: number } },
110
+ captionLookup?: Map<string, string>
111
+ ): GalleryImage {
112
+ const img = unwrapModule(mod);
113
+ const custom = captionLookup ? lookupCaption(rawFileName(filepath), captionLookup) : undefined;
114
+ const label = custom ?? formatFilename(getFileName(filepath));
115
+ return {
116
+ src: img.src,
117
+ thumb: img.src,
118
+ width: img.width,
119
+ height: img.height,
120
+ alt: label,
121
+ caption: label,
122
+ };
123
+ }
124
+
125
+ const normalizePath = (pathname: string): string => pathname.replace(/\/$/, '');
126
+
127
+ const COLOCATED_GALLERY_PATTERN = /\/(pages|content)(.+)\/gallery\//;
128
+ const SHARED_GALLERY_PATTERN = /\/galleries\/([^/]+)\//;
129
+
130
+ export function getColocatedCaptions(pathname: string): Record<string, string> | undefined {
131
+ const normalized = normalizePath(pathname);
132
+ const allCaptions = { ...manifest.pagesCaptions, ...manifest.contentCaptions };
133
+ const entry = Object.entries(allCaptions).find(([filepath]) => {
134
+ const match = filepath.match(COLOCATED_GALLERY_PATTERN);
135
+ return !!match && normalized === match[2];
136
+ });
137
+ if (!entry) return undefined;
138
+ const [filepath, mod] = entry;
139
+ return parseCaptions(unwrapModule(mod), filepath);
140
+ }
141
+
142
+ export function getSharedCaptions(name: string): Record<string, string> | undefined {
143
+ const entry = Object.entries(manifest.sharedCaptions).find(([filepath]) => {
144
+ const match = filepath.match(SHARED_GALLERY_PATTERN);
145
+ return !!match && match[1] === name;
146
+ });
147
+ if (!entry) return undefined;
148
+ const [filepath, mod] = entry;
149
+ return parseCaptions(unwrapModule(mod), filepath);
150
+ }
151
+
152
+ /**
153
+ * Images from a `gallery/` folder colocated with the current page/content
154
+ * file, matched by comparing the current URL path against the folder
155
+ * structure under usr/pages/ or usr/content/. Applies captions.json from
156
+ * the same folder, if present.
157
+ *
158
+ * Known limitation (unchanged from before this redesign): if a content
159
+ * collection entry overrides its slug, or the site has a non-root `base`,
160
+ * the URL path diverges from the on-disk folder path and matching silently
161
+ * finds nothing.
162
+ */
163
+ export function getColocatedGalleryImages(pathname: string): GalleryImage[] {
164
+ const normalized = normalizePath(pathname);
165
+ const allImages = { ...manifest.pagesImages, ...manifest.contentImages };
166
+ const captions = getColocatedCaptions(pathname);
167
+ const captionLookup = captions ? buildCaptionLookup(captions) : undefined;
168
+ return Object.entries(allImages)
169
+ .filter(([filepath]) => {
170
+ const match = filepath.match(COLOCATED_GALLERY_PATTERN);
171
+ return !!match && normalized === match[2];
172
+ })
173
+ .map(([filepath, mod]) => moduleToGalleryImage(filepath, mod, captionLookup));
174
+ }
175
+
176
+ /** Images from a shared gallery at usr/galleries/<name>/. Applies captions.json from the same folder, if present. */
177
+ export function getSharedGalleryImages(name: string): GalleryImage[] {
178
+ const captions = getSharedCaptions(name);
179
+ const captionLookup = captions ? buildCaptionLookup(captions) : undefined;
180
+ return Object.entries(manifest.sharedImages)
181
+ .filter(([filepath]) => {
182
+ const match = filepath.match(SHARED_GALLERY_PATTERN);
183
+ return !!match && match[1] === name;
184
+ })
185
+ .map(([filepath, mod]) => moduleToGalleryImage(filepath, mod, captionLookup));
186
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Gallery Component Exports
3
+ */
4
+
5
+ export { default as Gallery } from './Gallery.astro';
6
+ export type { GalleryProps, GalleryImage } from './types';
7
+ export { sortImages, getFileName, formatFilename } from './galleryUtils';
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Type declarations for react-photoswipe-gallery
3
+ * Since @types/react-photoswipe-gallery doesn't exist, we provide minimal types
4
+ */
5
+
6
+ declare module 'react-photoswipe-gallery' {
7
+ import { ReactNode } from 'react';
8
+
9
+ export interface GalleryProps {
10
+ children?: ReactNode;
11
+ options?: any;
12
+ uiElements?: any[];
13
+ }
14
+
15
+ export interface ItemProps {
16
+ original: string;
17
+ thumbnail?: string;
18
+ width: number | string;
19
+ height: number | string;
20
+ caption?: string;
21
+ children?: (props: { ref: any; open: () => void }) => ReactNode;
22
+ }
23
+
24
+ export const Gallery: React.FC<GalleryProps>;
25
+ export const Item: React.FC<ItemProps>;
26
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Type definitions for Gallery component
3
+ */
4
+
5
+ export interface GalleryImage {
6
+ src: string;
7
+ thumb: string;
8
+ width: number;
9
+ height: number;
10
+ alt: string;
11
+ caption?: string;
12
+ }
13
+
14
+ export interface GalleryProps {
15
+ /**
16
+ * Name of a shared gallery under usr/galleries/<name>/
17
+ * If not provided (and `images` isn't either), auto-loads from the
18
+ * `gallery/` folder colocated with the current page/content file.
19
+ */
20
+ name?: string;
21
+
22
+ /**
23
+ * Array of image objects to display
24
+ * Explicit escape hatch — takes precedence over `name` and auto-loading
25
+ */
26
+ images?: GalleryImage[];
27
+
28
+ /**
29
+ * Reverse the default sorting order (DESC by name) of images
30
+ * When true, images will be sorted in ascending order
31
+ */
32
+ reverseSorting?: boolean;
33
+
34
+ /**
35
+ * Grid configuration
36
+ */
37
+ columns?: {
38
+ min?: number;
39
+ max?: number;
40
+ };
41
+
42
+ /**
43
+ * Additional CSS classes
44
+ */
45
+ className?: string;
46
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Type declarations for the virtual module generated by
3
+ * core/integrations/galleryIntegration.ts. See that file for why this exists.
4
+ */
5
+ declare module 'virtual:scms/galleries' {
6
+ type ImageModule = { default: { src: string; width: number; height: number; format?: string } };
7
+ type CaptionsModule = { default: Record<string, string> } | Record<string, string>;
8
+
9
+ export const pagesImages: Record<string, ImageModule>;
10
+ export const contentImages: Record<string, ImageModule>;
11
+ export const sharedImages: Record<string, ImageModule>;
12
+ export const pagesCaptions: Record<string, CaptionsModule>;
13
+ export const contentCaptions: Record<string, CaptionsModule>;
14
+ export const sharedCaptions: Record<string, CaptionsModule>;
15
+ }
@@ -0,0 +1,368 @@
1
+ import React, { useState, useEffect, useRef, useLayoutEffect } from 'react';
2
+ import { createRoot, type Root } from 'react-dom/client';
3
+ import { createPortal } from 'react-dom';
4
+ import { Layers, Search, X } from 'lucide-react';
5
+ import type { IControl } from 'maplibre-gl';
6
+ import type { BaseLayerConfig, VectorLayerConfig, ControlPosition, SearchInFields, SearchQuery } from './types';
7
+ import { SearchUI } from './Search';
8
+
9
+ export interface LayerControlProps {
10
+ baseLayers?: BaseLayerConfig[];
11
+ activeBaseLayer?: number;
12
+ onBaseLayerChange?: (index: number) => void;
13
+ vectorLayers?: Array<VectorLayerConfig & { id: string }>;
14
+ vectorLayerVisibility?: Record<string, boolean>;
15
+ onVectorLayerToggle?: (layerId: string) => void;
16
+ onLayerSearch?: (layerId: string, query: SearchQuery) => void;
17
+ layerSearchQueries?: Record<string, SearchQuery>;
18
+ position?: ControlPosition;
19
+ }
20
+
21
+ // Internal React component for the control UI
22
+ function LayerControlUI({
23
+ baseLayers = [],
24
+ activeBaseLayer = 0,
25
+ onBaseLayerChange,
26
+ vectorLayers = [],
27
+ vectorLayerVisibility = {},
28
+ onVectorLayerToggle,
29
+ onLayerSearch,
30
+ layerSearchQueries = {},
31
+ }: Omit<LayerControlProps, 'position'>) {
32
+ const [isExpanded, setIsExpanded] = useState(false);
33
+ const [searchModal, setSearchModal] = useState<{isOpen: boolean, layerId: string, layerName: string, fieldList: SearchInFields, currentQuery?: SearchQuery} | null>(null);
34
+ const radioGroupId = React.useId();
35
+ const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
36
+
37
+ const hasContent = baseLayers.length > 0 || vectorLayers.length > 0;
38
+ if (!hasContent) return null;
39
+
40
+ const handleMouseEnter = () => {
41
+ if (timeoutRef.current) {
42
+ clearTimeout(timeoutRef.current);
43
+ timeoutRef.current = null;
44
+ }
45
+ setIsExpanded(true);
46
+ };
47
+
48
+ const handleMouseLeave = () => {
49
+ timeoutRef.current = setTimeout(() => {
50
+ setIsExpanded(false);
51
+ }, 300);
52
+ };
53
+
54
+ const handleSearchClick = (layerId: string, layerName: string, searchInFields: SearchInFields) => {
55
+ // Prevent layout shift by preserving scrollbar space
56
+ const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
57
+ document.body.style.overflow = 'hidden';
58
+ document.body.style.paddingRight = `${scrollbarWidth}px`;
59
+
60
+ setSearchModal({
61
+ isOpen: true,
62
+ layerId,
63
+ layerName,
64
+ fieldList: searchInFields,
65
+ currentQuery: layerSearchQueries[layerId]
66
+ });
67
+ };
68
+
69
+ const handleSearchClose = () => {
70
+ setSearchModal(null);
71
+
72
+ // Defer body style restoration until after React re-render
73
+ setTimeout(() => {
74
+ document.body.style.overflow = '';
75
+ document.body.style.paddingRight = '';
76
+ }, 0);
77
+ };
78
+
79
+ const handleSearch = (query: SearchQuery) => {
80
+ if (searchModal && onLayerSearch) {
81
+ onLayerSearch(searchModal.layerId, query);
82
+ }
83
+ handleSearchClose();
84
+ };
85
+
86
+ useEffect(() => {
87
+ return () => {
88
+ if (timeoutRef.current) {
89
+ clearTimeout(timeoutRef.current);
90
+ }
91
+ // Cleanup: restore body styles if component unmounts with modal open
92
+ if (searchModal?.isOpen) {
93
+ document.body.style.overflow = '';
94
+ document.body.style.paddingRight = '';
95
+ }
96
+ };
97
+ }, [searchModal?.isOpen]);
98
+
99
+ // Prevent re-render position shifts by using useLayoutEffect for immediate cleanup
100
+ useLayoutEffect(() => {
101
+ if (!searchModal?.isOpen) {
102
+ // Ensure body styles are restored immediately after modal closes
103
+ const timeoutId = setTimeout(() => {
104
+ if (document.body.style.overflow === 'hidden') {
105
+ document.body.style.overflow = '';
106
+ document.body.style.paddingRight = '';
107
+ }
108
+ }, 50); // Small delay to let React finish re-rendering
109
+
110
+ return () => clearTimeout(timeoutId);
111
+ }
112
+ }, [searchModal?.isOpen, layerSearchQueries]);
113
+
114
+ return (
115
+ <div onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} style={{ position: 'relative' }}>
116
+ <div
117
+ style={{
118
+ padding: '8px',
119
+ fontWeight: 600,
120
+ userSelect: 'none',
121
+ display: 'flex',
122
+ alignItems: 'center',
123
+ justifyContent: 'center',
124
+ minHeight: '32px',
125
+ minWidth: '32px',
126
+ background: 'white',
127
+ borderRadius: '4px',
128
+ position: 'relative',
129
+ zIndex: 1
130
+ }}
131
+ >
132
+ {!isExpanded && <Layers size={20} />}
133
+ </div>
134
+
135
+ {isExpanded && (
136
+ <div style={{
137
+ position: 'absolute',
138
+ top: 0,
139
+ right: 0,
140
+ backgroundColor: 'white',
141
+ borderRadius: '4px',
142
+ border: '1px solid rgba(0,0,0,0.1)',
143
+ boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
144
+ padding: '8px 12px 12px 12px',
145
+ minWidth: 180,
146
+ maxWidth: 250,
147
+ zIndex: 1000,
148
+ whiteSpace: 'nowrap'
149
+ }}>
150
+
151
+ {/* Vector Layers Section */}
152
+ {vectorLayers.length > 0 && (
153
+ <div style={{ marginBottom: 10 }}>
154
+ {vectorLayers.map((layer) => (
155
+ <div key={layer.id} style={{ marginBottom: 6, display: 'flex', alignItems: 'center' }}>
156
+ <input
157
+ type="checkbox"
158
+ id={`vector-layer-${layer.id}`}
159
+ checked={vectorLayerVisibility[layer.id] !== false}
160
+ onChange={() => onVectorLayerToggle?.(layer.id)}
161
+ style={{ marginRight: 8, cursor: 'pointer' }}
162
+ />
163
+ <label
164
+ htmlFor={`vector-layer-${layer.id}`}
165
+ style={{ cursor: 'pointer', fontSize: '12px', userSelect: 'none', flex: 1 }}
166
+ >
167
+ {layer.name}
168
+ </label>
169
+ {layer.searchInFields && Object.keys(layer.searchInFields).length > 0 && (
170
+ <button
171
+ type="button"
172
+ onClick={() => handleSearchClick(layer.id, layer.name, layer.searchInFields!)}
173
+ className={`btn btn-sm p-1 lh-1 border-0 ${
174
+ layerSearchQueries[layer.id]?.filters?.length ? 'btn-primary' : 'btn-light'
175
+ }`}
176
+ title={`Search ${layer.name}${layerSearchQueries[layer.id]?.filters?.length ? ' (Active)' : ''}`}
177
+ >
178
+ <Search size={14} />
179
+ </button>
180
+ )}
181
+ </div>
182
+ ))}
183
+ {baseLayers.length > 0 && <hr style={{ margin: '8px 0', borderColor: '#eee' }} />}
184
+ </div>
185
+ )}
186
+
187
+ {/* Base Layers Section */}
188
+ {baseLayers.length > 0 && (
189
+ <div>
190
+ <div style={{ fontSize: '11px', color: '#666', fontWeight: 600, marginBottom: 4, textTransform: 'uppercase' }}>
191
+ Base Maps
192
+ </div>
193
+ {baseLayers.map((layer, index) => {
194
+ const isChecked = index === activeBaseLayer;
195
+ return (
196
+ <div key={`${layer.name}-${index}`} style={{ marginBottom: 4, display: 'flex', alignItems: 'center' }}>
197
+ <input
198
+ type="radio"
199
+ id={`base-layer-${radioGroupId}-${index}`}
200
+ name={`base-layer-select-${radioGroupId}`}
201
+ value={index}
202
+ checked={isChecked}
203
+ onChange={() => onBaseLayerChange?.(index)}
204
+ style={{
205
+ marginRight: 8,
206
+ cursor: 'pointer',
207
+ width: '16px',
208
+ height: '16px',
209
+ accentColor: '#3b82f6'
210
+ }}
211
+ />
212
+ <label
213
+ htmlFor={`base-layer-${radioGroupId}-${index}`}
214
+ style={{ cursor: 'pointer', fontSize: '12px', userSelect: 'none' }}
215
+ >
216
+ {layer.name}
217
+ </label>
218
+ </div>
219
+ );
220
+ })}
221
+ </div>
222
+ )}
223
+ </div>
224
+ )}
225
+
226
+ {/* Search Modal */}
227
+ {searchModal?.isOpen && createPortal(
228
+ <div
229
+ style={{
230
+ position: 'fixed',
231
+ top: 0,
232
+ left: 0,
233
+ right: 0,
234
+ bottom: 0,
235
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
236
+ display: 'flex',
237
+ alignItems: 'center',
238
+ justifyContent: 'center',
239
+ zIndex: 10000,
240
+ overflowY: 'auto'
241
+ }}
242
+ onClick={handleSearchClose}
243
+ >
244
+ <div
245
+ className="bg-white rounded shadow"
246
+ style={{ maxHeight: 'calc(100vh - 4rem)', width: '500px', maxWidth: '90vw', margin: '2rem 1rem' }}
247
+ onClick={(e) => e.stopPropagation()}
248
+ >
249
+ <div className="d-flex justify-content-between align-items-center p-3 border-bottom">
250
+ <h3 className="h5 fw-semibold mb-0 d-flex align-items-center gap-2 flex-grow-1 pe-2">
251
+ <Search size={18} />
252
+ Search {searchModal.layerName}
253
+ </h3>
254
+ <button
255
+ onClick={handleSearchClose}
256
+ className="btn btn-sm btn-light flex-shrink-0"
257
+ aria-label="Close modal"
258
+ >
259
+ <X size={18} />
260
+ </button>
261
+ </div>
262
+
263
+ <div className="p-3" style={{ overflowY: 'auto' }}>
264
+ <SearchUI
265
+ fieldList={searchModal.fieldList}
266
+ onSearch={handleSearch}
267
+ currentQuery={searchModal.currentQuery}
268
+ />
269
+ </div>
270
+ </div>
271
+ </div>,
272
+ document.body
273
+ )}
274
+ </div>
275
+ );
276
+ }
277
+
278
+ // MapLibre IControl wrapper
279
+ export class LayerControlClass implements IControl {
280
+ private container: HTMLDivElement | undefined;
281
+ private root: Root | undefined;
282
+ private props: Omit<LayerControlProps, 'position'>;
283
+ private isAddedToMap: boolean = false;
284
+
285
+ constructor(props: Omit<LayerControlProps, 'position'>) {
286
+ this.props = props;
287
+ }
288
+
289
+ onAdd(): HTMLElement {
290
+ this.container = document.createElement('div');
291
+ this.container.className = 'maplibregl-ctrl maplibregl-ctrl-group';
292
+
293
+ this.root = createRoot(this.container);
294
+ this.root.render(<LayerControlUI {...this.props} />);
295
+ this.isAddedToMap = true;
296
+
297
+ return this.container;
298
+ }
299
+
300
+ onRemove(): void {
301
+ if (this.root) {
302
+ this.root.unmount();
303
+ this.root = undefined;
304
+ }
305
+ if (this.container) {
306
+ this.container.remove();
307
+ }
308
+ this.container = undefined;
309
+ this.isAddedToMap = false;
310
+ }
311
+
312
+ updateProps(newProps: Omit<LayerControlProps, 'position'>): void {
313
+ // Only update if the control is actually added to the map and props have changed
314
+ if (!this.isAddedToMap || !this.root) {
315
+ this.props = newProps;
316
+ return;
317
+ }
318
+
319
+ // Compare props to avoid unnecessary re-renders
320
+ const hasChanged =
321
+ JSON.stringify(this.props.layerSearchQueries) !== JSON.stringify(newProps.layerSearchQueries) ||
322
+ JSON.stringify(this.props.vectorLayerVisibility) !== JSON.stringify(newProps.vectorLayerVisibility) ||
323
+ this.props.activeBaseLayer !== newProps.activeBaseLayer;
324
+
325
+ if (hasChanged) {
326
+ this.props = newProps;
327
+ // Use requestAnimationFrame to ensure the update happens after current execution
328
+ requestAnimationFrame(() => {
329
+ if (this.root && this.isAddedToMap) {
330
+ this.root.render(<LayerControlUI {...this.props} />);
331
+ }
332
+ });
333
+ }
334
+ }
335
+ }
336
+
337
+ // React component wrapper for use in @vis.gl/react-maplibre
338
+ export function LayerControl(props: LayerControlProps) {
339
+ const controlRef = useRef<LayerControlClass | null>(null);
340
+ const previousPropsRef = useRef<LayerControlProps | undefined>(undefined);
341
+
342
+ // Only update if props have actually changed
343
+ useEffect(() => {
344
+ if (controlRef.current && previousPropsRef.current) {
345
+ const { position, ...restProps } = props;
346
+ const { position: prevPosition, ...prevRestProps } = previousPropsRef.current;
347
+
348
+ // Only call updateProps if relevant props changed
349
+ if (JSON.stringify(restProps) !== JSON.stringify(prevRestProps)) {
350
+ controlRef.current.updateProps(restProps);
351
+ }
352
+ }
353
+ previousPropsRef.current = props;
354
+ }, [props.layerSearchQueries, props.vectorLayerVisibility, props.activeBaseLayer]);
355
+
356
+ // Create control instance only once
357
+ if (!controlRef.current) {
358
+ const { position, ...restProps } = props;
359
+ controlRef.current = new LayerControlClass(restProps);
360
+ }
361
+
362
+ // Return a placeholder that react-maplibre can recognize
363
+ // The actual control is added via map.addControl in Map.tsx
364
+ return null;
365
+ }
366
+
367
+ // Export the control class for direct use
368
+ export { LayerControlClass as LayerControlIControl };