Skip to content

Commit c70f9f4

Browse files
committed
feat: internationalize the UI (ca, en, es, eu, gl, va)
Add a t() translation layer with six locales, browser-language detection and a language selector. Externalize every UI string; usernames stay translate="no" so neither our i18n nor browser auto-translate touch them.
1 parent 9f10c55 commit c70f9f4

12 files changed

Lines changed: 771 additions & 130 deletions

File tree

src/i18n/index.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { ca } from './locales/ca'
2+
import { en } from './locales/en'
3+
import { es } from './locales/es'
4+
import { eu } from './locales/eu'
5+
import { gl } from './locales/gl'
6+
import { va } from './locales/va'
7+
8+
type Dict = Record<string, string>
9+
10+
export const LOCALES: Array<{ code: string; label: string; dict: Dict }> = [
11+
{ code: 'en', label: 'English', dict: en },
12+
{ code: 'es', label: 'Español', dict: es },
13+
{ code: 'ca', label: 'Català', dict: ca },
14+
{ code: 'gl', label: 'Galego', dict: gl },
15+
{ code: 'eu', label: 'Euskara', dict: eu },
16+
{ code: 'va', label: 'Valencià', dict: va },
17+
]
18+
19+
const byCode = (code: string) => LOCALES.find((l) => l.code === code)
20+
21+
let current = 'en'
22+
23+
/** Pick the best locale from the browser, falling back to English. */
24+
export function detectLocale(): string {
25+
const lang = (navigator.language || 'en').slice(0, 2).toLowerCase()
26+
return byCode(lang) ? lang : 'en'
27+
}
28+
29+
export function setLocale(code: string): void {
30+
if (byCode(code)) current = code
31+
}
32+
33+
export function getLocale(): string {
34+
return current
35+
}
36+
37+
/** Translate a key for the current locale, substituting {placeholders}. */
38+
export function t(key: string, params?: Record<string, string | number>): string {
39+
let s = byCode(current)?.dict[key] ?? en[key] ?? key
40+
if (params) {
41+
for (const [k, v] of Object.entries(params)) s = s.split(`{${k}}`).join(String(v))
42+
}
43+
return s
44+
}

