@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
@@ -2,6 +2,7 @@ import { useState } from 'react';
2
2
  import type { SearchInFields, SearchQuery, SearchOperators } from '../types';
3
3
  import { SearchUISimple } from './SearchUISimple';
4
4
  import { SearchUIAdvanced } from './SearchUIAdvanced';
5
+ import { useTranslation } from '../../i18n';
5
6
 
6
7
  export interface SearchUIProps {
7
8
  fieldList: SearchInFields;
@@ -24,6 +25,7 @@ export function SearchUI({
24
25
  limitTo,
25
26
  currentQuery
26
27
  }: SearchUIProps) {
28
+ const t = useTranslation();
27
29
  const [isAdvanced, setIsAdvanced] = useState(limitTo === 'advanced');
28
30
 
29
31
  const toggleMode = () => {
@@ -34,7 +36,7 @@ export function SearchUI({
34
36
  if (!fieldList || Object.keys(fieldList).length === 0) {
35
37
  return (
36
38
  <div className="text-danger small">
37
- No searchable fields configured for this layer.
39
+ {t('map.search.noFieldsConfigured')}
38
40
  </div>
39
41
  );
40
42
  }
@@ -47,7 +49,7 @@ export function SearchUI({
47
49
  {isLoading && (
48
50
  <div className="text-center mb-3">
49
51
  <div className="spinner-border text-primary" role="status">
50
- <span className="visually-hidden">Loading...</span>
52
+ <span className="visually-hidden">{t('map.search.loading')}</span>
51
53
  </div>
52
54
  </div>
53
55
  )}
@@ -1,7 +1,7 @@
1
1
  import { useState, type SyntheticEvent } from 'react';
2
2
  import { Search, X, Plus, Minus, ArrowLeft } from 'lucide-react';
3
3
  import type { SearchInFields, SearchQuery, SearchFilter, SearchOperators } from '../types';
4
- import { DEFAULT_OPERATORS, DEFAULT_CONNECTORS } from './SearchOperators';
4
+ import { useTranslation } from '../../i18n';
5
5
 
6
6
  export interface SearchUIAdvancedProps {
7
7
  fieldList: SearchInFields;
@@ -24,8 +24,33 @@ export function SearchUIAdvanced({
24
24
  onToggleSimple,
25
25
  currentQuery
26
26
  }: SearchUIAdvancedProps) {
27
- const mergedOperators = { ...DEFAULT_OPERATORS, ...operators };
28
-
27
+ const t = useTranslation();
28
+ // Translated defaults for the operator/connector *labels* only — the
29
+ // underscore-prefixed keys are Directus's own filter-operator wire
30
+ // format, unaffected by translation. An explicit `operators` prop still
31
+ // overrides these, same precedence as before.
32
+ const translatedOperators: SearchOperators = {
33
+ _eq: t('map.search.operators.eq'),
34
+ _neq: t('map.search.operators.neq'),
35
+ _lt: t('map.search.operators.lt'),
36
+ _lte: t('map.search.operators.lte'),
37
+ _gt: t('map.search.operators.gt'),
38
+ _gte: t('map.search.operators.gte'),
39
+ _null: t('map.search.operators.null'),
40
+ _nnull: t('map.search.operators.nnull'),
41
+ _contains: t('map.search.operators.contains'),
42
+ _icontains: t('map.search.operators.icontains'),
43
+ _ncontains: t('map.search.operators.ncontains'),
44
+ _starts_with: t('map.search.operators.startsWith'),
45
+ _istarts_with: t('map.search.operators.istartsWith'),
46
+ _nstarts_with: t('map.search.operators.nstartsWith'),
47
+ _ends_with: t('map.search.operators.endsWith'),
48
+ _iends_with: t('map.search.operators.iendsWith'),
49
+ _nends_with: t('map.search.operators.nendsWith'),
50
+ };
51
+ const translatedConnectors = { _and: t('map.search.connectors.and'), _or: t('map.search.connectors.or') };
52
+ const mergedOperators = { ...translatedOperators, ...operators };
53
+
29
54
  // Initialize state from currentQuery if provided
30
55
  const getInitialConnector = (): '_and' | '_or' => {
31
56
  return currentQuery?.connector || '_and';
@@ -140,7 +165,7 @@ export function SearchUIAdvanced({
140
165
  onChange={(e) => setConnector(e.target.value as '_and' | '_or')}
141
166
  className="form-check-input"
142
167
  />
143
- <span className="small">{DEFAULT_CONNECTORS._and}</span>
168
+ <span className="small">{translatedConnectors._and}</span>
144
169
  </label>
145
170
 
146
171
  <label className="d-flex align-items-center gap-2">
@@ -152,7 +177,7 @@ export function SearchUIAdvanced({
152
177
  onChange={(e) => setConnector(e.target.value as '_and' | '_or')}
153
178
  className="form-check-input"
154
179
  />
155
- <span className="small">{DEFAULT_CONNECTORS._or}</span>
180
+ <span className="small">{translatedConnectors._or}</span>
156
181
  </label>
157
182
  </div>
158
183
  </div>
@@ -226,7 +251,7 @@ export function SearchUIAdvanced({
226
251
  className="btn btn-danger btn-sm"
227
252
  onClick={() => removeFilter(index)}
228
253
  disabled={isLoading || filters.length === 1}
229
- title={filters.length === 1 ? "Cannot remove the last filter" : "Remove filter"}
254
+ title={filters.length === 1 ? t('map.search.cannotRemoveLastFilter') : t('map.search.removeFilter')}
230
255
  >
231
256
  <Minus size={16} />
232
257
  </button>
@@ -243,34 +268,34 @@ export function SearchUIAdvanced({
243
268
  disabled={isLoading}
244
269
  >
245
270
  <Plus size={16} />
246
- Add Filter
271
+ {t('map.search.addFilter')}
247
272
  </button>
248
273
  </div>
249
274
 
250
275
  {/* Actions */}
251
276
  <div className="d-flex gap-2 mt-3">
252
- <button
253
- type="submit"
277
+ <button
278
+ type="submit"
254
279
  className="btn btn-primary btn-sm"
255
280
  disabled={isLoading}
256
281
  >
257
282
  {isLoading ? (
258
283
  <span className="spinner-border spinner-border-sm" role="status">
259
- <span className="visually-hidden">Loading...</span>
284
+ <span className="visually-hidden">{t('map.search.loading')}</span>
260
285
  </span>
261
286
  ) : (
262
287
  <Search size={16} />
263
288
  )}
264
- Search
289
+ {t('map.search.submit')}
265
290
  </button>
266
- <button
267
- type="button"
291
+ <button
292
+ type="button"
268
293
  className="btn btn-secondary btn-sm"
269
294
  onClick={handleClear}
270
295
  disabled={isLoading || !hasFilters()}
271
296
  >
272
297
  <X size={16} />
273
- Clear
298
+ {t('map.search.clear')}
274
299
  </button>
275
300
  {onToggleSimple && (
276
301
  <button
@@ -279,7 +304,7 @@ export function SearchUIAdvanced({
279
304
  onClick={onToggleSimple}
280
305
  >
281
306
  <ArrowLeft size={16} />
282
- Simple Search
307
+ {t('map.search.simpleSearch')}
283
308
  </button>
284
309
  )}
285
310
  </div>
@@ -1,6 +1,7 @@
1
1
  import { useState, type SyntheticEvent } from 'react';
2
2
  import { Search, X, TextSearch } from 'lucide-react';
3
3
  import type { SearchInFields, SearchQuery, SearchFilter } from '../types';
4
+ import { useTranslation } from '../../i18n';
4
5
 
5
6
  export interface SearchUISimpleProps {
6
7
  fieldList: SearchInFields;
@@ -21,6 +22,7 @@ export function SearchUISimple({
21
22
  onToggleAdvanced,
22
23
  currentQuery
23
24
  }: SearchUISimpleProps) {
25
+ const t = useTranslation();
24
26
  // Extract search text from currentQuery if it exists and is a simple search (all filters have same value)
25
27
  const getInitialSearchText = () => {
26
28
  if (!currentQuery?.filters?.length) return '';
@@ -67,7 +69,7 @@ export function SearchUISimple({
67
69
  <input
68
70
  type="text"
69
71
  className="form-control form-control-sm"
70
- placeholder="Search..."
72
+ placeholder={t('map.search.placeholder')}
71
73
  value={searchText}
72
74
  onChange={(e) => setSearchText(e.target.value)}
73
75
  disabled={isLoading}
@@ -79,34 +81,34 @@ export function SearchUISimple({
79
81
  >
80
82
  {isLoading ? (
81
83
  <span className="spinner-border spinner-border-sm" role="status">
82
- <span className="visually-hidden">Loading...</span>
84
+ <span className="visually-hidden">{t('map.search.loading')}</span>
83
85
  </span>
84
86
  ) : (
85
87
  <Search size={16} />
86
88
  )}
87
- Search
89
+ {t('map.search.submit')}
88
90
  </button>
89
- <button
90
- type="button"
91
+ <button
92
+ type="button"
91
93
  className="btn btn-secondary btn-sm"
92
94
  onClick={handleClear}
93
95
  disabled={isLoading || !searchText}
94
96
  >
95
97
  <X size={16} />
96
- Clear
98
+ {t('map.search.clear')}
97
99
  </button>
98
100
  </div>
99
101
  </form>
100
-
102
+
101
103
  {onToggleAdvanced && (
102
104
  <div className="text-center">
103
- <button
105
+ <button
104
106
  type="button"
105
107
  className="btn btn-sm btn-link"
106
108
  onClick={onToggleAdvanced}
107
109
  >
108
110
  <TextSearch size={16} />
109
- Advanced Search
111
+ {t('map.search.advancedSearch')}
110
112
  </button>
111
113
  </div>
112
114
  )}
@@ -56,7 +56,7 @@ Core components should be:
56
56
 
57
57
  ## User Customization
58
58
 
59
- Users should **not** edit core components directly. Instead:
60
- - Override behavior in `usr/layouts/`
61
- - Create custom components in `usr/components/`
59
+ Users should **not** fork these components. Instead:
60
+ - Override behavior in `src/layouts/`
61
+ - Create custom components in `src/components/`
62
62
  - Extend layouts with slots and composition
@@ -1,5 +1,6 @@
1
1
  import { useState, useEffect } from 'react';
2
2
  import type { ComponentType } from 'react';
3
+ import { useTranslation } from '../i18n';
3
4
 
4
5
  export interface RecordFetcherProps {
5
6
  /** Directus base URL. Defaults to PUBLIC_DIRECTUS_URL env var. */
@@ -23,17 +24,19 @@ export interface RecordFetcherProps {
23
24
  }
24
25
 
25
26
  function DefaultLoading() {
27
+ const t = useTranslation();
26
28
  return (
27
29
  <div style={{ padding: '2rem', opacity: 0.5 }}>
28
- Loading record&hellip;
30
+ {t('record.loading')}
29
31
  </div>
30
32
  );
31
33
  }
32
34
 
33
35
  function DefaultError({ table, id }: { table: string; id: string }) {
36
+ const t = useTranslation();
34
37
  return (
35
38
  <div style={{ padding: '2rem', color: 'red' }}>
36
- Record not found for table &ldquo;{table}&rdquo; with ID &ldquo;{id}&rdquo;.
39
+ {t('record.notFound', { table, id })}
37
40
  </div>
38
41
  );
39
42
  }
@@ -19,7 +19,7 @@ const { maxDepth = 3, minDepth = 2, title = "Table of Contents" } = Astro.props;
19
19
  const tocId = `toc-${Math.random().toString(36).substring(7)}`;
20
20
  ---
21
21
 
22
- <nav id={tocId} class="toc bg-light border border-secondary rounded p-3 mb-4 shadow-sm">
22
+ <nav id={tocId} class="toc border rounded p-3 mb-4 shadow-sm">
23
23
  <h2 class="h5 mb-3 text-secondary">{title}</h2>
24
24
  <ul class="toc-list small"></ul>
25
25
  </nav>
@@ -27,7 +27,6 @@ const tocId = `toc-${Math.random().toString(36).substring(7)}`;
27
27
  <style>
28
28
  .toc {
29
29
  background-color: var(--bs-light);
30
- border-color: var(--bs-secondary);
31
30
  }
32
31
 
33
32
  a.toc-link {
@@ -0,0 +1,145 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { Sun, Moon, Monitor } from "lucide-react";
3
+
4
+ /** Must match the key the `scms()` integration's anti-FOUC head script reads/writes — see `scms.ts`. */
5
+ const STORAGE_KEY = "scms-theme";
6
+
7
+ type ThemePreference = "system" | "light" | "dark";
8
+ type ResolvedTheme = "light" | "dark";
9
+
10
+ /** Click order: System -> Light -> Dark -> System -> ... */
11
+ const CYCLE: ThemePreference[] = ["system", "light", "dark"];
12
+
13
+ const LABELS: Record<ThemePreference, string> = {
14
+ system: "Auto (follows system)",
15
+ light: "Light",
16
+ dark: "Dark",
17
+ };
18
+
19
+ function prefersDark(): boolean {
20
+ return window.matchMedia("(prefers-color-scheme: dark)").matches;
21
+ }
22
+
23
+ /** Reads the stored preference, defaulting to "system" for anything else (absent, or a stale/foreign value). */
24
+ function readStoredPreference(): ThemePreference {
25
+ try {
26
+ const stored = localStorage.getItem(STORAGE_KEY);
27
+ return stored === "light" || stored === "dark" ? stored : "system";
28
+ } catch {
29
+ return "system";
30
+ }
31
+ }
32
+
33
+ function resolveTheme(preference: ThemePreference): ResolvedTheme {
34
+ return preference === "system" ? (prefersDark() ? "dark" : "light") : preference;
35
+ }
36
+
37
+ function applyTheme(theme: ResolvedTheme) {
38
+ document.documentElement.setAttribute("data-bs-theme", theme);
39
+ }
40
+
41
+ /** Optional CSS class override for the toggle button. */
42
+ interface ThemeToggleProps {
43
+ /** Additional CSS classes applied to the toggle button. */
44
+ className?: string;
45
+ }
46
+
47
+ /**
48
+ * Floating three-state (system / light / dark) theme toggle. Clicking cycles
49
+ * through the three; while on "system" the resolved theme follows the OS's
50
+ * `prefers-color-scheme` live, including changes made after the page loaded
51
+ * (e.g. an OS's scheduled light/dark switch) — this is the standard pattern
52
+ * (GitHub, VS Code, Bootstrap's own docs all default to "system" and only
53
+ * pin to an explicit theme once the user overrides it).
54
+ *
55
+ * Flips Bootstrap 5.3's `data-bs-theme` attribute on `<html>` and persists
56
+ * the choice to `localStorage` ("system" is stored as the *absence* of a
57
+ * key, so a user who never touches the toggle keeps following the OS
58
+ * indefinitely). Pairs with the inline anti-FOUC script `scms()` injects
59
+ * into every page's `<head>` (see `scms.ts`), which sets the same attribute
60
+ * from `localStorage`/`prefers-color-scheme` before first paint.
61
+ *
62
+ * A site's own `global.css` supplies the actual dark palette as
63
+ * `[data-bs-theme="dark"]` overrides of its light-mode custom properties;
64
+ * this component only owns the toggle mechanism.
65
+ *
66
+ * Must be hydrated with `client:load`.
67
+ *
68
+ * @example
69
+ * ```astro
70
+ * <ThemeToggle client:load />
71
+ * ```
72
+ */
73
+ const ThemeToggle: React.FC<ThemeToggleProps> = ({ className }) => {
74
+ // Starts "system" on both server and first client render so hydration
75
+ // never mismatches; corrected to the real stored value in the effect
76
+ // below, which only runs client-side after `document`/`localStorage` exist.
77
+ const [preference, setPreference] = useState<ThemePreference>("system");
78
+
79
+ useEffect(() => {
80
+ setPreference(readStoredPreference());
81
+
82
+ // Live-follow the OS while (and only while) "system" is the active
83
+ // preference. Reads localStorage directly at fire time rather than
84
+ // closing over `preference`, so this stays correct without having to
85
+ // resubscribe every time the preference changes.
86
+ const media = window.matchMedia("(prefers-color-scheme: dark)");
87
+ const handleSystemChange = () => {
88
+ if (readStoredPreference() === "system") {
89
+ applyTheme(resolveTheme("system"));
90
+ }
91
+ };
92
+ media.addEventListener("change", handleSystemChange);
93
+ return () => media.removeEventListener("change", handleSystemChange);
94
+ }, []);
95
+
96
+ const cycle = () => {
97
+ const next = CYCLE[(CYCLE.indexOf(preference) + 1) % CYCLE.length];
98
+ setPreference(next);
99
+ try {
100
+ if (next === "system") {
101
+ localStorage.removeItem(STORAGE_KEY);
102
+ } else {
103
+ localStorage.setItem(STORAGE_KEY, next);
104
+ }
105
+ } catch {
106
+ // localStorage unavailable (private browsing, disabled storage) — theme still applies for this page view.
107
+ }
108
+ applyTheme(resolveTheme(next));
109
+ };
110
+
111
+ const next = CYCLE[(CYCLE.indexOf(preference) + 1) % CYCLE.length];
112
+ const icon =
113
+ preference === "system" ? <Monitor size={18} /> : preference === "dark" ? <Moon size={18} /> : <Sun size={18} />;
114
+
115
+ return (
116
+ <button
117
+ type="button"
118
+ onClick={cycle}
119
+ aria-label={`Theme: ${LABELS[preference]}. Click to switch to ${LABELS[next]}.`}
120
+ title={`Theme: ${LABELS[preference]}`}
121
+ className={className}
122
+ style={{
123
+ position: "fixed",
124
+ bottom: "1.25rem",
125
+ right: "1.25rem",
126
+ zIndex: 1040,
127
+ width: "2.75rem",
128
+ height: "2.75rem",
129
+ borderRadius: "50%",
130
+ border: "1px solid var(--bs-border-color)",
131
+ background: "var(--bs-body-bg)",
132
+ color: "var(--bs-body-color)",
133
+ display: "flex",
134
+ alignItems: "center",
135
+ justifyContent: "center",
136
+ boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)",
137
+ cursor: "pointer",
138
+ }}
139
+ >
140
+ {icon}
141
+ </button>
142
+ );
143
+ };
144
+
145
+ export default ThemeToggle;
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
2
2
  import type { ZoteroGeoViewerProps } from './types';
3
3
  import { Map } from '../Map';
4
4
  import { ZoteroRecordsPreview } from './ZoteroRecordsPreview';
5
+ import { useTranslation } from '../i18n';
5
6
 
6
7
  // Module-level cache to avoid re-fetching data
7
8
  let zoteroCache: Record<string, number> | null = null;
@@ -10,12 +11,15 @@ let coordinateCache: any[] | null = null;
10
11
  const COORDINATES_URL = '/data/zoteroTagCoordinates.geojson';
11
12
 
12
13
  export function ZoteroGeoViewer(props: ZoteroGeoViewerProps) {
14
+ // Hooks must run unconditionally, before any of the early error returns below.
15
+ const t = useTranslation();
16
+
13
17
  // Add error handling for props
14
18
  if (!props) {
15
19
  return (
16
20
  <div className="alert alert-danger">
17
- <h3 className="fw-semibold">Error loading Zotero data</h3>
18
- <p className="mb-0">Component props are undefined</p>
21
+ <h3 className="fw-semibold">{t('zoteroGeoViewer.error.title')}</h3>
22
+ <p className="mb-0">{t('zoteroGeoViewer.error.propsUndefined')}</p>
19
23
  </div>
20
24
  );
21
25
  }
@@ -33,9 +37,9 @@ export function ZoteroGeoViewer(props: ZoteroGeoViewerProps) {
33
37
  if (!groupId) {
34
38
  return (
35
39
  <div className="alert alert-danger">
36
- <h3 className="fw-semibold">Error loading Zotero data</h3>
37
- <p className="mb-1">Group ID is required</p>
38
- <p className="small mb-0">Group ID: {groupId}</p>
40
+ <h3 className="fw-semibold">{t('zoteroGeoViewer.error.title')}</h3>
41
+ <p className="mb-1">{t('zoteroGeoViewer.error.groupIdRequired')}</p>
42
+ <p className="small mb-0">{t('zoteroGeoViewer.groupId', { id: groupId })}</p>
39
43
  </div>
40
44
  );
41
45
  }
@@ -300,10 +304,10 @@ export function ZoteroGeoViewer(props: ZoteroGeoViewerProps) {
300
304
  return (
301
305
  <div className="d-flex align-items-center justify-content-center p-4">
302
306
  <div className="spinner-border text-primary" role="status">
303
- <span className="visually-hidden">Loading...</span>
307
+ <span className="visually-hidden">{t('zoteroGeoViewer.loading')}</span>
304
308
  </div>
305
- <span className="ms-2">Loading Zotero library...</span>
306
- <div className="ms-3 small text-secondary">Group ID: {groupId}</div>
309
+ <span className="ms-2">{t('zoteroGeoViewer.loadingLibrary')}</span>
310
+ <div className="ms-3 small text-secondary">{t('zoteroGeoViewer.groupId', { id: groupId })}</div>
307
311
  </div>
308
312
  );
309
313
  }
@@ -311,15 +315,15 @@ export function ZoteroGeoViewer(props: ZoteroGeoViewerProps) {
311
315
  if (error) {
312
316
  return (
313
317
  <div className="alert alert-danger">
314
- <h3 className="fw-semibold">Error loading Zotero data</h3>
318
+ <h3 className="fw-semibold">{t('zoteroGeoViewer.error.title')}</h3>
315
319
  <p className="small mb-1">{error}</p>
316
- <p className="small mb-0">Group ID: {groupId}</p>
320
+ <p className="small mb-0">{t('zoteroGeoViewer.groupId', { id: groupId })}</p>
317
321
  </div>
318
322
  );
319
323
  }
320
324
 
321
325
  if (!data || !mapped) {
322
- return <div className="p-3 text-secondary">No data loaded</div>;
326
+ return <div className="p-3 text-secondary">{t('zoteroGeoViewer.noDataLoaded')}</div>;
323
327
  }
324
328
 
325
329
  // Prepare GeoJSON for map
@@ -332,8 +336,23 @@ export function ZoteroGeoViewer(props: ZoteroGeoViewerProps) {
332
336
  const totalItems = Object.values(data).reduce((sum, count) => sum + count, 0);
333
337
  const geoItems = featuresWithGeometry.reduce((sum, f) => sum + (f.properties.zoteroCount || 0), 0);
334
338
 
339
+ // The popup below is built as an HTML string for MapLibre's own template
340
+ // substitution (its "${propertyName}" syntax, resolved per-feature when a
341
+ // popup opens — a second, later templating pass, unrelated to this one).
342
+ // t() runs now, at React render time, so `{count}` here means "substitute
343
+ // the translated text" — the literal marker string `${zoteroCount}` inside
344
+ // it is what actually reaches MapLibre, preserved verbatim so it can still
345
+ // do its own substitution afterwards.
346
+ const popupItemsLabel = t('zoteroGeoViewer.popup.items', { count: '${zoteroCount}' });
347
+ const popupShowRecordsLabel = t('zoteroGeoViewer.popup.showRecords', { count: '${zoteroCount}' });
348
+
335
349
  return (
336
350
  <div className="zotero-geo-viewer">
351
+ <style>{`
352
+ .zoterogeo-panel {
353
+ background-color: var(--bs-tertiary-bg);
354
+ }
355
+ `}</style>
337
356
  <div className={getLayoutClasses()}>
338
357
  {/* Map */}
339
358
  <div className={getMapColSpan()}>
@@ -342,7 +361,7 @@ export function ZoteroGeoViewer(props: ZoteroGeoViewerProps) {
342
361
  center={mapCenter}
343
362
  baseLayers={['EsriSatellite', 'GoogleTerrain', 'Imperium']}
344
363
  vectorLayers={mapCenter && geojson && geojson.features.length > 0 ? [{
345
- name: 'Zotero Items',
364
+ name: t('zoteroGeoViewer.layerName'),
346
365
  source: { type: 'geojson', data: geojson },
347
366
  style: {
348
367
  type: 'circle',
@@ -368,7 +387,7 @@ export function ZoteroGeoViewer(props: ZoteroGeoViewerProps) {
368
387
  'circle-stroke-color': '#ffffff'
369
388
  }
370
389
  },
371
- popupTemplate: `<h4>\${name}</h4><p>\${altLabel}</p><div class="text-sm mb-2">Items: \${zoteroCount}</div><button style="background-color: #1d4ed8; color: white; padding: 8px 16px; border: none; border-radius: 6px; font-size: 14px; font-weight: 600; cursor: pointer; margin-top: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.2);" onmouseover="this.style.backgroundColor='#1e40af'" onmouseout="this.style.backgroundColor='#1d4ed8'" type="button" data-tag="\${name}" data-alt-labels="\${altLabel}" onclick="window['${handlerName}'] && window['${handlerName}'](this.dataset.tag, this.dataset.altLabels)">📚 Show \${zoteroCount} records</button>`,
390
+ popupTemplate: `<h4>\${name}</h4><p>\${altLabel}</p><div class="text-sm mb-2">${popupItemsLabel}</div><button style="background-color: #1d4ed8; color: white; padding: 8px 16px; border: none; border-radius: 6px; font-size: 14px; font-weight: 600; cursor: pointer; margin-top: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.2);" onmouseover="this.style.backgroundColor='#1e40af'" onmouseout="this.style.backgroundColor='#1d4ed8'" type="button" data-tag="\${name}" data-alt-labels="\${altLabel}" onclick="window['${handlerName}'] && window['${handlerName}'](this.dataset.tag, this.dataset.altLabels)">${popupShowRecordsLabel}</button>`,
372
391
  visible: true,
373
392
  fitToContent: true
374
393
  }] : []}
@@ -382,27 +401,27 @@ export function ZoteroGeoViewer(props: ZoteroGeoViewerProps) {
382
401
  <div className={getControlsColSpan()}>
383
402
  <div className="mb-3">
384
403
  {/* Library Statistics */}
385
- <div className="bg-light rounded p-3 mb-3">
386
- <h3 className="fw-semibold mb-3">Library Statistics</h3>
404
+ <div className="zoterogeo-panel rounded p-3 mb-3">
405
+ <h3 className="fw-semibold mb-3">{t('zoteroGeoViewer.stats.title')}</h3>
387
406
  <div className="row g-3 small">
388
407
  <div className="col-6 text-center">
389
408
  <div className="h4 fw-bold text-primary">{totalItems}</div>
390
- <div className="text-secondary">Total Items</div>
409
+ <div className="text-secondary">{t('zoteroGeoViewer.stats.totalItems')}</div>
391
410
  </div>
392
411
  <div className="col-6 text-center">
393
412
  <div className="h4 fw-bold text-success">{geoItems}</div>
394
- <div className="text-secondary">Georeferenced</div>
413
+ <div className="text-secondary">{t('zoteroGeoViewer.stats.georeferenced')}</div>
395
414
  </div>
396
415
  </div>
397
416
  </div>
398
417
 
399
418
  {/* Tag Search */}
400
- <div className="bg-white border rounded p-3">
401
- <h3 className="fw-semibold mb-3">Search Tags</h3>
419
+ <div className="zoterogeo-panel border rounded p-3">
420
+ <h3 className="fw-semibold mb-3">{t('zoteroGeoViewer.search.title')}</h3>
402
421
  <div className="position-relative">
403
422
  <input
404
423
  type="text"
405
- placeholder="Search for locations..."
424
+ placeholder={t('zoteroGeoViewer.search.placeholder')}
406
425
  value={searchTerm}
407
426
  onChange={(e) => {
408
427
  setSearchTerm(e.target.value);
@@ -415,7 +434,7 @@ export function ZoteroGeoViewer(props: ZoteroGeoViewerProps) {
415
434
 
416
435
  {/* Simple autocomplete */}
417
436
  {tagAutocomplete && showDropdown && filteredSuggestions.length > 0 && (
418
- <div className="position-absolute w-100 mt-1 bg-white border rounded shadow" style={{zIndex: 1000, maxHeight: '12rem', overflowY: 'auto'}}>
437
+ <div className="zoterogeo-panel position-absolute w-100 mt-1 border rounded shadow" style={{zIndex: 1000, maxHeight: '12rem', overflowY: 'auto'}}>
419
438
  {filteredSuggestions.map((tag, index) => (
420
439
  <button
421
440
  key={index}
@@ -432,8 +451,8 @@ export function ZoteroGeoViewer(props: ZoteroGeoViewerProps) {
432
451
  <div className="fw-medium">{tag.label}</div>
433
452
  {tag.alts.length > 0 && (
434
453
  <div className="small text-secondary">
435
- Also: {tag.alts.slice(0, 3).join(', ')}
436
- {tag.alts.length > 3 && ` +${tag.alts.length - 3} more`}
454
+ {t('zoteroGeoViewer.search.also', { list: tag.alts.slice(0, 3).join(', ') })}
455
+ {tag.alts.length > 3 && ` ${t('zoteroGeoViewer.search.moreCount', { count: tag.alts.length - 3 })}`}
437
456
  </div>
438
457
  )}
439
458
  </button>
@@ -451,7 +470,7 @@ export function ZoteroGeoViewer(props: ZoteroGeoViewerProps) {
451
470
  }}
452
471
  className="mt-2 small btn btn-link p-0"
453
472
  >
454
- Clear search
473
+ {t('zoteroGeoViewer.search.clearSearch')}
455
474
  </button>
456
475
  )}
457
476
  </div>
@@ -459,14 +478,14 @@ export function ZoteroGeoViewer(props: ZoteroGeoViewerProps) {
459
478
  {/* Selected tag info */}
460
479
  {selectedTag && (
461
480
  <div className="alert alert-info">
462
- <h4 className="fw-semibold alert-heading">Selected: {selectedTag.main}</h4>
481
+ <h4 className="fw-semibold alert-heading">{t('zoteroGeoViewer.search.selected', { tag: selectedTag.main })}</h4>
463
482
  {selectedTag.alternatives.length > 0 && (
464
483
  <p className="small mb-1">
465
- Also includes: {selectedTag.alternatives.join(', ')}
484
+ {t('zoteroGeoViewer.search.alsoIncludes', { list: selectedTag.alternatives.join(', ') })}
466
485
  </p>
467
486
  )}
468
487
  <p className="small mb-0">
469
- Use the map markers to explore items at this location.
488
+ {t('zoteroGeoViewer.search.helpText')}
470
489
  </p>
471
490
  </div>
472
491
  )}