group editor implemented
This commit is contained in:
@@ -593,3 +593,34 @@
|
||||
|
||||
.current-location-button { min-width: 44px; min-height: 44px; }
|
||||
.current-location-button img { width: 24px; height: 24px; vertical-align: middle; }
|
||||
|
||||
/* Shared drafts and vertically scrolling Wichtel-code wheels. */
|
||||
.group-pair { display: inline-flex; align-items: center; justify-content: center; width: 4.5rem; }
|
||||
.group-pair img { width: 1.8rem; height: 2.3rem; object-fit: contain; pointer-events: none; }
|
||||
.group-dialog { width: min(35rem, calc(100% - 2rem)); max-height: 90dvh; box-sizing: border-box; overflow-y: auto; padding: 1.25rem; border: 1px solid #c8d3ca; border-radius: 1.25rem; background: #f8faf5; color: #1f3022; }
|
||||
.group-dialog::backdrop { background: rgba(20, 40, 25, .5); }
|
||||
.group-dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
||||
.group-dialog-header h2 { margin: 0; font-size: 1.2rem; }
|
||||
.group-dialog button { min-width: 44px; min-height: 44px; border: 1px solid #c8d3ca; border-radius: .85rem; background: white; color: #24462b; touch-action: manipulation; }
|
||||
.group-dialog button:focus-visible, .group-wheel-viewport:focus-visible { outline: 3px solid #1e6fba; outline-offset: 2px; }
|
||||
.group-wheels { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .8rem; padding: 0; border: 0; margin: 1rem 0; }
|
||||
.group-code-fieldset { border: 0; padding: 0; min-width: 0; }
|
||||
.group-code-fieldset legend { margin-bottom: .5rem; font-weight: 600; }
|
||||
.group-wheel { display: grid; gap: .3rem; min-width: 0; }
|
||||
.group-wheel-viewport { height: 156px; padding: 52px 0; box-sizing: border-box; overflow-y: auto; overscroll-behavior: contain; scroll-snap-type: y mandatory; scrollbar-width: none; border-radius: .85rem; background: linear-gradient(to bottom, #edf2eb 0 33.33%, #dcebdc 33.33% 66.66%, #edf2eb 66.66%); }
|
||||
.group-wheel-viewport::-webkit-scrollbar { display: none; }
|
||||
.group-wheel-option { height: 52px; display: grid; place-items: center; scroll-snap-align: center; cursor: pointer; }
|
||||
.group-wheel-option img { width: 48px; height: 48px; object-fit: contain; pointer-events: none; }
|
||||
.group-code { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .75rem; padding: 1rem; border: 1px solid #c8d3ca; border-radius: 1rem; background: #e7f1e9; }
|
||||
.group-code img { width: 100%; height: 64px; object-fit: contain; }
|
||||
.group-actions { max-width: min(18rem, 52vw); flex-wrap: wrap; justify-content: flex-end; }
|
||||
.group-actions .editor-secondary-button { font-size: .85rem; }
|
||||
.group-status { min-height: 1.5rem; color: #9b2c2c; }
|
||||
@media (prefers-reduced-motion: reduce) { .group-wheel-viewport { scroll-behavior: auto; } }
|
||||
|
||||
.point-compare-dialog { width: min(56rem, calc(100vw - 2rem)); }
|
||||
.point-compare-content { overflow-x: auto; max-height: 55vh; overflow-y: auto; }
|
||||
.point-compare-content table { width: 100%; border-collapse: collapse; }
|
||||
.point-compare-content th, .point-compare-content td { padding: .6rem; border: 1px solid #c9d1ca; text-align: start; vertical-align: top; overflow-wrap: anywhere; white-space: pre-wrap; min-width: 6rem; }
|
||||
.point-compare-content img { max-width: 10rem; max-height: 10rem; object-fit: contain; }
|
||||
.point-compare-content audio { max-width: 100%; }
|
||||
|
||||
+42
-3
@@ -180,6 +180,7 @@
|
||||
</section>
|
||||
<div class="route-control-actions">
|
||||
<button id="editor-button" class="editor-button" type="button" aria-label="Strecken bearbeiten" title="Strecken bearbeiten" aria-haspopup="dialog" aria-expanded="false" aria-controls="editor-dialog" data-i18n-title="editor.open" data-i18n-aria-label="editor.open" data-i18n="editor.open">Strecken bearbeiten</button>
|
||||
<button id="group-join-open" class="editor-secondary-button" type="button" data-i18n="groups.join-open">Gruppencode eingeben</button>
|
||||
<button id="route-control-new" class="editor-primary-button" type="button" data-i18n="editor.new-route">Neue Strecke</button>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -334,10 +335,11 @@
|
||||
</label>
|
||||
<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>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“ oder „Gemeinsam bearbeiten“ wählst, wird deine Strecke an die Website gesendet.</p>
|
||||
<p>Änderungen an gemeinsamen Stationen werden vor dem Senden zusätzlich auf diesem Gerät gesichert. Dazu gehören deine Eingaben, neu ausgewählte Bilder und Tonaufnahmen sowie vorgemerkte Entfernungen. Noch nicht gesendete Änderungen bleiben bis zum erfolgreichen Senden oder bewussten Verwerfen erhalten. Das Löschen der Website-Daten im Internetbrowser kann diese Sicherungen entfernen. Auf der Website wird während der Bearbeitung eine Sperre mit deiner Gerätekennung und deinem Gruppenzugang gespeichert, damit sich Änderungen nicht unbemerkt überschreiben.</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>
|
||||
<p>Wegwichtel verwendet keine Werbe- oder Tracking-Cookies. Für die Gruppenbearbeitung wird nach dem Öffnen einer Gruppe oder dem Beitritt ein geschütztes Zugangscookie für höchstens 30 Tage gespeichert. Es merkt sich, welche gemeinsamen Entwürfe du bearbeiten darfst. Beim Öffnen einer Gruppe oder beim Beitritt werden außerdem deine Gerätekennung und deine Gruppenmitgliedschaft auf der Website gespeichert. Gemeinsame Entwürfe und ihre Bilder und Tonaufnahmen werden auf der Website gespeichert und sind für die Gruppenmitglieder zugänglich. 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>
|
||||
<p>Bilder, Texte und Tonaufnahmen dürfen nur geteilt werden, wenn das erlaubt ist. Wenn Kinder auf Bildern oder in Aufnahmen erkannt werden können, müssen die Regeln der Schule beachtet werden. Auch die Eltern oder anderen Sorgeberechtigten müssen die nötige Erlaubnis geben.</p>
|
||||
<h3>8. Eingesandte Bilder und Tonaufnahmen</h3>
|
||||
@@ -427,7 +429,7 @@
|
||||
</ul>
|
||||
<p data-i18n="storage-help.permissions.optional">Du kannst diese Erlaubnisse ablehnen. Dann fehlen die jeweiligen Funktionen. Strecken und Stationen kannst du weiterhin selbst auswählen.</p>
|
||||
<h3 data-i18n="storage-help.providers-title">Werbung und andere Anbieter</h3>
|
||||
<p data-i18n="storage-help.providers-data">Wegwichtel zeigt keine Werbung und verfolgt nicht, welche anderen Seiten du besuchst. Die App lädt ihre eigenen Programme und Bilder von Wegwichtel. Es werden keine Cookies verwendet.</p>
|
||||
<p data-i18n="storage-help.providers-data">Wegwichtel zeigt keine Werbung und verfolgt nicht, welche anderen Seiten du besuchst. Die App lädt ihre eigenen Programme und Bilder von Wegwichtel. Für gemeinsame Entwürfe merkt sich dein Internetbrowser deinen Zugang für höchstens 30 Tage mit einem Cookie.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
@@ -459,6 +461,13 @@
|
||||
</section>
|
||||
<p class="editor-muted" data-i18n="editor.routes-hint">Hier findest du Strecken für alle und deine eigenen Entwürfe.</p>
|
||||
<ul id="editor-route-list" class="editor-route-list" aria-live="polite"></ul>
|
||||
<h3 data-i18n="groups.title">Gemeinsame Entwürfe</h3>
|
||||
<p class="editor-muted" data-i18n="groups.online">Gemeinsam bearbeiten braucht eine Internetverbindung.</p>
|
||||
<button id="editor-group-join" class="editor-secondary-button" type="button" data-i18n="groups.join-open">Gruppencode eingeben</button>
|
||||
<h4 data-i18n="point-locks.pending-title">Noch nicht gesendete Änderungen</h4>
|
||||
<p class="editor-muted" data-i18n="point-locks.pending-help">Diese Änderungen bleiben auf diesem Gerät. Öffne sie später und tippe auf Speichern. Vor dem Senden prüft Wegwichtel, ob die Station inzwischen geändert wurde.</p>
|
||||
<ul id="editor-point-drafts" class="editor-route-list" aria-live="polite"></ul>
|
||||
<ul id="editor-group-list" class="editor-route-list" aria-live="polite"></ul>
|
||||
</section>
|
||||
<section id="editor-route-detail-view" class="editor-view" aria-labelledby="editor-route-detail-title" hidden>
|
||||
<div class="editor-toolbar editor-toolbar-back">
|
||||
@@ -570,6 +579,36 @@
|
||||
<script src="js/user-messages.js?v=0.12.14"></script>
|
||||
<script src="js/local-routes.js?v=0.12.14"></script>
|
||||
<script src="js/route-recorder.js?v=0.12.14"></script>
|
||||
<dialog id="group-dialog" class="group-dialog" aria-labelledby="group-title">
|
||||
<header class="group-dialog-header">
|
||||
<h2 id="group-title" data-i18n="groups.share">Gemeinsam bearbeiten</h2>
|
||||
<button id="group-close" type="button" aria-label="Schließen" data-i18n-aria-label="info.close">×</button>
|
||||
</header>
|
||||
<section id="group-entry">
|
||||
<p data-i18n="groups.wheel-help">Schiebe die Wichtel nach oben oder unten. Die Wichtel in der Mitte ergeben den Code. Lies von links nach rechts, zuerst die obere Reihe.</p>
|
||||
<fieldset class="group-code-fieldset"><legend data-i18n="groups.code-label">Gruppencode</legend><div id="group-wheels" class="group-wheels"></div></fieldset>
|
||||
<button id="group-join" type="button" data-i18n="groups.join">Mitmachen</button>
|
||||
</section>
|
||||
<section id="group-invitation" hidden>
|
||||
<p data-i18n="groups.invite-help">Zeige diesen Code den anderen. Damit können sie deinen gemeinsamen Entwurf öffnen und bearbeiten. Das Original bleibt erhalten.</p>
|
||||
<div id="group-code" class="group-code" aria-label="Gruppencode" data-i18n-aria-label="groups.code-label"></div>
|
||||
<p id="group-expiry"></p>
|
||||
<button id="group-edit" type="button" data-i18n="groups.edit">Entwurf bearbeiten</button>
|
||||
</section>
|
||||
<p id="group-status" class="group-status" role="status"></p>
|
||||
</dialog>
|
||||
<dialog id="point-compare-dialog" class="group-dialog point-compare-dialog" aria-labelledby="point-compare-title">
|
||||
<h2 id="point-compare-title" data-i18n="point-locks.compare-title">Die Station wurde geändert</h2>
|
||||
<p data-i18n="point-locks.compare-help">Vergleiche deine Änderungen mit dem neuen Stand. Wenn du deine Änderungen sendest, ersetzen deine Angaben den aktuellen Stand. Prüfe auch Bilder und Tonaufnahmen. Bei gleicher Anzahl können andere Dateien dahinterstecken.</p>
|
||||
<div id="point-compare-content" class="point-compare-content"></div>
|
||||
<div class="editor-form-actions">
|
||||
<button id="point-compare-cancel" type="button" data-i18n="point-locks.keep-device">Auf diesem Gerät behalten</button>
|
||||
<button id="point-compare-accept" type="button" data-i18n="point-locks.use-mine">Meine Änderungen senden</button>
|
||||
</div>
|
||||
</dialog>
|
||||
<script src="js/group-points.js?v=0.12.14"></script>
|
||||
<script src="js/group-symbols.js?v=0.12.14"></script>
|
||||
<script src="js/groups.js?v=0.12.14"></script>
|
||||
<script src="js/editor-ui.js?v=0.12.14"></script>
|
||||
<script src="js/privacy-storage.js?v=0.12.14"></script>
|
||||
<script src="js/legal-ui.js?v=0.12.14"></script>
|
||||
|
||||
+209
-20
@@ -88,6 +88,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
|
||||
const state = {
|
||||
currentPoi: null,
|
||||
groupDeletions: [],
|
||||
groupDeletePoint: false,
|
||||
currentRoute: null,
|
||||
currentSource: null,
|
||||
pendingAudioFile: null,
|
||||
@@ -212,6 +214,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
|
||||
function showListView() {
|
||||
void ns.GroupPoints?.release();
|
||||
releasePoiPreview();
|
||||
ns.RouteRecorder?.endEditing();
|
||||
setDialogTitle('Streckenverwaltung');
|
||||
@@ -221,6 +224,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
|
||||
function showRouteView() {
|
||||
void ns.GroupPoints?.release();
|
||||
releasePoiPreview();
|
||||
setDialogTitle(routeDialogTitle());
|
||||
setHidden(byId(ids.routeListView), true);
|
||||
@@ -276,7 +280,18 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
|
||||
async function handleResponse(response) {
|
||||
if (response.ok) return parseJson(response);
|
||||
if ([401, 403].includes(response.status) && state.currentSource === 'group') {
|
||||
throw new Error(ns.I18n.t('groups.error.access'));
|
||||
}
|
||||
if (response.status === 423) throw new Error(ns.I18n.t('point-locks.lost'));
|
||||
if (response.status === 409 && response.headers?.get('X-Group-Route')) {
|
||||
throw new Error(ns.I18n.t('groups.error.conflict'));
|
||||
}
|
||||
if (response.ok) {
|
||||
ns.Groups?.observe?.(response);
|
||||
ns.GroupPoints?.observe?.(response);
|
||||
return parseJson(response);
|
||||
}
|
||||
const payload = await parseJson(response);
|
||||
const error = new Error(payload.message || ns.I18n.t("editor.error.action"));
|
||||
error.status = response.status;
|
||||
@@ -286,7 +301,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
async function readJson(path) {
|
||||
const response = await fetch(apiUrl(path), {
|
||||
credentials: 'same-origin',
|
||||
headers: { Accept: 'application/json' }
|
||||
headers: { ...ns.Groups?.headersFor?.(path), ...ns.GroupPoints?.headersFor?.(path), Accept: 'application/json' }
|
||||
});
|
||||
return handleResponse(response);
|
||||
}
|
||||
@@ -296,6 +311,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
body: JSON.stringify(body),
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
...ns.Groups?.headersFor?.(path), ...ns.GroupPoints?.headersFor?.(path),
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
@@ -308,7 +324,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const response = await fetch(apiUrl(path), {
|
||||
body: formData,
|
||||
credentials: 'same-origin',
|
||||
headers: { Accept: 'application/json' },
|
||||
headers: { ...ns.Groups?.headersFor?.(path), ...ns.GroupPoints?.headersFor?.(path), Accept: 'application/json' },
|
||||
method
|
||||
});
|
||||
return handleResponse(response);
|
||||
@@ -317,7 +333,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
async function sendDelete(path) {
|
||||
const response = await fetch(apiUrl(path), {
|
||||
credentials: 'same-origin',
|
||||
headers: { Accept: 'application/json' },
|
||||
headers: { ...ns.Groups?.headersFor?.(path), ...ns.GroupPoints?.headersFor?.(path), Accept: 'application/json' },
|
||||
method: 'DELETE'
|
||||
});
|
||||
return handleResponse(response);
|
||||
@@ -375,6 +391,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const actions = document.createElement('div');
|
||||
actions.className = classes.iconRow;
|
||||
actions.appendChild(iconButton(ns.I18n.t("editor.edit-route"), '✎', 'edit-route'));
|
||||
if (ns.Groups?.shareButton) actions.appendChild(ns.Groups.shareButton('share-route'));
|
||||
if (routeSource(route) === 'local') {
|
||||
actions.appendChild(iconButton(ns.I18n.t("editor.delete-route"), '×', 'delete-route', false, true));
|
||||
}
|
||||
@@ -453,6 +470,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
|
||||
let routeListGeneration = 0;
|
||||
let shareInProgress = false;
|
||||
|
||||
async function loadRoutes() {
|
||||
const requestGeneration = ++routeListGeneration;
|
||||
@@ -518,6 +536,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
setDialogExpanded(true);
|
||||
showListView();
|
||||
await guarded(loadRoutes);
|
||||
void ns.Groups?.refresh?.();
|
||||
}
|
||||
|
||||
async function openNewLocalRoute() {
|
||||
@@ -526,6 +545,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
if (pointSaveInProgress) return;
|
||||
void ns.GroupPoints?.release();
|
||||
releasePoiPreview();
|
||||
ns.RouteRecorder?.endEditing();
|
||||
const dialog = byId(ids.dialog);
|
||||
@@ -837,7 +858,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
} else {
|
||||
const payload = await readJson(`/routes/${encodeURIComponent(id)}`);
|
||||
const detail = payload.route || payload;
|
||||
state.currentRoute = { ...detail, _source: 'server' };
|
||||
state.currentRoute = { ...detail, _source: state.currentSource };
|
||||
state.pois = poiArray(detail);
|
||||
}
|
||||
|
||||
@@ -871,6 +892,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const file = byId(ids.routeGpx)?.files?.[0];
|
||||
data.append('name', values.name);
|
||||
data.append('description', values.description);
|
||||
data.append('creator_id', state.currentRoute.creator_id || ns.LocalRoutes.creatorId());
|
||||
if (file) data.append('gpx', file);
|
||||
return data;
|
||||
}
|
||||
@@ -911,21 +933,22 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const id = routeId(state.currentRoute);
|
||||
if (!id) throw new Error(ns.I18n.t("editor.error.create-locally"));
|
||||
const file = byId(ids.routeGpx)?.files?.[0];
|
||||
values = { ...values, creator_id: state.currentRoute.creator_id || ns.LocalRoutes.creatorId() };
|
||||
const path = `/routes/${encodeURIComponent(id)}`;
|
||||
const payload = file
|
||||
? await sendForm(path, 'PUT', formDataFromRoute(values))
|
||||
: await sendJson(path, 'PUT', values);
|
||||
const saved = payload.route || payload;
|
||||
state.currentRoute = { ...saved, _source: 'server' };
|
||||
state.currentRoute = { ...saved, _source: state.currentSource };
|
||||
await loadRoutes();
|
||||
await loadRoute({ id, _source: 'server' });
|
||||
await loadRoute({ id, _source: state.currentSource });
|
||||
setStatus(ns.I18n.t("editor.saved"));
|
||||
}
|
||||
|
||||
async function saveRoute() {
|
||||
const values = routeFromForm();
|
||||
if (!values.name) throw new Error(ns.I18n.t("editor.error.route-name"));
|
||||
if (state.currentSource === 'server') await saveServerRoute(values);
|
||||
if (state.currentSource !== 'local') await saveServerRoute(values);
|
||||
else await saveLocalRoute(values);
|
||||
}
|
||||
|
||||
@@ -1134,6 +1157,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
|
||||
function newPoi() {
|
||||
void ns.GroupPoints?.release();
|
||||
releasePoiPreview();
|
||||
const position = currentPosition();
|
||||
const lat = position?.lat ?? null;
|
||||
@@ -1141,6 +1165,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
|
||||
const accuracy = Number(position?.accuracy);
|
||||
state.currentPoi = null;
|
||||
state.groupDeletions = [];
|
||||
state.groupDeletePoint = false;
|
||||
const identifier = state.currentSource === 'local' ? nextLocalPoiId() : null;
|
||||
fillPoiForm({
|
||||
...(identifier == null ? {} : { id: identifier }),
|
||||
@@ -1155,11 +1181,30 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
async function editPoi(poi) {
|
||||
if (!poi) return;
|
||||
releasePoiPreview();
|
||||
let draft;
|
||||
if (state.currentSource === 'group' && ns.GroupPoints) {
|
||||
const opened = await ns.GroupPoints.begin(routeId(state.currentRoute), poi);
|
||||
if (!opened) return;
|
||||
poi = opened.poi;
|
||||
draft = opened.draft;
|
||||
}
|
||||
state.groupDeletions = draft?.media?.deletions || [];
|
||||
state.groupDeletePoint = Boolean(draft?.media?.deletePoint);
|
||||
state.currentPoi = poi;
|
||||
fillPoiForm(poi);
|
||||
fillPoiForm(draft ? { ...poi, ...draft.values } : poi);
|
||||
if (draft) {
|
||||
state.pendingAudioFile = draft.media?.audio || null;
|
||||
state.pendingImages = (draft.media?.images || []).map(file => ({ file, key: `pending-image-${ns.LocalRoutes.createPoiId()}`, previewUrl: objectUrlFor(file) }));
|
||||
const hiddenImages = state.groupDeletions.map(item => item.path);
|
||||
state.currentPoi = { ...poi, images: (poi.images || []).filter(image => !hiddenImages.includes(`pictures/${image.id}`)),
|
||||
...(hiddenImages.includes('audio') ? { audioUrl: null } : {}) };
|
||||
renderPoiMediaPreview();
|
||||
updatePoiMediaStatus();
|
||||
setStatus(ns.I18n.t(state.groupDeletePoint ? 'point-locks.delete-restored' : 'point-locks.restored'));
|
||||
}
|
||||
byId(ids.poiFormTitle).textContent = ns.I18n.t("editor.edit-station");
|
||||
showPoiView();
|
||||
if (state.currentSource !== 'local') return;
|
||||
if (state.currentSource !== 'local') return true;
|
||||
const requestGeneration = poiMediaGeneration;
|
||||
const hydrated = await ns.LocalRoutes.getPoiWithMedia(routeId(state.currentRoute), poi.id, { requestPermission: true });
|
||||
if (requestGeneration !== poiMediaGeneration || state.currentPoi !== poi) {
|
||||
@@ -1278,16 +1323,62 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
if (state.pendingAudioFile) {
|
||||
const method = existingAudioUrl(state.currentPoi) ? 'PUT' : 'POST';
|
||||
await uploadOne(`${basePath}/audio`, 'audio', state.pendingAudioFile, method);
|
||||
if (state.currentSource === 'group') { state.pendingAudioFile = null; await rememberGroupPoint(poiFromForm(), currentPoiId); }
|
||||
}
|
||||
for (const pending of state.pendingImages) {
|
||||
for (const pending of [...state.pendingImages]) {
|
||||
await uploadOne(`${basePath}/pictures`, 'picture', pending.file);
|
||||
if (state.currentSource === 'group') {
|
||||
state.pendingImages = state.pendingImages.filter(item => item !== pending);
|
||||
releasePendingPreview(pending);
|
||||
await rememberGroupPoint(poiFromForm(), currentPoiId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function rememberGroupPoint(values, identifier = byId(ids.poiId).value || 'new') {
|
||||
return ns.GroupPoints.remember(state.currentRoute, identifier, values, {
|
||||
audio: state.pendingAudioFile, images: state.pendingImages.map(item => item.file), deletions: state.groupDeletions, deletePoint: state.groupDeletePoint
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareGroupPoint(values, currentPoiId) {
|
||||
const draft = await rememberGroupPoint(values);
|
||||
if (!currentPoiId) return true;
|
||||
try {
|
||||
const prepared = await ns.GroupPoints.prepare(draft);
|
||||
if (prepared) {
|
||||
state.groupDeletions = draft.media.deletions;
|
||||
state.currentPoi = prepared.poi;
|
||||
return true;
|
||||
}
|
||||
} catch { /* The complete intent was persisted before trying to acquire the point. */ }
|
||||
setStatus(ns.I18n.t('point-locks.saved-device'));
|
||||
return false;
|
||||
}
|
||||
|
||||
async function saveGroupPointActions(currentRouteId, currentPoiId, saved, values) {
|
||||
const savedPoiId = poiId(saved) || currentPoiId;
|
||||
if (!currentPoiId) {
|
||||
byId(ids.poiId).value = savedPoiId;
|
||||
state.currentPoi = saved;
|
||||
// Persist the acknowledged ID before any further network request.
|
||||
await rememberGroupPoint(values, savedPoiId);
|
||||
await ns.GroupPoints.remove(currentRouteId, 'new');
|
||||
await ns.GroupPoints.acceptNew(currentRouteId, saved);
|
||||
}
|
||||
for (const deletion of [...state.groupDeletions]) {
|
||||
await sendDelete(`/routes/${currentRouteId}/pois/${savedPoiId}/${deletion.path}`);
|
||||
state.groupDeletions = state.groupDeletions.filter(item => item !== deletion);
|
||||
await rememberGroupPoint(values, savedPoiId);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveServerPoi(values) {
|
||||
if (state.currentSource === 'group' && state.groupDeletePoint) return deleteCurrentPoi();
|
||||
const currentRouteId = routeId(state.currentRoute);
|
||||
if (!currentRouteId) throw new Error(ns.I18n.t("editor.error.save-route-first"));
|
||||
const currentPoiId = byId(ids.poiId).value;
|
||||
if (state.currentSource === 'group' && ns.GroupPoints && !await prepareGroupPoint(values, currentPoiId)) return;
|
||||
const path = currentPoiId
|
||||
? `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`
|
||||
: `/routes/${encodeURIComponent(currentRouteId)}/pois`;
|
||||
@@ -1295,16 +1386,34 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const payload = await sendJson(path, method, values);
|
||||
const saved = payload.poi || payload;
|
||||
const savedPoiId = poiId(saved) || currentPoiId;
|
||||
if (state.currentSource === 'group' && ns.GroupPoints) await saveGroupPointActions(currentRouteId, currentPoiId, saved, values);
|
||||
if (savedPoiId) await uploadPoiMedia(currentRouteId, savedPoiId);
|
||||
if (state.currentSource === 'group' && ns.GroupPoints) await ns.GroupPoints.remove(currentRouteId, savedPoiId);
|
||||
setStatus(ns.I18n.t("editor.station-saved"));
|
||||
await loadRoute({ id: currentRouteId, _source: 'server' });
|
||||
await loadRoute({ id: currentRouteId, _source: state.currentSource });
|
||||
}
|
||||
|
||||
let pointSaveInProgress = false;
|
||||
async function savePoi() {
|
||||
if (pointSaveInProgress) return;
|
||||
pointSaveInProgress = true;
|
||||
byId(ids.dialog).inert = true;
|
||||
try { await savePoiContents(); } finally { pointSaveInProgress = false; byId(ids.dialog).inert = false; }
|
||||
}
|
||||
|
||||
async function savePoiContents() {
|
||||
const values = poiFromForm();
|
||||
if (!values.title) throw new Error(ns.I18n.t("editor.error.station-name"));
|
||||
if (state.currentSource === 'local') await saveLocalPoi(values);
|
||||
else await saveServerPoi(values);
|
||||
else {
|
||||
try { await saveServerPoi(values); }
|
||||
catch (error) {
|
||||
if (state.currentSource !== 'group' || !ns.GroupPoints) throw error;
|
||||
// Retain all unacknowledged files and intents, even when the lock changed during upload.
|
||||
await rememberGroupPoint(values);
|
||||
setStatus(ns.I18n.t('point-locks.saved-device'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateCurrentPoi(updatedPoi) {
|
||||
@@ -1364,13 +1473,15 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const updated = { ...state.currentPoi };
|
||||
|
||||
if (kind === 'audio') {
|
||||
await sendDelete(`${basePath}/audio`);
|
||||
if (state.currentSource === 'group') state.groupDeletions.push({ path: 'audio' });
|
||||
else await sendDelete(`${basePath}/audio`);
|
||||
updated.audio = null;
|
||||
updated.audioUrl = null;
|
||||
updated.audio_path = null;
|
||||
} else {
|
||||
if (!mediaId) throw new Error(ns.I18n.t("editor.error.remove-image"));
|
||||
await sendDelete(`${basePath}/pictures/${encodeURIComponent(mediaId)}`);
|
||||
if (state.currentSource === 'group') state.groupDeletions.push({ path: `pictures/${encodeURIComponent(mediaId)}` });
|
||||
else await sendDelete(`${basePath}/pictures/${encodeURIComponent(mediaId)}`);
|
||||
updated.images = (state.currentPoi.images || []).filter(image => text(image.id) !== text(mediaId));
|
||||
}
|
||||
|
||||
@@ -1378,7 +1489,10 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
state.selectedMediaKey = null;
|
||||
renderPoiMediaPreview();
|
||||
updatePoiMediaStatus();
|
||||
setStatus(kind === 'audio' ? ns.I18n.t("editor.audio.removed") : ns.I18n.t("editor.image-removed"));
|
||||
if (state.currentSource === 'group') {
|
||||
await rememberGroupPoint(poiFromForm());
|
||||
setStatus(ns.I18n.t('point-locks.deletion-staged'));
|
||||
} else setStatus(kind === 'audio' ? ns.I18n.t("editor.audio.removed") : ns.I18n.t("editor.image-removed"));
|
||||
}
|
||||
|
||||
async function removeMedia(control) {
|
||||
@@ -1427,8 +1541,16 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
return;
|
||||
}
|
||||
|
||||
await sendDelete(`/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`);
|
||||
await loadRoute({ id: currentRouteId, _source: 'server' });
|
||||
if (state.currentSource === 'group' && ns.GroupPoints) {
|
||||
state.groupDeletePoint = true;
|
||||
const draft = await rememberGroupPoint(poiFromForm());
|
||||
try {
|
||||
if (!await ns.GroupPoints.prepare(draft)) { setStatus(ns.I18n.t('point-locks.saved-device')); return; }
|
||||
await sendDelete(`/routes/${currentRouteId}/pois/${currentPoiId}`);
|
||||
await ns.GroupPoints.remove(currentRouteId, currentPoiId);
|
||||
} catch { setStatus(ns.I18n.t('point-locks.saved-device')); return; }
|
||||
} else await sendDelete(`/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`);
|
||||
await loadRoute({ id: currentRouteId, _source: state.currentSource });
|
||||
setStatus(ns.I18n.t("editor.station-removed"));
|
||||
}
|
||||
|
||||
@@ -1447,6 +1569,25 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
guarded(savePoiOrder);
|
||||
}
|
||||
|
||||
async function saveGroupPoiOrder(currentRouteId) {
|
||||
let deferred = false;
|
||||
for (const [index, poi] of state.pois.entries()) {
|
||||
if (await ns.GroupPoints.read(currentRouteId, poi.id)) { deferred = true; continue; }
|
||||
const values = { ...poi, sequence: index };
|
||||
try {
|
||||
const opened = await ns.GroupPoints.begin(currentRouteId, poi);
|
||||
await ns.GroupPoints.remember(state.currentRoute, poi.id, { ...(opened?.poi || values), sequence: index }, { images: [], audio: null, deletions: [] });
|
||||
if (!opened) { deferred = true; continue; }
|
||||
await sendJson(`/routes/${currentRouteId}/pois/${poi.id}`, 'PUT', { sequence: index, sortOrder: index });
|
||||
await ns.GroupPoints.remove(currentRouteId, poi.id);
|
||||
} catch {
|
||||
await ns.GroupPoints.remember(state.currentRoute, poi.id, values, { images: [], audio: null, deletions: [] });
|
||||
deferred = true;
|
||||
} finally { await ns.GroupPoints.release(); }
|
||||
}
|
||||
setStatus(ns.I18n.t(deferred ? 'point-locks.saved-device' : 'editor.order.saved'));
|
||||
}
|
||||
|
||||
async function savePoiOrder() {
|
||||
const currentRouteId = routeId(state.currentRoute);
|
||||
if (!currentRouteId) return;
|
||||
@@ -1455,6 +1596,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
setStatus(ns.I18n.t("editor.station-order-saved"));
|
||||
return;
|
||||
}
|
||||
if (state.currentSource === 'group' && ns.GroupPoints) return saveGroupPoiOrder(currentRouteId);
|
||||
const jobs = state.pois.map((poi, index) => {
|
||||
const currentPoiId = poiId(poi);
|
||||
if (!currentPoiId) return null;
|
||||
@@ -1506,6 +1648,21 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const route = routeForItem(event.target);
|
||||
if (!route) return;
|
||||
|
||||
if (action === 'share-route') {
|
||||
if (shareInProgress) return;
|
||||
shareInProgress = true;
|
||||
try {
|
||||
let sourceId = routeId(route);
|
||||
if (routeSource(route) === 'local') {
|
||||
if (!globalThis.confirm(ns.I18n.t('groups.upload-confirm'))) return;
|
||||
await loadRoute(route);
|
||||
await uploadLocalRoute();
|
||||
sourceId = state.currentRoute.serverRouteId;
|
||||
}
|
||||
await ns.Groups.share(sourceId);
|
||||
} finally { shareInProgress = false; }
|
||||
return;
|
||||
}
|
||||
if (action === 'edit-route') {
|
||||
await loadRoute(route);
|
||||
return;
|
||||
@@ -1517,6 +1674,12 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
|
||||
async function deletePoiFromList(poi) {
|
||||
if (!poi) return;
|
||||
if (state.currentSource === 'group') {
|
||||
if (!await editPoi(poi)) return;
|
||||
state.groupDeletePoint = true;
|
||||
await savePoi();
|
||||
return;
|
||||
}
|
||||
state.currentPoi = poi;
|
||||
await deleteCurrentPoi();
|
||||
}
|
||||
@@ -1550,8 +1713,10 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
|
||||
function bind() {
|
||||
for (const eventName of ['cancel', 'close']) {
|
||||
byId(ids.dialog)?.addEventListener(eventName, () => {
|
||||
byId(ids.dialog)?.addEventListener(eventName, event => {
|
||||
if (pointSaveInProgress && eventName === 'cancel') { event.preventDefault(); return; }
|
||||
releasePoiPreview();
|
||||
void ns.GroupPoints?.release();
|
||||
ns.RouteRecorder?.endEditing();
|
||||
});
|
||||
}
|
||||
@@ -1567,7 +1732,10 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
byId(ids.poiAudio)?.addEventListener('change', handleAudioSelection);
|
||||
byId(ids.poiImages)?.addEventListener('change', () => guarded(handleImageSelection));
|
||||
activate(byId(ids.poiMediaPreview), event => guarded(() => mediaPreviewAction(event)));
|
||||
activate(byId(ids.poiDelete), () => guarded(deleteCurrentPoi));
|
||||
activate(byId(ids.poiDelete), () => {
|
||||
if (state.currentSource === 'group') { state.groupDeletePoint = true; void guarded(savePoi); }
|
||||
else void guarded(deleteCurrentPoi);
|
||||
});
|
||||
activate(byId(ids.poiCancel), showRouteView);
|
||||
activate(byId(ids.routeList), event => guarded(() => routeListAction(event)));
|
||||
activate(byId(ids.poiList), event => guarded(() => poiListAction(event)));
|
||||
@@ -1580,6 +1748,27 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
close: closeEditor,
|
||||
open: openEditor,
|
||||
openNewLocalRoute,
|
||||
openGroup: async route => {
|
||||
await openEditor();
|
||||
await loadRoute({ ...route, _source: 'group' });
|
||||
},
|
||||
openPendingPoint: async draft => {
|
||||
await openEditor();
|
||||
try { await loadRoute({ ...draft.route, _source: 'group' }); }
|
||||
catch {
|
||||
state.currentSource = 'group';
|
||||
state.currentRoute = { ...draft.route, _source: 'group' };
|
||||
state.pois = draft.basePoi ? [draft.basePoi] : [];
|
||||
fillRouteForm(state.currentRoute); renderPoiList(); showRouteView();
|
||||
}
|
||||
if (draft.poiId === 'new') {
|
||||
newPoi();
|
||||
fillPoiForm(draft.values);
|
||||
state.pendingAudioFile = draft.media?.audio || null;
|
||||
state.pendingImages = (draft.media?.images || []).map(file => ({ file, key: `pending-image-${ns.LocalRoutes.createPoiId()}`, previewUrl: objectUrlFor(file) }));
|
||||
renderPoiMediaPreview(); updatePoiMediaStatus();
|
||||
} else await editPoi(draft.basePoi || { id: draft.poiId });
|
||||
},
|
||||
refresh: loadRoutes
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
globalThis.Wegwichtel ??= {};
|
||||
(function (ns) {
|
||||
'use strict';
|
||||
const t = (key, vars) => ns.I18n.t(`point-locks.${key}`, vars);
|
||||
let database;
|
||||
let current = null;
|
||||
let generation = 0;
|
||||
const key = (routeId, poiId) => `${routeId}:${poiId}`;
|
||||
async function store(mode, operation) {
|
||||
if (!database) database = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open('wegwichtel.group-point-drafts.v1', 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore('drafts', { keyPath: 'key' });
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => { database = null; reject(request.error); };
|
||||
request.onblocked = () => { database = null; reject(new Error(t('storage-error'))); };
|
||||
});
|
||||
const db = await database;
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('drafts', mode);
|
||||
const request = operation(transaction.objectStore('drafts'));
|
||||
transaction.oncomplete = () => resolve(request.result);
|
||||
transaction.onabort = () => reject(transaction.error || new Error(t('storage-error')));
|
||||
transaction.onerror = () => reject(transaction.error || new Error(t('storage-error')));
|
||||
});
|
||||
}
|
||||
const read = (routeId, poiId) => store('readonly', target => target.get(key(routeId, poiId)));
|
||||
const list = () => store('readonly', target => target.getAll());
|
||||
async function remove(routeId, poiId) {
|
||||
await store('readwrite', target => target.delete(key(routeId, poiId)));
|
||||
document.dispatchEvent(new Event('wegwichtel:point-drafts-changed'));
|
||||
}
|
||||
async function remember(route, poiId, values, media) {
|
||||
const previous = await read(route.id, poiId);
|
||||
const draft = { key: key(route.id, poiId), route: { id: route.id, name: route.name, description: route.description, creator_id: route.creator_id },
|
||||
poiId, values, media, baseVersion: previous?.baseVersion || current?.version || null,
|
||||
baseMediaVersion: previous?.baseMediaVersion || current?.mediaVersion || null,
|
||||
basePoi: previous?.basePoi || current?.poi || { ...values, id: poiId }, savedAt: new Date().toISOString() };
|
||||
try { await store('readwrite', target => target.put(draft)); }
|
||||
catch { throw new Error(t('storage-error')); }
|
||||
document.dispatchEvent(new Event('wegwichtel:point-drafts-changed'));
|
||||
return draft;
|
||||
}
|
||||
async function request(routeId, poiId, body) {
|
||||
const response = await fetch(`${(ns.Config?.apiBase || '/api').replace(/\/$/, '')}/groups/routes/${routeId}/pois/${poiId}/lock`, {
|
||||
method: 'POST', credentials: 'same-origin', cache: 'no-store',
|
||||
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
const error = new Error(t(response.status === 423 ? 'lost' : 'unavailable'));
|
||||
error.status = response.status;
|
||||
error.details = payload.details;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
async function acquire(routeId, poiId, token = null) {
|
||||
try { return await request(routeId, poiId, { token }); }
|
||||
catch (error) {
|
||||
if (error.details?.code !== 'point-locked') throw error;
|
||||
if (!globalThis.confirm(t('takeover'))) return null;
|
||||
// Compare-and-swap: a second takeover while the question was open needs a new confirmation.
|
||||
return request(routeId, poiId, { replace: error.details.replace });
|
||||
}
|
||||
}
|
||||
async function release() {
|
||||
generation += 1;
|
||||
const previous = current;
|
||||
current = null;
|
||||
if (!previous?.token) return;
|
||||
try { await request(previous.routeId, previous.poiId, { release: true, token: previous.token }); }
|
||||
catch { /* Another editor may own the point now; never remove their lock. */ }
|
||||
}
|
||||
async function begin(routeId, poi) {
|
||||
await release();
|
||||
const openedGeneration = generation;
|
||||
const draft = await read(routeId, poi.id);
|
||||
let result;
|
||||
try { result = await acquire(routeId, poi.id); }
|
||||
catch (error) {
|
||||
if (openedGeneration !== generation) return null;
|
||||
if (!draft) throw error;
|
||||
if (!globalThis.confirm(t('offline-restore'))) return null;
|
||||
current = { routeId, poiId: poi.id, token: null, version: draft.baseVersion, mediaVersion: draft.baseMediaVersion, poi: draft.basePoi };
|
||||
return { poi: draft.basePoi, draft };
|
||||
}
|
||||
if (!result) return null;
|
||||
if (openedGeneration !== generation) {
|
||||
await request(routeId, poi.id, { release: true, token: result.token }).catch(() => {}); return null;
|
||||
}
|
||||
current = { ...result, routeId, poiId: poi.id };
|
||||
if (draft && !globalThis.confirm(t('restore'))) { await release(); return null; }
|
||||
// Keep the old baseline until the saved draft has been compared with the server.
|
||||
if (draft) current = { ...current, version: draft.baseVersion, mediaVersion: draft.baseMediaVersion, poi: draft.basePoi };
|
||||
return { poi: result.poi, draft };
|
||||
}
|
||||
function compare(draft, remote) {
|
||||
return new Promise(resolve => {
|
||||
const dialog = document.getElementById('point-compare-dialog');
|
||||
const content = document.getElementById('point-compare-content');
|
||||
const table = document.createElement('table');
|
||||
const header = document.createElement('tr');
|
||||
for (const label of ['field', 'before', 'mine', 'theirs']) {
|
||||
const cell = document.createElement('th'); cell.textContent = t(label); header.appendChild(cell);
|
||||
}
|
||||
table.appendChild(header);
|
||||
for (const field of ['title', 'description', 'lat', 'lon', 'triggerRadiusM', 'sequence']) {
|
||||
const row = document.createElement('tr');
|
||||
for (const value of [t(`fields.${field === 'triggerRadiusM' ? 'trigger-radius' : field}`), draft.basePoi?.[field], draft.values[field], remote.poi[field]]) {
|
||||
const cell = document.createElement('td'); cell.textContent = String(value ?? '—'); row.appendChild(cell);
|
||||
}
|
||||
table.appendChild(row);
|
||||
}
|
||||
const summary = document.createElement('p');
|
||||
const media = poi => t('media-count', { images: poi?.images?.length || 0, audio: poi?.audioUrl ? 1 : 0 });
|
||||
const files = [...(draft.media?.images || []).map(file => file.name), draft.media?.audio?.name].filter(Boolean).join(', ');
|
||||
summary.textContent = t('media-compare', { before: media(draft.basePoi), theirs: media(remote.poi), files: files || '—', removed: draft.media?.deletions?.length || 0 });
|
||||
const finish = accepted => {
|
||||
dialog.removeEventListener('close', cancel);
|
||||
document.getElementById('point-compare-accept').removeEventListener('click', accept);
|
||||
document.getElementById('point-compare-cancel').removeEventListener('click', cancel);
|
||||
dialog.close(); resolve(accepted);
|
||||
};
|
||||
const accept = () => finish(true);
|
||||
const cancel = () => finish(false);
|
||||
document.getElementById('point-compare-accept').addEventListener('click', accept);
|
||||
document.getElementById('point-compare-cancel').addEventListener('click', cancel);
|
||||
dialog.addEventListener('close', cancel);
|
||||
const fileStatus = document.createElement('p');
|
||||
fileStatus.textContent = t(draft.baseMediaVersion === remote.mediaVersion ? 'files-unchanged' : 'files-changed');
|
||||
const previews = document.createElement('div');
|
||||
for (const image of remote.poi.images || []) {
|
||||
const preview = document.createElement('img'); preview.src = image.url; preview.alt = image.caption || ''; previews.appendChild(preview);
|
||||
}
|
||||
if (remote.poi.audioUrl) {
|
||||
const audio = document.createElement('audio'); audio.controls = true; audio.preload = 'none'; audio.src = remote.poi.audioUrl; previews.appendChild(audio);
|
||||
}
|
||||
const warning = document.createElement('p');
|
||||
warning.textContent = draft.media?.deletePoint ? t('delete-warning') : '';
|
||||
content.replaceChildren(table, summary, fileStatus, previews, warning);
|
||||
dialog.showModal();
|
||||
});
|
||||
}
|
||||
async function prepare(draft) {
|
||||
const savingGeneration = generation;
|
||||
const remote = await acquire(draft.route.id, draft.poiId, current?.token);
|
||||
if (!remote) return false;
|
||||
if (savingGeneration !== generation) {
|
||||
await request(draft.route.id, draft.poiId, { release: true, token: remote.token }).catch(() => {}); return false;
|
||||
}
|
||||
current = { ...remote, routeId: draft.route.id, poiId: draft.poiId };
|
||||
if (draft.baseVersion !== remote.version && !await compare(draft, remote)) {
|
||||
await release(); return false;
|
||||
}
|
||||
// Deleting an already absent file is a no-op after an explicit content comparison.
|
||||
draft.media.deletions = (draft.media.deletions || []).filter(item => item.path === 'audio'
|
||||
? Boolean(remote.poi.audioUrl) : remote.poi.images.some(image => item.path === `pictures/${image.id}`));
|
||||
if (savingGeneration !== generation) return false;
|
||||
return remote;
|
||||
}
|
||||
function headersFor(path) {
|
||||
const match = /^\/routes\/(\d+)\/pois\/(\d+)(?:\/|$)/.exec(path);
|
||||
if (!match || !current || String(current.routeId) !== match[1] || String(current.poiId) !== match[2]) return {};
|
||||
return { 'X-Poi-Lock': current.token || '', 'If-Poi-Match': current.version || '' };
|
||||
}
|
||||
function observe(response) {
|
||||
const version = response.headers?.get('X-Poi-Version');
|
||||
if (current && String(current.poiId) === response.headers?.get('X-Poi-Id') && version) current.version = version;
|
||||
}
|
||||
ns.GroupPoints = Object.freeze({ begin, release, remember, read, list, remove, prepare, headersFor, observe,
|
||||
acceptNew: async (routeId, poi) => { current = { ...await acquire(routeId, poi.id), routeId, poiId: poi.id }; },
|
||||
changed: () => document.dispatchEvent(new Event('wegwichtel:point-drafts-changed')) });
|
||||
}(globalThis.Wegwichtel));
|
||||
@@ -0,0 +1,298 @@
|
||||
globalThis.Wegwichtel ??= {};
|
||||
|
||||
(function (ns) {
|
||||
'use strict';
|
||||
const ids = {
|
||||
joinOpen: 'group-join-open', editorJoin: 'editor-group-join', list: 'editor-group-list',
|
||||
dialog: 'group-dialog', close: 'group-close', status: 'group-status', wheels: 'group-wheels',
|
||||
entry: 'group-entry', invitation: 'group-invitation', code: 'group-code', join: 'group-join',
|
||||
edit: 'group-edit', expiry: 'group-expiry'
|
||||
};
|
||||
const codeLength = 8;
|
||||
const wheelStep = 52;
|
||||
const revisions = new Map();
|
||||
let selection = [];
|
||||
let currentGroup = null;
|
||||
let pending = false;
|
||||
let dialogGeneration = 0;
|
||||
const element = id => document.getElementById(id);
|
||||
const t = (key, variables) => ns.I18n.t(`groups.${key}`, variables);
|
||||
|
||||
function activate(button, action) {
|
||||
button.addEventListener('pointerup', event => {
|
||||
if (event.isPrimary === false || (event.button && event.button !== 0)) return;
|
||||
event.preventDefault();
|
||||
void action();
|
||||
});
|
||||
button.addEventListener('keydown', event => {
|
||||
if (!['Enter', ' '].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
void action();
|
||||
});
|
||||
}
|
||||
|
||||
function button(label, action) {
|
||||
const result = document.createElement('button');
|
||||
result.type = 'button';
|
||||
result.className = 'editor-secondary-button';
|
||||
result.textContent = label;
|
||||
activate(result, action);
|
||||
return result;
|
||||
}
|
||||
|
||||
function errorMessage(error) {
|
||||
if (error.reason === 'group-limit') return t('error.limit');
|
||||
if (error.reason === 'group-full') return t('error.full');
|
||||
if (error.status === 423) return ns.I18n.t('point-locks.busy');
|
||||
if (error.status === 429) return t('error.rate');
|
||||
if (error.status === 404) return t('error.code');
|
||||
if (error.status === 401 || error.status === 403) return t('error.access');
|
||||
if (error.status === 409) return t('error.conflict');
|
||||
return t('error.connection');
|
||||
}
|
||||
|
||||
async function request(path, method = 'GET', body = null) {
|
||||
const base = (ns.Config?.apiBase || '/api').replace(/\/$/, '');
|
||||
const response = await fetch(`${base}/groups${path}`, {
|
||||
method, credentials: 'same-origin', cache: 'no-store',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
...(body ? { body: JSON.stringify(body) } : {})
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = new Error(t('error.connection'));
|
||||
error.status = response.status;
|
||||
if (typeof response.json === 'function') {
|
||||
try { error.reason = (await response.json()).details?.code; } catch { /* Use the translated generic error. */ }
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function guarded(action) {
|
||||
if (pending) return;
|
||||
pending = true;
|
||||
element(ids.status).textContent = '';
|
||||
element(ids.join).disabled = true;
|
||||
try { await action(); } catch (error) {
|
||||
element(ids.status).textContent = errorMessage(error);
|
||||
if (!element(ids.dialog).open) {
|
||||
element(ids.entry).hidden = true;
|
||||
element(ids.invitation).hidden = true;
|
||||
element(ids.dialog).showModal();
|
||||
}
|
||||
}
|
||||
finally { pending = false; element(ids.join).disabled = false; }
|
||||
}
|
||||
|
||||
function picture(symbol) {
|
||||
const image = document.createElement('img');
|
||||
image.src = symbol.imageUrl;
|
||||
image.alt = symbol.label;
|
||||
image.width = 48;
|
||||
image.height = 48;
|
||||
image.draggable = false;
|
||||
return image;
|
||||
}
|
||||
|
||||
function makeWheel(position) {
|
||||
const motifs = ns.GroupSymbols.list();
|
||||
const column = document.createElement('div');
|
||||
column.className = 'group-wheel';
|
||||
const viewport = document.createElement('div');
|
||||
viewport.className = 'group-wheel-viewport';
|
||||
viewport.tabIndex = 0;
|
||||
viewport.setAttribute('role', 'spinbutton');
|
||||
viewport.setAttribute('aria-label', t('position', { number: position + 1 }));
|
||||
viewport.setAttribute('aria-valuemin', '1');
|
||||
viewport.setAttribute('aria-valuemax', String(motifs.length));
|
||||
const paint = index => {
|
||||
selection[position] = index;
|
||||
viewport.setAttribute('aria-valuenow', String(index + 1));
|
||||
viewport.setAttribute('aria-valuetext', motifs[index].label);
|
||||
};
|
||||
const choose = index => {
|
||||
const next = (index + motifs.length) % motifs.length;
|
||||
paint(next);
|
||||
viewport.scrollTo({ top: next * wheelStep, behavior: globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
|
||||
};
|
||||
const up = button('↑', () => choose(selection[position] - 1));
|
||||
up.setAttribute('aria-label', t('previous', { number: position + 1 }));
|
||||
const down = button('↓', () => choose(selection[position] + 1));
|
||||
down.setAttribute('aria-label', t('next', { number: position + 1 }));
|
||||
for (const [index, motif] of motifs.entries()) {
|
||||
const option = document.createElement('div');
|
||||
option.className = 'group-wheel-option';
|
||||
option.setAttribute('aria-hidden', 'true');
|
||||
const image = picture(motif);
|
||||
image.alt = '';
|
||||
option.appendChild(image);
|
||||
activate(option, () => choose(index));
|
||||
viewport.appendChild(option);
|
||||
}
|
||||
viewport.addEventListener('scroll', () => paint(Math.max(0, Math.min(motifs.length - 1, Math.round(viewport.scrollTop / wheelStep)))));
|
||||
viewport.addEventListener('keydown', event => {
|
||||
const changes = { ArrowUp: -1, ArrowDown: 1 };
|
||||
if (Object.hasOwn(changes, event.key)) {
|
||||
event.preventDefault(); choose(selection[position] + changes[event.key]);
|
||||
} else if (event.key === 'Home' || event.key === 'End') {
|
||||
event.preventDefault(); choose(event.key === 'Home' ? 0 : motifs.length - 1);
|
||||
}
|
||||
});
|
||||
paint(0);
|
||||
column.append(up, viewport, down);
|
||||
return column;
|
||||
}
|
||||
|
||||
function openEntry() {
|
||||
dialogGeneration += 1;
|
||||
selection = Array(codeLength).fill(0);
|
||||
element(ids.status).textContent = '';
|
||||
element(ids.entry).hidden = false;
|
||||
element(ids.invitation).hidden = true;
|
||||
const wheels = element(ids.wheels);
|
||||
wheels.replaceChildren(...selection.map((_, index) => makeWheel(index)));
|
||||
if (!element(ids.dialog).open) element(ids.dialog).showModal();
|
||||
}
|
||||
|
||||
function showInvitation(payload) {
|
||||
currentGroup = payload.group;
|
||||
element(ids.status).textContent = '';
|
||||
element(ids.entry).hidden = true;
|
||||
element(ids.invitation).hidden = false;
|
||||
const motifs = payload.code.map(value => ns.GroupSymbols.get(value));
|
||||
element(ids.code).replaceChildren(...motifs.map(picture));
|
||||
const time = new Date(payload.expiresAt * 1000).toLocaleTimeString(ns.I18n.language, { hour: '2-digit', minute: '2-digit' });
|
||||
element(ids.expiry).textContent = t('expires', { time });
|
||||
if (!element(ids.dialog).open) element(ids.dialog).showModal();
|
||||
}
|
||||
|
||||
async function edit(group) {
|
||||
element(ids.dialog).close();
|
||||
await ns.EditorUi.openGroup(group.route);
|
||||
}
|
||||
|
||||
async function renderPending() {
|
||||
const list = element('editor-point-drafts');
|
||||
if (!list || !ns.GroupPoints) return;
|
||||
try {
|
||||
const drafts = await ns.GroupPoints.list();
|
||||
list.replaceChildren();
|
||||
for (const draft of drafts) {
|
||||
const item = document.createElement('li'); item.className = 'editor-route-item';
|
||||
const name = document.createElement('span');
|
||||
name.textContent = `${draft.route.name}: ${draft.values.title}`;
|
||||
const actions = document.createElement('div'); actions.className = 'editor-icon-row';
|
||||
actions.appendChild(button(ns.I18n.t('point-locks.open-draft'), () => guarded(() => ns.EditorUi.openPendingPoint(draft))));
|
||||
actions.appendChild(button(ns.I18n.t('point-locks.discard'), () => guarded(async () => {
|
||||
if (globalThis.confirm(ns.I18n.t('point-locks.discard-confirm'))) await ns.GroupPoints.remove(draft.route.id, draft.poiId);
|
||||
})));
|
||||
item.append(name, actions); list.appendChild(item);
|
||||
}
|
||||
} catch { /* Storage errors are reported when attempting to save; never claim a draft was saved. */ }
|
||||
}
|
||||
async function refresh() {
|
||||
await renderPending();
|
||||
try {
|
||||
const payload = await request('');
|
||||
const list = element(ids.list);
|
||||
if (!list) return;
|
||||
list.replaceChildren();
|
||||
for (const group of payload.groups) {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'editor-route-item';
|
||||
const name = document.createElement('strong');
|
||||
name.textContent = group.route.name;
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'editor-icon-row group-actions';
|
||||
actions.appendChild(button(t('edit'), () => guarded(async () => { await edit(group); })));
|
||||
actions.appendChild(button(t('reload'), () => guarded(async () => {
|
||||
if (globalThis.confirm(t('reload-confirm'))) await edit(group);
|
||||
})));
|
||||
if (group.role === 'owner') {
|
||||
actions.appendChild(button(t('new-code'), () => guarded(async () => {
|
||||
if (!globalThis.confirm(t('new-code-confirm'))) return;
|
||||
showInvitation(await request(`/${group.id}/code`, 'POST', {}));
|
||||
await refresh();
|
||||
})));
|
||||
if (group.active) actions.appendChild(button(t('close-sharing'), () => guarded(async () => {
|
||||
if (!globalThis.confirm(t('close-confirm'))) return;
|
||||
await request(`/${group.id}`, 'DELETE');
|
||||
await refresh();
|
||||
})));
|
||||
}
|
||||
item.append(name, actions);
|
||||
list.appendChild(item);
|
||||
}
|
||||
if (!payload.groups.length) {
|
||||
const empty = document.createElement('li');
|
||||
empty.textContent = t('none');
|
||||
list.appendChild(empty);
|
||||
}
|
||||
} catch { /* Existing drafts and forms stay usable without a connection. */ }
|
||||
}
|
||||
|
||||
async function share(routeId) {
|
||||
try {
|
||||
const payload = await request('', 'POST', { routeId, creator_id: ns.LocalRoutes.creatorId() });
|
||||
const editor = document.getElementById('editor-dialog');
|
||||
if (!editor || editor.open) showInvitation(payload);
|
||||
await refresh();
|
||||
} catch (error) { throw new Error(errorMessage(error)); }
|
||||
}
|
||||
|
||||
function shareButton(action) {
|
||||
const result = document.createElement('button');
|
||||
result.type = 'button';
|
||||
result.className = 'editor-icon-button group-pair';
|
||||
result.dataset.editorAction = action;
|
||||
result.title = t('share');
|
||||
result.setAttribute('aria-label', t('share'));
|
||||
for (const value of ['🐦', '🪨']) {
|
||||
const image = picture(ns.GroupSymbols.get(value));
|
||||
image.alt = '';
|
||||
result.appendChild(image);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function headersFor(path) {
|
||||
const id = /^\/routes\/(\d+)(?:\/|$)/.exec(path)?.[1];
|
||||
return id && revisions.has(id) ? { 'If-Match': String(revisions.get(id)) } : {};
|
||||
}
|
||||
|
||||
function observe(response) {
|
||||
const routeId = response.headers?.get('X-Group-Route');
|
||||
const revision = response.headers?.get('X-Group-Revision');
|
||||
if (routeId && revision !== null && /^\d+$/.test(revision)) revisions.set(routeId, Number(revision));
|
||||
}
|
||||
|
||||
function init() {
|
||||
document.addEventListener('wegwichtel:point-drafts-changed', () => { void renderPending(); void ns.StorageUsage?.refresh(); });
|
||||
element(ids.dialog).addEventListener('cancel', () => { dialogGeneration += 1; });
|
||||
element(ids.dialog).addEventListener('close', () => { dialogGeneration += 1; });
|
||||
for (const id of [ids.joinOpen, ids.editorJoin]) activate(element(id), openEntry);
|
||||
activate(element(ids.close), () => element(ids.dialog).close());
|
||||
activate(element(ids.join), () => guarded(async () => {
|
||||
const generation = dialogGeneration;
|
||||
const motifs = ns.GroupSymbols.list();
|
||||
let payload;
|
||||
try {
|
||||
payload = await request('/join', 'POST', {
|
||||
code: selection.map(index => motifs[index].unicode), creator_id: ns.LocalRoutes.creatorId()
|
||||
});
|
||||
} catch (error) {
|
||||
if (!element(ids.dialog).open || generation !== dialogGeneration) return;
|
||||
throw error;
|
||||
}
|
||||
if (!element(ids.dialog).open || generation !== dialogGeneration) return;
|
||||
await edit(payload.group);
|
||||
await refresh();
|
||||
}));
|
||||
activate(element(ids.edit), () => guarded(() => edit(currentGroup)));
|
||||
}
|
||||
|
||||
ns.Groups = Object.freeze({ share, shareButton, refresh, headersFor, observe });
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init, { once: true });
|
||||
else init();
|
||||
}(globalThis.Wegwichtel));
|
||||
+72
-1
@@ -6,6 +6,45 @@ globalThis.Wegwichtel ??= {};
|
||||
static language = 'de';
|
||||
static messages = Object.freeze(
|
||||
{
|
||||
"point-locks": {
|
||||
"busy": "Diese Station wird gerade gespeichert. Warte kurz und versuche es erneut.",
|
||||
"pending-title": "Noch nicht gesendete Änderungen",
|
||||
"pending-help": "Diese Änderungen bleiben auf diesem Gerät. Öffne sie später und tippe auf Speichern. Vor dem Senden prüft Wegwichtel, ob die Station inzwischen geändert wurde.",
|
||||
"open-draft": "Änderungen öffnen",
|
||||
"discard": "Verwerfen",
|
||||
"discard-confirm": "Diese ungesendeten Änderungen und die dazu ausgewählten Dateien von diesem Gerät entfernen?",
|
||||
"takeover": "Jemand bearbeitet diese Station bereits. Möchtest du die Bearbeitung übernehmen? Die andere Person kann dann nicht mehr speichern.",
|
||||
"lost": "Jemand anderes bearbeitet diese Station. Sichere deine Änderungen auf diesem Gerät und versuche es später erneut.",
|
||||
"unavailable": "Die Station kann gerade nicht geöffnet werden. Prüfe deine Verbindung und deinen Gruppenzugang.",
|
||||
"storage-error": "Die Änderungen konnten nicht auf diesem Gerät gesichert werden. Lass dieses Fenster offen und schaffe Platz auf deinem Gerät.",
|
||||
"offline-restore": "Die Station ist gerade nicht erreichbar. Möchtest du deine gesicherten Änderungen auf diesem Gerät öffnen?",
|
||||
"restore": "Für diese Station liegen ungesendete Änderungen auf diesem Gerät. Möchtest du sie öffnen?",
|
||||
"restored": "Deine ungesendeten Änderungen sind geöffnet. Tippe auf Speichern, um sie zu prüfen und zu senden.",
|
||||
"saved-device": "Die Änderungen sind auf diesem Gerät gesichert und noch nicht vollständig gesendet. Öffne sie später unter „Noch nicht gesendete Änderungen“ oder tippe erneut auf Speichern.",
|
||||
"deletion-staged": "Die Entfernung ist vorgemerkt. Tippe auf Speichern, um sie zu senden.",
|
||||
"delete-restored": "Die Entfernung dieser Station ist vorgemerkt. Speichern prüft den aktuellen Stand und sendet die Entfernung.",
|
||||
"compare-title": "Die Station wurde geändert",
|
||||
"compare-help": "Vergleiche deine Änderungen mit dem neuen Stand. Wenn du deine Änderungen sendest, ersetzen deine Angaben den aktuellen Stand. Prüfe auch Bilder und Tonaufnahmen. Bei gleicher Anzahl können andere Dateien dahinterstecken.",
|
||||
"field": "Angabe",
|
||||
"before": "Beim Öffnen",
|
||||
"mine": "Deine Änderungen",
|
||||
"theirs": "Jetzt online",
|
||||
"keep-device": "Auf diesem Gerät behalten",
|
||||
"use-mine": "Meine Änderungen senden",
|
||||
"media-count": "{images} Bilder, {audio} Tonaufnahmen",
|
||||
"media-compare": "Beim Öffnen: {before}. Jetzt online: {theirs}. Von dir hinzugefügt: {files}. Vorgemerkte Entfernungen: {removed}.",
|
||||
"files-changed": "Bilder oder Tonaufnahmen wurden geändert. Prüfe den aktuellen Stand unten.",
|
||||
"files-unchanged": "Die vorhandenen Bild- und Tondateien sind unverändert.",
|
||||
"delete-warning": "Du hast die Entfernung der gesamten Station vorgemerkt.",
|
||||
"fields": {
|
||||
"title": "Name",
|
||||
"description": "Beschreibung",
|
||||
"lat": "Breitengrad",
|
||||
"lon": "Längengrad",
|
||||
"trigger-radius": "Abstand zum Öffnen",
|
||||
"sequence": "Reihenfolge"
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"no-tracking": "Hier gibt es keine Werbung. Wir verfolgen nicht, welche anderen Seiten du besuchst.",
|
||||
"resume-hint": "Du kannst dort weitermachen, wo du aufgehört hast.",
|
||||
@@ -94,6 +133,38 @@ globalThis.Wegwichtel ??= {};
|
||||
"cleanup-progress": "Alten Fortschritt löschen ({value1} / {value2})",
|
||||
"ready": "Alles ist bereit."
|
||||
},
|
||||
"groups": {
|
||||
"title": "Gemeinsame Entwürfe",
|
||||
"upload-confirm": "Dein Entwurf mit Bildern und Ton wird dafür an die Website gesendet und als gemeinsamer Entwurf kopiert. Fortfahren?",
|
||||
"share": "Gemeinsam bearbeiten",
|
||||
"join-open": "Gruppencode eingeben",
|
||||
"online": "Gemeinsam bearbeiten braucht eine Internetverbindung.",
|
||||
"wheel-help": "Schiebe die Wichtel nach oben oder unten. Die Wichtel in der Mitte ergeben den Code. Lies von links nach rechts, zuerst die obere Reihe.",
|
||||
"code-label": "Gruppencode",
|
||||
"join": "Mitmachen",
|
||||
"invite-help": "Zeige diesen Code den anderen. Damit können sie deinen gemeinsamen Entwurf öffnen und bearbeiten. Das Original bleibt erhalten.",
|
||||
"edit": "Entwurf bearbeiten",
|
||||
"reload": "Änderungen holen",
|
||||
"reload-confirm": "Ungespeicherte Eingaben werden ersetzt. Möchtest du den aktuellen gemeinsamen Stand holen?",
|
||||
"new-code": "Neuen Code anzeigen",
|
||||
"new-code-confirm": "Der bisherige Code funktioniert danach nicht mehr. Bereits beigetretene Personen bleiben dabei. Neuen Code erstellen?",
|
||||
"close-sharing": "Gruppe schließen",
|
||||
"close-confirm": "Die anderen können diesen Entwurf danach nicht mehr öffnen oder bearbeiten. Du behältst den Zugriff. Gruppe schließen?",
|
||||
"expires": "Der Code gilt bis {time}.",
|
||||
"position": "Wichtel {number}",
|
||||
"previous": "Vorheriger Wichtel an Stelle {number}",
|
||||
"next": "Nächster Wichtel an Stelle {number}",
|
||||
"none": "Du bist noch in keiner Gruppe.",
|
||||
"error": {
|
||||
"limit": "Schließe erst eine deiner Gruppen, bevor du eine weitere öffnest.",
|
||||
"full": "Diese Gruppe ist bereits voll. Bitte um einen Code für eine andere Gruppe.",
|
||||
"rate": "Bitte warte eine Minute und versuche es dann erneut.",
|
||||
"code": "Der Code stimmt nicht oder ist abgelaufen. Prüfe die Wichtel noch einmal.",
|
||||
"access": "Diese Gruppe ist für dich nicht geöffnet. Bitte die Person, die sie geöffnet hat, um einen neuen Code.",
|
||||
"conflict": "Jemand hat den Entwurf inzwischen geändert. Deine Eingaben bleiben hier stehen. Hole die Änderungen, bevor du weiter speicherst.",
|
||||
"connection": "Das hat nicht geklappt. Prüfe deine Internetverbindung und versuche es erneut."
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
"error": {
|
||||
"add-station": "Zu dieser Strecke kann gerade keine weitere Station hinzugefügt werden.",
|
||||
@@ -436,7 +507,7 @@ globalThis.Wegwichtel ??= {};
|
||||
"optional": "Du kannst diese Erlaubnisse ablehnen. Dann fehlen die jeweiligen Funktionen. Strecken und Stationen kannst du weiterhin selbst auswählen."
|
||||
},
|
||||
"providers-title": "Werbung und andere Anbieter",
|
||||
"providers-data": "Wegwichtel zeigt keine Werbung und verfolgt nicht, welche anderen Seiten du besuchst. Die App lädt ihre eigenen Programme und Bilder von Wegwichtel. Es werden keine Cookies verwendet."
|
||||
"providers-data": "Wegwichtel zeigt keine Werbung und verfolgt nicht, welche anderen Seiten du besuchst. Die App lädt ihre eigenen Programme und Bilder von Wegwichtel. Für gemeinsame Entwürfe merkt sich dein Internetbrowser deinen Zugang für höchstens 30 Tage mit einem Cookie."
|
||||
},
|
||||
"media": {
|
||||
"audio": {
|
||||
|
||||
+72
-1
@@ -6,6 +6,45 @@ globalThis.Wegwichtel ??= {};
|
||||
static language = 'en';
|
||||
static messages = Object.freeze(
|
||||
{
|
||||
"point-locks": {
|
||||
"busy": "This stop is being saved. Wait a moment and try again.",
|
||||
"pending-title": "Changes waiting to be sent",
|
||||
"pending-help": "These changes stay on this device. Open them later and choose Save. Before sending, Wegwichtel checks whether the stop has changed.",
|
||||
"open-draft": "Open changes",
|
||||
"discard": "Discard",
|
||||
"discard-confirm": "Remove these unsent changes and the selected files from this device?",
|
||||
"takeover": "Someone is already editing this stop. Do you want to take over? They will no longer be able to save.",
|
||||
"lost": "Someone else is editing this stop. Keep your changes on this device and try again later.",
|
||||
"unavailable": "This stop cannot be opened right now. Check your connection and group access.",
|
||||
"storage-error": "Your changes could not be saved on this device. Keep this window open and free up some space.",
|
||||
"offline-restore": "This stop cannot be reached right now. Would you like to open the changes saved on this device?",
|
||||
"restore": "This device has unsent changes for this stop. Would you like to open them?",
|
||||
"restored": "Your unsent changes are open. Choose Save to check and send them.",
|
||||
"saved-device": "Your changes are saved on this device and have not all been sent. Open them later under “Changes waiting to be sent”, or choose Save again.",
|
||||
"deletion-staged": "The removal is ready. Choose Save to send it.",
|
||||
"delete-restored": "This stop is marked for removal. Save checks the latest version and sends the removal.",
|
||||
"compare-title": "This stop has changed",
|
||||
"compare-help": "Compare your changes with the latest version. Sending your changes replaces the current details with yours. Check pictures and recordings too: the same number can contain different files.",
|
||||
"field": "Detail",
|
||||
"before": "When opened",
|
||||
"mine": "Your changes",
|
||||
"theirs": "Online now",
|
||||
"keep-device": "Keep on this device",
|
||||
"use-mine": "Send my changes",
|
||||
"media-count": "{images} pictures, {audio} recordings",
|
||||
"media-compare": "When opened: {before}. Online now: {theirs}. Added by you: {files}. Planned removals: {removed}.",
|
||||
"files-changed": "Pictures or recordings have changed. Check the current files below.",
|
||||
"files-unchanged": "The existing picture and audio files are unchanged.",
|
||||
"delete-warning": "You have marked the entire stop for removal.",
|
||||
"fields": {
|
||||
"title": "Name",
|
||||
"description": "Description",
|
||||
"lat": "Latitude",
|
||||
"lon": "Longitude",
|
||||
"trigger-radius": "Opening distance",
|
||||
"sequence": "Order"
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"no-tracking": "There are no adverts here. We do not track which other websites you visit.",
|
||||
"resume-hint": "You can carry on where you left off.",
|
||||
@@ -94,6 +133,38 @@ globalThis.Wegwichtel ??= {};
|
||||
"cleanup-progress": "Removing old progress ({value1} / {value2})",
|
||||
"ready": "Everything is ready."
|
||||
},
|
||||
"groups": {
|
||||
"title": "Shared drafts",
|
||||
"upload-confirm": "Your draft, including pictures and recordings, will be sent to the website and copied into a shared draft. Continue?",
|
||||
"share": "Edit together",
|
||||
"join-open": "Enter group code",
|
||||
"online": "You need an internet connection to edit together.",
|
||||
"wheel-help": "Slide the gnomes up or down. The gnomes in the middle make the code. Read from left to right, starting with the top row.",
|
||||
"code-label": "Group code",
|
||||
"join": "Join in",
|
||||
"invite-help": "Show this code to the others so they can open and edit your shared draft. The original stays as it is.",
|
||||
"edit": "Edit draft",
|
||||
"reload": "Get changes",
|
||||
"reload-confirm": "Your unsaved changes will be replaced. Would you like to get the latest shared version?",
|
||||
"new-code": "Show a new code",
|
||||
"new-code-confirm": "The old code will stop working. People who have already joined will keep their access. Create a new code?",
|
||||
"close-sharing": "Close group",
|
||||
"close-confirm": "The others will no longer be able to open or edit this draft. You will keep your access. Close the group?",
|
||||
"expires": "This code works until {time}.",
|
||||
"position": "Gnome {number}",
|
||||
"previous": "Previous gnome in position {number}",
|
||||
"next": "Next gnome in position {number}",
|
||||
"none": "You have not joined a group yet.",
|
||||
"error": {
|
||||
"limit": "Close one of your groups before opening another.",
|
||||
"full": "This group is full. Ask for a code for another group.",
|
||||
"rate": "Please wait a minute and try again.",
|
||||
"code": "The code is wrong or has expired. Check the gnomes again.",
|
||||
"access": "This group is not open to you. Ask the person who opened it for a new code.",
|
||||
"conflict": "Someone else has changed the draft. Your entries are still here. Get the changes before saving again.",
|
||||
"connection": "That did not work. Check your internet connection and try again."
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
"error": {
|
||||
"add-station": "Another stop cannot be added to this trail right now.",
|
||||
@@ -436,7 +507,7 @@ globalThis.Wegwichtel ??= {};
|
||||
"optional": "You can refuse these permissions. The matching features will then be unavailable. You can still choose trails and stops yourself."
|
||||
},
|
||||
"providers-title": "Adverts and other providers",
|
||||
"providers-data": "Wegwichtel does not show adverts or track which other websites you visit. The app loads its own programs and pictures from Wegwichtel. No cookies are used."
|
||||
"providers-data": "Wegwichtel does not show adverts or track which other websites you visit. The app loads its own programs and pictures from Wegwichtel. For shared drafts, a cookie remembers your access for up to 30 days."
|
||||
},
|
||||
"media": {
|
||||
"audio": {
|
||||
|
||||
Reference in New Issue
Block a user