diff --git a/README.md b/README.md
index 46587fe..efe6266 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,8 @@ Please check our [issues page](https://github.com/bellingcat/ukraine-timemap/iss
* `XXXX_EXT` - points to the respective JSONs of the data, for events, sources, and associations
* `MAPBOX_TOKEN` - used to load the custom styles
* `DATE_FMT` and `TIME_FMT` - how to consume the events' date/time from the API
+* `store.app.language` - configures default language
+* `store.app.languages` - configures available languages
* `store.app.map` - configures the initial map view and the UX limits
* `store.app.cluster` - configures how clusters/bubbles are grouped into larger clusters, larger `radius` means bigger cluster bubbles
* `store.app.timeline` - configure timeline ranges, zoom level options, and default range
diff --git a/config.js b/config.js
index c610c88..cee4c10 100644
--- a/config.js
+++ b/config.js
@@ -1,6 +1,10 @@
const one_day = 1440;
module.exports = {
title: "ukraine",
+ page_title: {
+ en: "Civilian Harm in Ukraine",
+ ru: "Ущерб гражданскому населению Украины",
+ },
display_title: "Civilian Harm in Ukraine",
SERVER_ROOT: "https://ukraine.bellingcat.com/ukraine-server",
EVENTS_EXT: "/api/ukraine/export_events/deeprows",
@@ -63,7 +67,6 @@ module.exports = {
'This map plots out and highlights incidents that have resulted in potential civilian impact or harm since Russia began its invasion of Ukraine. The incidents detailed have been collected by Bellingcat researchers. Included in the map are instances where civilian areas and infrastructure have been damaged or destroyed, where the presence of civilian injuries are visible and/or there is the presence of immobile civilian bodies. Collection for the incidences contained in this map began on February 24, 2022. Users can explore incidents by date and location. We intend this to be a living project that will continue to be updated as long as the conflict persists. For more detailed information about the entries included in this map, please refer to our methodology and explainer article which can be read here. ',
"Image left: Vyacheslav Madiyevskyy/Reuters. Image right: Järva Teataja/Scanpix Baltics via Reuters.",
],
-
flags: { isInfopoup: false, isCover: false },
cover: {
title: "About and Methodology",
diff --git a/src/common/data/copy.json b/src/common/data/copy.json
index 6847064..df30d74 100644
--- a/src/common/data/copy.json
+++ b/src/common/data/copy.json
@@ -93,7 +93,8 @@
"warning": "(!) HECHOS CUESTIONADOS"
}
},
- "en-US": {
+ "en": {
+ "language_short": "Eng",
"tiles": {
"default": "Map",
"satellite": "Sat"
@@ -192,5 +193,11 @@
"receiver": "Receiver",
"warning": "(!) Highly questioned"
}
+ },
+ "ru": {
+ "language_short": "Рус"
+ },
+ "uk": {
+ "language_short": "Укр"
}
}
diff --git a/src/common/utilities.js b/src/common/utilities.js
index bdcb50a..c768660 100644
--- a/src/common/utilities.js
+++ b/src/common/utilities.js
@@ -7,7 +7,7 @@ let { DATE_FMT, TIME_FMT } = process.env;
if (!DATE_FMT) DATE_FMT = "MM/DD/YYYY";
if (!TIME_FMT) TIME_FMT = "HH:mm";
-export const language = process.env.store.app.language || "en-US";
+export const language = process.env.store.app.language || "en";
export function getPathLeaf(path) {
const splitPath = path.split("/");
diff --git a/src/components/Toolbar.js b/src/components/Toolbar.js
index 6f9db38..e60b3cd 100644
--- a/src/components/Toolbar.js
+++ b/src/components/Toolbar.js
@@ -23,6 +23,7 @@ import {
} from "../common/utilities.js";
import { ToolbarButton } from "./controls/atoms/ToolbarButton";
import { FullscreenToggle } from "./controls/FullScreenToggle";
+import { LanguageSwitch } from "./controls/LanguageSwitch";
class Toolbar extends React.Component {
constructor(props) {
@@ -274,6 +275,15 @@ class Toolbar extends React.Component {
+
+
+
{narrativesExist
@@ -347,6 +357,7 @@ function mapStateToProps(state) {
narratives: selectors.selectNarratives(state),
shapes: selectors.getShapes(state),
language: state.app.language,
+ languages: state.app.languages,
toolbarCopy: state.app.toolbar,
activeFilters: selectors.getActiveFilters(state),
activeCategories: selectors.getActiveCategories(state),
diff --git a/src/components/controls/Card.js b/src/components/controls/Card.js
index 5dcc051..bb49f73 100644
--- a/src/components/controls/Card.js
+++ b/src/components/controls/Card.js
@@ -84,7 +84,7 @@ export const Card = ({
onSelect = () => {},
sources = [],
isSelected = false,
- language = "en-US",
+ language = "en",
}) => {
const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen(!isOpen);
diff --git a/src/components/controls/LanguageSwitch.js b/src/components/controls/LanguageSwitch.js
new file mode 100644
index 0000000..32ac530
--- /dev/null
+++ b/src/components/controls/LanguageSwitch.js
@@ -0,0 +1,24 @@
+import { createElement } from "react";
+import copy from "../../common/data/copy.json";
+
+export function LanguageSwitch({
+ language: currentLanguage,
+ languages,
+ actions: { toggleLanguage },
+}) {
+ if (!languages || languages.length <= 1) return null;
+ return createElement("div", {
+ className: "language-switch",
+ onClick: () => toggleLanguage(),
+ children: languages.map((language) =>
+ createElement("span", {
+ key: language,
+ className:
+ language !== currentLanguage
+ ? "language-option"
+ : "language-option selected",
+ children: copy[language].language_short,
+ })
+ ),
+ });
+}
diff --git a/src/index.jsx b/src/index.jsx
index ae17d6d..b983407 100644
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -3,6 +3,14 @@ import ReactDOM from "react-dom/client";
import { Provider } from "react-redux";
import store from "./store";
import App from "./components/App";
+import copy from "./common/data/copy.json";
+
+// XXX: Hack to make migration from "copy.json" and
+// adding missing translation strings smoother.
+Object.assign(copy, {
+ ru: { ...copy["en"], ...copy["uk"], ...copy["ru"] },
+ uk: { ...copy["en"], ...copy["ru"], ...copy["uk"] },
+});
const root = ReactDOM.createRoot(document.getElementById("explore-app"));
root.render(
@@ -11,6 +19,21 @@ root.render(
);
+store.subscribe(() => {
+ const { app } = store.getState();
+ renderAppLanguage(app);
+});
+
+// Update language in places that are out of the App's reach
+function renderAppLanguage({ language }) {
+ const html = document.documentElement;
+ if (language && language !== html.lang) {
+ html.lang = language;
+ const title = process.env.page_title[language];
+ if (title) document.title = title;
+ }
+}
+
// Expressions from https://exceptionshub.com/how-to-detect-safari-chrome-ie-firefox-and-opera-browser.html
/* eslint-disable */
diff --git a/src/reducers/app.js b/src/reducers/app.js
index 41cda86..c52f619 100644
--- a/src/reducers/app.js
+++ b/src/reducers/app.js
@@ -227,10 +227,15 @@ function updateDimensions(appState, action) {
}
function toggleLanguage(appState, action) {
- const otherLanguage = appState.language === "es-MX" ? "en-US" : "es-MX";
- return Object.assign({}, appState, {
- language: action.language || otherLanguage,
- });
+ return {
+ ...appState,
+ language: action.language || selectNextLanguage(appState),
+ };
+ function selectNextLanguage({ language, languages }) {
+ const currentIndex = appState.languages.indexOf(language);
+ const nextIndex = (currentIndex + 1) % languages.length;
+ return languages[nextIndex];
+ }
}
function updateSource(appState, action) {
diff --git a/src/scss/languageswitch.scss b/src/scss/languageswitch.scss
new file mode 100644
index 0000000..f6948ec
--- /dev/null
+++ b/src/scss/languageswitch.scss
@@ -0,0 +1,22 @@
+.language-switch {
+ padding: 1.5em 1em;
+ color: $midwhite;
+ text-transform: uppercase;
+ cursor: pointer;
+ user-select: none;
+ .language-option {
+ padding: 0.5em 0.25em;
+ transition: 0.2s ease;
+ border-bottom: solid 2px transparent;
+ &.selected {
+ font-weight: bold;
+ border-bottom-color: $midwhite;
+ color: $offwhite;
+ }
+ }
+ &:hover {
+ .language-option {
+ border-bottom-color: $offwhite;
+ }
+ }
+}
diff --git a/src/scss/main.scss b/src/scss/main.scss
index bc64558..8329d12 100644
--- a/src/scss/main.scss
+++ b/src/scss/main.scss
@@ -14,3 +14,4 @@
@import "cover";
@import "search";
@import "satelliteoverlaytoggle";
+@import "languageswitch";
diff --git a/src/store/initial.js b/src/store/initial.js
index 36bb5cf..cc98318 100644
--- a/src/store/initial.js
+++ b/src/store/initial.js
@@ -66,7 +66,8 @@ const initial = {
},
},
shapes: [],
- language: "en-US",
+ language: "en",
+ languages: ["en", "ru", "uk"],
cluster: {
radius: 30,
minZoom: 2,