feat(map,i18n): single-copy world clamp + regional flags + pt-PT

- Map: min zoom = smallest integer zoom whose 256*2^z world covers the
  viewport (no grey bands, no duplicated continents). Horizontal wrap is
  restored (worldCopyJump, no longitude maxBounds) so Hawaii/Pacific are
  reachable by dragging; vertical panning stays clamped.
- Flags: real SVGs for Catalonia, Basque Country, Galicia and England
  (no Unicode region flags exist; English is not the UK). Portuguese now has
  Brasil (pt) and Portugal (pt-PT).
- pt-PT shares the pt dictionary with a European drift for the visible chrome
  (Guardar, Palavra-passe, Autocarro, Utilizador, ...).
- check-i18n covers pt-PT: 24 codes at 100%; tsc/tests/build green.
This commit is contained in:
SkateSpots 2026-10-26 16:09:21 +07:00
parent d4c6261b81
commit 7e48884eb9
15 changed files with 243 additions and 23 deletions

View file

@ -2697,14 +2697,27 @@ similar are internal endpoints/hook names, not credits — leave them.)
stays English (spot, park, bowl, DIY, vert, crew, line, Stash, The Lab, Nostr,
Lightning, Zap, Relay, sats) because Thai skaters use the English terms.
### Catalan, Basque, Galician — 23 locales, all 100% (2026-10-26)
- Added **ca (Català), eu (Euskara), gl (Galego)** → **23 locales**.
### Catalan, Basque, Galician + pt-PT — 23 dictionaries, 24 codes, all 100% (2026-10-26)
- Added **ca (Català), eu (Euskara), gl (Galego)** → **23 dictionaries**;
**pt-PT** is a variant that shares the `pt` dictionary (24 selectable codes).
- Registered in `Language` + `LANGUAGE_LOADERS`, `detectLanguage` (ca/eu/gl
checked before `es`), the `LanguageToggle` list, and `dateFnsI18n`
(date-fns `ca`/`eu`/`gl`, codes `ca-ES`/`eu-ES`/`gl-ES`).
- `LanguageToggle` uses **text badges `CA` / `EU` / `GL`**: there are no Unicode
region flags for these languages, and a country flag would be wrong. The
`CreateTourPage` language list already had ca/eu/gl for tour tags.
checked before `es`; `pt-BR`→`pt`, `pt-PT`→`pt-PT`), the `LanguageToggle`
list, and `dateFnsI18n` (date-fns `ca`/`eu`/`gl`/`pt`, codes
`ca-ES`/`eu-ES`/`gl-ES`/`pt-BR`/`pt-PT`).
- **Flags are real SVGs** in `public/flags/` for `ca`, `eu`, `gl` and `gb-eng`
(there are no Unicode region flags for Catalonia/Basque/Galicia, and England
is not the UK flag). `LanguageToggle` renders a path as `<img>` and an emoji
as text. **English now uses the England flag** (not the UK), and Portuguese
has **Brasil 🇧🇷** (`pt`) + **Portugal 🇵🇹** (`pt-PT`).
- **`pt-PT`** shares the `pt` base and applies a European drift
(`src/i18n/pt-PT/drift.ts`: Salvar→Guardar, Senha→Palavra-passe,
Ônibus→Autocarro, etc.). A full European-Portuguese review is a follow-up.
- **Map world clamp:** `SpotMap` uses `worldCopyJump: true` and
`applyWorldMinZoom()` (smallest integer zoom whose 256·2^z world covers the
viewport). That stops a second full copy of the continents and keeps the map
covering the surface (no grey bands), while still letting you drag across the
antimeridian to Hawaii/Pacific. On a wide desktop the whole globe cannot also
fit without distortion; the clamp prioritises coverage.
- Full dictionaries in `src/i18n/{ca,eu,gl}/` — 7 namespace batches each
(8,703 keys), composed in `index.ts`; placeholders verified, only 2–4%
identical strings (brand/protocol names). `check-i18n.mjs` includes them in

9
public/flags/ca.svg Normal file
View file

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 36" preserveAspectRatio="none">
<rect width="60" height="36" fill="#fcdd09"/>
<g fill="#da121a">
<rect y="4" width="60" height="4"/>
<rect y="12" width="60" height="4"/>
<rect y="20" width="60" height="4"/>
<rect y="28" width="60" height="4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 334 B

5
public/flags/eu.svg Normal file
View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 36" preserveAspectRatio="none">
<rect width="60" height="36" fill="#d52b1e"/>
<path d="M0 0 L60 36 M60 0 L0 36" stroke="#009b48" stroke-width="7"/>
<path d="M30 0 V36 M0 18 H60" stroke="#fff" stroke-width="8"/>
</svg>

After

Width:  |  Height:  |  Size: 280 B

5
public/flags/gb-eng.svg Normal file
View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 36" preserveAspectRatio="none">
<rect width="60" height="36" fill="#fff"/>
<rect x="25" y="0" width="10" height="36" fill="#cf142b"/>
<rect x="0" y="13" width="60" height="10" fill="#cf142b"/>
</svg>

After

Width:  |  Height:  |  Size: 262 B