src/i18n/locales/ca.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Català
2+
export const ca: Record<string, string> = {
3+
'drop.prompt':
4+
'Tria o deixa caure el teu export d’Instagram (.zip en format JSON). No surt mai del teu navegador.',
5+
'drop.helpSummary': 'Com descarregar l’export correcte',
6+
'drop.helpSteps':
7+
'A Instagram: Configuració → Centre de comptes → La teva informació i permisos → Descarrega la teva informació. Tria "Part de la teva informació" i marca:',
8+
'drop.itemLikes': 'M’agrada',
9+
'drop.itemComments': 'Comentaris',
10+
'drop.itemSaved': 'Guardat',
11+
'drop.itemStories': 'Interaccions amb històries',
12+
'drop.itemFollowers': 'Seguidors i seguits',
13+
'drop.helpFormat':
14+
'Posa Format: JSON (no HTML) i Rang de dates: Des del principi per al resultat més complet. Els "m’agrada" a posts no es poden analitzar (Instagram omet l’autor), de manera que "Interaccions amb històries" dona el resultat més ric.',
15+
16+
'status.reading': 'Llegint…',
17+
'status.unrecognized': 'Export no reconegut. És un .zip d’Instagram descarregat en format JSON?',
18+
'status.noInteractions':
19+
'No s’han trobat m’agrada/comentaris. L’has descarregat en HTML en lloc de JSON?',
20+
'status.badZip': 'No s’ha pogut llegir aquest fitxer com a .zip.',
21+
22+
'notice.lowData':
23+
'Només s’han trobat {n} interaccions atribuïbles — insuficients per a un veredicte fiable. Les xifres de sota es mostren per transparència, però tracta la puntuació com a orientativa. Torna a exportar en JSON amb un rang de dates més ampli i inclou "Interaccions amb històries".',
24+
'notice.unattributed':
25+
'{n} m’agrada a posts no s’han pogut atribuir: l’export actual d’Instagram ja no registra qui va crear el post que t’ha agradat, de manera que s’exclouen. Els likes a històries, comentaris i likes a comentaris sí que porten el compte i s’inclouen.',
26+
27+
'verdict.title': 'Salut de la xarxa',
28+
'verdict.caption':
29+
'Una heurística transparent. Més alt = una dieta diversa i equilibrada. Més baix = poques veus acaparen la teva atenció. Diversitat {div}% · Concentració top-10 {conc}%.',
30+
'verdict.fewAccounts':
31+
' (Tens ≤10 comptes amb interacció, de manera que la concentració top-10 és ~100% per naturalesa i la puntuació és aproximada aquí.)',
32+
'band.Healthy': 'Sana',
33+
'band.Moderate': 'Moderada',
34+
'band.Captured': 'Capturada',
35+
36+
'headline.before': 'Segueixes {follows} comptes, però ',
37+
'headline.after': ' acapara el {pct}% de les teves {total} interaccions registrades.',
38+
39+
'graph.title': 'El teu graf d’atenció',
40+
'graph.caption':
41+
'Ets al centre. Cada node és un compte amb el qual interactues — més gran = més atenció. Rosa = comptes que segueixes i amb els quals interactues (et capturen). Taronja = interactues sense seguir-los (fuga d’atenció). Arrossega els nodes per explorar.',
42+
43+
'net.title': 'Tu vs la teva xarxa',
44+
'net.caption':
45+
'{followYouNotBack} et segueixen sense que tu els segueixis; {youFollowNotBack} que segueixes no et segueixen. Només {mutual} són mutus.',
46+
'stat.following': 'seguits',
47+
'stat.followers': 'seguidors',
48+
'stat.mutual': 'mutus',
49+
'leaning.caption':
50+
'Consumidor ↔ Creador — un proxy aproximat del teu ratio seguidors/seguits ({ratio}, {leaning}). L’export no pot mesurar la teva influència real sobre els altres.',
51+
'leaning.consumerEnd': 'Consumidor (absorbeixes)',
52+
'leaning.creatorEnd': 'Creador (t’absorbeixen)',
53+
'leaning.consumer': 'consumidor',
54+
'leaning.balanced': 'equilibrat',
55+
'leaning.creator': 'creador',
56+
57+
'bubble.title': 'La teva bombolla',
58+
'bubble.caption':
59+
'Quanta de la teva atenció es queda dins del teu propi cercle. Alt = cambra de ressò de comptes que ja segueixes; baix = mires cap a fora.',
60+
'bubble.followed': 'Atenció a comptes que segueixes',
61+
'bubble.mutual': 'Atenció a mutus (cercle íntim)',
62+
63+
'dead.title': 'Seguits morts',
64+
'dead.caption':
65+
'Comptes que segueixes però als quals no prestes cap atenció. Una quota alta significa que la teva llista de seguits és sobretot soroll que ja ignores.',
66+
'dead.bar': '{n} de {total} que segueixes no reben gens de la teva atenció',
67+
68+
'when.title': 'Quan t’enganxes',
69+
'when.caption':
70+
'La teva activitat per hora (UTC). Pic cap a les {hour}:00, al llarg de {days} dies d’historial.',
71+
72+
'close.title': 'Test de cercle íntim',
73+
'close.caption': 'Dels teus {total} close friends, de veritat interactues amb {engaged}.',
74+
'close.bar': 'Close friends amb qui de veritat interactues',
75+
76+
'para.title': 'Fuites parasocials',
77+
'para.caption': 'Comptes als quals dediques atenció però que ni tan sols segueixes.',
78+
79+
'lorenz.title': 'Com de desigual està repartida la teva atenció?',
80+
'lorenz.caption':
81+
'Com més s’arrodoneix per sota de la diagonal puntejada, més es concentra la teva atenció en uns pocs comptes. Gini {gini}.',
82+
83+
'bars.title': 'Cap on va la teva atenció',
84+
'bars.caption': 'Quota de les teves interaccions totals que s’emporten els teus comptes principals.',
85+
86+
'table.title': 'Les dades en brut',
87+
'table.caption': 'Tots els comptes amb els quals has interactuat, classificats. Aquí no hi ha res amagat ni manipulat.',
88+
'table.colRank': '#',
89+
'table.colAccount': 'Compte',
90+
'table.colInteractions': 'Interaccions',
91+
'table.colShare': 'Quota',
92+
'table.colFollowed': 'Seguit?',
93+
'table.yes': 'sí',
94+
'table.no': 'no',
95+
}

