Merge branch 'dev'

This commit is contained in:
Alexis
2025-05-24 12:38:58 +02:00
11 changed files with 1026 additions and 532 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "leim-maps", "name": "leim-maps",
"type": "module", "type": "module",
"version": "1.4.8", "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.1", "@astrojs/sitemap": "^3.4.0",
"@astrojs/vue": "^5.0.13", "@astrojs/vue": "^5.1.0",
"@nanostores/persistent": "1.0.0", "@nanostores/persistent": "1.0.0",
"@nanostores/vue": "^1.0.0", "@nanostores/vue": "^1.0.0",
"@types/leaflet": "^1.9.17", "@types/leaflet": "^1.9.18",
"@vueuse/core": "^13.1.0", "@vueuse/core": "^13.2.0",
"astro": "^5.7.10", "astro": "^5.8.0",
"nanostores": "^1.0.1", "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.87.0" "sass": "^1.89.0"
} }
} }

1074
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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

@@ -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

@@ -602,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
*/ */
@@ -609,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

@@ -7,6 +7,7 @@ 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 {}
@@ -50,6 +51,8 @@ const currentUrl = Astro.url
} }
</div> </div>
<ToastService client:only="vue" />
<style lang="scss"> <style lang="scss">
.world-overlay { .world-overlay {
position: absolute; position: absolute;

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

@@ -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 }

View File

@@ -31,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',
@@ -61,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;