feat: i18n

This commit is contained in:
Senko-san
2026-06-06 15:23:07 +03:00
parent bbd59cc225
commit e45bcef3a5
21 changed files with 613 additions and 163 deletions
+76 -2
View File
@@ -11,8 +11,10 @@
"@olly/modern-sk": "0.1.4-3", "@olly/modern-sk": "0.1.4-3",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@reduxjs/toolkit": "^2.12.0", "@reduxjs/toolkit": "^2.12.0",
"i18next": "^26.3.1",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-i18next": "^17.0.8",
"react-redux": "^9.3.0", "react-redux": "^9.3.0",
"react-router": "^7.16.0" "react-router": "^7.16.0"
}, },
@@ -496,7 +498,6 @@
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -3491,6 +3492,43 @@
"node": ">=20.0.0" "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": { "node_modules/immer": {
"version": "11.1.8", "version": "11.1.8",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
@@ -4069,6 +4107,33 @@
"react": "^19.2.7" "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": { "node_modules/react-is": {
"version": "17.0.2", "version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -4335,7 +4400,7 @@
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@@ -4435,6 +4500,15 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "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": { "node_modules/whatwg-mimetype": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
+3 -1
View File
@@ -13,11 +13,13 @@
"test:watch": "rstest --watch" "test:watch": "rstest --watch"
}, },
"dependencies": { "dependencies": {
"@olly/modern-sk": "0.1.4-3",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@reduxjs/toolkit": "^2.12.0", "@reduxjs/toolkit": "^2.12.0",
"@olly/modern-sk": "0.1.4-3", "i18next": "^26.3.1",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-i18next": "^17.0.8",
"react-redux": "^9.3.0", "react-redux": "^9.3.0",
"react-router": "^7.16.0" "react-router": "^7.16.0"
}, },
+10 -7
View File
@@ -1,12 +1,13 @@
import { Badge, Tooltip } from '@olly/modern-sk'; import { Badge, Tooltip } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import { useConnectionStatus } from '../../hooks/useConnectionStatus'; import { useConnectionStatus } from '../../hooks/useConnectionStatus';
import { getApiBaseUrl } from '../../config/runtime-config'; import { getApiBaseUrl } from '../../config/runtime-config';
const STATUS_LABELS = { const STATUS_KEY = {
connected: 'Connected', connected: 'conn.connected',
connecting: 'Connecting', connecting: 'conn.connecting',
disconnected: 'Disconnected', disconnected: 'conn.disconnected',
error: 'Connection error', error: 'conn.error',
} as const; } as const;
const STATUS_VARIANTS = { const STATUS_VARIANTS = {
@@ -17,13 +18,15 @@ const STATUS_VARIANTS = {
} as const; } as const;
export function ConnectionStatus() { export function ConnectionStatus() {
const { t } = useTranslation();
const status = useConnectionStatus(); const status = useConnectionStatus();
const baseUrl = getApiBaseUrl(); const baseUrl = getApiBaseUrl();
const label = t(STATUS_KEY[status]);
return ( return (
<Tooltip content={`${STATUS_LABELS[status]} · ${baseUrl}`}> <Tooltip content={`${label} · ${baseUrl}`}>
<Badge variant={STATUS_VARIANTS[status]} dot> <Badge variant={STATUS_VARIANTS[status]} dot>
{STATUS_LABELS[status]} {label}
</Badge> </Badge>
</Tooltip> </Tooltip>
); );
+5 -6
View File
@@ -1,18 +1,17 @@
import { Callout, Button } from '@olly/modern-sk'; import { Callout, Button } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
interface ErrorStateProps { interface ErrorStateProps {
message?: string; message?: string;
onRetry?: () => void; onRetry?: () => void;
} }
export function ErrorState({ export function ErrorState({ message, onRetry }: ErrorStateProps) {
message = 'Something went wrong', const { t } = useTranslation();
onRetry,
}: ErrorStateProps) {
return ( return (
<div style={{ padding: '2rem' }}> <div style={{ padding: '2rem' }}>
<Callout variant="danger"> <Callout variant="danger">
{message} {message ?? t('common.error')}
{onRetry && ( {onRetry && (
<Button <Button
variant="ghost" variant="ghost"
@@ -20,7 +19,7 @@ export function ErrorState({
onClick={onRetry} onClick={onRetry}
style={{ marginLeft: '1rem' }} style={{ marginLeft: '1rem' }}
> >
Retry {t('common.retry')}
</Button> </Button>
)} )}
</Callout> </Callout>
+26 -24
View File
@@ -1,4 +1,5 @@
import { NavLink, useNavigate } from 'react-router'; import { NavLink, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { Icon, type IconName } from '../common/Icon'; import { Icon, type IconName } from '../common/Icon';
import { useAppDispatch } from '../../hooks/useAppDispatch'; import { useAppDispatch } from '../../hooks/useAppDispatch';
import { usePermissions } from '../../hooks/usePermissions'; import { usePermissions } from '../../hooks/usePermissions';
@@ -9,24 +10,24 @@ import { getActiveInstance } from '../../config/instances';
interface NavDef { interface NavDef {
to: string; to: string;
label: string; labelKey: string;
icon: IconName; icon: IconName;
end?: boolean; end?: boolean;
} }
const MAIN_NAV: NavDef[] = [ const MAIN_NAV: NavDef[] = [
{ to: '/', label: 'Home', icon: 'house', end: true }, { to: '/', labelKey: 'nav.home', icon: 'house', end: true },
{ to: '/library', label: 'Library', icon: 'vinyl-record' }, { to: '/library', labelKey: 'nav.library', icon: 'vinyl-record' },
{ to: '/search', label: 'Search & download', icon: 'magnifying-glass' }, { to: '/search', labelKey: 'nav.search', icon: 'magnifying-glass' },
{ to: '/downloads', label: 'Downloads', icon: 'arrow-circle-down' }, { to: '/downloads', labelKey: 'nav.downloads', icon: 'arrow-circle-down' },
{ to: '/storage', label: 'Storage', icon: 'hard-drives' }, { to: '/storage', labelKey: 'nav.storage', icon: 'hard-drives' },
]; ];
const CONN_CLASS: Record<string, { cls: string; txt: string }> = { const CONN_KEY: Record<string, { cls: string; txtKey: string }> = {
connected: { cls: 'online', txt: 'Connected' }, connected: { cls: 'online', txtKey: 'conn.connected' },
connecting: { cls: 'syncing', txt: 'Connecting' }, connecting: { cls: 'syncing', txtKey: 'conn.connecting' },
disconnected: { cls: 'offline', txt: 'Offline' }, disconnected: { cls: 'offline', txtKey: 'conn.disconnected' },
error: { cls: 'error', txt: 'Unreachable' }, error: { cls: 'error', txtKey: 'conn.error' },
}; };
function navClass({ isActive }: { isActive: boolean }) { function navClass({ isActive }: { isActive: boolean }) {
@@ -34,6 +35,7 @@ function navClass({ isActive }: { isActive: boolean }) {
} }
export function Sidebar() { export function Sidebar() {
const { t } = useTranslation();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const navigate = useNavigate(); const navigate = useNavigate();
const { user, isAdmin } = usePermissions(); const { user, isAdmin } = usePermissions();
@@ -41,7 +43,7 @@ export function Sidebar() {
const { data: playlists } = useGetPlaylistsQuery(); const { data: playlists } = useGetPlaylistsQuery();
const instance = getActiveInstance(); const instance = getActiveInstance();
const conn = CONN_CLASS[status] ?? CONN_CLASS.connecting; const conn = CONN_KEY[status] ?? CONN_KEY.connecting;
const online = status === 'connected'; const online = status === 'connected';
const handleLogout = (e: React.MouseEvent) => { const handleLogout = (e: React.MouseEvent) => {
@@ -59,16 +61,16 @@ export function Sidebar() {
</div> </div>
<div className="sb-sec"> <div className="sb-sec">
{MAIN_NAV.map(({ to, label, icon, end }) => ( {MAIN_NAV.map(({ to, labelKey, icon, end }) => (
<NavLink key={to} to={to} end={end} className={navClass}> <NavLink key={to} to={to} end={end} className={navClass}>
<Icon name={icon} /> <Icon name={icon} />
<span>{label}</span> <span>{t(labelKey)}</span>
</NavLink> </NavLink>
))} ))}
</div> </div>
<div className="sb-sec"> <div className="sb-sec">
<span className="msk-label">Playlists</span> <span className="msk-label">{t('nav.playlists')}</span>
{(playlists?.items ?? []).map((pl) => ( {(playlists?.items ?? []).map((pl) => (
<NavLink <NavLink
key={pl.id} key={pl.id}
@@ -85,27 +87,27 @@ export function Sidebar() {
onClick={() => void navigate('/library')} onClick={() => void navigate('/library')}
> >
<Icon name="plus" /> <Icon name="plus" />
<span className="pl-name">New playlist</span> <span className="pl-name">{t('nav.newPlaylist')}</span>
</button> </button>
</div> </div>
{isAdmin ? ( {isAdmin ? (
<div className="sb-sec"> <div className="sb-sec">
<span className="msk-label">Administration</span> <span className="msk-label">{t('nav.administration')}</span>
<NavLink to="/admin" className={navClass}> <NavLink to="/admin" className={navClass}>
<Icon name="shield-check" /> <Icon name="shield-check" />
<span>Admin</span> <span>{t('nav.admin')}</span>
</NavLink> </NavLink>
<NavLink to="/settings" className={navClass}> <NavLink to="/settings" className={navClass}>
<Icon name="gear-six" /> <Icon name="gear-six" />
<span>Settings</span> <span>{t('nav.settings')}</span>
</NavLink> </NavLink>
</div> </div>
) : ( ) : (
<div className="sb-sec"> <div className="sb-sec">
<NavLink to="/settings" className={navClass}> <NavLink to="/settings" className={navClass}>
<Icon name="gear-six" /> <Icon name="gear-six" />
<span>Settings</span> <span>{t('nav.settings')}</span>
</NavLink> </NavLink>
</div> </div>
)} )}
@@ -116,10 +118,10 @@ export function Sidebar() {
type="button" type="button"
className={`conn ${conn.cls}`} className={`conn ${conn.cls}`}
onClick={() => void navigate('/connect')} onClick={() => void navigate('/connect')}
title="Connection — manage instances" title={t('conn.manage')}
> >
<span className="led" /> <span className="led" />
{conn.txt} {t(conn.txtKey)}
</button> </button>
{user && ( {user && (
<button <button
@@ -133,10 +135,10 @@ export function Sidebar() {
<div className="user-meta"> <div className="user-meta">
<div className="nm">{user.username}</div> <div className="nm">{user.username}</div>
<div className="rl"> <div className="rl">
{user.role} · {online ? 'online' : 'offline'} {user.role} · {online ? t('user.online') : t('user.offline')}
</div> </div>
</div> </div>
<span className="uc-action" onClick={handleLogout} title="Sign out"> <span className="uc-action" onClick={handleLogout} title={t('user.signOut')}>
<Icon name="sign-out" /> <Icon name="sign-out" />
</span> </span>
</button> </button>
+12 -14
View File
@@ -1,4 +1,5 @@
import { Slider } from '@olly/modern-sk'; import { Slider } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import { Icon } from '../common/Icon'; import { Icon } from '../common/Icon';
import { ArtTile } from '../common/ArtTile'; import { ArtTile } from '../common/ArtTile';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch'; import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
@@ -17,6 +18,7 @@ import { formatDuration } from '../../lib/format';
import { getCoverUrl } from '../../api/endpoints/streaming'; import { getCoverUrl } from '../../api/endpoints/streaming';
export function PersistentPlayer() { export function PersistentPlayer() {
const { t } = useTranslation();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { seek, playNext, playPrev } = useAudioPlayer(); const { seek, playNext, playPrev } = useAudioPlayer();
const player = useAppSelector((s) => s.player); const player = useAppSelector((s) => s.player);
@@ -24,17 +26,15 @@ export function PersistentPlayer() {
const currentEntry = queue.entries[queue.currentIndex]; const currentEntry = queue.entries[queue.currentIndex];
if (!currentEntry && !player.currentTrackId) { if (!currentEntry && !player.currentTrackId) {
return <div className="player empty">Nothing playing</div>; return <div className="player empty">{t('player.nothingPlaying')}</div>;
} }
const artUrl = getCoverUrl(currentEntry?.albumArtUrl); const artUrl = getCoverUrl(currentEntry?.albumArtUrl);
const seedLabel = currentEntry?.albumTitle ?? currentEntry?.title ?? ''; const seedLabel = currentEntry?.albumTitle ?? currentEntry?.title ?? '';
// Streaming is the web default; local playback is a mobile-client concern.
const onStream = true; const onStream = true;
return ( return (
<div className="player"> <div className="player">
{/* now-playing identity */}
<div <div
className="pl-now" className="pl-now"
onClick={() => dispatch(toggleNowPlaying())} onClick={() => dispatch(toggleNowPlaying())}
@@ -49,19 +49,18 @@ export function PersistentPlayer() {
style={{ color: onStream ? 'var(--fg-3)' : 'var(--lime)' }} style={{ color: onStream ? 'var(--fg-3)' : 'var(--lime)' }}
> >
<Icon name={onStream ? 'cloud' : 'check-circle'} fill={!onStream} /> <Icon name={onStream ? 'cloud' : 'check-circle'} fill={!onStream} />
{onStream ? 'Streaming · 320 kbps' : 'Local · FLAC'} {onStream ? t('player.streaming') : t('player.local')}
</div> </div>
</div> </div>
</div> </div>
{/* transport + scrubber */}
<div className="pl-center"> <div className="pl-center">
<div className="pl-transport"> <div className="pl-transport">
<button <button
type="button" type="button"
className={`pl-tbtn${player.shuffle ? ' on' : ''}`} className={`pl-tbtn${player.shuffle ? ' on' : ''}`}
onClick={() => dispatch(toggleShuffle())} onClick={() => dispatch(toggleShuffle())}
title="Shuffle" title={t('player.shuffle')}
> >
<Icon name="shuffle" /> <Icon name="shuffle" />
</button> </button>
@@ -69,7 +68,7 @@ export function PersistentPlayer() {
type="button" type="button"
className="pl-tbtn" className="pl-tbtn"
onClick={playPrev} onClick={playPrev}
title="Previous" title={t('player.previous')}
> >
<Icon name="skip-back" fill /> <Icon name="skip-back" fill />
</button> </button>
@@ -79,7 +78,7 @@ export function PersistentPlayer() {
onClick={() => onClick={() =>
player.isPlaying ? dispatch(pause()) : dispatch(resume()) player.isPlaying ? dispatch(pause()) : dispatch(resume())
} }
title={player.isPlaying ? 'Pause' : 'Play'} title={player.isPlaying ? t('player.pause') : t('player.play')}
> >
<Icon name={player.isPlaying ? 'pause' : 'play'} fill /> <Icon name={player.isPlaying ? 'pause' : 'play'} fill />
</button> </button>
@@ -87,7 +86,7 @@ export function PersistentPlayer() {
type="button" type="button"
className="pl-tbtn" className="pl-tbtn"
onClick={playNext} onClick={playNext}
title="Next" title={t('player.next')}
> >
<Icon name="skip-forward" fill /> <Icon name="skip-forward" fill />
</button> </button>
@@ -105,7 +104,7 @@ export function PersistentPlayer() {
), ),
) )
} }
title={`Repeat: ${player.repeat}`} title={t('player.repeat', { mode: player.repeat })}
> >
<Icon name="repeat" /> <Icon name="repeat" />
</button> </button>
@@ -121,7 +120,7 @@ export function PersistentPlayer() {
step={1} step={1}
value={[player.position]} value={[player.position]}
onValueChange={([v]) => seek(v)} onValueChange={([v]) => seek(v)}
aria-label="Seek" aria-label={t('player.play')}
/> />
<span className="pl-time"> <span className="pl-time">
{formatDuration(player.duration * 1000)} {formatDuration(player.duration * 1000)}
@@ -129,13 +128,12 @@ export function PersistentPlayer() {
</div> </div>
</div> </div>
{/* volume + queue */}
<div className="pl-right"> <div className="pl-right">
<button <button
type="button" type="button"
className="pl-tbtn" className="pl-tbtn"
onClick={() => dispatch(toggleMute())} onClick={() => dispatch(toggleMute())}
title={player.muted ? 'Unmute' : 'Mute'} title={player.muted ? t('player.unmute') : t('player.mute')}
> >
<Icon name={player.muted ? 'speaker-x' : 'speaker-high'} /> <Icon name={player.muted ? 'speaker-x' : 'speaker-high'} />
</button> </button>
@@ -154,7 +152,7 @@ export function PersistentPlayer() {
type="button" type="button"
className={`iconbtn sm${player.isQueueOpen ? ' on' : ''}`} className={`iconbtn sm${player.isQueueOpen ? ' on' : ''}`}
onClick={() => dispatch(toggleQueue())} onClick={() => dispatch(toggleQueue())}
title="Play queue" title={t('player.queue')}
> >
<Icon name="queue" /> <Icon name="queue" />
</button> </button>
+19 -24
View File
@@ -1,4 +1,5 @@
import { Slider, Badge } from '@olly/modern-sk'; import { Slider, Badge } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import { Icon } from '../common/Icon'; import { Icon } from '../common/Icon';
import { ArtTile } from '../common/ArtTile'; import { ArtTile } from '../common/ArtTile';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch'; import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
@@ -10,6 +11,7 @@ import {
import { toggleQueue } from '../../store/slices/player'; import { toggleQueue } from '../../store/slices/player';
export function QueuePanel() { export function QueuePanel() {
const { t } = useTranslation();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const queue = useAppSelector((s) => s.queue); const queue = useAppSelector((s) => s.queue);
const isOpen = useAppSelector((s) => s.player.isQueueOpen); const isOpen = useAppSelector((s) => s.player.isQueueOpen);
@@ -27,13 +29,13 @@ export function QueuePanel() {
<div className="qd-inner"> <div className="qd-inner">
<div className="qd-head"> <div className="qd-head">
<div className="row"> <div className="row">
<h3>Play queue</h3> <h3>{t('queue.title')}</h3>
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
<button <button
type="button" type="button"
className="iconbtn sm" className="iconbtn sm"
onClick={() => dispatch(clearQueue())} onClick={() => dispatch(clearQueue())}
title="Clear queue" title={t('queue.clear')}
> >
<Icon name="trash" /> <Icon name="trash" />
</button> </button>
@@ -41,7 +43,7 @@ export function QueuePanel() {
type="button" type="button"
className="iconbtn sm" className="iconbtn sm"
onClick={() => dispatch(toggleQueue())} onClick={() => dispatch(toggleQueue())}
title="Close" title={t('queue.close')}
> >
<Icon name="x" /> <Icon name="x" />
</button> </button>
@@ -53,10 +55,10 @@ export function QueuePanel() {
/> />
{isRadio ? ( {isRadio ? (
<span style={{ color: 'var(--lime)' }}> <span style={{ color: 'var(--lime)' }}>
Radio · {sourceLabel} {t('queue.radio', { source: sourceLabel })}
</span> </span>
) : ( ) : (
<span>From {sourceLabel}</span> <span>{t('queue.from', { source: sourceLabel })}</span>
)} )}
</div> </div>
</div> </div>
@@ -68,7 +70,7 @@ export function QueuePanel() {
className="msk-label" className="msk-label"
style={{ display: 'block', marginBottom: 8 }} style={{ display: 'block', marginBottom: 8 }}
> >
Now playing {t('queue.nowPlaying')}
</span> </span>
<div className="qd-now"> <div className="qd-now">
<ArtTile <ArtTile
@@ -87,21 +89,14 @@ export function QueuePanel() {
<div className="qd-radio"> <div className="qd-radio">
<div className="row"> <div className="row">
<Icon name="radio" /> <Icon name="radio" />
<span <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--fg-1)' }}>
style={{ {t('queue.radioActive')}
fontSize: 13,
fontWeight: 600,
color: 'var(--fg-1)',
}}
>
Radio active
</span> </span>
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
<Badge variant="neutral"> mixing</Badge> <Badge variant="neutral">{t('queue.mixing')}</Badge>
</div> </div>
{/* exploration balance — stub under the future ML contract */}
<div className="expl"> <div className="expl">
<span className="lab">Familiar</span> <span className="lab">{t('queue.familiar')}</span>
<Slider <Slider
className="expl-slider" className="expl-slider"
min={0} min={0}
@@ -110,7 +105,7 @@ export function QueuePanel() {
defaultValue={[42]} defaultValue={[42]}
aria-label="Exploration" aria-label="Exploration"
/> />
<span className="lab">New</span> <span className="lab">{t('queue.new')}</span>
</div> </div>
</div> </div>
)} )}
@@ -119,17 +114,17 @@ export function QueuePanel() {
className="msk-label" className="msk-label"
style={{ display: 'block', margin: '4px 0 8px' }} style={{ display: 'block', margin: '4px 0 8px' }}
> >
Next up {t('queue.nextUp')}
</span> </span>
{upNext.length === 0 ? ( {upNext.length === 0 ? (
<div className="qd-empty">Nothing queued next</div> <div className="qd-empty">{t('queue.nothingNext')}</div>
) : ( ) : (
upNext.map(({ entry, index }) => ( upNext.map(({ entry, index }) => (
<div <div
key={`${entry.trackId}-${index}`} key={`${entry.trackId}-${index}`}
className="qrow" className="qrow"
onDoubleClick={() => dispatch(goToIndex(index))} onDoubleClick={() => dispatch(goToIndex(index))}
title="Double-click to play" title={t('queue.doubleClickPlay')}
> >
<span className="grip"> <span className="grip">
<Icon name="dots-six-vertical" /> <Icon name="dots-six-vertical" />
@@ -147,7 +142,7 @@ export function QueuePanel() {
type="button" type="button"
className="iconbtn sm" className="iconbtn sm"
onClick={() => dispatch(removeFromQueue(index))} onClick={() => dispatch(removeFromQueue(index))}
title="Remove from queue" title={t('queue.removeFromQueue')}
> >
<Icon name="x" /> <Icon name="x" />
</button> </button>
@@ -156,11 +151,11 @@ export function QueuePanel() {
)} )}
{isRadio && ( {isRadio && (
<div className="qd-loadmore">Loading more from radio</div> <div className="qd-loadmore">{t('queue.loadingMore')}</div>
)} )}
</> </>
) : ( ) : (
<div className="qd-empty">Queue is empty</div> <div className="qd-empty">{t('queue.empty')}</div>
)} )}
</div> </div>
</div> </div>
+17 -23
View File
@@ -6,6 +6,7 @@ import {
MenuSeparator, MenuSeparator,
IconButton, IconButton,
} from '@olly/modern-sk'; } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '../../hooks/useAppDispatch'; import { useAppDispatch } from '../../hooks/useAppDispatch';
import { addToQueue, addNextInQueue } from '../../store/slices/queue'; import { addToQueue, addNextInQueue } from '../../store/slices/queue';
import { play } from '../../store/slices/player'; import { play } from '../../store/slices/player';
@@ -26,6 +27,7 @@ export function TrackContextMenu({
onDelete, onDelete,
onDownload, onDownload,
}: Props) { }: Props) {
const { t } = useTranslation();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const entry = { const entry = {
@@ -40,49 +42,41 @@ export function TrackContextMenu({
return ( return (
<Menu> <Menu>
<MenuTrigger asChild> <MenuTrigger asChild>
<IconButton variant="ghost" size="sm" aria-label="Track options"> <IconButton variant="ghost" size="sm" aria-label={t('track.menu.options')}>
</IconButton> </IconButton>
</MenuTrigger> </MenuTrigger>
<MenuContent> <MenuContent>
<MenuItem <MenuItem onSelect={() => { dispatch(play(track.id)); }}>
onSelect={() => { {t('track.menu.playNow')}
dispatch(play(track.id));
}}
>
Play now
</MenuItem> </MenuItem>
<MenuItem <MenuItem onSelect={() => { dispatch(addNextInQueue(entry)); }}>
onSelect={() => { {t('track.menu.playNext')}
dispatch(addNextInQueue(entry));
}}
>
Play next
</MenuItem> </MenuItem>
<MenuItem <MenuItem onSelect={() => { dispatch(addToQueue(entry)); }}>
onSelect={() => { {t('track.menu.addToQueue')}
dispatch(addToQueue(entry));
}}
>
Add to queue
</MenuItem> </MenuItem>
<MenuSeparator /> <MenuSeparator />
{onAddToPlaylist && ( {onAddToPlaylist && (
<MenuItem onSelect={() => onAddToPlaylist(track)}> <MenuItem onSelect={() => onAddToPlaylist(track)}>
Add to playlist {t('track.menu.addToPlaylist')}
</MenuItem> </MenuItem>
)} )}
<MenuSeparator /> <MenuSeparator />
{onEditMetadata && ( {onEditMetadata && (
<MenuItem onSelect={() => onEditMetadata(track)}> <MenuItem onSelect={() => onEditMetadata(track)}>
Edit metadata {t('track.menu.editMetadata')}
</MenuItem> </MenuItem>
)} )}
{onDownload && ( {onDownload && (
<MenuItem onSelect={() => onDownload(track)}>Download</MenuItem> <MenuItem onSelect={() => onDownload(track)}>
{t('track.menu.download')}
</MenuItem>
)} )}
{onDelete && ( {onDelete && (
<MenuItem onSelect={() => onDelete(track)}>Delete</MenuItem> <MenuItem onSelect={() => onDelete(track)}>
{t('track.menu.delete')}
</MenuItem>
)} )}
</MenuContent> </MenuContent>
</Menu> </Menu>
+5 -2
View File
@@ -1,9 +1,12 @@
import { useTranslation } from 'react-i18next';
import { Window } from '@olly/modern-sk'; import { Window } from '@olly/modern-sk';
export function AdminPage() { export function AdminPage() {
const { t } = useTranslation();
return ( return (
<div style={{ padding: '1.5rem' }}> <div style={{ padding: '1.5rem' }}>
<Window title="Admin"> <Window title={t('pages.admin')}>
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p> <p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p>
</Window> </Window>
</div> </div>
); );
+10 -10
View File
@@ -1,4 +1,5 @@
import { useParams, useNavigate } from 'react-router'; import { useParams, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { ScrollArea, IconButton, Button } from '@olly/modern-sk'; import { ScrollArea, IconButton, Button } from '@olly/modern-sk';
import { import {
useGetAlbumQuery, useGetAlbumQuery,
@@ -14,6 +15,7 @@ import { formatDuration } from '../../lib/format';
import { getCoverUrl } from '../../api/endpoints/streaming'; import { getCoverUrl } from '../../api/endpoints/streaming';
export function AlbumDetailPage() { export function AlbumDetailPage() {
const { t } = useTranslation();
const { albumId } = useParams<{ albumId: string }>(); const { albumId } = useParams<{ albumId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -32,7 +34,7 @@ export function AlbumDetailPage() {
if (albumQuery.isError) { if (albumQuery.isError) {
return ( return (
<ErrorState <ErrorState
message="Failed to load album" message={t('album.error')}
onRetry={() => albumQuery.refetch()} onRetry={() => albumQuery.refetch()}
/> />
); );
@@ -63,7 +65,6 @@ export function AlbumDetailPage() {
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}> <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* header */}
<div <div
style={{ style={{
padding: '1.25rem 1.5rem', padding: '1.25rem 1.5rem',
@@ -78,7 +79,7 @@ export function AlbumDetailPage() {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
aria-label="Back" aria-label={t('common.back')}
> >
</IconButton> </IconButton>
@@ -125,7 +126,7 @@ export function AlbumDetailPage() {
letterSpacing: '0.05em', letterSpacing: '0.05em',
}} }}
> >
Album {t('album.type')}
</p> </p>
<h1 <h1
style={{ style={{
@@ -146,7 +147,7 @@ export function AlbumDetailPage() {
{album?.artistName} {album?.artistName}
{album?.year && ` · ${album.year}`} {album?.year && ` · ${album.year}`}
{album && {album &&
` · ${album.trackCount} tracks · ${formatDuration(album.totalDurationMs)}`} ` · ${album.trackCount} · ${formatDuration(album.totalDurationMs)}`}
</p> </p>
</div> </div>
</div> </div>
@@ -155,16 +156,15 @@ export function AlbumDetailPage() {
onClick={handlePlayAll} onClick={handlePlayAll}
disabled={!tracks.length} disabled={!tracks.length}
> >
Play {t('album.play')}
</Button> </Button>
</div> </div>
{/* tracks */}
<ScrollArea style={{ flex: 1 }}> <ScrollArea style={{ flex: 1 }}>
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />} {tracksQuery.isLoading && <LoadingSkeleton rows={10} />}
{tracksQuery.isError && ( {tracksQuery.isError && (
<ErrorState <ErrorState
message="Failed to load tracks" message={t('album.tracksError')}
onRetry={() => tracksQuery.refetch()} onRetry={() => tracksQuery.refetch()}
/> />
)} )}
@@ -173,8 +173,8 @@ export function AlbumDetailPage() {
tracks.length === 0 && ( tracks.length === 0 && (
<EmptyState <EmptyState
icon="♫" icon="♫"
title="No tracks" title={t('album.empty.title')}
description="This album has no tracks." description={t('album.empty.description')}
/> />
)} )}
{tracks.map((track, i) => ( {tracks.map((track, i) => (
+13 -17
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { Card, TextField, Button, Callout, Badge } from '@olly/modern-sk'; import { Card, TextField, Button, Callout, Badge } from '@olly/modern-sk';
import { Icon } from '../../components/common/Icon'; import { Icon } from '../../components/common/Icon';
import { useAppDispatch } from '../../hooks/useAppDispatch'; import { useAppDispatch } from '../../hooks/useAppDispatch';
@@ -14,10 +15,10 @@ import {
import type { User } from '../../api/types'; import type { User } from '../../api/types';
export function ConnectPage() { export function ConnectPage() {
const { t } = useTranslation();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const navigate = useNavigate(); const navigate = useNavigate();
// Re-read on each render trigger; instance ops below force a remount via state.
const [rev, setRev] = useState(0); const [rev, setRev] = useState(0);
const instances = listInstances(); const instances = listInstances();
const activeId = getActiveInstanceId(); const activeId = getActiveInstanceId();
@@ -26,8 +27,6 @@ export function ConnectPage() {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = 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) => { const switchTo = (id: string) => {
setActiveInstanceId(id); setActiveInstanceId(id);
window.location.assign('/'); window.location.assign('/');
@@ -38,11 +37,9 @@ export function ConnectPage() {
setRev((r) => r + 1); 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) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setApiBaseUrl(apiUrl); // upsert + activate this backend setApiBaseUrl(apiUrl);
const fakeUser: User = { const fakeUser: User = {
id: 'dev-user', id: 'dev-user',
@@ -114,7 +111,7 @@ export function ConnectPage() {
}} }}
> >
<span className="msk-label" style={{ marginBottom: '0.25rem' }}> <span className="msk-label" style={{ marginBottom: '0.25rem' }}>
Saved instances {t('connect.savedInstances')}
</span> </span>
{instances.map((inst) => ( {instances.map((inst) => (
<div <div
@@ -165,21 +162,21 @@ export function ConnectPage() {
</div> </div>
</div> </div>
{inst.id === activeId ? ( {inst.id === activeId ? (
<Badge variant="lime">active</Badge> <Badge variant="lime">{t('connect.active')}</Badge>
) : ( ) : (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => switchTo(inst.id)} onClick={() => switchTo(inst.id)}
> >
Use {t('connect.use')}
</Button> </Button>
)} )}
<button <button
type="button" type="button"
className="iconbtn sm" className="iconbtn sm"
onClick={() => forget(inst.id)} onClick={() => forget(inst.id)}
title="Forget this instance" title={t('connect.forgetTitle')}
> >
<Icon name="trash" /> <Icon name="trash" />
</button> </button>
@@ -199,9 +196,9 @@ export function ConnectPage() {
padding: '1.5rem', padding: '1.5rem',
}} }}
> >
<span className="msk-label">Connect to a backend</span> <span className="msk-label">{t('connect.form.title')}</span>
<div> <div>
<label style={labelStyle}>Server URL</label> <label style={labelStyle}>{t('connect.form.serverUrl')}</label>
<TextField <TextField
value={apiUrl} value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)} onChange={(e) => setApiUrl(e.target.value)}
@@ -210,7 +207,7 @@ export function ConnectPage() {
/> />
</div> </div>
<div> <div>
<label style={labelStyle}>Username</label> <label style={labelStyle}>{t('connect.form.username')}</label>
<TextField <TextField
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
@@ -220,7 +217,7 @@ export function ConnectPage() {
/> />
</div> </div>
<div> <div>
<label style={labelStyle}>Password</label> <label style={labelStyle}>{t('connect.form.password')}</label>
<TextField <TextField
type="password" type="password"
value={password} value={password}
@@ -231,15 +228,14 @@ export function ConnectPage() {
/> />
</div> </div>
<Callout variant="warning"> <Callout variant="warning">
Stub mode backend not wired. Connect signs in with a fake admin {t('connect.form.stubNote')}
session, scoped to this instance.
</Callout> </Callout>
<Button <Button
type="submit" type="submit"
variant="primary" variant="primary"
style={{ marginTop: '0.5rem' }} style={{ marginTop: '0.5rem' }}
> >
Connect {t('connect.form.submit')}
</Button> </Button>
</form> </form>
</Card> </Card>
@@ -1,9 +1,12 @@
import { useTranslation } from 'react-i18next';
import { Window } from '@olly/modern-sk'; import { Window } from '@olly/modern-sk';
export function DownloadsManagerPage() { export function DownloadsManagerPage() {
const { t } = useTranslation();
return ( return (
<div style={{ padding: '1.5rem' }}> <div style={{ padding: '1.5rem' }}>
<Window title="Downloads"> <Window title={t('pages.downloads')}>
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p> <p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p>
</Window> </Window>
</div> </div>
); );
+25 -15
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { import {
Tabs, Tabs,
TabsList, TabsList,
@@ -24,6 +25,7 @@ import { getCoverUrl } from '../../api/endpoints/streaming';
import { formatDuration } from '../../lib/format'; import { formatDuration } from '../../lib/format';
export function LibraryPage() { export function LibraryPage() {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [tab, setTab] = useState('tracks'); const [tab, setTab] = useState('tracks');
@@ -45,7 +47,7 @@ export function LibraryPage() {
albumArtUrl: t.albumArtUrl, albumArtUrl: t.albumArtUrl,
})), })),
source: 'manual', source: 'manual',
sourceName: 'Library', sourceName: t('library.title'),
}), }),
); );
}; };
@@ -63,13 +65,13 @@ export function LibraryPage() {
}} }}
> >
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}> <h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
Library {t('library.title')}
</h2> </h2>
<div style={{ flex: 1, maxWidth: '20rem' }}> <div style={{ flex: 1, maxWidth: '20rem' }}>
<SearchField <SearchField
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="Search library…" placeholder={t('library.searchPlaceholder')}
icon="⌕" icon="⌕"
/> />
</div> </div>
@@ -94,9 +96,9 @@ export function LibraryPage() {
> >
<TabsList <TabsList
items={[ items={[
{ value: 'tracks', label: 'Tracks' }, { value: 'tracks', label: t('library.tabs.tracks') },
{ value: 'albums', label: 'Albums' }, { value: 'albums', label: t('library.tabs.albums') },
{ value: 'artists', label: 'Artists' }, { value: 'artists', label: t('library.tabs.artists') },
]} ]}
/> />
</div> </div>
@@ -110,8 +112,8 @@ export function LibraryPage() {
{tracksQuery.data && tracksQuery.data.items.length === 0 && ( {tracksQuery.data && tracksQuery.data.items.length === 0 && (
<EmptyState <EmptyState
icon="♫" icon="♫"
title="No tracks" title={t('library.empty.tracks.title')}
description="Your library is empty. Start by downloading some music." description={t('library.empty.tracks.description')}
/> />
)} )}
{tracksQuery.data && {tracksQuery.data &&
@@ -140,7 +142,7 @@ export function LibraryPage() {
fontWeight: 500, fontWeight: 500,
}} }}
> >
Play all ({data.total}) {t('library.playAll', { count: data.total })}
</button> </button>
</div> </div>
{data.items.map((track, i) => ( {data.items.map((track, i) => (
@@ -166,8 +168,8 @@ export function LibraryPage() {
{albumsQuery.data && albumsQuery.data.items.length === 0 && ( {albumsQuery.data && albumsQuery.data.items.length === 0 && (
<EmptyState <EmptyState
icon="💿" icon="💿"
title="No albums" title={t('library.empty.albums.title')}
description="No albums in library." description={t('library.empty.albums.description')}
/> />
)} )}
{albumsQuery.data && ( {albumsQuery.data && (
@@ -200,8 +202,8 @@ export function LibraryPage() {
{artistsQuery.data && artistsQuery.data.items.length === 0 && ( {artistsQuery.data && artistsQuery.data.items.length === 0 && (
<EmptyState <EmptyState
icon="🎤" icon="🎤"
title="No artists" title={t('library.empty.artists.title')}
description="No artists in library." description={t('library.empty.artists.description')}
/> />
)} )}
{artistsQuery.data && ( {artistsQuery.data && (
@@ -219,6 +221,7 @@ export function LibraryPage() {
} }
function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) { function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
const { t } = useTranslation();
const artUrl = getCoverUrl(album.artUrl); const artUrl = getCoverUrl(album.artUrl);
return ( return (
<Card <Card
@@ -282,7 +285,10 @@ function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
{album.artistName} {album.artistName}
</div> </div>
<div style={{ fontSize: '0.6875rem', color: 'var(--color-text-3)' }}> <div style={{ fontSize: '0.6875rem', color: 'var(--color-text-3)' }}>
{album.trackCount} tracks · {formatDuration(album.totalDurationMs)} {t('library.albumCard.tracksDuration', {
count: album.trackCount,
duration: formatDuration(album.totalDurationMs),
})}
</div> </div>
</div> </div>
</Card> </Card>
@@ -290,6 +296,7 @@ function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
} }
function ArtistRow({ artist }: { artist: Artist }) { function ArtistRow({ artist }: { artist: Artist }) {
const { t } = useTranslation();
return ( return (
<div <div
style={{ style={{
@@ -319,7 +326,10 @@ function ArtistRow({ artist }: { artist: Artist }) {
{artist.name} {artist.name}
</div> </div>
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}> <div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
{artist.albumCount} albums · {artist.trackCount} tracks {t('library.artistRow.meta', {
albumCount: artist.albumCount,
trackCount: artist.trackCount,
})}
</div> </div>
</div> </div>
</div> </div>
@@ -1,4 +1,5 @@
import { useParams, useNavigate } from 'react-router'; import { useParams, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { ScrollArea, IconButton, Button } from '@olly/modern-sk'; import { ScrollArea, IconButton, Button } from '@olly/modern-sk';
import { import {
useGetPlaylistQuery, useGetPlaylistQuery,
@@ -13,6 +14,7 @@ import { setQueue } from '../../store/slices/queue';
import { formatDuration } from '../../lib/format'; import { formatDuration } from '../../lib/format';
export function PlaylistDetailPage() { export function PlaylistDetailPage() {
const { t } = useTranslation();
const { playlistId } = useParams<{ playlistId: string }>(); const { playlistId } = useParams<{ playlistId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -35,7 +37,7 @@ export function PlaylistDetailPage() {
if (playlistQuery.isError) { if (playlistQuery.isError) {
return ( return (
<ErrorState <ErrorState
message="Failed to load playlist" message={t('playlist.error')}
onRetry={() => playlistQuery.refetch()} onRetry={() => playlistQuery.refetch()}
/> />
); );
@@ -79,7 +81,7 @@ export function PlaylistDetailPage() {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
aria-label="Back" aria-label={t('common.back')}
> >
</IconButton> </IconButton>
@@ -93,7 +95,7 @@ export function PlaylistDetailPage() {
letterSpacing: '0.05em', letterSpacing: '0.05em',
}} }}
> >
Playlist {t('playlist.type')}
</p> </p>
<h1 <h1
style={{ margin: '0.25rem 0', fontSize: '1.5rem', fontWeight: 700 }} style={{ margin: '0.25rem 0', fontSize: '1.5rem', fontWeight: 700 }}
@@ -119,7 +121,7 @@ export function PlaylistDetailPage() {
}} }}
> >
{playlist && {playlist &&
`${playlist.trackCount} tracks · ${formatDuration(playlist.totalDurationMs)}`} `${playlist.trackCount} · ${formatDuration(playlist.totalDurationMs)}`}
</p> </p>
</div> </div>
<Button <Button
@@ -127,7 +129,7 @@ export function PlaylistDetailPage() {
onClick={handlePlayAll} onClick={handlePlayAll}
disabled={!tracks.length} disabled={!tracks.length}
> >
Play {t('playlist.play')}
</Button> </Button>
</div> </div>
@@ -135,7 +137,7 @@ export function PlaylistDetailPage() {
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />} {tracksQuery.isLoading && <LoadingSkeleton rows={10} />}
{tracksQuery.isError && ( {tracksQuery.isError && (
<ErrorState <ErrorState
message="Failed to load tracks" message={t('playlist.tracksError')}
onRetry={() => tracksQuery.refetch()} onRetry={() => tracksQuery.refetch()}
/> />
)} )}
@@ -144,8 +146,8 @@ export function PlaylistDetailPage() {
tracks.length === 0 && ( tracks.length === 0 && (
<EmptyState <EmptyState
icon="♫" icon="♫"
title="Empty playlist" title={t('playlist.empty.title')}
description="This playlist has no tracks yet." description={t('playlist.empty.description')}
/> />
)} )}
{tracks.map((track, i) => ( {tracks.map((track, i) => (
@@ -1,9 +1,12 @@
import { useTranslation } from 'react-i18next';
import { Window } from '@olly/modern-sk'; import { Window } from '@olly/modern-sk';
export function SearchDownloadPage() { export function SearchDownloadPage() {
const { t } = useTranslation();
return ( return (
<div style={{ padding: '1.5rem' }}> <div style={{ padding: '1.5rem' }}>
<Window title="Search & Download"> <Window title={t('pages.search')}>
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p> <p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p>
</Window> </Window>
</div> </div>
); );
+20 -4
View File
@@ -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() { export function SettingsPage() {
const { t, i18n } = useTranslation();
return ( return (
<div style={{ padding: '1.5rem' }}> <div style={{ padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<Window title="Settings"> <Window title={t('pages.settings')}>
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p> <div style={{ padding: '0.75rem 0', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<span style={{ fontSize: '0.875rem', color: 'var(--color-text-2)', minWidth: '6rem' }}>
Language
</span>
<SegmentedControl
value={i18n.language}
onValueChange={setLanguage}
items={SUPPORTED_LANGUAGES.map((l) => ({ value: l.code, label: l.label }))}
/>
</div>
</div>
</Window> </Window>
</div> </div>
); );
+5 -2
View File
@@ -1,9 +1,12 @@
import { useTranslation } from 'react-i18next';
import { Window } from '@olly/modern-sk'; import { Window } from '@olly/modern-sk';
export function StoragePage() { export function StoragePage() {
const { t } = useTranslation();
return ( return (
<div style={{ padding: '1.5rem' }}> <div style={{ padding: '1.5rem' }}>
<Window title="Storage"> <Window title={t('pages.storage')}>
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p> <p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p>
</Window> </Window>
</div> </div>
); );
+37
View File
@@ -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;
+156
View File
@@ -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<T> = {
[K in keyof T]: T[K] extends Record<string, unknown> ? DeepString<T[K]> : string;
};
export type Translations = DeepString<typeof en>;
+153
View File
@@ -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;
+1
View File
@@ -2,6 +2,7 @@ import '@olly/modern-sk/styles.css';
import '@olly/modern-sk/fonts.css'; import '@olly/modern-sk/fonts.css';
import './styles/global.css'; import './styles/global.css';
import './styles/shell.css'; import './styles/shell.css';
import './i18n';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';