@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,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map Component Types
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { SourceConfig } from '../../utils/data-fetcher';
|
|
6
|
+
import type { BasemapKey } from './defaultBasemaps';
|
|
7
|
+
import type { FilterObject } from './utils';
|
|
8
|
+
import type { DirectusShorthand } from '../../utils/directus-config';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Search field configuration
|
|
12
|
+
* Can be a simple label string or an object with label and predefined values
|
|
13
|
+
*/
|
|
14
|
+
export type SearchFieldConfig =
|
|
15
|
+
| string
|
|
16
|
+
| {
|
|
17
|
+
label: string;
|
|
18
|
+
values?: string[];
|
|
19
|
+
operator?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Search fields configuration for vector layers
|
|
24
|
+
* Maps field names to their search configuration
|
|
25
|
+
*/
|
|
26
|
+
export type SearchInFields = Record<string, SearchFieldConfig>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Search operators supported by the search interface
|
|
30
|
+
*/
|
|
31
|
+
export interface SearchOperators {
|
|
32
|
+
_eq: string;
|
|
33
|
+
_neq: string;
|
|
34
|
+
_lt: string;
|
|
35
|
+
_lte: string;
|
|
36
|
+
_gt: string;
|
|
37
|
+
_gte: string;
|
|
38
|
+
_null: string;
|
|
39
|
+
_nnull: string;
|
|
40
|
+
_contains: string;
|
|
41
|
+
_icontains: string;
|
|
42
|
+
_ncontains: string;
|
|
43
|
+
_starts_with: string;
|
|
44
|
+
_istarts_with: string;
|
|
45
|
+
_nstarts_with: string;
|
|
46
|
+
_ends_with: string;
|
|
47
|
+
_iends_with: string;
|
|
48
|
+
_nends_with: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Search filter triplet: field, operator, value
|
|
53
|
+
*/
|
|
54
|
+
export interface SearchFilter {
|
|
55
|
+
field: string;
|
|
56
|
+
operator: string;
|
|
57
|
+
value: any;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Search query structure
|
|
62
|
+
*/
|
|
63
|
+
export interface SearchQuery {
|
|
64
|
+
connector: '_and' | '_or';
|
|
65
|
+
filters: SearchFilter[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type ControlPosition = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
|
|
69
|
+
|
|
70
|
+
export interface BaseLayerConfig {
|
|
71
|
+
name: string;
|
|
72
|
+
url: string;
|
|
73
|
+
attribution?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface VectorLayerConfig {
|
|
77
|
+
/** Layer name/ID */
|
|
78
|
+
name: string;
|
|
79
|
+
|
|
80
|
+
/** Data source configuration */
|
|
81
|
+
source: SourceConfig;
|
|
82
|
+
|
|
83
|
+
/** Layer style (MapLibre paint/layout properties) */
|
|
84
|
+
style?: any;
|
|
85
|
+
|
|
86
|
+
/** Popup template string (e.g., "<b>${Title}</b>: ${Description}") */
|
|
87
|
+
popupTemplate?: string;
|
|
88
|
+
|
|
89
|
+
/** Initial visibility */
|
|
90
|
+
visible?: boolean;
|
|
91
|
+
|
|
92
|
+
/** Fit map bounds to layer content */
|
|
93
|
+
fitToContent?: boolean;
|
|
94
|
+
|
|
95
|
+
/** Optional client-side filter for features */
|
|
96
|
+
filter?: FilterObject | ((feature: any) => boolean);
|
|
97
|
+
|
|
98
|
+
/** Search configuration - defines which fields are searchable */
|
|
99
|
+
searchInFields?: SearchInFields;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface MapProps {
|
|
103
|
+
/** Map height (CSS string) */
|
|
104
|
+
height?: string;
|
|
105
|
+
|
|
106
|
+
/** Initial center [lng, lat, zoom] as string "lng,lat,zoom" */
|
|
107
|
+
center?: string;
|
|
108
|
+
|
|
109
|
+
/** MapLibre style object or URL (for full style specification) */
|
|
110
|
+
mapStyle?: string | object;
|
|
111
|
+
|
|
112
|
+
/** Overrides/expansions for mapStyle layers */
|
|
113
|
+
styleOverrides?: {
|
|
114
|
+
[layerId: string]: {
|
|
115
|
+
/** Override layer paint properties */
|
|
116
|
+
paint?: any;
|
|
117
|
+
/** Override layer layout properties */
|
|
118
|
+
layout?: any;
|
|
119
|
+
/** Add popup template (expanded feature) */
|
|
120
|
+
popupTemplate?: string;
|
|
121
|
+
/** Fit map to layer bounds (expanded feature) */
|
|
122
|
+
fitToContent?: boolean;
|
|
123
|
+
/** Initial visibility (expanded feature) */
|
|
124
|
+
visible?: boolean;
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** Base layers configuration (can be array of configs or array of basemap keys) */
|
|
129
|
+
baseLayers?: BaseLayerConfig[] | BasemapKey[];
|
|
130
|
+
|
|
131
|
+
/** Vector layers configuration (alternative to children for hydration-safe usage) */
|
|
132
|
+
vectorLayers?: VectorLayerConfig[];
|
|
133
|
+
|
|
134
|
+
/** Controls positions (null/undefined to hide) */
|
|
135
|
+
geolocateControl?: ControlPosition;
|
|
136
|
+
fullscreenControl?: ControlPosition;
|
|
137
|
+
navigationControl?: ControlPosition;
|
|
138
|
+
scaleControl?: ControlPosition;
|
|
139
|
+
|
|
140
|
+
/** Show layer control for basemap switching (default: true) */
|
|
141
|
+
layerControl?: boolean | ControlPosition;
|
|
142
|
+
|
|
143
|
+
/** Optional sprite URL */
|
|
144
|
+
sprite?: string;
|
|
145
|
+
|
|
146
|
+
/** GeoJSON shorthand - for simple single-layer maps */
|
|
147
|
+
geojson?: string | object | {
|
|
148
|
+
path: string;
|
|
149
|
+
name?: string;
|
|
150
|
+
style?: any;
|
|
151
|
+
popup?: string;
|
|
152
|
+
fitToContent?: boolean;
|
|
153
|
+
filter?: FilterObject | ((feature: any) => boolean);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** CSV shorthand - for simple single-layer maps */
|
|
157
|
+
csv?: string | {
|
|
158
|
+
path: string;
|
|
159
|
+
lng: string;
|
|
160
|
+
lat: string;
|
|
161
|
+
name?: string;
|
|
162
|
+
style?: any;
|
|
163
|
+
popup?: string;
|
|
164
|
+
fitToContent?: boolean;
|
|
165
|
+
filter?: FilterObject | ((row: any) => boolean);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/** JSON shorthand - for simple single-layer maps */
|
|
169
|
+
json?: any[] | string;
|
|
170
|
+
|
|
171
|
+
/** Directus shorthand - for simple single-layer maps */
|
|
172
|
+
directus?: DirectusShorthand & {
|
|
173
|
+
geoField?: string;
|
|
174
|
+
name?: string;
|
|
175
|
+
style?: any;
|
|
176
|
+
popup?: string;
|
|
177
|
+
fitToContent?: boolean;
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map Utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { DataRow } from '../../utils/data-fetcher';
|
|
6
|
+
import type { FeatureCollection, Feature, Geometry } from 'geojson';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Tries to identify latitude and longitude fields from a data row
|
|
10
|
+
*/
|
|
11
|
+
export function getGeoFields(row: DataRow): { latField: string; lngField: string } | null {
|
|
12
|
+
const keys = Object.keys(row);
|
|
13
|
+
|
|
14
|
+
const latField = keys.find(k => /^(lat|latitude|y)$/i.test(k));
|
|
15
|
+
const lngField = keys.find(k => /^(lng|long|longitude|x)$/i.test(k));
|
|
16
|
+
|
|
17
|
+
if (latField && lngField) {
|
|
18
|
+
return { latField, lngField };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Converts generic data rows to GeoJSON FeatureCollection
|
|
26
|
+
* Handles both coordinate-based data and data already containing geometry
|
|
27
|
+
*/
|
|
28
|
+
export function dataToGeoJson(
|
|
29
|
+
data: DataRow[],
|
|
30
|
+
customLngField?: string,
|
|
31
|
+
customLatField?: string,
|
|
32
|
+
geoField?: string
|
|
33
|
+
): FeatureCollection {
|
|
34
|
+
const features: Feature[] = [];
|
|
35
|
+
|
|
36
|
+
if (data.length === 0) {
|
|
37
|
+
return { type: 'FeatureCollection', features: [] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Check if data already has geometry (from GeoJSON source)
|
|
41
|
+
if (data[0]._geometry) {
|
|
42
|
+
data.forEach((row, index) => {
|
|
43
|
+
features.push({
|
|
44
|
+
type: 'Feature',
|
|
45
|
+
id: row._id || row.id || index,
|
|
46
|
+
geometry: row._geometry,
|
|
47
|
+
properties: { ...row, _geometry: undefined, _id: undefined }
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
return { type: 'FeatureCollection', features };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check if geoField is provided (from Directus or other sources)
|
|
54
|
+
if (geoField && data[0][geoField]) {
|
|
55
|
+
data.forEach((row, index) => {
|
|
56
|
+
let geometry = row[geoField];
|
|
57
|
+
|
|
58
|
+
// Parse geometry if it's a string (JSON)
|
|
59
|
+
if (typeof geometry === 'string') {
|
|
60
|
+
try {
|
|
61
|
+
geometry = JSON.parse(geometry);
|
|
62
|
+
} catch (e) {
|
|
63
|
+
console.warn(`Failed to parse geometry field "${geoField}" for row ${index}:`, e);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Validate geometry object
|
|
69
|
+
if (geometry && typeof geometry === 'object' && geometry.type && geometry.coordinates) {
|
|
70
|
+
features.push({
|
|
71
|
+
type: 'Feature',
|
|
72
|
+
id: row.id || index,
|
|
73
|
+
geometry: geometry as Geometry,
|
|
74
|
+
properties: { ...row, [geoField]: undefined }
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
return { type: 'FeatureCollection', features };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// If custom fields provided, use them
|
|
82
|
+
let latField = customLatField;
|
|
83
|
+
let lngField = customLngField;
|
|
84
|
+
|
|
85
|
+
// Otherwise, try to detect lat/lng fields
|
|
86
|
+
if (!latField || !lngField) {
|
|
87
|
+
const geoFields = getGeoFields(data[0]);
|
|
88
|
+
if (!geoFields) {
|
|
89
|
+
console.warn('Map: Could not auto-detect simple lat/lng fields in data. GeoJSON conversion failed.');
|
|
90
|
+
return { type: 'FeatureCollection', features: [] };
|
|
91
|
+
}
|
|
92
|
+
latField = latField || geoFields.latField;
|
|
93
|
+
lngField = lngField || geoFields.lngField;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
data.forEach((row, index) => {
|
|
97
|
+
const lat = Number(row[latField!]);
|
|
98
|
+
const lng = Number(row[lngField!]);
|
|
99
|
+
|
|
100
|
+
if (!isNaN(lat) && !isNaN(lng)) {
|
|
101
|
+
features.push({
|
|
102
|
+
type: 'Feature',
|
|
103
|
+
id: row.id || index,
|
|
104
|
+
geometry: {
|
|
105
|
+
type: 'Point',
|
|
106
|
+
coordinates: [lng, lat]
|
|
107
|
+
},
|
|
108
|
+
properties: row
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
type: 'FeatureCollection',
|
|
115
|
+
features
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Escapes a string for safe interpolation into HTML markup/attributes.
|
|
121
|
+
*/
|
|
122
|
+
function escapeHtml(value: string): string {
|
|
123
|
+
return value
|
|
124
|
+
.replace(/&/g, '&')
|
|
125
|
+
.replace(/</g, '<')
|
|
126
|
+
.replace(/>/g, '>')
|
|
127
|
+
.replace(/"/g, '"')
|
|
128
|
+
.replace(/'/g, ''');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Parses simple string templates with ${variable} syntax.
|
|
133
|
+
*
|
|
134
|
+
* The template itself is treated as trusted markup (it's author-supplied,
|
|
135
|
+
* e.g. `popupTemplate: '<b>${Title}</b>'`), but each interpolated value comes
|
|
136
|
+
* from feature/row data — which may be third-party or user-submitted content
|
|
137
|
+
* (CSV, GeoJSON, Directus) — and is HTML-escaped before substitution, since
|
|
138
|
+
* the result is rendered via dangerouslySetInnerHTML.
|
|
139
|
+
*/
|
|
140
|
+
export function parseStringTemplate(template: string, data: Record<string, any>): string {
|
|
141
|
+
return template.replace(/\$\{([^}]+)\}/g, (_, key) => {
|
|
142
|
+
return data[key] !== undefined ? escapeHtml(String(data[key])) : '';
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Filter object syntax type (similar to Directus filtering)
|
|
148
|
+
*
|
|
149
|
+
* Example:
|
|
150
|
+
* { field: { _eq: 'value' } }
|
|
151
|
+
* { field: { _neq: 'value' } }
|
|
152
|
+
* { field: { _in: ['value1', 'value2'] } }
|
|
153
|
+
* { field: { _contains: 'substring' } }
|
|
154
|
+
* { field: { _gt: 100 }, field2: { _eq: 'value' } } // Multiple conditions (AND)
|
|
155
|
+
*/
|
|
156
|
+
export type FilterObject = {
|
|
157
|
+
[field: string]: {
|
|
158
|
+
_eq?: any;
|
|
159
|
+
_neq?: any;
|
|
160
|
+
_in?: any[];
|
|
161
|
+
_nin?: any[];
|
|
162
|
+
_contains?: string;
|
|
163
|
+
_ncontains?: string;
|
|
164
|
+
_gt?: number;
|
|
165
|
+
_gte?: number;
|
|
166
|
+
_lt?: number;
|
|
167
|
+
_lte?: number;
|
|
168
|
+
_null?: boolean;
|
|
169
|
+
_nnull?: boolean;
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Converts a Directus-like filter object to a JavaScript predicate function
|
|
175
|
+
* Supports both GeoJSON features and CSV rows
|
|
176
|
+
*/
|
|
177
|
+
export function filterObjectToPredicate(
|
|
178
|
+
filterObj: FilterObject
|
|
179
|
+
): (item: Feature | DataRow) => boolean {
|
|
180
|
+
return (item: Feature | DataRow) => {
|
|
181
|
+
// Get properties (for GeoJSON features) or use item directly (for CSV rows)
|
|
182
|
+
const properties = 'properties' in item ? item.properties : item;
|
|
183
|
+
|
|
184
|
+
// Check all field conditions (AND logic)
|
|
185
|
+
for (const [field, conditions] of Object.entries(filterObj)) {
|
|
186
|
+
const value = properties?.[field];
|
|
187
|
+
|
|
188
|
+
// Check each operator
|
|
189
|
+
for (const [operator, expected] of Object.entries(conditions)) {
|
|
190
|
+
switch (operator) {
|
|
191
|
+
case '_eq':
|
|
192
|
+
if (value != expected) return false; // Loose equality
|
|
193
|
+
break;
|
|
194
|
+
case '_neq':
|
|
195
|
+
if (value == expected) return false;
|
|
196
|
+
break;
|
|
197
|
+
case '_in':
|
|
198
|
+
if (!Array.isArray(expected) || !expected.includes(value)) return false;
|
|
199
|
+
break;
|
|
200
|
+
case '_nin':
|
|
201
|
+
if (!Array.isArray(expected) || expected.includes(value)) return false;
|
|
202
|
+
break;
|
|
203
|
+
case '_contains':
|
|
204
|
+
if (typeof value !== 'string' || !value.includes(String(expected))) return false;
|
|
205
|
+
break;
|
|
206
|
+
case '_ncontains':
|
|
207
|
+
if (typeof value !== 'string' || value.includes(String(expected))) return false;
|
|
208
|
+
break;
|
|
209
|
+
case '_gt':
|
|
210
|
+
if (Number(value) <= Number(expected)) return false;
|
|
211
|
+
break;
|
|
212
|
+
case '_gte':
|
|
213
|
+
if (Number(value) < Number(expected)) return false;
|
|
214
|
+
break;
|
|
215
|
+
case '_lt':
|
|
216
|
+
if (Number(value) >= Number(expected)) return false;
|
|
217
|
+
break;
|
|
218
|
+
case '_lte':
|
|
219
|
+
if (Number(value) > Number(expected)) return false;
|
|
220
|
+
break;
|
|
221
|
+
case '_null':
|
|
222
|
+
if (expected === true && value != null) return false;
|
|
223
|
+
if (expected === false && value == null) return false;
|
|
224
|
+
break;
|
|
225
|
+
case '_nnull':
|
|
226
|
+
if (expected === true && value == null) return false;
|
|
227
|
+
if (expected === false && value != null) return false;
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return true;
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Converts a search query to a MapLibre filter expression
|
|
239
|
+
* This provides better performance than client-side filtering for large datasets
|
|
240
|
+
*/
|
|
241
|
+
export function searchQueryToMapLibreFilter(query: import('./types').SearchQuery): any[] | null {
|
|
242
|
+
if (!query.filters || query.filters.length === 0) {
|
|
243
|
+
return null; // No filter
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const filters = query.filters.map(filter => {
|
|
247
|
+
const { field, operator, value } = filter;
|
|
248
|
+
|
|
249
|
+
switch (operator) {
|
|
250
|
+
case '_eq':
|
|
251
|
+
return ['==', ['get', field], value];
|
|
252
|
+
case '_neq':
|
|
253
|
+
return ['!=', ['get', field], value];
|
|
254
|
+
case '_gt':
|
|
255
|
+
return ['>', ['get', field], value];
|
|
256
|
+
case '_gte':
|
|
257
|
+
return ['>=', ['get', field], value];
|
|
258
|
+
case '_lt':
|
|
259
|
+
return ['<', ['get', field], value];
|
|
260
|
+
case '_lte':
|
|
261
|
+
return ['<=', ['get', field], value];
|
|
262
|
+
case '_contains':
|
|
263
|
+
return ['in', value, ['get', field]];
|
|
264
|
+
case '_icontains':
|
|
265
|
+
// Case-insensitive contains using downcase for both field value and search value
|
|
266
|
+
return ['in', ['downcase', value], ['downcase', ['get', field]]];
|
|
267
|
+
case '_ncontains':
|
|
268
|
+
return ['!', ['in', value, ['get', field]]];
|
|
269
|
+
case '_starts_with':
|
|
270
|
+
return ['==', ['slice', ['get', field], 0, value.length], value];
|
|
271
|
+
case '_istarts_with':
|
|
272
|
+
// Case-insensitive starts with using downcase
|
|
273
|
+
return ['==', ['slice', ['downcase', ['get', field]], 0, value.length], ['downcase', value]];
|
|
274
|
+
case '_nstarts_with':
|
|
275
|
+
return ['!=', ['slice', ['get', field], 0, value.length], value];
|
|
276
|
+
case '_ends_with':
|
|
277
|
+
return ['==', ['slice', ['get', field], ['-', ['length', ['get', field]], value.length]], value];
|
|
278
|
+
case '_iends_with':
|
|
279
|
+
// Case-insensitive ends with using downcase
|
|
280
|
+
return ['==', ['slice', ['downcase', ['get', field]], ['-', ['length', ['get', field]], value.length]], ['downcase', value]];
|
|
281
|
+
case '_nends_with':
|
|
282
|
+
return ['!=', ['slice', ['get', field], ['-', ['length', ['get', field]], value.length]], value];
|
|
283
|
+
case '_null':
|
|
284
|
+
return ['==', ['get', field], null];
|
|
285
|
+
case '_nnull':
|
|
286
|
+
return ['!=', ['get', field], null];
|
|
287
|
+
default:
|
|
288
|
+
console.warn(`Unsupported search operator for MapLibre filter: ${operator}`);
|
|
289
|
+
return ['==', ['get', field], value];
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Combine filters with the specified connector
|
|
294
|
+
if (filters.length === 1) {
|
|
295
|
+
return filters[0];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (query.connector === '_and') {
|
|
299
|
+
return ['all', ...filters];
|
|
300
|
+
} else {
|
|
301
|
+
return ['any', ...filters];
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Converts a search query to a client-side predicate function
|
|
307
|
+
* Fallback when MapLibre filtering is not suitable or for complex operations
|
|
308
|
+
*/
|
|
309
|
+
export function searchQueryToPredicate(query: import('./types').SearchQuery): (item: Feature | DataRow) => boolean {
|
|
310
|
+
if (!query.filters || query.filters.length === 0) {
|
|
311
|
+
return () => true; // No filter, match all
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return (item: Feature | DataRow) => {
|
|
315
|
+
const properties = 'properties' in item ? item.properties : item;
|
|
316
|
+
|
|
317
|
+
const results = query.filters.map(filter => {
|
|
318
|
+
const { field, operator, value } = filter;
|
|
319
|
+
const fieldValue = properties?.[field];
|
|
320
|
+
|
|
321
|
+
switch (operator) {
|
|
322
|
+
case '_eq':
|
|
323
|
+
return fieldValue == value;
|
|
324
|
+
case '_neq':
|
|
325
|
+
return fieldValue != value;
|
|
326
|
+
case '_gt':
|
|
327
|
+
return Number(fieldValue) > Number(value);
|
|
328
|
+
case '_gte':
|
|
329
|
+
return Number(fieldValue) >= Number(value);
|
|
330
|
+
case '_lt':
|
|
331
|
+
return Number(fieldValue) < Number(value);
|
|
332
|
+
case '_lte':
|
|
333
|
+
return Number(fieldValue) <= Number(value);
|
|
334
|
+
case '_contains':
|
|
335
|
+
return typeof fieldValue === 'string' && fieldValue.includes(String(value));
|
|
336
|
+
case '_icontains':
|
|
337
|
+
return typeof fieldValue === 'string' && fieldValue.toLowerCase().includes(String(value).toLowerCase());
|
|
338
|
+
case '_ncontains':
|
|
339
|
+
return typeof fieldValue === 'string' && !fieldValue.includes(String(value));
|
|
340
|
+
case '_starts_with':
|
|
341
|
+
return typeof fieldValue === 'string' && fieldValue.startsWith(String(value));
|
|
342
|
+
case '_istarts_with':
|
|
343
|
+
return typeof fieldValue === 'string' && fieldValue.toLowerCase().startsWith(String(value).toLowerCase());
|
|
344
|
+
case '_nstarts_with':
|
|
345
|
+
return typeof fieldValue === 'string' && !fieldValue.startsWith(String(value));
|
|
346
|
+
case '_ends_with':
|
|
347
|
+
return typeof fieldValue === 'string' && fieldValue.endsWith(String(value));
|
|
348
|
+
case '_iends_with':
|
|
349
|
+
return typeof fieldValue === 'string' && fieldValue.toLowerCase().endsWith(String(value).toLowerCase());
|
|
350
|
+
case '_nends_with':
|
|
351
|
+
return typeof fieldValue === 'string' && !fieldValue.endsWith(String(value));
|
|
352
|
+
case '_null':
|
|
353
|
+
return fieldValue == null;
|
|
354
|
+
case '_nnull':
|
|
355
|
+
return fieldValue != null;
|
|
356
|
+
default:
|
|
357
|
+
console.warn(`Unsupported search operator: ${operator}`);
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// Apply connector logic
|
|
363
|
+
return query.connector === '_and'
|
|
364
|
+
? results.every(Boolean)
|
|
365
|
+
: results.some(Boolean);
|
|
366
|
+
};
|
|
367
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Core Components
|
|
2
|
+
|
|
3
|
+
This directory contains reusable, framework-level components that provide essential functionality for the s:CMS system.
|
|
4
|
+
|
|
5
|
+
## Components
|
|
6
|
+
|
|
7
|
+
### SEO.astro
|
|
8
|
+
|
|
9
|
+
Comprehensive SEO component that generates meta tags and structured data for search engines and social media.
|
|
10
|
+
|
|
11
|
+
**Usage:**
|
|
12
|
+
|
|
13
|
+
```astro
|
|
14
|
+
---
|
|
15
|
+
import SEO from '@lad-sapienza/scms-core/components/SEO/SEO.astro';
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
<head>
|
|
19
|
+
<SEO
|
|
20
|
+
title="Page Title"
|
|
21
|
+
description="Page description"
|
|
22
|
+
image="/images/og-image.jpg"
|
|
23
|
+
author="Author Name"
|
|
24
|
+
isArticle={true}
|
|
25
|
+
datePublished="2026-01-14"
|
|
26
|
+
tags={['tag1', 'tag2']}
|
|
27
|
+
/>
|
|
28
|
+
</head>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Props:**
|
|
32
|
+
- `title` (string, optional) - Page title
|
|
33
|
+
- `description` (string, optional) - Page description
|
|
34
|
+
- `image` (string, optional) - Social media image URL
|
|
35
|
+
- `author` (string, optional) - Content author name
|
|
36
|
+
- `isArticle` (boolean, optional) - Whether this is an article (adds Article schema)
|
|
37
|
+
- `datePublished` (string|Date, optional) - Publication date
|
|
38
|
+
- `dateModified` (string|Date, optional) - Last modification date
|
|
39
|
+
- `tags` (string[], optional) - Article tags
|
|
40
|
+
- `canonical` (string, optional) - Canonical URL override
|
|
41
|
+
|
|
42
|
+
**Features:**
|
|
43
|
+
- Open Graph meta tags for Facebook/LinkedIn
|
|
44
|
+
- Twitter Card meta tags
|
|
45
|
+
- JSON-LD structured data for articles
|
|
46
|
+
- Automatic canonical URL generation
|
|
47
|
+
- Image URL resolution
|
|
48
|
+
|
|
49
|
+
## Extending Core
|
|
50
|
+
|
|
51
|
+
Core components should be:
|
|
52
|
+
1. **Framework-level**: Provide essential, reusable functionality
|
|
53
|
+
2. **Well-documented**: Include usage examples and prop descriptions
|
|
54
|
+
3. **Tested**: Ensure they work across different use cases
|
|
55
|
+
4. **Minimal dependencies**: Keep external dependencies to a minimum
|
|
56
|
+
|
|
57
|
+
## User Customization
|
|
58
|
+
|
|
59
|
+
Users should **not** edit core components directly. Instead:
|
|
60
|
+
- Override behavior in `usr/layouts/`
|
|
61
|
+
- Create custom components in `usr/components/`
|
|
62
|
+
- Extend layouts with slots and composition
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import React, { useContext } from 'react';
|
|
2
|
+
import { RecordContext } from './context';
|
|
3
|
+
import { getValueByDotPath } from './utils';
|
|
4
|
+
|
|
5
|
+
export interface FieldProps {
|
|
6
|
+
name: string;
|
|
7
|
+
transformer?: (value: any) => React.ReactNode;
|
|
8
|
+
fallback?: React.ReactNode;
|
|
9
|
+
record?: any;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function Field({ name, transformer, fallback = null, record }: FieldProps) {
|
|
13
|
+
const contextRecord = useContext(RecordContext);
|
|
14
|
+
const sourceRecord = record ?? contextRecord;
|
|
15
|
+
|
|
16
|
+
if (!sourceRecord) return <>{fallback}</>;
|
|
17
|
+
|
|
18
|
+
const value = getValueByDotPath(sourceRecord, name);
|
|
19
|
+
|
|
20
|
+
if (value === undefined || value === null || value === '') {
|
|
21
|
+
return <>{fallback}</>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (transformer) {
|
|
25
|
+
return <>{transformer(value)}</>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
29
|
+
return <>{String(value)}</>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return <>{JSON.stringify(value, null, 2)}</>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default Field;
|