@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.
- package/LICENSE +14 -0
- package/README.md +73 -0
- package/components/BSNavbar.tsx +264 -0
- package/components/DataTb/DataTb.astro +33 -0
- package/components/DataTb/DataTb.tsx +340 -0
- package/components/DataTb/DataTbMdx.tsx +75 -0
- package/components/DataTb/README.md +275 -0
- package/components/DataTb/index.ts +36 -0
- package/components/DataTb/sources/ApiSource.tsx +89 -0
- package/components/DataTb/sources/CsvSource.tsx +69 -0
- package/components/DataTb/sources/DirectusSource.tsx +98 -0
- package/components/DataTb/sources/JsonSource.tsx +65 -0
- package/components/DataTb/sources/index.ts +17 -0
- package/components/DataTb/types.ts +215 -0
- package/components/DataTb/utils.ts +186 -0
- package/components/Gallery/Gallery.astro +65 -0
- package/components/Gallery/Gallery.tsx +136 -0
- package/components/Gallery/README.md +118 -0
- package/components/Gallery/__tests__/galleryUtils.test.ts +178 -0
- package/components/Gallery/galleryUtils.ts +186 -0
- package/components/Gallery/index.ts +7 -0
- package/components/Gallery/react-photoswipe-gallery.d.ts +26 -0
- package/components/Gallery/types.ts +46 -0
- package/components/Gallery/virtual-gallery.d.ts +15 -0
- package/components/Map/LayerControl.tsx +368 -0
- package/components/Map/Map.tsx +735 -0
- package/components/Map/MapMdx.tsx +37 -0
- package/components/Map/RasterLayerLibre.tsx +33 -0
- package/components/Map/Search/SearchOperators.ts +31 -0
- package/components/Map/Search/SearchUI.tsx +75 -0
- package/components/Map/Search/SearchUIAdvanced.tsx +289 -0
- package/components/Map/Search/SearchUISimple.tsx +115 -0
- package/components/Map/Search/index.ts +7 -0
- package/components/Map/defaultBasemaps.ts +88 -0
- package/components/Map/index.ts +6 -0
- package/components/Map/types.ts +179 -0
- package/components/Map/utils.ts +367 -0
- package/components/README.md +62 -0
- package/components/Record/Field.tsx +35 -0
- package/components/Record/Image.tsx +114 -0
- package/components/Record/RecordFetcher.tsx +127 -0
- package/components/Record/context.tsx +14 -0
- package/components/Record/index.ts +11 -0
- package/components/Record/useRecordFetcher.ts +104 -0
- package/components/Record/utils.ts +13 -0
- package/components/SEO/SEO.astro +132 -0
- package/components/TableOfContents/TableOfContents.astro +86 -0
- package/components/ZoteroGeoViewer/README.md +195 -0
- package/components/ZoteroGeoViewer/ZoteroGeoViewer.astro +26 -0
- package/components/ZoteroGeoViewer/ZoteroGeoViewer.tsx +483 -0
- package/components/ZoteroGeoViewer/ZoteroRecordsPreview.tsx +185 -0
- package/components/ZoteroGeoViewer/index.ts +16 -0
- package/components/ZoteroGeoViewer/types.ts +123 -0
- package/index.ts +26 -0
- package/integrations/__tests__/galleryIntegration.test.ts +70 -0
- package/integrations/contentAssetsIntegration.ts +132 -0
- package/integrations/directusLoader.ts +98 -0
- package/integrations/galleryIntegration.ts +78 -0
- package/package.json +56 -0
- package/scms.ts +95 -0
- package/utils/content.ts +88 -0
- package/utils/data-fetcher.ts +128 -0
- package/utils/directus-config.ts +160 -0
- 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,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 };
|