From e45bcef3a5a9c3a401f849ddf7c2d3646a50c13e Mon Sep 17 00:00:00 2001
From: Senko-san
Date: Sat, 6 Jun 2026 15:23:07 +0300
Subject: [PATCH] feat: i18n
---
package-lock.json | 78 ++++++++-
package.json | 4 +-
src/components/common/ConnectionStatus.tsx | 17 +-
src/components/common/ErrorState.tsx | 11 +-
src/components/layout/Sidebar.tsx | 50 +++---
src/components/player/PersistentPlayer.tsx | 26 ++-
src/components/player/QueuePanel.tsx | 43 +++--
src/components/track/TrackContextMenu.tsx | 40 ++---
src/features/admin/AdminPage.tsx | 7 +-
src/features/album-detail/AlbumDetailPage.tsx | 20 +--
src/features/connect/ConnectPage.tsx | 30 ++--
.../DownloadsManagerPage.tsx | 7 +-
src/features/library/LibraryPage.tsx | 40 +++--
.../playlist-detail/PlaylistDetailPage.tsx | 18 +-
.../search-download/SearchDownloadPage.tsx | 7 +-
src/features/settings/SettingsPage.tsx | 24 ++-
src/features/storage/StoragePage.tsx | 7 +-
src/i18n/index.ts | 37 +++++
src/i18n/locales/en.ts | 156 ++++++++++++++++++
src/i18n/locales/ru.ts | 153 +++++++++++++++++
src/index.tsx | 1 +
21 files changed, 613 insertions(+), 163 deletions(-)
create mode 100644 src/i18n/index.ts
create mode 100644 src/i18n/locales/en.ts
create mode 100644 src/i18n/locales/ru.ts
diff --git a/package-lock.json b/package-lock.json
index c868a9c..21b29d0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,8 +11,10 @@
"@olly/modern-sk": "0.1.4-3",
"@phosphor-icons/react": "^2.1.10",
"@reduxjs/toolkit": "^2.12.0",
+ "i18next": "^26.3.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
+ "react-i18next": "^17.0.8",
"react-redux": "^9.3.0",
"react-router": "^7.16.0"
},
@@ -496,7 +498,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -3491,6 +3492,43 @@
"node": ">=20.0.0"
}
},
+ "node_modules/html-parse-stringify": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
+ "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
+ "license": "MIT",
+ "dependencies": {
+ "void-elements": "3.1.0"
+ }
+ },
+ "node_modules/i18next": {
+ "version": "26.3.1",
+ "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
+ "integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://www.locize.com/i18next"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.locize.com"
+ }
+ ],
+ "license": "MIT",
+ "peerDependencies": {
+ "typescript": "^5 || ^6"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/immer": {
"version": "11.1.8",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
@@ -4069,6 +4107,33 @@
"react": "^19.2.7"
}
},
+ "node_modules/react-i18next": {
+ "version": "17.0.8",
+ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
+ "integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.2",
+ "html-parse-stringify": "^3.0.1",
+ "use-sync-external-store": "^1.6.0"
+ },
+ "peerDependencies": {
+ "i18next": ">= 26.2.0",
+ "react": ">= 16.8.0",
+ "typescript": "^5 || ^6"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -4335,7 +4400,7 @@
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -4435,6 +4500,15 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/void-elements": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
+ "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
diff --git a/package.json b/package.json
index 9c1d279..7215923 100644
--- a/package.json
+++ b/package.json
@@ -13,11 +13,13 @@
"test:watch": "rstest --watch"
},
"dependencies": {
+ "@olly/modern-sk": "0.1.4-3",
"@phosphor-icons/react": "^2.1.10",
"@reduxjs/toolkit": "^2.12.0",
- "@olly/modern-sk": "0.1.4-3",
+ "i18next": "^26.3.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
+ "react-i18next": "^17.0.8",
"react-redux": "^9.3.0",
"react-router": "^7.16.0"
},
diff --git a/src/components/common/ConnectionStatus.tsx b/src/components/common/ConnectionStatus.tsx
index 2d5a1ff..bcbd2d0 100644
--- a/src/components/common/ConnectionStatus.tsx
+++ b/src/components/common/ConnectionStatus.tsx
@@ -1,12 +1,13 @@
import { Badge, Tooltip } from '@olly/modern-sk';
+import { useTranslation } from 'react-i18next';
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
import { getApiBaseUrl } from '../../config/runtime-config';
-const STATUS_LABELS = {
- connected: 'Connected',
- connecting: 'Connecting…',
- disconnected: 'Disconnected',
- error: 'Connection error',
+const STATUS_KEY = {
+ connected: 'conn.connected',
+ connecting: 'conn.connecting',
+ disconnected: 'conn.disconnected',
+ error: 'conn.error',
} as const;
const STATUS_VARIANTS = {
@@ -17,13 +18,15 @@ const STATUS_VARIANTS = {
} as const;
export function ConnectionStatus() {
+ const { t } = useTranslation();
const status = useConnectionStatus();
const baseUrl = getApiBaseUrl();
+ const label = t(STATUS_KEY[status]);
return (
-
+
- {STATUS_LABELS[status]}
+ {label}
);
diff --git a/src/components/common/ErrorState.tsx b/src/components/common/ErrorState.tsx
index 7808587..e0c34ee 100644
--- a/src/components/common/ErrorState.tsx
+++ b/src/components/common/ErrorState.tsx
@@ -1,18 +1,17 @@
import { Callout, Button } from '@olly/modern-sk';
+import { useTranslation } from 'react-i18next';
interface ErrorStateProps {
message?: string;
onRetry?: () => void;
}
-export function ErrorState({
- message = 'Something went wrong',
- onRetry,
-}: ErrorStateProps) {
+export function ErrorState({ message, onRetry }: ErrorStateProps) {
+ const { t } = useTranslation();
return (
- {message}
+ {message ?? t('common.error')}
{onRetry && (
)}
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx
index 87e6ed8..a105155 100644
--- a/src/components/layout/Sidebar.tsx
+++ b/src/components/layout/Sidebar.tsx
@@ -1,4 +1,5 @@
import { NavLink, useNavigate } from 'react-router';
+import { useTranslation } from 'react-i18next';
import { Icon, type IconName } from '../common/Icon';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { usePermissions } from '../../hooks/usePermissions';
@@ -9,24 +10,24 @@ import { getActiveInstance } from '../../config/instances';
interface NavDef {
to: string;
- label: string;
+ labelKey: string;
icon: IconName;
end?: boolean;
}
const MAIN_NAV: NavDef[] = [
- { to: '/', label: 'Home', icon: 'house', end: true },
- { to: '/library', label: 'Library', icon: 'vinyl-record' },
- { to: '/search', label: 'Search & download', icon: 'magnifying-glass' },
- { to: '/downloads', label: 'Downloads', icon: 'arrow-circle-down' },
- { to: '/storage', label: 'Storage', icon: 'hard-drives' },
+ { to: '/', labelKey: 'nav.home', icon: 'house', end: true },
+ { to: '/library', labelKey: 'nav.library', icon: 'vinyl-record' },
+ { to: '/search', labelKey: 'nav.search', icon: 'magnifying-glass' },
+ { to: '/downloads', labelKey: 'nav.downloads', icon: 'arrow-circle-down' },
+ { to: '/storage', labelKey: 'nav.storage', icon: 'hard-drives' },
];
-const CONN_CLASS: Record = {
- connected: { cls: 'online', txt: 'Connected' },
- connecting: { cls: 'syncing', txt: 'Connecting…' },
- disconnected: { cls: 'offline', txt: 'Offline' },
- error: { cls: 'error', txt: 'Unreachable' },
+const CONN_KEY: Record = {
+ connected: { cls: 'online', txtKey: 'conn.connected' },
+ connecting: { cls: 'syncing', txtKey: 'conn.connecting' },
+ disconnected: { cls: 'offline', txtKey: 'conn.disconnected' },
+ error: { cls: 'error', txtKey: 'conn.error' },
};
function navClass({ isActive }: { isActive: boolean }) {
@@ -34,6 +35,7 @@ function navClass({ isActive }: { isActive: boolean }) {
}
export function Sidebar() {
+ const { t } = useTranslation();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const { user, isAdmin } = usePermissions();
@@ -41,7 +43,7 @@ export function Sidebar() {
const { data: playlists } = useGetPlaylistsQuery();
const instance = getActiveInstance();
- const conn = CONN_CLASS[status] ?? CONN_CLASS.connecting;
+ const conn = CONN_KEY[status] ?? CONN_KEY.connecting;
const online = status === 'connected';
const handleLogout = (e: React.MouseEvent) => {
@@ -59,16 +61,16 @@ export function Sidebar() {
- {MAIN_NAV.map(({ to, label, icon, end }) => (
+ {MAIN_NAV.map(({ to, labelKey, icon, end }) => (
- {label}
+ {t(labelKey)}
))}
- Playlists
+ {t('nav.playlists')}
{(playlists?.items ?? []).map((pl) => (
void navigate('/library')}
>
- New playlist
+ {t('nav.newPlaylist')}
{isAdmin ? (
- Administration
+ {t('nav.administration')}
- Admin
+ {t('nav.admin')}
- Settings
+ {t('nav.settings')}
) : (
- Settings
+ {t('nav.settings')}
)}
@@ -116,10 +118,10 @@ export function Sidebar() {
type="button"
className={`conn ${conn.cls}`}
onClick={() => void navigate('/connect')}
- title="Connection — manage instances"
+ title={t('conn.manage')}
>
- {conn.txt}
+ {t(conn.txtKey)}
{user && (
diff --git a/src/components/player/PersistentPlayer.tsx b/src/components/player/PersistentPlayer.tsx
index 0f559ff..ce5a188 100644
--- a/src/components/player/PersistentPlayer.tsx
+++ b/src/components/player/PersistentPlayer.tsx
@@ -1,4 +1,5 @@
import { Slider } from '@olly/modern-sk';
+import { useTranslation } from 'react-i18next';
import { Icon } from '../common/Icon';
import { ArtTile } from '../common/ArtTile';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
@@ -17,6 +18,7 @@ import { formatDuration } from '../../lib/format';
import { getCoverUrl } from '../../api/endpoints/streaming';
export function PersistentPlayer() {
+ const { t } = useTranslation();
const dispatch = useAppDispatch();
const { seek, playNext, playPrev } = useAudioPlayer();
const player = useAppSelector((s) => s.player);
@@ -24,17 +26,15 @@ export function PersistentPlayer() {
const currentEntry = queue.entries[queue.currentIndex];
if (!currentEntry && !player.currentTrackId) {
- return Nothing playing
;
+ return {t('player.nothingPlaying')}
;
}
const artUrl = getCoverUrl(currentEntry?.albumArtUrl);
const seedLabel = currentEntry?.albumTitle ?? currentEntry?.title ?? '';
- // Streaming is the web default; local playback is a mobile-client concern.
const onStream = true;
return (
- {/* now-playing identity */}
dispatch(toggleNowPlaying())}
@@ -49,19 +49,18 @@ export function PersistentPlayer() {
style={{ color: onStream ? 'var(--fg-3)' : 'var(--lime)' }}
>
- {onStream ? 'Streaming · 320 kbps' : 'Local · FLAC'}
+ {onStream ? t('player.streaming') : t('player.local')}
- {/* transport + scrubber */}
@@ -69,7 +68,7 @@ export function PersistentPlayer() {
type="button"
className="pl-tbtn"
onClick={playPrev}
- title="Previous"
+ title={t('player.previous')}
>
@@ -79,7 +78,7 @@ export function PersistentPlayer() {
onClick={() =>
player.isPlaying ? dispatch(pause()) : dispatch(resume())
}
- title={player.isPlaying ? 'Pause' : 'Play'}
+ title={player.isPlaying ? t('player.pause') : t('player.play')}
>
@@ -87,7 +86,7 @@ export function PersistentPlayer() {
type="button"
className="pl-tbtn"
onClick={playNext}
- title="Next"
+ title={t('player.next')}
>
@@ -105,7 +104,7 @@ export function PersistentPlayer() {
),
)
}
- title={`Repeat: ${player.repeat}`}
+ title={t('player.repeat', { mode: player.repeat })}
>
@@ -121,7 +120,7 @@ export function PersistentPlayer() {
step={1}
value={[player.position]}
onValueChange={([v]) => seek(v)}
- aria-label="Seek"
+ aria-label={t('player.play')}
/>
{formatDuration(player.duration * 1000)}
@@ -129,13 +128,12 @@ export function PersistentPlayer() {
- {/* volume + queue */}
@@ -154,7 +152,7 @@ export function PersistentPlayer() {
type="button"
className={`iconbtn sm${player.isQueueOpen ? ' on' : ''}`}
onClick={() => dispatch(toggleQueue())}
- title="Play queue"
+ title={t('player.queue')}
>
diff --git a/src/components/player/QueuePanel.tsx b/src/components/player/QueuePanel.tsx
index 2e1e97d..e0a43ff 100644
--- a/src/components/player/QueuePanel.tsx
+++ b/src/components/player/QueuePanel.tsx
@@ -1,4 +1,5 @@
import { Slider, Badge } from '@olly/modern-sk';
+import { useTranslation } from 'react-i18next';
import { Icon } from '../common/Icon';
import { ArtTile } from '../common/ArtTile';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
@@ -10,6 +11,7 @@ import {
import { toggleQueue } from '../../store/slices/player';
export function QueuePanel() {
+ const { t } = useTranslation();
const dispatch = useAppDispatch();
const queue = useAppSelector((s) => s.queue);
const isOpen = useAppSelector((s) => s.player.isQueueOpen);
@@ -27,13 +29,13 @@ export function QueuePanel() {
-
Play queue
+
{t('queue.title')}
@@ -41,7 +43,7 @@ export function QueuePanel() {
type="button"
className="iconbtn sm"
onClick={() => dispatch(toggleQueue())}
- title="Close"
+ title={t('queue.close')}
>
@@ -53,10 +55,10 @@ export function QueuePanel() {
/>
{isRadio ? (
- Radio · {sourceLabel}
+ {t('queue.radio', { source: sourceLabel })}
) : (
-
From {sourceLabel}
+
{t('queue.from', { source: sourceLabel })}
)}
@@ -68,7 +70,7 @@ export function QueuePanel() {
className="msk-label"
style={{ display: 'block', marginBottom: 8 }}
>
- Now playing
+ {t('queue.nowPlaying')}
-
- Radio active
+
+ {t('queue.radioActive')}
- ∞ mixing
+ {t('queue.mixing')}
- {/* exploration balance — stub under the future ML contract */}
- Familiar
+ {t('queue.familiar')}
- New
+ {t('queue.new')}
)}
@@ -119,17 +114,17 @@ export function QueuePanel() {
className="msk-label"
style={{ display: 'block', margin: '4px 0 8px' }}
>
- Next up
+ {t('queue.nextUp')}
{upNext.length === 0 ? (
-
Nothing queued next
+
{t('queue.nothingNext')}
) : (
upNext.map(({ entry, index }) => (
dispatch(goToIndex(index))}
- title="Double-click to play"
+ title={t('queue.doubleClickPlay')}
>
@@ -147,7 +142,7 @@ export function QueuePanel() {
type="button"
className="iconbtn sm"
onClick={() => dispatch(removeFromQueue(index))}
- title="Remove from queue"
+ title={t('queue.removeFromQueue')}
>
@@ -156,11 +151,11 @@ export function QueuePanel() {
)}
{isRadio && (
- Loading more from radio…
+ {t('queue.loadingMore')}
)}
>
) : (
- Queue is empty
+ {t('queue.empty')}
)}
diff --git a/src/components/track/TrackContextMenu.tsx b/src/components/track/TrackContextMenu.tsx
index 953894d..41cd6f4 100644
--- a/src/components/track/TrackContextMenu.tsx
+++ b/src/components/track/TrackContextMenu.tsx
@@ -6,6 +6,7 @@ import {
MenuSeparator,
IconButton,
} from '@olly/modern-sk';
+import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { addToQueue, addNextInQueue } from '../../store/slices/queue';
import { play } from '../../store/slices/player';
@@ -26,6 +27,7 @@ export function TrackContextMenu({
onDelete,
onDownload,
}: Props) {
+ const { t } = useTranslation();
const dispatch = useAppDispatch();
const entry = {
@@ -40,49 +42,41 @@ export function TrackContextMenu({
return (
diff --git a/src/features/admin/AdminPage.tsx b/src/features/admin/AdminPage.tsx
index 2a3ec56..1b894ac 100644
--- a/src/features/admin/AdminPage.tsx
+++ b/src/features/admin/AdminPage.tsx
@@ -1,9 +1,12 @@
+import { useTranslation } from 'react-i18next';
import { Window } from '@olly/modern-sk';
+
export function AdminPage() {
+ const { t } = useTranslation();
return (
-
- Coming soon
+
+ {t('common.comingSoon')}
);
diff --git a/src/features/album-detail/AlbumDetailPage.tsx b/src/features/album-detail/AlbumDetailPage.tsx
index 95e214c..455121a 100644
--- a/src/features/album-detail/AlbumDetailPage.tsx
+++ b/src/features/album-detail/AlbumDetailPage.tsx
@@ -1,4 +1,5 @@
import { useParams, useNavigate } from 'react-router';
+import { useTranslation } from 'react-i18next';
import { ScrollArea, IconButton, Button } from '@olly/modern-sk';
import {
useGetAlbumQuery,
@@ -14,6 +15,7 @@ import { formatDuration } from '../../lib/format';
import { getCoverUrl } from '../../api/endpoints/streaming';
export function AlbumDetailPage() {
+ const { t } = useTranslation();
const { albumId } = useParams<{ albumId: string }>();
const navigate = useNavigate();
const dispatch = useAppDispatch();
@@ -32,7 +34,7 @@ export function AlbumDetailPage() {
if (albumQuery.isError) {
return (
albumQuery.refetch()}
/>
);
@@ -63,7 +65,6 @@ export function AlbumDetailPage() {
return (
- {/* header */}
navigate(-1)}
- aria-label="Back"
+ aria-label={t('common.back')}
>
←
@@ -125,7 +126,7 @@ export function AlbumDetailPage() {
letterSpacing: '0.05em',
}}
>
- Album
+ {t('album.type')}
@@ -155,16 +156,15 @@ export function AlbumDetailPage() {
onClick={handlePlayAll}
disabled={!tracks.length}
>
- ▶ Play
+ {t('album.play')}
- {/* tracks */}
{tracksQuery.isLoading && }
{tracksQuery.isError && (
tracksQuery.refetch()}
/>
)}
@@ -173,8 +173,8 @@ export function AlbumDetailPage() {
tracks.length === 0 && (
)}
{tracks.map((track, i) => (
diff --git a/src/features/connect/ConnectPage.tsx b/src/features/connect/ConnectPage.tsx
index 9dda15b..6b78e25 100644
--- a/src/features/connect/ConnectPage.tsx
+++ b/src/features/connect/ConnectPage.tsx
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
+import { useTranslation } from 'react-i18next';
import { Card, TextField, Button, Callout, Badge } from '@olly/modern-sk';
import { Icon } from '../../components/common/Icon';
import { useAppDispatch } from '../../hooks/useAppDispatch';
@@ -14,10 +15,10 @@ import {
import type { User } from '../../api/types';
export function ConnectPage() {
+ const { t } = useTranslation();
const dispatch = useAppDispatch();
const navigate = useNavigate();
- // Re-read on each render trigger; instance ops below force a remount via state.
const [rev, setRev] = useState(0);
const instances = listInstances();
const activeId = getActiveInstanceId();
@@ -26,8 +27,6 @@ export function ConnectPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
- // Switching to a saved backend reloads the app so every slice re-initialises
- // from that instance's namespaced storage (its own session, prefs, cache).
const switchTo = (id: string) => {
setActiveInstanceId(id);
window.location.assign('/');
@@ -38,11 +37,9 @@ export function ConnectPage() {
setRev((r) => r + 1);
};
- // STUB: no backend yet. Register the instance, then fake a session so the rest
- // of the app is reachable. Replace with the real useLoginMutation() flow later.
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
- setApiBaseUrl(apiUrl); // upsert + activate this backend
+ setApiBaseUrl(apiUrl);
const fakeUser: User = {
id: 'dev-user',
@@ -114,7 +111,7 @@ export function ConnectPage() {
}}
>
- Saved instances
+ {t('connect.savedInstances')}
{instances.map((inst) => (
{inst.id === activeId ? (
- active
+ {t('connect.active')}
) : (
)}
@@ -199,9 +196,9 @@ export function ConnectPage() {
padding: '1.5rem',
}}
>
- Connect to a backend
+ {t('connect.form.title')}
-
+
setApiUrl(e.target.value)}
@@ -210,7 +207,7 @@ export function ConnectPage() {
/>
-
+
setUsername(e.target.value)}
@@ -220,7 +217,7 @@ export function ConnectPage() {
/>
-
+
- Stub mode — backend not wired. Connect signs in with a fake admin
- session, scoped to this instance.
+ {t('connect.form.stubNote')}
diff --git a/src/features/downloads-manager/DownloadsManagerPage.tsx b/src/features/downloads-manager/DownloadsManagerPage.tsx
index 2d0f615..35100bb 100644
--- a/src/features/downloads-manager/DownloadsManagerPage.tsx
+++ b/src/features/downloads-manager/DownloadsManagerPage.tsx
@@ -1,9 +1,12 @@
+import { useTranslation } from 'react-i18next';
import { Window } from '@olly/modern-sk';
+
export function DownloadsManagerPage() {
+ const { t } = useTranslation();
return (
-
- Coming soon
+
+ {t('common.comingSoon')}
);
diff --git a/src/features/library/LibraryPage.tsx b/src/features/library/LibraryPage.tsx
index bb5e9c8..b4a21ca 100644
--- a/src/features/library/LibraryPage.tsx
+++ b/src/features/library/LibraryPage.tsx
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
+import { useTranslation } from 'react-i18next';
import {
Tabs,
TabsList,
@@ -24,6 +25,7 @@ import { getCoverUrl } from '../../api/endpoints/streaming';
import { formatDuration } from '../../lib/format';
export function LibraryPage() {
+ const { t } = useTranslation();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const [tab, setTab] = useState('tracks');
@@ -45,7 +47,7 @@ export function LibraryPage() {
albumArtUrl: t.albumArtUrl,
})),
source: 'manual',
- sourceName: 'Library',
+ sourceName: t('library.title'),
}),
);
};
@@ -63,13 +65,13 @@ export function LibraryPage() {
}}
>
- Library
+ {t('library.title')}
setSearch(e.target.value)}
- placeholder="Search library…"
+ placeholder={t('library.searchPlaceholder')}
icon="⌕"
/>
@@ -94,9 +96,9 @@ export function LibraryPage() {
>
@@ -110,8 +112,8 @@ export function LibraryPage() {
{tracksQuery.data && tracksQuery.data.items.length === 0 && (
)}
{tracksQuery.data &&
@@ -140,7 +142,7 @@ export function LibraryPage() {
fontWeight: 500,
}}
>
- ▶ Play all ({data.total})
+ {t('library.playAll', { count: data.total })}
{data.items.map((track, i) => (
@@ -166,8 +168,8 @@ export function LibraryPage() {
{albumsQuery.data && albumsQuery.data.items.length === 0 && (
)}
{albumsQuery.data && (
@@ -200,8 +202,8 @@ export function LibraryPage() {
{artistsQuery.data && artistsQuery.data.items.length === 0 && (
)}
{artistsQuery.data && (
@@ -219,6 +221,7 @@ export function LibraryPage() {
}
function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
+ const { t } = useTranslation();
const artUrl = getCoverUrl(album.artUrl);
return (
void }) {
{album.artistName}
- {album.trackCount} tracks · {formatDuration(album.totalDurationMs)}
+ {t('library.albumCard.tracksDuration', {
+ count: album.trackCount,
+ duration: formatDuration(album.totalDurationMs),
+ })}
@@ -290,6 +296,7 @@ function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
}
function ArtistRow({ artist }: { artist: Artist }) {
+ const { t } = useTranslation();
return (
- {artist.albumCount} albums · {artist.trackCount} tracks
+ {t('library.artistRow.meta', {
+ albumCount: artist.albumCount,
+ trackCount: artist.trackCount,
+ })}
diff --git a/src/features/playlist-detail/PlaylistDetailPage.tsx b/src/features/playlist-detail/PlaylistDetailPage.tsx
index 93b0a48..fe7f700 100644
--- a/src/features/playlist-detail/PlaylistDetailPage.tsx
+++ b/src/features/playlist-detail/PlaylistDetailPage.tsx
@@ -1,4 +1,5 @@
import { useParams, useNavigate } from 'react-router';
+import { useTranslation } from 'react-i18next';
import { ScrollArea, IconButton, Button } from '@olly/modern-sk';
import {
useGetPlaylistQuery,
@@ -13,6 +14,7 @@ import { setQueue } from '../../store/slices/queue';
import { formatDuration } from '../../lib/format';
export function PlaylistDetailPage() {
+ const { t } = useTranslation();
const { playlistId } = useParams<{ playlistId: string }>();
const navigate = useNavigate();
const dispatch = useAppDispatch();
@@ -35,7 +37,7 @@ export function PlaylistDetailPage() {
if (playlistQuery.isError) {
return (
playlistQuery.refetch()}
/>
);
@@ -79,7 +81,7 @@ export function PlaylistDetailPage() {
variant="ghost"
size="sm"
onClick={() => navigate(-1)}
- aria-label="Back"
+ aria-label={t('common.back')}
>
←
@@ -93,7 +95,7 @@ export function PlaylistDetailPage() {
letterSpacing: '0.05em',
}}
>
- Playlist
+ {t('playlist.type')}
{playlist &&
- `${playlist.trackCount} tracks · ${formatDuration(playlist.totalDurationMs)}`}
+ `${playlist.trackCount} · ${formatDuration(playlist.totalDurationMs)}`}
@@ -135,7 +137,7 @@ export function PlaylistDetailPage() {
{tracksQuery.isLoading && }
{tracksQuery.isError && (
tracksQuery.refetch()}
/>
)}
@@ -144,8 +146,8 @@ export function PlaylistDetailPage() {
tracks.length === 0 && (
)}
{tracks.map((track, i) => (
diff --git a/src/features/search-download/SearchDownloadPage.tsx b/src/features/search-download/SearchDownloadPage.tsx
index 706a7f5..e965665 100644
--- a/src/features/search-download/SearchDownloadPage.tsx
+++ b/src/features/search-download/SearchDownloadPage.tsx
@@ -1,9 +1,12 @@
+import { useTranslation } from 'react-i18next';
import { Window } from '@olly/modern-sk';
+
export function SearchDownloadPage() {
+ const { t } = useTranslation();
return (
-
- Coming soon
+
+ {t('common.comingSoon')}
);
diff --git a/src/features/settings/SettingsPage.tsx b/src/features/settings/SettingsPage.tsx
index dc4e17d..55ead8a 100644
--- a/src/features/settings/SettingsPage.tsx
+++ b/src/features/settings/SettingsPage.tsx
@@ -1,9 +1,25 @@
-import { Window } from '@olly/modern-sk';
+import { useTranslation } from 'react-i18next';
+import { Window, SegmentedControl } from '@olly/modern-sk';
+import { SUPPORTED_LANGUAGES, setLanguage } from '../../i18n';
+
export function SettingsPage() {
+ const { t, i18n } = useTranslation();
+
return (
-
-
- Coming soon
+
+
+
+
+
+ Language
+
+ ({ value: l.code, label: l.label }))}
+ />
+
+
);
diff --git a/src/features/storage/StoragePage.tsx b/src/features/storage/StoragePage.tsx
index 58b30f7..1d37aaf 100644
--- a/src/features/storage/StoragePage.tsx
+++ b/src/features/storage/StoragePage.tsx
@@ -1,9 +1,12 @@
+import { useTranslation } from 'react-i18next';
import { Window } from '@olly/modern-sk';
+
export function StoragePage() {
+ const { t } = useTranslation();
return (
-
- Coming soon
+
+ {t('common.comingSoon')}
);
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
new file mode 100644
index 0000000..15909e0
--- /dev/null
+++ b/src/i18n/index.ts
@@ -0,0 +1,37 @@
+import i18n from 'i18next';
+import { initReactI18next } from 'react-i18next';
+import en from './locales/en';
+import ru from './locales/ru';
+
+const STORAGE_KEY = 'mcma_lang';
+
+function detectLanguage(): string {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored) return stored;
+ const browser = navigator.language.split('-')[0];
+ return browser === 'ru' ? 'ru' : 'en';
+}
+
+export function setLanguage(lang: string): void {
+ localStorage.setItem(STORAGE_KEY, lang);
+ void i18n.changeLanguage(lang);
+}
+
+export const SUPPORTED_LANGUAGES = [
+ { code: 'en', label: 'English' },
+ { code: 'ru', label: 'Русский' },
+] as const;
+
+void i18n.use(initReactI18next).init({
+ resources: {
+ en: { translation: en },
+ ru: { translation: ru },
+ },
+ lng: detectLanguage(),
+ fallbackLng: 'en',
+ interpolation: {
+ escapeValue: false,
+ },
+});
+
+export default i18n;
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
new file mode 100644
index 0000000..4b70cae
--- /dev/null
+++ b/src/i18n/locales/en.ts
@@ -0,0 +1,156 @@
+const en = {
+ nav: {
+ home: 'Home',
+ library: 'Library',
+ search: 'Search & download',
+ downloads: 'Downloads',
+ storage: 'Storage',
+ playlists: 'Playlists',
+ newPlaylist: 'New playlist',
+ admin: 'Admin',
+ settings: 'Settings',
+ administration: 'Administration',
+ },
+ conn: {
+ connected: 'Connected',
+ connecting: 'Connecting…',
+ disconnected: 'Offline',
+ error: 'Unreachable',
+ manage: 'Connection — manage instances',
+ },
+ user: {
+ online: 'online',
+ offline: 'offline',
+ signOut: 'Sign out',
+ },
+ connect: {
+ savedInstances: 'Saved instances',
+ active: 'active',
+ use: 'Use',
+ forgetTitle: 'Forget this instance',
+ form: {
+ title: 'Connect to a backend',
+ serverUrl: 'Server URL',
+ username: 'Username',
+ password: 'Password',
+ submit: 'Connect',
+ stubNote:
+ 'Stub mode — backend not wired. Connect signs in with a fake admin session, scoped to this instance.',
+ },
+ },
+ library: {
+ title: 'Library',
+ searchPlaceholder: 'Search library…',
+ tabs: {
+ tracks: 'Tracks',
+ albums: 'Albums',
+ artists: 'Artists',
+ },
+ playAll: '▶ Play all ({{count}})',
+ empty: {
+ tracks: {
+ title: 'No tracks',
+ description: 'Your library is empty. Start by downloading some music.',
+ },
+ albums: {
+ title: 'No albums',
+ description: 'No albums in library.',
+ },
+ artists: {
+ title: 'No artists',
+ description: 'No artists in library.',
+ },
+ },
+ albumCard: {
+ tracks: '{{count}} tracks',
+ tracksDuration: '{{count}} tracks · {{duration}}',
+ },
+ artistRow: {
+ meta: '{{albumCount}} albums · {{trackCount}} tracks',
+ },
+ },
+ album: {
+ type: 'Album',
+ play: '▶ Play',
+ error: 'Failed to load album',
+ tracksError: 'Failed to load tracks',
+ empty: {
+ title: 'No tracks',
+ description: 'This album has no tracks.',
+ },
+ },
+ playlist: {
+ type: 'Playlist',
+ play: '▶ Play',
+ error: 'Failed to load playlist',
+ tracksError: 'Failed to load tracks',
+ empty: {
+ title: 'Empty playlist',
+ description: 'This playlist has no tracks yet.',
+ },
+ },
+ player: {
+ nothingPlaying: 'Nothing playing',
+ shuffle: 'Shuffle',
+ previous: 'Previous',
+ next: 'Next',
+ pause: 'Pause',
+ play: 'Play',
+ repeat: 'Repeat: {{mode}}',
+ streaming: 'Streaming · 320 kbps',
+ local: 'Local · FLAC',
+ queue: 'Play queue',
+ mute: 'Mute',
+ unmute: 'Unmute',
+ },
+ queue: {
+ title: 'Play queue',
+ clear: 'Clear queue',
+ close: 'Close',
+ from: 'From {{source}}',
+ radio: 'Radio · {{source}}',
+ nowPlaying: 'Now playing',
+ nextUp: 'Next up',
+ nothingNext: 'Nothing queued next',
+ empty: 'Queue is empty',
+ radioActive: 'Radio active',
+ mixing: '∞ mixing',
+ familiar: 'Familiar',
+ new: 'New',
+ loadingMore: 'Loading more from radio…',
+ doubleClickPlay: 'Double-click to play',
+ removeFromQueue: 'Remove from queue',
+ },
+ track: {
+ menu: {
+ options: 'Track options',
+ playNow: 'Play now',
+ playNext: 'Play next',
+ addToQueue: 'Add to queue',
+ addToPlaylist: 'Add to playlist…',
+ editMetadata: 'Edit metadata',
+ download: 'Download',
+ delete: 'Delete',
+ },
+ },
+ common: {
+ error: 'Something went wrong',
+ retry: 'Retry',
+ comingSoon: 'Coming soon',
+ back: 'Back',
+ },
+ pages: {
+ admin: 'Admin',
+ settings: 'Settings',
+ downloads: 'Downloads',
+ search: 'Search & Download',
+ storage: 'Storage',
+ },
+} as const;
+
+export default en;
+
+type DeepString = {
+ [K in keyof T]: T[K] extends Record ? DeepString : string;
+};
+export type Translations = DeepString;
diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts
new file mode 100644
index 0000000..e9e640d
--- /dev/null
+++ b/src/i18n/locales/ru.ts
@@ -0,0 +1,153 @@
+import type { Translations } from './en';
+
+const ru: Translations = {
+ nav: {
+ home: 'Главная',
+ library: 'Библиотека',
+ search: 'Поиск и загрузка',
+ downloads: 'Загрузки',
+ storage: 'Хранилище',
+ playlists: 'Плейлисты',
+ newPlaylist: 'Новый плейлист',
+ admin: 'Администрирование',
+ settings: 'Настройки',
+ administration: 'Администрирование',
+ },
+ conn: {
+ connected: 'Подключено',
+ connecting: 'Подключение…',
+ disconnected: 'Нет связи',
+ error: 'Недоступно',
+ manage: 'Соединение — управление экземплярами',
+ },
+ user: {
+ online: 'онлайн',
+ offline: 'офлайн',
+ signOut: 'Выйти',
+ },
+ connect: {
+ savedInstances: 'Сохранённые серверы',
+ active: 'активный',
+ use: 'Выбрать',
+ forgetTitle: 'Забыть этот сервер',
+ form: {
+ title: 'Подключиться к серверу',
+ serverUrl: 'URL сервера',
+ username: 'Имя пользователя',
+ password: 'Пароль',
+ submit: 'Подключиться',
+ stubNote:
+ 'Режим заглушки — сервер не подключён. Создаётся фиктивная сессия администратора для этого экземпляра.',
+ },
+ },
+ library: {
+ title: 'Библиотека',
+ searchPlaceholder: 'Поиск в библиотеке…',
+ tabs: {
+ tracks: 'Треки',
+ albums: 'Альбомы',
+ artists: 'Исполнители',
+ },
+ playAll: '▶ Воспроизвести все ({{count}})',
+ empty: {
+ tracks: {
+ title: 'Нет треков',
+ description: 'Библиотека пуста. Начните с загрузки музыки.',
+ },
+ albums: {
+ title: 'Нет альбомов',
+ description: 'В библиотеке нет альбомов.',
+ },
+ artists: {
+ title: 'Нет исполнителей',
+ description: 'В библиотеке нет исполнителей.',
+ },
+ },
+ albumCard: {
+ tracks: '{{count}} треков',
+ tracksDuration: '{{count}} треков · {{duration}}',
+ },
+ artistRow: {
+ meta: '{{albumCount}} альб. · {{trackCount}} треков',
+ },
+ },
+ album: {
+ type: 'Альбом',
+ play: '▶ Слушать',
+ error: 'Не удалось загрузить альбом',
+ tracksError: 'Не удалось загрузить треки',
+ empty: {
+ title: 'Нет треков',
+ description: 'В этом альбоме нет треков.',
+ },
+ },
+ playlist: {
+ type: 'Плейлист',
+ play: '▶ Слушать',
+ error: 'Не удалось загрузить плейлист',
+ tracksError: 'Не удалось загрузить треки',
+ empty: {
+ title: 'Плейлист пуст',
+ description: 'В этом плейлисте пока нет треков.',
+ },
+ },
+ player: {
+ nothingPlaying: 'Ничего не играет',
+ shuffle: 'Перемешать',
+ previous: 'Назад',
+ next: 'Вперёд',
+ pause: 'Пауза',
+ play: 'Воспроизвести',
+ repeat: 'Повтор: {{mode}}',
+ streaming: 'Стриминг · 320 kbps',
+ local: 'Локально · FLAC',
+ queue: 'Очередь',
+ mute: 'Выключить звук',
+ unmute: 'Включить звук',
+ },
+ queue: {
+ title: 'Очередь воспроизведения',
+ clear: 'Очистить очередь',
+ close: 'Закрыть',
+ from: 'Из: {{source}}',
+ radio: 'Радио · {{source}}',
+ nowPlaying: 'Сейчас играет',
+ nextUp: 'Далее',
+ nothingNext: 'Очередь пуста',
+ empty: 'Очередь пуста',
+ radioActive: 'Радио активно',
+ mixing: '∞ микс',
+ familiar: 'Знакомое',
+ new: 'Новое',
+ loadingMore: 'Загрузка радио…',
+ doubleClickPlay: 'Двойной клик для воспроизведения',
+ removeFromQueue: 'Убрать из очереди',
+ },
+ track: {
+ menu: {
+ options: 'Действия с треком',
+ playNow: 'Играть сейчас',
+ playNext: 'Следующим',
+ addToQueue: 'Добавить в очередь',
+ addToPlaylist: 'Добавить в плейлист…',
+ editMetadata: 'Редактировать метаданные',
+ download: 'Скачать',
+ delete: 'Удалить',
+ },
+ },
+ common: {
+ error: 'Что-то пошло не так',
+ retry: 'Повторить',
+ comingSoon: 'Скоро',
+ back: 'Назад',
+ },
+ pages: {
+ admin: 'Администрирование',
+ settings: 'Настройки',
+ downloads: 'Загрузки',
+ search: 'Поиск и загрузка',
+ storage: 'Хранилище',
+ },
+};
+
+export default ru;
diff --git a/src/index.tsx b/src/index.tsx
index b2290dd..5de2225 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -2,6 +2,7 @@ import '@olly/modern-sk/styles.css';
import '@olly/modern-sk/fonts.css';
import './styles/global.css';
import './styles/shell.css';
+import './i18n';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';