src/i18n/locales/en.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// English — source of truth. Every other locale mirrors these keys.
2+
// Placeholders use {name}. Usernames are never translated.
3+
export const en: Record<string, string> = {
4+
'drop.prompt':
5+
'Pick or drop your Instagram data export (.zip in JSON format). It never leaves your browser.',
6+
'drop.helpSummary': 'How to download the right export',
7+
'drop.helpSteps':
8+
'In Instagram: Settings → Accounts Center → Your information and permissions → Download your information. Choose “Some of your information” and tick:',
9+
'drop.itemLikes': 'Likes',
10+
'drop.itemComments': 'Comments',
11+
'drop.itemSaved': 'Saved',
12+
'drop.itemStories': 'Story interactions',
13+
'drop.itemFollowers': 'Followers and following',
14+
'drop.helpFormat':
15+
'Set Format: JSON (not HTML) and Date range: All time for the fullest picture. Liked posts can’t be analysed (Instagram omits the author), so “Story interactions” gives the richest result.',
16+
17+
'status.reading': 'Reading…',
18+
'status.unrecognized': 'Unrecognized export. Is this an Instagram .zip downloaded in JSON format?',
19+
'status.noInteractions':
20+
'No likes/comments found. Did you download in HTML instead of JSON format?',
21+
'status.badZip': 'Could not read that file as a .zip.',
22+
23+
'notice.lowData':
24+
'Only {n} attributable interactions found — not enough for a reliable verdict. The numbers below are shown for transparency, but treat the score as indicative only. Re-export from Instagram in JSON with a wider date range and “Story interactions” included.',
25+
'notice.unattributed':
26+
'{n} liked posts couldn’t be attributed: Instagram’s current export no longer records who authored a liked post, so those are excluded. Story likes, comments and liked comments still carry the account and are included.',
27+
28+
'verdict.title': 'Network Health',
29+
'verdict.caption':
30+
'A transparent heuristic. Higher = a diverse, balanced diet. Lower = a few voices own your attention. Diversity {div}% · Top-10 concentration {conc}%.',
31+
'verdict.fewAccounts':
32+
' (You have ≤10 engaged accounts, so top-10 concentration is naturally ~100% and the score is rough here.)',
33+
'band.Healthy': 'Healthy',
34+
'band.Moderate': 'Moderate',
35+
'band.Captured': 'Captured',
36+
37+
'headline.before': 'You follow {follows} accounts, but ',
38+
'headline.after': ' alone takes {pct}% of your {total} logged interactions.',
39+
40+
'graph.title': 'Your attention graph',
41+
'graph.caption':
42+
'You are at the center. Each node is an account you engage with — bigger = more of your attention. Pink = accounts you follow and engage with (they capture you). Orange = you engage without following (attention leak). Drag nodes to explore.',
43+
44+
'net.title': 'You vs your network',
45+
'net.caption':
46+
'{followYouNotBack} follow you that you don’t follow back; {youFollowNotBack} you follow don’t follow you back. Only {mutual} are mutual.',
47+
'stat.following': 'following',
48+
'stat.followers': 'followers',
49+
'stat.mutual': 'mutual',
50+
'leaning.caption':
51+
'Consumer ↔ Creator — a rough proxy from your follower/following ratio ({ratio}, {leaning}). The export cannot measure your real influence on others.',
52+
'leaning.consumerEnd': 'Consumer (you absorb)',
53+
'leaning.creatorEnd': 'Creator (others absorb you)',
54+
'leaning.consumer': 'consumer',
55+
'leaning.balanced': 'balanced',
56+
'leaning.creator': 'creator',
57+
58+
'bubble.title': 'Your bubble',
59+
'bubble.caption':
60+
'How much of your attention stays inside your own circle. High = an echo chamber of accounts you already follow; low = you look outward.',
61+
'bubble.followed': 'Attention to accounts you follow',
62+
'bubble.mutual': 'Attention to mutuals (inner circle)',
63+
64+
'dead.title': 'Dead follows',
65+
'dead.caption':
66+
'Accounts you follow but give zero attention to. A high share means your following list is mostly noise you’ve tuned out.',
67+
'dead.bar': '{n} of {total} you follow get none of your attention',
68+
69+
'when.title': 'When you get hooked',
70+
'when.caption':
71+
'Your activity by hour (UTC). Busiest around {hour}:00, across {days} days of history.',
72+
73+
'close.title': 'Close-friends reality check',
74+
'close.caption': 'Of your {total} close friends, you actually engage with {engaged}.',
75+
'close.bar': 'Close friends you actually engage with',
76+
77+
'para.title': 'Parasocial leaks',
78+
'para.caption': 'Accounts you pour attention into but don’t even follow.',
79+
80+
'lorenz.title': 'How unevenly is your attention spread?',
81+
'lorenz.caption':
82+
'The further the curve bows below the dotted diagonal, the more your attention concentrates in a few accounts. Gini {gini}.',
83+
84+
'bars.title': 'Where your attention goes',
85+
'bars.caption': 'Share of your total interactions held by your top accounts.',
86+
87+
'table.title': 'The raw data',
88+
'table.caption': 'Every account you engaged with, ranked. Nothing here is hidden or modeled away.',
89+
'table.colRank': '#',
90+
'table.colAccount': 'Account',
91+
'table.colInteractions': 'Interactions',
92+
'table.colShare': 'Share',
93+
'table.colFollowed': 'Followed?',
94+
'table.yes': 'yes',
95+
'table.no': 'no',
96+
}

