@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,195 @@
1
+ # ZoteroGeoViewer Component
2
+
3
+ A React component that connects to a Zotero group library and visualizes items with geographical coordinates on a map, providing tag-based filtering and customizable layouts.
4
+
5
+ ## Features
6
+
7
+ - **Zotero API Integration**: Fetches items directly from Zotero group libraries
8
+ - **Geographical Visualization**: Maps items to coordinates using tag-based georeferencing
9
+ - **Interactive Filtering**: Tag-based search with autocomplete functionality
10
+ - **Flexible Layouts**: Multiple layout options for different use cases
11
+ - **Real-time Statistics**: Live counts of total, georeferenced, and filtered items
12
+ - **Recent Items Preview**: Quick access to the latest library additions
13
+
14
+ ## Usage
15
+
16
+ ### In Astro files
17
+
18
+ ```astro
19
+ ---
20
+ import { ZoteroGeoViewer } from '@lad-sapienza/scms-core/components/ZoteroGeoViewer';
21
+ ---
22
+
23
+ <ZoteroGeoViewer
24
+ groupId={12345}
25
+ layout="8x4"
26
+ mapHeight="500px"
27
+ client:idle
28
+ />
29
+ ```
30
+
31
+ ### In MDX files
32
+
33
+ ```jsx
34
+ import { ZoteroGeoViewer } from '@lad-sapienza/scms-core/components/ZoteroGeoViewer';
35
+
36
+ <ZoteroGeoViewer
37
+ groupId={12345}
38
+ layout="6x6"
39
+ tagAutocomplete={true}
40
+ client:load
41
+ />
42
+ ```
43
+
44
+ ### In React components
45
+
46
+ ```tsx
47
+ import { ZoteroGeoViewer } from '@lad-sapienza/scms-core/components/ZoteroGeoViewer';
48
+
49
+ export function MyPage() {
50
+ return (
51
+ <ZoteroGeoViewer
52
+ groupId={12345}
53
+ layout="vertical"
54
+ mapCenter="20.5,40.0,8"
55
+ maxItems={500}
56
+ />
57
+ );
58
+ }
59
+ ```
60
+
61
+ ## Props
62
+
63
+ | Prop | Type | Default | Description |
64
+ |------|------|---------|-------------|
65
+ | `groupId` | `number` | **required** | Zotero group library ID |
66
+ | `layout` | `LayoutType` | `"8x4"` | Display layout configuration |
67
+ | `mapHeight` | `string` | `"600px"` | Height of the map container |
68
+ | `mapCenter` | `string` | `"20.5,40.0,8"` | Map center (lng,lat,zoom) |
69
+ | `tagAutocomplete` | `boolean` | `true` | Show the autocomplete dropdown in tag search |
70
+ | `maxItems` | `number` | `1000` | Maximum records to fetch/display in the selected-tag preview list |
71
+
72
+ ### Layout Options
73
+
74
+ - **`vertical`**: Stacked layout (map top, controls bottom)
75
+ - **`6x6`**: Equal split (50/50) - good for detailed analysis
76
+ - **`8x4`**: Map focus (67/33) - emphasizes geographical view
77
+ - **`4x8`**: Controls focus (33/67) - emphasizes data and filtering
78
+ - **`12x4`**: Full-width map with controls below
79
+ - **`horizontal`**: Side-by-side layout
80
+
81
+ ## Data Requirements
82
+
83
+ ### Coordinate Data File
84
+
85
+ The component requires a GeoJSON file at `/data/zoteroTagCoordinates.geojson` with the following structure:
86
+
87
+ ```json
88
+ {
89
+ "type": "FeatureCollection",
90
+ "features": [
91
+ {
92
+ "type": "Feature",
93
+ "properties": {
94
+ "name": "Location Name",
95
+ "altLabel": "Alternative Name",
96
+ "id": 123,
97
+ "source": "data_source"
98
+ },
99
+ "geometry": {
100
+ "type": "Point",
101
+ "coordinates": [longitude, latitude]
102
+ }
103
+ }
104
+ ]
105
+ }
106
+ ```
107
+
108
+ ### Tag-based Georeferencing
109
+
110
+ The component matches Zotero items to geographical locations by:
111
+
112
+ 1. Extracting tags from each Zotero item
113
+ 2. Matching tags against location names and alternative labels
114
+ 3. Grouping items by matched coordinates
115
+ 4. Creating map markers with item counts and popup details
116
+
117
+ ## API Integration
118
+
119
+ ### Zotero API
120
+
121
+ The component uses the public Zotero API:
122
+ - **Endpoint**: `https://api.zotero.org/groups/{groupId}/items`
123
+ - **Format**: JSON
124
+ - **Authentication**: None required for public groups
125
+ - **Rate Limits**: Respects Zotero's API limits
126
+
127
+ ### Error Handling
128
+
129
+ - Network failures are gracefully handled with user feedback
130
+ - Invalid group IDs show appropriate error messages
131
+ - Missing coordinate data falls back to empty map
132
+
133
+ ## Styling
134
+
135
+ The component uses Bootstrap 5 classes and follows the s:CMS design system:
136
+
137
+ - **Color Scheme**: Blue for primary actions, gray for neutral elements
138
+ - **Responsive Design**: Adapts to mobile and desktop layouts
139
+ - **Interactive Elements**: Hover states and focus management
140
+ - **Loading States**: Spinner animation during data fetching
141
+
142
+ ## Performance Considerations
143
+
144
+ - **Lazy Loading**: Component uses `client:idle` directive
145
+ - **Data Caching**: Fetched data persists during filter operations
146
+ - **Efficient Filtering**: Client-side filtering for tag queries
147
+ - **Map Optimization**: Uses MapLibre for performance
148
+
149
+ ## Examples
150
+
151
+ ### Basic Usage
152
+ ```jsx
153
+ <ZoteroGeoViewer groupId={12345} client:load />
154
+ ```
155
+
156
+ ### Custom Configuration
157
+ ```jsx
158
+ <ZoteroGeoViewer
159
+ groupId={12345}
160
+ layout="6x6"
161
+ mapHeight="400px"
162
+ mapCenter="12.5,42.0,6"
163
+ maxItems={500}
164
+ tagAutocomplete={false}
165
+ client:load
166
+ />
167
+ ```
168
+
169
+ ### Vertical Layout for Narrow Spaces
170
+ ```jsx
171
+ <ZoteroGeoViewer
172
+ groupId={12345}
173
+ layout="vertical"
174
+ mapHeight="300px"
175
+ client:load
176
+ />
177
+ ```
178
+
179
+ ## Dependencies
180
+
181
+ - **React**: Component framework
182
+ - **MapLibre GL**: Map rendering
183
+ - **Bootstrap 5**: Styling
184
+ - **Zotero API**: Data source
185
+ - **s:CMS Map Component**: Map integration
186
+
187
+ ## Migration from Legacy sCMS
188
+
189
+ This component replaces the old `ZoteroGeoViewer.js` with:
190
+
191
+ 1. **Modern React Hooks**: Uses functional components with hooks
192
+ 2. **TypeScript Support**: Full type safety and IntelliSense
193
+ 3. **s:CMS Integration**: Follows new component patterns
194
+ 4. **Improved Performance**: Better data handling and rendering
195
+ 5. **Enhanced UX**: Better loading states and error handling
@@ -0,0 +1,26 @@
1
+ ---
2
+ /**
3
+ * ZoteroGeoViewer - Astro wrapper for the ZoteroGeoViewer React component
4
+ *
5
+ * This component fetches data from a Zotero group library and displays
6
+ * items with geographical coordinates on a map with tag-based filtering.
7
+ */
8
+
9
+ import { ZoteroGeoViewer as ZoteroGeoViewerClient } from './ZoteroGeoViewer.tsx';
10
+ import type { ZoteroGeoViewerProps } from './types.ts';
11
+
12
+ export interface Props extends ZoteroGeoViewerProps {}
13
+
14
+ const { groupId, layout = '8x4', ...restProps } = Astro.props;
15
+
16
+ if (!groupId) {
17
+ throw new Error('ZoteroGeoViewer requires a groupId prop');
18
+ }
19
+ ---
20
+
21
+ <ZoteroGeoViewerClient
22
+ groupId={groupId}
23
+ layout={layout}
24
+ {...restProps}
25
+ client:idle
26
+ />
@@ -0,0 +1,483 @@
1
+ import { useState, useEffect } from 'react';
2
+ import type { ZoteroGeoViewerProps } from './types';
3
+ import { Map } from '../Map';
4
+ import { ZoteroRecordsPreview } from './ZoteroRecordsPreview';
5
+
6
+ // Module-level cache to avoid re-fetching data
7
+ let zoteroCache: Record<string, number> | null = null;
8
+ let coordinateCache: any[] | null = null;
9
+
10
+ const COORDINATES_URL = '/data/zoteroTagCoordinates.geojson';
11
+
12
+ export function ZoteroGeoViewer(props: ZoteroGeoViewerProps) {
13
+ // Add error handling for props
14
+ if (!props) {
15
+ return (
16
+ <div className="alert alert-danger">
17
+ <h3 className="fw-semibold">Error loading Zotero data</h3>
18
+ <p className="mb-0">Component props are undefined</p>
19
+ </div>
20
+ );
21
+ }
22
+
23
+ const {
24
+ groupId,
25
+ layout = '8x4',
26
+ mapHeight = '600px',
27
+ mapCenter = '20.5,40.0,8',
28
+ tagAutocomplete = true,
29
+ maxItems = 1000
30
+ } = props;
31
+
32
+ // Add error handling for required props
33
+ if (!groupId) {
34
+ return (
35
+ <div className="alert alert-danger">
36
+ <h3 className="fw-semibold">Error loading Zotero data</h3>
37
+ <p className="mb-1">Group ID is required</p>
38
+ <p className="small mb-0">Group ID: {groupId}</p>
39
+ </div>
40
+ );
41
+ }
42
+ const [data, setData] = useState<Record<string, number> | null>(null);
43
+ const [mapped, setMapped] = useState<any[] | null>(null);
44
+ const [error, setError] = useState<string | null>(null);
45
+ const [loading, setLoading] = useState(true);
46
+ const [tags, setTags] = useState<Array<{label: string; alts: string[]}>>([]);
47
+ const [searchTerm, setSearchTerm] = useState('');
48
+ const [selectedTag, setSelectedTag] = useState<{main: string; alternatives: string[]} | null>(null);
49
+ const [showDropdown, setShowDropdown] = useState(false);
50
+
51
+ // Per-instance id for namespacing globals when multiple viewers are on same page
52
+ const [instanceId] = useState(() => Math.floor(Math.random() * 1e9));
53
+ const handlerName = `__zotSelectTag_${instanceId}`;
54
+
55
+ // Fetch Zotero tags
56
+ async function fetchZoteroTagsFlattened() {
57
+ const baseUrl = `https://api.zotero.org/groups/${groupId}/items/tags`;
58
+ const limit = 100;
59
+ let start = 0;
60
+ let total = Infinity;
61
+ const result: Record<string, number> = {};
62
+ const headers = { Accept: 'application/json' };
63
+
64
+ while (start < total) {
65
+ const url = `${baseUrl}?start=${start}&limit=${limit}`;
66
+ const resp = await fetch(url, { headers });
67
+ if (!resp.ok) {
68
+ throw new Error(`Zotero API error: ${resp.status} ${resp.statusText}`);
69
+ }
70
+ const items = await resp.json();
71
+ const totalResults = resp.headers.get('Total-Results');
72
+ if (totalResults == null) {
73
+ throw new Error('Missing Total-Results header');
74
+ }
75
+ total = parseInt(totalResults, 10);
76
+ for (const item of items) {
77
+ if (item.tag?.startsWith('@')) {
78
+ result[item.tag] = item.meta.numItems;
79
+ }
80
+ }
81
+ start += limit;
82
+ }
83
+ return result;
84
+ }
85
+
86
+ // Fetch coordinate data
87
+ async function fetchCoordinateData() {
88
+ if (coordinateCache) return coordinateCache;
89
+
90
+ const resp = await fetch(COORDINATES_URL);
91
+ if (!resp.ok) throw new Error('Failed to load zoteroTagCoordinates.geojson');
92
+ const geoJson = await resp.json();
93
+
94
+ coordinateCache = geoJson.features;
95
+ return geoJson.features;
96
+ }
97
+
98
+ // Main data loading effect
99
+ useEffect(() => {
100
+ let cancelled = false;
101
+
102
+ async function loadAll() {
103
+ try {
104
+ setLoading(true);
105
+ setError(null);
106
+
107
+ console.log('Loading Zotero data for group:', groupId);
108
+
109
+ // Get Zotero data (use cache if available)
110
+ let zotData = zoteroCache;
111
+ if (!zotData) {
112
+ zotData = await fetchZoteroTagsFlattened();
113
+ zoteroCache = zotData;
114
+ }
115
+ if (cancelled) return;
116
+ setData(zotData);
117
+
118
+ console.log('Loaded', Object.keys(zotData).length, 'Zotero tags');
119
+
120
+ // Get coordinate data
121
+ const coordinates = await fetchCoordinateData();
122
+ if (cancelled) return;
123
+
124
+ console.log('Loaded', coordinates.length, 'coordinate features');
125
+
126
+ // Map coordinates to zotero data
127
+ const ontologyTagSet = new Set<string>();
128
+ const mappedFeatures = coordinates.map((coord: any) => {
129
+ const name = coord.properties?.name || '';
130
+ let candidates = [name];
131
+ const altLabel = coord.properties?.altLabel;
132
+ if (altLabel) {
133
+ const splitCandidates = altLabel
134
+ .split(',')
135
+ .map((t: string) => t.trim())
136
+ .filter(Boolean);
137
+ candidates = candidates.concat(splitCandidates);
138
+ }
139
+ const candidateSet = new Set(candidates);
140
+
141
+ // Add all @candidates to ontologyTagSet
142
+ for (const term of candidateSet) {
143
+ ontologyTagSet.add('@' + term);
144
+ }
145
+
146
+ // Sum zotero counts for all @candidates
147
+ let zoteroCount = 0;
148
+ for (const term of candidateSet) {
149
+ const zotKey = '@' + term;
150
+ if (zotData[zotKey]) {
151
+ zoteroCount += zotData[zotKey];
152
+ }
153
+ }
154
+
155
+ return {
156
+ type: 'Feature',
157
+ properties: {
158
+ name,
159
+ altLabel,
160
+ zoteroCount,
161
+ },
162
+ geometry: coord.geometry
163
+ };
164
+ });
165
+
166
+ if (cancelled) return;
167
+ setMapped(mappedFeatures);
168
+
169
+ console.log('Mapped', mappedFeatures.length, 'features with coordinates');
170
+
171
+ // Create tags array for autocomplete
172
+ const byLabel: Record<string, {label: string; alts: string[]}> = {};
173
+ for (const f of mappedFeatures) {
174
+ const name = f?.properties?.name;
175
+ if (!name || typeof name !== 'string') continue;
176
+ const key = name.toLowerCase();
177
+ let entry = byLabel[key];
178
+ if (!entry) {
179
+ entry = { label: name, alts: [] };
180
+ byLabel[key] = entry;
181
+ }
182
+ const altLabel = f?.properties?.altLabel;
183
+ if (typeof altLabel === 'string' && altLabel.trim()) {
184
+ const phrases = altLabel
185
+ .split(',')
186
+ .map((s: string) => s.trim())
187
+ .filter(Boolean);
188
+ const all = new Set([...entry.alts, ...phrases]);
189
+ entry.alts = Array.from(all);
190
+ }
191
+ }
192
+ const tagsArray = Object.values(byLabel).sort((a: any, b: any) =>
193
+ a.label.toLowerCase().localeCompare(b.label.toLowerCase())
194
+ );
195
+ setTags(tagsArray);
196
+
197
+ console.log('Created', tagsArray.length, 'tag entries');
198
+
199
+ } catch (err) {
200
+ console.error('ZoteroGeoViewer error:', err);
201
+ if (!cancelled) {
202
+ setError(err instanceof Error ? err.message : 'An error occurred');
203
+ }
204
+ } finally {
205
+ if (!cancelled) setLoading(false);
206
+ }
207
+ }
208
+
209
+ if (groupId) {
210
+ loadAll();
211
+ } else {
212
+ setError('No group ID provided');
213
+ setLoading(false);
214
+ }
215
+
216
+ return () => {
217
+ cancelled = true;
218
+ };
219
+ }, [groupId]);
220
+
221
+ // Expose a per-instance global handler for popup buttons to select a tag
222
+ useEffect(() => {
223
+ const name = handlerName;
224
+ (window as any)[name] = (tag: string, altLabels: string) => {
225
+ try {
226
+ if (typeof tag === 'string' && tag.length) {
227
+ setSearchTerm(tag);
228
+ // Include both the main tag and alt labels in the selected tag data
229
+ setSelectedTag({
230
+ main: tag,
231
+ alternatives: altLabels ? altLabels.split(',').map((s: string) => s.trim()).filter(Boolean) : []
232
+ });
233
+ }
234
+ } catch (e) {
235
+ console.error('Error in popup handler:', e);
236
+ }
237
+ };
238
+ return () => {
239
+ try {
240
+ delete (window as any)[name];
241
+ } catch (e) {
242
+ console.error('Error cleaning up handler:', e);
243
+ }
244
+ };
245
+ }, [handlerName]);
246
+
247
+ // Filter suggestions based on search term
248
+ const filteredSuggestions = searchTerm.trim()
249
+ ? tags.filter(t => {
250
+ const v = searchTerm.toLowerCase();
251
+ const inLabel = t.label.toLowerCase().includes(v);
252
+ const inAlt = t.alts.some((a: string) => a.toLowerCase().includes(v));
253
+ return inLabel || inAlt;
254
+ }).slice(0, 10)
255
+ : showDropdown ? tags.slice(0, 10) : []; // Show first 10 tags when focused but no search term
256
+
257
+ // Layout configuration
258
+ const getLayoutClasses = () => {
259
+ switch (layout) {
260
+ case 'vertical':
261
+ return 'd-flex flex-column gap-3';
262
+ case 'horizontal':
263
+ return 'd-flex flex-column flex-md-row gap-3';
264
+ case '6x6':
265
+ return 'row g-3';
266
+ case '8x4':
267
+ return 'row g-3';
268
+ case '4x8':
269
+ return 'row g-3';
270
+ case '12x4':
271
+ return 'd-flex flex-column gap-3';
272
+ default:
273
+ return 'row g-3';
274
+ }
275
+ };
276
+
277
+ const getMapColSpan = () => {
278
+ switch (layout) {
279
+ case 'horizontal': return 'col-md-8';
280
+ case '6x6': return 'col-md-6';
281
+ case '8x4': return 'col-md-8';
282
+ case '4x8': return 'col-md-4';
283
+ case '12x4': return 'col-12';
284
+ default: return 'col-md-8';
285
+ }
286
+ };
287
+
288
+ const getControlsColSpan = () => {
289
+ switch (layout) {
290
+ case 'horizontal': return 'col-md-4';
291
+ case '6x6': return 'col-md-6';
292
+ case '8x4': return 'col-md-4';
293
+ case '4x8': return 'col-md-8';
294
+ case '12x4': return 'col-12';
295
+ default: return 'col-md-4';
296
+ }
297
+ };
298
+
299
+ if (loading) {
300
+ return (
301
+ <div className="d-flex align-items-center justify-content-center p-4">
302
+ <div className="spinner-border text-primary" role="status">
303
+ <span className="visually-hidden">Loading...</span>
304
+ </div>
305
+ <span className="ms-2">Loading Zotero library...</span>
306
+ <div className="ms-3 small text-secondary">Group ID: {groupId}</div>
307
+ </div>
308
+ );
309
+ }
310
+
311
+ if (error) {
312
+ return (
313
+ <div className="alert alert-danger">
314
+ <h3 className="fw-semibold">Error loading Zotero data</h3>
315
+ <p className="small mb-1">{error}</p>
316
+ <p className="small mb-0">Group ID: {groupId}</p>
317
+ </div>
318
+ );
319
+ }
320
+
321
+ if (!data || !mapped) {
322
+ return <div className="p-3 text-secondary">No data loaded</div>;
323
+ }
324
+
325
+ // Prepare GeoJSON for map
326
+ const featuresWithGeometry = mapped.filter(f => f.geometry);
327
+ const geojson = {
328
+ type: 'FeatureCollection' as const,
329
+ features: featuresWithGeometry,
330
+ };
331
+
332
+ const totalItems = Object.values(data).reduce((sum, count) => sum + count, 0);
333
+ const geoItems = featuresWithGeometry.reduce((sum, f) => sum + (f.properties.zoteroCount || 0), 0);
334
+
335
+ return (
336
+ <div className="zotero-geo-viewer">
337
+ <div className={getLayoutClasses()}>
338
+ {/* Map */}
339
+ <div className={getMapColSpan()}>
340
+ <Map
341
+ height={mapHeight}
342
+ center={mapCenter}
343
+ baseLayers={['EsriSatellite', 'GoogleTerrain', 'Imperium']}
344
+ vectorLayers={mapCenter && geojson && geojson.features.length > 0 ? [{
345
+ name: 'Zotero Items',
346
+ source: { type: 'geojson', data: geojson },
347
+ style: {
348
+ type: 'circle',
349
+ paint: {
350
+ 'circle-radius': [
351
+ 'interpolate',
352
+ ['linear'],
353
+ ['get', 'zoteroCount'],
354
+ 0, 4,
355
+ 1, 6,
356
+ 5, 10,
357
+ 20, 16,
358
+ 50, 24
359
+ ],
360
+ 'circle-color': [
361
+ 'case',
362
+ ['>', ['get', 'zoteroCount'], 0],
363
+ '#3b82f6',
364
+ '#ef4444'
365
+ ],
366
+ 'circle-opacity': 0.8,
367
+ 'circle-stroke-width': 2,
368
+ 'circle-stroke-color': '#ffffff'
369
+ }
370
+ },
371
+ popupTemplate: `<h4>\${name}</h4><p>\${altLabel}</p><div class="text-sm mb-2">Items: \${zoteroCount}</div><button style="background-color: #1d4ed8; color: white; padding: 8px 16px; border: none; border-radius: 6px; font-size: 14px; font-weight: 600; cursor: pointer; margin-top: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.2);" onmouseover="this.style.backgroundColor='#1e40af'" onmouseout="this.style.backgroundColor='#1d4ed8'" type="button" data-tag="\${name}" data-alt-labels="\${altLabel}" onclick="window['${handlerName}'] && window['${handlerName}'](this.dataset.tag, this.dataset.altLabels)">📚 Show \${zoteroCount} records</button>`,
372
+ visible: true,
373
+ fitToContent: true
374
+ }] : []}
375
+ navigationControl="top-left"
376
+ scaleControl="bottom-left"
377
+ layerControl="top-right"
378
+ />
379
+ </div>
380
+
381
+ {/* Controls and Stats */}
382
+ <div className={getControlsColSpan()}>
383
+ <div className="mb-3">
384
+ {/* Library Statistics */}
385
+ <div className="bg-light rounded p-3 mb-3">
386
+ <h3 className="fw-semibold mb-3">Library Statistics</h3>
387
+ <div className="row g-3 small">
388
+ <div className="col-6 text-center">
389
+ <div className="h4 fw-bold text-primary">{totalItems}</div>
390
+ <div className="text-secondary">Total Items</div>
391
+ </div>
392
+ <div className="col-6 text-center">
393
+ <div className="h4 fw-bold text-success">{geoItems}</div>
394
+ <div className="text-secondary">Georeferenced</div>
395
+ </div>
396
+ </div>
397
+ </div>
398
+
399
+ {/* Tag Search */}
400
+ <div className="bg-white border rounded p-3">
401
+ <h3 className="fw-semibold mb-3">Search Tags</h3>
402
+ <div className="position-relative">
403
+ <input
404
+ type="text"
405
+ placeholder="Search for locations..."
406
+ value={searchTerm}
407
+ onChange={(e) => {
408
+ setSearchTerm(e.target.value);
409
+ setShowDropdown(true);
410
+ }}
411
+ onFocus={() => setShowDropdown(true)}
412
+ onBlur={() => setTimeout(() => setShowDropdown(false), 200)}
413
+ className="form-control form-control-sm"
414
+ />
415
+
416
+ {/* Simple autocomplete */}
417
+ {tagAutocomplete && showDropdown && filteredSuggestions.length > 0 && (
418
+ <div className="position-absolute w-100 mt-1 bg-white border rounded shadow" style={{zIndex: 1000, maxHeight: '12rem', overflowY: 'auto'}}>
419
+ {filteredSuggestions.map((tag, index) => (
420
+ <button
421
+ key={index}
422
+ onClick={() => {
423
+ setSearchTerm(tag.label);
424
+ setSelectedTag({
425
+ main: tag.label,
426
+ alternatives: tag.alts
427
+ });
428
+ setShowDropdown(false);
429
+ }}
430
+ className="w-100 px-3 py-2 text-start btn btn-sm"
431
+ >
432
+ <div className="fw-medium">{tag.label}</div>
433
+ {tag.alts.length > 0 && (
434
+ <div className="small text-secondary">
435
+ Also: {tag.alts.slice(0, 3).join(', ')}
436
+ {tag.alts.length > 3 && ` +${tag.alts.length - 3} more`}
437
+ </div>
438
+ )}
439
+ </button>
440
+ ))}
441
+ </div>
442
+ )}
443
+ </div>
444
+
445
+ {searchTerm && (
446
+ <button
447
+ onClick={() => {
448
+ setSearchTerm('');
449
+ setSelectedTag(null);
450
+ setShowDropdown(false);
451
+ }}
452
+ className="mt-2 small btn btn-link p-0"
453
+ >
454
+ Clear search
455
+ </button>
456
+ )}
457
+ </div>
458
+
459
+ {/* Selected tag info */}
460
+ {selectedTag && (
461
+ <div className="alert alert-info">
462
+ <h4 className="fw-semibold alert-heading">Selected: {selectedTag.main}</h4>
463
+ {selectedTag.alternatives.length > 0 && (
464
+ <p className="small mb-1">
465
+ Also includes: {selectedTag.alternatives.join(', ')}
466
+ </p>
467
+ )}
468
+ <p className="small mb-0">
469
+ Use the map markers to explore items at this location.
470
+ </p>
471
+ </div>
472
+ )}
473
+
474
+ {/* Zotero Records Preview */}
475
+ {selectedTag && (
476
+ <ZoteroRecordsPreview groupId={groupId} tag={selectedTag} maxItems={maxItems} />
477
+ )}
478
+ </div>
479
+ </div>
480
+ </div>
481
+ </div>
482
+ );
483
+ }