4
public/flags/gl.svg Normal file
View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 36" preserveAspectRatio="none">
<rect width="60" height="36" fill="#fff"/>
<path d="M0 0 L60 36" stroke="#0055a4" stroke-width="12"/>
</svg>

After

Width:  |  Height:  |  Size: 201 B

View file

@ -130,7 +130,7 @@ async function main() {
const jaCoverage = en.size ? Math.round(((en.size - missingJa) / en.size) * 100) : 100;
// 5. Additional partial locales (same rules as th/ja).
const extraLocales = ['pt', 'fr', 'de', 'it', 'nl', 'pl', 'ru', 'ko', 'zh-CN', 'zh-TW', 'uk', 'tr', 'vi', 'id', 'sv', 'cs', 'ro', 'el', 'ca', 'eu', 'gl'];
const extraLocales = ['pt', 'pt-PT', 'fr', 'de', 'it', 'nl', 'pl', 'ru', 'ko', 'zh-CN', 'zh-TW', 'uk', 'tr', 'vi', 'id', 'sv', 'cs', 'ro', 'el', 'ca', 'eu', 'gl'];
const extraStats = [];
for (const lang of extraLocales) {
const dict = flatten(await loadTranslations(join(SRC, `i18n/${lang}/index.ts`)));

View file

@ -18,12 +18,13 @@ interface LanguageOption {
}
const languages: LanguageOption[] = [
{ code: 'en', name: 'English', nativeName: 'English', flag: '🇬🇧' },
{ code: 'en', name: 'English', nativeName: 'English', flag: '/flags/gb-eng.svg' },
{ code: 'es', name: 'Spanish', nativeName: 'Español', flag: '🇪🇸' },
{ code: 'ca', name: 'Catalan', nativeName: 'Català', flag: 'CA' },
{ code: 'eu', name: 'Basque', nativeName: 'Euskara', flag: 'EU' },
{ code: 'gl', name: 'Galician', nativeName: 'Galego', flag: 'GL' },
{ code: 'pt', name: 'Portuguese', nativeName: 'Português', flag: '🇧🇷' },
{ code: 'ca', name: 'Catalan', nativeName: 'Català', flag: '/flags/ca.svg' },
{ code: 'eu', name: 'Basque', nativeName: 'Euskara', flag: '/flags/eu.svg' },
{ code: 'gl', name: 'Galician', nativeName: 'Galego', flag: '/flags/gl.svg' },
{ code: 'pt', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)', flag: '🇧🇷' },
{ code: 'pt-PT', name: 'Portuguese (Portugal)', nativeName: 'Português (Portugal)', flag: '🇵🇹' },
{ code: 'fr', name: 'French', nativeName: 'Français', flag: '🇫🇷' },
{ code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪' },
{ code: 'it', name: 'Italian', nativeName: 'Italiano', flag: '🇮🇹' },
@ -62,7 +63,26 @@ interface LanguageToggleProps {
}
/**
* Language toggle dropdown for switching between English and Spanish
* Flags are an emoji or a local SVG. Catalonia, the Basque Country, Galicia and
* England have no Unicode emoji, so they ship as real SVG files and render as
* an image instead of a text badge.
*/
function Flag({ flag, className }: { flag: string; className?: string }) {
if (flag.startsWith('/')) {
return (
<img
src={flag}
alt=""
aria-hidden="true"
className={cn('h-4 w-6 shrink-0 rounded-[2px] object-cover shadow-sm', className)}
/>
);
}
return <span className={cn('text-lg leading-none', className)}>{flag}</span>;
}
/**
* Language toggle dropdown for switching between the available locales
*/
export function LanguageToggle({ compact = false, inline = false, className, variant = 'ghost' }: LanguageToggleProps) {
const { language, setLanguage, t } = useTranslation();
@ -103,7 +123,7 @@ export function LanguageToggle({ compact = false, inline = false, className, var
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<span className="text-lg">{lang.flag}</span>
<Flag flag={lang.flag} />
<div className="flex flex-col">
<span className="font-medium">{lang.nativeName}</span>
{lang.nativeName !== lang.name && (
@ -136,7 +156,7 @@ export function LanguageToggle({ compact = false, inline = false, className, var
className
)}
>
<span className="text-lg">{currentLanguage.flag}</span>
<Flag flag={currentLanguage.flag} />
{!compact && (
<>
<span className="hidden sm:inline">{currentLanguage.nativeName}</span>
@ -168,7 +188,7 @@ export function LanguageToggle({ compact = false, inline = false, className, var
language === lang.code && 'bg-primary/10 text-primary'
)}
>
<span className="text-lg">{lang.flag}</span>
<Flag flag={lang.flag} />
<div className="flex flex-col">
<span className="font-medium">{lang.nativeName}</span>
{lang.nativeName !== lang.name && (

View file

@ -602,6 +602,7 @@ export function RouteDialog({
attribution: '',
subdomains: 'abc',
maxZoom: 19,
noWrap: true,
className: 'skatespots-dark-tiles',
}).addTo(map);

View file

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

View file

@ -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, string | number>) => 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);

View file

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

View file

@ -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<Language, () => Promise<Translations>> = {
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),

111
src/i18n/pt-PT/drift.ts Normal file
View file

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

13
src/i18n/pt-PT/index.ts Normal file
View file

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

View file

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