Skip to content

Commit

Permalink
Add user preferences dialog and persist language setting as cookie
Browse files Browse the repository at this point in the history
  • Loading branch information
cdauth committed Apr 4, 2024
1 parent bbbfe83 commit 0878fb3
Show file tree
Hide file tree
Showing 18 changed files with 302 additions and 39 deletions.
6 changes: 5 additions & 1 deletion client/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { io, type ManagerOptions, type Socket as SocketIO, type SocketOptions } from "socket.io-client";
import { type Bbox, type BboxWithZoom, type CRU, type EventHandler, type EventName, type FindOnMapQuery, type FindPadsQuery, type FindPadsResult, type FindQuery, type GetPadQuery, type HistoryEntry, type ID, type Line, type LineExportRequest, type LineTemplateRequest, type LineToRouteCreate, type SocketEvents, type Marker, type MultipleEvents, type ObjectWithId, type PadData, type PadId, type PagedResults, type SocketRequest, type SocketRequestName, type SocketResponse, type Route, type RouteClear, type RouteCreate, type RouteExportRequest, type RouteInfo, type RouteRequest, type SearchResult, type SocketVersion, type TrackPoint, type Type, type View, type Writable, type SocketClientToServerEvents, type SocketServerToClientEvents, type LineTemplate, type LinePointsEvent, PadNotFoundError } from "facilmap-types";
import { type Bbox, type BboxWithZoom, type CRU, type EventHandler, type EventName, type FindOnMapQuery, type FindPadsQuery, type FindPadsResult, type FindQuery, type GetPadQuery, type HistoryEntry, type ID, type Line, type LineExportRequest, type LineTemplateRequest, type LineToRouteCreate, type SocketEvents, type Marker, type MultipleEvents, type ObjectWithId, type PadData, type PadId, type PagedResults, type SocketRequest, type SocketRequestName, type SocketResponse, type Route, type RouteClear, type RouteCreate, type RouteExportRequest, type RouteInfo, type RouteRequest, type SearchResult, type SocketVersion, type TrackPoint, type Type, type View, type Writable, type SocketClientToServerEvents, type SocketServerToClientEvents, type LineTemplate, type LinePointsEvent, PadNotFoundError, type SetLanguageRequest } from "facilmap-types";
import { deserializeError, errorConstructors } from "serialize-error";

