@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,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MDX-safe wrapper for Map component
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useMemo } from 'react';
|
|
6
|
+
import { Map as MapCore } from './Map';
|
|
7
|
+
import type { MapProps } from './types';
|
|
8
|
+
|
|
9
|
+
export function Map(props: MapProps) {
|
|
10
|
+
// Add props validation
|
|
11
|
+
if (!props) {
|
|
12
|
+
console.warn('Map component received undefined props');
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Memoize center prop
|
|
17
|
+
const stableCenter = useMemo(() => props?.center, [props?.center]);
|
|
18
|
+
|
|
19
|
+
// Memoize array/object props to prevent unnecessary re-renders in MDX
|
|
20
|
+
const stableBaseLayers = useMemo(() => props?.baseLayers, [JSON.stringify(props?.baseLayers || [])]);
|
|
21
|
+
const stableVectorLayers = useMemo(() => props?.vectorLayers, [JSON.stringify(props?.vectorLayers || [])]);
|
|
22
|
+
const stableGeojson = useMemo(() => props?.geojson, [JSON.stringify(props?.geojson || null)]);
|
|
23
|
+
const stableCsv = useMemo(() => props?.csv, [JSON.stringify(props?.csv || null)]);
|
|
24
|
+
const stableJson = useMemo(() => props?.json, [JSON.stringify(props?.json || null)]);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<MapCore
|
|
28
|
+
{...props}
|
|
29
|
+
center={stableCenter}
|
|
30
|
+
baseLayers={stableBaseLayers}
|
|
31
|
+
vectorLayers={stableVectorLayers}
|
|
32
|
+
geojson={stableGeojson}
|
|
33
|
+
csv={stableCsv}
|
|
34
|
+
json={stableJson}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
|
|
2
|
+
import { Source, Layer } from '@vis.gl/react-maplibre';
|
|
3
|
+
|
|
4
|
+
interface RasterLayerProps {
|
|
5
|
+
url: string[];
|
|
6
|
+
attribution?: string;
|
|
7
|
+
tileSize?: number;
|
|
8
|
+
id?: string;
|
|
9
|
+
beforeId?: string;
|
|
10
|
+
visible?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function RasterLayerLibre({ url, attribution, tileSize = 256, id = 'base-layer', beforeId, visible = true }: RasterLayerProps) {
|
|
14
|
+
return (
|
|
15
|
+
<Source
|
|
16
|
+
id={`source-${id}`}
|
|
17
|
+
type="raster"
|
|
18
|
+
tiles={url}
|
|
19
|
+
tileSize={tileSize}
|
|
20
|
+
attribution={attribution}
|
|
21
|
+
>
|
|
22
|
+
<Layer
|
|
23
|
+
id={id}
|
|
24
|
+
type="raster"
|
|
25
|
+
paint={{}}
|
|
26
|
+
beforeId={beforeId}
|
|
27
|
+
layout={{
|
|
28
|
+
visibility: visible ? 'visible' : 'none'
|
|
29
|
+
}}
|
|
30
|
+
/>
|
|
31
|
+
</Source>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search Operators Configuration
|
|
3
|
+
* Default search operators with their UI labels
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { SearchOperators } from '../types';
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_OPERATORS: SearchOperators = {
|
|
9
|
+
_eq: "Equals",
|
|
10
|
+
_neq: "Doesn't equal",
|
|
11
|
+
_lt: "Less than",
|
|
12
|
+
_lte: "Less than or equal to",
|
|
13
|
+
_gt: "Greater than",
|
|
14
|
+
_gte: "Greater than or equal to",
|
|
15
|
+
_null: "Is null",
|
|
16
|
+
_nnull: "Isn't null",
|
|
17
|
+
_contains: "Contains",
|
|
18
|
+
_icontains: "Contains (case-insensitive)",
|
|
19
|
+
_ncontains: "Doesn't contain",
|
|
20
|
+
_starts_with: "Starts with",
|
|
21
|
+
_istarts_with: "Starts with (case-insensitive)",
|
|
22
|
+
_nstarts_with: "Doesn't start with",
|
|
23
|
+
_ends_with: "Ends with",
|
|
24
|
+
_iends_with: "Ends with (case-insensitive)",
|
|
25
|
+
_nends_with: "Doesn't end with"
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_CONNECTORS = {
|
|
29
|
+
_and: "AND",
|
|
30
|
+
_or: "OR"
|
|
31
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { SearchInFields, SearchQuery, SearchOperators } from '../types';
|
|
3
|
+
import { SearchUISimple } from './SearchUISimple';
|
|
4
|
+
import { SearchUIAdvanced } from './SearchUIAdvanced';
|
|
5
|
+
|
|
6
|
+
export interface SearchUIProps {
|
|
7
|
+
fieldList: SearchInFields;
|
|
8
|
+
onSearch: (query: SearchQuery) => void;
|
|
9
|
+
operators?: Partial<SearchOperators>;
|
|
10
|
+
isLoading?: boolean;
|
|
11
|
+
limitTo?: 'simple' | 'advanced';
|
|
12
|
+
currentQuery?: SearchQuery;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Main search UI component
|
|
17
|
+
* Handles switching between simple and advanced search modes
|
|
18
|
+
*/
|
|
19
|
+
export function SearchUI({
|
|
20
|
+
fieldList,
|
|
21
|
+
onSearch,
|
|
22
|
+
operators,
|
|
23
|
+
isLoading = false,
|
|
24
|
+
limitTo,
|
|
25
|
+
currentQuery
|
|
26
|
+
}: SearchUIProps) {
|
|
27
|
+
const [isAdvanced, setIsAdvanced] = useState(limitTo === 'advanced');
|
|
28
|
+
|
|
29
|
+
const toggleMode = () => {
|
|
30
|
+
if (limitTo) return; // Don't allow toggling if limited to one mode
|
|
31
|
+
setIsAdvanced(!isAdvanced);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
if (!fieldList || Object.keys(fieldList).length === 0) {
|
|
35
|
+
return (
|
|
36
|
+
<div className="text-danger small">
|
|
37
|
+
No searchable fields configured for this layer.
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Show advanced by default if limitTo is 'advanced', simple if limitTo is 'simple'
|
|
43
|
+
const showAdvanced = limitTo === 'advanced' || (limitTo !== 'simple' && isAdvanced);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="search-ui">
|
|
47
|
+
{isLoading && (
|
|
48
|
+
<div className="text-center mb-3">
|
|
49
|
+
<div className="spinner-border text-primary" role="status">
|
|
50
|
+
<span className="visually-hidden">Loading...</span>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
54
|
+
|
|
55
|
+
{showAdvanced ? (
|
|
56
|
+
<SearchUIAdvanced
|
|
57
|
+
fieldList={fieldList}
|
|
58
|
+
onSearch={onSearch}
|
|
59
|
+
operators={operators}
|
|
60
|
+
isLoading={isLoading}
|
|
61
|
+
onToggleSimple={limitTo ? undefined : toggleMode}
|
|
62
|
+
currentQuery={currentQuery}
|
|
63
|
+
/>
|
|
64
|
+
) : (
|
|
65
|
+
<SearchUISimple
|
|
66
|
+
fieldList={fieldList}
|
|
67
|
+
onSearch={onSearch}
|
|
68
|
+
isLoading={isLoading}
|
|
69
|
+
onToggleAdvanced={limitTo ? undefined : toggleMode}
|
|
70
|
+
currentQuery={currentQuery}
|
|
71
|
+
/>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { useState, type SyntheticEvent } from 'react';
|
|
2
|
+
import { Search, X, Plus, Minus, ArrowLeft } from 'lucide-react';
|
|
3
|
+
import type { SearchInFields, SearchQuery, SearchFilter, SearchOperators } from '../types';
|
|
4
|
+
import { DEFAULT_OPERATORS, DEFAULT_CONNECTORS } from './SearchOperators';
|
|
5
|
+
|
|
6
|
+
export interface SearchUIAdvancedProps {
|
|
7
|
+
fieldList: SearchInFields;
|
|
8
|
+
onSearch: (query: SearchQuery) => void;
|
|
9
|
+
operators?: Partial<SearchOperators>;
|
|
10
|
+
isLoading?: boolean;
|
|
11
|
+
onToggleSimple?: () => void;
|
|
12
|
+
currentQuery?: SearchQuery;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Advanced search UI component
|
|
17
|
+
* Provides multiple field-operator-value triplets with AND/OR logic
|
|
18
|
+
*/
|
|
19
|
+
export function SearchUIAdvanced({
|
|
20
|
+
fieldList,
|
|
21
|
+
onSearch,
|
|
22
|
+
operators = {},
|
|
23
|
+
isLoading = false,
|
|
24
|
+
onToggleSimple,
|
|
25
|
+
currentQuery
|
|
26
|
+
}: SearchUIAdvancedProps) {
|
|
27
|
+
const mergedOperators = { ...DEFAULT_OPERATORS, ...operators };
|
|
28
|
+
|
|
29
|
+
// Initialize state from currentQuery if provided
|
|
30
|
+
const getInitialConnector = (): '_and' | '_or' => {
|
|
31
|
+
return currentQuery?.connector || '_and';
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const getInitialFilters = (): SearchFilter[] => {
|
|
35
|
+
if (currentQuery?.filters?.length) {
|
|
36
|
+
return currentQuery.filters;
|
|
37
|
+
}
|
|
38
|
+
return [{
|
|
39
|
+
field: Object.keys(fieldList)[0] || '',
|
|
40
|
+
operator: Object.keys(mergedOperators)[0] || '_eq',
|
|
41
|
+
value: ''
|
|
42
|
+
}];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const [connector, setConnector] = useState<'_and' | '_or'>(getInitialConnector());
|
|
46
|
+
const [filters, setFilters] = useState<SearchFilter[]>(getInitialFilters());
|
|
47
|
+
|
|
48
|
+
// Get the first field's default value if it has predefined values
|
|
49
|
+
const getDefaultValue = (fieldName: string) => {
|
|
50
|
+
const fieldConfig = fieldList[fieldName];
|
|
51
|
+
if (typeof fieldConfig === 'object' && fieldConfig.values && fieldConfig.values.length > 0) {
|
|
52
|
+
return fieldConfig.values[0];
|
|
53
|
+
}
|
|
54
|
+
return '';
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const addFilter = () => {
|
|
58
|
+
const firstField = Object.keys(fieldList)[0] || '';
|
|
59
|
+
setFilters([...filters, {
|
|
60
|
+
field: firstField,
|
|
61
|
+
operator: Object.keys(mergedOperators)[0] || '_eq',
|
|
62
|
+
value: getDefaultValue(firstField)
|
|
63
|
+
}]);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const removeFilter = (index: number) => {
|
|
67
|
+
if (filters.length > 1) {
|
|
68
|
+
setFilters(filters.filter((_, i) => i !== index));
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const updateFilter = (index: number, field: keyof SearchFilter, value: any) => {
|
|
73
|
+
const newFilters = [...filters];
|
|
74
|
+
newFilters[index] = { ...newFilters[index], [field]: value };
|
|
75
|
+
|
|
76
|
+
// If field changed, update default value
|
|
77
|
+
if (field === 'field') {
|
|
78
|
+
newFilters[index].value = getDefaultValue(value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
setFilters(newFilters);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const handleSubmit = (e: SyntheticEvent) => {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
|
|
87
|
+
// Filter out empty values
|
|
88
|
+
const validFilters = filters.filter(f => f.field && f.value !== '');
|
|
89
|
+
|
|
90
|
+
onSearch({
|
|
91
|
+
connector,
|
|
92
|
+
filters: validFilters
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const handleClear = () => {
|
|
97
|
+
setFilters([{
|
|
98
|
+
field: Object.keys(fieldList)[0] || '',
|
|
99
|
+
operator: Object.keys(mergedOperators)[0] || '_eq',
|
|
100
|
+
value: ''
|
|
101
|
+
}]);
|
|
102
|
+
onSearch({ connector: '_or', filters: [] });
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const hasFilters = () => {
|
|
106
|
+
return filters.some(f => f.field && f.value !== '');
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const getFieldLabel = (fieldName: string): string => {
|
|
110
|
+
const fieldConfig = fieldList[fieldName];
|
|
111
|
+
return typeof fieldConfig === 'string' ? fieldConfig : fieldConfig?.label || fieldName;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const hasFieldValues = (fieldName: string): boolean => {
|
|
115
|
+
const fieldConfig = fieldList[fieldName];
|
|
116
|
+
return typeof fieldConfig === 'object' && Array.isArray(fieldConfig.values) && fieldConfig.values.length > 0;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const getFieldValues = (fieldName: string): string[] => {
|
|
120
|
+
const fieldConfig = fieldList[fieldName];
|
|
121
|
+
if (typeof fieldConfig === 'object' && fieldConfig.values) {
|
|
122
|
+
return fieldConfig.values;
|
|
123
|
+
}
|
|
124
|
+
return [];
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div className="search-ui-advanced">
|
|
129
|
+
<form onSubmit={handleSubmit}>
|
|
130
|
+
{/* Connector selection (only show if more than one filter) */}
|
|
131
|
+
{filters.length > 1 && (
|
|
132
|
+
<div className="mb-3">
|
|
133
|
+
<div className="d-flex gap-3">
|
|
134
|
+
<label className="d-flex align-items-center gap-2">
|
|
135
|
+
<input
|
|
136
|
+
type="radio"
|
|
137
|
+
name="connector"
|
|
138
|
+
value="_and"
|
|
139
|
+
checked={connector === '_and'}
|
|
140
|
+
onChange={(e) => setConnector(e.target.value as '_and' | '_or')}
|
|
141
|
+
className="form-check-input"
|
|
142
|
+
/>
|
|
143
|
+
<span className="small">{DEFAULT_CONNECTORS._and}</span>
|
|
144
|
+
</label>
|
|
145
|
+
|
|
146
|
+
<label className="d-flex align-items-center gap-2">
|
|
147
|
+
<input
|
|
148
|
+
type="radio"
|
|
149
|
+
name="connector"
|
|
150
|
+
value="_or"
|
|
151
|
+
checked={connector === '_or'}
|
|
152
|
+
onChange={(e) => setConnector(e.target.value as '_and' | '_or')}
|
|
153
|
+
className="form-check-input"
|
|
154
|
+
/>
|
|
155
|
+
<span className="small">{DEFAULT_CONNECTORS._or}</span>
|
|
156
|
+
</label>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
|
|
161
|
+
{/* Filter rows */}
|
|
162
|
+
{filters.map((filter, index) => (
|
|
163
|
+
<div key={index} className="row g-2 mb-2 align-items-center">
|
|
164
|
+
{/* Field selection */}
|
|
165
|
+
<div className="col-3">
|
|
166
|
+
<select
|
|
167
|
+
className="form-select form-select-sm"
|
|
168
|
+
value={filter.field}
|
|
169
|
+
onChange={(e) => updateFilter(index, 'field', e.target.value)}
|
|
170
|
+
disabled={isLoading}
|
|
171
|
+
>
|
|
172
|
+
{Object.entries(fieldList).map(([fieldName, _config]) => (
|
|
173
|
+
<option key={fieldName} value={fieldName}>
|
|
174
|
+
{getFieldLabel(fieldName)}
|
|
175
|
+
</option>
|
|
176
|
+
))}
|
|
177
|
+
</select>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{/* Operator selection */}
|
|
181
|
+
<div className="col-3">
|
|
182
|
+
<select
|
|
183
|
+
className="form-select form-select-sm"
|
|
184
|
+
value={filter.operator}
|
|
185
|
+
onChange={(e) => updateFilter(index, 'operator', e.target.value)}
|
|
186
|
+
disabled={isLoading}
|
|
187
|
+
>
|
|
188
|
+
{Object.entries(mergedOperators).map(([op, label]) => (
|
|
189
|
+
<option key={op} value={op}>
|
|
190
|
+
{label}
|
|
191
|
+
</option>
|
|
192
|
+
))}
|
|
193
|
+
</select>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
{/* Value input */}
|
|
197
|
+
<div className="col-4">
|
|
198
|
+
{hasFieldValues(filter.field) ? (
|
|
199
|
+
<select
|
|
200
|
+
className="form-select form-select-sm"
|
|
201
|
+
value={filter.value}
|
|
202
|
+
onChange={(e) => updateFilter(index, 'value', e.target.value)}
|
|
203
|
+
disabled={isLoading}
|
|
204
|
+
>
|
|
205
|
+
{getFieldValues(filter.field).map(value => (
|
|
206
|
+
<option key={value} value={value}>
|
|
207
|
+
{value}
|
|
208
|
+
</option>
|
|
209
|
+
))}
|
|
210
|
+
</select>
|
|
211
|
+
) : (
|
|
212
|
+
<input
|
|
213
|
+
type="text"
|
|
214
|
+
className="form-control form-control-sm"
|
|
215
|
+
value={filter.value}
|
|
216
|
+
onChange={(e) => updateFilter(index, 'value', e.target.value)}
|
|
217
|
+
disabled={isLoading}
|
|
218
|
+
/>
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
{/* Actions */}
|
|
223
|
+
<div className="col-2 d-flex gap-1">
|
|
224
|
+
<button
|
|
225
|
+
type="button"
|
|
226
|
+
className="btn btn-danger btn-sm"
|
|
227
|
+
onClick={() => removeFilter(index)}
|
|
228
|
+
disabled={isLoading || filters.length === 1}
|
|
229
|
+
title={filters.length === 1 ? "Cannot remove the last filter" : "Remove filter"}
|
|
230
|
+
>
|
|
231
|
+
<Minus size={16} />
|
|
232
|
+
</button>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
))}
|
|
236
|
+
|
|
237
|
+
{/* Add Filter Button */}
|
|
238
|
+
<div className="mb-3">
|
|
239
|
+
<button
|
|
240
|
+
type="button"
|
|
241
|
+
className="btn btn-secondary btn-sm"
|
|
242
|
+
onClick={addFilter}
|
|
243
|
+
disabled={isLoading}
|
|
244
|
+
>
|
|
245
|
+
<Plus size={16} />
|
|
246
|
+
Add Filter
|
|
247
|
+
</button>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
{/* Actions */}
|
|
251
|
+
<div className="d-flex gap-2 mt-3">
|
|
252
|
+
<button
|
|
253
|
+
type="submit"
|
|
254
|
+
className="btn btn-primary btn-sm"
|
|
255
|
+
disabled={isLoading}
|
|
256
|
+
>
|
|
257
|
+
{isLoading ? (
|
|
258
|
+
<span className="spinner-border spinner-border-sm" role="status">
|
|
259
|
+
<span className="visually-hidden">Loading...</span>
|
|
260
|
+
</span>
|
|
261
|
+
) : (
|
|
262
|
+
<Search size={16} />
|
|
263
|
+
)}
|
|
264
|
+
Search
|
|
265
|
+
</button>
|
|
266
|
+
<button
|
|
267
|
+
type="button"
|
|
268
|
+
className="btn btn-secondary btn-sm"
|
|
269
|
+
onClick={handleClear}
|
|
270
|
+
disabled={isLoading || !hasFilters()}
|
|
271
|
+
>
|
|
272
|
+
<X size={16} />
|
|
273
|
+
Clear
|
|
274
|
+
</button>
|
|
275
|
+
{onToggleSimple && (
|
|
276
|
+
<button
|
|
277
|
+
type="button"
|
|
278
|
+
className="btn btn-sm btn-link"
|
|
279
|
+
onClick={onToggleSimple}
|
|
280
|
+
>
|
|
281
|
+
<ArrowLeft size={16} />
|
|
282
|
+
Simple Search
|
|
283
|
+
</button>
|
|
284
|
+
)}
|
|
285
|
+
</div>
|
|
286
|
+
</form>
|
|
287
|
+
</div>
|
|
288
|
+
);
|
|
289
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { useState, type SyntheticEvent } from 'react';
|
|
2
|
+
import { Search, X, TextSearch } from 'lucide-react';
|
|
3
|
+
import type { SearchInFields, SearchQuery, SearchFilter } from '../types';
|
|
4
|
+
|
|
5
|
+
export interface SearchUISimpleProps {
|
|
6
|
+
fieldList: SearchInFields;
|
|
7
|
+
onSearch: (query: SearchQuery) => void;
|
|
8
|
+
isLoading?: boolean;
|
|
9
|
+
onToggleAdvanced?: () => void;
|
|
10
|
+
currentQuery?: SearchQuery;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Simple search UI component
|
|
15
|
+
* Provides a single text input that searches across all configured fields using OR logic
|
|
16
|
+
*/
|
|
17
|
+
export function SearchUISimple({
|
|
18
|
+
fieldList,
|
|
19
|
+
onSearch,
|
|
20
|
+
isLoading = false,
|
|
21
|
+
onToggleAdvanced,
|
|
22
|
+
currentQuery
|
|
23
|
+
}: SearchUISimpleProps) {
|
|
24
|
+
// Extract search text from currentQuery if it exists and is a simple search (all filters have same value)
|
|
25
|
+
const getInitialSearchText = () => {
|
|
26
|
+
if (!currentQuery?.filters?.length) return '';
|
|
27
|
+
const firstFilter = currentQuery.filters[0];
|
|
28
|
+
if (currentQuery.filters.every(f => f.value === firstFilter.value && f.operator === '_icontains')) {
|
|
29
|
+
return firstFilter.value;
|
|
30
|
+
}
|
|
31
|
+
return '';
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const [searchText, setSearchText] = useState(getInitialSearchText());
|
|
35
|
+
|
|
36
|
+
const handleSubmit = (e: SyntheticEvent) => {
|
|
37
|
+
e.preventDefault();
|
|
38
|
+
|
|
39
|
+
if (!searchText.trim()) {
|
|
40
|
+
// Clear search
|
|
41
|
+
onSearch({ connector: '_or', filters: [] });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Create filters for all searchable fields using case-insensitive contains
|
|
46
|
+
const filters: SearchFilter[] = Object.keys(fieldList).map(field => ({
|
|
47
|
+
field,
|
|
48
|
+
operator: '_icontains',
|
|
49
|
+
value: searchText.trim()
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
onSearch({
|
|
53
|
+
connector: '_or',
|
|
54
|
+
filters
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const handleClear = () => {
|
|
59
|
+
setSearchText('');
|
|
60
|
+
onSearch({ connector: '_or', filters: [] });
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div className="search-ui-simple">
|
|
65
|
+
<form onSubmit={handleSubmit} className="mb-3">
|
|
66
|
+
<div className="d-flex gap-2">
|
|
67
|
+
<input
|
|
68
|
+
type="text"
|
|
69
|
+
className="form-control form-control-sm"
|
|
70
|
+
placeholder="Search..."
|
|
71
|
+
value={searchText}
|
|
72
|
+
onChange={(e) => setSearchText(e.target.value)}
|
|
73
|
+
disabled={isLoading}
|
|
74
|
+
/>
|
|
75
|
+
<button
|
|
76
|
+
type="submit"
|
|
77
|
+
className="btn btn-primary btn-sm"
|
|
78
|
+
disabled={isLoading}
|
|
79
|
+
>
|
|
80
|
+
{isLoading ? (
|
|
81
|
+
<span className="spinner-border spinner-border-sm" role="status">
|
|
82
|
+
<span className="visually-hidden">Loading...</span>
|
|
83
|
+
</span>
|
|
84
|
+
) : (
|
|
85
|
+
<Search size={16} />
|
|
86
|
+
)}
|
|
87
|
+
Search
|
|
88
|
+
</button>
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
className="btn btn-secondary btn-sm"
|
|
92
|
+
onClick={handleClear}
|
|
93
|
+
disabled={isLoading || !searchText}
|
|
94
|
+
>
|
|
95
|
+
<X size={16} />
|
|
96
|
+
Clear
|
|
97
|
+
</button>
|
|
98
|
+
</div>
|
|
99
|
+
</form>
|
|
100
|
+
|
|
101
|
+
{onToggleAdvanced && (
|
|
102
|
+
<div className="text-center">
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
className="btn btn-sm btn-link"
|
|
106
|
+
onClick={onToggleAdvanced}
|
|
107
|
+
>
|
|
108
|
+
<TextSearch size={16} />
|
|
109
|
+
Advanced Search
|
|
110
|
+
</button>
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { SearchUI } from './SearchUI';
|
|
2
|
+
export { SearchUISimple } from './SearchUISimple';
|
|
3
|
+
export { SearchUIAdvanced } from './SearchUIAdvanced';
|
|
4
|
+
export { DEFAULT_OPERATORS, DEFAULT_CONNECTORS } from './SearchOperators';
|
|
5
|
+
export type { SearchUIProps } from './SearchUI';
|
|
6
|
+
export type { SearchUISimpleProps } from './SearchUISimple';
|
|
7
|
+
export type { SearchUIAdvancedProps } from './SearchUIAdvanced';
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default Basemap configurations
|
|
3
|
+
* Based on https://github.com/lad-sapienza/sCMS/blob/main/src/modules/maps/defaultBaseLayers.js
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { BaseLayerConfig } from './types';
|
|
7
|
+
|
|
8
|
+
export const defaultBasemaps: Record<string, BaseLayerConfig> = {
|
|
9
|
+
CAWM: {
|
|
10
|
+
name: "Ancient World Map",
|
|
11
|
+
url: "https://cawm.lib.uiowa.edu/tiles/{z}/{x}/{y}.png",
|
|
12
|
+
attribution: '© <a href="http://cawm.lib.uiowa.edu/index.html">Consortium of Ancient World Mappers</a>',
|
|
13
|
+
},
|
|
14
|
+
OSM: {
|
|
15
|
+
name: "Open Street Map",
|
|
16
|
+
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
17
|
+
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
18
|
+
},
|
|
19
|
+
EsriSatellite: {
|
|
20
|
+
name: "Esri Imagery/Satellite",
|
|
21
|
+
url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
|
|
22
|
+
},
|
|
23
|
+
EsriStreets: {
|
|
24
|
+
name: "Esri Streets",
|
|
25
|
+
url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}",
|
|
26
|
+
},
|
|
27
|
+
EsriTopo: {
|
|
28
|
+
name: "Esri Topo",
|
|
29
|
+
url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}",
|
|
30
|
+
},
|
|
31
|
+
GoogleSatellite: {
|
|
32
|
+
name: "Google Satellite",
|
|
33
|
+
url: "https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}",
|
|
34
|
+
},
|
|
35
|
+
GoogleRoadmap: {
|
|
36
|
+
name: "Google Roadmap",
|
|
37
|
+
url: "https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}",
|
|
38
|
+
},
|
|
39
|
+
GoogleTerrain: {
|
|
40
|
+
name: "Google Terrain",
|
|
41
|
+
url: "https://mt1.google.com/vt/lyrs=p&x={x}&y={y}&z={z}",
|
|
42
|
+
},
|
|
43
|
+
GoogleAlteredRoadmap: {
|
|
44
|
+
name: "Google Altered Roadmap",
|
|
45
|
+
url: "https://mt1.google.com/vt/lyrs=r&x={x}&y={y}&z={z}",
|
|
46
|
+
},
|
|
47
|
+
CartoDB: {
|
|
48
|
+
name: "CartoDB",
|
|
49
|
+
url: "https://cartodb-basemaps-a.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png",
|
|
50
|
+
},
|
|
51
|
+
StamenTerrain: {
|
|
52
|
+
name: "Stamen Terrain",
|
|
53
|
+
url: "https://a.tile.stamen.com/terrain/{z}/{x}/{y}.png",
|
|
54
|
+
},
|
|
55
|
+
OSMMapnick: {
|
|
56
|
+
name: "OpenStreetMap Mapnick",
|
|
57
|
+
url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
58
|
+
},
|
|
59
|
+
OSMCycle: {
|
|
60
|
+
name: "OSM Cycle Map",
|
|
61
|
+
url: "https://tile.thunderforest.com/cycle/{z}/{x}/{y}.png",
|
|
62
|
+
},
|
|
63
|
+
Imperium: {
|
|
64
|
+
name: "Imperium (DARE)",
|
|
65
|
+
url: "https://dh.gu.se/tiles/imperium/{z}/{x}/{y}.png",
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Valid basemap keys
|
|
71
|
+
*/
|
|
72
|
+
export type BasemapKey = keyof typeof defaultBasemaps;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get basemap configuration by key
|
|
76
|
+
*/
|
|
77
|
+
export function getBasemap(key: string): BaseLayerConfig | undefined {
|
|
78
|
+
return defaultBasemaps[key];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get multiple basemaps by keys
|
|
83
|
+
*/
|
|
84
|
+
export function getBasemaps(keys: string[]): BaseLayerConfig[] {
|
|
85
|
+
return keys
|
|
86
|
+
.map(key => defaultBasemaps[key])
|
|
87
|
+
.filter((config): config is BaseLayerConfig => config !== undefined);
|
|
88
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { Map } from './MapMdx';
|
|
2
|
+
export { Map as MapCore } from './Map';
|
|
3
|
+
export { defaultBasemaps, getBasemap, getBasemaps } from './defaultBasemaps';
|
|
4
|
+
export type { BasemapKey } from './defaultBasemaps';
|
|
5
|
+
export type { VectorLayerConfig } from './types';
|
|
6
|
+
export * from './types';
|