feat: add i18n with LingUI, Crowdin integration, and typed error codes (#16)

* feat: add i18n with LingUI, Crowdin integration, and typed error codes

- Add `@sofa/i18n` shared package with LingUI v5 (v6-ready config using
  `defineConfig` + `@lingui/format-po`), eager English + lazy-loaded
  fr/de/es/it/pt catalogs, `Intl`-based date/number format utilities,
  and test helpers
- Wire `@lingui/vite-plugin` + babel macro plugin for web, and
  `@lingui/metro-transformer` + babel config for native
- Add `I18nProvider` to both app roots with locale auto-detection
  (navigator.language / expo-localization) and persistence
  (localStorage / MMKV)
- Wrap all ~512 user-facing strings across web and native with LingUI
  macros (`<Trans>`, `useLingui`/`t`, `i18n._(msg`...`)`, `plural`)
- Add language switcher to Settings in both apps
- Add `@sofa/api/errors` with 13 typed `AppErrorCode` values and
  `appErrorData()` helper for contract `.errors()` schemas
- Update all oRPC contract error definitions with typed `data` fields;
  convert import procedure `throw new Error()` to `ORPCError` with codes
- Add per-app `error-messages.ts` utilities that map error codes to
  localized strings; update global `QueryCache.onError` handlers to stop
  leaking raw `error.message` to users
- Add `crowdin.yml` config and `.github/workflows/crowdin.yml` for
  automated source upload on merge and translation pull via dispatch
- Add `lingui.config.ts` at repo root with `bun run i18n:extract` and
  `bun run i18n:compile` convenience scripts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: add Crowdin language mapping and ignore context file

* fix(i18n): address 20 localization quality issues

- Stop leaking raw error.message to users in change-password and
  account-section; use localized fallbacks instead
- Add typed error data to discover contract's .errors() schema
- Replace all manual English plural suffixes with LingUI plural() macro
  in episode counts, backup counts, star ratings, title/image counts
- Replace date-fns formatDistanceToNow and hardcoded date patterns with
  locale-aware formatDate/formatRelativeTime from @sofa/i18n/format
- Convert integration-configs from module-scope translations (frozen at
  import time) to lazy getIntegrationConfigs(i18n) function
- Combine split Trans fragments into single translatable units in
  backup-schedule retention sentence and stats-display period labels
- Localize TV media type badge and full episode accessibilityLabel
- Replace Android-incompatible Alert.alert language picker (max 3
  buttons) with zeego DropdownMenu
- Await async locale initialization before hiding splash screen so
  non-English users don't see English flash on cold start
- Add @lingui/core as direct native app dependency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(i18n): add Intl polyfills for Hermes, replace DropdownMenu language picker, and remove date-fns
- Add `@formatjs/intl-*` polyfills loaded at app startup in `intl-polyfills.ts` so Hermes on Android has full `Intl` support for locale-aware formatting
- Alias `@formatjs/icu-messageformat-parser` to its `no-parser` variant in metro config to reduce bundle size
- Replace the zeego `DropdownMenu` language picker in Settings with a new `SelectModal` component that works reliably on all platforms without the Android 3-button limit
- Replace `date-fns` `format`/`parseISO` in `person/[id].tsx` with `formatDate` from `@sofa/i18n/format`; remove `date-fns` from native and web `package.json`
- Refactor `StatusActionButton` to use a `StatusLabel` component with `<Trans>` rather than a `getStatusConfig(t)` factory so labels are always reactive to locale changes
- Fix crowdin workflow to use root `bun run i18n:compile` script instead of `cd packages/i18n && bun run compile`
- Wrap root layout `GestureHandlerRootView` in `SafeAreaProvider` and add `.catch` to locale-ready promise to prevent unhandled rejections blocking the splash screen

* fix(i18n): localize integration status helpers, fix stats-display Trans wrapping, and clean up SelectModal
- Localize `webhookStatus` and `listStatus` in `integration-card.tsx` using `i18n._(msg`...`)` so status strings are translated instead of always rendering in English
- Wrap "Movies {select}" and "Episodes {select}" in a single `<Trans>` in `stats-display.tsx` so the period selector element is embedded inside the translatable unit rather than concatenated outside it
- Fix locale activation order in Settings: close the modal first, then `activateLocale`, and only call `setPersistedLocale` on success to avoid persisting a locale that failed to load
- Remove the redundant `SafeAreaProvider`/`SafeAreaView` wrapper from `SelectModal` — the root layout already provides `SafeAreaProvider`
- Recompile all six `.po`/`.ts` catalogs to pick up new and updated message strings

* chore(i18n): crowdin sync

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 12:16:10 -04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent e50ab44783
commit ad0497ccd6
142 changed files with 18002 additions and 1243 deletions
+17 -4
View File
@@ -1,5 +1,6 @@
import path from "node:path";
import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import { BACKUP_DIR } from "@sofa/config";
import {
createBackup,
@@ -47,9 +48,15 @@ export const backupsDelete = os.admin.backups.delete
if (err instanceof ORPCError) throw err;
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("not found")) {
throw new ORPCError("NOT_FOUND", { message: msg });
throw new ORPCError("NOT_FOUND", {
message: msg,
data: { code: AppErrorCode.BACKUP_NOT_FOUND },
});
}
throw new ORPCError("BAD_REQUEST", { message: msg });
throw new ORPCError("BAD_REQUEST", {
message: msg,
data: { code: AppErrorCode.BACKUP_DELETE_FAILED },
});
}
});
@@ -71,7 +78,10 @@ export const backupsRestore = os.admin.backups.restore
if (await f.exists()) await f.delete();
if (err instanceof ORPCError) throw err;
const msg = err instanceof Error ? err.message : String(err);
throw new ORPCError("BAD_REQUEST", { message: msg });
throw new ORPCError("BAD_REQUEST", {
message: msg,
data: { code: AppErrorCode.BACKUP_RESTORE_FAILED },
});
}
});
@@ -160,7 +170,10 @@ export const triggerJob = os.admin.triggerJob
.handler(async ({ input }) => {
const triggered = await triggerCronJob(input.name);
if (!triggered) {
throw new ORPCError("NOT_FOUND", { message: "Job not found" });
throw new ORPCError("NOT_FOUND", {
message: "Job not found",
data: { code: AppErrorCode.JOB_NOT_FOUND },
});
}
return { ok: true as const };
});
@@ -1,4 +1,5 @@
import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
import {
getEpisodeProgressByTitleIds,
@@ -16,6 +17,7 @@ export const discover = os.discover
if (!isTmdbConfigured()) {
throw new ORPCError("PRECONDITION_FAILED", {
message: "TMDB API key is not configured",
data: { code: AppErrorCode.TMDB_NOT_CONFIGURED },
});
}
@@ -1,4 +1,5 @@
import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
import {
getEpisodeProgressByTitleIds,
@@ -14,6 +15,7 @@ function requireTmdb() {
if (!isTmdbConfigured()) {
throw new ORPCError("PRECONDITION_FAILED", {
message: "TMDB API key is not configured",
data: { code: AppErrorCode.TMDB_NOT_CONFIGURED },
});
}
}
+22 -11
View File
@@ -1,3 +1,5 @@
import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import type { ParseResult } from "@sofa/core/imports";
import {
countUnresolved,
@@ -31,9 +33,10 @@ export const parseFile = os.imports.parseFile
try {
json = await file.json();
} catch {
throw new Error(
"Invalid JSON file. Ensure it is a valid Trakt export.",
);
throw new ORPCError("BAD_REQUEST", {
message: "Invalid JSON file",
data: { code: AppErrorCode.IMPORT_INVALID_FILE },
});
}
result = parseTraktPayload(
json as Parameters<typeof parseTraktPayload>[0],
@@ -45,9 +48,10 @@ export const parseFile = os.imports.parseFile
try {
json = await file.json();
} catch {
throw new Error(
"Invalid JSON file. Ensure it is a valid Simkl export.",
);
throw new ORPCError("BAD_REQUEST", {
message: "Invalid JSON file",
data: { code: AppErrorCode.IMPORT_INVALID_FILE },
});
}
result = parseSimklPayload(
json as Parameters<typeof parseSimklPayload>[0],
@@ -99,9 +103,10 @@ export const createJob = os.imports.createJob
data.watchlist.length +
data.ratings.length;
if (totalItems > 100_000) {
throw new Error(
`Import payload too large (${totalItems} items, max 100,000)`,
);
throw new ORPCError("BAD_REQUEST", {
message: "Import payload too large",
data: { code: AppErrorCode.IMPORT_PAYLOAD_TOO_LARGE },
});
}
// Prevent concurrent imports per user.
@@ -137,7 +142,10 @@ export const createJob = os.imports.createJob
.run();
log.warn(`Auto-cancelled stale import job ${existing.id}`);
} else {
throw new Error("An import is already in progress");
throw new ORPCError("CONFLICT", {
message: "An import is already in progress",
data: { code: AppErrorCode.IMPORT_ALREADY_RUNNING },
});
}
}
@@ -175,7 +183,10 @@ export const cancelJob = os.imports.cancelJob
.handler(({ input, context }) => {
const job = readImportJob(input.id, context.user.id);
if (job.status !== "pending" && job.status !== "running") {
throw new Error("Can only cancel pending or running jobs");
throw new ORPCError("BAD_REQUEST", {
message: "Can only cancel pending or running jobs",
data: { code: AppErrorCode.IMPORT_CANNOT_CANCEL },
});
}
db.update(importJobs)
.set({ status: "cancelled" })
@@ -1,4 +1,5 @@
import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import { db } from "@sofa/db/client";
import { and, desc, eq } from "@sofa/db/helpers";
import { integrationEvents, integrations } from "@sofa/db/schema";
@@ -145,7 +146,10 @@ export const regenerateToken = os.integrations.regenerateToken
.get();
if (!row) {
throw new ORPCError("NOT_FOUND", { message: "Integration not found" });
throw new ORPCError("NOT_FOUND", {
message: "Integration not found",
data: { code: AppErrorCode.INTEGRATION_NOT_FOUND },
});
}
return serializeIntegration(row);
+5 -1
View File
@@ -1,4 +1,5 @@
import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import { fetchFullFilmography, getOrFetchPerson } from "@sofa/core/person";
import { getUserStatusesByTitleIds } from "@sofa/core/tracking";
import { os } from "../context";
@@ -9,7 +10,10 @@ export const detail = os.people.detail
.handler(async ({ input, context }) => {
const person = await getOrFetchPerson(input.id);
if (!person)
throw new ORPCError("NOT_FOUND", { message: "Person not found" });
throw new ORPCError("NOT_FOUND", {
message: "Person not found",
data: { code: AppErrorCode.PERSON_NOT_FOUND },
});
const allCredits = await fetchFullFilmography(person.id);
@@ -1,4 +1,5 @@
import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
import { ensureBrowsePersonsExist } from "@sofa/core/person";
import {
@@ -16,6 +17,7 @@ export const search = os.search.use(authed).handler(async ({ input }) => {
if (!isTmdbConfigured()) {
throw new ORPCError("PRECONDITION_FAILED", {
message: "TMDB API key is not configured",
data: { code: AppErrorCode.TMDB_NOT_CONFIGURED },
});
}
+9 -2
View File
@@ -1,4 +1,5 @@
import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import { getRecommendationsForTitle } from "@sofa/core/discovery";
import { getOrFetchTitle, getOrFetchTitleByTmdbId } from "@sofa/core/metadata";
import {
@@ -21,7 +22,10 @@ export const detail = os.titles.detail
.handler(async ({ input }) => {
const result = await getOrFetchTitle(input.id);
if (!result)
throw new ORPCError("NOT_FOUND", { message: "Title not found" });
throw new ORPCError("NOT_FOUND", {
message: "Title not found",
data: { code: AppErrorCode.TITLE_NOT_FOUND },
});
return result;
});
@@ -80,7 +84,10 @@ export const quickAdd = os.titles.quickAdd
.where(eq(titles.id, input.id))
.get();
if (!title) {
throw new ORPCError("NOT_FOUND", { message: "Title not found" });
throw new ORPCError("NOT_FOUND", {
message: "Title not found",
data: { code: AppErrorCode.TITLE_NOT_FOUND },
});
}
// Trigger full TMDB import if still a shell