@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,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* galleryIntegration
|
|
3
|
+
*
|
|
4
|
+
* Registers the virtual module `virtual:scms/galleries`, which holds the
|
|
5
|
+
* import.meta.glob() calls the Gallery component needs to auto-load images.
|
|
6
|
+
*
|
|
7
|
+
* Vite requires import.meta.glob patterns to be static string literals in the
|
|
8
|
+
* file that calls them — a plain relative pattern shipped inside a
|
|
9
|
+
* node_modules/ package would resolve against the package's own folder, never
|
|
10
|
+
* the consumer project's. Because this module's source is generated here
|
|
11
|
+
* (not shipped verbatim as part of the component), the literal lives in code
|
|
12
|
+
* Vite parses fresh in the consumer's own build, satisfying that requirement.
|
|
13
|
+
* Verified empirically (2026-08-22): a generated virtual module's glob still
|
|
14
|
+
* goes through Astro's image pipeline (hashed src + width/height), not just
|
|
15
|
+
* raw strings — see project memory for the prototype that confirmed this.
|
|
16
|
+
*/
|
|
17
|
+
import type { AstroIntegration } from 'astro';
|
|
18
|
+
|
|
19
|
+
export const GALLERY_VIRTUAL_MODULE_ID = 'virtual:scms/galleries';
|
|
20
|
+
const RESOLVED_VIRTUAL_MODULE_ID = '\0' + GALLERY_VIRTUAL_MODULE_ID;
|
|
21
|
+
|
|
22
|
+
const IMAGE_EXTENSIONS = '{jpg,jpeg,png,gif,webp,avif,JPG,JPEG,PNG,GIF,WEBP,AVIF}';
|
|
23
|
+
|
|
24
|
+
export interface GalleryIntegrationOptions {
|
|
25
|
+
/** Path to page files, relative to the project root. Defaults to 'usr/pages'. */
|
|
26
|
+
pagesDir?: string;
|
|
27
|
+
/** Path to content collections, relative to the project root. Defaults to 'usr/content'. */
|
|
28
|
+
contentDir?: string;
|
|
29
|
+
/** Path to shared galleries, relative to the project root. Defaults to 'usr/galleries'. */
|
|
30
|
+
galleriesDir?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Normalize a directory segment into a Vite root-relative glob prefix, e.g. 'usr/pages' -> '/usr/pages'. */
|
|
34
|
+
function toRootGlobPrefix(dir: string): string {
|
|
35
|
+
return '/' + dir.replace(/^\/+|\/+$/g, '');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildGalleryVirtualModuleSource(options: GalleryIntegrationOptions = {}): string {
|
|
39
|
+
const pages = toRootGlobPrefix(options.pagesDir ?? 'usr/pages');
|
|
40
|
+
const content = toRootGlobPrefix(options.contentDir ?? 'usr/content');
|
|
41
|
+
const galleries = toRootGlobPrefix(options.galleriesDir ?? 'usr/galleries');
|
|
42
|
+
|
|
43
|
+
return `
|
|
44
|
+
export const pagesImages = import.meta.glob('${pages}/**/gallery/*.${IMAGE_EXTENSIONS}', { eager: true });
|
|
45
|
+
export const contentImages = import.meta.glob('${content}/**/gallery/*.${IMAGE_EXTENSIONS}', { eager: true });
|
|
46
|
+
export const sharedImages = import.meta.glob('${galleries}/*/*.${IMAGE_EXTENSIONS}', { eager: true });
|
|
47
|
+
export const pagesCaptions = import.meta.glob('${pages}/**/gallery/captions.json', { eager: true });
|
|
48
|
+
export const contentCaptions = import.meta.glob('${content}/**/gallery/captions.json', { eager: true });
|
|
49
|
+
export const sharedCaptions = import.meta.glob('${galleries}/*/captions.json', { eager: true });
|
|
50
|
+
`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function galleryIntegration(options: GalleryIntegrationOptions = {}): AstroIntegration {
|
|
54
|
+
const virtualModuleSource = buildGalleryVirtualModuleSource(options);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
name: 'scms-gallery',
|
|
58
|
+
hooks: {
|
|
59
|
+
'astro:config:setup': ({ updateConfig }) => {
|
|
60
|
+
updateConfig({
|
|
61
|
+
vite: {
|
|
62
|
+
plugins: [
|
|
63
|
+
{
|
|
64
|
+
name: 'scms-gallery-virtual-module',
|
|
65
|
+
resolveId(id: string) {
|
|
66
|
+
if (id === GALLERY_VIRTUAL_MODULE_ID) return RESOLVED_VIRTUAL_MODULE_ID;
|
|
67
|
+
},
|
|
68
|
+
load(id: string) {
|
|
69
|
+
if (id === RESOLVED_VIRTUAL_MODULE_ID) return virtualModuleSource;
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lad-sapienza/scms-core",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"description": "Framework components and Astro integrations for s:CMS — DataTb, Gallery, Map, ZoteroGeoViewer, Record.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "BSD-0-Clause",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/lad-sapienza/scms-core.git"
|
|
10
|
+
},
|
|
11
|
+
"author": "Julian Bogdani <julian.bogdani@uniroma1.it>",
|
|
12
|
+
"main": "./index.ts",
|
|
13
|
+
"files": [
|
|
14
|
+
"index.ts",
|
|
15
|
+
"scms.ts",
|
|
16
|
+
"components",
|
|
17
|
+
"integrations",
|
|
18
|
+
"utils",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"test:watch": "vitest",
|
|
25
|
+
"typecheck": "tsc --noEmit"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@types/react": "^19.2.14",
|
|
29
|
+
"@types/react-dom": "^19.2.3",
|
|
30
|
+
"astro": "^7.2.4",
|
|
31
|
+
"maplibre-gl": "^5.21.0",
|
|
32
|
+
"react": "^19.2.4",
|
|
33
|
+
"react-dom": "^19.2.4"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@astrojs/mdx": "^7.0.7",
|
|
37
|
+
"@astrojs/react": "^6.0.4",
|
|
38
|
+
"@astrojs/sitemap": "^3.7.3",
|
|
39
|
+
"@directus/sdk": "^21.2.0",
|
|
40
|
+
"@expressive-code/plugin-line-numbers": "^0.44.1",
|
|
41
|
+
"@tanstack/react-table": "^8.21.3",
|
|
42
|
+
"@types/geojson": "^7946.0.16",
|
|
43
|
+
"@types/papaparse": "^5.5.2",
|
|
44
|
+
"@vis.gl/react-maplibre": "^8.1.0",
|
|
45
|
+
"astro-expressive-code": "^0.44.1",
|
|
46
|
+
"lucide-react": "^1.8.0",
|
|
47
|
+
"papaparse": "^5.5.3",
|
|
48
|
+
"photoswipe": "^5.4.4",
|
|
49
|
+
"react-photoswipe-gallery": "^4.0.0",
|
|
50
|
+
"zod": "^4.3.6"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"typescript": "^5.9.3",
|
|
54
|
+
"vitest": "^4.1.11"
|
|
55
|
+
}
|
|
56
|
+
}
|
package/scms.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scms
|
|
3
|
+
*
|
|
4
|
+
* Bundles every integration sCMS's components need, plus the Vite config
|
|
5
|
+
* (React/@tanstack dedupe, optimizeDeps) they depend on, into one function.
|
|
6
|
+
*
|
|
7
|
+
* Why the dedupe/optimizeDeps live here rather than in the consumer's own
|
|
8
|
+
* astro.config.mjs: confirmed empirically (2026-08-22, npm-packaging
|
|
9
|
+
* prototype — see project memory) that without an explicit Vite `dedupe`
|
|
10
|
+
* entry, React and @tanstack/react-table can resolve to two different
|
|
11
|
+
* physical module copies in the same render tree, crashing with "Invalid
|
|
12
|
+
* hook call" in dev mode specifically (a production build can look fine and
|
|
13
|
+
* still be broken in dev). Registering it from the integration itself means
|
|
14
|
+
* every consumer gets the fix automatically, instead of needing to
|
|
15
|
+
* hand-configure Vite themselves.
|
|
16
|
+
*
|
|
17
|
+
* This file has no path-alias dependency, by design — it's consumed
|
|
18
|
+
* directly as the `@lad-sapienza/scms-core` npm package (`import { scms }
|
|
19
|
+
* from '@lad-sapienza/scms-core'`), not via a path alias into a sibling
|
|
20
|
+
* folder. Any `@user`/`@components`/etc. aliases a consuming site defines
|
|
21
|
+
* for its own content are that site's own astro.config.mjs concern.
|
|
22
|
+
*/
|
|
23
|
+
import type { AstroIntegration } from 'astro';
|
|
24
|
+
import mdx from '@astrojs/mdx';
|
|
25
|
+
import react from '@astrojs/react';
|
|
26
|
+
import sitemap from '@astrojs/sitemap';
|
|
27
|
+
import expressiveCode from 'astro-expressive-code';
|
|
28
|
+
import { pluginLineNumbers } from '@expressive-code/plugin-line-numbers';
|
|
29
|
+
import { contentAssetsIntegration } from './integrations/contentAssetsIntegration';
|
|
30
|
+
import { galleryIntegration } from './integrations/galleryIntegration';
|
|
31
|
+
|
|
32
|
+
export interface ScmsOptions {
|
|
33
|
+
/** Path to the content directory, relative to the project root. Defaults to 'usr/content'. */
|
|
34
|
+
contentDir?: string;
|
|
35
|
+
/** Path to page files, relative to the project root. Defaults to 'usr/pages'. */
|
|
36
|
+
pagesDir?: string;
|
|
37
|
+
/** Path to shared galleries, relative to the project root. Defaults to 'usr/galleries'. */
|
|
38
|
+
galleriesDir?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** react, react-dom, and any library that calls hooks internally (@tanstack/react-table) — see file doc comment. */
|
|
42
|
+
const HOOK_LIBRARIES = ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime', 'react/jsx-dev-runtime', 'scheduler', '@tanstack/react-table'];
|
|
43
|
+
|
|
44
|
+
function scmsViteConfigIntegration(): AstroIntegration {
|
|
45
|
+
return {
|
|
46
|
+
name: 'scms-vite-config',
|
|
47
|
+
hooks: {
|
|
48
|
+
'astro:config:setup': ({ updateConfig }) => {
|
|
49
|
+
updateConfig({
|
|
50
|
+
vite: {
|
|
51
|
+
resolve: {
|
|
52
|
+
dedupe: HOOK_LIBRARIES,
|
|
53
|
+
},
|
|
54
|
+
optimizeDeps: {
|
|
55
|
+
// papaparse is CommonJS-only (no `module`/`exports` field), and
|
|
56
|
+
// maplibre-gl's `dist/maplibre-gl.js` is a UMD bundle despite
|
|
57
|
+
// declaring `"type": "module"` in its own package.json (verified:
|
|
58
|
+
// it's a `(function(global, factory) {...})` UMD wrapper with zero
|
|
59
|
+
// `export` statements). When their importers (CsvSource.tsx,
|
|
60
|
+
// Map.tsx via @vis.gl/react-maplibre's dynamic `import('maplibre-gl')`)
|
|
61
|
+
// lived inside this project's own source tree, Vite's dev-time
|
|
62
|
+
// dependency scanner discovered and pre-bundled them automatically,
|
|
63
|
+
// performing the CJS/UMD→ESM interop conversion. Now that the
|
|
64
|
+
// importers live in node_modules (this package), Vite's default
|
|
65
|
+
// scanning no longer picks them up on its own, and the browser gets
|
|
66
|
+
// served the raw file as if it already were ESM — resulting in
|
|
67
|
+
// "does not provide an export named 'default'" for papaparse and
|
|
68
|
+
// an empty module namespace (silently breaking `'Map' in module`)
|
|
69
|
+
// for maplibre-gl. Both must be pre-bundled explicitly.
|
|
70
|
+
include: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime', 'react/jsx-dev-runtime', 'papaparse', 'maplibre-gl'],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function scms(options: ScmsOptions = {}): AstroIntegration[] {
|
|
80
|
+
const { contentDir, pagesDir, galleriesDir } = options;
|
|
81
|
+
|
|
82
|
+
return [
|
|
83
|
+
contentAssetsIntegration({ contentDir }),
|
|
84
|
+
galleryIntegration({ pagesDir, contentDir, galleriesDir }),
|
|
85
|
+
expressiveCode({
|
|
86
|
+
themes: ['github-dark'],
|
|
87
|
+
plugins: [pluginLineNumbers()],
|
|
88
|
+
defaultProps: { showLineNumbers: true },
|
|
89
|
+
}),
|
|
90
|
+
mdx(),
|
|
91
|
+
react(),
|
|
92
|
+
sitemap(),
|
|
93
|
+
scmsViteConfigIntegration(),
|
|
94
|
+
];
|
|
95
|
+
}
|
package/utils/content.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content Utility Functions
|
|
3
|
+
* Helper functions for working with Astro Content Collections
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getCollection } from 'astro:content';
|
|
7
|
+
import Papa from 'papaparse';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Get all entries from a collection sorted by a field (agnostic to collection name)
|
|
11
|
+
*/
|
|
12
|
+
export async function getSortedCollection(
|
|
13
|
+
collectionName: string,
|
|
14
|
+
sortField: string = 'date',
|
|
15
|
+
order: 'asc' | 'desc' = 'desc'
|
|
16
|
+
) {
|
|
17
|
+
const entries = await getCollection(collectionName as any);
|
|
18
|
+
return entries.sort((a: any, b: any) => {
|
|
19
|
+
// Type guard: ensure a and b are objects with a 'data' property
|
|
20
|
+
if (
|
|
21
|
+
typeof a !== 'object' || a === null || !('data' in a) ||
|
|
22
|
+
typeof b !== 'object' || b === null || !('data' in b)
|
|
23
|
+
) {
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
const aData = (a as { data: Record<string, any> }).data;
|
|
27
|
+
const bData = (b as { data: Record<string, any> }).data;
|
|
28
|
+
const aValue = aData[sortField];
|
|
29
|
+
const bValue = bData[sortField];
|
|
30
|
+
if (aValue === undefined || bValue === undefined) return 0;
|
|
31
|
+
// Date comparison
|
|
32
|
+
if (
|
|
33
|
+
Object.prototype.toString.call(aValue) === '[object Date]' &&
|
|
34
|
+
Object.prototype.toString.call(bValue) === '[object Date]'
|
|
35
|
+
) {
|
|
36
|
+
const aTime = (aValue as unknown as Date).getTime();
|
|
37
|
+
const bTime = (bValue as unknown as Date).getTime();
|
|
38
|
+
return order === 'desc' ? bTime - aTime : aTime - bTime;
|
|
39
|
+
}
|
|
40
|
+
// Number comparison
|
|
41
|
+
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
|
42
|
+
return order === 'desc' ? bValue - aValue : aValue - bValue;
|
|
43
|
+
}
|
|
44
|
+
// String comparison
|
|
45
|
+
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
|
46
|
+
return order === 'desc' ? bValue.localeCompare(aValue) : aValue.localeCompare(bValue);
|
|
47
|
+
}
|
|
48
|
+
return 0;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Menu extraction logic should be implemented in user code (usr/), not in core.
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse CSV string to JSON
|
|
56
|
+
*/
|
|
57
|
+
export async function parseCSV(csvText: string): Promise<any[]> {
|
|
58
|
+
const { data } = Papa.parse(csvText, {
|
|
59
|
+
header: true,
|
|
60
|
+
skipEmptyLines: true,
|
|
61
|
+
dynamicTyping: true,
|
|
62
|
+
});
|
|
63
|
+
return data as any[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Fetch and parse remote data
|
|
68
|
+
*/
|
|
69
|
+
export async function fetchRemoteData(url: string): Promise<any> {
|
|
70
|
+
const response = await fetch(url);
|
|
71
|
+
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
throw new Error(`Failed to fetch ${url}: ${response.statusText}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const contentType = response.headers.get('content-type');
|
|
77
|
+
|
|
78
|
+
if (contentType?.includes('application/json')) {
|
|
79
|
+
return await response.json();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (contentType?.includes('text/csv') || url.endsWith('.csv')) {
|
|
83
|
+
const text = await response.text();
|
|
84
|
+
return await parseCSV(text);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return await response.text();
|
|
88
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import Papa from 'papaparse';
|
|
2
|
+
import { createDirectus, rest, staticToken, readItems, readItem } from '@directus/sdk';
|
|
3
|
+
import type { SourceConfig } from '../components/DataTb/types';
|
|
4
|
+
|
|
5
|
+
export type { SourceConfig } from '../components/DataTb/types';
|
|
6
|
+
export type DataRow = Record<string, any>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Fetches data from a configured source
|
|
10
|
+
*/
|
|
11
|
+
export async function fetchData(source: SourceConfig): Promise<DataRow[]> {
|
|
12
|
+
let fetchedData: DataRow[] = [];
|
|
13
|
+
|
|
14
|
+
switch (source.type) {
|
|
15
|
+
case 'csv':
|
|
16
|
+
await new Promise<void>((resolve, reject) => {
|
|
17
|
+
Papa.parse(source.url, {
|
|
18
|
+
download: true,
|
|
19
|
+
header: true,
|
|
20
|
+
delimiter: source.delimiter || ',',
|
|
21
|
+
skipEmptyLines: true,
|
|
22
|
+
dynamicTyping: true,
|
|
23
|
+
complete: (results) => {
|
|
24
|
+
fetchedData = source.skipRows && source.skipRows > 0
|
|
25
|
+
? (results.data as DataRow[]).slice(source.skipRows)
|
|
26
|
+
: (results.data as DataRow[]);
|
|
27
|
+
resolve();
|
|
28
|
+
},
|
|
29
|
+
error: (error) => {
|
|
30
|
+
reject(error);
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
break;
|
|
35
|
+
|
|
36
|
+
case 'json':
|
|
37
|
+
if (source.data) {
|
|
38
|
+
fetchedData = source.data;
|
|
39
|
+
} else if (source.url) {
|
|
40
|
+
const response = await fetch(source.url);
|
|
41
|
+
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
|
42
|
+
const jsonData = await response.json();
|
|
43
|
+
fetchedData = Array.isArray(jsonData) ? jsonData : [jsonData];
|
|
44
|
+
}
|
|
45
|
+
break;
|
|
46
|
+
|
|
47
|
+
case 'directus':
|
|
48
|
+
if (!source.config) throw new Error('Directus configuration required');
|
|
49
|
+
const client = createDirectus(source.config.url)
|
|
50
|
+
.with(staticToken(source.config.token))
|
|
51
|
+
.with(rest());
|
|
52
|
+
|
|
53
|
+
// Handle single item request vs collection query
|
|
54
|
+
if (source.itemId !== undefined) {
|
|
55
|
+
// Fetch single item by ID (more efficient)
|
|
56
|
+
const queryOptions: any = {};
|
|
57
|
+
if (source.fields) queryOptions.fields = source.fields;
|
|
58
|
+
|
|
59
|
+
const item = await client.request(readItem(source.collection, source.itemId, queryOptions));
|
|
60
|
+
fetchedData = item ? [item] : [];
|
|
61
|
+
} else {
|
|
62
|
+
// Query collection with filters
|
|
63
|
+
const queryOptions: any = {};
|
|
64
|
+
if (source.filter) queryOptions.filter = source.filter;
|
|
65
|
+
if (source.fields) queryOptions.fields = source.fields;
|
|
66
|
+
if (source.sort) queryOptions.sort = source.sort;
|
|
67
|
+
if (source.limit) queryOptions.limit = source.limit;
|
|
68
|
+
|
|
69
|
+
const items = await client.request(readItems(source.collection, queryOptions));
|
|
70
|
+
fetchedData = items as any[];
|
|
71
|
+
}
|
|
72
|
+
break;
|
|
73
|
+
|
|
74
|
+
case 'geojson':
|
|
75
|
+
if (source.data) {
|
|
76
|
+
// Direct GeoJSON object - extract features as data rows
|
|
77
|
+
const geojsonData = source.data;
|
|
78
|
+
fetchedData = geojsonData.features ? geojsonData.features.map((f: any) => ({
|
|
79
|
+
...f.properties,
|
|
80
|
+
_geometry: f.geometry,
|
|
81
|
+
_id: f.id
|
|
82
|
+
})) : [];
|
|
83
|
+
} else if (source.url) {
|
|
84
|
+
const geoResponse = await fetch(source.url);
|
|
85
|
+
if (!geoResponse.ok) throw new Error(`HTTP error! status: ${geoResponse.status}`);
|
|
86
|
+
const geojsonData = await geoResponse.json();
|
|
87
|
+
fetchedData = geojsonData.features ? geojsonData.features.map((f: any) => ({
|
|
88
|
+
...f.properties,
|
|
89
|
+
_geometry: f.geometry,
|
|
90
|
+
_id: f.id
|
|
91
|
+
})) : [];
|
|
92
|
+
}
|
|
93
|
+
break;
|
|
94
|
+
|
|
95
|
+
case 'api':
|
|
96
|
+
const apiOptions: RequestInit = {
|
|
97
|
+
method: source.method || 'GET',
|
|
98
|
+
headers: {
|
|
99
|
+
'Content-Type': 'application/json',
|
|
100
|
+
...source.headers,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
if (source.body && source.method === 'POST') {
|
|
104
|
+
apiOptions.body = JSON.stringify(source.body);
|
|
105
|
+
}
|
|
106
|
+
const apiResponse = await fetch(source.url, apiOptions);
|
|
107
|
+
if (!apiResponse.ok) throw new Error(`HTTP error! status: ${apiResponse.status}`);
|
|
108
|
+
const apiData = await apiResponse.json();
|
|
109
|
+
|
|
110
|
+
if (source.transformer) {
|
|
111
|
+
fetchedData = source.transformer(apiData);
|
|
112
|
+
} else if (Array.isArray(apiData)) {
|
|
113
|
+
fetchedData = apiData;
|
|
114
|
+
} else if (apiData.data && Array.isArray(apiData.data)) {
|
|
115
|
+
fetchedData = apiData.data;
|
|
116
|
+
} else {
|
|
117
|
+
fetchedData = [apiData];
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
|
|
121
|
+
default:
|
|
122
|
+
// e.g. 'vector' (MapLibre vector tile sources) — these are rendered
|
|
123
|
+
// directly by MapLibre from tiles, not fetched/converted to rows here.
|
|
124
|
+
throw new Error(`fetchData: unsupported source type "${(source as SourceConfig).type}"`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return fetchedData;
|
|
128
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Directus Configuration Utilities
|
|
3
|
+
*
|
|
4
|
+
* Provides consistent Directus connection handling across components
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Simple Directus configuration interface
|
|
9
|
+
* Used by Map and DataTb components for a consistent API
|
|
10
|
+
*/
|
|
11
|
+
export interface DirectusShorthand {
|
|
12
|
+
/** Table/collection name in Directus */
|
|
13
|
+
table: string;
|
|
14
|
+
|
|
15
|
+
/** Directus query string (e.g., "filter[status][_eq]=published&limit=10") */
|
|
16
|
+
queryString?: string;
|
|
17
|
+
|
|
18
|
+
/** Optional: Override Directus URL (defaults to PUBLIC_DIRECTUS_URL env var) */
|
|
19
|
+
url?: string;
|
|
20
|
+
|
|
21
|
+
/** Optional: Override Directus token (defaults to PUBLIC_DIRECTUS_TOKEN env var) */
|
|
22
|
+
token?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Full Directus source configuration (internal format used by data-fetcher)
|
|
27
|
+
*/
|
|
28
|
+
export interface DirectusSourceConfig {
|
|
29
|
+
type: 'directus';
|
|
30
|
+
collection: string;
|
|
31
|
+
config: {
|
|
32
|
+
url: string;
|
|
33
|
+
token: string;
|
|
34
|
+
};
|
|
35
|
+
/** Optional: Fetch single item by ID instead of querying collection */
|
|
36
|
+
itemId?: string | number;
|
|
37
|
+
filter?: Record<string, any>;
|
|
38
|
+
fields?: string[];
|
|
39
|
+
sort?: string[];
|
|
40
|
+
limit?: number;
|
|
41
|
+
offset?: number;
|
|
42
|
+
/** GeoJSON field name (for map conversion) - Directus stores GeoJSON as JSON field */
|
|
43
|
+
geoField?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse Directus query string into filter object and other parameters
|
|
48
|
+
* Supports Directus API query syntax: filter[field][operator]=value
|
|
49
|
+
*/
|
|
50
|
+
export function parseDirectusQueryString(queryString: string): {
|
|
51
|
+
filter?: Record<string, any>;
|
|
52
|
+
fields?: string[];
|
|
53
|
+
sort?: string[];
|
|
54
|
+
limit?: number;
|
|
55
|
+
offset?: number;
|
|
56
|
+
} {
|
|
57
|
+
const params = new URLSearchParams(queryString);
|
|
58
|
+
const filter: any = {};
|
|
59
|
+
const fields: string[] = [];
|
|
60
|
+
const sort: string[] = [];
|
|
61
|
+
let limit: number | undefined;
|
|
62
|
+
let offset: number | undefined;
|
|
63
|
+
|
|
64
|
+
params.forEach((value, key) => {
|
|
65
|
+
// Parse filter syntax: filter[field][operator]=value
|
|
66
|
+
const filterMatch = key.match(/^filter\[([^\]]+)\]\[([^\]]+)\]$/);
|
|
67
|
+
if (filterMatch) {
|
|
68
|
+
const [, field, operator] = filterMatch;
|
|
69
|
+
if (!filter[field]) filter[field] = {};
|
|
70
|
+
// Convert string booleans to actual booleans
|
|
71
|
+
filter[field][operator] = value === 'true' ? true : value === 'false' ? false : value;
|
|
72
|
+
}
|
|
73
|
+
// Parse fields: fields[]=field1&fields[]=field2 or fields=field1,field2
|
|
74
|
+
else if (key === 'fields[]' || key === 'fields') {
|
|
75
|
+
if (value.includes(',')) {
|
|
76
|
+
fields.push(...value.split(',').map(f => f.trim()));
|
|
77
|
+
} else {
|
|
78
|
+
fields.push(value);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Parse sort: sort[]=field1&sort[]=-field2 or sort=field1,-field2
|
|
82
|
+
else if (key === 'sort[]' || key === 'sort') {
|
|
83
|
+
if (value.includes(',')) {
|
|
84
|
+
sort.push(...value.split(',').map(s => s.trim()));
|
|
85
|
+
} else {
|
|
86
|
+
sort.push(value);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Parse limit and offset
|
|
90
|
+
else if (key === 'limit') {
|
|
91
|
+
limit = parseInt(value, 10);
|
|
92
|
+
} else if (key === 'offset') {
|
|
93
|
+
offset = parseInt(value, 10);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const result: any = {};
|
|
98
|
+
if (Object.keys(filter).length > 0) result.filter = filter;
|
|
99
|
+
if (fields.length > 0) result.fields = fields;
|
|
100
|
+
if (sort.length > 0) result.sort = sort;
|
|
101
|
+
if (limit !== undefined) result.limit = limit;
|
|
102
|
+
if (offset !== undefined) result.offset = offset;
|
|
103
|
+
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Convert simplified Directus shorthand to full source config
|
|
109
|
+
* Handles environment variables and query string parsing
|
|
110
|
+
*/
|
|
111
|
+
export function directusShorthandToConfig(directus: DirectusShorthand): DirectusSourceConfig | null {
|
|
112
|
+
const directusUrl = directus.url || import.meta.env.PUBLIC_DIRECTUS_URL;
|
|
113
|
+
const directusToken = directus.token || import.meta.env.PUBLIC_DIRECTUS_TOKEN;
|
|
114
|
+
|
|
115
|
+
if (!directusUrl) {
|
|
116
|
+
console.error('Directus URL is not configured. Please provide it via the directus.url prop or set PUBLIC_DIRECTUS_URL environment variable.');
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const sourceConfig: DirectusSourceConfig = {
|
|
121
|
+
type: 'directus',
|
|
122
|
+
collection: directus.table,
|
|
123
|
+
config: {
|
|
124
|
+
url: directusUrl,
|
|
125
|
+
token: directusToken || ''
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// Parse query string if provided
|
|
130
|
+
if (directus.queryString) {
|
|
131
|
+
const parsed = parseDirectusQueryString(directus.queryString);
|
|
132
|
+
Object.assign(sourceConfig, parsed);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Default to no limit if not specified
|
|
136
|
+
if (sourceConfig.limit === undefined) {
|
|
137
|
+
sourceConfig.limit = -1;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return sourceConfig;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get Directus connection config from environment or props
|
|
145
|
+
* Returns null if configuration is incomplete
|
|
146
|
+
*/
|
|
147
|
+
export function getDirectusConfig(url?: string, token?: string): { url: string; token: string } | null {
|
|
148
|
+
const directusUrl = url || import.meta.env.PUBLIC_DIRECTUS_URL;
|
|
149
|
+
const directusToken = token || import.meta.env.PUBLIC_DIRECTUS_TOKEN;
|
|
150
|
+
|
|
151
|
+
if (!directusUrl) {
|
|
152
|
+
console.error('Directus URL is not configured. Set PUBLIC_DIRECTUS_URL environment variable or pass url prop.');
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
url: directusUrl,
|
|
158
|
+
token: directusToken || ''
|
|
159
|
+
};
|
|
160
|
+
}
|