@lad-sapienza/scms-core 0.1.0-alpha.2 → 0.1.0-alpha.4
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/README.md +16 -6
- package/components/BSNavbar.tsx +42 -8
- package/components/DataTb/DataTb.astro +1 -1
- package/components/DataTb/DataTb.tsx +25 -10
- package/components/DataTb/README.md +2 -2
- package/components/Gallery/Gallery.astro +1 -1
- package/components/Gallery/Gallery.tsx +4 -1
- package/components/Gallery/README.md +4 -4
- package/components/Gallery/__tests__/galleryUtils.test.ts +21 -0
- package/components/Gallery/galleryUtils.ts +40 -3
- package/components/LocaleSwitcher.tsx +132 -0
- package/components/Map/LayerControl.tsx +8 -6
- package/components/Map/Map.tsx +3 -1
- package/components/Map/Search/SearchUI.tsx +4 -2
- package/components/Map/Search/SearchUIAdvanced.tsx +40 -15
- package/components/Map/Search/SearchUISimple.tsx +11 -9
- package/components/README.md +3 -3
- package/components/Record/RecordFetcher.tsx +5 -2
- package/components/TableOfContents/TableOfContents.astro +1 -2
- package/components/ThemeToggle.tsx +145 -0
- package/components/ZoteroGeoViewer/ZoteroGeoViewer.tsx +46 -27
- package/components/ZoteroGeoViewer/ZoteroRecordsPreview.tsx +16 -7
- package/components/i18n/I18nInit.astro +36 -0
- package/components/i18n/__tests__/translator.test.ts +87 -0
- package/components/i18n/builtinDictionary.ts +127 -0
- package/components/i18n/index.ts +7 -0
- package/components/i18n/translator.ts +71 -0
- package/components/i18n/useTranslation.ts +38 -0
- package/components/i18n/window.ts +33 -0
- package/index.ts +5 -0
- package/package.json +1 -1
- package/scms.ts +25 -0
- package/template/src/layouts/BaseLayout.astro +2 -0
package/README.md
CHANGED
|
@@ -29,9 +29,9 @@ export default defineConfig({
|
|
|
29
29
|
integrations: [
|
|
30
30
|
...scms({
|
|
31
31
|
// all optional, default to the paths below
|
|
32
|
-
contentDir: '
|
|
33
|
-
pagesDir: '
|
|
34
|
-
galleriesDir: '
|
|
32
|
+
contentDir: 'src/content',
|
|
33
|
+
pagesDir: 'src/pages',
|
|
34
|
+
galleriesDir: 'src/galleries',
|
|
35
35
|
}),
|
|
36
36
|
],
|
|
37
37
|
});
|
|
@@ -65,16 +65,26 @@ import { directusLoader } from '@lad-sapienza/scms-core/integrations/directusLoa
|
|
|
65
65
|
|
|
66
66
|
See each component folder's own `README.md` (`components/DataTb`, `components/Gallery`, `components/ZoteroGeoViewer`, `components/`) for detailed API docs and examples.
|
|
67
67
|
|
|
68
|
+
## Creating a new site
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
npx --package=@lad-sapienza/scms-core scms-create my-site
|
|
72
|
+
cd my-site
|
|
73
|
+
npm run dev
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Prompts for a title, description, author, and site URL, then scaffolds a minimal, ready-to-run Astro + s:CMS project (`src/content.config.ts`, `src/pages/index.astro`, `astro.config.mjs`, etc.) and runs `npm install`.
|
|
77
|
+
|
|
68
78
|
## Scaffolding CLI
|
|
69
79
|
|
|
70
|
-
This package also ships the interactive scaffolding tools
|
|
80
|
+
This package also ships the interactive scaffolding tools s:CMS sites use to add content, as `bin` commands — run from the consuming site's project root:
|
|
71
81
|
|
|
72
82
|
```bash
|
|
73
|
-
npx scms-add-collection # scaffold a new Astro content collection under
|
|
83
|
+
npx scms-add-collection # scaffold a new Astro content collection under src/
|
|
74
84
|
npx scms-add-content # add a new content file to an existing collection
|
|
75
85
|
```
|
|
76
86
|
|
|
77
|
-
A consuming site typically wires these up as `npm run add-collection` / `npm run add-content` in its own `package.json
|
|
87
|
+
A consuming site typically wires these up as `npm run add-collection` / `npm run add-content` in its own `package.json` (this is what `scms-create` sets up automatically).
|
|
78
88
|
|
|
79
89
|
## Development
|
|
80
90
|
|
package/components/BSNavbar.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React, { useState, useRef, useEffect } from "react";
|
|
2
2
|
import { Menu, X, ChevronRight } from "lucide-react";
|
|
3
|
+
import { useTranslation } from "./i18n";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* A single navigation link entry.
|
|
@@ -100,7 +101,10 @@ interface BSNavbarProps {
|
|
|
100
101
|
function isItemActive(item: MenuItem, currentPath: string): boolean {
|
|
101
102
|
const target = item.match ?? item.href;
|
|
102
103
|
if (target) {
|
|
103
|
-
|
|
104
|
+
// A target ending in "/" is an index page (e.g. "/" itself, or a
|
|
105
|
+
// locale-prefixed home like "/it/") — match it exactly, otherwise
|
|
106
|
+
// startsWith would make it "active" for every page beneath it too.
|
|
107
|
+
const active = target.endsWith("/") ? currentPath === target : currentPath.startsWith(target);
|
|
104
108
|
if (active) return true;
|
|
105
109
|
}
|
|
106
110
|
return item.children?.some(child => isItemActive(child, currentPath)) ?? false;
|
|
@@ -159,11 +163,7 @@ const DropdownItem: React.FC<{ item: MenuItem; currentPath: string }> = ({ item,
|
|
|
159
163
|
</button>
|
|
160
164
|
{isOpen && (
|
|
161
165
|
<ul
|
|
162
|
-
className=
|
|
163
|
-
style={openLeft
|
|
164
|
-
? { position: "absolute", right: "100%", left: "auto", top: 0, minWidth: "12rem" }
|
|
165
|
-
: { position: "absolute", left: "100%", top: 0, minWidth: "12rem" }
|
|
166
|
-
}
|
|
166
|
+
className={`dropdown-menu show scms-nested-dropdown${openLeft ? " scms-nested-dropdown--left" : ""}`}
|
|
167
167
|
>
|
|
168
168
|
{item.children!.map((child, i) => (
|
|
169
169
|
<DropdownItem key={child.href ?? i} item={child} currentPath={currentPath} />
|
|
@@ -229,10 +229,44 @@ const NavItem: React.FC<{ item: MenuItem; currentPath: string; liClass?: string
|
|
|
229
229
|
// BSNavbar — root component
|
|
230
230
|
// ---------------------------------------------------------------------------
|
|
231
231
|
const BSNavbar: React.FC<BSNavbarProps> = ({ menuItems, currentPath = "/", cssClasses = {}, brand }) => {
|
|
232
|
+
const t = useTranslation();
|
|
232
233
|
const [isOpen, setIsOpen] = useState(false);
|
|
233
234
|
|
|
234
235
|
return (
|
|
235
236
|
<nav className={`navbar navbar-expand-md ${cssClasses.nav ?? ""}`}>
|
|
237
|
+
{/*
|
|
238
|
+
Third-level (and deeper) dropdowns cascade sideways on desktop
|
|
239
|
+
(position: absolute; left/right: 100% — see DropdownItem below).
|
|
240
|
+
Below Bootstrap's md breakpoint the navbar collapses into a
|
|
241
|
+
single narrow column (navbar-expand-md), and a sideways flyout
|
|
242
|
+
has nowhere to go but off the edge of the screen — unreachable,
|
|
243
|
+
not just visually broken. Below md, this flips the same markup
|
|
244
|
+
to flow inline instead, indented to show the hierarchy.
|
|
245
|
+
*/}
|
|
246
|
+
<style>{`
|
|
247
|
+
@media (max-width: 767.98px) {
|
|
248
|
+
.scms-nested-dropdown {
|
|
249
|
+
position: static !important;
|
|
250
|
+
width: 100% !important;
|
|
251
|
+
min-width: 0 !important;
|
|
252
|
+
box-shadow: none !important;
|
|
253
|
+
border: none !important;
|
|
254
|
+
padding-left: 1rem;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
@media (min-width: 768px) {
|
|
258
|
+
.scms-nested-dropdown {
|
|
259
|
+
position: absolute;
|
|
260
|
+
left: 100%;
|
|
261
|
+
top: 0;
|
|
262
|
+
min-width: 12rem;
|
|
263
|
+
}
|
|
264
|
+
.scms-nested-dropdown.scms-nested-dropdown--left {
|
|
265
|
+
left: auto;
|
|
266
|
+
right: 100%;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
`}</style>
|
|
236
270
|
<div className="container">
|
|
237
271
|
{brand && (
|
|
238
272
|
<a className={`navbar-brand ${brand.cssClasses ?? ""}`} href={brand.link}>
|
|
@@ -245,9 +279,9 @@ const BSNavbar: React.FC<BSNavbarProps> = ({ menuItems, currentPath = "/", cssCl
|
|
|
245
279
|
onClick={() => setIsOpen(prev => !prev)}
|
|
246
280
|
aria-controls="navbarCollapse"
|
|
247
281
|
aria-expanded={isOpen}
|
|
248
|
-
aria-label=
|
|
282
|
+
aria-label={t('navbar.toggleNavigation')}
|
|
249
283
|
>
|
|
250
|
-
{isOpen ? <X size={24}
|
|
284
|
+
{isOpen ? <X size={24} /> : <Menu size={24} />}
|
|
251
285
|
</button>
|
|
252
286
|
<div className={`collapse navbar-collapse${isOpen ? " show" : ""}`} id="navbarCollapse">
|
|
253
287
|
<ul className={`navbar-nav ${cssClasses.ul ?? ""}`}>
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* headless data-loaders (they render nothing; they call onDataLoad/onLoadingChange/
|
|
18
18
|
* onError) for building a fully custom table UI — they are not children of
|
|
19
19
|
* <DataTb>, which has no children/composition API. See the "Headless Source
|
|
20
|
-
* Components" section of
|
|
20
|
+
* Components" section of DataTb/README.md.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
// Re-export for convenience. `DataTb` itself is deliberately not re-exported
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type ColumnFiltersState,
|
|
18
18
|
} from '@tanstack/react-table';
|
|
19
19
|
import { fetchData } from '../../utils/data-fetcher';
|
|
20
|
+
import { useTranslation } from '../i18n';
|
|
20
21
|
import type { DataRow, DataTbProps } from './types';
|
|
21
22
|
import {
|
|
22
23
|
autoDetectColumns,
|
|
@@ -33,13 +34,18 @@ export function DataTb({
|
|
|
33
34
|
initialSort,
|
|
34
35
|
className = '',
|
|
35
36
|
source,
|
|
36
|
-
loadingMessage
|
|
37
|
-
emptyMessage
|
|
38
|
-
errorMessage
|
|
37
|
+
loadingMessage,
|
|
38
|
+
emptyMessage,
|
|
39
|
+
errorMessage,
|
|
39
40
|
truncateContent = true,
|
|
40
41
|
truncateMaxWidth = '20rem',
|
|
41
42
|
contentSize = 'sm',
|
|
42
43
|
}: DataTbProps) {
|
|
44
|
+
const t = useTranslation();
|
|
45
|
+
const resolvedLoadingMessage = loadingMessage ?? t('dataTb.loading');
|
|
46
|
+
const resolvedEmptyMessage = emptyMessage ?? t('dataTb.empty');
|
|
47
|
+
const resolvedErrorMessage = errorMessage ?? t('dataTb.error');
|
|
48
|
+
|
|
43
49
|
const [data, setData] = useState<DataRow[]>([]);
|
|
44
50
|
const [loading, setLoading] = useState(true);
|
|
45
51
|
const [error, setError] = useState<Error | null>(null);
|
|
@@ -148,7 +154,7 @@ export function DataTb({
|
|
|
148
154
|
return (
|
|
149
155
|
<div className="datatb-container">
|
|
150
156
|
<div className="datatb-loading p-4 text-center text-secondary">
|
|
151
|
-
{
|
|
157
|
+
{resolvedLoadingMessage}
|
|
152
158
|
</div>
|
|
153
159
|
</div>
|
|
154
160
|
);
|
|
@@ -159,7 +165,7 @@ export function DataTb({
|
|
|
159
165
|
return (
|
|
160
166
|
<div className="datatb-container">
|
|
161
167
|
<div className="datatb-error p-4 text-center text-danger">
|
|
162
|
-
{
|
|
168
|
+
{resolvedErrorMessage}: {error.message}
|
|
163
169
|
</div>
|
|
164
170
|
</div>
|
|
165
171
|
);
|
|
@@ -170,7 +176,7 @@ export function DataTb({
|
|
|
170
176
|
return (
|
|
171
177
|
<div className="datatb-container">
|
|
172
178
|
<div className="datatb-empty p-4 text-center text-secondary">
|
|
173
|
-
{
|
|
179
|
+
{resolvedEmptyMessage}
|
|
174
180
|
</div>
|
|
175
181
|
</div>
|
|
176
182
|
);
|
|
@@ -178,6 +184,12 @@ export function DataTb({
|
|
|
178
184
|
|
|
179
185
|
return (
|
|
180
186
|
<div className={`datatb-container ${className}`}>
|
|
187
|
+
<style>{`
|
|
188
|
+
.datatb-thead {
|
|
189
|
+
--bs-table-bg: var(--bs-tertiary-bg);
|
|
190
|
+
--bs-table-color: var(--bs-emphasis-color);
|
|
191
|
+
}
|
|
192
|
+
`}</style>
|
|
181
193
|
|
|
182
194
|
{/* Search input */}
|
|
183
195
|
{searchable && (
|
|
@@ -186,7 +198,7 @@ export function DataTb({
|
|
|
186
198
|
type="text"
|
|
187
199
|
value={globalFilter ?? ''}
|
|
188
200
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
|
189
|
-
placeholder=
|
|
201
|
+
placeholder={t('dataTb.searchPlaceholder')}
|
|
190
202
|
className="form-control form-control-sm"
|
|
191
203
|
style={{ maxWidth: '24rem' }}
|
|
192
204
|
/>
|
|
@@ -196,7 +208,7 @@ export function DataTb({
|
|
|
196
208
|
{/* Table */}
|
|
197
209
|
<div className="datatb-table-wrapper table-responsive border rounded shadow-sm">
|
|
198
210
|
<table className="datatb-table table table-sm table-hover mb-0">
|
|
199
|
-
<thead className="
|
|
211
|
+
<thead className="datatb-thead">
|
|
200
212
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
201
213
|
<tr key={headerGroup.id}>
|
|
202
214
|
{headerGroup.headers.map((header, index) => (
|
|
@@ -309,7 +321,10 @@ export function DataTb({
|
|
|
309
321
|
|
|
310
322
|
<div className="d-flex align-items-center gap-2 flex-nowrap">
|
|
311
323
|
<span className="small text-secondary text-nowrap">
|
|
312
|
-
|
|
324
|
+
{t('dataTb.paginationSummary', {
|
|
325
|
+
page: table.getState().pagination.pageIndex + 1,
|
|
326
|
+
totalPages: table.getPageCount(),
|
|
327
|
+
})}
|
|
313
328
|
</span>
|
|
314
329
|
|
|
315
330
|
{paginationConfig.showPageSize !== false && (
|
|
@@ -330,7 +345,7 @@ export function DataTb({
|
|
|
330
345
|
)}
|
|
331
346
|
|
|
332
347
|
<span className="small text-secondary text-nowrap">
|
|
333
|
-
({data.length}
|
|
348
|
+
{t('dataTb.rowsTotal', { count: data.length })}
|
|
334
349
|
</span>
|
|
335
350
|
</div>
|
|
336
351
|
</div>
|
|
@@ -11,7 +11,7 @@ A powerful, flexible data table component for Astro with support for multiple da
|
|
|
11
11
|
- 🎨 **Auto-detection**: Automatically detects columns from data
|
|
12
12
|
- ⚙️ **Customizable**: Override columns, formatting, and rendering
|
|
13
13
|
- 🎯 **TypeScript**: Full type safety
|
|
14
|
-
- 💅 **Bootstrap Styled**: Uses the same Bootstrap 5 classes as the rest of s:CMS, themeable via `
|
|
14
|
+
- 💅 **Bootstrap Styled**: Uses the same Bootstrap 5 classes as the rest of s:CMS, themeable via your site's `src/styles/global.css`
|
|
15
15
|
|
|
16
16
|
## Basic Usage
|
|
17
17
|
|
|
@@ -272,4 +272,4 @@ Data fetching is handled internally based on the `source`/`csv`/`json`/`api`/`di
|
|
|
272
272
|
|
|
273
273
|
## Examples
|
|
274
274
|
|
|
275
|
-
See the [DataTb docs page](
|
|
275
|
+
See the [DataTb docs page](https://scms.lad-sapienza.it/docs/components/datatb) on the s:CMS demo site for live, interactive examples of every prop and source type.
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* for how auto-loading works without each caller needing its own glob).
|
|
8
8
|
*
|
|
9
9
|
* <Gallery /> colocated: ./gallery/ next to this page/content file
|
|
10
|
-
* <Gallery name="scavi-2024" /> shared:
|
|
10
|
+
* <Gallery name="scavi-2024" /> shared: src/galleries/scavi-2024/
|
|
11
11
|
* <Gallery images={[...]} /> explicit: pass images directly (remote, custom order, ...)
|
|
12
12
|
*/
|
|
13
13
|
import { Gallery as GalleryClient } from './Gallery';
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { Gallery as PhotoSwipeGallery, Item } from 'react-photoswipe-gallery';
|
|
9
9
|
import type { GalleryImage } from './types';
|
|
10
|
+
import { useTranslation } from '../i18n';
|
|
10
11
|
import 'photoswipe/dist/photoswipe.css';
|
|
11
12
|
|
|
12
13
|
interface Props {
|
|
@@ -43,6 +44,8 @@ export function Gallery({
|
|
|
43
44
|
columns = { min: 200, max: 1 },
|
|
44
45
|
className = '',
|
|
45
46
|
}: Props) {
|
|
47
|
+
const t = useTranslation();
|
|
48
|
+
|
|
46
49
|
if (images.length === 0) {
|
|
47
50
|
return null;
|
|
48
51
|
}
|
|
@@ -112,7 +115,7 @@ export function Gallery({
|
|
|
112
115
|
tabIndex={0}
|
|
113
116
|
style={{ cursor: 'pointer', overflow: 'hidden', borderRadius: '4px' }}
|
|
114
117
|
data-caption={image.caption || image.alt}
|
|
115
|
-
aria-label={
|
|
118
|
+
aria-label={t('gallery.openImage', { alt: image.alt })}
|
|
116
119
|
>
|
|
117
120
|
<img
|
|
118
121
|
src={image.thumb}
|
|
@@ -7,7 +7,7 @@ A responsive image gallery component with PhotoSwipe lightbox integration. One c
|
|
|
7
7
|
- 📸 **PhotoSwipe Lightbox**: Full-screen image viewing with zoom and navigation
|
|
8
8
|
- 🎨 **Responsive Grid**: Auto-adjusting columns based on screen size
|
|
9
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 `
|
|
10
|
+
- 🔗 **Shared galleries**: reference one set of images by name from `src/galleries/<name>/`
|
|
11
11
|
- 🔍 **Custom Captions**: via `captions.json`, or auto-generated from filenames
|
|
12
12
|
- ⌨️ **Keyboard Navigation**: Arrow keys and ESC support
|
|
13
13
|
- ♿ **Accessible**: Proper ARIA labels and keyboard support
|
|
@@ -30,7 +30,7 @@ details, and `core/components/Gallery/galleryUtils.ts` for the matching logic.
|
|
|
30
30
|
### Colocated (default) — auto-loads from a sibling `gallery/` folder
|
|
31
31
|
|
|
32
32
|
```
|
|
33
|
-
|
|
33
|
+
src/content/blog/my-post/
|
|
34
34
|
index.mdx
|
|
35
35
|
gallery/
|
|
36
36
|
photo1.jpg
|
|
@@ -53,7 +53,7 @@ nothing. Use `name` (below) or `images` as a workaround in that case.
|
|
|
53
53
|
### Shared — referenced by name from any page
|
|
54
54
|
|
|
55
55
|
```
|
|
56
|
-
|
|
56
|
+
src/galleries/
|
|
57
57
|
scavi-2024/
|
|
58
58
|
photo1.jpg
|
|
59
59
|
photo2.jpg
|
|
@@ -98,7 +98,7 @@ the build.
|
|
|
98
98
|
|
|
99
99
|
| Prop | Type | Default | Description |
|
|
100
100
|
|---|---|---|---|
|
|
101
|
-
| `name` | `string` | — | Load a shared gallery from `
|
|
101
|
+
| `name` | `string` | — | Load a shared gallery from `src/galleries/<name>/` |
|
|
102
102
|
| `images` | `GalleryImage[]` | — | Explicit images — takes precedence over `name` and auto-loading |
|
|
103
103
|
| `reverseSorting` | `boolean` | `false` | Reverse the default alphabetical sort |
|
|
104
104
|
| `columns` | `{ min?: number, max?: number }` | `{ min: 200, max: 1 }` | `min` = minimum item width in px; `max` = maximum column count |
|
|
@@ -7,6 +7,7 @@ const manifest = {
|
|
|
7
7
|
contentImages: {
|
|
8
8
|
'/src/content/blog/my-article/gallery/photo2.jpg': { default: { src: '/_astro/photo2.def456.jpg', width: 1000, height: 600 } },
|
|
9
9
|
'/src/content/blog/other-article/gallery/photo3.jpg': { default: { src: '/_astro/photo3.ghi789.jpg', width: 900, height: 500 } },
|
|
10
|
+
'/src/content/blog/en/localized-article/gallery/photo4.jpg': { default: { src: '/_astro/photo4.stu333.jpg', width: 700, height: 400 } },
|
|
10
11
|
},
|
|
11
12
|
sharedImages: {
|
|
12
13
|
'/src/galleries/scavi-2024/site-a.jpg': { default: { src: '/_astro/site-a.jkl000.jpg', width: 800, height: 600 } },
|
|
@@ -56,6 +57,26 @@ describe('getColocatedGalleryImages', () => {
|
|
|
56
57
|
expect(images[0].src).toBe('/_astro/photo2.def456.jpg');
|
|
57
58
|
});
|
|
58
59
|
|
|
60
|
+
it('matches a locale-prefixed URL against a content tree with locale nested inside the collection folder', () => {
|
|
61
|
+
// On disk: /content/blog/en/localized-article/gallery/... (locale nested
|
|
62
|
+
// inside the collection). URL for a [locale] dynamic route puts the
|
|
63
|
+
// locale first instead: /en/blog/localized-article. Same segments,
|
|
64
|
+
// different order — see `matchesColocatedPath` in galleryUtils.ts.
|
|
65
|
+
const images = getColocatedGalleryImages('/en/blog/localized-article');
|
|
66
|
+
expect(images).toHaveLength(1);
|
|
67
|
+
expect(images[0].src).toBe('/_astro/photo4.stu333.jpg');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('matches even when the URL locale differs from the content locale (missing-translation fallback)', () => {
|
|
71
|
+
// A site falling back to default-locale content for a missing
|
|
72
|
+
// translation renders the "en" entry under another locale's URL, e.g.
|
|
73
|
+
// "/it/blog/localized-article" — the locale segment differs in value,
|
|
74
|
+
// not just position, between the URL and the on-disk content path.
|
|
75
|
+
const images = getColocatedGalleryImages('/it/blog/localized-article');
|
|
76
|
+
expect(images).toHaveLength(1);
|
|
77
|
+
expect(images[0].src).toBe('/_astro/photo4.stu333.jpg');
|
|
78
|
+
});
|
|
79
|
+
|
|
59
80
|
it('handles a trailing slash the same as without one', () => {
|
|
60
81
|
const withSlash = getColocatedGalleryImages('/blog/my-article/');
|
|
61
82
|
const withoutSlash = getColocatedGalleryImages('/blog/my-article');
|
|
@@ -127,12 +127,48 @@ const normalizePath = (pathname: string): string => pathname.replace(/\/$/, '');
|
|
|
127
127
|
const COLOCATED_GALLERY_PATTERN = /\/(pages|content)(.+)\/gallery\//;
|
|
128
128
|
const SHARED_GALLERY_PATTERN = /\/galleries\/([^/]+)\//;
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Matches a URL path against a colocated asset's captured content path,
|
|
132
|
+
* tolerating locale-prefixed routing (`src/pages/[locale]/...`) on top of
|
|
133
|
+
* the exact match every non-i18n site already relies on.
|
|
134
|
+
*
|
|
135
|
+
* Tried only when the exact match fails, so plain (non-i18n) sites are
|
|
136
|
+
* completely unaffected. Two things can differ once a `[locale]` layer is
|
|
137
|
+
* involved:
|
|
138
|
+
*
|
|
139
|
+
* 1. Position — the URL puts the locale first ("/it/blog/foo"), but a
|
|
140
|
+
* locale-per-directory content tree nests it one level inside the
|
|
141
|
+
* collection folder instead ("/blog/it/foo", from
|
|
142
|
+
* `src/content/blog/it/foo/gallery/*`).
|
|
143
|
+
* 2. Value — when a translation is missing and the site falls back to
|
|
144
|
+
* rendering the default-locale entry under another locale's URL (e.g.
|
|
145
|
+
* "/it/blog/foo" rendering the "en" content, whose images physically
|
|
146
|
+
* live under ".../blog/en/foo/gallery/"), the locale segment itself
|
|
147
|
+
* differs between the two paths, not just its position.
|
|
148
|
+
*
|
|
149
|
+
* Both are handled by dropping the assumed locale segment from each side —
|
|
150
|
+
* position 0 on the URL, position 1 on the content path (right after the
|
|
151
|
+
* collection name) — and comparing what's left: the actual content-identifying
|
|
152
|
+
* segments, locale-independent.
|
|
153
|
+
*/
|
|
154
|
+
function matchesColocatedPath(urlPath: string, contentCapturedPath: string): boolean {
|
|
155
|
+
if (urlPath === contentCapturedPath) return true;
|
|
156
|
+
|
|
157
|
+
const urlSegments = urlPath.split('/').filter(Boolean);
|
|
158
|
+
const contentSegments = contentCapturedPath.split('/').filter(Boolean);
|
|
159
|
+
if (urlSegments.length < 1 || contentSegments.length < 2) return false;
|
|
160
|
+
|
|
161
|
+
const urlRest = urlSegments.slice(1).join('/');
|
|
162
|
+
const contentRest = [contentSegments[0], ...contentSegments.slice(2)].join('/');
|
|
163
|
+
return urlRest === contentRest;
|
|
164
|
+
}
|
|
165
|
+
|
|
130
166
|
export function getColocatedCaptions(pathname: string): Record<string, string> | undefined {
|
|
131
167
|
const normalized = normalizePath(pathname);
|
|
132
168
|
const allCaptions = { ...manifest.pagesCaptions, ...manifest.contentCaptions };
|
|
133
169
|
const entry = Object.entries(allCaptions).find(([filepath]) => {
|
|
134
170
|
const match = filepath.match(COLOCATED_GALLERY_PATTERN);
|
|
135
|
-
return !!match && normalized
|
|
171
|
+
return !!match && matchesColocatedPath(normalized, match[2]);
|
|
136
172
|
});
|
|
137
173
|
if (!entry) return undefined;
|
|
138
174
|
const [filepath, mod] = entry;
|
|
@@ -158,7 +194,8 @@ export function getSharedCaptions(name: string): Record<string, string> | undefi
|
|
|
158
194
|
* Known limitation (unchanged from before this redesign): if a content
|
|
159
195
|
* collection entry overrides its slug, or the site has a non-root `base`,
|
|
160
196
|
* the URL path diverges from the on-disk folder path and matching silently
|
|
161
|
-
* finds nothing.
|
|
197
|
+
* finds nothing. Locale-prefixed routing (`/it/blog/foo` vs. an on-disk
|
|
198
|
+
* `blog/it/foo`) is handled — see `matchesColocatedPath` above.
|
|
162
199
|
*/
|
|
163
200
|
export function getColocatedGalleryImages(pathname: string): GalleryImage[] {
|
|
164
201
|
const normalized = normalizePath(pathname);
|
|
@@ -168,7 +205,7 @@ export function getColocatedGalleryImages(pathname: string): GalleryImage[] {
|
|
|
168
205
|
return Object.entries(allImages)
|
|
169
206
|
.filter(([filepath]) => {
|
|
170
207
|
const match = filepath.match(COLOCATED_GALLERY_PATTERN);
|
|
171
|
-
return !!match && normalized
|
|
208
|
+
return !!match && matchesColocatedPath(normalized, match[2]);
|
|
172
209
|
})
|
|
173
210
|
.map(([filepath, mod]) => moduleToGalleryImage(filepath, mod, captionLookup));
|
|
174
211
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect } from "react";
|
|
2
|
+
import { Globe } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
/** Optional CSS class override for the switcher button. */
|
|
5
|
+
interface LocaleSwitcherProps {
|
|
6
|
+
/** Every locale code the site routes (e.g. `["en", "it"]`). */
|
|
7
|
+
locales: string[];
|
|
8
|
+
/** The locale code for the page this component is rendered on. */
|
|
9
|
+
currentLocale: string;
|
|
10
|
+
/** Display name per locale code (e.g. `{ en: "English", it: "Italiano" }`). Falls back to the raw code when omitted. */
|
|
11
|
+
labels?: Record<string, string>;
|
|
12
|
+
/** Additional CSS classes applied to the trigger button. */
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Floating language switcher for sites using a `src/pages/[locale]/...`
|
|
18
|
+
* routing layer. Navigates to the same path with only the leading locale
|
|
19
|
+
* segment swapped — relies on every locale having a page at that path (a
|
|
20
|
+
* missing translation still resolves, rendered with default-locale content,
|
|
21
|
+
* rather than 404ing — see the site's own content-fallback logic), so no
|
|
22
|
+
* per-page URL mapping is needed here.
|
|
23
|
+
*
|
|
24
|
+
* Purely a navigation mechanism: it does not know or care how translations
|
|
25
|
+
* are organized, only that swapping the URL's first path segment is enough.
|
|
26
|
+
*
|
|
27
|
+
* Must be hydrated with `client:load`.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```astro
|
|
31
|
+
* <LocaleSwitcher
|
|
32
|
+
* client:load
|
|
33
|
+
* locales={["en", "it"]}
|
|
34
|
+
* currentLocale={locale}
|
|
35
|
+
* labels={{ en: "English", it: "Italiano" }}
|
|
36
|
+
* />
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
const LocaleSwitcher: React.FC<LocaleSwitcherProps> = ({ locales, currentLocale, labels = {}, className }) => {
|
|
40
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
41
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!isOpen) return;
|
|
45
|
+
const close = (e: MouseEvent) => {
|
|
46
|
+
if (ref.current && !ref.current.contains(e.target as Node)) setIsOpen(false);
|
|
47
|
+
};
|
|
48
|
+
document.addEventListener("mousedown", close);
|
|
49
|
+
return () => document.removeEventListener("mousedown", close);
|
|
50
|
+
}, [isOpen]);
|
|
51
|
+
|
|
52
|
+
const go = (locale: string) => {
|
|
53
|
+
if (locale === currentLocale) {
|
|
54
|
+
setIsOpen(false);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const { pathname, search, hash } = window.location;
|
|
58
|
+
const rest = pathname.replace(new RegExp(`^/${currentLocale}(/|$)`), "/");
|
|
59
|
+
window.location.href = `/${locale}${rest}${search}${hash}`;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div
|
|
64
|
+
ref={ref}
|
|
65
|
+
className={className}
|
|
66
|
+
style={{ position: "fixed", bottom: "1.25rem", left: "1.25rem", zIndex: 1040 }}
|
|
67
|
+
>
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
onClick={() => setIsOpen(p => !p)}
|
|
71
|
+
aria-label={`Language: ${labels[currentLocale] ?? currentLocale}. Click to change.`}
|
|
72
|
+
aria-expanded={isOpen}
|
|
73
|
+
style={{
|
|
74
|
+
width: "2.75rem",
|
|
75
|
+
height: "2.75rem",
|
|
76
|
+
borderRadius: "50%",
|
|
77
|
+
border: "1px solid var(--bs-border-color)",
|
|
78
|
+
background: "var(--bs-body-bg)",
|
|
79
|
+
color: "var(--bs-body-color)",
|
|
80
|
+
display: "flex",
|
|
81
|
+
alignItems: "center",
|
|
82
|
+
justifyContent: "center",
|
|
83
|
+
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)",
|
|
84
|
+
cursor: "pointer",
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
<Globe size={18} />
|
|
88
|
+
</button>
|
|
89
|
+
{isOpen && (
|
|
90
|
+
<ul
|
|
91
|
+
style={{
|
|
92
|
+
position: "absolute",
|
|
93
|
+
bottom: "calc(100% + 0.5rem)",
|
|
94
|
+
left: 0,
|
|
95
|
+
margin: 0,
|
|
96
|
+
padding: "0.35rem",
|
|
97
|
+
listStyle: "none",
|
|
98
|
+
minWidth: "8rem",
|
|
99
|
+
borderRadius: "0.5rem",
|
|
100
|
+
border: "1px solid var(--bs-border-color)",
|
|
101
|
+
background: "var(--bs-body-bg)",
|
|
102
|
+
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.15)",
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
{locales.map(locale => (
|
|
106
|
+
<li key={locale}>
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
onClick={() => go(locale)}
|
|
110
|
+
style={{
|
|
111
|
+
width: "100%",
|
|
112
|
+
textAlign: "left",
|
|
113
|
+
background: locale === currentLocale ? "var(--bs-tertiary-bg)" : "transparent",
|
|
114
|
+
color: "var(--bs-body-color)",
|
|
115
|
+
border: "none",
|
|
116
|
+
borderRadius: "0.375rem",
|
|
117
|
+
padding: "0.5rem 0.75rem",
|
|
118
|
+
fontWeight: locale === currentLocale ? 600 : 400,
|
|
119
|
+
cursor: "pointer",
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
{labels[locale] ?? locale}
|
|
123
|
+
</button>
|
|
124
|
+
</li>
|
|
125
|
+
))}
|
|
126
|
+
</ul>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export default LocaleSwitcher;
|
|
@@ -5,6 +5,7 @@ import { Layers, Search, X } from 'lucide-react';
|
|
|
5
5
|
import type { IControl } from 'maplibre-gl';
|
|
6
6
|
import type { BaseLayerConfig, VectorLayerConfig, ControlPosition, SearchInFields, SearchQuery } from './types';
|
|
7
7
|
import { SearchUI } from './Search';
|
|
8
|
+
import { useTranslation } from '../i18n';
|
|
8
9
|
|
|
9
10
|
export interface LayerControlProps {
|
|
10
11
|
baseLayers?: BaseLayerConfig[];
|
|
@@ -29,6 +30,7 @@ function LayerControlUI({
|
|
|
29
30
|
onLayerSearch,
|
|
30
31
|
layerSearchQueries = {},
|
|
31
32
|
}: Omit<LayerControlProps, 'position'>) {
|
|
33
|
+
const t = useTranslation();
|
|
32
34
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
33
35
|
const [searchModal, setSearchModal] = useState<{isOpen: boolean, layerId: string, layerName: string, fieldList: SearchInFields, currentQuery?: SearchQuery} | null>(null);
|
|
34
36
|
const radioGroupId = React.useId();
|
|
@@ -173,7 +175,7 @@ function LayerControlUI({
|
|
|
173
175
|
className={`btn btn-sm p-1 lh-1 border-0 ${
|
|
174
176
|
layerSearchQueries[layer.id]?.filters?.length ? 'btn-primary' : 'btn-light'
|
|
175
177
|
}`}
|
|
176
|
-
title={
|
|
178
|
+
title={`${t('map.searchLayer', { name: layer.name })}${layerSearchQueries[layer.id]?.filters?.length ? ` ${t('map.active')}` : ''}`}
|
|
177
179
|
>
|
|
178
180
|
<Search size={14} />
|
|
179
181
|
</button>
|
|
@@ -188,7 +190,7 @@ function LayerControlUI({
|
|
|
188
190
|
{baseLayers.length > 0 && (
|
|
189
191
|
<div>
|
|
190
192
|
<div style={{ fontSize: '11px', color: '#666', fontWeight: 600, marginBottom: 4, textTransform: 'uppercase' }}>
|
|
191
|
-
|
|
193
|
+
{t('map.baseMaps')}
|
|
192
194
|
</div>
|
|
193
195
|
{baseLayers.map((layer, index) => {
|
|
194
196
|
const isChecked = index === activeBaseLayer;
|
|
@@ -242,19 +244,19 @@ function LayerControlUI({
|
|
|
242
244
|
onClick={handleSearchClose}
|
|
243
245
|
>
|
|
244
246
|
<div
|
|
245
|
-
className="
|
|
246
|
-
style={{ maxHeight: 'calc(100vh - 4rem)', width: '500px', maxWidth: '90vw', margin: '2rem 1rem' }}
|
|
247
|
+
className="rounded shadow"
|
|
248
|
+
style={{ backgroundColor: 'var(--bs-tertiary-bg)', maxHeight: 'calc(100vh - 4rem)', width: '500px', maxWidth: '90vw', margin: '2rem 1rem' }}
|
|
247
249
|
onClick={(e) => e.stopPropagation()}
|
|
248
250
|
>
|
|
249
251
|
<div className="d-flex justify-content-between align-items-center p-3 border-bottom">
|
|
250
252
|
<h3 className="h5 fw-semibold mb-0 d-flex align-items-center gap-2 flex-grow-1 pe-2">
|
|
251
253
|
<Search size={18} />
|
|
252
|
-
|
|
254
|
+
{t('map.searchLayer', { name: searchModal.layerName })}
|
|
253
255
|
</h3>
|
|
254
256
|
<button
|
|
255
257
|
onClick={handleSearchClose}
|
|
256
258
|
className="btn btn-sm btn-light flex-shrink-0"
|
|
257
|
-
aria-label=
|
|
259
|
+
aria-label={t('map.closeModal')}
|
|
258
260
|
>
|
|
259
261
|
<X size={18} />
|
|
260
262
|
</button>
|
package/components/Map/Map.tsx
CHANGED
|
@@ -18,6 +18,7 @@ import type { BaseLayerConfig } from './types';
|
|
|
18
18
|
import { defaultBasemaps, getBasemap } from './defaultBasemaps';
|
|
19
19
|
import { fetchData } from '../../utils/data-fetcher';
|
|
20
20
|
import { dataToGeoJson, parseStringTemplate, filterObjectToPredicate, searchQueryToMapLibreFilter, searchQueryToPredicate } from './utils';
|
|
21
|
+
import { useTranslation } from '../i18n';
|
|
21
22
|
import type { FeatureCollection } from 'geojson';
|
|
22
23
|
import type { CircleLayerSpecification } from 'maplibre-gl';
|
|
23
24
|
import type { SearchQuery } from './types';
|
|
@@ -42,6 +43,7 @@ export function Map({
|
|
|
42
43
|
json,
|
|
43
44
|
directus,
|
|
44
45
|
}: MapProps) {
|
|
46
|
+
const t = useTranslation();
|
|
45
47
|
const [lng, lat, zoom] = center.split(',').map(Number);
|
|
46
48
|
const [activeBaseLayer, setActiveBaseLayer] = useState<number>(0);
|
|
47
49
|
const [layersData, setLayersData] = useState<Record<string, FeatureCollection>>({});
|
|
@@ -242,7 +244,7 @@ export function Map({
|
|
|
242
244
|
}
|
|
243
245
|
|
|
244
246
|
if (implicitSource) {
|
|
245
|
-
let layerName = '
|
|
247
|
+
let layerName = t('map.defaultLayerName');
|
|
246
248
|
const layerConfig: any = {
|
|
247
249
|
name: layerName,
|
|
248
250
|
source: implicitSource,
|