From 7e48884eb95f711f4d7b82de1223b7c99cda8744 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Mon, 26 Oct 2026 16:09:21 +0700 Subject: [PATCH] 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. --- AGENTS.md | 27 +++++-- public/flags/ca.svg | 9 +++ public/flags/eu.svg | 5 ++ public/flags/gb-eng.svg | 5 ++ public/flags/gl.svg | 4 + scripts/check-i18n.mjs | 2 +- src/components/LanguageToggle.tsx | 38 ++++++--- src/components/RouteDialog.tsx | 1 + src/components/SpotLocationPicker.tsx | 1 + src/components/SpotMap.tsx | 39 +++++++-- src/contexts/LanguageContext.tsx | 2 + src/contexts/language.ts | 5 +- src/i18n/pt-PT/drift.ts | 111 ++++++++++++++++++++++++++ src/i18n/pt-PT/index.ts | 13 +++ src/lib/dateFnsI18n.ts | 4 + 15 files changed, 243 insertions(+), 23 deletions(-) create mode 100644 public/flags/ca.svg create mode 100644 public/flags/eu.svg create mode 100644 public/flags/gb-eng.svg create mode 100644 public/flags/gl.svg create mode 100644 src/i18n/pt-PT/drift.ts create mode 100644 src/i18n/pt-PT/index.ts diff --git a/AGENTS.md b/AGENTS.md index 7e96e1f..270c5b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `` 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 diff --git a/public/flags/ca.svg b/public/flags/ca.svg new file mode 100644 index 0000000..babf6cb --- /dev/null +++ b/public/flags/ca.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/flags/eu.svg b/public/flags/eu.svg new file mode 100644 index 0000000..e76defc --- /dev/null +++ b/public/flags/eu.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/flags/gb-eng.svg b/public/flags/gb-eng.svg new file mode 100644 index 0000000..9329d67 --- /dev/null +++ b/public/flags/gb-eng.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/flags/gl.svg b/public/flags/gl.svg new file mode 100644 index 0000000..09c7fec --- /dev/null +++ b/public/flags/gl.svg @@ -0,0 +1,4 @@ + + + + diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs index f7375a2..67119f1 100644 --- a/scripts/check-i18n.mjs +++ b/scripts/check-i18n.mjs @@ -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`))); diff --git a/src/components/LanguageToggle.tsx b/src/components/LanguageToggle.tsx index 1e023a3..1966934 100644 --- a/src/components/LanguageToggle.tsx +++ b/src/components/LanguageToggle.tsx @@ -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 ( + + ); + } + return {flag}; +} + +/** + * 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' )} > - {lang.flag} +
{lang.nativeName} {lang.nativeName !== lang.name && ( @@ -136,7 +156,7 @@ export function LanguageToggle({ compact = false, inline = false, className, var className )} > - {currentLanguage.flag} + {!compact && ( <> {currentLanguage.nativeName} @@ -168,7 +188,7 @@ export function LanguageToggle({ compact = false, inline = false, className, var language === lang.code && 'bg-primary/10 text-primary' )} > - {lang.flag} +
{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';