@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,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
|
+
}
|