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:
parent
d4c6261b81
commit
7e48884eb9
15 changed files with 243 additions and 23 deletions
27
AGENTS.md
27
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,
|
stays English (spot, park, bowl, DIY, vert, crew, line, Stash, The Lab, Nostr,
|
||||||
Lightning, Zap, Relay, sats) because Thai skaters use the English terms.
|
Lightning, Zap, Relay, sats) because Thai skaters use the English terms.
|
||||||
|
|
||||||
### Catalan, Basque, Galician — 23 locales, all 100% (2026-10-26)
|
### Catalan, Basque, Galician + pt-PT — 23 dictionaries, 24 codes, all 100% (2026-10-26)
|
||||||
- Added **ca (Català), eu (Euskara), gl (Galego)** → **23 locales**.
|
- 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
|
- Registered in `Language` + `LANGUAGE_LOADERS`, `detectLanguage` (ca/eu/gl
|
||||||
checked before `es`), the `LanguageToggle` list, and `dateFnsI18n`
|
checked before `es`; `pt-BR`→`pt`, `pt-PT`→`pt-PT`), the `LanguageToggle`
|
||||||
(date-fns `ca`/`eu`/`gl`, codes `ca-ES`/`eu-ES`/`gl-ES`).
|
list, and `dateFnsI18n` (date-fns `ca`/`eu`/`gl`/`pt`, codes
|
||||||
- `LanguageToggle` uses **text badges `CA` / `EU` / `GL`**: there are no Unicode
|
`ca-ES`/`eu-ES`/`gl-ES`/`pt-BR`/`pt-PT`).
|
||||||
region flags for these languages, and a country flag would be wrong. The
|
- **Flags are real SVGs** in `public/flags/` for `ca`, `eu`, `gl` and `gb-eng`
|
||||||
`CreateTourPage` language list already had ca/eu/gl for tour tags.
|
(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
|
- Full dictionaries in `src/i18n/{ca,eu,gl}/` — 7 namespace batches each
|
||||||
(8,703 keys), composed in `index.ts`; placeholders verified, only 2–4%
|
(8,703 keys), composed in `index.ts`; placeholders verified, only 2–4%
|
||||||
identical strings (brand/protocol names). `check-i18n.mjs` includes them in
|
identical strings (brand/protocol names). `check-i18n.mjs` includes them in
|
||||||
|
|
|
||||||
9
public/flags/ca.svg
Normal file
9
public/flags/ca.svg
Normal 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
5
public/flags/eu.svg
Normal 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
5
public/flags/gb-eng.svg
Normal 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
4
public/flags/gl.svg
Normal 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 |
|
|
@ -130,7 +130,7 @@ async function main() {
|
||||||
const jaCoverage = en.size ? Math.round(((en.size - missingJa) / en.size) * 100) : 100;
|
const jaCoverage = en.size ? Math.round(((en.size - missingJa) / en.size) * 100) : 100;
|
||||||
|
|
||||||
// 5. Additional partial locales (same rules as th/ja).
|
// 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 = [];
|
const extraStats = [];
|
||||||
for (const lang of extraLocales) {
|
for (const lang of extraLocales) {
|
||||||
const dict = flatten(await loadTranslations(join(SRC, `i18n/${lang}/index.ts`)));
|
const dict = flatten(await loadTranslations(join(SRC, `i18n/${lang}/index.ts`)));
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,13 @@ interface LanguageOption {
|
||||||
}
|
}
|
||||||
|
|
||||||
const languages: 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: 'es', name: 'Spanish', nativeName: 'Español', flag: '🇪🇸' },
|
||||||
{ code: 'ca', name: 'Catalan', nativeName: 'Català', flag: 'CA' },
|
{ code: 'ca', name: 'Catalan', nativeName: 'Català', flag: '/flags/ca.svg' },
|
||||||
{ code: 'eu', name: 'Basque', nativeName: 'Euskara', flag: 'EU' },
|
{ code: 'eu', name: 'Basque', nativeName: 'Euskara', flag: '/flags/eu.svg' },
|
||||||
{ code: 'gl', name: 'Galician', nativeName: 'Galego', flag: 'GL' },
|
{ code: 'gl', name: 'Galician', nativeName: 'Galego', flag: '/flags/gl.svg' },
|
||||||
{ code: 'pt', name: 'Portuguese', nativeName: 'Português', flag: '🇧🇷' },
|
{ 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: 'fr', name: 'French', nativeName: 'Français', flag: '🇫🇷' },
|
||||||
{ code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪' },
|
{ code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪' },
|
||||||
{ code: 'it', name: 'Italian', nativeName: 'Italiano', 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) {
|
export function LanguageToggle({ compact = false, inline = false, className, variant = 'ghost' }: LanguageToggleProps) {
|
||||||
const { language, setLanguage, t } = useTranslation();
|
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'
|
: '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">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{lang.nativeName}</span>
|
<span className="font-medium">{lang.nativeName}</span>
|
||||||
{lang.nativeName !== lang.name && (
|
{lang.nativeName !== lang.name && (
|
||||||
|
|
@ -136,7 +156,7 @@ export function LanguageToggle({ compact = false, inline = false, className, var
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="text-lg">{currentLanguage.flag}</span>
|
<Flag flag={currentLanguage.flag} />
|
||||||
{!compact && (
|
{!compact && (
|
||||||
<>
|
<>
|
||||||
<span className="hidden sm:inline">{currentLanguage.nativeName}</span>
|
<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'
|
language === lang.code && 'bg-primary/10 text-primary'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="text-lg">{lang.flag}</span>
|
<Flag flag={lang.flag} />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{lang.nativeName}</span>
|
<span className="font-medium">{lang.nativeName}</span>
|
||||||
{lang.nativeName !== lang.name && (
|
{lang.nativeName !== lang.name && (
|
||||||
|
|
|
||||||
|
|
@ -602,6 +602,7 @@ export function RouteDialog({
|
||||||
attribution: '',
|
attribution: '',
|
||||||
subdomains: 'abc',
|
subdomains: 'abc',
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
|
noWrap: true,
|
||||||
className: 'skatespots-dark-tiles',
|
className: 'skatespots-dark-tiles',
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ export function SpotLocationPicker({ value, onChange, className }: SpotLocationP
|
||||||
subdomains: 'abc',
|
subdomains: 'abc',
|
||||||
minZoom: 2,
|
minZoom: 2,
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
|
noWrap: true,
|
||||||
className: 'skatespots-dark-tiles',
|
className: 'skatespots-dark-tiles',
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,27 @@ import { useTranslation } from '@/contexts/language';
|
||||||
/** Translation function shape, so module-level popup builders can receive `t`. */
|
/** Translation function shape, so module-level popup builders can receive `t`. */
|
||||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
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.
|
* 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)
|
* Every value that originates from a Nostr event (spot/tour/crew/event fields)
|
||||||
|
|
@ -921,13 +942,19 @@ export function SpotMap({
|
||||||
zoom,
|
zoom,
|
||||||
scrollWheelZoom: true,
|
scrollWheelZoom: true,
|
||||||
attributionControl: false,
|
attributionControl: false,
|
||||||
// Prevent zooming out past a single world view (no grey space, no tile duplication)
|
// Prevent zooming out past a single world view (no grey space, no tile
|
||||||
minZoom: 2,
|
// duplication). The real limit is computed after mount by
|
||||||
// Clamp panning so the world fills the viewport — no grey bands above/below
|
// `applyWorldMinZoom()` because it depends on the viewport size.
|
||||||
maxBounds: [[-90, -Infinity], [90, Infinity]],
|
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,
|
maxBoundsViscosity: 1.0,
|
||||||
|
worldCopyJump: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
applyWorldMinZoom(map);
|
||||||
|
|
||||||
// Free, no-API-key basemap: OpenStreetMap standard tiles, darkened via a CSS
|
// 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
|
// 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.
|
// 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', {
|
createCachedTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
attribution: '',
|
attribution: '',
|
||||||
subdomains: 'abc',
|
subdomains: 'abc',
|
||||||
minZoom: 2,
|
minZoom: 1,
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
className: 'skatespots-dark-tiles',
|
className: 'skatespots-dark-tiles',
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
|
@ -997,6 +1024,7 @@ export function SpotMap({
|
||||||
// Fix map size after initialization and ensure tiles are loaded
|
// Fix map size after initialization and ensure tiles are loaded
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
map.invalidateSize();
|
map.invalidateSize();
|
||||||
|
applyWorldMinZoom(map);
|
||||||
// Force a redraw to ensure everything is ready
|
// Force a redraw to ensure everything is ready
|
||||||
map.whenReady(() => {
|
map.whenReady(() => {
|
||||||
// Trigger initial map move event
|
// Trigger initial map move event
|
||||||
|
|
@ -1021,6 +1049,7 @@ export function SpotMap({
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
map.invalidateSize();
|
map.invalidateSize();
|
||||||
|
applyWorldMinZoom(map);
|
||||||
});
|
});
|
||||||
|
|
||||||
resizeObserver.observe(mapRef.current);
|
resizeObserver.observe(mapRef.current);
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ function detectLanguage(): Language {
|
||||||
if (tags.some((t) => t.startsWith('th'))) return 'th';
|
if (tags.some((t) => t.startsWith('th'))) return 'th';
|
||||||
if (tags.some((t) => t.startsWith('ja'))) return 'ja';
|
if (tags.some((t) => t.startsWith('ja'))) return 'ja';
|
||||||
if (tags.some((t) => t.startsWith('ko'))) return 'ko';
|
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('pt'))) return 'pt';
|
||||||
if (tags.some((t) => t.startsWith('fr'))) return 'fr';
|
if (tags.some((t) => t.startsWith('fr'))) return 'fr';
|
||||||
if (tags.some((t) => t.startsWith('de'))) return 'de';
|
if (tags.some((t) => t.startsWith('de'))) return 'de';
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import en from '@/i18n/en';
|
||||||
* `react-refresh/only-export-components` rule.
|
* `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 TranslationKey = string;
|
||||||
type TranslationValue = string | { [key: string]: TranslationValue };
|
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),
|
th: () => import('@/i18n/th').then((m) => m.default),
|
||||||
ja: () => import('@/i18n/ja').then((m) => m.default),
|
ja: () => import('@/i18n/ja').then((m) => m.default),
|
||||||
pt: () => import('@/i18n/pt').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),
|
fr: () => import('@/i18n/fr').then((m) => m.default),
|
||||||
de: () => import('@/i18n/de').then((m) => m.default),
|
de: () => import('@/i18n/de').then((m) => m.default),
|
||||||
it: () => import('@/i18n/it').then((m) => m.default),
|
it: () => import('@/i18n/it').then((m) => m.default),
|
||||||
|
|
|
||||||
111
src/i18n/pt-PT/drift.ts
Normal file
111
src/i18n/pt-PT/drift.ts
Normal 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
13
src/i18n/pt-PT/index.ts
Normal 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;
|
||||||
|
|
@ -28,6 +28,7 @@ import {
|
||||||
th as thLocale,
|
th as thLocale,
|
||||||
ja as jaLocale,
|
ja as jaLocale,
|
||||||
ptBR as ptLocale,
|
ptBR as ptLocale,
|
||||||
|
pt as ptPTLocale,
|
||||||
fr as frLocale,
|
fr as frLocale,
|
||||||
de as deLocale,
|
de as deLocale,
|
||||||
it as itLocale,
|
it as itLocale,
|
||||||
|
|
@ -75,6 +76,9 @@ export function setDateLocale(language: Language): void {
|
||||||
} else if (language === 'pt') {
|
} else if (language === 'pt') {
|
||||||
activeLocale = ptLocale;
|
activeLocale = ptLocale;
|
||||||
activeCode = 'pt-BR';
|
activeCode = 'pt-BR';
|
||||||
|
} else if (language === 'pt-PT') {
|
||||||
|
activeLocale = ptPTLocale;
|
||||||
|
activeCode = 'pt-PT';
|
||||||
} else if (language === 'fr') {
|
} else if (language === 'fr') {
|
||||||
activeLocale = frLocale;
|
activeLocale = frLocale;
|
||||||
activeCode = 'fr-FR';
|
activeCode = 'fr-FR';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue