@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,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataTb Utility Functions
|
|
3
|
+
*
|
|
4
|
+
* Helper functions for data transformation, column generation, and formatting
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ColumnDef } from '@tanstack/react-table';
|
|
8
|
+
import type { DataRow, ColumnConfig } from './types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Auto-detect columns from data
|
|
12
|
+
*/
|
|
13
|
+
export function autoDetectColumns(data: DataRow[]): ColumnConfig[] {
|
|
14
|
+
if (!data || data.length === 0) return [];
|
|
15
|
+
|
|
16
|
+
const firstRow = data[0];
|
|
17
|
+
const keys = Object.keys(firstRow);
|
|
18
|
+
|
|
19
|
+
return keys.map(key => ({
|
|
20
|
+
key,
|
|
21
|
+
header: formatHeader(key),
|
|
22
|
+
sortable: true,
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Convert snake_case or camelCase to Title Case
|
|
28
|
+
*/
|
|
29
|
+
export function formatHeader(key: string): string {
|
|
30
|
+
return key
|
|
31
|
+
.replace(/([A-Z])/g, ' $1') // camelCase to words
|
|
32
|
+
.replace(/_/g, ' ') // snake_case to words
|
|
33
|
+
.replace(/\b\w/g, (l) => l.toUpperCase()) // capitalize first letter
|
|
34
|
+
.trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Format value based on format type
|
|
39
|
+
*/
|
|
40
|
+
export function formatValue(value: any, format?: string): string {
|
|
41
|
+
if (value === null || value === undefined) return '';
|
|
42
|
+
|
|
43
|
+
switch (format) {
|
|
44
|
+
case 'date':
|
|
45
|
+
return formatDate(value);
|
|
46
|
+
case 'number':
|
|
47
|
+
return formatNumber(value);
|
|
48
|
+
case 'currency':
|
|
49
|
+
return formatCurrency(value);
|
|
50
|
+
case 'percent':
|
|
51
|
+
return formatPercent(value);
|
|
52
|
+
default:
|
|
53
|
+
return String(value);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Format date value
|
|
59
|
+
*/
|
|
60
|
+
export function formatDate(value: any): string {
|
|
61
|
+
try {
|
|
62
|
+
if (!value) return '';
|
|
63
|
+
const date = new Date(value);
|
|
64
|
+
if (isNaN(date.getTime())) return String(value);
|
|
65
|
+
|
|
66
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
67
|
+
year: 'numeric',
|
|
68
|
+
month: 'short',
|
|
69
|
+
day: 'numeric',
|
|
70
|
+
}).format(date);
|
|
71
|
+
} catch {
|
|
72
|
+
return String(value);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Format number value
|
|
78
|
+
*/
|
|
79
|
+
export function formatNumber(value: any): string {
|
|
80
|
+
const num = Number(value);
|
|
81
|
+
if (isNaN(num)) return String(value);
|
|
82
|
+
|
|
83
|
+
return new Intl.NumberFormat('en-US').format(num);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Format currency value (USD by default)
|
|
88
|
+
*/
|
|
89
|
+
export function formatCurrency(value: any, currency: string = 'USD'): string {
|
|
90
|
+
const num = Number(value);
|
|
91
|
+
if (isNaN(num)) return String(value);
|
|
92
|
+
|
|
93
|
+
return new Intl.NumberFormat('en-US', {
|
|
94
|
+
style: 'currency',
|
|
95
|
+
currency,
|
|
96
|
+
}).format(num);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Format percentage value
|
|
101
|
+
*/
|
|
102
|
+
export function formatPercent(value: any): string {
|
|
103
|
+
const num = Number(value);
|
|
104
|
+
if (isNaN(num)) return String(value);
|
|
105
|
+
|
|
106
|
+
return new Intl.NumberFormat('en-US', {
|
|
107
|
+
style: 'percent',
|
|
108
|
+
minimumFractionDigits: 0,
|
|
109
|
+
maximumFractionDigits: 2,
|
|
110
|
+
}).format(num);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Convert ColumnConfig to TanStack ColumnDef
|
|
115
|
+
*/
|
|
116
|
+
export function columnConfigToColumnDef(config: ColumnConfig): ColumnDef<DataRow> {
|
|
117
|
+
return {
|
|
118
|
+
id: config.key,
|
|
119
|
+
accessorKey: config.key,
|
|
120
|
+
header: config.header,
|
|
121
|
+
enableSorting: config.sortable ?? true,
|
|
122
|
+
meta: { width: config.width },
|
|
123
|
+
cell: (info) => {
|
|
124
|
+
const value = info.getValue();
|
|
125
|
+
const row = info.row.original;
|
|
126
|
+
|
|
127
|
+
// Use custom render if provided
|
|
128
|
+
if (config.render) {
|
|
129
|
+
return config.render(value, row);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Use labels map if provided
|
|
133
|
+
if (config.labels) {
|
|
134
|
+
const key = String(value);
|
|
135
|
+
if (config.labels[key]) {
|
|
136
|
+
return config.labels[key];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Apply format if specified
|
|
141
|
+
return formatValue(value, config.format);
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Merge auto-detected columns with user-provided overrides
|
|
148
|
+
*/
|
|
149
|
+
export function mergeColumns(
|
|
150
|
+
autoColumns: ColumnConfig[],
|
|
151
|
+
userColumns?: ColumnConfig[]
|
|
152
|
+
): ColumnConfig[] {
|
|
153
|
+
// If no user columns, use all auto-detected columns
|
|
154
|
+
if (!userColumns || userColumns.length === 0) {
|
|
155
|
+
return autoColumns;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Exclusive Mode:
|
|
159
|
+
// Return ONLY user columns, but enrich them with auto-detected defaults (like headers) if available
|
|
160
|
+
return userColumns.map(userCol => {
|
|
161
|
+
const autoCol = autoColumns.find(ac => ac.key === userCol.key);
|
|
162
|
+
// If we found a match, merge defaults from autoCol, but let userCol override
|
|
163
|
+
if (autoCol) {
|
|
164
|
+
return { ...autoCol, ...userCol };
|
|
165
|
+
}
|
|
166
|
+
// If no match (computed column or non-existent key), return userCol as is
|
|
167
|
+
return userCol;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Global filter function for searchable tables
|
|
173
|
+
*/
|
|
174
|
+
export function globalFilterFn(
|
|
175
|
+
row: any,
|
|
176
|
+
columnId: string,
|
|
177
|
+
filterValue: string
|
|
178
|
+
): boolean {
|
|
179
|
+
const value = row.getValue(columnId);
|
|
180
|
+
|
|
181
|
+
if (value === null || value === undefined) return false;
|
|
182
|
+
|
|
183
|
+
return String(value)
|
|
184
|
+
.toLowerCase()
|
|
185
|
+
.includes(String(filterValue).toLowerCase());
|
|
186
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Gallery Component
|
|
4
|
+
*
|
|
5
|
+
* Renders a responsive image grid with a PhotoSwipe lightbox. Usable from
|
|
6
|
+
* both .astro files and MDX content bodies (see core/integrations/galleryIntegration.ts
|
|
7
|
+
* for how auto-loading works without each caller needing its own glob).
|
|
8
|
+
*
|
|
9
|
+
* <Gallery /> colocated: ./gallery/ next to this page/content file
|
|
10
|
+
* <Gallery name="scavi-2024" /> shared: usr/galleries/scavi-2024/
|
|
11
|
+
* <Gallery images={[...]} /> explicit: pass images directly (remote, custom order, ...)
|
|
12
|
+
*/
|
|
13
|
+
import { Gallery as GalleryClient } from './Gallery';
|
|
14
|
+
import { getColocatedGalleryImages, getSharedGalleryImages, sortImages } from './galleryUtils';
|
|
15
|
+
import type { GalleryProps } from './types';
|
|
16
|
+
|
|
17
|
+
const { name, images: explicitImages, reverseSorting = false, columns = { min: 200, max: 1 }, className } = Astro.props as GalleryProps;
|
|
18
|
+
|
|
19
|
+
// Auto-loaded images (colocated/shared) are always sorted by filename —
|
|
20
|
+
// DESC by default, ASC when reverseSorting is set. Explicit `images` are
|
|
21
|
+
// left in exactly the order the caller gave them, since a custom order is
|
|
22
|
+
// one of the reasons to use that escape hatch in the first place.
|
|
23
|
+
let finalImages;
|
|
24
|
+
|
|
25
|
+
if (explicitImages && explicitImages.length > 0) {
|
|
26
|
+
finalImages = explicitImages;
|
|
27
|
+
} else if (name) {
|
|
28
|
+
finalImages = sortImages(getSharedGalleryImages(name), reverseSorting);
|
|
29
|
+
} else {
|
|
30
|
+
finalImages = sortImages(getColocatedGalleryImages(Astro.url.pathname), reverseSorting);
|
|
31
|
+
}
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
{finalImages.length > 0 ? (
|
|
35
|
+
<GalleryClient
|
|
36
|
+
images={finalImages}
|
|
37
|
+
columns={columns}
|
|
38
|
+
className={className}
|
|
39
|
+
client:idle
|
|
40
|
+
/>
|
|
41
|
+
) : (
|
|
42
|
+
<p class="gallery-empty">
|
|
43
|
+
No images found{name && <> for gallery "<code>{name}</code>"</>}.
|
|
44
|
+
{!name && !explicitImages && (
|
|
45
|
+
<> Add images to a <code>gallery/</code> folder next to this page.</>
|
|
46
|
+
)}
|
|
47
|
+
</p>
|
|
48
|
+
)}
|
|
49
|
+
|
|
50
|
+
<style>
|
|
51
|
+
.gallery-empty {
|
|
52
|
+
padding: 2rem;
|
|
53
|
+
background: #f5f5f5;
|
|
54
|
+
border-radius: 8px;
|
|
55
|
+
text-align: center;
|
|
56
|
+
color: #666;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.gallery-empty code {
|
|
60
|
+
background: #e0e0e0;
|
|
61
|
+
padding: 2px 6px;
|
|
62
|
+
border-radius: 3px;
|
|
63
|
+
font-family: monospace;
|
|
64
|
+
}
|
|
65
|
+
</style>
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gallery Component
|
|
3
|
+
*
|
|
4
|
+
* React component for displaying image gallery with PhotoSwipe lightbox
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
import { Gallery as PhotoSwipeGallery, Item } from 'react-photoswipe-gallery';
|
|
9
|
+
import type { GalleryImage } from './types';
|
|
10
|
+
import 'photoswipe/dist/photoswipe.css';
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
images?: GalleryImage[];
|
|
14
|
+
columns?: {
|
|
15
|
+
min?: number;
|
|
16
|
+
max?: number;
|
|
17
|
+
};
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const captionStyle = `
|
|
22
|
+
.pswp__custom-caption {
|
|
23
|
+
background: rgba(0, 0, 0, 0.75);
|
|
24
|
+
font-size: 16px;
|
|
25
|
+
color: #fff;
|
|
26
|
+
width: 100%;
|
|
27
|
+
max-width: 100%;
|
|
28
|
+
padding: 12px 16px;
|
|
29
|
+
border-radius: 0;
|
|
30
|
+
position: absolute;
|
|
31
|
+
left: 0;
|
|
32
|
+
bottom: 0;
|
|
33
|
+
text-align: center;
|
|
34
|
+
}
|
|
35
|
+
.pswp__custom-caption a {
|
|
36
|
+
color: #fff;
|
|
37
|
+
text-decoration: underline;
|
|
38
|
+
}
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
export function Gallery({
|
|
42
|
+
images = [],
|
|
43
|
+
columns = { min: 200, max: 1 },
|
|
44
|
+
className = '',
|
|
45
|
+
}: Props) {
|
|
46
|
+
if (images.length === 0) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Ensure defaults for columns
|
|
51
|
+
const minWidth = columns?.min ?? 200;
|
|
52
|
+
const maxWidth = columns?.max ?? 1;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<>
|
|
56
|
+
<style>{captionStyle}</style>
|
|
57
|
+
<PhotoSwipeGallery
|
|
58
|
+
options={{
|
|
59
|
+
showHideAnimationType: 'fade',
|
|
60
|
+
showAnimationDuration: 0,
|
|
61
|
+
hideAnimationDuration: 0,
|
|
62
|
+
}}
|
|
63
|
+
uiElements={[
|
|
64
|
+
{
|
|
65
|
+
name: 'custom-caption',
|
|
66
|
+
order: 9,
|
|
67
|
+
isButton: false,
|
|
68
|
+
appendTo: 'root',
|
|
69
|
+
html: '',
|
|
70
|
+
onInit: (el: any, pswpInstance: any) => {
|
|
71
|
+
el.classList.add('pswp__custom-caption');
|
|
72
|
+
|
|
73
|
+
pswpInstance.on('change', () => {
|
|
74
|
+
const currSlideElement = pswpInstance.currSlide?.data?.element;
|
|
75
|
+
// Captions are plain text (from captions.json or a filename) — use
|
|
76
|
+
// textContent, not innerHTML, so caption text can never be parsed as markup.
|
|
77
|
+
el.textContent = currSlideElement?.getAttribute('data-caption') || '';
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
]}
|
|
82
|
+
>
|
|
83
|
+
<div
|
|
84
|
+
className={`gallery-grid ${className}`}
|
|
85
|
+
style={{
|
|
86
|
+
display: 'grid',
|
|
87
|
+
gridTemplateColumns: `repeat(auto-fill, minmax(${minWidth}px, ${maxWidth}fr))`,
|
|
88
|
+
gap: '8px',
|
|
89
|
+
padding: 0,
|
|
90
|
+
}}
|
|
91
|
+
>
|
|
92
|
+
{images.map((image, index) => (
|
|
93
|
+
<Item
|
|
94
|
+
key={index}
|
|
95
|
+
original={image.src}
|
|
96
|
+
thumbnail={image.thumb}
|
|
97
|
+
width={image.width}
|
|
98
|
+
height={image.height}
|
|
99
|
+
caption={image.caption || image.alt}
|
|
100
|
+
>
|
|
101
|
+
{({ ref, open }: any) => (
|
|
102
|
+
<div
|
|
103
|
+
ref={ref}
|
|
104
|
+
onClick={open}
|
|
105
|
+
onKeyDown={(e) => {
|
|
106
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
107
|
+
e.preventDefault();
|
|
108
|
+
open();
|
|
109
|
+
}
|
|
110
|
+
}}
|
|
111
|
+
role="button"
|
|
112
|
+
tabIndex={0}
|
|
113
|
+
style={{ cursor: 'pointer', overflow: 'hidden', borderRadius: '4px' }}
|
|
114
|
+
data-caption={image.caption || image.alt}
|
|
115
|
+
aria-label={`Open image: ${image.alt}`}
|
|
116
|
+
>
|
|
117
|
+
<img
|
|
118
|
+
src={image.thumb}
|
|
119
|
+
alt={image.alt}
|
|
120
|
+
style={{
|
|
121
|
+
width: '100%',
|
|
122
|
+
height: '100%',
|
|
123
|
+
objectFit: 'cover',
|
|
124
|
+
aspectRatio: '1',
|
|
125
|
+
display: 'block',
|
|
126
|
+
}}
|
|
127
|
+
/>
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
</Item>
|
|
131
|
+
))}
|
|
132
|
+
</div>
|
|
133
|
+
</PhotoSwipeGallery>
|
|
134
|
+
</>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Gallery Component
|
|
2
|
+
|
|
3
|
+
A responsive image gallery component with PhotoSwipe lightbox integration. One component, usable the same way from `.astro` files and MDX content — no `import.meta.glob` needed in your own page or content file.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- 📸 **PhotoSwipe Lightbox**: Full-screen image viewing with zoom and navigation
|
|
8
|
+
- 🎨 **Responsive Grid**: Auto-adjusting columns based on screen size
|
|
9
|
+
- 🔄 **Auto-loading**: automatically discovers images in a `gallery/` folder colocated with the current page/content file
|
|
10
|
+
- 🔗 **Shared galleries**: reference one set of images by name from `usr/galleries/<name>/`
|
|
11
|
+
- 🔍 **Custom Captions**: via `captions.json`, or auto-generated from filenames
|
|
12
|
+
- ⌨️ **Keyboard Navigation**: Arrow keys and ESC support
|
|
13
|
+
- ♿ **Accessible**: Proper ARIA labels and keyboard support
|
|
14
|
+
|
|
15
|
+
## How auto-loading works
|
|
16
|
+
|
|
17
|
+
`Gallery.astro` doesn't glob its own files — a separate integration
|
|
18
|
+
(`core/integrations/galleryIntegration.ts`, bundled into `core/scms.ts` and wired into `astro.config.mjs`)
|
|
19
|
+
generates a Vite virtual module (`virtual:scms/galleries`) containing the
|
|
20
|
+
`import.meta.glob()` calls, at project build time. `Gallery.astro` just reads
|
|
21
|
+
from that virtual module and matches by URL path. This exists so the component
|
|
22
|
+
can move into an npm package later without losing auto-loading (a component
|
|
23
|
+
shipped in `node_modules/` can't statically glob a consumer project's files;
|
|
24
|
+
the integration generating the glob call at the consumer's own build time
|
|
25
|
+
can). See `core/integrations/galleryIntegration.ts`'s own doc comment for
|
|
26
|
+
details, and `core/components/Gallery/galleryUtils.ts` for the matching logic.
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
### Colocated (default) — auto-loads from a sibling `gallery/` folder
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
usr/content/blog/my-post/
|
|
34
|
+
index.mdx
|
|
35
|
+
gallery/
|
|
36
|
+
photo1.jpg
|
|
37
|
+
photo2.jpg
|
|
38
|
+
captions.json ← optional
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```mdx
|
|
42
|
+
import { Gallery } from '@lad-sapienza/scms-core/components/Gallery';
|
|
43
|
+
|
|
44
|
+
<Gallery />
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Matching is based on the current page's URL path against the folder
|
|
48
|
+
structure on disk. **Known limitation**: if a content collection entry
|
|
49
|
+
overrides its slug, or the site is deployed under a non-root `base`, the URL
|
|
50
|
+
path can diverge from the on-disk folder path and the match silently finds
|
|
51
|
+
nothing. Use `name` (below) or `images` as a workaround in that case.
|
|
52
|
+
|
|
53
|
+
### Shared — referenced by name from any page
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
usr/galleries/
|
|
57
|
+
scavi-2024/
|
|
58
|
+
photo1.jpg
|
|
59
|
+
photo2.jpg
|
|
60
|
+
captions.json ← optional
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```mdx
|
|
64
|
+
<Gallery name="scavi-2024" />
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Explicit — pass images directly
|
|
68
|
+
|
|
69
|
+
Escape hatch for remote images, a Directus-backed source, or a custom order.
|
|
70
|
+
Takes precedence over both `name` and auto-loading.
|
|
71
|
+
|
|
72
|
+
```jsx
|
|
73
|
+
<Gallery
|
|
74
|
+
images={[
|
|
75
|
+
{ src: '/photos/img1.jpg', thumb: '/photos/img1.jpg', width: 1200, height: 800, alt: 'Description', caption: 'My custom caption' },
|
|
76
|
+
]}
|
|
77
|
+
/>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Custom Captions
|
|
81
|
+
|
|
82
|
+
By default, captions are generated from filenames (e.g., `my-photo.jpg` → "My Photo"). Place a `captions.json` file in the same `gallery/` folder (colocated) or shared-gallery folder to override them:
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"photo1.jpg": "A beautiful sunset over the mountains",
|
|
87
|
+
"photo2.jpg": "Morning coffee with a view"
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Keys can be filenames with or without extensions, and can use a different
|
|
92
|
+
resolution suffix than the actual file (`_1280`, `_1920`, etc.) — matching is
|
|
93
|
+
fuzzy on the base filename. A malformed `captions.json` (not a flat
|
|
94
|
+
string-to-string map) is ignored with a console warning rather than breaking
|
|
95
|
+
the build.
|
|
96
|
+
|
|
97
|
+
## Props API
|
|
98
|
+
|
|
99
|
+
| Prop | Type | Default | Description |
|
|
100
|
+
|---|---|---|---|
|
|
101
|
+
| `name` | `string` | — | Load a shared gallery from `usr/galleries/<name>/` |
|
|
102
|
+
| `images` | `GalleryImage[]` | — | Explicit images — takes precedence over `name` and auto-loading |
|
|
103
|
+
| `reverseSorting` | `boolean` | `false` | Reverse the default alphabetical sort |
|
|
104
|
+
| `columns` | `{ min?: number, max?: number }` | `{ min: 200, max: 1 }` | `min` = minimum item width in px; `max` = maximum column count |
|
|
105
|
+
| `className` | `string` | — | Additional CSS classes |
|
|
106
|
+
|
|
107
|
+
### GalleryImage Type
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
interface GalleryImage {
|
|
111
|
+
src: string; // Full-size image URL
|
|
112
|
+
thumb: string; // Thumbnail URL
|
|
113
|
+
width: number;
|
|
114
|
+
height: number;
|
|
115
|
+
alt: string;
|
|
116
|
+
caption?: string;
|
|
117
|
+
}
|
|
118
|
+
```
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const manifest = {
|
|
4
|
+
pagesImages: {
|
|
5
|
+
'/usr/pages/my-page/gallery/photo1.jpg': { default: { src: '/_astro/photo1.abc123.jpg', width: 1200, height: 800 } },
|
|
6
|
+
},
|
|
7
|
+
contentImages: {
|
|
8
|
+
'/usr/content/blog/my-article/gallery/photo2.jpg': { default: { src: '/_astro/photo2.def456.jpg', width: 1000, height: 600 } },
|
|
9
|
+
'/usr/content/blog/other-article/gallery/photo3.jpg': { default: { src: '/_astro/photo3.ghi789.jpg', width: 900, height: 500 } },
|
|
10
|
+
},
|
|
11
|
+
sharedImages: {
|
|
12
|
+
'/usr/galleries/scavi-2024/site-a.jpg': { default: { src: '/_astro/site-a.jkl000.jpg', width: 800, height: 600 } },
|
|
13
|
+
'/usr/galleries/scavi-2024/site-b_1920.jpg': { default: { src: '/_astro/site-b_1920.mno111.jpg', width: 800, height: 600 } },
|
|
14
|
+
'/usr/galleries/other-name/site-c.jpg': { default: { src: '/_astro/site-c.pqr222.jpg', width: 800, height: 600 } },
|
|
15
|
+
},
|
|
16
|
+
pagesCaptions: {},
|
|
17
|
+
contentCaptions: {
|
|
18
|
+
'/usr/content/blog/my-article/gallery/captions.json': { default: { 'photo2.jpg': 'A custom caption' } },
|
|
19
|
+
},
|
|
20
|
+
sharedCaptions: {
|
|
21
|
+
'/usr/galleries/scavi-2024/captions.json': {
|
|
22
|
+
default: {
|
|
23
|
+
'site-a.jpg': 'Exact match caption',
|
|
24
|
+
// Deliberately a different resolution suffix than the real file
|
|
25
|
+
// (site-b_1920.jpg vs the real site-b_1920.jpg above uses the same
|
|
26
|
+
// base name so this also verifies base-filename fuzzy matching).
|
|
27
|
+
'site-b_1280.jpg': 'Fuzzy match caption',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
vi.mock('virtual:scms/galleries', () => manifest);
|
|
34
|
+
|
|
35
|
+
const {
|
|
36
|
+
getColocatedGalleryImages,
|
|
37
|
+
getColocatedCaptions,
|
|
38
|
+
getSharedGalleryImages,
|
|
39
|
+
getSharedCaptions,
|
|
40
|
+
sortImages,
|
|
41
|
+
formatFilename,
|
|
42
|
+
getFileName,
|
|
43
|
+
} = await import('../galleryUtils');
|
|
44
|
+
|
|
45
|
+
describe('getColocatedGalleryImages', () => {
|
|
46
|
+
it('matches a page-colocated gallery by URL path', () => {
|
|
47
|
+
const images = getColocatedGalleryImages('/my-page/');
|
|
48
|
+
expect(images).toHaveLength(1);
|
|
49
|
+
expect(images[0].src).toBe('/_astro/photo1.abc123.jpg');
|
|
50
|
+
expect(images[0].width).toBe(1200);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('matches a content-colocated gallery by URL path', () => {
|
|
54
|
+
const images = getColocatedGalleryImages('/blog/my-article');
|
|
55
|
+
expect(images).toHaveLength(1);
|
|
56
|
+
expect(images[0].src).toBe('/_astro/photo2.def456.jpg');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('handles a trailing slash the same as without one', () => {
|
|
60
|
+
const withSlash = getColocatedGalleryImages('/blog/my-article/');
|
|
61
|
+
const withoutSlash = getColocatedGalleryImages('/blog/my-article');
|
|
62
|
+
expect(withSlash).toEqual(withoutSlash);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns an empty array when no gallery matches the path', () => {
|
|
66
|
+
expect(getColocatedGalleryImages('/no/such/page')).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('derives alt/caption from the filename when no captions.json applies', () => {
|
|
70
|
+
const [image] = getColocatedGalleryImages('/my-page/');
|
|
71
|
+
expect(image.alt).toBe('Photo1');
|
|
72
|
+
expect(image.caption).toBe('Photo1');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('applies a matching captions.json caption instead of the filename fallback', () => {
|
|
76
|
+
const [image] = getColocatedGalleryImages('/blog/my-article');
|
|
77
|
+
expect(image.caption).toBe('A custom caption');
|
|
78
|
+
expect(image.alt).toBe('A custom caption');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('getColocatedCaptions', () => {
|
|
83
|
+
it('returns the parsed captions for a matching path', () => {
|
|
84
|
+
expect(getColocatedCaptions('/blog/my-article')).toEqual({ 'photo2.jpg': 'A custom caption' });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('returns undefined when there is no captions.json for the path', () => {
|
|
88
|
+
expect(getColocatedCaptions('/my-page/')).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('getSharedGalleryImages', () => {
|
|
93
|
+
it('returns only images under the matching shared gallery name', () => {
|
|
94
|
+
const images = getSharedGalleryImages('scavi-2024');
|
|
95
|
+
expect(images).toHaveLength(2);
|
|
96
|
+
expect(images.map((i) => i.src).sort()).toEqual(['/_astro/site-a.jkl000.jpg', '/_astro/site-b_1920.mno111.jpg']);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('returns an empty array for an unknown gallery name', () => {
|
|
100
|
+
expect(getSharedGalleryImages('does-not-exist')).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('applies an exact-filename caption match', () => {
|
|
104
|
+
const images = getSharedGalleryImages('scavi-2024');
|
|
105
|
+
const siteA = images.find((i) => i.src.includes('site-a'));
|
|
106
|
+
expect(siteA?.caption).toBe('Exact match caption');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('fuzzy-matches a caption across a different resolution suffix than the real file', () => {
|
|
110
|
+
const images = getSharedGalleryImages('scavi-2024');
|
|
111
|
+
const siteB = images.find((i) => i.src.includes('site-b'));
|
|
112
|
+
expect(siteB?.caption).toBe('Fuzzy match caption');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('falls back to the filename when no gallery-wide captions.json exists', () => {
|
|
116
|
+
const images = getSharedGalleryImages('other-name');
|
|
117
|
+
expect(images[0].caption).toBe('Site C');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('getSharedCaptions', () => {
|
|
122
|
+
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
123
|
+
|
|
124
|
+
beforeEach(() => {
|
|
125
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
afterEach(() => {
|
|
129
|
+
warnSpy.mockRestore();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('logs a warning and returns undefined for a malformed captions.json', () => {
|
|
133
|
+
const result = parseMalformedFixture();
|
|
134
|
+
expect(result).toBeUndefined();
|
|
135
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
136
|
+
expect.stringContaining('Invalid captions.json'),
|
|
137
|
+
expect.anything()
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('returns undefined for an unknown gallery name, without warning', () => {
|
|
142
|
+
expect(getSharedCaptions('does-not-exist')).toBeUndefined();
|
|
143
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
function parseMalformedFixture() {
|
|
147
|
+
// Reuses the real function against a manifest entry shaped like a
|
|
148
|
+
// malformed captions.json (nested object instead of Record<string,string>).
|
|
149
|
+
(manifest.sharedCaptions as any)['/usr/galleries/broken/captions.json'] = { default: { nested: { not: 'a string' } } };
|
|
150
|
+
return getSharedCaptions('broken');
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('sortImages', () => {
|
|
155
|
+
const images = [
|
|
156
|
+
{ src: 'a.jpg', thumb: '', width: 1, height: 1, alt: '', caption: '' },
|
|
157
|
+
{ src: 'c.jpg', thumb: '', width: 1, height: 1, alt: '', caption: '' },
|
|
158
|
+
{ src: 'b.jpg', thumb: '', width: 1, height: 1, alt: '', caption: '' },
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
it('sorts descending by filename by default', () => {
|
|
162
|
+
expect(sortImages(images).map((i) => i.src)).toEqual(['c.jpg', 'b.jpg', 'a.jpg']);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('sorts ascending when reverse is true', () => {
|
|
166
|
+
expect(sortImages(images, true).map((i) => i.src)).toEqual(['a.jpg', 'b.jpg', 'c.jpg']);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('formatFilename / getFileName', () => {
|
|
171
|
+
it('extracts a filename without its extension', () => {
|
|
172
|
+
expect(getFileName('/a/b/my-photo.jpg')).toBe('my-photo');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('formats a hyphenated filename into title case', () => {
|
|
176
|
+
expect(formatFilename('my-photo-file')).toBe('My Photo File');
|
|
177
|
+
});
|
|
178
|
+
});
|