feat(hikes): worldwide maps via a region-switching tile proxy
Add tile-proxy/: a small Rust (axum) service behind nginx that serves one
canonical XYZ scheme (/{karte,luftbild,dufour}/{z}/{x}/{y}) and, per tile,
picks the provider by geometry — swisstopo when the tile overlaps a
swisstopo-covered region (Switzerland or Liechtenstein, each simplified +
2 km buffer; tile-bbox ∩ polygon at every zoom), otherwise OpenTopoMap
(schematic) / Esri World Imagery (satellite), with an auto-fallback for
border 404s. Includes the region generator (gen-regions.mjs), a Makefile,
nginx caching-proxy + systemd examples, and a README. Listen address is
env-driven (TILE_PROXY_ADDR).
App side:
- New mapTiles.ts is the single source for the proxy URLs + combined
attribution; HikeMap / HikesOverviewMap / EditMap fetch through
maps.bocken.org instead of swisstopo directly, on-map attribution
controls removed, preconnect + footer credits updated (swisstopo /
OpenStreetMap+OpenTopoMap / Esri).
- Region-aware schematic max zoom (isSwissRegion helper): detail map caps
at z17 abroad and hides the CH/LI-only Dufour layer; overview caps at
z18 when a shown hike is abroad.
- Route-builder: add the satellite layer via the same bottom-right layer
popover as the other maps.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import type { HikeTrackPoint, ImagePoint, HikeStage } from '$types/hikes';
|
||||
import { hover, setHover, clearHover } from './hoverStore.svelte';
|
||||
import { stage } from './stageStore.svelte';
|
||||
import { TILE_URL, TILE_ATTRIBUTION } from '$lib/data/mapTiles';
|
||||
import { focused, setFocused, clearFocused } from './focusedImageStore.svelte';
|
||||
import Map from '@lucide/svelte/icons/map';
|
||||
import Satellite from '@lucide/svelte/icons/satellite';
|
||||
@@ -39,6 +40,10 @@
|
||||
* stageStore) the map highlights it, dims the rest, zooms to it, and
|
||||
* scopes photo markers to that stage. */
|
||||
stages?: HikeStage[] | null;
|
||||
/** Whether the hike lies in a swisstopo-covered region (CH/LI). Drives
|
||||
* the schematic max zoom (19 in-region vs 17 for OpenTopoMap abroad)
|
||||
* and whether the CH/LI-only Dufour layer is offered. */
|
||||
swissRegion?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -49,7 +54,8 @@
|
||||
initialZoom,
|
||||
onReady,
|
||||
trackColor,
|
||||
stages = null
|
||||
stages = null,
|
||||
swissRegion = true
|
||||
}: Props = $props();
|
||||
|
||||
// User-location toggle moved inside the map UI. localStorage-persisted so
|
||||
@@ -101,19 +107,26 @@
|
||||
enableUserLocation = true;
|
||||
}
|
||||
|
||||
const SWISSTOPO_FARBE = 'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.pixelkarte-farbe/default/current/3857/{z}/{x}/{y}.jpeg';
|
||||
const SWISSTOPO_IMAGE = 'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swissimage/default/current/3857/{z}/{x}/{y}.jpeg';
|
||||
const SWISSTOPO_DUFOUR = 'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.hiks-dufour/default/current/3857/{z}/{x}/{y}.png';
|
||||
const SWISSTOPO_ATTRIBUTION = '© <a href="https://www.swisstopo.admin.ch/" target="_blank" rel="noopener">swisstopo</a>';
|
||||
|
||||
type BaseLayer = 'schematic' | 'aerial' | 'dufour';
|
||||
const LAYER_DEFS: Record<BaseLayer, { label: string; icon: typeof Map; maxZoom: number }> = {
|
||||
schematic: { label: 'Karte', icon: Map, maxZoom: 19 },
|
||||
type LayerDef = { label: string; icon: typeof Map; maxZoom: number };
|
||||
// Schematic max zoom is region-aware: swisstopo reaches z19 over CH/LI,
|
||||
// but the global fallback (OpenTopoMap) only serves to z17.
|
||||
const LAYER_DEFS: Record<BaseLayer, LayerDef> = $derived({
|
||||
schematic: { label: 'Karte', icon: Map, maxZoom: swissRegion ? 19 : 17 },
|
||||
aerial: { label: 'Luftbild', icon: Satellite, maxZoom: 19 },
|
||||
// Dufour Map (1845–1864): swisstopo's historical layer, only goes up
|
||||
// to roughly z16. We cap the map's maxZoom when this layer is active.
|
||||
dufour: { label: 'Dufour (1864)', icon: Landmark, maxZoom: 16 }
|
||||
};
|
||||
});
|
||||
|
||||
// The Dufour historical layer exists only for CH/LI — hide it abroad.
|
||||
const layerOptions = $derived(
|
||||
Object.entries(LAYER_DEFS).filter(([key]) => swissRegion || key !== 'dufour') as [
|
||||
BaseLayer,
|
||||
LayerDef
|
||||
][]
|
||||
);
|
||||
|
||||
let showPhotos = $state(true);
|
||||
let baseLayer = $state<BaseLayer>('schematic');
|
||||
@@ -155,22 +168,22 @@
|
||||
});
|
||||
|
||||
const tileLayers: Record<BaseLayer, ReturnType<typeof L.tileLayer>> = {
|
||||
schematic: L.tileLayer(SWISSTOPO_FARBE, {
|
||||
schematic: L.tileLayer(TILE_URL.karte, {
|
||||
maxZoom: LAYER_DEFS.schematic.maxZoom,
|
||||
minZoom: 7,
|
||||
attribution: SWISSTOPO_ATTRIBUTION,
|
||||
attribution: TILE_ATTRIBUTION,
|
||||
updateWhenZooming: false
|
||||
}),
|
||||
aerial: L.tileLayer(SWISSTOPO_IMAGE, {
|
||||
aerial: L.tileLayer(TILE_URL.luftbild, {
|
||||
maxZoom: LAYER_DEFS.aerial.maxZoom,
|
||||
minZoom: 7,
|
||||
attribution: SWISSTOPO_ATTRIBUTION,
|
||||
attribution: TILE_ATTRIBUTION,
|
||||
updateWhenZooming: false
|
||||
}),
|
||||
dufour: L.tileLayer(SWISSTOPO_DUFOUR, {
|
||||
dufour: L.tileLayer(TILE_URL.dufour, {
|
||||
maxZoom: LAYER_DEFS.dufour.maxZoom,
|
||||
minZoom: 7,
|
||||
attribution: SWISSTOPO_ATTRIBUTION,
|
||||
attribution: TILE_ATTRIBUTION,
|
||||
updateWhenZooming: false
|
||||
})
|
||||
};
|
||||
@@ -675,7 +688,7 @@
|
||||
</button>
|
||||
{#if layerMenuOpen}
|
||||
<div class="layer-popover" role="menu">
|
||||
{#each Object.entries(LAYER_DEFS) as [key, def] (key)}
|
||||
{#each layerOptions as [key, def] (key)}
|
||||
{@const Icon = def.icon}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { sacTrailColor } from '$lib/data/sacColors';
|
||||
import { TILE_URL, TILE_ATTRIBUTION } from '$lib/data/mapTiles';
|
||||
import { isSwissRegion } from '$lib/hikes/hikeArea';
|
||||
import type { HikeManifestEntry } from '$types/hikes';
|
||||
import Map from '@lucide/svelte/icons/map';
|
||||
import Satellite from '@lucide/svelte/icons/satellite';
|
||||
@@ -28,18 +30,17 @@
|
||||
|
||||
const { hikes, initialCenter, initialZoom, onReady }: Props = $props();
|
||||
|
||||
const SWISSTOPO_FARBE = 'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.pixelkarte-farbe/default/current/3857/{z}/{x}/{y}.jpeg';
|
||||
const SWISSTOPO_IMAGE = 'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swissimage/default/current/3857/{z}/{x}/{y}.jpeg';
|
||||
const SWISSTOPO_DUFOUR = 'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.hiks-dufour/default/current/3857/{z}/{x}/{y}.png';
|
||||
const SWISSTOPO_ATTRIBUTION =
|
||||
'© <a href="https://www.swisstopo.admin.ch/" target="_blank" rel="noopener">swisstopo</a>';
|
||||
// When every displayed hike is in a swisstopo region (CH/LI), the schematic
|
||||
// can use swisstopo's z19; with a hike abroad the global fallback is shallower
|
||||
// so we cap a touch lower (still generous — the overview is a finder).
|
||||
const allSwiss = $derived(hikes.every((h) => isSwissRegion(h.canton, h.country)));
|
||||
|
||||
type BaseLayer = 'schematic' | 'aerial' | 'dufour';
|
||||
const LAYER_DEFS: Record<BaseLayer, { label: string; icon: typeof Map; maxZoom: number }> = {
|
||||
schematic: { label: 'Karte', icon: Map, maxZoom: 19 },
|
||||
const LAYER_DEFS: Record<BaseLayer, { label: string; icon: typeof Map; maxZoom: number }> = $derived({
|
||||
schematic: { label: 'Karte', icon: Map, maxZoom: allSwiss ? 19 : 18 },
|
||||
aerial: { label: 'Luftbild', icon: Satellite, maxZoom: 19 },
|
||||
dufour: { label: 'Dufour (1864)', icon: Landmark, maxZoom: 16 }
|
||||
};
|
||||
});
|
||||
|
||||
const GPS_STORAGE_KEY = 'hikes:gpsEnabled';
|
||||
|
||||
@@ -123,22 +124,22 @@
|
||||
}
|
||||
|
||||
const tileLayers: Record<BaseLayer, ReturnType<typeof L.tileLayer>> = {
|
||||
schematic: L.tileLayer(SWISSTOPO_FARBE, {
|
||||
schematic: L.tileLayer(TILE_URL.karte, {
|
||||
maxZoom: LAYER_DEFS.schematic.maxZoom,
|
||||
minZoom: 7,
|
||||
attribution: SWISSTOPO_ATTRIBUTION,
|
||||
attribution: TILE_ATTRIBUTION,
|
||||
updateWhenZooming: false
|
||||
}),
|
||||
aerial: L.tileLayer(SWISSTOPO_IMAGE, {
|
||||
aerial: L.tileLayer(TILE_URL.luftbild, {
|
||||
maxZoom: LAYER_DEFS.aerial.maxZoom,
|
||||
minZoom: 7,
|
||||
attribution: SWISSTOPO_ATTRIBUTION,
|
||||
attribution: TILE_ATTRIBUTION,
|
||||
updateWhenZooming: false
|
||||
}),
|
||||
dufour: L.tileLayer(SWISSTOPO_DUFOUR, {
|
||||
dufour: L.tileLayer(TILE_URL.dufour, {
|
||||
maxZoom: LAYER_DEFS.dufour.maxZoom,
|
||||
minZoom: 7,
|
||||
attribution: SWISSTOPO_ATTRIBUTION,
|
||||
attribution: TILE_ATTRIBUTION,
|
||||
updateWhenZooming: false
|
||||
})
|
||||
};
|
||||
@@ -381,7 +382,7 @@
|
||||
|
||||
<svelte:head>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<link rel="preconnect" href="https://wmts.geo.admin.ch" crossorigin="anonymous" />
|
||||
<link rel="preconnect" href="https://maps.bocken.org" crossorigin="anonymous" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="map-wrap">
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
scheduleSave
|
||||
} from './builderStore.svelte';
|
||||
import { SAC_TRAIL_COLOR } from '$lib/data/sacColors';
|
||||
import { TILE_URL, TILE_ATTRIBUTION } from '$lib/data/mapTiles';
|
||||
import MapIcon from '@lucide/svelte/icons/map';
|
||||
import Satellite from '@lucide/svelte/icons/satellite';
|
||||
import Layers from '@lucide/svelte/icons/layers';
|
||||
// Single-point Swisstopo elevation lookups are intentionally NOT used —
|
||||
// they returned 0 against WGS-84 inputs in practice, and image waypoints
|
||||
// don't need per-point altitudes anyway. Waypoint altitudes flow from
|
||||
@@ -28,11 +32,30 @@
|
||||
pendingPlacementId ? builder.waypoints.find((w) => w.id === pendingPlacementId) ?? null : null
|
||||
);
|
||||
|
||||
// Schematic ↔ satellite base layer (satellite helps placing waypoints on
|
||||
// trails/landmarks, esp. off the marked path). Same bottom-right layer
|
||||
// popover as the detail / overview maps.
|
||||
type BaseLayer = 'schematic' | 'aerial';
|
||||
const LAYER_DEFS: Record<BaseLayer, { label: string; icon: typeof MapIcon }> = {
|
||||
schematic: { label: 'Karte', icon: MapIcon },
|
||||
aerial: { label: 'Luftbild', icon: Satellite }
|
||||
};
|
||||
let baseLayer = $state<BaseLayer>('schematic');
|
||||
let layerMenuOpen = $state(false);
|
||||
|
||||
// Close the layer popover on outside click (the opening click stops
|
||||
// propagation so this never sees it).
|
||||
$effect(() => {
|
||||
if (!layerMenuOpen) return;
|
||||
function onAway(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target && !target.closest('.layer-menu')) layerMenuOpen = false;
|
||||
}
|
||||
window.addEventListener('click', onAway);
|
||||
return () => window.removeEventListener('click', onAway);
|
||||
});
|
||||
|
||||
|
||||
const SWISSTOPO_FARBE =
|
||||
'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.pixelkarte-farbe/default/current/3857/{z}/{x}/{y}.jpeg';
|
||||
const SWISSTOPO_ATTRIBUTION =
|
||||
'© <a href="https://www.swisstopo.admin.ch/" target="_blank" rel="noopener">swisstopo</a>';
|
||||
// Default view: Switzerland-wide.
|
||||
const DEFAULT_CENTER: [number, number] = [46.8, 8.3];
|
||||
const DEFAULT_ZOOM = 8;
|
||||
@@ -101,12 +124,22 @@
|
||||
preferCanvas: false
|
||||
}).setView(DEFAULT_CENTER, DEFAULT_ZOOM);
|
||||
|
||||
L.tileLayer(SWISSTOPO_FARBE, {
|
||||
maxZoom: 19,
|
||||
minZoom: 7,
|
||||
attribution: SWISSTOPO_ATTRIBUTION,
|
||||
updateWhenZooming: false
|
||||
}).addTo(map);
|
||||
const tileLayers = {
|
||||
schematic: L.tileLayer(TILE_URL.karte, {
|
||||
maxZoom: 19,
|
||||
minZoom: 7,
|
||||
attribution: TILE_ATTRIBUTION,
|
||||
updateWhenZooming: false
|
||||
}),
|
||||
aerial: L.tileLayer(TILE_URL.luftbild, {
|
||||
maxZoom: 19,
|
||||
minZoom: 7,
|
||||
attribution: TILE_ATTRIBUTION,
|
||||
updateWhenZooming: false
|
||||
})
|
||||
};
|
||||
tileLayers.schematic.addTo(map);
|
||||
let currentBase: 'schematic' | 'aerial' = 'schematic';
|
||||
|
||||
const markerLayer = L.layerGroup().addTo(map);
|
||||
const lineLayer = L.layerGroup().addTo(map);
|
||||
@@ -252,6 +285,14 @@
|
||||
|
||||
// React to store changes.
|
||||
const stopRoot = $effect.root(() => {
|
||||
// Base-layer switch (schematic ↔ satellite).
|
||||
$effect(() => {
|
||||
if (baseLayer === currentBase) return;
|
||||
tileLayers[currentBase].remove();
|
||||
tileLayers[baseLayer].addTo(map);
|
||||
currentBase = baseLayer;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Touch each reactive field so we re-render on any mutation,
|
||||
// including focus changes (so the active marker re-styles).
|
||||
@@ -337,6 +378,45 @@
|
||||
|
||||
<div class="edit-map-wrap" class:placement-mode={!!pendingWaypoint}>
|
||||
<div class="edit-map" {@attach editAttachment}></div>
|
||||
|
||||
<div class="map-controls">
|
||||
<div class="layer-menu" class:open={layerMenuOpen}>
|
||||
<button
|
||||
type="button"
|
||||
class="round-btn"
|
||||
aria-label="Kartenebene wählen"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={layerMenuOpen}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
layerMenuOpen = !layerMenuOpen;
|
||||
}}
|
||||
>
|
||||
<Layers size={20} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
{#if layerMenuOpen}
|
||||
<div class="layer-popover" role="menu">
|
||||
{#each Object.entries(LAYER_DEFS) as [key, def] (key)}
|
||||
{@const Icon = def.icon}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={baseLayer === key}
|
||||
class:active={baseLayer === key}
|
||||
onclick={() => {
|
||||
baseLayer = key as BaseLayer;
|
||||
layerMenuOpen = false;
|
||||
}}
|
||||
>
|
||||
<Icon size={14} strokeWidth={1.75} aria-hidden="true" />
|
||||
{def.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if pendingWaypoint}
|
||||
<div class="placement-banner" role="status">
|
||||
<span>Klicke auf die Karte, um <strong>das Bild</strong> zu platzieren.</span>
|
||||
@@ -387,6 +467,97 @@
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
/* Bottom-right round controls + layer popover — same language as the
|
||||
* detail / overview maps. */
|
||||
.map-controls {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
z-index: 500;
|
||||
}
|
||||
|
||||
.round-btn {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 50%;
|
||||
box-shadow: var(--shadow-md);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
background var(--transition-fast),
|
||||
transform var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.round-btn:hover {
|
||||
color: var(--color-primary);
|
||||
transform: scale(1.05);
|
||||
box-shadow: var(--shadow-hover);
|
||||
}
|
||||
|
||||
.layer-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.layer-popover {
|
||||
position: absolute;
|
||||
right: calc(100% + 0.5rem);
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
padding: 0.3rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
min-width: 9.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.layer-popover button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.layer-popover button :global(svg) {
|
||||
color: var(--color-text-tertiary);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.layer-popover button:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.layer-popover button.active {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-on-primary);
|
||||
}
|
||||
|
||||
.layer-popover button.active :global(svg) {
|
||||
color: var(--color-text-on-primary);
|
||||
}
|
||||
|
||||
.placement-banner {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Map tile sources for the hikes maps.
|
||||
*
|
||||
* Tiles are served through the region-switching caching proxy (see
|
||||
* `tile-proxy/`), which transparently picks swisstopo inside Switzerland and
|
||||
* global providers (OpenTopoMap / Esri) elsewhere. The app just uses one
|
||||
* canonical scheme and never talks to the providers directly.
|
||||
*
|
||||
* To point back at swisstopo directly (e.g. local dev without the proxy),
|
||||
* change `TILE_BASE` here — it's the single switch.
|
||||
*/
|
||||
export const TILE_BASE = 'https://maps.bocken.org';
|
||||
|
||||
export const TILE_URL = {
|
||||
/** Schematic / topographic ("Karte"). */
|
||||
karte: `${TILE_BASE}/karte/{z}/{x}/{y}`,
|
||||
/** Satellite ("Luftbild"). */
|
||||
luftbild: `${TILE_BASE}/luftbild/{z}/{x}/{y}`,
|
||||
/** Historical Dufour map — Switzerland only. */
|
||||
dufour: `${TILE_BASE}/dufour/{z}/{x}/{y}`
|
||||
} as const;
|
||||
|
||||
/** Combined attribution — the proxy may serve any provider depending on the
|
||||
* region in view, so all three are credited. Shown in the page footer (the
|
||||
* on-map control is disabled). */
|
||||
export const TILE_ATTRIBUTION =
|
||||
'© <a href="https://www.swisstopo.admin.ch/" target="_blank" rel="noopener">swisstopo</a> · ' +
|
||||
'© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a>, ' +
|
||||
'<a href="https://opentopomap.org/" target="_blank" rel="noopener">OpenTopoMap</a> · ' +
|
||||
'© <a href="https://www.esri.com/" target="_blank" rel="noopener">Esri</a>';
|
||||
@@ -25,3 +25,15 @@ export function resolveHikeArea(
|
||||
if (k) return { value: `country:${k.code}`, label: k.name, iconUrl: k.flagUrl, kind: 'country' };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether a hike sits in a swisstopo-covered region (Switzerland or
|
||||
* Liechtenstein). Drives the schematic max-zoom (swisstopo reaches deeper than
|
||||
* the global fallback) and whether the CH/LI-only Dufour layer is offered. */
|
||||
export function isSwissRegion(
|
||||
canton: string | null | undefined,
|
||||
country: string | null | undefined
|
||||
): boolean {
|
||||
if (resolveCanton(canton)) return true;
|
||||
const c = resolveCountry(country);
|
||||
return c?.code === 'CH' || c?.code === 'LI';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user