Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions .agents/log/6-react-i18next-i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# React-i18next I18n

## Summary

Add app-wide localization with `react-i18next`, JSON locale files, browser-language auto-detect, and `localStorage` persistence. Ship `en` and `uk`, and add a homescreen toggle that switches language immediately.

## Key Changes

- Add i18n bootstrap with `i18next` + `react-i18next`.
- Configure resources from JSON files under `src/i18n/locales/`.
- Detection order:
1. stored locale in `localStorage`
2. browser language
3. fallback `en`
- Persist any resolved/manual locale back to `localStorage`.
- Initialize once at app start, before rendering routes.
- Wire the provider at the app root.
- Wrap `BrowserRouter` in `src/App.tsx` with `I18nextProvider`.
- Keep basename/base-path logic unchanged.
- Localize all current UI copy.
- Homescreen, folder picker, loading fallback, import status, viewer sidebar, 3D controls, axis labels, and error text.
- Move disclaimer text and progress strings out of hardcoded constants into locale resources.
- Replace raw `ImportStage` enum output with localized labels.
- Add a homescreen language toggle.
- Place it in the import page hero/header.
- Show `EN` and `UK`.
- Active locale is visually selected.
- Changing it updates the whole app immediately.
- Keep the viewer on the same locale.
- No second toggle in the viewer for this pass.
- User can return to import and switch there if needed.

## Public Interfaces / Types

- Add locale resource shape and translation keys for:
- `common`
- `importPage`
- `folderPicker`
- `importStatus`
- `viewerPage`
- `viewerSidebar`
- `axisViewport`
- `volumeViewport3d`
- `errors`
- Add a small locale helper for:
- resolving the active locale
- reading/writing `localStorage`
- exposing a typed `t()` wrapper where it reduces repetition
- Stop exporting plain English UI strings from constants where they belong in translated resources.

## Test Plan

- Verify startup locale resolution:
- saved locale wins over browser language
- browser `uk*` selects Ukrainian
- everything else falls back to English
- Verify homescreen toggle:
- switching locale updates all visible import-page text immediately
- selection persists after refresh
- Verify localized runtime strings:
- scanning/loading/error status text
- viewer labels and control labels
- loading fallback and unsupported states
- Regression checks:
- folder picking still works
- router/base-path behavior unchanged
- viewer layout and compact mode unchanged apart from copy
- Run:
- `npm run build`
- `npm run format:check`
- `npm run lint`

## Assumptions

- First release ships only `en` and `uk`.
- JSON resources are sufficient; no ICU/plural tooling is needed yet.
- Locale choice is app-wide, not route-based.
- The plan text itself cannot be written into `.agents/log` until execution mode is available.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
[![Twitter][twitter-image]][twitter-url]

> [!NOTE]
> Web-based viewer for CBCT (Cone Beam Computed Tomography) volumes and scan folders (supports Sirona GALILEOS, DICOM, OneVolume)
> Web-based viewer for [CBCT (Cone Beam Computed Tomography)][cbct-wiki] volumes and scan folders (supports Sirona GALILEOS, DICOM, OneVolume)

## Problem

Expand Down Expand Up @@ -88,6 +88,7 @@ npm run dev
[app]: https://denysdovhan.com/voxel-viewer
[app-repo]: https://github.com/denysdovhan/voxel-viewer
[denysdovhan]: https://denysdovhan.com
[cbct-wiki]: https://en.wikipedia.org/wiki/Cone_beam_computed_tomography

<!-- Badges -->

Expand Down
98 changes: 97 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@
"@types/three": "^0.183.1",
"clsx": "^2.1.1",
"fflate": "^0.8.2",
"i18next": "^26.0.3",
"lodash": "^4.17.23",
"lucide-react": "^1.7.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^17.0.2",
"react-router-dom": "^7.13.2",
"tailwind-merge": "^3.5.0",
"three": "^0.183.2"
Expand Down
10 changes: 7 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { I18nextProvider } from 'react-i18next';
import { BrowserRouter } from 'react-router-dom';
import { AppRouter } from './app/AppRouter';
import { i18n } from './i18n';

const basename = import.meta.env.BASE_URL.replace(/\/$/, '');

export default function App() {
return (
<BrowserRouter basename={basename}>
<AppRouter />
</BrowserRouter>
<I18nextProvider i18n={i18n}>
<BrowserRouter basename={basename}>
<AppRouter />
</BrowserRouter>
</I18nextProvider>
);
}
5 changes: 4 additions & 1 deletion src/app/AppRouter.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { lazy, Suspense, useMemo } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { APP_ROUTES } from '../constants';
import { useTranslation } from '../i18n';
import { createDefaultScanFolderPicker } from '../lib/import/source-picker';
import { useViewerApp } from './useViewerApp';

const ImportPage = lazy(() => import('../pages/ImportPage'));
const ViewerPage = lazy(() => import('../pages/ViewerPage'));

function RouteFallback() {
const { t } = useTranslation();

return (
<main className="flex min-h-screen items-center justify-center bg-slate-950 px-4 text-slate-100">
<div className="rounded border border-slate-800 bg-slate-950/90 px-4 py-3 text-sm text-slate-400">
Loading viewer shell...
{t('common.loadingViewerShell')}
</div>
</main>
);
Expand Down
24 changes: 17 additions & 7 deletions src/app/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { LEVEL_MAX, LEVEL_MIN, WINDOW_MAX, WINDOW_MIN } from '../constants';
import { i18n } from '../i18n';
import type {
ImportIssue,
ImportProgress,
Expand Down Expand Up @@ -28,21 +29,30 @@ export function makeImportIssue(error: unknown): ImportIssue {
};

if (typeof value.message === 'string') {
const code =
typeof value.code === 'string'
? value.code
: typeof value.name === 'string'
? value.name
: 'E_IMPORT';

if (code === 'E_FORMAT') {
return {
code,
message: i18n.t('errors.unsupportedFolderLayout'),
};
}

return {
code:
typeof value.code === 'string'
? value.code
: typeof value.name === 'string'
? value.name
: 'E_IMPORT',
code,
message: value.message,
};
}
}

return {
code: 'E_IMPORT',
message: 'Failed to load the selected scan folder.',
message: i18n.t('errors.failedToLoadSelectedScanFolder'),
};
}

Expand Down
13 changes: 5 additions & 8 deletions src/app/useViewerApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,6 @@ export function useViewerApp({
const loadSource = useEffectEvent(async (source: ScanFolderSource) => {
resetViewer();
setSourceLabel(source.label);
setProgress({
stage: ImportStage.Scanning,
detail: `Scanning ${source.label}`,
completed: 0,
total: 1,
});

try {
const loaded = await loadVolumeFromFolder(source, setProgress);
Expand All @@ -153,7 +147,10 @@ export function useViewerApp({
setMprZoom(DEFAULT_MPR_ZOOM);
setProgress({
stage: ImportStage.Ready,
detail: `Loaded ${loaded.meta.scanId}`,
detailKey: 'importStatus.progress.loadedScan',
detailValues: {
scanId: loaded.meta.scanId,
},
completed: loaded.meta.sliceCount,
total: loaded.meta.sliceCount,
});
Expand All @@ -163,7 +160,7 @@ export function useViewerApp({
setIssue(makeImportIssue(error));
setProgress({
stage: ImportStage.Error,
detail: 'Import failed',
detailKey: 'importStatus.progress.importFailed',
completed: 0,
total: 1,
});
Expand Down
Loading