@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.
Files changed (33) hide show
  1. package/README.md +16 -6
  2. package/components/BSNavbar.tsx +42 -8
  3. package/components/DataTb/DataTb.astro +1 -1
  4. package/components/DataTb/DataTb.tsx +25 -10
  5. package/components/DataTb/README.md +2 -2
  6. package/components/Gallery/Gallery.astro +1 -1
  7. package/components/Gallery/Gallery.tsx +4 -1
  8. package/components/Gallery/README.md +4 -4
  9. package/components/Gallery/__tests__/galleryUtils.test.ts +21 -0
  10. package/components/Gallery/galleryUtils.ts +40 -3
  11. package/components/LocaleSwitcher.tsx +132 -0
  12. package/components/Map/LayerControl.tsx +8 -6
  13. package/components/Map/Map.tsx +3 -1
  14. package/components/Map/Search/SearchUI.tsx +4 -2
  15. package/components/Map/Search/SearchUIAdvanced.tsx +40 -15
  16. package/components/Map/Search/SearchUISimple.tsx +11 -9
  17. package/components/README.md +3 -3
  18. package/components/Record/RecordFetcher.tsx +5 -2
  19. package/components/TableOfContents/TableOfContents.astro +1 -2
  20. package/components/ThemeToggle.tsx +145 -0
  21. package/components/ZoteroGeoViewer/ZoteroGeoViewer.tsx +46 -27
  22. package/components/ZoteroGeoViewer/ZoteroRecordsPreview.tsx +16 -7
  23. package/components/i18n/I18nInit.astro +36 -0
  24. package/components/i18n/__tests__/translator.test.ts +87 -0
  25. package/components/i18n/builtinDictionary.ts +127 -0
  26. package/components/i18n/index.ts +7 -0
  27. package/components/i18n/translator.ts +71 -0
  28. package/components/i18n/useTranslation.ts +38 -0
  29. package/components/i18n/window.ts +33 -0
  30. package/index.ts +5 -0
  31. package/package.json +1 -1
  32. package/scms.ts +25 -0
  33. 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: 'usr/content',
33
- pagesDir: 'usr/pages',
34
- galleriesDir: 'usr/galleries',
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 sCMS sites use to add content, as `bin` commands — run from the consuming site's project root:
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 usr/
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
 
@@ -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
- const active = target === "/" ? currentPath === "/" : currentPath.startsWith(target);
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="dropdown-menu show"
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="Toggle navigation"
282
+ aria-label={t('navbar.toggleNavigation')}
249
283
  >
250
- {isOpen ? <X size={24} className="text-dark" /> : <Menu size={24} className="text-dark" />}
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 usr/content/docs/components/datatb.mdx.
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 = 'Loading data...',
37
- emptyMessage = 'No data available',
38
- errorMessage = 'Error loading data',
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
- {loadingMessage}
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
- {errorMessage}: {error.message}
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
- {emptyMessage}
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="Search..."
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="table-light">
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
- Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
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} total)
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 `usr/styles/global.css`
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](../../../usr/content/docs/components/datatb.mdx) (rendered at `/docs/components/datatb` on a running site) for live, interactive examples of every prop and source type.
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: usr/galleries/scavi-2024/
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={`Open image: ${image.alt}`}
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 `usr/galleries/<name>/`
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
- usr/content/blog/my-post/
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
- usr/galleries/
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 `usr/galleries/<name>/` |
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 === match[2];
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 === match[2];
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={`Search ${layer.name}${layerSearchQueries[layer.id]?.filters?.length ? ' (Active)' : ''}`}
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
- Base Maps
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="bg-white rounded shadow"
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
- Search {searchModal.layerName}
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="Close modal"
259
+ aria-label={t('map.closeModal')}
258
260
  >
259
261
  <X size={18} />
260
262
  </button>
@@ -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 = 'Default Layer';
247
+ let layerName = t('map.defaultLayerName');
246
248
  const layerConfig: any = {
247
249
  name: layerName,
248
250
  source: implicitSource,