Karten: Routen-Übersichtskarte klickbar + Tagebuch-Karten auf GL
Punkt 2 (Routen-Übersicht 'Karte'): _renderRoutesOnMap crashte, weil die Polyline-Facade kein bindTooltip/on/setStyle/getLatLngs kannte. In map-gl-mini.js ergänzt — inkl. breiter, fast unsichtbarer Hit-Linie, damit Routen auf dem Handy gut antippbar sind (Klick → Detail). Hover-Tooltip (Name+km) + Hover-Highlight. Punkt 4 (Tagebuch): beide Leaflet/OSM-Karten (Standort-Übersicht + Einzeleintrag) auf UI.map.create + Facade-Marker migriert. popupopen-Wiring (kennt die GL-Facade nicht) → Klick-Delegation auf dem Karten-Container. Karten-Instanzen werden beim View-Wechsel/Verlassen freigegeben (destroy + _clearDiaryMaps) gegen WebGL-Kontext-Leak. Detail/Übersicht fitten mehrfach (Container-Timing). Nebenbei: _loadPraise warf NotFoundError (insertBefore) — #diary-list liegt in #diary-view-content, nicht direkt in _container. Jetzt vor der Liste in deren echtem Elternknoten einfügen. Verifiziert (headless, eingeloggt, echte Daten): Routenkarte 8 Marker klickbar → Detail; Detail+Vorschläge zoomen auf die Route; Tagebuch-Karte GL mit 108 Markern, Popup-Klick → Eintrag, keine Fehler.
This commit is contained in:
parent
1defeec537
commit
285928f6f7
7 changed files with 134 additions and 87 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
1201
|
||||
1203
|
||||
|
|
@ -86,14 +86,14 @@
|
|||
<title>Ban Yaro</title>
|
||||
|
||||
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
|
||||
<script src="/js/boot-early.js?v=1201"></script>
|
||||
<script src="/js/boot-early.js?v=1203"></script>
|
||||
|
||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1201">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1201">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1201">
|
||||
<link rel="stylesheet" href="/css/utilities.css?v=1201">
|
||||
<link rel="stylesheet" href="/css/lists.css?v=1201">
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1203">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1203">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1203">
|
||||
<link rel="stylesheet" href="/css/utilities.css?v=1203">
|
||||
<link rel="stylesheet" href="/css/lists.css?v=1203">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -617,11 +617,11 @@
|
|||
<div id="modal-container"></div>
|
||||
|
||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||
<script src="/js/api.js?v=1201"></script>
|
||||
<script src="/js/ui.js?v=1201"></script>
|
||||
<script src="/js/app.js?v=1201"></script>
|
||||
<script src="/js/worlds.js?v=1201"></script>
|
||||
<script src="/js/offline-indicator.js?v=1201"></script>
|
||||
<script src="/js/api.js?v=1203"></script>
|
||||
<script src="/js/ui.js?v=1203"></script>
|
||||
<script src="/js/app.js?v=1203"></script>
|
||||
<script src="/js/worlds.js?v=1203"></script>
|
||||
<script src="/js/offline-indicator.js?v=1203"></script>
|
||||
|
||||
<!-- Feature-Seiten werden lazy geladen -->
|
||||
|
||||
|
|
@ -631,7 +631,7 @@
|
|||
|
||||
|
||||
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
|
||||
<script src="/js/boot.js?v=1201"></script>
|
||||
<script src="/js/boot.js?v=1203"></script>
|
||||
|
||||
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '1201'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VER = '1203'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
|
||||
window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
|
||||
window.APP_VERSION = APP_VERSION;
|
||||
|
|
|
|||
|
|
@ -115,7 +115,11 @@
|
|||
_id: 'poly-' + (++_seq),
|
||||
_map: null,
|
||||
_opts: opts,
|
||||
_handlers: {}, // ev → [fn]
|
||||
_tooltip: null,
|
||||
_tipPopup: null,
|
||||
_geo: function () { return { type: 'Feature', geometry: { type: 'LineString', coordinates: this._latlngs.map(_toLngLat) } }; },
|
||||
_hitId: function () { return this._id + '-hit'; },
|
||||
_ensure: function () {
|
||||
var self = this, m = self._map;
|
||||
var add = function () {
|
||||
|
|
@ -123,18 +127,75 @@
|
|||
if (!m.getLayer(self._id)) m.addLayer({ id: self._id, type: 'line', source: self._id,
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: { 'line-color': self._opts.color || '#C4843A', 'line-width': self._opts.weight || 4, 'line-opacity': self._opts.opacity != null ? self._opts.opacity : 0.9 } });
|
||||
// Breite, fast unsichtbare Hit-Linie → auf dem Handy gut antippbar.
|
||||
if (self._opts.interactive !== false && !m.getLayer(self._hitId())) {
|
||||
m.addLayer({ id: self._hitId(), type: 'line', source: self._id,
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: { 'line-color': '#000', 'line-opacity': 0.01, 'line-width': 18 } });
|
||||
}
|
||||
self._wireAll();
|
||||
};
|
||||
if (m.isStyleLoaded && m.isStyleLoaded()) add(); else m.once('load', add);
|
||||
},
|
||||
_wireOne: function (ev, fn) {
|
||||
var self = this, m = self._map, hit = self._hitId();
|
||||
if (!m.getLayer(hit)) return;
|
||||
if (ev === 'click') {
|
||||
m.on('click', hit, function (e) { if (e.originalEvent) e.originalEvent.stopPropagation(); fn(e); });
|
||||
} else if (ev === 'mouseover') {
|
||||
m.on('mouseenter', hit, function (e) { m.getCanvas().style.cursor = 'pointer'; fn(e); });
|
||||
} else if (ev === 'mouseout') {
|
||||
m.on('mouseleave', hit, function (e) { m.getCanvas().style.cursor = ''; fn(e); });
|
||||
}
|
||||
},
|
||||
_wireAll: function () {
|
||||
var self = this;
|
||||
Object.keys(self._handlers).forEach(function (ev) {
|
||||
self._handlers[ev].forEach(function (fn) { self._wireOne(ev, fn); });
|
||||
self._handlers[ev]._wired = true;
|
||||
});
|
||||
if (self._tooltip && !self._tipWired) self._wireTooltip();
|
||||
},
|
||||
_wireTooltip: function () {
|
||||
var self = this, m = self._map, hit = self._hitId();
|
||||
if (!m.getLayer(hit)) return;
|
||||
self._tipWired = true;
|
||||
m.on('mousemove', hit, function (e) {
|
||||
if (!self._tipPopup) self._tipPopup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 10, className: 'rk-map-tip' });
|
||||
self._tipPopup.setLngLat(e.lngLat).setHTML(self._tooltip).addTo(m);
|
||||
});
|
||||
m.on('mouseleave', hit, function () { if (self._tipPopup) { self._tipPopup.remove(); } });
|
||||
},
|
||||
addTo: function (mapWrap) { this._map = mapWrap && mapWrap._gl ? mapWrap._gl : mapWrap; this._ensure(); return this; },
|
||||
on: function (ev, fn) {
|
||||
(this._handlers[ev] = this._handlers[ev] || []).push(fn);
|
||||
if (this._map && this._map.getLayer(this._hitId())) this._wireOne(ev, fn);
|
||||
return this;
|
||||
},
|
||||
bindTooltip: function (t) {
|
||||
this._tooltip = typeof t === 'string' ? t : '';
|
||||
if (this._map && this._map.getLayer(this._hitId())) this._wireTooltip();
|
||||
return this;
|
||||
},
|
||||
setStyle: function (s) {
|
||||
var m = this._map; if (!m || !m.getLayer(this._id)) return this;
|
||||
if (s.color != null) m.setPaintProperty(this._id, 'line-color', s.color);
|
||||
if (s.weight != null) m.setPaintProperty(this._id, 'line-width', s.weight);
|
||||
if (s.opacity != null) m.setPaintProperty(this._id, 'line-opacity', s.opacity);
|
||||
return this;
|
||||
},
|
||||
setLatLngs: function (lls) {
|
||||
this._latlngs = lls || [];
|
||||
if (this._map && this._map.getSource(this._id)) this._map.getSource(this._id).setData(this._geo());
|
||||
return this;
|
||||
},
|
||||
// Leaflet-kompatibel: Array von {lat,lng} (für fitBounds-Sammlung).
|
||||
getLatLngs: function () { return this._latlngs.map(function (p) { return (p && p.lat != null) ? { lat: p.lat, lng: p.lng } : { lat: p[0], lng: p[1] }; }); },
|
||||
getBounds: function () { return { _coords: this._latlngs.map(function (p) { return (p && p.lat != null) ? [p.lat, p.lng] : p; }) }; },
|
||||
remove: function () {
|
||||
var m = this._map; if (!m) return this;
|
||||
if (this._tipPopup) { try { this._tipPopup.remove(); } catch (e) {} }
|
||||
if (m.getLayer(this._hitId())) m.removeLayer(this._hitId());
|
||||
if (m.getLayer(this._id)) m.removeLayer(this._id);
|
||||
if (m.getSource(this._id)) m.removeSource(this._id);
|
||||
return this;
|
||||
|
|
|
|||
|
|
@ -310,8 +310,10 @@ window.Page_diary = (() => {
|
|||
aria-label="Schließen">×</button>
|
||||
`;
|
||||
|
||||
// #diary-list liegt in #diary-view-content (nicht direkt in _container) → vor der
|
||||
// Liste in IHREM echten Elternknoten einfügen, sonst wirft insertBefore (NotFoundError).
|
||||
const list = _container.querySelector('#diary-list');
|
||||
if (list) _container.insertBefore(card, list);
|
||||
if (list && list.parentNode) list.parentNode.insertBefore(card, list);
|
||||
|
||||
card.querySelector('#diary-praise-close')?.addEventListener('click', () => {
|
||||
card.style.opacity = '0';
|
||||
|
|
@ -363,6 +365,13 @@ window.Page_diary = (() => {
|
|||
|
||||
let _currentView = 'list'; // 'list' | 'media' | 'calendar' | 'map'
|
||||
let _totalStats = null; // {entries, photos, days} — Gesamtstatistik aus API
|
||||
let _diaryMaps = []; // aktive Karten-Instanzen → beim View-Wechsel freigeben (GL-Kontext-Leak)
|
||||
|
||||
// Karten beim View-Wechsel/Verlassen sauber freigeben (sonst leakt der WebGL-Kontext).
|
||||
function _clearDiaryMaps() {
|
||||
_diaryMaps.forEach(m => { try { m && m.remove && m.remove(); } catch (e) {} });
|
||||
_diaryMaps = [];
|
||||
}
|
||||
|
||||
async function _loadStats() {
|
||||
const dog = _appState.activeDog;
|
||||
|
|
@ -431,6 +440,7 @@ window.Page_diary = (() => {
|
|||
const content = _container.querySelector('#diary-view-content');
|
||||
const loadMore = _container.querySelector('#diary-load-more');
|
||||
if (!content) return;
|
||||
_clearDiaryMaps(); // evtl. offene Karte (z.B. Map-Ansicht) freigeben
|
||||
// "Weitere laden" nur in der Listenansicht sinnvoll
|
||||
if (loadMore) loadMore.style.display = 'none';
|
||||
if (_currentView === 'list') {
|
||||
|
|
@ -470,16 +480,6 @@ window.Page_diary = (() => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Leaflet laden
|
||||
if (!window.L) {
|
||||
await new Promise((res, rej) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = `/js/leaflet.js?v=${APP_VER}`;
|
||||
s.onload = res; s.onerror = rej;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
const mapEl = content.querySelector('#diary-map-view');
|
||||
if (!mapEl) return;
|
||||
|
||||
|
|
@ -488,8 +488,23 @@ window.Page_diary = (() => {
|
|||
const lons = locations.map(l => l.gps_lon);
|
||||
const bounds = [[Math.min(...lats), Math.min(...lons)], [Math.max(...lats), Math.max(...lons)]];
|
||||
|
||||
const map = L.map(mapEl, { zoomControl: true, attributionControl: false });
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map);
|
||||
// GL-Karte (gleicher Style wie die zentrale Karte), Fallback Leaflet über die Facade.
|
||||
const map = await UI.map.create(mapEl, { zoomControl: true, attributionControl: false });
|
||||
_diaryMaps.push(map);
|
||||
|
||||
// Popup-Klick → Eintrag öffnen (Delegation auf dem Karten-Container; engine-neutral,
|
||||
// ersetzt das Leaflet-'popupopen'-Wiring, das die GL-Facade nicht kennt).
|
||||
mapEl.addEventListener('click', async (e) => {
|
||||
const pop = e.target.closest('.diary-map-popup');
|
||||
if (!pop) return;
|
||||
const id = parseInt(pop.dataset.id);
|
||||
if (!_entries.find(en => en.id === id)) {
|
||||
try { const fresh = await API.diary.get(_appState.activeDog.id, id); _entries.unshift(fresh); }
|
||||
catch { return; }
|
||||
}
|
||||
if (map.closePopup) map.closePopup();
|
||||
_openDetail(id);
|
||||
});
|
||||
|
||||
// Marker für jeden Eintrag
|
||||
locations.forEach(loc => {
|
||||
|
|
@ -497,59 +512,36 @@ window.Page_diary = (() => {
|
|||
const dateStr = loc.datum ? new Date(loc.datum+'T12:00').toLocaleDateString('de-DE', {day:'numeric',month:'short',year:'numeric'}) : '';
|
||||
const title = UI.escape(loc.titel || loc.location_name || dateStr);
|
||||
|
||||
const icon = L.divIcon({
|
||||
html: hasPhoto
|
||||
const iconHtml = hasPhoto
|
||||
? `<div style="width:44px;height:44px;border-radius:50%;overflow:hidden;border:3px solid var(--c-primary,#C4843A);box-shadow:0 2px 8px rgba(0,0,0,.3);background:#fff">
|
||||
<img src="${UI.escape(loc.cover_preview_url || loc.cover_url)}" style="width:100%;height:100%;object-fit:cover" data-fb-src="${UI.escape(loc.cover_url)}">
|
||||
</div>`
|
||||
: `<div style="width:32px;height:32px;border-radius:50%;background:var(--c-primary,#C4843A);border:3px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,.3);display:flex;align-items:center;justify-content:center">
|
||||
<svg style="width:16px;height:16px;fill:#fff" viewBox="0 0 256 256"><path d="M128,16a96,96,0,1,0,96,96A96.11,96.11,0,0,0,128,16Zm0,176a80,80,0,1,1,80-80A80.09,80.09,0,0,1,128,192Zm0-104a24,24,0,1,0,24,24A24,24,0,0,0,128,88Z"/></svg>
|
||||
</div>`,
|
||||
iconSize: hasPhoto ? [44, 44] : [32, 32],
|
||||
iconAnchor: hasPhoto ? [22, 22] : [16, 16],
|
||||
className: '',
|
||||
});
|
||||
</div>`;
|
||||
const _mSize = hasPhoto ? 44 : 32;
|
||||
|
||||
const marker = L.marker([loc.gps_lat, loc.gps_lon], { icon });
|
||||
marker.bindPopup(`
|
||||
UI.map.svgMarker(loc.gps_lat, loc.gps_lon, iconHtml, { size: _mSize, anchorY: _mSize / 2 })
|
||||
.bindPopup(`
|
||||
<div style="min-width:160px;cursor:pointer" class="diary-map-popup" data-id="${loc.id}">
|
||||
${hasPhoto ? `<img src="${UI.escape(loc.cover_preview_url || loc.cover_url)}" style="width:100%;height:100px;object-fit:cover;border-radius:6px;display:block;margin-bottom:8px" data-fb-src="${UI.escape(loc.cover_url)}">` : ''}
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:2px">${title}</div>
|
||||
<div style="font-size:11px;color:#888">${dateStr}</div>
|
||||
${loc.media_count > 1 ? `<div style="font-size:11px;color:#888;margin-top:2px">📷 ${loc.media_count} Medien</div>` : ''}
|
||||
<div style="margin-top:6px;text-align:center;font-size:12px;color:var(--c-primary,#C4843A);font-weight:600">→ Öffnen</div>
|
||||
</div>`, { maxWidth: 200 });
|
||||
|
||||
marker.on('popupopen', () => {
|
||||
setTimeout(() => {
|
||||
document.querySelectorAll('.diary-map-popup').forEach(el => {
|
||||
el.addEventListener('click', async () => {
|
||||
map.closePopup();
|
||||
const id = parseInt(el.dataset.id);
|
||||
// Eintrag aus _entries holen oder per API nachladen
|
||||
if (!_entries.find(e => e.id === id)) {
|
||||
try {
|
||||
const fresh = await API.diary.get(_appState.activeDog.id, id);
|
||||
_entries.unshift(fresh);
|
||||
} catch { return; }
|
||||
}
|
||||
_openDetail(id);
|
||||
});
|
||||
});
|
||||
}, 50);
|
||||
});
|
||||
|
||||
marker.addTo(map);
|
||||
</div>`, { maxWidth: 200 })
|
||||
.addTo(map);
|
||||
});
|
||||
|
||||
// Karte auf alle Punkte zoomen
|
||||
if (locations.length === 1) {
|
||||
map.setView([locations[0].gps_lat, locations[0].gps_lon], 14);
|
||||
} else {
|
||||
map.fitBounds(bounds, { padding: [40, 40] });
|
||||
}
|
||||
|
||||
setTimeout(() => map.invalidateSize(), 100);
|
||||
// Karte auf alle Punkte zoomen — mehrfach (Container/Style können beim Erstellen
|
||||
// noch nicht final sein → erneut fitten nach Layout/Tile-Load).
|
||||
const _fit = () => {
|
||||
map.invalidateSize();
|
||||
if (locations.length === 1) map.setView([locations[0].gps_lat, locations[0].gps_lon], 14);
|
||||
else map.fitBounds(bounds, { padding: [40, 40] });
|
||||
};
|
||||
_fit();
|
||||
setTimeout(_fit, 200); setTimeout(_fit, 500);
|
||||
}
|
||||
|
||||
function _renderMediaGrid(content) {
|
||||
|
|
@ -1154,26 +1146,18 @@ window.Page_diary = (() => {
|
|||
setTimeout(async () => {
|
||||
const mapEl = view.querySelector('#diary-dv-map');
|
||||
if (!mapEl) return;
|
||||
if (!window.L) {
|
||||
await new Promise((res, rej) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = `/js/leaflet.js?v=${APP_VER}`;
|
||||
s.onload = res; s.onerror = rej;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
const map = L.map(mapEl, { zoomControl: true, attributionControl: false });
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map);
|
||||
const svgIcon = L.divIcon({
|
||||
html: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="32" height="32">
|
||||
const map = await UI.map.create(mapEl, {
|
||||
center: [entry.gps_lat, entry.gps_lon], zoom: 15,
|
||||
zoomControl: true, attributionControl: false,
|
||||
});
|
||||
_diaryMaps.push(map);
|
||||
const iconHtml = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="32" height="32">
|
||||
<circle cx="128" cy="128" r="96" fill="var(--c-primary,#C4843A)" opacity=".25"/>
|
||||
<circle cx="128" cy="128" r="48" fill="var(--c-primary,#C4843A)"/>
|
||||
</svg>`,
|
||||
iconSize: [32, 32], iconAnchor: [16, 16], className: '',
|
||||
});
|
||||
L.marker([entry.gps_lat, entry.gps_lon], { icon: svgIcon }).addTo(map);
|
||||
map.setView([entry.gps_lat, entry.gps_lon], 15);
|
||||
map.invalidateSize();
|
||||
</svg>`;
|
||||
UI.map.svgMarker(entry.gps_lat, entry.gps_lon, iconHtml, { size: 32, anchorY: 16 }).addTo(map);
|
||||
const _fit = () => { map.invalidateSize(); map.setView([entry.gps_lat, entry.gps_lon], 15); };
|
||||
_fit(); setTimeout(_fit, 200); setTimeout(_fit, 500);
|
||||
}, 150);
|
||||
}
|
||||
|
||||
|
|
@ -1811,6 +1795,8 @@ window.Page_diary = (() => {
|
|||
.trim();
|
||||
}
|
||||
|
||||
return { init, refresh, openNew, onDogChange, openDetail: _openDetail };
|
||||
function destroy() { _clearDiaryMaps(); }
|
||||
|
||||
return { init, refresh, openNew, onDogChange, openDetail: _openDetail, destroy };
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<script src="/js/landing-init.js?v=1201"></script>
|
||||
<script src="/js/landing-init.js?v=1203"></script>
|
||||
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
|
||||
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, ohne App Store.">
|
||||
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
============================================================ */
|
||||
|
||||
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
|
||||
const VER = '1201';
|
||||
const VER = '1203';
|
||||
const CACHE_VERSION = `by-v${VER}`;
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue