37 Commits
1.4.5 ... 1.4.9

Author SHA1 Message Date
Alexis
99e66f6feb Merge branch 'dev' 2025-05-24 12:38:58 +02:00
Alexis
cfe3fe2dcc Updated packages 2025-05-24 12:38:50 +02:00
Alexis
42b3cfd725 Added bad bots list 2025-05-24 12:36:28 +02:00
Alexis
d3c0a87770 Version bump 2025-05-13 10:58:09 +02:00
Alexis
3e1d7c5200 Updated packages 2025-05-13 10:57:48 +02:00
Alexis
e893e133cc Added toast color 2025-05-04 20:39:34 +02:00
Alexis
4cf2f66d42 Added copy coords function 2025-05-04 18:33:42 +02:00
Alexis
f003df7d58 Added toast service 2025-05-03 21:48:32 +02:00
Alexis
355e9ae009 Merge branch 'dev' 2025-05-03 14:25:43 +02:00
Alexis
ca689c23af Merge branch 'cleanup' into dev 2025-05-03 14:25:05 +02:00
Alexis
c282c7ec7a Added env variables to disable some options 2025-05-02 18:54:28 +02:00
Alexis
0bc65e96c8 Updated package 2025-05-02 18:00:41 +02:00
Alexis
c6bf77462e Version bump 2025-04-10 19:21:46 +02:00
Alexis
fb26813932 Merge branch 'cleanup' 2025-04-08 16:50:47 +02:00
Alexis
6152b707d1 Minimize zoomify leaflet js 2025-04-05 18:45:05 +02:00
Alexis
a40bf13d98 Persist coords on lang switch 2025-04-04 22:08:09 +02:00
Alexis
c68b1baf8c Added docs for interfaces 2025-04-04 21:46:56 +02:00
Alexis
199bb98412 Marker tooltip content is a component 2025-04-04 21:13:08 +02:00
Alexis
82aa2f4d35 Updated package 2025-04-04 17:13:25 +02:00
Alexis
86dbe7ba2b Hover enter more lenient 2025-04-03 16:47:33 +02:00
Alexis
8b976e909a Fixed some accessibility issues 2025-04-02 14:02:53 +02:00
Alexis
47f5df936e Fixed meta semicolon 2025-04-02 12:17:40 +02:00
Alexis
6d281e14ea Added prefect to astro links 2025-04-02 12:17:12 +02:00
Alexis
cd99ed2444 Updated packages 2025-04-02 12:03:18 +02:00
Alexis
3e1382c404 Changed images to webp 2025-03-27 23:08:14 +01:00
Alexis
c84745b35a Version bump 2025-03-27 22:41:16 +01:00
Alexis
f5a76f9c6e Updated packages 2025-03-27 22:41:03 +01:00
Alexis
809edc167d Merge branch 'dev' 2025-03-26 16:54:26 +01:00
Alexis
ada061862f Fixed overflowing templates 2025-03-26 15:57:37 +01:00
Alexis
7d60b81763 Fixed oopsie 2025-03-26 15:50:55 +01:00
Alexis
635c58736c Fixed lang button alignment 2025-03-26 15:50:32 +01:00
Alexis
15f18a8f95 Added copy link component 2025-03-26 15:43:20 +01:00
Alexis
3460689d3d Changed marker builder
They're in the DOM now, so much easier to manage
2025-03-23 21:19:13 +01:00
Alexis
78c1991f6a Normalize string function 2025-03-23 18:41:51 +01:00
Alexis
98e65fe6e1 Added point parameter for initial setup 2025-03-23 18:10:38 +01:00
Alexis
769391fad0 Removed unused import 2025-03-23 17:16:58 +01:00
Alexis
8da145039c Version bump 2025-03-23 17:12:28 +01:00
79 changed files with 2097 additions and 1229 deletions

View File

@@ -1,21 +1,21 @@
import { defineConfig } from 'astro/config'; import { defineConfig, envField } from 'astro/config';
import { availableLangs, defaultLang } from './src/i18n/ui';
import vue from "@astrojs/vue"; import vue from "@astrojs/vue";
import sitemap from "@astrojs/sitemap"; import sitemap from "@astrojs/sitemap";
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
integrations: [vue(), sitemap()], integrations: [vue(), sitemap()],
output: 'static', output: 'static',
site: 'https://maps.alexcreates.fr', site: 'https://maps.alexcreates.fr',
// i18n: { prefetch: true,
// locales: availableLangs, env: {
// defaultLocale: defaultLang, schema: {
// routing: { ENABLE_TOOLBAR: envField.boolean({ context: "server", access: "secret", default: true }),
// prefixDefaultLocale: true, ENABLE_TOOLBAR_TAGS: envField.boolean({ context: "server", access: "secret", default: true }),
// } ENABLE_CUSTOM_MARKERS: envField.boolean({ context: "server", access: "secret", default: true }),
// } ENABLE_BREADCRUMB: envField.boolean({ context: "server", access: "secret", default: true }),
ENABLE_I18N: envField.boolean({ context: "server", access: "secret", default: true }),
}
}
}); });

View File

@@ -1,7 +1,7 @@
{ {
"name": "leim-maps", "name": "leim-maps",
"type": "module", "type": "module",
"version": "1.4.5", "version": "1.4.9",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"start": "astro dev", "start": "astro dev",
@@ -10,18 +10,18 @@
"astro": "astro" "astro": "astro"
}, },
"dependencies": { "dependencies": {
"@astrojs/sitemap": "^3.3.0", "@astrojs/sitemap": "^3.4.0",
"@astrojs/vue": "^5.0.7", "@astrojs/vue": "^5.1.0",
"@nanostores/persistent": "^0.10.2", "@nanostores/persistent": "1.0.0",
"@nanostores/vue": "^0.11.0", "@nanostores/vue": "^1.0.0",
"@types/leaflet": "^1.9.17", "@types/leaflet": "^1.9.18",
"@vueuse/core": "^13.0.0", "@vueuse/core": "^13.2.0",
"astro": "^5.5.4", "astro": "^5.8.0",
"nanostores": "^0.11.4", "nanostores": "^1.0.1",
"radix-vue": "^1.9.17", "radix-vue": "^1.9.17",
"vue": "^3.5.13" "vue": "^3.5.14"
}, },
"devDependencies": { "devDependencies": {
"sass": "^1.86.0" "sass": "^1.89.0"
} }
} }

1858
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

5
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,5 @@
onlyBuiltDependencies:
- '@parcel/watcher'
- esbuild
- sharp
- vue-demi

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 641 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -1,130 +1,4 @@
/* L.TileLayer.Zoomify=L.TileLayer.extend({options:{width:-1,height:-1,tileGroupPrefix:'TileGroup',tilesPerTileGroup:256},initialize:function(url,options){L.TileLayer.prototype.initialize.call(this,url,options);if(this.options.width<0||this.options.height<0){throw new Error('The user must set the Width and Height of the Zoomify image')}},beforeAdd:function(map){var imageSize=L.point(this.options.width,this.options.height);this._imageSize=[imageSize];this._gridSize=[this._getGridSize(imageSize)];while(imageSize.x>this.options.tileSize||imageSize.y>this.options.tileSize){imageSize=imageSize.divideBy(2).ceil();this._imageSize.push(imageSize);this._gridSize.push(this._getGridSize(imageSize))}
* L.TileLayer.Zoomify display Zoomify tiles with Leaflet this._imageSize.reverse();this._gridSize.reverse();var maxNativeZoom=this._gridSize.length-1;this.options.maxNativeZoom=maxNativeZoom;var maxZoomGrid=this._gridSize[maxNativeZoom],maxX=maxZoomGrid.x*this.options.tileSize,maxY=maxZoomGrid.y*this.options.tileSize,southEast=map.unproject([maxX,maxY],maxNativeZoom);this.options.bounds=new L.LatLngBounds([[0,0],southEast]);L.TileLayer.prototype.beforeAdd.call(this,map)},_getGridSize:function(imageSize){var tileSize=this.options.tileSize;return L.point(Math.ceil(imageSize.x/tileSize),Math.ceil(imageSize.y/tileSize))},_addTile:function(coords,container){L.TileLayer.prototype._addTile.call(this,coords,container);var imageSize=this._imageSize[this._getZoomForUrl()],gridSize=this._gridSize[this._getZoomForUrl()];var realTileSize=L.GridLayer.prototype.getTileSize.call(this),displayTileSize=L.TileLayer.prototype.getTileSize.call(this);var key=this._tileCoordsToKey(coords),tile=this._tiles[key].el;var scaleFactor=L.point((imageSize.x%realTileSize.x),(imageSize.y%realTileSize.y)).unscaleBy(realTileSize);if((imageSize.x%realTileSize.x)>0&&coords.x===gridSize.x-1){tile.style.width=displayTileSize.scaleBy(scaleFactor).x+'px'}
* if((imageSize.y%realTileSize.y)>0&&coords.y===gridSize.y-1){tile.style.height=displayTileSize.scaleBy(scaleFactor).y+'px'}},getTileUrl:function(coords){this.options.g=this.options.tileGroupPrefix+this._getTileGroup(coords);return L.TileLayer.prototype.getTileUrl.call(this,coords)},_getTileGroup:function(coords){var zoom=this._getZoomForUrl(),num=0,gridSize;for(var z=0;z<zoom;z++){gridSize=this._gridSize[z];num+=gridSize.x*gridSize.y}
* Based on the Leaflet.Zoomify (https://github.com/turban/Leaflet.Zoomify) num+=coords.y*this._gridSize[zoom].x+coords.x;return Math.floor(num/this.options.tilesPerTileGroup)},getBounds:function(){return this.options.bounds}});L.tileLayer.zoomify=function(url,options){return new L.TileLayer.Zoomify(url,options)}
* from turban (https://github.com/turban)
*
*/
L.TileLayer.Zoomify = L.TileLayer.extend({
options: {
width: -1, // Must be set by user, max zoom image width
height: -1, // Must be set by user, max zoom image height
tileGroupPrefix: 'TileGroup',
tilesPerTileGroup: 256
},
initialize: function (url, options) {
L.TileLayer.prototype.initialize.call(this, url, options);
// Replace with automatic loading from ImageProperties.xml
if (this.options.width < 0 || this.options.height < 0) {
throw new Error('The user must set the Width and Height of the Zoomify image');
}
},
beforeAdd: function (map) {
var imageSize = L.point(this.options.width, this.options.height);
// Build the zoom sizes of the pyramid and cache them in an array
this._imageSize = [imageSize];
this._gridSize = [this._getGridSize(imageSize)];
// Register the image size in pixels and the grid size in # of tiles for each zoom level
while (imageSize.x > this.options.tileSize || imageSize.y > this.options.tileSize) {
imageSize = imageSize.divideBy(2).ceil();
this._imageSize.push(imageSize);
this._gridSize.push(this._getGridSize(imageSize));
}
// We built the cache from bottom to top, but leaflet uses a top to bottom index for the zoomlevel,
// so reverse it for easy indexing by current zoomlevel
this._imageSize.reverse();
this._gridSize.reverse();
// Register our max supported zoom level
var maxNativeZoom = this._gridSize.length - 1;
this.options.maxNativeZoom = maxNativeZoom;
// Register our bounds for this zoomify layer based on the maximum zoom
var maxZoomGrid = this._gridSize[maxNativeZoom],
maxX = maxZoomGrid.x * this.options.tileSize,
maxY = maxZoomGrid.y * this.options.tileSize,
southEast = map.unproject([maxX, maxY], maxNativeZoom);
this.options.bounds = new L.LatLngBounds([[0, 0], southEast]);
L.TileLayer.prototype.beforeAdd.call(this, map);
},
// Calculate the grid size for a given image size (based on tile size)
_getGridSize: function (imageSize) {
var tileSize = this.options.tileSize;
return L.point(Math.ceil(imageSize.x / tileSize), Math.ceil(imageSize.y / tileSize));
},
// Extend the add tile function to update our arbitrary sized border tiles
_addTile: function (coords, container) {
// Load the tile via the original leaflet code
L.TileLayer.prototype._addTile.call(this, coords, container);
// Get out imagesize in pixels for this zoom level and our grid size
var imageSize = this._imageSize[this._getZoomForUrl()],
gridSize = this._gridSize[this._getZoomForUrl()];
// The real tile size (default:256) and the display tile size (if zoom > maxNativeZoom)
var realTileSize = L.GridLayer.prototype.getTileSize.call(this),
displayTileSize = L.TileLayer.prototype.getTileSize.call(this);
// Get the current tile to adjust
var key = this._tileCoordsToKey(coords),
tile = this._tiles[key].el;
// Calculate the required size of the border tiles
var scaleFactor = L.point( (imageSize.x % realTileSize.x),
(imageSize.y % realTileSize.y)).unscaleBy(realTileSize);
// Update tile dimensions if we are on a border
if ((imageSize.x % realTileSize.x) > 0 && coords.x === gridSize.x - 1) {
tile.style.width = displayTileSize.scaleBy(scaleFactor).x + 'px';
}
if ((imageSize.y % realTileSize.y) > 0 && coords.y === gridSize.y - 1) {
tile.style.height = displayTileSize.scaleBy(scaleFactor).y + 'px';
}
},
// Construct the tile url, by inserting our tilegroup before we template the url
getTileUrl: function (coords) {
// Set our TileGroup variable, and then let the original tile templater do the work
this.options.g = this.options.tileGroupPrefix + this._getTileGroup(coords);
// Call the original templater
return L.TileLayer.prototype.getTileUrl.call(this, coords);
},
// Calculates the TileGroup number, each group contains 256 tiles. The tiles are stored from topleft to bottomright
_getTileGroup: function (coords) {
var zoom = this._getZoomForUrl(),
num = 0,
gridSize;
// Get the total number of tiles from the lowest zoom level to our zoomlevel
for (var z = 0; z < zoom; z++) {
gridSize = this._gridSize[z];
num += gridSize.x * gridSize.y;
}
// Add the remaining tiles from this zoom layer to the running total of tiles
num += coords.y * this._gridSize[zoom].x + coords.x;
return Math.floor(num / this.options.tilesPerTileGroup);
},
getBounds: function () {
return this.options.bounds;
}
});
L.tileLayer.zoomify = function (url, options) {
return new L.TileLayer.Zoomify(url, options);
};

49
public/robots.txt Normal file
View File

@@ -0,0 +1,49 @@
User-agent: GPTBot
Disallow: /
User-agent: ChatGPT-User
Disallow: /
User-agent: Google-Extended
Disallow: /
User-agent: PerplexityBot
Disallow: /
User-agent: Amazonbot
Disallow: /
User-agent: ClaudeBot
Disallow: /
User-agent: Omgilibot
Disallow: /
User-Agent: FacebookBot
Disallow: /
User-Agent: Applebot
Disallow: /
User-agent: anthropic-ai
Disallow: /
User-agent: Bytespider
Disallow: /
User-agent: Claude-Web
Disallow: /
User-agent: Diffbot
Disallow: /
User-agent: ImagesiftBot
Disallow: /
User-agent: Omgilibot
Disallow: /
User-agent: Omgili
Disallow: /
User-agent: YouBot
Disallow: /
User-agent: AhrefsBot
Disallow: /
User-agent: AhrefsSiteAudit
Disallow: /
User-agent: SemrushBot
Disallow: /
User-agent: YandexMarket
Disallow: /
User-agent: YandexBot
Disallow: /
User-agent: DataForSeoBot
Disallow: /
Sitemap: https://maps.alexcreates.fr/sitemap-index.xml

View File

@@ -16,115 +16,6 @@
.leaflet-popup-content { .leaflet-popup-content {
min-width: 20rem; min-width: 20rem;
margin: 1rem; margin: 1rem;
figure {
position: relative;
margin-inline: -1.1rem;
margin-top: -1.1rem;
margin-bottom: .5rem;
&.landscape {
aspect-ratio: 16 / 9;
min-width: 20rem;
}
&.portrait {
aspect-ratio: 15 / 16;
min-width: 10rem;
}
&::before {
content: '';
display: block;
pointer-events: none;
user-select: none;
position: absolute;
inset: 0;
z-index: 10;
// Make a linear gradient from top to bottom, from transparent to black
background: linear-gradient(to bottom, transparent 70%, var(--black) 100%);
}
img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top;
}
figcaption cite {
position: absolute;
font-size: 85%;
font-style: italic;
font-weight: 600;
bottom: .5rem;
right: 1rem;
color: var(--white);
z-index: 20;
opacity: .6;
transition-property: opacity;
transition-duration: .2s;
transition-timing-function: cubic-bezier(0.23, 1, 0.320, 1);
a {
padding-right: 0;
color: var(--white);
text-decoration: underline;
text-underline-offset: .15rem;
&:hover {
color: var(--blue-500);
}
&::after {
display: none;
}
}
}
&:hover {
figcaption cite {
opacity: 1;
}
}
}
.title {
display: block;
font-weight: 600;
}
strong.title {
color: var(--green-500);
}
a.title,
a.map-link {
text-underline-offset: 2px;
text-decoration: underline;
&:hover {
text-decoration: none;
color: var(--green-500);
}
}
.map-link {
margin-top: .5rem;
display: inline-flex;
align-items: center;
gap: .5ch;
svg {
width: 1rem;
height: 1rem;
}
}
> * + * {
margin: 0;
margin-top: .4em;
}
} }
} }
@@ -133,6 +24,7 @@
height: .8rem; height: .8rem;
} }
} }
.leaflet-tooltip-pane { .leaflet-tooltip-pane {
.leaflet-tooltip { .leaflet-tooltip {
&.capital-name { &.capital-name {

View File

@@ -0,0 +1,115 @@
.toast-viewport {
--viewport-padding: 25px;
position: fixed;
bottom: 0;
right: 2rem;
display: flex;
flex-direction: column;
padding: var(--viewport-padding);
gap: 10px;
width: 480px;
max-width: 100vw;
margin: 0;
list-style: none;
z-index: 2147483647;
outline: none;
}
.toast-root {
position: relative;
padding: 15px;
display: grid;
grid-template-areas: 'title action' 'description action';
grid-template-columns: auto max-content;
column-gap: 15px;
align-items: center;
color: var(--slate-800);
border: 1px solid var(--slate-400);
background-color: var(--white);
border-radius: 6px;
box-shadow: hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px;
&.toast-success {
color: var(--green-700);
border: 1px solid var(--green-700);
background-color: color-mix(in srgb, var(--green-500) 17.5%, var(--white));
.toast-icon svg {
fill: var(--green-700);
}
}
&:has(.toast-icon) {
padding-left: 2.2rem;
}
.toast-icon {
position: absolute;
top: 50%;
transform: translateY(-50%);
left: .66rem;
}
}
.toast-root[data-state='open'] {
animation: slideIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
.toast-root[data-state='closed'] {
animation: hide 100ms ease-in;
}
.toast-root[data-swipe='move'] {
transform: translateX(var(--radix-toast-swipe-move-x));
}
.toast-root[data-swipe='cancel'] {
transform: translateX(0);
transition: transform 200ms ease-out;
}
.toast-root[data-swipe='end'] {
animation: swipeOut 100ms ease-out;
}
@keyframes hide {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes slideIn {
from {
transform: translateX(calc(100% + var(--viewport-padding)));
}
to {
transform: translateX(0);
}
}
@keyframes swipeOut {
from {
transform: translateX(var(--radix-toast-swipe-end-x));
}
to {
transform: translateX(calc(100% + var(--viewport-padding)));
}
}
.toast-title {
grid-area: title;
margin-bottom: 5px;
font-weight: 500;
color: var(--slate-12);
font-size: 15px;
}
.toast-description {
grid-area: description;
margin: 0;
color: var(--slate-11);
font-size: 13px;
line-height: 1.3;
}
.toast-action {
grid-area: action;
}

View File

@@ -9,6 +9,8 @@
@use 'card'; @use 'card';
@use 'map'; @use 'map';
@use 'radix/toast';
:root { :root {
--white: #fff; --white: #fff;
--black: #111; --black: #111;

View File

@@ -0,0 +1,81 @@
---
import { t } from '@/i18n/store';
import { getLangFromUrl } from '@/i18n/utils';
interface Props {
text: string,
}
const { text } = Astro.props
const lang = getLangFromUrl(Astro.url);
const placeholder = t('common.copyLink', lang)
const message = t('common.copiedLink', lang)
---
<button data-target={text} data-copy={placeholder} data-copy-message={message}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path d="M240,88.23a54.43,54.43,0,0,1-16,37L189.25,160a54.27,54.27,0,0,1-38.63,16h-.05A54.63,54.63,0,0,1,96,119.84a8,8,0,0,1,16,.45A38.62,38.62,0,0,0,150.58,160h0a38.39,38.39,0,0,0,27.31-11.31l34.75-34.75a38.63,38.63,0,0,0-54.63-54.63l-11,11A8,8,0,0,1,135.7,59l11-11A54.65,54.65,0,0,1,224,48,54.86,54.86,0,0,1,240,88.23ZM109,185.66l-11,11A38.41,38.41,0,0,1,70.6,208h0a38.63,38.63,0,0,1-27.29-65.94L78,107.31A38.63,38.63,0,0,1,144,135.71a8,8,0,0,0,16,.45A54.86,54.86,0,0,0,144,96a54.65,54.65,0,0,0-77.27,0L32,130.75A54.62,54.62,0,0,0,70.56,224h0a54.28,54.28,0,0,0,38.64-16l11-11A8,8,0,0,0,109,185.66Z"></path></svg>
<span data-copy-text>
{placeholder}
</span>
</button>
<script>
const copyColor = 'initial'
const copyColorSuccess = 'var(--green-500)'
const copyTimeout = 2000
const dataCopyButtons = document.querySelectorAll<HTMLButtonElement>('button[data-copy]')
dataCopyButtons.forEach(button => {
button.addEventListener('click', () => {
const copyText = button.getAttribute('data-target')
navigator.clipboard.writeText(copyText!)
changeCopyText(button)
})
})
function changeCopyText(copyButton: HTMLButtonElement) {
const copyPlaceholder = copyButton.getAttribute('data-copy')
const copyPlaceholderSuccess = copyButton.getAttribute('data-copy-message')
const dataText = copyButton.querySelector<HTMLSpanElement>('[data-copy-text]')
const svg = copyButton.querySelector<SVGElement>('svg')
if (!dataText || !svg) return
dataText.textContent = copyPlaceholderSuccess
dataText.style.color = copyColorSuccess
svg.style.fill = copyColorSuccess
setTimeout(() => {
dataText.textContent = copyPlaceholder
dataText.style.color = copyColor
svg.style.fill = copyColor
}, copyTimeout)
}
</script>
<style lang="scss">
button {
display: inline-flex;
align-items: center;
gap: .5ch;
color: var(--slate-700);
cursor: pointer;
text-underline-offset: 2px;
text-decoration: none;
&:hover,
&:focus-visible {
text-decoration: underline;
}
svg {
width: 1rem;
height: 1rem;
fill: var(--slate-700);
}
}
</style>

View File

@@ -1,10 +1,10 @@
--- ---
import { ENABLE_CUSTOM_MARKERS } from "astro:env/server";
import type { MapMarker, PlayerMarker } from '@/types/Leaflet'; import type { MapMarker, PlayerMarker } from '@/types/Leaflet';
import type { MapProps } from '@/types/Map'; import type { MapProps } from '@/types/Map';
import MarkerCreator from './overlay/MarkerCreator.vue'; import MarkerCreator from './overlay/MarkerCreator.vue';
import { t } from '@/i18n/store'; import MarkerContent from './MarkerContent.astro';
import { getLangFromUrl } from '@/i18n/utils';
import MapActions from './MapActions.astro';
interface Props extends MapProps {} interface Props extends MapProps {}
@@ -20,18 +20,23 @@ const {
rulerHideWalkDistance = false, rulerHideWalkDistance = false,
backgroundColor = 'transparent', backgroundColor = 'transparent',
} = Astro.props } = Astro.props
const lang = getLangFromUrl(Astro.url);
const byText = t('common.by', lang)
const seeMapText = t('maps.go-to-map', lang)
--- ---
<div class="map-wrapper"> <div class="map-wrapper">
<div class="world-wrapper"> <main class="world-wrapper">
<div id="world"></div> <div id="world"></div>
{ENABLE_CUSTOM_MARKERS &&
<MarkerCreator client:only="vue" mapKey={mapKey} /> <MarkerCreator client:only="vue" mapKey={mapKey} />
}
</main>
<div style="height: 0; width: 0; overflow: hidden; display: none;">
{markers.map((m, index) => (
<div id="popup-template" data-id={`marker-${index}`}>
<MarkerContent marker={m} />
</div>
))}
</div> </div>
</div> </div>
@@ -45,12 +50,20 @@ const seeMapText = t('maps.go-to-map', lang)
mapWidth, mapWidth,
rulerDistanceRatio, rulerDistanceRatio,
rulerMainUnit, rulerMainUnit,
rulerHideWalkDistance, rulerHideWalkDistance
byText,
seeMapText
}} }}
defer defer
> >
/** UTILITIES */
/**
* Normalize a string to be used as a key
* @param str
*/
function normalizeString(str) {
if (!str) return ''
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()
}
/** /**
* LEAFLET MAP SETUP * LEAFLET MAP SETUP
*/ */
@@ -131,6 +144,12 @@ const layerGroups = {
quests: L.layerGroup(), quests: L.layerGroup(),
} }
// Prepare initial fly to coords in case of ?p= query param
let initialFlyToMarker = false;
// Get ?p= query param
const urlParams = new URLSearchParams(window.location.search)
const initialPoint = normalizeString(urlParams.get('p'))
/** /**
* Build all markers and their related info * Build all markers and their related info
*/ */
@@ -204,54 +223,9 @@ for (let i = 0; i < markers.length; i++) {
const marker = L.marker(coords, { icon: markerIconOverride }).addTo(map) const marker = L.marker(coords, { icon: markerIconOverride }).addTo(map)
// Build popup content // Get popup content
let popupContent = "" const popupTemplate = document.querySelector(`#popup-template[data-id="marker-${i}"]`)
marker.bindPopup(popupTemplate)
if (m.cover && m.coverAuthor && m.coverLink) {
const figureClass = m.coverPortrait ? 'portrait' : 'landscape';
const figureWidth = m.coverPortrait ? 210 : 420;
popupContent += `
<figure class="${figureClass}">
<img src="/images/cover/${m.cover}" alt="${m.title}" width="${figureWidth}" />
<figcaption>
<cite>${byText} <a href="${m.coverLink}" target="_blank">${m.coverAuthor}</a></cite>
</figcaption>
</figure>
`
}
if (m.link) {
popupContent += `
<a href="${m.link}" target="_blank" class="title">
${m.title}
</a>
`
} else {
popupContent += `
<strong class="title">
${m.title}
</strong>
`
}
if (m.description) {
popupContent += `<p>${m.description}</p>`
}
if (m.mapId) {
popupContent += `
<a href="${m.mapId}" class="map-link">
<span>
${ seeMapText }
</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="80" y1="112" x2="144" y2="112" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="112" cy="112" r="80" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="168.57" y1="168.57" x2="224" y2="224" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="112" y1="80" x2="112" y2="144" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</a>
`
}
marker.bindPopup(popupContent).openPopup()
const displayTooltip = m.group === 'capitals' const displayTooltip = m.group === 'capitals'
@@ -270,6 +244,18 @@ for (let i = 0; i < markers.length; i++) {
marker.openPopup() marker.openPopup()
}, flyToDuration * 1000) }, flyToDuration * 1000)
}) })
/**
* In case the initial point is set, prepare event name for fly to
*/
// First we parse the marker title to :
// - Change special characters (not accentuated characters) and spaces to hyphens
// - Lowercase the string
const parsedMarkerTitle = normalizeString(m.title)
if (initialPoint && parsedMarkerTitle === initialPoint) {
initialFlyToMarker = m.title
}
} }
/** /**
@@ -572,7 +558,7 @@ function getCoordsSetupFromURL() {
function hasInitialSetup() { function hasInitialSetup() {
const params = new URL(document.location).searchParams const params = new URL(document.location).searchParams
return params.has('lat') && params.has('lon') return params.has('lat') && params.has('lon') || params.has('p')
} }
/** /**
@@ -593,16 +579,20 @@ function setupToURL() {
/** /**
* Uses the URL params to setup the leaflet map * Uses the URL params to setup the leaflet map
*/ */
function setupFromURL() { function setupFromURL(p) {
const setup = getCoordsSetupFromURL() const setup = getCoordsSetupFromURL()
if (setup.has('p')) {
document.dispatchEvent(new CustomEvent(`fly-to-${p}`))
} else {
map.setView({ lat: setup.get('lat'), lng: setup.get('lon') }, setup.get('zoom') | minZoom, { animate: false }) map.setView({ lat: setup.get('lat'), lng: setup.get('lon') }, setup.get('zoom') | minZoom, { animate: false })
}
} }
// Check if the map has initial params in the URL // Check if the map has initial params in the URL
// If not, just setup the default URL // If not, just setup the default URL
if (hasInitialSetup()) { if (hasInitialSetup()) {
setupFromURL() setupFromURL(initialFlyToMarker)
} else { } else {
setupToURL() setupToURL()
} }
@@ -612,6 +602,19 @@ map.on('moveend', () => {
setupToURL() setupToURL()
}) })
/**
* Listener for querying a copy of the current coordinates
*/
window.addEventListener('on-copy-coords', () => {
const lon = convertScaleToX(Number(localStorage.getItem('lastHeldXPosition')))
const lat = convertScaleToY(Number(localStorage.getItem('lastHeldYPosition')))
const coordsText = JSON.stringify({ x: lon, y: lat })
navigator.clipboard.writeText(coordsText)
window.dispatchEvent(new CustomEvent('on-toast-push', { bubbles: true, detail: { preset: 'copied-coords' }}))
})
/** /**
* UTILITIES * UTILITIES
*/ */
@@ -619,7 +622,7 @@ map.on('moveend', () => {
* Convert x coords to scale with ratio, zoom and offset * Convert x coords to scale with ratio, zoom and offset
* @param {number} x * @param {number} x
*/ */
function convertXToScale(x) { function convertXToScale(x) {
return (x * xRatio * zoomRatio) + xOffset return (x * xRatio * zoomRatio) + xOffset
} }
/** /**

View File

@@ -1,10 +1,13 @@
--- ---
import { ENABLE_BREADCRUMB, ENABLE_I18N, ENABLE_TOOLBAR, ENABLE_TOOLBAR_TAGS } from "astro:env/server";
import type { MapMarker, PlayerMarker } from "@/types/Leaflet"; import type { MapMarker, PlayerMarker } from "@/types/Leaflet";
import type { MapOverlayProps } from "@/types/Map"; import type { MapOverlayProps } from "@/types/Map";
import SearchMarkers from "./overlay/SearchMarkers.vue"; import SearchMarkers from "./overlay/SearchMarkers.vue";
import LangSwitcher from "./overlay/LangSwitcher.vue"; import LangSwitcher from "./overlay/LangSwitcher.vue";
import MapOverlayBreadcrumbs from "./MapOverlayBreadcrumbs.astro"; import MapOverlayBreadcrumbs from "./MapOverlayBreadcrumbs.astro";
import ToastService from "./overlay/ToastService.vue";
interface Props extends MapOverlayProps {} interface Props extends MapOverlayProps {}
@@ -25,16 +28,31 @@ const currentUrl = Astro.url
<div class="world-overlay"> <div class="world-overlay">
<div class="top-bar"> <div class="top-bar">
<SearchMarkers client:only="vue" mapKey={mapKey} markers={markers} players={players} searchConfig={searchConfig} > {ENABLE_TOOLBAR &&
<SearchMarkers
client:only="vue"
mapKey={mapKey}
markers={markers}
players={players}
searchConfig={searchConfig}
disableTags={!ENABLE_TOOLBAR_TAGS}
>
<div slot="fallback" style="height: 45px;"></div> <div slot="fallback" style="height: 45px;"></div>
</SearchMarkers> </SearchMarkers>
}
{ENABLE_I18N &&
<LangSwitcher currentUrl={currentUrl} client:only="vue" /> <LangSwitcher currentUrl={currentUrl} client:only="vue" />
}
</div> </div>
{ENABLE_BREADCRUMB &&
<MapOverlayBreadcrumbs breadcrumbs={breadcrumbs} /> <MapOverlayBreadcrumbs breadcrumbs={breadcrumbs} />
}
</div> </div>
<ToastService client:only="vue" />
<style lang="scss"> <style lang="scss">
.world-overlay { .world-overlay {
position: absolute; position: absolute;

View File

@@ -17,15 +17,15 @@ const { breadcrumbs = [] } = Astro.props
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#0f172a" viewBox="0 0 256 256" class="folder"><path d="M245,110.64A16,16,0,0,0,232,104H216V88a16,16,0,0,0-16-16H130.67L102.94,51.2a16.14,16.14,0,0,0-9.6-3.2H40A16,16,0,0,0,24,64V208h0a8,8,0,0,0,8,8H211.1a8,8,0,0,0,7.59-5.47l28.49-85.47A16.05,16.05,0,0,0,245,110.64ZM93.34,64,123.2,86.4A8,8,0,0,0,128,88h72v16H69.77a16,16,0,0,0-15.18,10.94L40,158.7V64Z"></path></svg> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#0f172a" viewBox="0 0 256 256" class="folder"><path d="M245,110.64A16,16,0,0,0,232,104H216V88a16,16,0,0,0-16-16H130.67L102.94,51.2a16.14,16.14,0,0,0-9.6-3.2H40A16,16,0,0,0,24,64V208h0a8,8,0,0,0,8,8H211.1a8,8,0,0,0,7.59-5.47l28.49-85.47A16.05,16.05,0,0,0,245,110.64ZM93.34,64,123.2,86.4A8,8,0,0,0,128,88h72v16H69.77a16,16,0,0,0-15.18,10.94L40,158.7V64Z"></path></svg>
<span class="breadcrumb" aria-current="page"> <span class="breadcrumb" aria-current="page">
<span class="label"> <h1 class="label">
{breadcrumb.name} {breadcrumb.name}
</span> </h1>
</span> </span>
} }
{!(index === breadcrumbs.length - 1) && {!(index === breadcrumbs.length - 1) &&
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#0f172a" viewBox="0 0 256 256" class="folder"><path d="M216,72H131.31L104,44.69A15.86,15.86,0,0,0,92.69,40H40A16,16,0,0,0,24,56V200.62A15.4,15.4,0,0,0,39.38,216H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72ZM40,56H92.69l16,16H40ZM216,200H40V88H216Z"></path></svg> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#0f172a" viewBox="0 0 256 256" class="folder"><path d="M216,72H131.31L104,44.69A15.86,15.86,0,0,0,92.69,40H40A16,16,0,0,0,24,56V200.62A15.4,15.4,0,0,0,39.38,216H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72ZM40,56H92.69l16,16H40ZM216,200H40V88H216Z"></path></svg>
<a href={breadcrumb.url} class="breadcrumb" class:list={{ active: index === breadcrumbs.length - 1 }}> <a href={breadcrumb.url} class="breadcrumb" class:list={{ active: index === breadcrumbs.length - 1 }} data-astro-prefetch="hover">
<span class="label"> <span class="label">
{breadcrumb.name} {breadcrumb.name}
</span> </span>

View File

@@ -0,0 +1,176 @@
---
import type { MapMarker } from '@/types/Leaflet';
import { normalizeString } from '@/utils/Strings';
import { getLangFromUrl } from '@/i18n/utils';
import { t } from '@/i18n/store';
import CopyText from '@/components/global/CopyText.astro';
interface Props {
marker: MapMarker
}
const { marker: m } = Astro.props as Props;
const lang = getLangFromUrl(Astro.url);
const byText = t('common.by', lang)
const seeMapText = t('maps.go-to-map', lang)
---
<div class="tooltip-content">
{m.cover && m.coverAuthor && m.coverLink && (
<figure class={m.coverPortrait ? 'portrait' : 'landscape'}>
<img src={`/images/cover/${m.cover}`} alt={m.title} width={m.coverPortrait ? 210 : 420} />
<figcaption>
<span class="author">
<span>{byText}</span>
<cite>
<a href={m.coverLink} target="_blank">{m.coverAuthor}</a>
</cite>
</span>
</figcaption>
</figure>
)}
{m.link ? (
<a href={m.link} target="_blank" class="title">{m.title}</a>
) : (
<strong class="title">{m.title}</strong>
)}
{m.description && <p set:html={m.description} />}
<div class="tooltip-footer">
{m.mapId && (
<div>
<a href={m.mapId} class="map-link">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="80" y1="112" x2="144" y2="112" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="112" cy="112" r="80" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="168.57" y1="168.57" x2="224" y2="224" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="112" y1="80" x2="112" y2="144" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
<span>
{ seeMapText }
</span>
</a>
</div>
)}
<div>
<CopyText text={`${Astro.url}?p=${normalizeString(m.title)}`} />
</div>
</div>
</div>
<style lang="scss">
figure {
position: relative;
margin-inline: -1.1rem;
margin-top: -1.1rem;
margin-bottom: .5rem;
&.landscape {
aspect-ratio: 16 / 9;
min-width: 20rem;
}
&.portrait {
aspect-ratio: 15 / 16;
min-width: 10rem;
}
&::before {
content: '';
display: block;
pointer-events: none;
user-select: none;
position: absolute;
inset: 0;
z-index: 10;
// Make a linear gradient from top to bottom, from transparent to black
background: linear-gradient(to bottom, transparent 70%, var(--black) 100%);
}
img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top;
}
figcaption .author {
position: absolute;
font-size: 85%;
font-style: italic;
font-weight: 600;
bottom: .5rem;
right: 1rem;
color: var(--white);
z-index: 20;
opacity: .6;
transition-property: opacity;
transition-duration: .2s;
transition-timing-function: cubic-bezier(0.23, 1, 0.320, 1);
a {
padding-right: 0;
color: var(--white);
text-decoration: underline;
text-underline-offset: .15rem;
&:hover {
color: var(--blue-500);
}
&::after {
display: none;
}
}
}
&:hover {
figcaption .author {
opacity: 1;
}
}
}
.title {
display: block;
font-weight: 600;
}
strong.title {
color: var(--green-500);
}
a.title,
a.map-link {
text-underline-offset: 2px;
text-decoration: underline;
&:hover,
&:focus-visible {
text-decoration: none;
color: var(--green-500);
}
}
.map-link {
display: inline-flex;
align-items: center;
gap: .5ch;
svg {
width: 1rem;
height: 1rem;
}
}
.tooltip-content > * + * {
margin: 0;
margin-top: .66em;
}
.tooltip-footer {
display: grid;
gap: .15em;
font-size: .95em;
}
</style>

View File

@@ -9,7 +9,7 @@
"x": 83.6, "x": 83.6,
"y": -185.2 "y": -185.2
}, },
"cover": "Ambrose.jpg", "cover": "Ambrose.webp",
"coverAuthor": "Claudio Pilia", "coverAuthor": "Claudio Pilia",
"coverLink": "https://pilia-art.com/portfolio-item/game-of-thrones-the-city-of-tyrosh/" "coverLink": "https://pilia-art.com/portfolio-item/game-of-thrones-the-city-of-tyrosh/"
}, },
@@ -22,7 +22,7 @@
"x": 133.46875, "x": 133.46875,
"y": -146.0625 "y": -146.0625
}, },
"cover": "Cordouac.jpg", "cover": "Cordouac.webp",
"coverAuthor": "Vincent Joyal", "coverAuthor": "Vincent Joyal",
"coverLink": "https://www.artstation.com/artwork/6B0b0" "coverLink": "https://www.artstation.com/artwork/6B0b0"
}, },
@@ -45,7 +45,7 @@
"x": 132.26953, "x": 132.26953,
"y": -98.19141 "y": -98.19141
}, },
"cover": "Tourgrise.png", "cover": "Tourgrise.webp",
"coverAuthor": "saya kuroha", "coverAuthor": "saya kuroha",
"coverLink": "https://www.pixiv.net/en/artworks/83793821", "coverLink": "https://www.pixiv.net/en/artworks/83793821",
"coverPortrait": true "coverPortrait": true
@@ -79,7 +79,7 @@
"x": 168.39491, "x": 168.39491,
"y": -109.13376 "y": -109.13376
}, },
"cover": "Port-brisé.jpg", "cover": "Port-brisé.webp",
"coverAuthor": "JC Jongwon Park", "coverAuthor": "JC Jongwon Park",
"coverLink": "https://www.artstation.com/artwork/okmQL" "coverLink": "https://www.artstation.com/artwork/okmQL"
}, },
@@ -122,7 +122,7 @@
"x": 204.42515, "x": 204.42515,
"y": -226.85883 "y": -226.85883
}, },
"cover": "Soubreciel.jpg", "cover": "Soubreciel.webp",
"coverAuthor": "Tony Andreas Rudolph", "coverAuthor": "Tony Andreas Rudolph",
"coverLink": "https://www.artstation.com/artwork/EB84" "coverLink": "https://www.artstation.com/artwork/EB84"
}, },
@@ -166,7 +166,7 @@
"y": -51.54687 "y": -51.54687
}, },
"mapId": "/en/aldys/borelis", "mapId": "/en/aldys/borelis",
"cover": "Port_de_Borélis.jpeg", "cover": "Port_de_Borélis.webp",
"coverAuthor": "Anton Bezrukov", "coverAuthor": "Anton Bezrukov",
"coverLink": "https://www.artstation.com/artwork/oODL5B" "coverLink": "https://www.artstation.com/artwork/oODL5B"
}, },
@@ -210,7 +210,7 @@
"y": -210.87224307207197 "y": -210.87224307207197
}, },
"mapId": "/en/aldys/cantane", "mapId": "/en/aldys/cantane",
"cover": "Cantane.jpg", "cover": "Cantane.webp",
"coverAuthor": "Andreas Roller", "coverAuthor": "Andreas Roller",
"coverLink": "https://en.wikipedia.org/wiki/Andreas_Roller" "coverLink": "https://en.wikipedia.org/wiki/Andreas_Roller"
}, },
@@ -223,7 +223,7 @@
"y": -210.9293951755887 "y": -210.9293951755887
}, },
"mapId": "/en/aldys/cantane/mines-blanches", "mapId": "/en/aldys/cantane/mines-blanches",
"cover": "Les Mines Blanches.png", "cover": "Les Mines Blanches.webp",
"coverAuthor": "MeckanicalMind", "coverAuthor": "MeckanicalMind",
"coverLink": "https://www.deviantart.com/meckanicalmind/art/R3-Mines-258469134" "coverLink": "https://www.deviantart.com/meckanicalmind/art/R3-Mines-258469134"
}, },
@@ -236,7 +236,7 @@
"x": 185.57431, "x": 185.57431,
"y": -229.08588 "y": -229.08588
}, },
"cover": "Horfer.jpg", "cover": "Horfer.webp",
"coverAuthor": "Nele-Diel", "coverAuthor": "Nele-Diel",
"coverLink": "https://www.deviantart.com/nele-diel/art/City-in-the-Lowlands-693529688" "coverLink": "https://www.deviantart.com/nele-diel/art/City-in-the-Lowlands-693529688"
}, },
@@ -519,7 +519,7 @@
"x": 121.47116769539711, "x": 121.47116769539711,
"y": -94.66997667550434 "y": -94.66997667550434
}, },
"cover": "Faille_de_Kiam.jpg", "cover": "Faille_de_Kiam.webp",
"coverAuthor": "Laura Sava", "coverAuthor": "Laura Sava",
"coverLink": "https://www.behance.net/gallery/16130223/Zariels-Doom", "coverLink": "https://www.behance.net/gallery/16130223/Zariels-Doom",
"coverPortrait": true "coverPortrait": true
@@ -573,7 +573,7 @@
"x": 107.50352, "x": 107.50352,
"y": -67.36631 "y": -67.36631
}, },
"cover": "Willeau.jpg", "cover": "Willeau.webp",
"coverAuthor": "Inconnu", "coverAuthor": "Inconnu",
"coverLink": "https://www.hebus.com/image-339033.html" "coverLink": "https://www.hebus.com/image-339033.html"
}, },
@@ -635,7 +635,7 @@
"x": 168.31641, "x": 168.31641,
"y": -60.54297 "y": -60.54297
}, },
"cover": "Tours_des_Cages.jpg", "cover": "Tours_des_Cages.webp",
"coverAuthor": "25kartinok", "coverAuthor": "25kartinok",
"coverLink": "https://www.deviantart.com/25kartinok/art/the-dark-tower-700231527", "coverLink": "https://www.deviantart.com/25kartinok/art/the-dark-tower-700231527",
"coverPortrait": true "coverPortrait": true
@@ -677,7 +677,7 @@
"x": 159.57688930332125, "x": 159.57688930332125,
"y": -157.4739168466964 "y": -157.4739168466964
}, },
"cover": "Plaines_de_poussière.png", "cover": "Plaines_de_poussière.webp",
"coverAuthor": "Inconnu", "coverAuthor": "Inconnu",
"coverLink": "https://www.pinterest.com/pin/pin-de-soup-em-inspiration--279223245638830461" "coverLink": "https://www.pinterest.com/pin/pin-de-soup-em-inspiration--279223245638830461"
}, },
@@ -710,7 +710,7 @@
"x": 285.0990986858211, "x": 285.0990986858211,
"y": -146.08986069534564 "y": -146.08986069534564
}, },
"cover": "Garràn.jpg", "cover": "Garràn.webp",
"coverAuthor": "Dmitry Dubinsky", "coverAuthor": "Dmitry Dubinsky",
"coverLink": "https://elhour.artstation.com/projects/RYynYm?album_id=47640" "coverLink": "https://elhour.artstation.com/projects/RYynYm?album_id=47640"
}, },
@@ -773,7 +773,7 @@
"x": 326.7497076703842, "x": 326.7497076703842,
"y": -206.67114645854178 "y": -206.67114645854178
}, },
"cover": "Fasàch.jpg", "cover": "Fasàch.webp",
"coverAuthor": "Liang Mark", "coverAuthor": "Liang Mark",
"coverLink": "https://www.artstation.com/artwork/Jll2Jd" "coverLink": "https://www.artstation.com/artwork/Jll2Jd"
}, },
@@ -886,7 +886,7 @@
"x": 83.80737652386473, "x": 83.80737652386473,
"y": -184.52963682119173 "y": -184.52963682119173
}, },
"cover": "Académie_Centrale_Kaldélienne.jpg", "cover": "Académie_Centrale_Kaldélienne.webp",
"coverAuthor": "Pengzhen Zhang", "coverAuthor": "Pengzhen Zhang",
"coverLink": "https://www.artstation.com/artwork/zAOWNd", "coverLink": "https://www.artstation.com/artwork/zAOWNd",
"icon": "graduation-cap" "icon": "graduation-cap"

View File

@@ -9,7 +9,7 @@
"x": 119.6667886200924, "x": 119.6667886200924,
"y": -17.945469721492717 "y": -17.945469721492717
}, },
"cover": "Faranne.jpg", "cover": "Faranne.webp",
"coverAuthor": "Pablo Mendoza Peces", "coverAuthor": "Pablo Mendoza Peces",
"coverLink": "https://www.artstation.com/artwork/dAJr1" "coverLink": "https://www.artstation.com/artwork/dAJr1"
}, },
@@ -32,7 +32,7 @@
"x": 113.52851847725508, "x": 113.52851847725508,
"y": -19.882939646724424 "y": -19.882939646724424
}, },
"cover": "Miverne.jpg", "cover": "Miverne.webp",
"coverAuthor": "Andrei-Pervukhin", "coverAuthor": "Andrei-Pervukhin",
"coverLink": "https://www.deviantart.com/andrei-pervukhin/art/Medieval-Village-383779337" "coverLink": "https://www.deviantart.com/andrei-pervukhin/art/Medieval-Village-383779337"
}, },
@@ -75,7 +75,7 @@
"x": 122.87040892704968, "x": 122.87040892704968,
"y": -58.7910005354329 "y": -58.7910005354329
}, },
"cover": "Rocheverre.jpg", "cover": "Rocheverre.webp",
"coverAuthor": "Creative Intelligence Arts", "coverAuthor": "Creative Intelligence Arts",
"coverLink": "https://www.kickstarter.com/projects/1300298569/project-phoenix-japans-indie-rpg-feat-aaa-talent" "coverLink": "https://www.kickstarter.com/projects/1300298569/project-phoenix-japans-indie-rpg-feat-aaa-talent"
}, },

View File

@@ -9,7 +9,7 @@
"x": 83.6, "x": 83.6,
"y": -185.2 "y": -185.2
}, },
"cover": "Ambrose.jpg", "cover": "Ambrose.webp",
"coverAuthor": "Claudio Pilia", "coverAuthor": "Claudio Pilia",
"coverLink": "https://pilia-art.com/portfolio-item/game-of-thrones-the-city-of-tyrosh/" "coverLink": "https://pilia-art.com/portfolio-item/game-of-thrones-the-city-of-tyrosh/"
}, },
@@ -22,7 +22,7 @@
"x": 133.46875, "x": 133.46875,
"y": -146.0625 "y": -146.0625
}, },
"cover": "Cordouac.jpg", "cover": "Cordouac.webp",
"coverAuthor": "Vincent Joyal", "coverAuthor": "Vincent Joyal",
"coverLink": "https://www.artstation.com/artwork/6B0b0" "coverLink": "https://www.artstation.com/artwork/6B0b0"
}, },
@@ -45,7 +45,7 @@
"x": 132.26953, "x": 132.26953,
"y": -98.19141 "y": -98.19141
}, },
"cover": "Tourgrise.png", "cover": "Tourgrise.webp",
"coverAuthor": "saya kuroha", "coverAuthor": "saya kuroha",
"coverLink": "https://www.pixiv.net/en/artworks/83793821", "coverLink": "https://www.pixiv.net/en/artworks/83793821",
"coverPortrait": true "coverPortrait": true
@@ -79,7 +79,7 @@
"x": 168.39491, "x": 168.39491,
"y": -109.13376 "y": -109.13376
}, },
"cover": "Port-brisé.jpg", "cover": "Port-brisé.webp",
"coverAuthor": "JC Jongwon Park", "coverAuthor": "JC Jongwon Park",
"coverLink": "https://www.artstation.com/artwork/okmQL" "coverLink": "https://www.artstation.com/artwork/okmQL"
}, },
@@ -122,7 +122,7 @@
"x": 204.42515, "x": 204.42515,
"y": -226.85883 "y": -226.85883
}, },
"cover": "Soubreciel.jpg", "cover": "Soubreciel.webp",
"coverAuthor": "Tony Andreas Rudolph", "coverAuthor": "Tony Andreas Rudolph",
"coverLink": "https://www.artstation.com/artwork/EB84" "coverLink": "https://www.artstation.com/artwork/EB84"
}, },
@@ -166,7 +166,7 @@
"y": -51.54687 "y": -51.54687
}, },
"mapId": "/fr/aldys/borelis", "mapId": "/fr/aldys/borelis",
"cover": "Port_de_Borélis.jpeg", "cover": "Port_de_Borélis.webp",
"coverAuthor": "Anton Bezrukov", "coverAuthor": "Anton Bezrukov",
"coverLink": "https://www.artstation.com/artwork/oODL5B" "coverLink": "https://www.artstation.com/artwork/oODL5B"
}, },
@@ -210,7 +210,7 @@
"y": -210.87224307207197 "y": -210.87224307207197
}, },
"mapId": "/fr/aldys/cantane", "mapId": "/fr/aldys/cantane",
"cover": "Cantane.jpg", "cover": "Cantane.webp",
"coverAuthor": "Andreas Roller", "coverAuthor": "Andreas Roller",
"coverLink": "https://en.wikipedia.org/wiki/Andreas_Roller" "coverLink": "https://en.wikipedia.org/wiki/Andreas_Roller"
}, },
@@ -223,7 +223,7 @@
"y": -210.9293951755887 "y": -210.9293951755887
}, },
"mapId": "/fr/aldys/cantane/mines-blanches", "mapId": "/fr/aldys/cantane/mines-blanches",
"cover": "Les Mines Blanches.png", "cover": "Les Mines Blanches.webp",
"coverAuthor": "MeckanicalMind", "coverAuthor": "MeckanicalMind",
"coverLink": "https://www.deviantart.com/meckanicalmind/art/R3-Mines-258469134" "coverLink": "https://www.deviantart.com/meckanicalmind/art/R3-Mines-258469134"
}, },
@@ -236,7 +236,7 @@
"x": 185.57431, "x": 185.57431,
"y": -229.08588 "y": -229.08588
}, },
"cover": "Horfer.jpg", "cover": "Horfer.webp",
"coverAuthor": "Nele-Diel", "coverAuthor": "Nele-Diel",
"coverLink": "https://www.deviantart.com/nele-diel/art/City-in-the-Lowlands-693529688" "coverLink": "https://www.deviantart.com/nele-diel/art/City-in-the-Lowlands-693529688"
}, },
@@ -519,7 +519,7 @@
"x": 121.47116769539711, "x": 121.47116769539711,
"y": -94.66997667550434 "y": -94.66997667550434
}, },
"cover": "Faille_de_Kiam.jpg", "cover": "Faille_de_Kiam.webp",
"coverAuthor": "Laura Sava", "coverAuthor": "Laura Sava",
"coverLink": "https://www.behance.net/gallery/16130223/Zariels-Doom", "coverLink": "https://www.behance.net/gallery/16130223/Zariels-Doom",
"coverPortrait": true "coverPortrait": true
@@ -573,7 +573,7 @@
"x": 107.50352, "x": 107.50352,
"y": -67.36631 "y": -67.36631
}, },
"cover": "Willeau.jpg", "cover": "Willeau.webp",
"coverAuthor": "Inconnu", "coverAuthor": "Inconnu",
"coverLink": "https://www.hebus.com/image-339033.html" "coverLink": "https://www.hebus.com/image-339033.html"
}, },
@@ -635,7 +635,7 @@
"x": 168.31641, "x": 168.31641,
"y": -60.54297 "y": -60.54297
}, },
"cover": "Tours_des_Cages.jpg", "cover": "Tours_des_Cages.webp",
"coverAuthor": "25kartinok", "coverAuthor": "25kartinok",
"coverLink": "https://www.deviantart.com/25kartinok/art/the-dark-tower-700231527", "coverLink": "https://www.deviantart.com/25kartinok/art/the-dark-tower-700231527",
"coverPortrait": true "coverPortrait": true
@@ -677,7 +677,7 @@
"x": 159.57688930332125, "x": 159.57688930332125,
"y": -157.4739168466964 "y": -157.4739168466964
}, },
"cover": "Plaines_de_poussière.png", "cover": "Plaines_de_poussière.webp",
"coverAuthor": "Inconnu", "coverAuthor": "Inconnu",
"coverLink": "https://www.pinterest.com/pin/pin-de-soup-em-inspiration--279223245638830461" "coverLink": "https://www.pinterest.com/pin/pin-de-soup-em-inspiration--279223245638830461"
}, },
@@ -710,7 +710,7 @@
"x": 285.0990986858211, "x": 285.0990986858211,
"y": -146.08986069534564 "y": -146.08986069534564
}, },
"cover": "Garràn.jpg", "cover": "Garràn.webp",
"coverAuthor": "Dmitry Dubinsky", "coverAuthor": "Dmitry Dubinsky",
"coverLink": "https://elhour.artstation.com/projects/RYynYm?album_id=47640" "coverLink": "https://elhour.artstation.com/projects/RYynYm?album_id=47640"
}, },
@@ -773,7 +773,7 @@
"x": 326.7497076703842, "x": 326.7497076703842,
"y": -206.67114645854178 "y": -206.67114645854178
}, },
"cover": "Fasàch.jpg", "cover": "Fasàch.webp",
"coverAuthor": "Liang Mark", "coverAuthor": "Liang Mark",
"coverLink": "https://www.artstation.com/artwork/Jll2Jd" "coverLink": "https://www.artstation.com/artwork/Jll2Jd"
}, },
@@ -886,7 +886,7 @@
"x": 83.80737652386473, "x": 83.80737652386473,
"y": -184.52963682119173 "y": -184.52963682119173
}, },
"cover": "Académie_Centrale_Kaldélienne.jpg", "cover": "Académie_Centrale_Kaldélienne.webp",
"coverAuthor": "Pengzhen Zhang", "coverAuthor": "Pengzhen Zhang",
"coverLink": "https://www.artstation.com/artwork/zAOWNd", "coverLink": "https://www.artstation.com/artwork/zAOWNd",
"icon": "graduation-cap" "icon": "graduation-cap"

View File

@@ -9,7 +9,7 @@
"x": 119.6667886200924, "x": 119.6667886200924,
"y": -17.945469721492717 "y": -17.945469721492717
}, },
"cover": "Faranne.jpg", "cover": "Faranne.webp",
"coverAuthor": "Pablo Mendoza Peces", "coverAuthor": "Pablo Mendoza Peces",
"coverLink": "https://www.artstation.com/artwork/dAJr1" "coverLink": "https://www.artstation.com/artwork/dAJr1"
}, },
@@ -32,7 +32,7 @@
"x": 113.52851847725508, "x": 113.52851847725508,
"y": -19.882939646724424 "y": -19.882939646724424
}, },
"cover": "Miverne.jpg", "cover": "Miverne.webp",
"coverAuthor": "Andrei-Pervukhin", "coverAuthor": "Andrei-Pervukhin",
"coverLink": "https://www.deviantart.com/andrei-pervukhin/art/Medieval-Village-383779337" "coverLink": "https://www.deviantart.com/andrei-pervukhin/art/Medieval-Village-383779337"
}, },
@@ -75,7 +75,7 @@
"x": 122.87040892704968, "x": 122.87040892704968,
"y": -58.7910005354329 "y": -58.7910005354329
}, },
"cover": "Rocheverre.jpg", "cover": "Rocheverre.webp",
"coverAuthor": "Creative Intelligence Arts", "coverAuthor": "Creative Intelligence Arts",
"coverLink": "https://www.kickstarter.com/projects/1300298569/project-phoenix-japans-indie-rpg-feat-aaa-talent" "coverLink": "https://www.kickstarter.com/projects/1300298569/project-phoenix-japans-indie-rpg-feat-aaa-talent"
}, },

View File

@@ -6,7 +6,7 @@ import { PopoverArrow, PopoverContent, PopoverRoot, PopoverTrigger } from 'radix
import LangSwitcherButton from './LangSwitcherButton.vue'; import LangSwitcherButton from './LangSwitcherButton.vue';
import { useStore } from '@nanostores/vue'; import { useStore } from '@nanostores/vue';
import { currentLang } from '@/i18n/store'; import { currentLang, t } from '@/i18n/store';
import { availableLangs } from '@/i18n/ui'; import { availableLangs } from '@/i18n/ui';
const $currentLang = useStore(currentLang); const $currentLang = useStore(currentLang);
@@ -22,7 +22,7 @@ const menuModel = ref(false);
const wrapper = ref<HTMLDivElement | null>(null) const wrapper = ref<HTMLDivElement | null>(null)
const buttonRef = ref<HTMLButtonElement | null>(null) const buttonRef = ref<HTMLButtonElement | null>(null)
const wrapperHovered = useElementHover(wrapper, { delayEnter: 500, delayLeave: 500 }) const wrapperHovered = useElementHover(wrapper, { delayEnter: 100, delayLeave: 500 })
const { focused: buttonFocused } = useFocus(buttonRef) const { focused: buttonFocused } = useFocus(buttonRef)
const isMenuOpen = computed(() => menuModel.value || wrapperHovered.value) const isMenuOpen = computed(() => menuModel.value || wrapperHovered.value)
@@ -39,7 +39,7 @@ watch([wrapperHovered, buttonFocused], (value) => {
<div ref="wrapper" class="wrapper appear-from-top"> <div ref="wrapper" class="wrapper appear-from-top">
<PopoverRoot :open="isMenuOpen" @update:open="() => menuModel = true"> <PopoverRoot :open="isMenuOpen" @update:open="() => menuModel = true">
<PopoverTrigger as-child> <PopoverTrigger as-child>
<button ref="buttonRef" class="btn-round" :class="{ 'active': isMenuOpen }"> <button ref="buttonRef" class="btn-round" :title="t('nav.lang.aria-label')" :class="{ 'active': isMenuOpen }">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="#000000" viewBox="0 0 256 256"><path d="M247.15,212.42l-56-112a8,8,0,0,0-14.31,0l-21.71,43.43A88,88,0,0,1,108,126.93,103.65,103.65,0,0,0,135.69,64H160a8,8,0,0,0,0-16H104V32a8,8,0,0,0-16,0V48H32a8,8,0,0,0,0,16h87.63A87.76,87.76,0,0,1,96,116.35a87.74,87.74,0,0,1-19-31,8,8,0,1,0-15.08,5.34A103.63,103.63,0,0,0,84,127a87.55,87.55,0,0,1-52,17,8,8,0,0,0,0,16,103.46,103.46,0,0,0,64-22.08,104.18,104.18,0,0,0,51.44,21.31l-26.6,53.19a8,8,0,0,0,14.31,7.16L148.94,192h70.11l13.79,27.58A8,8,0,0,0,240,224a8,8,0,0,0,7.15-11.58ZM156.94,176,184,121.89,211.05,176Z"></path></svg> <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="#000000" viewBox="0 0 256 256"><path d="M247.15,212.42l-56-112a8,8,0,0,0-14.31,0l-21.71,43.43A88,88,0,0,1,108,126.93,103.65,103.65,0,0,0,135.69,64H160a8,8,0,0,0,0-16H104V32a8,8,0,0,0-16,0V48H32a8,8,0,0,0,0,16h87.63A87.76,87.76,0,0,1,96,116.35a87.74,87.74,0,0,1-19-31,8,8,0,1,0-15.08,5.34A103.63,103.63,0,0,0,84,127a87.55,87.55,0,0,1-52,17,8,8,0,0,0,0,16,103.46,103.46,0,0,0,64-22.08,104.18,104.18,0,0,0,51.44,21.31l-26.6,53.19a8,8,0,0,0,14.31,7.16L148.94,192h70.11l13.79,27.58A8,8,0,0,0,240,224a8,8,0,0,0,7.15-11.58ZM156.94,176,184,121.89,211.05,176Z"></path></svg>
</button> </button>
</PopoverTrigger> </PopoverTrigger>

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Language } from '@/i18n/ui'; import { useUrlSearchParams } from '@vueuse/core';
import type { Language } from '@/i18n/ui';
import { t } from '@/i18n/store'; import { t } from '@/i18n/store';
import { switchLang } from '@/i18n/utils'; import { switchLang } from '@/i18n/utils';
@@ -12,10 +13,12 @@ defineProps<{
const flagWidth = 18; const flagWidth = 18;
const flagHeight = 18; const flagHeight = 18;
const { lat, lon, zoom } = useUrlSearchParams<{ lat: number; lon: number; zoom: number }>();
</script> </script>
<template> <template>
<a :href="switchLang(currentUrl, lang).pathname" :aria-disabled="disabled" :aria-current="disabled ? 'page' : 'false'" :tabindex="disabled ? -1 : 0"> <a :href="`${switchLang(currentUrl, lang).pathname}?lat=${lat}&lon=${lon}&zoom=${zoom}`" :aria-disabled="disabled" :aria-current="disabled ? 'page' : 'false'" :tabindex="disabled ? -1 : 0">
<div class="flag"> <div class="flag">
<svg v-if="disabled" xmlns="http://www.w3.org/2000/svg" :height="flagHeight" :width="flagWidth" class="valid" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm45.66,85.66-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35a8,8,0,0,1,11.32,11.32Z"></path></svg> <svg v-if="disabled" xmlns="http://www.w3.org/2000/svg" :height="flagHeight" :width="flagWidth" class="valid" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm45.66,85.66-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35a8,8,0,0,1,11.32,11.32Z"></path></svg>
@@ -42,24 +45,26 @@ a {
color: inherit; color: inherit;
line-height: 1; line-height: 1;
min-width: 14ch; min-width: 14ch;
font-size: .9em;
&:hover, &:hover,
&:focus-visible { &:focus-visible {
background-color: var(--slate-200); background-color: var(--slate-200);
} }
small { &[aria-current="page"] {
font-size: .9em; pointer-events: none;
cursor: default;
}
.flag {
display: grid;
place-items: center;
} }
.valid { .valid {
fill: var(--green-500); fill: var(--green-500);
color: var(--green-500); color: var(--green-500);
} }
&[aria-current="page"] {
pointer-events: none;
cursor: default;
}
} }
</style> </style>

View File

@@ -36,6 +36,7 @@ function switchMenuMode(newMode: MenuMode) {
onMounted(() => { onMounted(() => {
const mapRef = document.getElementById('world') const mapRef = document.getElementById('world')
if (mapRef) { if (mapRef) {
mapRef.addEventListener('contextmenu', handleContextMenu) mapRef.addEventListener('contextmenu', handleContextMenu)
mapRef.addEventListener('mousedown', hideMenu) mapRef.addEventListener('mousedown', hideMenu)
@@ -188,6 +189,11 @@ function hideMarkerModal() {
function setTitleError(error: Error | null) { function setTitleError(error: Error | null) {
markerTitleInputError.value = error markerTitleInputError.value = error
} }
function handleCoordsCopy() {
markerMenu.value?.dispatchEvent(new CustomEvent('on-copy-coords', { bubbles: true }))
hideMenu()
}
</script> </script>
<template> <template>
@@ -196,7 +202,16 @@ function setTitleError(error: Error | null) {
<menu v-show="shouldBeShown" ref="markerMenu"> <menu v-show="shouldBeShown" ref="markerMenu">
<li> <li>
<button @click="switchMenuMode('editing-marker')"> <button @click="switchMenuMode('editing-marker')">
{{ t('maps.markers.new') }} <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#000000" viewBox="0 0 256 256"><path d="M128,16a88.1,88.1,0,0,0-88,88c0,31.4,14.51,64.68,42,96.25a254.19,254.19,0,0,0,41.45,38.3,8,8,0,0,0,9.18,0A254.19,254.19,0,0,0,174,200.25c27.45-31.57,42-64.85,42-96.25A88.1,88.1,0,0,0,128,16Zm32,96H136v24a8,8,0,0,1-16,0V112H96a8,8,0,0,1,0-16h24V72a8,8,0,0,1,16,0V96h24a8,8,0,0,1,0,16Z"></path></svg>
<span>{{ t('maps.markers.new') }}</span>
</button>
</li>
<li>
<button @click="handleCoordsCopy">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#000000" viewBox="0 0 256 256"><path d="M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32Zm-8,128H176V88a8,8,0,0,0-8-8H96V48H208Z"></path></svg>
<span>{{ t('maps.copyCoords') }}</span>
</button> </button>
</li> </li>
</menu> </menu>
@@ -271,14 +286,23 @@ menu {
li { li {
a, button { a, button {
display: block; display: flex;
gap: .75ch;
align-items: center;
padding: .33rem 1rem; padding: .33rem 1rem;
padding-left: .66rem;
font-size: .77em; font-size: .77em;
cursor: pointer; cursor: pointer;
width: 100%;
&:hover, &:hover,
&:focus-within { &:focus-within {
color: var(--blue-700);
background-color: var(--slate-100); background-color: var(--slate-100);
svg {
fill: var(--blue-700);
}
} }
} }
} }

View File

@@ -13,7 +13,7 @@ const navModel = ref(false);
const wrapper = ref<HTMLDivElement | null>(null) const wrapper = ref<HTMLDivElement | null>(null)
const buttonRef = ref<HTMLButtonElement | null>(null) const buttonRef = ref<HTMLButtonElement | null>(null)
const wrapperHovered = useElementHover(wrapper, { delayEnter: 500, delayLeave: 500 }) const wrapperHovered = useElementHover(wrapper, { delayEnter: 100, delayLeave: 500 })
const { focused: buttonFocused } = useFocus(buttonRef) const { focused: buttonFocused } = useFocus(buttonRef)
const isNavOpened = computed(() => navModel.value || wrapperHovered.value) const isNavOpened = computed(() => navModel.value || wrapperHovered.value)
@@ -92,7 +92,7 @@ const menus: Menu[] = [
<div class="wrapper" ref="wrapper"> <div class="wrapper" ref="wrapper">
<PopoverRoot :open="isNavOpened" @update:open="() => navModel = true"> <PopoverRoot :open="isNavOpened" @update:open="() => navModel = true">
<PopoverTrigger as-child> <PopoverTrigger as-child>
<button ref="buttonRef" class="btn-round" :class="{ 'active': isNavOpened }"> <button ref="buttonRef" class="btn-round" :title="t('nav.map.aria-label')" :class="{ 'active': isNavOpened }">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M228.92,49.69a8,8,0,0,0-6.86-1.45L160.93,63.52,99.58,32.84a8,8,0,0,0-5.52-.6l-64,16A8,8,0,0,0,24,56V200a8,8,0,0,0,9.94,7.76l61.13-15.28,61.35,30.68A8.15,8.15,0,0,0,160,224a8,8,0,0,0,1.94-.24l64-16A8,8,0,0,0,232,200V56A8,8,0,0,0,228.92,49.69ZM96,176a8,8,0,0,0-1.94.24L40,189.75V62.25L95.07,48.48l.93.46Zm120,17.75-55.07,13.77-.93-.46V80a8,8,0,0,0,1.94-.23L216,66.25Z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M228.92,49.69a8,8,0,0,0-6.86-1.45L160.93,63.52,99.58,32.84a8,8,0,0,0-5.52-.6l-64,16A8,8,0,0,0,24,56V200a8,8,0,0,0,9.94,7.76l61.13-15.28,61.35,30.68A8.15,8.15,0,0,0,160,224a8,8,0,0,0,1.94-.24l64-16A8,8,0,0,0,232,200V56A8,8,0,0,0,228.92,49.69ZM96,176a8,8,0,0,0-1.94.24L40,189.75V62.25L95.07,48.48l.93.46Zm120,17.75-55.07,13.77-.93-.46V80a8,8,0,0,0,1.94-.23L216,66.25Z"/></svg>
</button> </button>
</PopoverTrigger> </PopoverTrigger>

View File

@@ -20,6 +20,7 @@ const props = defineProps<{
players: PlayerMarker, players: PlayerMarker,
mapKey?: string, mapKey?: string,
searchConfig?: SearchConfig searchConfig?: SearchConfig
disableTags?: boolean
}>() }>()
const customMarkersKey = props.mapKey ? `custom-markers-${props.mapKey}` : 'custom-markers' const customMarkersKey = props.mapKey ? `custom-markers-${props.mapKey}` : 'custom-markers'
@@ -209,7 +210,7 @@ function resetAllFields(actionAfter?: "focusAfter") {
</script> </script>
<template> <template>
<nav ref="searchBarWrapper" class="toolbar appear-from-top" :key="navKey"> <nav ref="searchBarWrapper" :aria-label="t('nav.aria-label')" class="toolbar appear-from-top" :key="navKey">
<SearchMapSwitch /> <SearchMapSwitch />
<div ref="searchBar" class="search-w" :data-focused="shouldBeActive"> <div ref="searchBar" class="search-w" :data-focused="shouldBeActive">
@@ -286,6 +287,7 @@ function resetAllFields(actionAfter?: "focusAfter") {
</div> </div>
<SearchMarkersTags <SearchMarkersTags
v-if="!props.disableTags"
:current-search-mode="currentSearchMode" :current-search-mode="currentSearchMode"
:custom-markers="customMarkersData" :custom-markers="customMarkersData"
:search-config="props.searchConfig" :search-config="props.searchConfig"

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import { isVNode, onMounted } from "vue"
import { ToastLifetime, useToast, type Toast } from './useToast.ts'
import { ToastDescription, ToastProvider, ToastRoot, ToastTitle, ToastViewport } from 'radix-vue'
const { toast, toasts } = useToast()
import { t } from '@/i18n/store';
onMounted(() => {
window.addEventListener('on-toast-push', (e) => handleInserted(e as CustomEvent))
})
function handleInserted(e: CustomEvent) {
if ( e.detail.preset) {
switch (e.detail.preset) {
case "copied-coords":
default:
toast({
description: t('toast.copyCoords.description'),
duration: ToastLifetime.SHORT,
variant: 'success'
})
break
}
return
}
const toastData = e.detail.toast as Toast
toast(toastData)
}
function handleUpdate(id: string) {
toasts.value.forEach((t, i) => {
if (t.id === id) {
t.open = false
}
})
}
</script>
<template>
<ToastProvider>
<ToastRoot
class="toast-root"
:class="toast.variant ? `toast-${toast.variant}` : ''"
v-for="toast in toasts"
:key="toast.id"
v-bind="toast"
@update:open="handleUpdate(toast.id)"
>
<div v-if="toast.variant === 'success'" class="toast-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#000000" viewBox="0 0 256 256"><path d="M232.49,80.49l-128,128a12,12,0,0,1-17,0l-56-56a12,12,0,1,1,17-17L96,183,215.51,63.51a12,12,0,0,1,17,17Z"></path></svg>
</div>
<ToastTitle class="toast-title" v-if="toast.title">
{{ toast.title }}
</ToastTitle>
<template v-if="toast.description">
<ToastDescription class="toast-description" v-if="isVNode(toast.description)">
<component :is="toast.description" />
</ToastDescription>
<ToastDescription v-else class="toast-description">
{{ toast.description }}
</ToastDescription>
</template>
</ToastRoot>
<ToastViewport class="toast-viewport"/>
</ToastProvider>
</template>

View File

@@ -0,0 +1,176 @@
import { computed, ref } from "vue"
import type { Component, VNode } from "vue"
import type { ToastRootProps } from "radix-vue"
const TOAST_LIMIT = 3
const TOAST_REMOVE_DELAY = 1000000
export interface ToastProps extends ToastRootProps {
onOpenChange?: ((value: boolean) => void) | undefined
}
export enum ToastLifetime {
SHORT = 2000,
MEDIUM = 3500,
LONG = 6000,
}
export type StringOrVNode =
| string
| VNode
| (() => VNode)
type ToasterToast = ToastProps & {
id: string
title?: string
description?: StringOrVNode
action?: Component,
variant?: "default" | "success" | "destructive"
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_VALUE
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
function addToRemoveQueue(toastId: string) {
if (toastTimeouts.has(toastId))
return
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: actionTypes.REMOVE_TOAST,
toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
const state = ref<State>({
toasts: [],
})
function dispatch(action: Action) {
switch (action.type) {
case actionTypes.ADD_TOAST:
state.value.toasts = [action.toast, ...state.value.toasts].slice(0, TOAST_LIMIT)
break
case actionTypes.UPDATE_TOAST:
state.value.toasts = state.value.toasts.map(t =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
)
break
case actionTypes.DISMISS_TOAST: {
const { toastId } = action
if (toastId) {
addToRemoveQueue(toastId)
}
else {
state.value.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
state.value.toasts = state.value.toasts.map(t =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
)
break
}
case actionTypes.REMOVE_TOAST:
if (action.toastId === undefined)
state.value.toasts = []
else
state.value.toasts = state.value.toasts.filter(t => t.id !== action.toastId)
break
}
}
function useToast() {
return {
toasts: computed(() => state.value.toasts),
toast,
dismiss: (toastId?: string) => dispatch({ type: actionTypes.DISMISS_TOAST, toastId }),
}
}
export type Toast = Omit<ToasterToast, "id">
function toast(props: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: actionTypes.UPDATE_TOAST,
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: actionTypes.DISMISS_TOAST, toastId: id })
dispatch({
type: actionTypes.ADD_TOAST,
toast: {
...props,
id,
open: true,
onOpenChange: (open: boolean) => {
if (!open)
dismiss()
},
},
})
return {
id,
dismiss,
update,
}
}
export { toast, useToast }

12
src/env.d.ts vendored
View File

@@ -1,2 +1,14 @@
/// <reference path="../.astro/types.d.ts" /> /// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" /> /// <reference types="astro/client" />
interface ImportMetaEnv {
readonly ENABLE_TOOLBAR: boolean
readonly ENABLE_TOOLBAR_TAGS: boolean
readonly ENABLE_CUSTOM_MARKERS: boolean
readonly ENABLE_BREADCRUMB: boolean
readonly ENABLE_I18N: boolean
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -1,4 +1,4 @@
export const availableLangs = ['en', 'fr'] as const; export const availableLangs = ['fr', 'en'] as const;
export type Language = typeof availableLangs[number]; export type Language = typeof availableLangs[number];
export type LanguageDict = Record<Language, any>; export type LanguageDict = Record<Language, any>;
@@ -11,6 +11,8 @@ export const translations: LanguageDict = {
'common.title': 'Titre', 'common.title': 'Titre',
'common.create': 'Créer', 'common.create': 'Créer',
'common.by': 'par', 'common.by': 'par',
'common.copyLink': 'Copier le lien',
'common.copiedLink': 'Lien copié !',
'continents': 'Continents', 'continents': 'Continents',
'cities': 'Villes', 'cities': 'Villes',
'others': 'Autres', 'others': 'Autres',
@@ -19,6 +21,9 @@ export const translations: LanguageDict = {
'capitals': 'Capitales', 'capitals': 'Capitales',
'points-of-interest': 'Points d\'intérêt', 'points-of-interest': 'Points d\'intérêt',
'personals': 'Personnels', 'personals': 'Personnels',
'nav.aria-label': 'Navigation principale',
'nav.map.aria-label': 'Changer de carte',
'nav.lang.aria-label': 'Changer de langue',
'maps.go-to-map': 'Voir la carte détaillée', 'maps.go-to-map': 'Voir la carte détaillée',
'maps.go-to-player': 'Aller à la position actuelle des joueurs', 'maps.go-to-player': 'Aller à la position actuelle des joueurs',
'maps.seeQuest': 'Voir la quête', 'maps.seeQuest': 'Voir la quête',
@@ -26,9 +31,11 @@ export const translations: LanguageDict = {
'maps.search': 'Rechercher la carte', 'maps.search': 'Rechercher la carte',
'maps.searchPlaceholder': "Ville, point d'intérêt…", 'maps.searchPlaceholder': "Ville, point d'intérêt…",
'maps.closeSearch': 'Enlever le filtre', 'maps.closeSearch': 'Enlever le filtre',
'maps.copyCoords': 'Copier les coordonnées',
'maps.markers.new': 'Nouveau marqueur', 'maps.markers.new': 'Nouveau marqueur',
'maps.markers.addPersonal': 'Ajouter un marqueur personnel', 'maps.markers.addPersonal': 'Ajouter un marqueur personnel',
'maps.markers.newNotice': "Le marqueur sera sauvegardé mais n'apparaîtra que sur votre carte !" 'maps.markers.newNotice': "Le marqueur sera sauvegardé mais n'apparaîtra que sur votre carte !",
'toast.copyCoords.description': 'Les coordonnées ont été copiées dans le presse-papiers !'
}, },
'en': { 'en': {
'lang.fr': 'Français', 'lang.fr': 'Français',
@@ -36,6 +43,8 @@ export const translations: LanguageDict = {
'common.title': 'Title', 'common.title': 'Title',
'common.create': 'Create', 'common.create': 'Create',
'common.by': 'by', 'common.by': 'by',
'common.copyLink': 'Copy link',
'common.copiedLink': 'Link copied!',
'continents': 'Continents', 'continents': 'Continents',
'cities': 'Cities', 'cities': 'Cities',
'others': 'Others', 'others': 'Others',
@@ -44,6 +53,9 @@ export const translations: LanguageDict = {
'capitals': 'Capitals', 'capitals': 'Capitals',
'points-of-interest': 'Points of interest', 'points-of-interest': 'Points of interest',
'personals': 'Personals', 'personals': 'Personals',
'nav.aria-label': 'Main navigation',
'nav.map.aria-label': 'Switch map',
'nav.lang.aria-label': 'Switch language',
'maps.go-to-map': 'See detailed map', 'maps.go-to-map': 'See detailed map',
'maps.go-to-player': 'Go to current players position', 'maps.go-to-player': 'Go to current players position',
'maps.seeQuest': 'See the quest', 'maps.seeQuest': 'See the quest',
@@ -51,8 +63,10 @@ export const translations: LanguageDict = {
'maps.search': 'Search the map', 'maps.search': 'Search the map',
'maps.searchPlaceholder': "City, point of interest…", 'maps.searchPlaceholder': "City, point of interest…",
'maps.closeSearch': 'Remove filter', 'maps.closeSearch': 'Remove filter',
'maps.copyCoords': 'Copy coordinates',
'maps.markers.new': 'New marker', 'maps.markers.new': 'New marker',
'maps.markers.addPersonal': 'Add a personal marker', 'maps.markers.addPersonal': 'Add a personal marker',
'maps.markers.newNotice': "The marker will be saved but will only appear on your map!" 'maps.markers.newNotice': "The marker will be saved but will only appear on your map!",
'toast.copyCoords.description': 'Coordinates have been copied to your clipboard !'
} }
} as const; } as const;

View File

@@ -28,7 +28,7 @@ const lang = getLangFromUrl(Astro.url) || defaultLang;
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="author" content="Alexis Pelé" /> <meta name="author" content="Alexis Pelé" />
{head?.description && <meta name="description" content={head.description} /> } {head?.description && <meta name="description" content={head.description} /> }
<meta name="viewport" content="width=device-width; initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<title>{title} — Leim Wiki</title> <title>{title} — Leim Wiki</title>

View File

@@ -17,6 +17,7 @@ import MapOverlay from '@/components/maps/MapOverlay.astro'
]} ]}
/> />
<Map <Map
title='Borélis'
mapKey='aldys-borelis' mapKey='aldys-borelis'
zoomifyKey='fr/borelis' zoomifyKey='fr/borelis'
mapHeight={3710} mapHeight={3710}

View File

@@ -24,6 +24,7 @@ const markers = markersData.data as MapMarker[]
]} ]}
/> />
<Map <Map
title='Cantane'
mapKey='aldys-cantane' mapKey='aldys-cantane'
zoomifyKey='fr/cantane' zoomifyKey='fr/cantane'
mapHeight={4124} mapHeight={4124}

View File

@@ -25,6 +25,7 @@ const markers = markersData.data as MapMarker[]
]} ]}
/> />
<Map <Map
title='White Hollow'
mapKey='aldys-cantane-mines' mapKey='aldys-cantane-mines'
zoomifyKey='fr/mines-blanches' zoomifyKey='fr/mines-blanches'
mapHeight={6020} mapHeight={6020}

View File

@@ -22,6 +22,7 @@ const players = playersData as PlayerMarker
]} ]}
/> />
<Map <Map
title='Bamast'
mapKey='bamast' mapKey='bamast'
zoomifyKey='fr/bamast' zoomifyKey='fr/bamast'
mapHeight={18342} mapHeight={18342}

View File

@@ -30,6 +30,7 @@ const players = playersData as PlayerMarker
]} ]}
/> />
<Map <Map
title='Aldys'
mapKey='aldys' mapKey='aldys'
zoomifyKey='en/aldys-nord' zoomifyKey='en/aldys-nord'
mapHeight={21896} mapHeight={21896}

View File

@@ -17,6 +17,7 @@ import MapOverlay from '@/components/maps/MapOverlay.astro'
]} ]}
/> />
<Map <Map
title='Borélis'
mapKey='aldys-borelis' mapKey='aldys-borelis'
zoomifyKey='fr/borelis' zoomifyKey='fr/borelis'
mapHeight={3710} mapHeight={3710}

View File

@@ -24,6 +24,7 @@ const markers = markersData.data as MapMarker[]
]} ]}
/> />
<Map <Map
title='Cantane'
mapKey='aldys-cantane' mapKey='aldys-cantane'
zoomifyKey='fr/cantane' zoomifyKey='fr/cantane'
mapHeight={4124} mapHeight={4124}

View File

@@ -25,6 +25,7 @@ const markers = markersData.data as MapMarker[]
]} ]}
/> />
<Map <Map
title='Les Mines Blanches'
mapKey='aldys-cantane-mines' mapKey='aldys-cantane-mines'
zoomifyKey='fr/mines-blanches' zoomifyKey='fr/mines-blanches'
mapHeight={6020} mapHeight={6020}

View File

@@ -22,6 +22,7 @@ const players = playersData as PlayerMarker
]} ]}
/> />
<Map <Map
title='Bamast'
mapKey='bamast' mapKey='bamast'
zoomifyKey='fr/bamast' zoomifyKey='fr/bamast'
mapHeight={18342} mapHeight={18342}

View File

@@ -30,6 +30,7 @@ const players = playersData as PlayerMarker
]} ]}
/> />
<Map <Map
title='Aldys'
mapKey='aldys' mapKey='aldys'
zoomifyKey='fr/aldys-nord' zoomifyKey='fr/aldys-nord'
mapHeight={21896} mapHeight={21896}

View File

@@ -7,26 +7,90 @@ export type MapCoords = {
} }
export type MapMarker = { export type MapMarker = {
/**
* The title of the marker (must be unique).
*/
title: string, title: string,
/**
* The coordinates of the marker.
* @example { x: 123, y: 456 }
*/
markerCoords: MapCoords, markerCoords: MapCoords,
/**
* The description of the marker.
*/
description?: string, description?: string,
/**
* The link that can appear on the marker title.
* @example https://example.com
*/
link?: string, link?: string,
/**
* The ID of the map the marker is supposed to point to.
*
* This should be a valid path of the `maps/data` folder.
* @example "fr/aldys/borelis"
*/
mapId?: string, mapId?: string,
/**
* The group that the marker belongs to.
* @example "capitals"
*/
group?: MapMarkerGroup, group?: MapMarkerGroup,
/**
* The icon that the marker should use.
* @example "castle"
*/
icon?: MapMarkerIcon, icon?: MapMarkerIcon,
/**
* Cover image for the marker.
* This should be a valid image in the public folder.
*
* @example "Ambrose.webp"
*/
cover?: string, cover?: string,
/**
* The link to the cover image's source.
*/
coverLink?: string, coverLink?: string,
/**
* The author of the cover image.
*/
coverAuthor?: string, coverAuthor?: string,
/**
* Whether the cover image should be displayed in portrait mode.
*/
coverPortrait?: boolean coverPortrait?: boolean
} }
/**
* A marker that is used to display players' location on the map.
*/
export type PlayerMarker = { export type PlayerMarker = {
/**
* The title of the marker (must be unique).
*/
title: string, title: string,
/**
* The coordinates of the marker.
* @example { x: 123, y: 456 }
*/
markerCoords: MapCoords, markerCoords: MapCoords,
/**
* The description of the marker.
*/
description?: string, description?: string,
/**
* The link that can appear on the marker title.
* @example https://example.com
*/
link?: string, link?: string,
} }
/**
* A marker that is used to display a custom location on the map.
* These are only used on the client side and are not saved anywhere else.
*/
export type CustomMarker = { export type CustomMarker = {
lat: number, lat: number,
lon: number, lon: number,

View File

@@ -1,33 +1,105 @@
import type { MapMarker, PlayerMarker } from "./Leaflet" import type { MapMarker, PlayerMarker } from "./Leaflet"
/**
* A breadcrumb item.
*/
export interface BreadcrumbItem { export interface BreadcrumbItem {
name: string; /**
url: string; * The label of the breadcrumb.
*/
name: string
/**
* The URL the breadcrumb points to.
*/
url: string
} }
/**
* Search configuration options for the map overlay.
* This is used to filter the markers on the map.
*/
export interface SearchConfig { export interface SearchConfig {
/** Whether to disable the quests in the search bar. */
disableQuests?: boolean disableQuests?: boolean
/** Whether to disable the capitals in the search bar. */
disableCapitals?: boolean disableCapitals?: boolean
/** Whether to disable the landmarks in the search bar. */
disableLandmarks?: boolean disableLandmarks?: boolean
} }
export interface MapProps { export interface MapProps {
/**
* The ID of the map.
* This is used to identify the map and store custom markers in the local storage.
* @example "fr/aldys/borelis"
*/
mapKey: string mapKey: string
/**
* The ID of the map in the zoomify folder.
*/
zoomifyKey: string zoomifyKey: string
/**
* Height of the map in pixels.
* This information is generated by the zoomify tool.
* @example 30000
*/
mapHeight: number mapHeight: number
/**
* Width of the map in pixels.
* This information is generated by the zoomify tool.
* @example 30000
*/
mapWidth: number mapWidth: number
/**
* List of markers to display on the map.
*/
markers?: MapMarker[] markers?: MapMarker[]
/**
* Current player position on the map.
*/
players?: PlayerMarker players?: PlayerMarker
/**
* Main unit of the map.
*/
rulerMainUnit?: string rulerMainUnit?: string
/**
* Ratio used to convert the map units to their relevant unit.
*/
rulerDistanceRatio?: number rulerDistanceRatio?: number
/**
* Whether to show the walking distance in the ruler.
*/
rulerHideWalkDistance?: boolean rulerHideWalkDistance?: boolean
/**
* Default background color of the map.
* @exemple "#FFF"
*/
backgroundColor?: string backgroundColor?: string
} }
export interface MapOverlayProps { export interface MapOverlayProps {
/**
* The ID of the map.
* This is used to identify the map and store custom markers in the local storage.
* @example "fr/aldys/borelis"
*/
mapKey: string mapKey: string
/**
* List of markers to display on the map.
*/
markers?: MapMarker[] markers?: MapMarker[]
/**
* Current player position on the map.
*/
players?: PlayerMarker players?: PlayerMarker
/**
* Configuration for the search bar.
* This is used to filter the markers on the map.
* @example { disableQuests: true, disableCapitals: false, disableLandmarks: false }
*/
searchConfig?: SearchConfig searchConfig?: SearchConfig
/**
* List of breadcrumbs to display.
*/
breadcrumbs?: BreadcrumbItem[] breadcrumbs?: BreadcrumbItem[]
} }

8
src/utils/Strings.ts Normal file
View File

@@ -0,0 +1,8 @@
/**
* Normalize a string to be used as a key
* @param str
*/
export function normalizeString(str: string) {
if (!str) return ''
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()
}