src/i18n/locales/es.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Español
2+
export const es: Record<string, string> = {
3+
'drop.prompt':
4+
'Elige o suelta tu export de Instagram (.zip en formato JSON). Nunca sale de tu navegador.',
5+
'drop.helpSummary': 'Cómo descargar el export correcto',
6+
'drop.helpSteps':
7+
'En Instagram: Configuración → Centro de cuentas → Tu información y permisos → Descargar tu información. Elige “Parte de tu información” y marca:',
8+
'drop.itemLikes': 'Me gusta',
9+
'drop.itemComments': 'Comentarios',
10+
'drop.itemSaved': 'Guardado',
11+
'drop.itemStories': 'Interacciones con historias',
12+
'drop.itemFollowers': 'Seguidores y seguidos',
13+
'drop.helpFormat':
14+
'Pon Formato: JSON (no HTML) y Rango de fechas: Desde el principio para el resultado más completo. Los “me gusta” a posts no se pueden analizar (Instagram omite el autor), así que “Interacciones con historias” da el resultado más rico.',
15+
16+
'status.reading': 'Leyendo…',
17+
'status.unrecognized': 'Export no reconocido. ¿Es un .zip de Instagram descargado en formato JSON?',
18+
'status.noInteractions':
19+
'No se encontraron me gusta/comentarios. ¿Lo descargaste en HTML en vez de JSON?',
20+
'status.badZip': 'No se pudo leer ese archivo como .zip.',
21+
22+
'notice.lowData':
23+
'Solo se encontraron {n} interacciones atribuibles — insuficiente para un veredicto fiable. Los números de abajo se muestran por transparencia, pero toma el score como orientativo. Vuelve a exportar en JSON con un rango de fechas mayor e incluyendo “Interacciones con historias”.',
24+
'notice.unattributed':
25+
'{n} me gusta a posts no se pudieron atribuir: el export actual de Instagram ya no registra quién creó el post al que diste like, así que se excluyen. Los likes a historias, comentarios y likes a comentarios sí llevan la cuenta y se incluyen.',
26+
27+
'verdict.title': 'Salud de la red',
28+
'verdict.caption':
29+
'Una heurística transparente. Más alto = una dieta diversa y equilibrada. Más bajo = pocas voces acaparan tu atención. Diversidad {div}% · Concentración top-10 {conc}%.',
30+
'verdict.fewAccounts':
31+
' (Tienes ≤10 cuentas con interacción, así que la concentración top-10 es ~100% por naturaleza y el score es aproximado aquí.)',
32+
'band.Healthy': 'Sana',
33+
'band.Moderate': 'Moderada',
34+
'band.Captured': 'Capturada',
35+
36+
'headline.before': 'Sigues a {follows} cuentas, pero ',
37+
'headline.after': ' acapara el {pct}% de tus {total} interacciones registradas.',
38+
39+
'graph.title': 'Tu grafo de atención',
40+
'graph.caption':
41+
'Estás en el centro. Cada nodo es una cuenta con la que interactúas — más grande = más atención. Rosa = cuentas que sigues e interactúas (te captan). Naranja = interactúas sin seguirlas (fuga de atención). Arrastra los nodos para explorar.',
42+
43+
'net.title': 'Tú vs tu red',
44+
'net.caption':
45+
'{followYouNotBack} te siguen sin que tú les sigas; {youFollowNotBack} que sigues no te siguen. Solo {mutual} son mutuos.',
46+
'stat.following': 'seguidos',
47+
'stat.followers': 'seguidores',
48+
'stat.mutual': 'mutuos',
49+
'leaning.caption':
50+
'Consumidor ↔ Creador — un proxy aproximado de tu ratio seguidores/seguidos ({ratio}, {leaning}). El export no puede medir tu influencia real sobre los demás.',
51+
'leaning.consumerEnd': 'Consumidor (absorbes)',
52+
'leaning.creatorEnd': 'Creador (te absorben)',
53+
'leaning.consumer': 'consumidor',
54+
'leaning.balanced': 'equilibrado',
55+
'leaning.creator': 'creador',
56+
57+
'bubble.title': 'Tu burbuja',
58+
'bubble.caption':
59+
'Cuánta de tu atención se queda dentro de tu propio círculo. Alto = cámara de eco de cuentas que ya sigues; bajo = miras hacia fuera.',
60+
'bubble.followed': 'Atención a cuentas que sigues',
61+
'bubble.mutual': 'Atención a mutuos (círculo íntimo)',
62+
63+
'dead.title': 'Seguidos muertos',
64+
'dead.caption':
65+
'Cuentas que sigues pero a las que das cero atención. Una cuota alta significa que tu lista de seguidos es sobre todo ruido que ya ignoras.',
66+
'dead.bar': '{n} de {total} que sigues no reciben nada de tu atención',
67+
68+
'when.title': 'Cuándo te enganchas',
69+
'when.caption':
70+
'Tu actividad por hora (UTC). Pico sobre las {hour}:00, a lo largo de {days} días de historial.',
71+
72+
'close.title': 'Test de círculo íntimo',
73+
'close.caption': 'De tus {total} close friends, de verdad interactúas con {engaged}.',
74+
'close.bar': 'Close friends con los que de verdad interactúas',
75+
76+
'para.title': 'Fugas parasociales',
77+
'para.caption': 'Cuentas en las que vuelcas atención pero ni siquiera sigues.',
78+
79+
'lorenz.title': '¿Cómo de desigual está repartida tu atención?',
80+
'lorenz.caption':
81+
'Cuanto más se curva por debajo de la diagonal punteada, más se concentra tu atención en unas pocas cuentas. Gini {gini}.',
82+
83+
'bars.title': 'Hacia dónde va tu atención',
84+
'bars.caption': 'Cuota de tus interacciones totales que se llevan tus cuentas principales.',
85+
86+
'table.title': 'Los datos crudos',
87+
'table.caption': 'Todas las cuentas con las que interactuaste, clasificadas. Aquí no hay nada oculto ni manipulado.',
88+
'table.colRank': '#',
89+
'table.colAccount': 'Cuenta',
90+
'table.colInteractions': 'Interacciones',
91+
'table.colShare': 'Cuota',
92+
'table.colFollowed': '¿Seguida?',
93+
'table.yes': 'sí',
94+
'table.no': 'no',
95+
}

0 commit comments

Comments
 (0)