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
+10 -7
View File
@@ -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 (
<Tooltip content={`${STATUS_LABELS[status]} · ${baseUrl}`}>
<Tooltip content={`${label} · ${baseUrl}`}>
<Badge variant={STATUS_VARIANTS[status]} dot>
{STATUS_LABELS[status]}
{label}
</Badge>
</Tooltip>
);
+5 -6
View File
@@ -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 (
<div style={{ padding: '2rem' }}>
<Callout variant="danger">
{message}
{message ?? t('common.error')}
{onRetry && (
<Button
variant="ghost"
@@ -20,7 +19,7 @@ export function ErrorState({
onClick={onRetry}
style={{ marginLeft: '1rem' }}
>
Retry
{t('common.retry')}
</Button>
)}
</Callout>
+26 -24
View File
@@ -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<string, { cls: string; txt: string }> = {
connected: { cls: 'online', txt: 'Connected' },
connecting: { cls: 'syncing', txt: 'Connecting' },
disconnected: { cls: 'offline', txt: 'Offline' },
error: { cls: 'error', txt: 'Unreachable' },
const CONN_KEY: Record<string, { cls: string; txtKey: string }> = {
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() {
</div>
<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}>
<Icon name={icon} />
<span>{label}</span>
<span>{t(labelKey)}</span>
</NavLink>
))}
</div>
<div className="sb-sec">
<span className="msk-label">Playlists</span>
<span className="msk-label">{t('nav.playlists')}</span>
{(playlists?.items ?? []).map((pl) => (
<NavLink
key={pl.id}
@@ -85,27 +87,27 @@ export function Sidebar() {
onClick={() => void navigate('/library')}
>
<Icon name="plus" />
<span className="pl-name">New playlist</span>
<span className="pl-name">{t('nav.newPlaylist')}</span>
</button>
</div>
{isAdmin ? (
<div className="sb-sec">
<span className="msk-label">Administration</span>
<span className="msk-label">{t('nav.administration')}</span>
<NavLink to="/admin" className={navClass}>
<Icon name="shield-check" />
<span>Admin</span>
<span>{t('nav.admin')}</span>
</NavLink>
<NavLink to="/settings" className={navClass}>
<Icon name="gear-six" />
<span>Settings</span>
<span>{t('nav.settings')}</span>
</NavLink>
</div>
) : (
<div className="sb-sec">
<NavLink to="/settings" className={navClass}>
<Icon name="gear-six" />
<span>Settings</span>
<span>{t('nav.settings')}</span>
</NavLink>
</div>
)}
@@ -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')}
>
<span className="led" />
{conn.txt}
{t(conn.txtKey)}
</button>
{user && (
<button
@@ -133,10 +135,10 @@ export function Sidebar() {
<div className="user-meta">
<div className="nm">{user.username}</div>
<div className="rl">
{user.role} · {online ? 'online' : 'offline'}
{user.role} · {online ? t('user.online') : t('user.offline')}
</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" />
</span>
</button>
+12 -14
View File
@@ -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 <div className="player empty">Nothing playing</div>;
return <div className="player empty">{t('player.nothingPlaying')}</div>;
}
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 (
<div className="player">
{/* now-playing identity */}
<div
className="pl-now"
onClick={() => dispatch(toggleNowPlaying())}
@@ -49,19 +49,18 @@ export function PersistentPlayer() {
style={{ color: onStream ? 'var(--fg-3)' : 'var(--lime)' }}
>
<Icon name={onStream ? 'cloud' : 'check-circle'} fill={!onStream} />
{onStream ? 'Streaming · 320 kbps' : 'Local · FLAC'}
{onStream ? t('player.streaming') : t('player.local')}
</div>
</div>
</div>
{/* transport + scrubber */}
<div className="pl-center">
<div className="pl-transport">
<button
type="button"
className={`pl-tbtn${player.shuffle ? ' on' : ''}`}
onClick={() => dispatch(toggleShuffle())}
title="Shuffle"
title={t('player.shuffle')}
>
<Icon name="shuffle" />
</button>
@@ -69,7 +68,7 @@ export function PersistentPlayer() {
type="button"
className="pl-tbtn"
onClick={playPrev}
title="Previous"
title={t('player.previous')}
>
<Icon name="skip-back" fill />
</button>
@@ -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')}
>
<Icon name={player.isPlaying ? 'pause' : 'play'} fill />
</button>
@@ -87,7 +86,7 @@ export function PersistentPlayer() {
type="button"
className="pl-tbtn"
onClick={playNext}
title="Next"
title={t('player.next')}
>
<Icon name="skip-forward" fill />
</button>
@@ -105,7 +104,7 @@ export function PersistentPlayer() {
),
)
}
title={`Repeat: ${player.repeat}`}
title={t('player.repeat', { mode: player.repeat })}
>
<Icon name="repeat" />
</button>
@@ -121,7 +120,7 @@ export function PersistentPlayer() {
step={1}
value={[player.position]}
onValueChange={([v]) => seek(v)}
aria-label="Seek"
aria-label={t('player.play')}
/>
<span className="pl-time">
{formatDuration(player.duration * 1000)}
@@ -129,13 +128,12 @@ export function PersistentPlayer() {
</div>
</div>
{/* volume + queue */}
<div className="pl-right">
<button
type="button"
className="pl-tbtn"
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'} />
</button>
@@ -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')}
>
<Icon name="queue" />
</button>
+19 -24
View File
@@ -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() {
<div className="qd-inner">
<div className="qd-head">
<div className="row">
<h3>Play queue</h3>
<h3>{t('queue.title')}</h3>
<div style={{ flex: 1 }} />
<button
type="button"
className="iconbtn sm"
onClick={() => dispatch(clearQueue())}
title="Clear queue"
title={t('queue.clear')}
>
<Icon name="trash" />
</button>
@@ -41,7 +43,7 @@ export function QueuePanel() {
type="button"
className="iconbtn sm"
onClick={() => dispatch(toggleQueue())}
title="Close"
title={t('queue.close')}
>
<Icon name="x" />
</button>
@@ -53,10 +55,10 @@ export function QueuePanel() {
/>
{isRadio ? (
<span style={{ color: 'var(--lime)' }}>
Radio · {sourceLabel}
{t('queue.radio', { source: sourceLabel })}
</span>
) : (
<span>From {sourceLabel}</span>
<span>{t('queue.from', { source: sourceLabel })}</span>
)}
</div>
</div>
@@ -68,7 +70,7 @@ export function QueuePanel() {
className="msk-label"
style={{ display: 'block', marginBottom: 8 }}
>
Now playing
{t('queue.nowPlaying')}
</span>
<div className="qd-now">
<ArtTile
@@ -87,21 +89,14 @@ export function QueuePanel() {
<div className="qd-radio">
<div className="row">
<Icon name="radio" />
<span
style={{
fontSize: 13,
fontWeight: 600,
color: 'var(--fg-1)',
}}
>
Radio active
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--fg-1)' }}>
{t('queue.radioActive')}
</span>
<div style={{ flex: 1 }} />
<Badge variant="neutral"> mixing</Badge>
<Badge variant="neutral">{t('queue.mixing')}</Badge>
</div>
{/* exploration balance — stub under the future ML contract */}
<div className="expl">
<span className="lab">Familiar</span>
<span className="lab">{t('queue.familiar')}</span>
<Slider
className="expl-slider"
min={0}
@@ -110,7 +105,7 @@ export function QueuePanel() {
defaultValue={[42]}
aria-label="Exploration"
/>
<span className="lab">New</span>
<span className="lab">{t('queue.new')}</span>
</div>
</div>
)}
@@ -119,17 +114,17 @@ export function QueuePanel() {
className="msk-label"
style={{ display: 'block', margin: '4px 0 8px' }}
>
Next up
{t('queue.nextUp')}
</span>
{upNext.length === 0 ? (
<div className="qd-empty">Nothing queued next</div>
<div className="qd-empty">{t('queue.nothingNext')}</div>
) : (
upNext.map(({ entry, index }) => (
<div
key={`${entry.trackId}-${index}`}
className="qrow"
onDoubleClick={() => dispatch(goToIndex(index))}
title="Double-click to play"
title={t('queue.doubleClickPlay')}
>
<span className="grip">
<Icon name="dots-six-vertical" />
@@ -147,7 +142,7 @@ export function QueuePanel() {
type="button"
className="iconbtn sm"
onClick={() => dispatch(removeFromQueue(index))}
title="Remove from queue"
title={t('queue.removeFromQueue')}
>
<Icon name="x" />
</button>
@@ -156,11 +151,11 @@ export function QueuePanel() {
)}
{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>
+17 -23
View File
@@ -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 (
<Menu>
<MenuTrigger asChild>
<IconButton variant="ghost" size="sm" aria-label="Track options">
<IconButton variant="ghost" size="sm" aria-label={t('track.menu.options')}>
</IconButton>
</MenuTrigger>
<MenuContent>
<MenuItem
onSelect={() => {
dispatch(play(track.id));
}}
>
Play now
<MenuItem onSelect={() => { dispatch(play(track.id)); }}>
{t('track.menu.playNow')}
</MenuItem>
<MenuItem
onSelect={() => {
dispatch(addNextInQueue(entry));
}}
>
Play next
<MenuItem onSelect={() => { dispatch(addNextInQueue(entry)); }}>
{t('track.menu.playNext')}
</MenuItem>
<MenuItem
onSelect={() => {
dispatch(addToQueue(entry));
}}
>
Add to queue
<MenuItem onSelect={() => { dispatch(addToQueue(entry)); }}>
{t('track.menu.addToQueue')}
</MenuItem>
<MenuSeparator />
{onAddToPlaylist && (
<MenuItem onSelect={() => onAddToPlaylist(track)}>
Add to playlist
{t('track.menu.addToPlaylist')}
</MenuItem>
)}
<MenuSeparator />
{onEditMetadata && (
<MenuItem onSelect={() => onEditMetadata(track)}>
Edit metadata
{t('track.menu.editMetadata')}
</MenuItem>
)}
{onDownload && (
<MenuItem onSelect={() => onDownload(track)}>Download</MenuItem>
<MenuItem onSelect={() => onDownload(track)}>
{t('track.menu.download')}
</MenuItem>
)}
{onDelete && (
<MenuItem onSelect={() => onDelete(track)}>Delete</MenuItem>
<MenuItem onSelect={() => onDelete(track)}>
{t('track.menu.delete')}
</MenuItem>
)}
</MenuContent>
</Menu>
+5 -2
View File
@@ -1,9 +1,12 @@
import { useTranslation } from 'react-i18next';
import { Window } from '@olly/modern-sk';
export function AdminPage() {
const { t } = useTranslation();
return (
<div style={{ padding: '1.5rem' }}>
<Window title="Admin">
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
<Window title={t('pages.admin')}>
<p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p>
</Window>
</div>
);
+10 -10
View File
@@ -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 (
<ErrorState
message="Failed to load album"
message={t('album.error')}
onRetry={() => albumQuery.refetch()}
/>
);
@@ -63,7 +65,6 @@ export function AlbumDetailPage() {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* header */}
<div
style={{
padding: '1.25rem 1.5rem',
@@ -78,7 +79,7 @@ export function AlbumDetailPage() {
variant="ghost"
size="sm"
onClick={() => navigate(-1)}
aria-label="Back"
aria-label={t('common.back')}
>
</IconButton>
@@ -125,7 +126,7 @@ export function AlbumDetailPage() {
letterSpacing: '0.05em',
}}
>
Album
{t('album.type')}
</p>
<h1
style={{
@@ -146,7 +147,7 @@ export function AlbumDetailPage() {
{album?.artistName}
{album?.year && ` · ${album.year}`}
{album &&
` · ${album.trackCount} tracks · ${formatDuration(album.totalDurationMs)}`}
` · ${album.trackCount} · ${formatDuration(album.totalDurationMs)}`}
</p>
</div>
</div>
@@ -155,16 +156,15 @@ export function AlbumDetailPage() {
onClick={handlePlayAll}
disabled={!tracks.length}
>
Play
{t('album.play')}
</Button>
</div>
{/* tracks */}
<ScrollArea style={{ flex: 1 }}>
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />}
{tracksQuery.isError && (
<ErrorState
message="Failed to load tracks"
message={t('album.tracksError')}
onRetry={() => tracksQuery.refetch()}
/>
)}
@@ -173,8 +173,8 @@ export function AlbumDetailPage() {
tracks.length === 0 && (
<EmptyState
icon="♫"
title="No tracks"
description="This album has no tracks."
title={t('album.empty.title')}
description={t('album.empty.description')}
/>
)}
{tracks.map((track, i) => (
+13 -17
View File
@@ -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() {
}}
>
<span className="msk-label" style={{ marginBottom: '0.25rem' }}>
Saved instances
{t('connect.savedInstances')}
</span>
{instances.map((inst) => (
<div
@@ -165,21 +162,21 @@ export function ConnectPage() {
</div>
</div>
{inst.id === activeId ? (
<Badge variant="lime">active</Badge>
<Badge variant="lime">{t('connect.active')}</Badge>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => switchTo(inst.id)}
>
Use
{t('connect.use')}
</Button>
)}
<button
type="button"
className="iconbtn sm"
onClick={() => forget(inst.id)}
title="Forget this instance"
title={t('connect.forgetTitle')}
>
<Icon name="trash" />
</button>
@@ -199,9 +196,9 @@ export function ConnectPage() {
padding: '1.5rem',
}}
>
<span className="msk-label">Connect to a backend</span>
<span className="msk-label">{t('connect.form.title')}</span>
<div>
<label style={labelStyle}>Server URL</label>
<label style={labelStyle}>{t('connect.form.serverUrl')}</label>
<TextField
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
@@ -210,7 +207,7 @@ export function ConnectPage() {
/>
</div>
<div>
<label style={labelStyle}>Username</label>
<label style={labelStyle}>{t('connect.form.username')}</label>
<TextField
value={username}
onChange={(e) => setUsername(e.target.value)}
@@ -220,7 +217,7 @@ export function ConnectPage() {
/>
</div>
<div>
<label style={labelStyle}>Password</label>
<label style={labelStyle}>{t('connect.form.password')}</label>
<TextField
type="password"
value={password}
@@ -231,15 +228,14 @@ export function ConnectPage() {
/>
</div>
<Callout variant="warning">
Stub mode backend not wired. Connect signs in with a fake admin
session, scoped to this instance.
{t('connect.form.stubNote')}
</Callout>
<Button
type="submit"
variant="primary"
style={{ marginTop: '0.5rem' }}
>
Connect
{t('connect.form.submit')}
</Button>
</form>
</Card>
@@ -1,9 +1,12 @@
import { useTranslation } from 'react-i18next';
import { Window } from '@olly/modern-sk';
export function DownloadsManagerPage() {
const { t } = useTranslation();
return (
<div style={{ padding: '1.5rem' }}>
<Window title="Downloads">
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
<Window title={t('pages.downloads')}>
<p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p>
</Window>
</div>
);
+25 -15
View File
@@ -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() {
}}
>
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
Library
{t('library.title')}
</h2>
<div style={{ flex: 1, maxWidth: '20rem' }}>
<SearchField
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search library…"
placeholder={t('library.searchPlaceholder')}
icon="⌕"
/>
</div>
@@ -94,9 +96,9 @@ export function LibraryPage() {
>
<TabsList
items={[
{ value: 'tracks', label: 'Tracks' },
{ value: 'albums', label: 'Albums' },
{ value: 'artists', label: 'Artists' },
{ value: 'tracks', label: t('library.tabs.tracks') },
{ value: 'albums', label: t('library.tabs.albums') },
{ value: 'artists', label: t('library.tabs.artists') },
]}
/>
</div>
@@ -110,8 +112,8 @@ export function LibraryPage() {
{tracksQuery.data && tracksQuery.data.items.length === 0 && (
<EmptyState
icon="♫"
title="No tracks"
description="Your library is empty. Start by downloading some music."
title={t('library.empty.tracks.title')}
description={t('library.empty.tracks.description')}
/>
)}
{tracksQuery.data &&
@@ -140,7 +142,7 @@ export function LibraryPage() {
fontWeight: 500,
}}
>
Play all ({data.total})
{t('library.playAll', { count: data.total })}
</button>
</div>
{data.items.map((track, i) => (
@@ -166,8 +168,8 @@ export function LibraryPage() {
{albumsQuery.data && albumsQuery.data.items.length === 0 && (
<EmptyState
icon="💿"
title="No albums"
description="No albums in library."
title={t('library.empty.albums.title')}
description={t('library.empty.albums.description')}
/>
)}
{albumsQuery.data && (
@@ -200,8 +202,8 @@ export function LibraryPage() {
{artistsQuery.data && artistsQuery.data.items.length === 0 && (
<EmptyState
icon="🎤"
title="No artists"
description="No artists in library."
title={t('library.empty.artists.title')}
description={t('library.empty.artists.description')}
/>
)}
{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 (
<Card
@@ -282,7 +285,10 @@ function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
{album.artistName}
</div>
<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>
</Card>
@@ -290,6 +296,7 @@ function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
}
function ArtistRow({ artist }: { artist: Artist }) {
const { t } = useTranslation();
return (
<div
style={{
@@ -319,7 +326,10 @@ function ArtistRow({ artist }: { artist: Artist }) {
{artist.name}
</div>
<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>
@@ -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 (
<ErrorState
message="Failed to load playlist"
message={t('playlist.error')}
onRetry={() => playlistQuery.refetch()}
/>
);
@@ -79,7 +81,7 @@ export function PlaylistDetailPage() {
variant="ghost"
size="sm"
onClick={() => navigate(-1)}
aria-label="Back"
aria-label={t('common.back')}
>
</IconButton>
@@ -93,7 +95,7 @@ export function PlaylistDetailPage() {
letterSpacing: '0.05em',
}}
>
Playlist
{t('playlist.type')}
</p>
<h1
style={{ margin: '0.25rem 0', fontSize: '1.5rem', fontWeight: 700 }}
@@ -119,7 +121,7 @@ export function PlaylistDetailPage() {
}}
>
{playlist &&
`${playlist.trackCount} tracks · ${formatDuration(playlist.totalDurationMs)}`}
`${playlist.trackCount} · ${formatDuration(playlist.totalDurationMs)}`}
</p>
</div>
<Button
@@ -127,7 +129,7 @@ export function PlaylistDetailPage() {
onClick={handlePlayAll}
disabled={!tracks.length}
>
Play
{t('playlist.play')}
</Button>
</div>
@@ -135,7 +137,7 @@ export function PlaylistDetailPage() {
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />}
{tracksQuery.isError && (
<ErrorState
message="Failed to load tracks"
message={t('playlist.tracksError')}
onRetry={() => tracksQuery.refetch()}
/>
)}
@@ -144,8 +146,8 @@ export function PlaylistDetailPage() {
tracks.length === 0 && (
<EmptyState
icon="♫"
title="Empty playlist"
description="This playlist has no tracks yet."
title={t('playlist.empty.title')}
description={t('playlist.empty.description')}
/>
)}
{tracks.map((track, i) => (
@@ -1,9 +1,12 @@
import { useTranslation } from 'react-i18next';
import { Window } from '@olly/modern-sk';
export function SearchDownloadPage() {
const { t } = useTranslation();
return (
<div style={{ padding: '1.5rem' }}>
<Window title="Search & Download">
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
<Window title={t('pages.search')}>
<p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p>
</Window>
</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() {
const { t, i18n } = useTranslation();
return (
<div style={{ padding: '1.5rem' }}>
<Window title="Settings">
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
<div style={{ padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<Window title={t('pages.settings')}>
<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>
</div>
);
+5 -2
View File
@@ -1,9 +1,12 @@
import { useTranslation } from 'react-i18next';
import { Window } from '@olly/modern-sk';
export function StoragePage() {
const { t } = useTranslation();
return (
<div style={{ padding: '1.5rem' }}>
<Window title="Storage">
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
<Window title={t('pages.storage')}>
<p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p>
</Window>
</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 './styles/global.css';
import './styles/shell.css';
import './i18n';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';