@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
|
@@ -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
|
-
|
|
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">
|
|
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 {
|
|
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
|
|
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">{
|
|
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">{
|
|
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 ?
|
|
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
|
-
|
|
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">
|
|
284
|
+
<span className="visually-hidden">{t('map.search.loading')}</span>
|
|
260
285
|
</span>
|
|
261
286
|
) : (
|
|
262
287
|
<Search size={16} />
|
|
263
288
|
)}
|
|
264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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">
|
|
84
|
+
<span className="visually-hidden">{t('map.search.loading')}</span>
|
|
83
85
|
</span>
|
|
84
86
|
) : (
|
|
85
87
|
<Search size={16} />
|
|
86
88
|
)}
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
+
{t('map.search.advancedSearch')}
|
|
110
112
|
</button>
|
|
111
113
|
</div>
|
|
112
114
|
)}
|
package/components/README.md
CHANGED
|
@@ -56,7 +56,7 @@ Core components should be:
|
|
|
56
56
|
|
|
57
57
|
## User Customization
|
|
58
58
|
|
|
59
|
-
Users should **not**
|
|
60
|
-
- Override behavior in `
|
|
61
|
-
- Create custom components in `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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">
|
|
18
|
-
<p className="mb-0">
|
|
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">
|
|
37
|
-
<p className="mb-1">
|
|
38
|
-
<p className="small mb-0">
|
|
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">
|
|
307
|
+
<span className="visually-hidden">{t('zoteroGeoViewer.loading')}</span>
|
|
304
308
|
</div>
|
|
305
|
-
<span className="ms-2">
|
|
306
|
-
<div className="ms-3 small text-secondary">
|
|
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">
|
|
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">
|
|
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">
|
|
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: '
|
|
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"
|
|
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="
|
|
386
|
-
<h3 className="fw-semibold mb-3">
|
|
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">
|
|
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">
|
|
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="
|
|
401
|
-
<h3 className="fw-semibold mb-3">
|
|
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=
|
|
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
|
|
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
|
-
|
|
436
|
-
{tag.alts.length > 3 && `
|
|
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
|
-
|
|
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">
|
|
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
|
-
|
|
484
|
+
{t('zoteroGeoViewer.search.alsoIncludes', { list: selectedTag.alternatives.join(', ') })}
|
|
466
485
|
</p>
|
|
467
486
|
)}
|
|
468
487
|
<p className="small mb-0">
|
|
469
|
-
|
|
488
|
+
{t('zoteroGeoViewer.search.helpText')}
|
|
470
489
|
</p>
|
|
471
490
|
</div>
|
|
472
491
|
)}
|