@lad-sapienza/scms-core 0.1.0-alpha.5 → 0.1.0-alpha.7

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 CHANGED
@@ -51,6 +51,7 @@ import { DataTb, Map, Gallery } from '@lad-sapienza/scms-core';
51
51
  | `MapComponent` (`Map`) | Interactive map with MapLibre GL JS |
52
52
  | `Gallery` | Responsive image gallery with lightbox |
53
53
  | `TableOfContents` | Auto-generated TOC from headings |
54
+ | `RedirectPage` | Static client-side redirect stub (meta-refresh + JS fallback) for static hosts |
54
55
  | `ZoteroGeoViewer` | Zotero library visualised on a map |
55
56
  | `RecordProvider`, `Field`, `Image`, `RecordFetcher`, `useRecordFetcher` | Build single-record detail pages against Directus |
56
57
  | `SearchUI`, `SearchUISimple`, `SearchUIAdvanced` | Field/operator/value search UI (used by `Map`'s `searchInFields`) |
@@ -63,7 +64,7 @@ import BSNavbar from '@lad-sapienza/scms-core/components/BSNavbar';
63
64
  import { directusLoader } from '@lad-sapienza/scms-core/integrations/directusLoader';
64
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
+ See each component folder's own `README.md` (`components/DataTb`, `components/Gallery`, `components/RedirectPage`, `components/ZoteroGeoViewer`, `components/`) for detailed API docs and examples.
67
68
 
68
69
  ## Creating a new site
69
70
 
@@ -31,35 +31,11 @@ if (explicitImages && explicitImages.length > 0) {
31
31
  }
32
32
  ---
33
33
 
34
- {finalImages.length > 0 ? (
34
+ {finalImages.length > 0 && (
35
35
  <GalleryClient
36
36
  images={finalImages}
37
37
  columns={columns}
38
38
  className={className}
39
39
  client:idle
40
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
41
  )}
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>
@@ -1,4 +1,4 @@
1
- import { useState, useMemo, useEffect, useRef } from 'react';
1
+ import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
2
2
  import MapLibreMap, {
3
3
  NavigationControl,
4
4
  FullscreenControl,
@@ -310,20 +310,34 @@ export function Map({
310
310
  }, [allVectorLayers]);
311
311
 
312
312
  // Handle vector layer visibility toggle
313
- const handleVectorLayerToggle = (layerId: string) => {
313
+ //
314
+ // Wrapped in useCallback (both here and below) so its identity stays
315
+ // stable across renders — it's one of two functions passed into the
316
+ // "add LayerControl as an IControl" effect's dependency array. Left as a
317
+ // plain function, it got a new reference on *every* render (including
318
+ // the one triggered by clicking a basemap radio, since that sets
319
+ // activeBaseLayer state on this very component), which spuriously
320
+ // refired that effect on every click. Each refire tore down the old
321
+ // IControl and created a fresh one — and since the cleanup removed
322
+ // whatever layerControlRef.current happened to point to *at cleanup
323
+ // time* rather than the specific instance being torn down, the freshly
324
+ // created (correct) control was the one actually getting removed a tick
325
+ // later, leaving the stale first control (still showing the original
326
+ // basemap selected) as the only one left on the map.
327
+ const handleVectorLayerToggle = useCallback((layerId: string) => {
314
328
  setVectorLayerVisibility(prev => ({
315
329
  ...prev,
316
330
  [layerId]: !prev[layerId]
317
331
  }));
318
- };
332
+ }, []);
319
333
 
320
- // Handle search for vector layers
321
- const handleLayerSearch = (layerId: string, query: SearchQuery) => {
334
+ // Handle search for vector layers — see handleVectorLayerToggle above.
335
+ const handleLayerSearch = useCallback((layerId: string, query: SearchQuery) => {
322
336
  setLayerSearchQueries(prev => ({
323
337
  ...prev,
324
338
  [layerId]: query
325
339
  }));
326
- };
340
+ }, []);
327
341
 
328
342
  // Load data for all vector layers
329
343
  useEffect(() => {
@@ -467,13 +481,14 @@ export function Map({
467
481
  map.addControl(control, position);
468
482
 
469
483
  return () => {
470
- if (layerControlRef.current && map) {
471
- setTimeout(() => {
472
- if (layerControlRef.current && map) {
473
- map.removeControl(layerControlRef.current);
474
- }
475
- }, 0);
476
- }
484
+ // Remove this specific control instance, not whatever
485
+ // layerControlRef.current happens to point to when the timeout
486
+ // fires — if this effect ever refires before the timeout runs, the
487
+ // ref will already point at the *new* control, and reading it here
488
+ // would remove that one instead of this (correct) cleanup target.
489
+ setTimeout(() => {
490
+ map.removeControl(control);
491
+ }, 0);
477
492
  };
478
493
  }, [mapLoaded, layerControl, resolvedBaseLayers, allVectorLayers.length, handleLayerSearch]);
479
494
 
@@ -0,0 +1,119 @@
1
+ # RedirectPage Component
2
+
3
+ A static, client-side redirect stub for static hosts (GitHub Pages, S3, any
4
+ CDN without server-side redirect support). Renders a page that redirects via
5
+ `<meta http-equiv="refresh">`, a `<link rel="canonical">` for search engines,
6
+ and a `window.location.replace()` fallback for browsers that ignore
7
+ meta-refresh.
8
+
9
+ ## When to use this vs. Astro's native `redirects`
10
+
11
+ Astro's [`redirects`](https://docs.astro.build/en/reference/configuration-reference/#redirects)
12
+ config option generates the same kind of stub automatically for `output:
13
+ 'static'` sites, and should be preferred whenever it applies — it's zero
14
+ extra code. Reach for `RedirectPage` only when `redirects` can't express the
15
+ mapping.
16
+
17
+ **Works fine with native `redirects`:** a static source path redirecting to
18
+ a destination produced by a dynamic route, as long as the destination is
19
+ given as a concrete, already-resolved path (not a bracketed route pattern).
20
+ Astro only validates dynamic-parameter propagation when the *source* path
21
+ itself has dynamic segments — a static source skips that check entirely.
22
+ Confirmed empirically against Astro 7.2.4 (`output: 'static'`): a source
23
+ route with no params, e.g. `/chi-siamo`, redirecting to `/it/chi-siamo`
24
+ (itself generated by a `src/pages/[locale]/chi-siamo.astro` with
25
+ `getStaticPaths`), builds and emits the expected redirect stub with no
26
+ errors.
27
+
28
+ ```js
29
+ // astro.config.mjs
30
+ export default defineConfig({
31
+ redirects: {
32
+ '/chi-siamo': '/it/chi-siamo',
33
+ '/notizie': '/it/notizie',
34
+ },
35
+ });
36
+ ```
37
+
38
+ **Needs `RedirectPage` instead:** the source path itself is dynamic — e.g.
39
+ one redirect per old article slug, `/notizie/<slug>` → `/it/notizie/<slug>`
40
+ — *and* the destination route has a dynamic parameter the source doesn't
41
+ carry (typically a locale prefix). Astro requires the destination's dynamic
42
+ parameters to already be present in the source; `[locale]` isn't, since the
43
+ old flat URL had no locale segment. Trying to force it fails at build time:
44
+
45
+ ```js
46
+ redirects: {
47
+ '/notizie/[...slug]': '/it/notizie/[...slug]', // does NOT match the
48
+ // registered `/[locale]/notizie/[...slug]` route — this throws
49
+ // InvalidRedirectDestination: "The destination ... does not match any
50
+ // existing route in your project."
51
+ }
52
+ ```
53
+
54
+ There's no config-level way to say "always substitute `it` for `[locale]`
55
+ here" — the list of old slugs to redirect, and the mapping to each new URL,
56
+ has to come from actual code that knows about the content collection. That's
57
+ what the recipe below does.
58
+
59
+ ## Basic usage — single static redirect
60
+
61
+ For a handful of one-off redirects where you'd rather not touch
62
+ `astro.config.mjs` (or where the source **is** dynamic but happens not to
63
+ need a config-level `redirects` entry), drop a page using `RedirectPage`
64
+ directly:
65
+
66
+ ```astro
67
+ ---
68
+ // src/pages/chi-siamo.astro
69
+ import { RedirectPage } from '@lad-sapienza/scms-core';
70
+ export const prerender = true;
71
+ ---
72
+ <RedirectPage target="/it/chi-siamo" />
73
+ ```
74
+
75
+ ## Recipe — redirecting every entry of a content collection
76
+
77
+ The common case: a collection is moving from a flat URL (`/notizie/<slug>/`)
78
+ to a locale-prefixed one (`/it/notizie/<slug>/`), and every existing entry
79
+ needs its own redirect stub. Generate one page per old slug with
80
+ `getStaticPaths`, reading the slug list straight from the collection so the
81
+ redirect list can never drift out of sync with the content itself:
82
+
83
+ ```astro
84
+ ---
85
+ // src/pages/notizie/[...slug].astro
86
+ import { RedirectPage } from '@lad-sapienza/scms-core';
87
+ import { getCollection } from 'astro:content';
88
+
89
+ export const prerender = true;
90
+
91
+ export async function getStaticPaths() {
92
+ const articles = await getCollection('notizie');
93
+ // Adapt this to however your entry IDs encode locale/slug — e.g. if
94
+ // content lives under src/content/notizie/it/<slug>/, split the id
95
+ // yourself and keep only the default-locale entries (each locale has the
96
+ // same slug, so the redirect only needs to be generated once).
97
+ return articles
98
+ .filter((a) => a.id.startsWith('it/'))
99
+ .map((a) => ({ params: { slug: a.id.slice('it/'.length) } }));
100
+ }
101
+
102
+ const { slug } = Astro.params;
103
+ ---
104
+ <RedirectPage target={`/it/notizie/${slug}/`} />
105
+ ```
106
+
107
+ Repeat one such `[...slug].astro` per collection that needs migrating
108
+ (`blog`, `ricerca`, `didattica`, ...) — the body only changes in the
109
+ collection name and the target prefix. A collection's own `index.astro`
110
+ (the old flat listing page) is a single static redirect and can either use
111
+ this same component directly or, per the section above, a plain
112
+ `redirects` config entry.
113
+
114
+ ## Props
115
+
116
+ | Prop | Type | Default | Description |
117
+ |---|---|---|---|
118
+ | `target` | `string` | — | Destination URL to redirect to (required) |
119
+ | `title` | `string` | `"Redirecting…"` | Page `<title>`, shown briefly before the redirect fires |
@@ -0,0 +1,34 @@
1
+ ---
2
+ /**
3
+ * Static client-side redirect stub (meta-refresh + JS), for static hosts
4
+ * where server-side redirects aren't available (e.g. GitHub Pages). Render
5
+ * one of these at each old URL to send visitors/search engines/citations to
6
+ * a new location — typically after a URL scheme change (e.g. adding an
7
+ * i18n locale prefix) that Astro's own config-level `redirects` can't
8
+ * express. See `components/RedirectPage/README.md` for when to reach for
9
+ * this instead of `redirects`, and for the `getStaticPaths` recipe that
10
+ * generates one of these per entry of a content collection.
11
+ */
12
+ interface Props {
13
+ /** Destination URL to redirect to */
14
+ target: string
15
+ /** Page title shown briefly before the redirect fires */
16
+ title?: string
17
+ }
18
+ const { target, title = "Redirecting…" } = Astro.props
19
+ ---
20
+ <!doctype html>
21
+ <html lang="en">
22
+ <head>
23
+ <meta charset="UTF-8" />
24
+ <meta http-equiv="refresh" content={`0; url=${target}`} />
25
+ <link rel="canonical" href={target} />
26
+ <title>{title}</title>
27
+ </head>
28
+ <body>
29
+ <p>Redirecting to <a href={target}>{target}</a>…</p>
30
+ <script is:inline define:vars={{ target }}>
31
+ window.location.replace(target);
32
+ </script>
33
+ </body>
34
+ </html>
package/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Core package exports
2
2
  export { Gallery } from './components/Gallery';
3
3
  export { default as TableOfContents } from './components/TableOfContents/TableOfContents.astro';
4
+ export { default as RedirectPage } from './components/RedirectPage/RedirectPage.astro';
4
5
  export { default as ThemeToggle } from './components/ThemeToggle';
5
6
 
6
7
  // DataTb (recommended table component)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lad-sapienza/scms-core",
3
- "version": "0.1.0-alpha.5",
3
+ "version": "0.1.0-alpha.7",
4
4
  "description": "Framework components and Astro integrations for s:CMS — DataTb, Gallery, Map, ZoteroGeoViewer, Record.",
5
5
  "type": "module",
6
6
  "license": "BSD-0-Clause",