allow update for uploaded routes and implement creator id for routes
This commit is contained in:
@@ -336,6 +336,7 @@
|
||||
<h3>5. Deine eigenen Strecken</h3>
|
||||
<p>Neue Strecken bleiben zuerst auf deinem Gerät. Gespeichert werden der Weg, die Orte der Stationen, Texte sowie ausgewählte Bilder und Tonaufnahmen.</p>
|
||||
<p>Deine Entwürfe bleiben erhalten, wenn alter Fortschritt gelöscht wird oder du „Meinen Fortschritt merken“ ausschaltest. Sie bleiben auf diesem Gerät, bis du sie löschst oder die gespeicherten Daten von Wegwichtel in deinem Internetbrowser entfernst. Nur wenn du „Zur Prüfung senden“ wählst, wird deine Strecke an die Website gesendet.</p>
|
||||
<p>Beim ersten Start wird eine zufällige Gerätekennung gespeichert. Sie wird beim Einsenden mit deiner Strecke verknüpft. Dafür werden keine Angaben über dein Gerät oder deinen Internetanbieter gesammelt. Beim Löschen von Entwürfen oder gespeicherten App-Daten bleibt die Kennung erhalten. Wenn du alle Website-Daten im Internetbrowser löschst, geht auch die Kennung verloren.</p>
|
||||
<h3>6. Werbung und gespeicherte Einstellungen</h3>
|
||||
<p>Wegwichtel verwendet keine Cookies. Es zählt keine Besuche für Werbung und verfolgt nicht, welche anderen Seiten du besuchst. Auf deinem Gerät wird gespeichert, ob du „Meinen Fortschritt merken“ eingeschaltet hast.</p>
|
||||
<h3>7. Inhalte von Schülerinnen und Schülern</h3>
|
||||
|
||||
@@ -799,6 +799,7 @@
|
||||
},
|
||||
|
||||
async initialize(context) {
|
||||
ns.LocalRoutes?.creatorId?.();
|
||||
bindEvents();
|
||||
await ns.Api.health();
|
||||
context.markStep('server', 'done');
|
||||
|
||||
+85
-50
@@ -202,6 +202,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
|
||||
function showListView() {
|
||||
ns.RouteRecorder?.endEditing();
|
||||
setDialogTitle('Streckenverwaltung');
|
||||
setHidden(byId(ids.routeListView), false);
|
||||
setHidden(byId(ids.routeDetailView), true);
|
||||
@@ -469,6 +470,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
ns.RouteRecorder?.endEditing();
|
||||
const dialog = byId(ids.dialog);
|
||||
if (dialog?.open && typeof dialog.close === 'function') dialog.close();
|
||||
else if (dialog?.open) dialog.removeAttribute('open');
|
||||
@@ -507,7 +509,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
if (state.currentSource === 'local') {
|
||||
const uploaded = Boolean(route?.serverRouteId);
|
||||
setHidden(uploadButton, false);
|
||||
setDisabled(uploadButton, uploaded);
|
||||
setDisabled(uploadButton, false);
|
||||
if (uploadButton) uploadButton.textContent = uploaded ? ns.I18n.t("editor.submitted") : ns.I18n.t("editor.submit");
|
||||
byId(ids.routeActivated).textContent = uploaded
|
||||
? ns.I18n.t("editor.submitted-hint")
|
||||
@@ -761,6 +763,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
async function loadRoute(route) {
|
||||
const id = routeId(route);
|
||||
if (!id) return;
|
||||
if (ns.RouteRecorder?.routeId && (routeSource(route) !== 'local' || ns.RouteRecorder.routeId !== id)) {
|
||||
ns.RouteRecorder.endEditing();
|
||||
}
|
||||
setStatus(ns.I18n.t("editor.route.loading"));
|
||||
state.currentSource = routeSource(route);
|
||||
|
||||
@@ -773,6 +778,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
gpx: routePackage.gpx,
|
||||
_source: 'local'
|
||||
};
|
||||
if (byId(ids.dialog)?.open) ns.RouteRecorder?.beginEditing(id);
|
||||
state.pois = [...routePackage.pois].sort((left, right) => Number(left.sequence) - Number(right.sequence));
|
||||
} else {
|
||||
const payload = await readJson(`/routes/${encodeURIComponent(id)}`);
|
||||
@@ -789,6 +795,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
|
||||
function newRoute() {
|
||||
ns.RouteRecorder?.endEditing();
|
||||
state.currentSource = 'local';
|
||||
state.currentRoute = {
|
||||
id: '',
|
||||
@@ -839,7 +846,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
});
|
||||
state.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: 'local' };
|
||||
state.currentSource = 'local';
|
||||
if (isNew) ns.RouteRecorder?.start(saved.id);
|
||||
if (isNew && byId(ids.dialog)?.open) ns.RouteRecorder?.beginEditing(saved.id);
|
||||
state.pois = saved.pois;
|
||||
await loadRoutes();
|
||||
await loadRoute({ id: saved.id, _source: 'local' });
|
||||
@@ -884,6 +891,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const data = new FormData();
|
||||
data.append('name', routePackage.route?.name || ns.I18n.t("editor.untitled-route"));
|
||||
data.append('description', routePackage.route?.description || '');
|
||||
data.append('creator_id', routePackage.route?.creator_id || ns.LocalRoutes.creatorId());
|
||||
const uploadGpx = ns.LocalRoutes.gpxForUpload(routePackage);
|
||||
const gpx = new Blob([uploadGpx], { type: 'application/gpx+xml' });
|
||||
data.append('gpx', gpx, safeGpxFilename(routePackage.route?.name));
|
||||
@@ -898,72 +906,89 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
return blob;
|
||||
}
|
||||
|
||||
async function uploadStoredMedia(path, fieldName, media, fallbackName) {
|
||||
async function uploadStoredMedia(path, fieldName, media, fallbackName, method = 'POST') {
|
||||
const blob = await storedMediaBlob(media);
|
||||
if (!blob) return;
|
||||
const data = new FormData();
|
||||
data.append(fieldName, blob, media.name || fallbackName);
|
||||
await sendForm(path, 'POST', data);
|
||||
}
|
||||
|
||||
async function uploadLocalPoiMedia(serverRouteId, serverPoiId, poi) {
|
||||
const basePath = `/routes/${encodeURIComponent(serverRouteId)}/pois/${encodeURIComponent(serverPoiId)}`;
|
||||
if (poi.audio) {
|
||||
await uploadStoredMedia(`${basePath}/audio`, 'audio', poi.audio, 'audio.bin');
|
||||
}
|
||||
for (const [index, image] of (poi.images || []).entries()) {
|
||||
if (!image) continue;
|
||||
await uploadStoredMedia(`${basePath}/pictures`, 'picture', image, `bild-${index + 1}.bin`);
|
||||
}
|
||||
if (fieldName === 'picture') data.append('caption', media.caption || '');
|
||||
return sendForm(path, method, data);
|
||||
}
|
||||
|
||||
async function createServerRouteFromLocal(routePackage) {
|
||||
let createdRouteId = null;
|
||||
try {
|
||||
const checkpoint = () => {
|
||||
const latest = ns.LocalRoutes.get(routePackage.id);
|
||||
// GPS samples may arrive while media requests are pending.
|
||||
if (latest) routePackage.gpx = latest.gpx;
|
||||
return ns.LocalRoutes.save(routePackage);
|
||||
};
|
||||
let id = routePackage.route.serverRouteId;
|
||||
let previousPois = [];
|
||||
if (id) {
|
||||
const payload = await readJson(`/routes/${encodeURIComponent(id)}`);
|
||||
const existing = payload.route || payload;
|
||||
if (existing.activatedAt || existing.activated_at) {
|
||||
throw new Error(ns.I18n.t("editor.read-only-hint"));
|
||||
}
|
||||
previousPois = existing.pois || [];
|
||||
await sendForm(`/routes/${encodeURIComponent(id)}`, 'PUT', localRouteUploadForm(routePackage));
|
||||
} else {
|
||||
const payload = await sendForm('/routes', 'POST', localRouteUploadForm(routePackage));
|
||||
const serverRoute = payload.route || payload;
|
||||
createdRouteId = routeId(serverRoute);
|
||||
if (!createdRouteId) throw new Error(ns.I18n.t("editor.error.send-route"));
|
||||
|
||||
const orderedPois = [...(routePackage.pois || [])]
|
||||
.sort((left, right) => Number(left.sequence) - Number(right.sequence));
|
||||
for (const [index, poi] of orderedPois.entries()) {
|
||||
const poiTitle = poi.title || ns.I18n.t("editor.station-number", { value1: index + 1 });
|
||||
const poiPayload = await sendJson(`/routes/${encodeURIComponent(createdRouteId)}/pois`, 'POST', {
|
||||
description: poi.description || '',
|
||||
lat: poi.lat,
|
||||
lon: poi.lon,
|
||||
sequence: index,
|
||||
title: poiTitle,
|
||||
id = routeId(payload.route || payload);
|
||||
if (!id) throw new Error(ns.I18n.t("editor.error.send-route"));
|
||||
routePackage.route.serverRouteId = id;
|
||||
checkpoint();
|
||||
}
|
||||
const orderedPois = [...(routePackage.pois || [])]
|
||||
.sort((left, right) => Number(left.sequence) - Number(right.sequence));
|
||||
for (const [index, poi] of orderedPois.entries()) {
|
||||
const base = `/routes/${encodeURIComponent(id)}/pois`;
|
||||
const payload = await sendJson(poi.serverPoiId ? `${base}/${poi.serverPoiId}` : base,
|
||||
poi.serverPoiId ? 'PUT' : 'POST', {
|
||||
description: poi.description || '', lat: poi.lat, lon: poi.lon, sequence: index,
|
||||
title: poi.title || ns.I18n.t("editor.station-number", { value1: index + 1 }),
|
||||
triggerRadiusM: poi.triggerRadiusM || 25
|
||||
});
|
||||
const serverPoi = poiPayload.poi || poiPayload;
|
||||
const serverPoiId = poiId(serverPoi);
|
||||
if (!serverPoiId) throw new Error(ns.I18n.t("editor.error.send-station", { value1: poiTitle }));
|
||||
await uploadLocalPoiMedia(createdRouteId, serverPoiId, poi);
|
||||
poi.serverPoiId = poiId(payload.poi || payload);
|
||||
if (!poi.serverPoiId) throw new Error(ns.I18n.t("editor.error.send-station", { value1: poi.title || '' }));
|
||||
checkpoint();
|
||||
const mediaBase = `${base}/${poi.serverPoiId}`;
|
||||
const oldPoi = previousPois.find(old => String(old.id) === String(poi.serverPoiId));
|
||||
if (poi.audio) {
|
||||
await uploadStoredMedia(`${mediaBase}/audio`, 'audio', poi.audio, 'audio.bin',
|
||||
oldPoi?.audioUrl || poi.serverAudio ? 'PUT' : 'POST');
|
||||
poi.serverAudio = true;
|
||||
checkpoint();
|
||||
} else if (oldPoi?.audioUrl) {
|
||||
await sendDelete(`${mediaBase}/audio`);
|
||||
poi.serverAudio = false;
|
||||
checkpoint();
|
||||
}
|
||||
return createdRouteId;
|
||||
} catch (error) {
|
||||
if (createdRouteId) {
|
||||
try {
|
||||
await sendDelete(`/routes/${encodeURIComponent(createdRouteId)}`);
|
||||
} catch {
|
||||
// Der ursprüngliche Uploadfehler ist wichtiger als ein fehlgeschlagener Rollback.
|
||||
for (const [imageIndex, image] of (poi.images || []).entries()) {
|
||||
const path = image.serverPictureId ? `${mediaBase}/pictures/${image.serverPictureId}` : `${mediaBase}/pictures`;
|
||||
const picture = await uploadStoredMedia(path, 'picture', image, `bild-${imageIndex + 1}.bin`,
|
||||
image.serverPictureId ? 'PUT' : 'POST');
|
||||
image.serverPictureId = (picture.picture || picture).id;
|
||||
if (!image.serverPictureId) throw new Error(ns.I18n.t("editor.error.send-file", { value1: image.name || '' }));
|
||||
checkpoint();
|
||||
}
|
||||
for (const oldImage of oldPoi?.images || []) {
|
||||
if (!(poi.images || []).some(image => String(image.serverPictureId) === String(oldImage.id))) {
|
||||
await sendDelete(`${mediaBase}/pictures/${oldImage.id}`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
for (const oldPoi of previousPois) {
|
||||
if (!orderedPois.some(poi => String(poi.serverPoiId) === String(oldPoi.id))) {
|
||||
await sendDelete(`/routes/${encodeURIComponent(id)}/pois/${oldPoi.id}`);
|
||||
}
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
async function uploadLocalRoute() {
|
||||
if (state.currentSource !== 'local') throw new Error(ns.I18n.t("editor.error.submit-own-draft"));
|
||||
const values = routeFromForm();
|
||||
if (!values.name) throw new Error(ns.I18n.t("editor.error.route-name"));
|
||||
if (state.currentRoute?.serverRouteId) {
|
||||
setStatus(ns.I18n.t("editor.error.already-submitted"));
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadButton = byId(ids.routeUpload);
|
||||
setDisabled(uploadButton, true);
|
||||
try {
|
||||
@@ -989,7 +1014,14 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
await loadRoutes();
|
||||
setStatus(ns.I18n.t("editor.submit-success"));
|
||||
} finally {
|
||||
if (!state.currentRoute?.serverRouteId) setDisabled(uploadButton, false);
|
||||
// Keep acknowledged server IDs even if a later media request failed.
|
||||
const latest = ns.LocalRoutes.get(routeId(state.currentRoute));
|
||||
if (latest) {
|
||||
state.currentRoute = { ...latest.route, id: latest.id, gpx: latest.gpx, _source: 'local' };
|
||||
state.pois = latest.pois;
|
||||
fillRouteForm(state.currentRoute);
|
||||
}
|
||||
setDisabled(uploadButton, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1392,6 +1424,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
|
||||
function bind() {
|
||||
for (const eventName of ['cancel', 'close']) {
|
||||
byId(ids.dialog)?.addEventListener(eventName, () => ns.RouteRecorder?.endEditing());
|
||||
}
|
||||
activate(byId(ids.button), () => guarded(openEditor));
|
||||
activate(byId(ids.close), closeEditor);
|
||||
activate(byId(ids.newRoute), newRoute);
|
||||
|
||||
@@ -19,6 +19,31 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
return target;
|
||||
}
|
||||
|
||||
// Separate from app/route storage: ordinary cleanup must preserve this identity.
|
||||
const CREATOR_KEY = 'wegwichtel.creator-id.v1';
|
||||
|
||||
function creatorId() {
|
||||
const target = storage();
|
||||
const existing = target.getItem(CREATOR_KEY);
|
||||
if (existing) {
|
||||
if (!/^[a-f0-9]{64}$/.test(existing)) throw new Error(ns.I18n.t("local.error.identity"));
|
||||
return existing;
|
||||
}
|
||||
if (typeof globalThis.crypto?.getRandomValues !== 'function') {
|
||||
throw new Error(ns.I18n.t("local.error.identity"));
|
||||
}
|
||||
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(32));
|
||||
const value = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
target.setItem(CREATOR_KEY, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
function forgetCreatorId() {
|
||||
if (globalThis.confirm?.(ns.I18n.t("local.confirm-forget-identity")) !== true) return false;
|
||||
storage().removeItem(CREATOR_KEY);
|
||||
return true;
|
||||
}
|
||||
|
||||
function routeKey(id, suffix) {
|
||||
return `${STORAGE_PREFIX}.${encodeURIComponent(String(id))}.${suffix}`;
|
||||
}
|
||||
@@ -499,6 +524,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const inputRoute = routePackage.route || {};
|
||||
const route = {
|
||||
id,
|
||||
creator_id: inputRoute.creator_id || creatorId(),
|
||||
name: String(inputRoute.name || ''),
|
||||
description: String(inputRoute.description || ''),
|
||||
createdAt: inputRoute.createdAt || now,
|
||||
@@ -724,6 +750,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
|
||||
ns.LocalRoutes = Object.freeze({
|
||||
STORAGE_PREFIX,
|
||||
creatorId,
|
||||
forgetCreatorId,
|
||||
appendTrackPoint,
|
||||
recordedPositions,
|
||||
replaceRecordedPositions,
|
||||
|
||||
@@ -3,6 +3,7 @@ globalThis.Wegwichtel ??= {};
|
||||
'use strict';
|
||||
const INTERVAL_MS = 30000;
|
||||
let id = null;
|
||||
let editingId = null;
|
||||
let status = 'idle';
|
||||
let timer = null;
|
||||
let generation = 0;
|
||||
@@ -79,9 +80,21 @@ globalThis.Wegwichtel ??= {};
|
||||
function start(routeId) {
|
||||
lastPosition = null;
|
||||
id = String(routeId);
|
||||
editingId = id;
|
||||
resume();
|
||||
}
|
||||
|
||||
function beginEditing(routeId) {
|
||||
if (editingId === String(routeId)) return;
|
||||
stop();
|
||||
start(routeId);
|
||||
}
|
||||
|
||||
function endEditing() {
|
||||
editingId = null;
|
||||
stop();
|
||||
}
|
||||
|
||||
function addPositionField(position = {}, index = null) {
|
||||
const container = document.getElementById('editor-recorded-positions');
|
||||
if (!container) return;
|
||||
@@ -132,6 +145,7 @@ globalThis.Wegwichtel ??= {};
|
||||
}
|
||||
|
||||
function bind() {
|
||||
globalThis.addEventListener?.('pagehide', endEditing);
|
||||
const activate = (elementId, action) => {
|
||||
const element = document.getElementById(elementId);
|
||||
element?.addEventListener('pointerup', event => {
|
||||
@@ -153,7 +167,7 @@ globalThis.Wegwichtel ??= {};
|
||||
});
|
||||
}
|
||||
|
||||
ns.RouteRecorder = Object.freeze({ start, pause, resume, stop, renderPoints, editGpx, get status() { return status; }, get routeId() { return id; }, get position() { return lastPosition; } });
|
||||
ns.RouteRecorder = Object.freeze({ start, pause, resume, stop, beginEditing, endEditing, renderPoints, editGpx, get status() { return status; }, get routeId() { return id; }, get position() { return lastPosition; } });
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bind, { once: true });
|
||||
else bind();
|
||||
}(globalThis.Wegwichtel));
|
||||
|
||||
@@ -105,7 +105,6 @@ globalThis.Wegwichtel ??= {};
|
||||
"send-route": "Die Strecke konnte nicht gesendet werden. Bitte versuche es noch einmal.",
|
||||
"send-station": "Die Station {value1} konnte nicht gesendet werden. Bitte versuche es noch einmal.",
|
||||
"submit-own-draft": "Du kannst nur deine eigenen Entwürfe zur Prüfung senden.",
|
||||
"already-submitted": "Diese Strecke wurde schon zur Prüfung gesendet.",
|
||||
"location-required": "Für eine neue Station brauchen wir deinen Standort. Erlaube den Standort und versuche es noch einmal.",
|
||||
"save-route-first": "Speichere zuerst die Strecke.",
|
||||
"station-location": "Der Ort dieser Station fehlt. Erlaube deinen Standort und versuche es noch einmal.",
|
||||
@@ -131,9 +130,9 @@ globalThis.Wegwichtel ??= {};
|
||||
"no-draft": "Noch kein eigener Entwurf vorhanden.",
|
||||
"no-stations": "Noch keine Stationen angelegt.",
|
||||
"loading-routes": "Strecken werden geladen …",
|
||||
"submitted": "Schon zur Prüfung gesendet",
|
||||
"submitted": "Erneut zur Prüfung senden",
|
||||
"submit": "Zur Prüfung senden",
|
||||
"submitted-hint": "Diese Strecke wurde zur Prüfung gesendet. Andere können sie erst nach der Freigabe sehen.",
|
||||
"submitted-hint": "Du kannst Änderungen erneut zur Prüfung senden, solange die Strecke noch nicht freigegeben wurde.",
|
||||
"draft-hint": "Dieser Entwurf bleibt auf deinem Gerät. Mit „Zur Prüfung senden“ reichst du ihn zur Freigabe ein.",
|
||||
"read-only-hint": "Diese Strecke ist für alle freigegeben und kann hier nicht mehr verändert werden.",
|
||||
"unpublished-hint": "Diese Strecke wurde noch nicht für alle freigegeben.",
|
||||
@@ -220,7 +219,9 @@ globalThis.Wegwichtel ??= {};
|
||||
"missing": "{value1}: Diese Information fehlt noch."
|
||||
},
|
||||
"local": {
|
||||
"confirm-forget-identity": "Gerätekennung wirklich löschen? Dieses Gerät bekommt eine neue Kennung. Die Zuordnung zu bisherigen Strecken bleibt bestehen.",
|
||||
"error": {
|
||||
"identity": "Die Gerätekennung konnte nicht gespeichert werden. Erlaube das Speichern für diese Seite und versuche es erneut.",
|
||||
"route-storage": "Deine Strecke kann auf diesem Gerät gerade nicht gespeichert werden.",
|
||||
"media-storage": "Bilder und Ton können auf diesem Gerät gerade nicht gespeichert werden.",
|
||||
"media-read": "Gespeicherte Bilder und Ton konnten nicht geöffnet werden.",
|
||||
|
||||
@@ -105,7 +105,6 @@ globalThis.Wegwichtel ??= {};
|
||||
"send-route": "The trail could not be sent. Please try again.",
|
||||
"send-station": "The stop {value1} could not be sent. Please try again.",
|
||||
"submit-own-draft": "You can only send your own drafts for review.",
|
||||
"already-submitted": "This trail has already been sent for review.",
|
||||
"location-required": "We need your location to add a new stop. Allow location access and try again.",
|
||||
"save-route-first": "Save the trail first.",
|
||||
"station-location": "This stop has no location. Allow location access and try again.",
|
||||
@@ -131,9 +130,9 @@ globalThis.Wegwichtel ??= {};
|
||||
"no-draft": "There are no drafts yet.",
|
||||
"no-stations": "There are no stops yet.",
|
||||
"loading-routes": "Loading trails …",
|
||||
"submitted": "Already sent for review",
|
||||
"submitted": "Send changes for review",
|
||||
"submit": "Send for review",
|
||||
"submitted-hint": "This trail has been sent for review. Others can see it once it has been approved.",
|
||||
"submitted-hint": "You can send changes for review until the route has been approved.",
|
||||
"draft-hint": "This draft stays on your device. Choose “Send for review” to submit it for approval.",
|
||||
"read-only-hint": "This trail is available to everyone and can no longer be changed here.",
|
||||
"unpublished-hint": "This trail has not been made available to everyone yet.",
|
||||
@@ -220,7 +219,9 @@ globalThis.Wegwichtel ??= {};
|
||||
"missing": "{value1}: This information still needs to be added."
|
||||
},
|
||||
"local": {
|
||||
"confirm-forget-identity": "Delete the device ID? This device will get a new ID. Existing routes will keep their original creator ID.",
|
||||
"error": {
|
||||
"identity": "The device ID could not be saved. Allow this site to store data and try again.",
|
||||
"route-storage": "Your trail cannot be saved on this device right now.",
|
||||
"media-storage": "Pictures and recordings cannot be saved on this device right now.",
|
||||
"media-read": "Saved pictures and recordings could not be opened.",
|
||||
|
||||
Reference in New Issue
Block a user