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 ( - + - { - dispatch(play(track.id)); - }} - > - Play now + { dispatch(play(track.id)); }}> + {t('track.menu.playNow')} - { - dispatch(addNextInQueue(entry)); - }} - > - Play next + { dispatch(addNextInQueue(entry)); }}> + {t('track.menu.playNext')} - { - dispatch(addToQueue(entry)); - }} - > - Add to queue + { dispatch(addToQueue(entry)); }}> + {t('track.menu.addToQueue')} {onAddToPlaylist && ( onAddToPlaylist(track)}> - Add to playlist… + {t('track.menu.addToPlaylist')} )} {onEditMetadata && ( onEditMetadata(track)}> - Edit metadata + {t('track.menu.editMetadata')} )} {onDownload && ( - onDownload(track)}>Download + onDownload(track)}> + {t('track.menu.download')} + )} {onDelete && ( - onDelete(track)}>Delete + onDelete(track)}> + {t('track.menu.delete')} + )} 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';