feat: i18n
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user