export interface ClientEvents extends SocketEvents<SocketVersion.V2> {
Expand Down Expand Up @@ -355,6 +355,10 @@ export default class Client {
return await this._setPadId(padId);
}

async setLanguage(language: SetLanguageRequest): Promise<void> {
await this._emit("setLanguage", language);
}

async updateBbox(bbox: BboxWithZoom): Promise<MultipleEvents<SocketEvents<SocketVersion.V2>>> {
const isZoomChange = this.bbox && bbox.zoom !== this.bbox.zoom;

Expand Down
1 change: 1 addition & 0 deletions docs/src/developers/embed.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ You can control the display of different components by using the following query
* `autofocus`: Autofocus the search field (default: `false`)
* `legend`: Show the legend if available (default: `true`)
* `interactive`: Enable [interactive mode](#interactive-mode) (default: `false`)
* `lang`: Fix the user interface to one particular language, for example `en`. If not specified, the user language is auto-detected.

Example:

Expand Down
4 changes: 2 additions & 2 deletions docs/src/developers/i18n.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# I18n

FacilMap uses [i18next](https://www.i18next.com/) for internationalization throughout the frontend, the server and its libraries. It detects the desired user language like this:
* In the browser, [i18next-browser-languageDetector](https://github.com/i18next/i18next-browser-languageDetector) is used to detect the user’s language. It looks at the configured browser languages ([`navigator.languages`](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/languages)) and checks for which one a translation exists. The configured language can be overridden by setting an `i18next` item in local/session storage or the cookies or appending an `?lng=` query parameter to the URL.
* On the server, when a request is handled through HTTP (including the WebSocket), [i18next-http-middleware](https://www.npmjs.com/package/i18next-http-middleware) is used to detect the user’s language. It looks at the configured browser languages ([`Accept-Language`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language)) and checks for which one a translation exists. The configured language can be overridden by setting an `i18next` cookie or by appending an `?lng=` query parameter to the URL. The server stores the selected language in the [Node.js domain](https://nodejs.org/api/domain.html) that is created for each incoming request, causing all functions triggered (sync or async) from the request to use the language setting of the request.
* In the browser, [i18next-browser-languageDetector](https://github.com/i18next/i18next-browser-languageDetector) is used to detect the user’s language. It looks at the configured browser languages ([`navigator.languages`](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/languages)) and checks for which one a translation exists. The configured language can be overridden by setting a `lang` cookie or appending a `?lang=` query parameter to the URL.
* On the server, when a request is handled through HTTP (including the WebSocket), [i18next-http-middleware](https://www.npmjs.com/package/i18next-http-middleware) is used to detect the user’s language. It looks at the configured browser languages ([`Accept-Language`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language)) and checks for which one a translation exists. The configured language can be overridden by setting a `lang` cookie or by appending a `?lang=` query parameter to the URL. The server stores the selected language in the [Node.js domain](https://nodejs.org/api/domain.html) that is created for each incoming request, causing all functions triggered (sync or async) from the request to use the language setting of the request.
* On the sever, when a function is called outside of an incoming HTTP request, messages are not internationalized and output in English.

## Use FacilMap in an app not using i18next
Expand Down
2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"hammerjs": "^2.0.8",
"i18next": "^23.10.1",
"jquery": "^3.7.1",
"js-cookie": "^3.0.5",
"leaflet": "^1.9.4",
"leaflet-draggable-lines": "^2.0.0",
"leaflet-graphicscale": "^0.0.4",
Expand Down Expand Up @@ -82,6 +83,7 @@
"@types/file-saver": "^2.0.7",
"@types/hammerjs": "^2.0.45",
"@types/jquery": "^3.5.29",
"@types/js-cookie": "^3.0.6",
"@types/leaflet": "^1.9.8",
"@types/leaflet-mouse-position": "^1.2.4",
"@types/leaflet.locatecontrol": "^0.74.4",
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ const messagesDe = {
"close": "Schließen",
"cancel": "Abbrechen",
"save": "Speichern"
},

"user-preferences-dialog": {
"title": `Benutzereinstellungen`,
"introduction": `Diese Einstellungen werden als Cookies auf Ihrem Computer gespeichert und werden unabhängig von der geöffneten Karte angewendet.`,
"language": `Sprache`
}
};

Expand Down
6 changes: 6 additions & 0 deletions frontend/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ const messagesEn = {
"close": "Close",
"cancel": "Cancel",
"save": "Save"
},

"user-preferences-dialog": {
"title": `User preferences`,
"introduction": `These settings are stored on your computer as a cookie and are applied independently of the opened map.`,
"language": `Language`
}
};

Expand Down
7 changes: 6 additions & 1 deletion frontend/src/lib/components/client-provider.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import type { ClientContext } from "./facil-map-context-provider/client-context";
import { injectContextRequired } from "./facil-map-context-provider/facil-map-context-provider.vue";
import { useI18n } from "../utils/i18n";
import { getCurrentLanguage } from "facilmap-utils";
function isPadNotFoundError(serverError: Client["serverError"]): boolean {
return !!serverError && serverError instanceof PadNotFoundError;
Expand Down Expand Up @@ -57,7 +58,11 @@
}
}
const newClient = new CustomClient(props.serverUrl, props.padId);
const newClient = new CustomClient(props.serverUrl, props.padId, {
query: {
lang: getCurrentLanguage()
}
});
connectingClient.value = newClient;
let lastPadId: PadId | undefined = undefined;
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/lib/components/toolbox/toolbox-tools-dropdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import DropdownMenu from "../ui/dropdown-menu.vue";
import { injectContextRequired, requireClientContext } from "../facil-map-context-provider/facil-map-context-provider.vue";
import ExportDialog from "../export-dialog.vue";
import UserPreferencesDialog from "../user-preferences-dialog.vue";
const context = injectContextRequired();
const client = requireClientContext(context);
Expand All @@ -26,6 +27,7 @@
| "export"
| "edit-filter"
| "history"
| "user-preferences"
>();
</script>

Expand Down Expand Up @@ -95,6 +97,19 @@
draggable="false"
>History</a>
</li>

<li>
<hr class="dropdown-divider">
</li>

<li>
<a
class="dropdown-item"
href="javascript:"
@click="dialog = 'user-preferences'; emit('hide-sidebar')"
draggable="false"
>User preferences</a>
</li>
</DropdownMenu>

<PadSettingsDialog
Expand Down Expand Up @@ -123,4 +138,9 @@
v-if="dialog === 'history' && client.padData"
@hidden="dialog = undefined"
></HistoryDialog>

<UserPreferencesDialog
v-if="dialog === 'user-preferences'"
@hidden="dialog = undefined"
></UserPreferencesDialog>
</template>
65 changes: 65 additions & 0 deletions frontend/src/lib/components/user-preferences-dialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { LANGUAGES, getCurrentLanguage } from "facilmap-utils";
import ModalDialog from "./ui/modal-dialog.vue";
import { computed, reactive, ref, toRef } from "vue";
import { useI18n } from "../utils/i18n";
import { getUniqueId } from "../utils/utils";
import { setLangCookie } from "../utils/cookies";
import { isEqual } from "lodash-es";
import { injectContextOptional } from "./facil-map-context-provider/facil-map-context-provider.vue";
const i18n = useI18n();
const context = injectContextOptional();
const client = toRef(() => context?.components.client);
const emit = defineEmits<{
hidden: [];
}>();
const modalRef = ref<InstanceType<typeof ModalDialog>>();
const id = getUniqueId("fm-user-preferences-dialog");
const initialValues = {
lang: getCurrentLanguage()
};
const values = reactive({ ...initialValues });
const isModified = computed(() => {
return !isEqual(values, reactive(initialValues));
});
async function save() {
await setLangCookie(values.lang);
if (client.value) {
await client.value.setLanguage({ lang: values.lang });
}
await i18n.changeLanguage(values.lang);
modalRef.value?.modal.hide();
}
</script>

<template>
<ModalDialog
:title="i18n.t('user-preferences-dialog.title')"
class="fm-user-preferences"
:isModified="isModified"
@submit="save"
ref="modalRef"
@hidden="emit('hidden')"
>
<p>{{i18n.t("user-preferences-dialog.introduction")}}</p>

<div class="row mb-3">
<label :for="`${id}-language-input`" class="col-sm-3 col-form-label">{{i18n.t("user-preferences-dialog.language")}}</label>
<div class="col-sm-9">
<select :id="`${id}-language-input`" class="form-select" v-model="values.lang">
<option v-for="(label, key) in LANGUAGES" :key="key" :value="key">{{label}}</option>
</select>
</div>
</div>
</ModalDialog>
</template>
47 changes: 47 additions & 0 deletions frontend/src/lib/utils/cookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import Cookies from "js-cookie";
import { computed, reactive, readonly, ref } from "vue";

export interface Cookies {
lang?: string;
}

const cookieCounter = ref(0);

function cookie(name: string) {
return computed(() => {
cookieCounter.value;
return Cookies.get(name);
});
}

export const cookies = readonly(reactive({
lang: cookie("lang")
}));

const hasStorageAccessP = (async () => {
if ("hasStorageAccess" in document) {
return await document.hasStorageAccess();
} else {
return true;
}
})();

async function setLongTermCookie(name: keyof Cookies, value: string): Promise<void> {
try {
Cookies.set(name, value, {
expires: 3650,
partitioned: !(await hasStorageAccessP)
});
} finally {
cookieCounter.value++;
}
}

export async function setLangCookie(value: string): Promise<void> {
await setLongTermCookie("lang", value);
}

// Renew long-term cookies (see https://developer.chrome.com/blog/cookie-max-age-expires)
if (cookies.lang) {
void setLangCookie(cookies.lang);
}
11 changes: 9 additions & 2 deletions frontend/src/lib/utils/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,21 @@ onI18nReady((i18n) => {
i18n.on("loaded", rerender);
});

export function useI18n(): Pick<i18n, "t"> {
export function useI18n(): {
t: i18n["t"];
changeLanguage: (lang: string) => Promise<void>;
} {
return {
t: new Proxy(getRawI18n().getFixedT(null, namespace), {
apply: (target, thisArg, argumentsList) => {
rerenderCounter.value;
return target.apply(thisArg, argumentsList as any);
}
})
}),

changeLanguage: async (lang) => {
await getRawI18n().changeLanguage(lang);
}
};
}

Expand Down
Loading

0 comments on commit 0878fb3

Please sign in to comment.