{lang.nativeName}
{lang.nativeName !== lang.name && (
diff --git a/src/components/RouteDialog.tsx b/src/components/RouteDialog.tsx
index ce59be1..6e12742 100644
--- a/src/components/RouteDialog.tsx
+++ b/src/components/RouteDialog.tsx
@@ -602,6 +602,7 @@ export function RouteDialog({
attribution: '',
subdomains: 'abc',
maxZoom: 19,
+ noWrap: true,
className: 'skatespots-dark-tiles',
}).addTo(map);
diff --git a/src/components/SpotLocationPicker.tsx b/src/components/SpotLocationPicker.tsx
index 93aac30..b5d3a52 100644
--- a/src/components/SpotLocationPicker.tsx
+++ b/src/components/SpotLocationPicker.tsx
@@ -58,6 +58,7 @@ export function SpotLocationPicker({ value, onChange, className }: SpotLocationP
subdomains: 'abc',
minZoom: 2,
maxZoom: 19,
+ noWrap: true,
className: 'skatespots-dark-tiles',
}).addTo(map);
diff --git a/src/components/SpotMap.tsx b/src/components/SpotMap.tsx
index c853f7c..935cfe1 100644
--- a/src/components/SpotMap.tsx
+++ b/src/components/SpotMap.tsx
@@ -21,6 +21,27 @@ import { useTranslation } from '@/contexts/language';
/** Translation function shape, so module-level popup builders can receive `t`. */
type Translate = (key: string, params?: Record) => string;
+/**
+ * The map never shows a *second full copy* of the world. `applyWorldMinZoom`
+ * clamps the minimum zoom so the 256·2^z world is at least as large as the
+ * viewport. The map can still wrap across the antimeridian (so Hawaii and the
+ * Pacific are reachable by dragging), but at that zoom you only ever see a
+ * sliver of the adjacent copy — never duplicated continents.
+ */
+function applyWorldMinZoom(map: L.Map): void {
+ // The world is a 256·2^z square. To cover the viewport (no grey bands, no
+ // repeated continents) pick the smallest integer zoom whose world is at
+ // least as large as the larger viewport dimension. Using the container's
+ // client size directly avoids the 0×0 size Leaflet reports before layout.
+ const el = map.getContainer();
+ const width = el.clientWidth || map.getSize().x;
+ const height = el.clientHeight || map.getSize().y;
+ if (!width || !height) return;
+ const min = Math.max(1, Math.ceil(Math.log2(Math.max(width, height) / 256)));
+ map.setMinZoom(min);
+ if (map.getZoom() < min) map.setZoom(min, { animate: false });
+}
+
/**
* SECURITY: Leaflet popups are built as raw DOM, outside React's auto-escaping.
* Every value that originates from a Nostr event (spot/tour/crew/event fields)
@@ -921,13 +942,19 @@ export function SpotMap({
zoom,
scrollWheelZoom: true,
attributionControl: false,
- // Prevent zooming out past a single world view (no grey space, no tile duplication)
- minZoom: 2,
- // Clamp panning so the world fills the viewport — no grey bands above/below
- maxBounds: [[-90, -Infinity], [90, Infinity]],
+ // Prevent zooming out past a single world view (no grey space, no tile
+ // duplication). The real limit is computed after mount by
+ // `applyWorldMinZoom()` because it depends on the viewport size.
+ minZoom: 1,
+ // Clamp vertically (no grey above/below the poles) but let the map wrap
+ // horizontally at the antimeridian, so the Pacific is reachable.
+ maxBounds: [[-85, -Infinity], [85, Infinity]],
maxBoundsViscosity: 1.0,
+ worldCopyJump: true,
});
+ applyWorldMinZoom(map);
+
// Free, no-API-key basemap: OpenStreetMap standard tiles, darkened via a CSS
// filter (see the `.skatespots-dark-tiles` rule in index.css) so it matches
// the app's dark theme without depending on a keyed provider like CARTO.
@@ -935,7 +962,7 @@ export function SpotMap({
createCachedTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '',
subdomains: 'abc',
- minZoom: 2,
+ minZoom: 1,
maxZoom: 19,
className: 'skatespots-dark-tiles',
}).addTo(map);
@@ -997,6 +1024,7 @@ export function SpotMap({
// Fix map size after initialization and ensure tiles are loaded
setTimeout(() => {
map.invalidateSize();
+ applyWorldMinZoom(map);
// Force a redraw to ensure everything is ready
map.whenReady(() => {
// Trigger initial map move event
@@ -1021,6 +1049,7 @@ export function SpotMap({
const resizeObserver = new ResizeObserver(() => {
map.invalidateSize();
+ applyWorldMinZoom(map);
});
resizeObserver.observe(mapRef.current);
diff --git a/src/contexts/LanguageContext.tsx b/src/contexts/LanguageContext.tsx
index 1557f9a..d321b8f 100644
--- a/src/contexts/LanguageContext.tsx
+++ b/src/contexts/LanguageContext.tsx
@@ -28,6 +28,8 @@ function detectLanguage(): Language {
if (tags.some((t) => t.startsWith('th'))) return 'th';
if (tags.some((t) => t.startsWith('ja'))) return 'ja';
if (tags.some((t) => t.startsWith('ko'))) return 'ko';
+ if (tags.some((t) => t.startsWith('pt-br'))) return 'pt';
+ if (tags.some((t) => t.startsWith('pt-pt'))) return 'pt-PT';
if (tags.some((t) => t.startsWith('pt'))) return 'pt';
if (tags.some((t) => t.startsWith('fr'))) return 'fr';
if (tags.some((t) => t.startsWith('de'))) return 'de';
diff --git a/src/contexts/language.ts b/src/contexts/language.ts
index d674081..9352ddb 100644
--- a/src/contexts/language.ts
+++ b/src/contexts/language.ts
@@ -7,7 +7,7 @@ import en from '@/i18n/en';
* `react-refresh/only-export-components` rule.
*/
-export type Language = 'en' | 'es' | 'ca' | 'eu' | 'gl' | 'th' | 'ja' | 'pt' | 'fr' | 'de' | 'it' | 'nl' | 'pl' | 'ru' | 'ko' | 'ko-KP' | 'zh-CN' | 'zh-TW' | 'uk' | 'tr' | 'vi' | 'id' | 'sv' | 'cs' | 'ro' | 'el';
+export type Language = 'en' | 'es' | 'ca' | 'eu' | 'gl' | 'th' | 'ja' | 'pt' | 'pt-PT' | 'fr' | 'de' | 'it' | 'nl' | 'pl' | 'ru' | 'ko' | 'ko-KP' | 'zh-CN' | 'zh-TW' | 'uk' | 'tr' | 'vi' | 'id' | 'sv' | 'cs' | 'ro' | 'el';
type TranslationKey = string;
type TranslationValue = string | { [key: string]: TranslationValue };
@@ -45,6 +45,9 @@ export const LANGUAGE_LOADERS: Record Promise> = {
th: () => import('@/i18n/th').then((m) => m.default),
ja: () => import('@/i18n/ja').then((m) => m.default),
pt: () => import('@/i18n/pt').then((m) => m.default),
+ // European Portuguese reuses the Brazilian dictionary as a base and applies
+ // a drift of European terms (see src/i18n/pt-PT/drift.ts).
+ 'pt-PT': () => import('@/i18n/pt-PT').then((m) => m.default),
fr: () => import('@/i18n/fr').then((m) => m.default),
de: () => import('@/i18n/de').then((m) => m.default),
it: () => import('@/i18n/it').then((m) => m.default),
diff --git a/src/i18n/pt-PT/drift.ts b/src/i18n/pt-PT/drift.ts
new file mode 100644
index 0000000..25e7cf7
--- /dev/null
+++ b/src/i18n/pt-PT/drift.ts
@@ -0,0 +1,111 @@
+/**
+ * European Portuguese overrides for the shared `pt` (Brazilian) dictionary.
+ * Keys must also exist in `en`.
+ */
+const drift = {
+ common: {
+ save: "Guardar",
+ },
+ chromeUi: {
+ logIn: "Iniciar sessão",
+ logOut: "Terminar sessão",
+ },
+ navSheet: {
+ login: "Iniciar sessão",
+ },
+ mapUi: {
+ saveChanges: "Guardar alterações",
+ youAreHere: "Está aqui",
+ youreOffline: "Está offline.",
+ relayOnlineNoAnswer: "Está online, mas o relay da comunidade não está a responder agora.",
+ mapFooter: {
+ links: "Ligações",
+ },
+ },
+ spotUi: {
+ saveChanges: "Guardar alterações",
+ targetType: {
+ user: "Utilizador",
+ },
+ loginToEditSpot: "Tem de iniciar sessão para editar este spot.",
+ logInToReport: "Tem de iniciar sessão para denunciar conteúdo.",
+ accessDeniedDesc: "Só pode editar spots que criou.",
+ areYouShopOwner: "É o dono da loja?",
+ claimBullet1: "Torna-se o dono e pode editar o spot",
+ claimIntro: "Está prestes a reivindicar a propriedade deste spot. Isto criará a sua própria versão do spot que pode editar e manter.",
+ claim: {
+ submittedDesc: "Um moderador vai rever a sua reivindicação. Assumirá o spot assim que for aprovada.",
+ },
+ spotClaimedDesc: "Agora é o dono deste spot. Pode editá-lo a partir do seu perfil.",
+ },
+ cardUi: {
+ save: "Guardar cartão no Nostr",
+ saveFrontPng: "Guardar frente (PNG)",
+ studio: {
+ saveChanges: "Guardar alterações",
+ },
+ verify: {
+ invalidLink: "Ligação inválida",
+ },
+ },
+ routeUi: {
+ saveSpotOne: "Guardar {{count}} spot numa Coleção",
+ saveSpots: "Guardar {{count}} spots numa Coleção",
+ group: {
+ bus: "Autocarro",
+ train: "Comboio",
+ },
+ arrived: "Chegou. 🛹",
+ voiceArrived: "Chegou",
+ },
+ pwa: {
+ iosStep2: "Role e toque em \"Adicionar ao Ecrã Principal\"",
+ },
+ sasquatchUi: {
+ mustBeLoggedIn: "Tem de iniciar sessão.",
+ proSupporter: "É um apoiador Pro ⚡",
+ },
+ settingsUi: {
+ loginArea: {
+ logIn: "Iniciar sessão",
+ },
+ accountSwitcher: {
+ logout: "Terminar sessão",
+ },
+ loginDialog: {
+ login: "Iniciar sessão",
+ loginWithAmber: "Iniciar sessão com Amber (Android)",
+ loginWithExtension: "Iniciar sessão com extensão",
+ password: "Palavra-passe",
+ },
+ signupDialog: {
+ download: "Transferir",
+ stepSaveKey: "Guardar chave",
+ saveAndStart: "Guardar e começar a andar",
+ saveEncBackup: "Guardar uma cópia de segurança encriptada",
+ saveEncBackupCta: "Guardar cópia de segurança encriptada",
+ encBackupBefore: "Uma cópia protegida por palavra-passe — segura para guardar na nuvem ou num gestor de palavras-passe. Vai precisar do ficheiro",
+ fileTooLargeTitle: "Ficheiro demasiado grande",
+ nameRequiredDesc: "Escolha um nome de apresentação para os outros skaters saberem quem é. Pode alterá-lo quando quiser.",
+ changeAnytime: "Pode alterá-lo quando quiser. Bio e avatar são opcionais.",
+ youreInPickName: "Entrou! 🛹 Escolha um nome de apresentação para os skaters saberem quem é",
+ youreIn: "Entrou! 🛹",
+ reserveFailedDesc: "{{error}} Pode reivindicar um mais tarde nas Definições.",
+ profileFailedDesc: "A sua conta foi criada, mas a configuração do perfil falhou. Pode atualizá-lo mais tarde.",
+ },
+ editProfile: {
+ saveProfile: "Guardar perfil",
+ invalidCordnTitle: "Ligação do Cordn inválida",
+ mustBeLoggedIn: "Tem de iniciar sessão para atualizar o seu perfil.",
+ },
+ mnemonic: {
+ download: "Transferir",
+ twelveWordDesc: "A sua frase de recuperação é como uma palavra-passe mestra. Anote no papel e guarde-a bem. Vai precisar dela para aceder à sua conta noutros dispositivos ou se limpar os dados do navegador.",
+ },
+ security: {
+ recoveryCodeDesc: "Não guardou quando se registou? Pode obtê-lo aqui quando quiser.",
+ },
+ },
+};
+
+export default drift;
diff --git a/src/i18n/pt-PT/index.ts b/src/i18n/pt-PT/index.ts
new file mode 100644
index 0000000..1838574
--- /dev/null
+++ b/src/i18n/pt-PT/index.ts
@@ -0,0 +1,13 @@
+import pt from '../pt';
+import drift from './drift';
+import { deepMerge } from '../deepMerge';
+
+/**
+ * European Portuguese. The full dictionary is shared with `pt` (Brazilian);
+ * `drift` rewrites the visible Brazilian vocabulary (Salvar→Guardar,
+ * Celular→Telemóvel, Ônibus→Autocarro, Senha→Palavra-passe, etc.) to European
+ * Portuguese. Missing/unchanged keys fall back to the shared base.
+ */
+const ptPT = deepMerge(pt, drift);
+
+export default ptPT;
diff --git a/src/lib/dateFnsI18n.ts b/src/lib/dateFnsI18n.ts
index d824a75..e2fcae7 100644
--- a/src/lib/dateFnsI18n.ts
+++ b/src/lib/dateFnsI18n.ts
@@ -28,6 +28,7 @@ import {
th as thLocale,
ja as jaLocale,
ptBR as ptLocale,
+ pt as ptPTLocale,
fr as frLocale,
de as deLocale,
it as itLocale,
@@ -75,6 +76,9 @@ export function setDateLocale(language: Language): void {
} else if (language === 'pt') {
activeLocale = ptLocale;
activeCode = 'pt-BR';
+ } else if (language === 'pt-PT') {
+ activeLocale = ptPTLocale;
+ activeCode = 'pt-PT';
} else if (language === 'fr') {
activeLocale = frLocale;
activeCode = 'fr-FR';