Compare commits
4 Commits
37c1a5944a
...
61dbb1abd2
| Author | SHA1 | Date | |
|---|---|---|---|
| 61dbb1abd2 | |||
| aed0572071 | |||
| e45bcef3a5 | |||
| bbd59cc225 |
Generated
+91
-53
@@ -8,11 +8,13 @@
|
||||
"name": "mcma-webui",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@olly/modern-sk": "0.1.4-3",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@reduxjs/toolkit": "^2.12.0",
|
||||
"modern-sk": "file:./modern-sk-0.1.2.tgz",
|
||||
"i18next": "^26.3.1",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-redux": "^9.3.0",
|
||||
"react-router": "^7.16.0"
|
||||
},
|
||||
@@ -496,7 +498,6 @@
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -691,6 +692,20 @@
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@olly/modern-sk": {
|
||||
"version": "0.1.4-3",
|
||||
"resolved": "https://git.ollyhearn.ru/api/packages/olly/npm/%40olly%2Fmodern-sk/-/0.1.4-3/modern-sk-0.1.4-3.tgz",
|
||||
"integrity": "sha512-h+d+Jd3DBr7d51V78p1Eb5rVrpN4PAskwQFnh2X4Dk7Q8oajiMVJuhZU1amx97bKHFDHgcOfhwc4cS8P4tFCmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"radix-ui": "^1.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@phosphor-icons/react": {
|
||||
"version": "2.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz",
|
||||
@@ -2464,9 +2479,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2481,9 +2493,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2498,9 +2507,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2515,9 +2521,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2818,9 +2821,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2838,9 +2838,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2858,9 +2855,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2878,9 +2872,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3501,6 +3492,43 @@
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
||||
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com/i18next"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "11.1.8",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
|
||||
@@ -3707,9 +3735,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3731,9 +3756,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3755,9 +3777,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3779,9 +3798,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3877,20 +3893,6 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/modern-sk": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "file:modern-sk-0.1.2.tgz",
|
||||
"integrity": "sha512-tKSxbtUxT0CLkGc8DK+SABlVmKsMqqQr61uvAJ8EDcrutzm+VD230hTRVzk9hp2oSo6nXeeMig7KS8v0Lz5mWw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"radix-ui": "^1.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -4105,6 +4107,33 @@
|
||||
"react": "^19.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
|
||||
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
@@ -4371,7 +4400,7 @@
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -4471,6 +4500,15 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||
|
||||
+3
-1
@@ -13,11 +13,13 @@
|
||||
"test:watch": "rstest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@olly/modern-sk": "0.1.4-3",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@reduxjs/toolkit": "^2.12.0",
|
||||
"modern-sk": "file:./modern-sk-0.1.2.tgz",
|
||||
"i18next": "^26.3.1",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-redux": "^9.3.0",
|
||||
"react-router": "^7.16.0"
|
||||
},
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { api } from '../index';
|
||||
import type { Track } from '../types';
|
||||
import type { UploadResponse } from '../types';
|
||||
|
||||
/**
|
||||
* Build the multipart body for `/upload`. The backend expects exactly one file
|
||||
* per request under the field name `file` (anything else → FastAPI 422).
|
||||
*/
|
||||
export function buildUploadFormData(file: File): FormData {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
return fd;
|
||||
}
|
||||
|
||||
export const uploadApi = api.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
uploadTrack: build.mutation<Track, FormData>({
|
||||
uploadTrack: build.mutation<UploadResponse, FormData>({
|
||||
query: (formData) => ({
|
||||
url: '/upload',
|
||||
method: 'POST',
|
||||
|
||||
@@ -72,6 +72,12 @@ export interface DownloadJob {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UploadResponse {
|
||||
track_id: string;
|
||||
title: string;
|
||||
already_exists: boolean;
|
||||
}
|
||||
|
||||
export interface StorageStats {
|
||||
totalBytes: number;
|
||||
usedBytes: number;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Badge, Tooltip } from 'modern-sk';
|
||||
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 'modern-sk';
|
||||
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>
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
GearSix,
|
||||
HardDrives,
|
||||
Heart,
|
||||
House,
|
||||
MagnifyingGlass,
|
||||
Pause,
|
||||
Play,
|
||||
@@ -48,7 +47,6 @@ import {
|
||||
|
||||
const ICONS = {
|
||||
'vinyl-record': VinylRecord,
|
||||
house: House,
|
||||
'magnifying-glass': MagnifyingGlass,
|
||||
'arrow-circle-down': ArrowCircleDown,
|
||||
'upload-simple': UploadSimple,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Window } from '@olly/modern-sk';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
/** Window title. Pass an already-translated string. */
|
||||
title: string;
|
||||
/** Optional sub-line under the title; defaults to the shared "coming soon" copy. */
|
||||
note?: string;
|
||||
/** Optional extra content rendered below the note (e.g. a sub-nav). */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scaffolding placeholder for screens that exist in the navigation map
|
||||
* (music-selfhost-routes.md) but have no functionality yet. Keeps every
|
||||
* stub visually consistent so filling one in later is mechanical.
|
||||
*/
|
||||
export function Placeholder({ title, note, children }: Props) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div style={{ padding: '1.5rem' }}>
|
||||
<Window title={title}>
|
||||
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
|
||||
{note ?? t('common.comingSoon')}
|
||||
</p>
|
||||
{children}
|
||||
</Window>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NavLink } from 'react-router';
|
||||
|
||||
export interface SubNavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
/** Match the path exactly (used for index/redirect targets). */
|
||||
end?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: SubNavItem[];
|
||||
}
|
||||
|
||||
function subNavClass({ isActive }: { isActive: boolean }) {
|
||||
return isActive ? 'sub-nav-item active' : 'sub-nav-item';
|
||||
}
|
||||
|
||||
/** Horizontal secondary navigation for screens with sub-sections (Settings, Admin). */
|
||||
export function SubNav({ items }: Props) {
|
||||
return (
|
||||
<nav className="sub-nav">
|
||||
{items.map((it) => (
|
||||
<NavLink key={it.to} to={it.to} end={it.end} className={subNavClass}>
|
||||
{it.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Outlet } from 'react-router';
|
||||
import { Suspense } from 'react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { PersistentPlayer } from '../player/PersistentPlayer';
|
||||
import { QueuePanel } from '../player/QueuePanel';
|
||||
import { LoadingSkeleton } from '../common/LoadingSkeleton';
|
||||
|
||||
export function AppShell() {
|
||||
return (
|
||||
@@ -17,7 +19,15 @@ export function AppShell() {
|
||||
<Sidebar />
|
||||
<main className="app-main">
|
||||
<div className="app-screen">
|
||||
<Outlet />
|
||||
<Suspense
|
||||
fallback={
|
||||
<div style={{ padding: '2rem' }}>
|
||||
<LoadingSkeleton />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</div>
|
||||
</main>
|
||||
<QueuePanel />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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';
|
||||
import { usePermissions, type Permission } from '../../hooks/usePermissions';
|
||||
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
|
||||
import { logout } from '../../store/slices/auth';
|
||||
import { useGetPlaylistsQuery } from '../../api/endpoints/playlists';
|
||||
@@ -9,24 +10,26 @@ import { getActiveInstance } from '../../config/instances';
|
||||
|
||||
interface NavDef {
|
||||
to: string;
|
||||
label: string;
|
||||
labelKey: string;
|
||||
icon: IconName;
|
||||
end?: boolean;
|
||||
/** Hide this item unless the user holds the permission. */
|
||||
perm?: Permission;
|
||||
}
|
||||
|
||||
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: '/library', labelKey: 'nav.library', icon: 'vinyl-record' },
|
||||
{ to: '/discover', labelKey: 'nav.search', icon: 'magnifying-glass', perm: 'download' },
|
||||
{ to: '/downloads', labelKey: 'nav.downloads', icon: 'arrow-circle-down', perm: 'download' },
|
||||
{ to: '/upload', labelKey: 'nav.upload', icon: 'upload-simple', perm: 'upload' },
|
||||
{ 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,14 +37,15 @@ function navClass({ isActive }: { isActive: boolean }) {
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const { user, isAdmin } = usePermissions();
|
||||
const { user, isAdmin, hasPermission } = usePermissions();
|
||||
const status = useConnectionStatus();
|
||||
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,20 +63,24 @@ export function Sidebar() {
|
||||
</div>
|
||||
|
||||
<div className="sb-sec">
|
||||
{MAIN_NAV.map(({ to, label, icon, end }) => (
|
||||
<NavLink key={to} to={to} end={end} className={navClass}>
|
||||
<Icon name={icon} />
|
||||
<span>{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
{MAIN_NAV.filter((n) => !n.perm || hasPermission(n.perm)).map(
|
||||
({ to, labelKey, icon, end }) => (
|
||||
<NavLink key={to} to={to} end={end} className={navClass}>
|
||||
<Icon name={icon} />
|
||||
<span>{t(labelKey)}</span>
|
||||
</NavLink>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sb-sec">
|
||||
<span className="msk-label">Playlists</span>
|
||||
<NavLink to="/playlists" end className="msk-label sb-sec-link">
|
||||
{t('nav.playlists')}
|
||||
</NavLink>
|
||||
{(playlists?.items ?? []).map((pl) => (
|
||||
<NavLink
|
||||
key={pl.id}
|
||||
to={`/library/playlists/${pl.id}`}
|
||||
to={`/playlists/${pl.id}`}
|
||||
className={navClass}
|
||||
>
|
||||
<Icon name="playlist" />
|
||||
@@ -82,30 +90,30 @@ export function Sidebar() {
|
||||
<button
|
||||
type="button"
|
||||
className="pl-item"
|
||||
onClick={() => void navigate('/library')}
|
||||
onClick={() => void navigate('/playlists')}
|
||||
>
|
||||
<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 +124,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 +141,14 @@ 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 'modern-sk';
|
||||
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 'modern-sk';
|
||||
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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Badge, Tooltip } from 'modern-sk';
|
||||
import { Badge, Tooltip } from '@olly/modern-sk';
|
||||
import type { TrackAvailability } from '../../api/types';
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
IconButton,
|
||||
} from 'modern-sk';
|
||||
} 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,4 +1,4 @@
|
||||
import { Row } from 'modern-sk';
|
||||
import { Row } from '@olly/modern-sk';
|
||||
import { TrackContextMenu } from './TrackContextMenu';
|
||||
import { AvailabilityBadge } from './AvailabilityBadge';
|
||||
import { formatDuration } from '../../lib/format';
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
import { Window } from 'modern-sk';
|
||||
import { Outlet } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SubNav, type SubNavItem } from '../../components/common/SubNav';
|
||||
|
||||
/**
|
||||
* `/admin` — A9 admin shell (admin-gated). Secondary nav + nested `<Outlet/>`
|
||||
* for users/sources/instance. `/admin` redirects to `/admin/users`.
|
||||
*/
|
||||
export function AdminPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const items: SubNavItem[] = [
|
||||
{ to: '/admin/users', label: t('admin.tabs.users') },
|
||||
{ to: '/admin/sources', label: t('admin.tabs.sources') },
|
||||
{ to: '/admin/instance', label: t('admin.tabs.instance') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '1.5rem' }}>
|
||||
<Window title="Admin">
|
||||
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
|
||||
</Window>
|
||||
<div
|
||||
style={{
|
||||
padding: '1.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1.25rem',
|
||||
}}
|
||||
>
|
||||
<h1 className="page-title">{t('pages.admin')}</h1>
|
||||
<SubNav items={items} />
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Window } from '@olly/modern-sk';
|
||||
|
||||
function StubPanel({ title }: { title: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Window title={title}>
|
||||
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
|
||||
{t('common.comingSoon')}
|
||||
</p>
|
||||
</Window>
|
||||
);
|
||||
}
|
||||
|
||||
/** `/admin/users` — user list (add/remove). Scaffold. */
|
||||
export function AdminUsers() {
|
||||
const { t } = useTranslation();
|
||||
return <StubPanel title={t('admin.tabs.users')} />;
|
||||
}
|
||||
|
||||
/** `/admin/users/:userId` — per-user permissions / reset password / status. Scaffold. */
|
||||
export function AdminUserDetail() {
|
||||
const { t } = useTranslation();
|
||||
return <StubPanel title={t('admin.userDetail')} />;
|
||||
}
|
||||
|
||||
/** `/admin/sources` — pluggable source management (creds/cookies/status). Scaffold. */
|
||||
export function AdminSources() {
|
||||
const { t } = useTranslation();
|
||||
return <StubPanel title={t('admin.tabs.sources')} />;
|
||||
}
|
||||
|
||||
/** `/admin/instance` — service health, ML_SERVICE_URL, reindex. Scaffold. */
|
||||
export function AdminInstance() {
|
||||
const { t } = useTranslation();
|
||||
return <StubPanel title={t('admin.tabs.instance')} />;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { ScrollArea, IconButton, Button } from 'modern-sk';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ScrollArea, IconButton, Button } from '@olly/modern-sk';
|
||||
import {
|
||||
useGetAlbumQuery,
|
||||
useGetAlbumTracksQuery,
|
||||
@@ -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) => (
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Placeholder } from '../../components/common/Placeholder';
|
||||
|
||||
/** `/artists/:artistId` — A3 artist detail (discography + similar). Scaffold only. */
|
||||
export function ArtistDetailPage() {
|
||||
const { t } = useTranslation();
|
||||
return <Placeholder title={t('pages.artist')} />;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Placeholder } from '../../components/common/Placeholder';
|
||||
|
||||
/** `/login` — sign in when the instance is already chosen (B1-for-web). Scaffold only. */
|
||||
export function LoginPage() {
|
||||
const { t } = useTranslation();
|
||||
return <Placeholder title={t('pages.login')} />;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Card, TextField, Button, Callout, Badge } from 'modern-sk';
|
||||
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';
|
||||
import { setTokens, setUser } from '../../store/slices/auth';
|
||||
@@ -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 { Window } from 'modern-sk';
|
||||
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,444 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
TextField,
|
||||
TextArea,
|
||||
SearchField,
|
||||
Select,
|
||||
Switch,
|
||||
Checkbox,
|
||||
RadioGroup,
|
||||
RadioItem,
|
||||
Control,
|
||||
SegmentedControl,
|
||||
Slider,
|
||||
Stepper,
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsContent,
|
||||
Progress,
|
||||
Badge,
|
||||
Chip,
|
||||
Card,
|
||||
List,
|
||||
Row,
|
||||
Menu,
|
||||
MenuTrigger,
|
||||
MenuContent,
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
Tooltip,
|
||||
Spinner,
|
||||
Callout,
|
||||
Table,
|
||||
THead,
|
||||
TBody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
AlertDialog,
|
||||
Window,
|
||||
useTheme,
|
||||
} from 'modern-sk';
|
||||
|
||||
const sectionStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
};
|
||||
|
||||
const rowWrap: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.75rem',
|
||||
alignItems: 'center',
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
color: 'var(--color-text-3)',
|
||||
};
|
||||
|
||||
function Section({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card style={{ padding: '1.25rem', ...sectionStyle }}>
|
||||
<span style={labelStyle}>{title}</span>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [search, setSearch] = useState('');
|
||||
const [select, setSelect] = useState<string | undefined>();
|
||||
const [seg, setSeg] = useState('list');
|
||||
const [tab, setTab] = useState('one');
|
||||
const [vol, setVol] = useState([60]);
|
||||
const [count, setCount] = useState(3);
|
||||
const [chips, setChips] = useState(['rock', 'jazz', 'ambient']);
|
||||
const [switchOn, setSwitchOn] = useState(true);
|
||||
const [radio, setRadio] = useState('a');
|
||||
|
||||
return (
|
||||
<div style={{ overflow: 'auto', height: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '1.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1.25rem',
|
||||
maxWidth: '64rem',
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 700 }}>
|
||||
♫ MCMA — Component Kitchen Sink
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
margin: '0.25rem 0 0',
|
||||
color: 'var(--color-text-2)',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
modern-sk reference. Project base ready for development.
|
||||
</p>
|
||||
</div>
|
||||
<Tooltip content={`Switch to ${theme === 'dark' ? 'light' : 'dark'}`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
>
|
||||
{theme === 'dark' ? '☾ Dark' : '☀ Light'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Section title="Buttons">
|
||||
<div style={rowWrap}>
|
||||
<Button variant="key">Key</Button>
|
||||
<Button variant="primary">Primary</Button>
|
||||
<Button variant="ember">Ember</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
<Button variant="primary" size="sm">
|
||||
Small
|
||||
</Button>
|
||||
<Button variant="primary" disabled>
|
||||
Disabled
|
||||
</Button>
|
||||
</div>
|
||||
<div style={rowWrap}>
|
||||
<IconButton variant="primary" aria-label="Play">
|
||||
▶
|
||||
</IconButton>
|
||||
<IconButton variant="ghost" aria-label="Next">
|
||||
⏭
|
||||
</IconButton>
|
||||
<IconButton variant="ember" size="lg" aria-label="Stop">
|
||||
⏹
|
||||
</IconButton>
|
||||
<Stepper
|
||||
onDecrement={() => setCount((c) => c - 1)}
|
||||
onIncrement={() => setCount((c) => c + 1)}
|
||||
/>
|
||||
<span
|
||||
style={{ fontSize: '0.875rem', color: 'var(--color-text-2)' }}
|
||||
>
|
||||
count: {count}
|
||||
</span>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Inputs">
|
||||
<div style={rowWrap}>
|
||||
<TextField placeholder="Text field" style={{ width: '14rem' }} />
|
||||
<SearchField
|
||||
icon="⌕"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search…"
|
||||
style={{ width: '14rem' }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Pick genre"
|
||||
aria-label="Genre"
|
||||
value={select}
|
||||
onValueChange={setSelect}
|
||||
items={[
|
||||
{ value: 'rock', label: 'Rock' },
|
||||
{ value: 'jazz', label: 'Jazz' },
|
||||
{ value: 'ambient', label: 'Ambient' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<TextArea placeholder="Text area / description…" rows={3} />
|
||||
</Section>
|
||||
|
||||
<Section title="Toggles & selection">
|
||||
<div style={rowWrap}>
|
||||
<Control
|
||||
control={
|
||||
<Switch checked={switchOn} onCheckedChange={setSwitchOn} />
|
||||
}
|
||||
>
|
||||
Switch
|
||||
</Control>
|
||||
<Control control={<Checkbox defaultChecked />}>Checkbox</Control>
|
||||
</div>
|
||||
<RadioGroup
|
||||
value={radio}
|
||||
onValueChange={setRadio}
|
||||
style={{ display: 'flex', gap: '1rem' }}
|
||||
>
|
||||
<Control control={<RadioItem value="a" />}>Option A</Control>
|
||||
<Control control={<RadioItem value="b" />}>Option B</Control>
|
||||
<Control control={<RadioItem value="c" />}>Option C</Control>
|
||||
</RadioGroup>
|
||||
<SegmentedControl
|
||||
value={seg}
|
||||
onValueChange={setSeg}
|
||||
items={[
|
||||
{ value: 'list', label: 'List' },
|
||||
{ value: 'grid', label: 'Grid' },
|
||||
{ value: 'compact', label: 'Compact' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Sliders & progress">
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={vol}
|
||||
onValueChange={setVol}
|
||||
notches="bottom"
|
||||
/>
|
||||
<span style={{ fontSize: '0.875rem', color: 'var(--color-text-2)' }}>
|
||||
value: {vol[0]}
|
||||
</span>
|
||||
<Progress value={vol[0]} />
|
||||
</Section>
|
||||
|
||||
<Section title="Badges, chips, spinner">
|
||||
<div style={rowWrap}>
|
||||
<Badge variant="lime" dot>
|
||||
On server
|
||||
</Badge>
|
||||
<Badge variant="ember" dot>
|
||||
Error
|
||||
</Badge>
|
||||
<Badge variant="neutral">Neutral</Badge>
|
||||
<Badge variant="outline">Outline</Badge>
|
||||
<Spinner size="sm" />
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
<div style={rowWrap}>
|
||||
{chips.map((c) => (
|
||||
<Chip
|
||||
key={c}
|
||||
onRemove={() => setChips((prev) => prev.filter((x) => x !== c))}
|
||||
>
|
||||
{c}
|
||||
</Chip>
|
||||
))}
|
||||
{chips.length === 0 && (
|
||||
<span
|
||||
style={{ fontSize: '0.875rem', color: 'var(--color-text-3)' }}
|
||||
>
|
||||
all removed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Callouts">
|
||||
<Callout variant="info">
|
||||
Info — backend address resolves from runtime → env → relative
|
||||
/api/v1.
|
||||
</Callout>
|
||||
<Callout variant="success">
|
||||
Success — typecheck and lint pass clean.
|
||||
</Callout>
|
||||
<Callout variant="warning">
|
||||
Warning — most feature screens are still stubs.
|
||||
</Callout>
|
||||
<Callout variant="danger">
|
||||
Danger — destructive actions use AlertDialog.
|
||||
</Callout>
|
||||
</Section>
|
||||
|
||||
<Section title="Tabs">
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList
|
||||
items={[
|
||||
{ value: 'one', label: 'First' },
|
||||
{ value: 'two', label: 'Second' },
|
||||
{ value: 'three', label: 'Third' },
|
||||
]}
|
||||
/>
|
||||
<TabsContent
|
||||
value="one"
|
||||
style={{
|
||||
padding: '0.75rem 0',
|
||||
color: 'var(--color-text-2)',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
First panel
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="two"
|
||||
style={{
|
||||
padding: '0.75rem 0',
|
||||
color: 'var(--color-text-2)',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
Second panel
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="three"
|
||||
style={{
|
||||
padding: '0.75rem 0',
|
||||
color: 'var(--color-text-2)',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
Third panel
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Section>
|
||||
|
||||
<Section title="List & rows">
|
||||
<List>
|
||||
<Row style={{ padding: '0.5rem 0.75rem' }}>Track one — Artist</Row>
|
||||
<Row selected style={{ padding: '0.5rem 0.75rem' }}>
|
||||
Track two — Artist (selected)
|
||||
</Row>
|
||||
<Row style={{ padding: '0.5rem 0.75rem' }}>
|
||||
Track three — Artist
|
||||
</Row>
|
||||
</List>
|
||||
</Section>
|
||||
|
||||
<Section title="Table">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Title</Th>
|
||||
<Th>Artist</Th>
|
||||
<Th>Duration</Th>
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
<Tr>
|
||||
<Td>Intro</Td>
|
||||
<Td>Aphex</Td>
|
||||
<Td>2:14</Td>
|
||||
</Tr>
|
||||
<Tr selected>
|
||||
<Td>Windowlicker</Td>
|
||||
<Td>Aphex</Td>
|
||||
<Td>6:07</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Avril 14th</Td>
|
||||
<Td>Aphex</Td>
|
||||
<Td>2:01</Td>
|
||||
</Tr>
|
||||
</TBody>
|
||||
</Table>
|
||||
</Section>
|
||||
|
||||
<Section title="Menu, Dialog, AlertDialog">
|
||||
<div style={rowWrap}>
|
||||
<Menu>
|
||||
<MenuTrigger asChild>
|
||||
<Button variant="ghost">Open menu ▾</Button>
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<MenuItem>Play</MenuItem>
|
||||
<MenuItem shortcut="⌘N">Add to queue</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem>Edit metadata</MenuItem>
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
|
||||
<Dialog
|
||||
trigger={<Button variant="primary">Open dialog</Button>}
|
||||
title="Dialog title"
|
||||
description="Composed from modern-sk primitives."
|
||||
footer={
|
||||
<DialogClose asChild>
|
||||
<Button variant="primary">Done</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
color: 'var(--color-text-2)',
|
||||
fontSize: '0.875rem',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Dialog body content.
|
||||
</p>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
trigger={<Button variant="ember">Delete…</Button>}
|
||||
title="Delete track?"
|
||||
description="This permanently removes the file from the server."
|
||||
actionLabel="Delete"
|
||||
destructive
|
||||
onAction={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Window">
|
||||
<Window
|
||||
title="Now Playing"
|
||||
badge={
|
||||
<Badge variant="lime" dot>
|
||||
live
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
color: 'var(--color-text-2)',
|
||||
fontSize: '0.875rem',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Window chrome for grouped content.
|
||||
</p>
|
||||
</Window>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Tabs,
|
||||
TabsList,
|
||||
@@ -7,7 +8,7 @@ import {
|
||||
SearchField,
|
||||
ScrollArea,
|
||||
Card,
|
||||
} from 'modern-sk';
|
||||
} from '@olly/modern-sk';
|
||||
import {
|
||||
useGetTracksQuery,
|
||||
useGetAlbumsQuery,
|
||||
@@ -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 && (
|
||||
@@ -183,7 +185,7 @@ export function LibraryPage() {
|
||||
<AlbumCard
|
||||
key={album.id}
|
||||
album={album}
|
||||
onClick={() => void navigate(`/library/albums/${album.id}`)}
|
||||
onClick={() => void navigate(`/albums/${album.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Placeholder } from '../../components/common/Placeholder';
|
||||
|
||||
interface Props {
|
||||
/** Single-track editor vs. batch editor — both A7, same scaffold. */
|
||||
batch?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* `/tracks/:trackId/metadata` (single) and `/metadata/batch` (bulk) — A7
|
||||
* metadata editor with auto-enrichment / diff view. Scaffold only.
|
||||
*/
|
||||
export function MetadataEditorPage({ batch = false }: Props) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Placeholder title={batch ? t('pages.metadataBatch') : t('pages.metadata')} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Link } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { EmptyState } from '../../components/common/EmptyState';
|
||||
|
||||
/** `*` — 404. Lives inside AppShell so the sidebar/player stay visible. */
|
||||
export function NotFoundPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div style={{ padding: '1.5rem' }}>
|
||||
<EmptyState
|
||||
title={t('notFound.title')}
|
||||
description={t('notFound.description')}
|
||||
/>
|
||||
<div style={{ textAlign: 'center', marginTop: '1rem' }}>
|
||||
<Link to="/library" style={{ color: 'var(--color-accent)' }}>
|
||||
{t('notFound.backToLibrary')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { ScrollArea, IconButton, Button } from 'modern-sk';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ScrollArea, IconButton, Button } from '@olly/modern-sk';
|
||||
import {
|
||||
useGetPlaylistQuery,
|
||||
useGetPlaylistTracksQuery,
|
||||
@@ -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) => (
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Placeholder } from '../../components/common/Placeholder';
|
||||
|
||||
/** `/playlists` — user's playlist list. Scaffold only. */
|
||||
export function PlaylistsPage() {
|
||||
const { t } = useTranslation();
|
||||
return <Placeholder title={t('pages.playlists')} />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Placeholder } from '../../components/common/Placeholder';
|
||||
|
||||
/**
|
||||
* `/queue` — A11 full-screen play queue (narrow viewports). On desktop the
|
||||
* queue is the `QueuePanel` drawer in AppShell, not this route. Scaffold only.
|
||||
*/
|
||||
export function QueuePage() {
|
||||
const { t } = useTranslation();
|
||||
return <Placeholder title={t('pages.queue')} />;
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Window } from 'modern-sk';
|
||||
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,10 +1,34 @@
|
||||
import { Window } from 'modern-sk';
|
||||
import { Outlet } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SubNav, type SubNavItem } from '../../components/common/SubNav';
|
||||
|
||||
/**
|
||||
* `/settings` — A10 settings shell. Hosts a secondary nav + nested `<Outlet/>`
|
||||
* for the profile/playback/scrobbling/instance panels. `/settings` itself
|
||||
* redirects to `/settings/profile` (see routes).
|
||||
*/
|
||||
export function SettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const items: SubNavItem[] = [
|
||||
{ to: '/settings/profile', label: t('settings.tabs.profile') },
|
||||
{ to: '/settings/playback', label: t('settings.tabs.playback') },
|
||||
{ to: '/settings/scrobbling', label: t('settings.tabs.scrobbling') },
|
||||
{ to: '/settings/instance', label: t('settings.tabs.instance') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '1.5rem' }}>
|
||||
<Window title="Settings">
|
||||
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
|
||||
</Window>
|
||||
<div
|
||||
style={{
|
||||
padding: '1.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1.25rem',
|
||||
}}
|
||||
>
|
||||
<h1 className="page-title">{t('pages.settings')}</h1>
|
||||
<SubNav items={items} />
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Window, SegmentedControl, useTheme } from '@olly/modern-sk';
|
||||
import { SUPPORTED_LANGUAGES, setLanguage } from '../../i18n';
|
||||
|
||||
/** Labelled settings row: caption on the left, control on the right. */
|
||||
function SettingRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.875rem',
|
||||
color: 'var(--color-text-2)',
|
||||
minWidth: '6rem',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** `/settings/profile` — profile + app language + theme (all wired). */
|
||||
export function ProfileSettings() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { theme, setTheme } = useTheme();
|
||||
return (
|
||||
<Window title={t('settings.tabs.profile')}>
|
||||
<div
|
||||
style={{
|
||||
padding: '0.75rem 0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<SettingRow label={t('settings.language')}>
|
||||
<SegmentedControl
|
||||
value={i18n.language}
|
||||
onValueChange={setLanguage}
|
||||
items={SUPPORTED_LANGUAGES.map((l) => ({
|
||||
value: l.code,
|
||||
label: l.label,
|
||||
}))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label={t('settings.theme')}>
|
||||
<SegmentedControl
|
||||
value={theme}
|
||||
onValueChange={(v) => setTheme(v === 'light' ? 'light' : 'dark')}
|
||||
items={[
|
||||
{ value: 'dark', label: t('settings.themeDark') },
|
||||
{ value: 'light', label: t('settings.themeLight') },
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</Window>
|
||||
);
|
||||
}
|
||||
|
||||
/** `/settings/playback` — default stream quality / playback behaviour. Scaffold. */
|
||||
export function PlaybackSettings() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Window title={t('settings.tabs.playback')}>
|
||||
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
|
||||
{t('common.comingSoon')}
|
||||
</p>
|
||||
</Window>
|
||||
);
|
||||
}
|
||||
|
||||
/** `/settings/scrobbling` — last.fm / ListenBrainz linking. Scaffold. */
|
||||
export function ScrobblingSettings() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Window title={t('settings.tabs.scrobbling')}>
|
||||
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
|
||||
{t('common.comingSoon')}
|
||||
</p>
|
||||
</Window>
|
||||
);
|
||||
}
|
||||
|
||||
/** `/settings/instance` — switch/forget instance. Scaffold. */
|
||||
export function InstanceSettings() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Window title={t('settings.tabs.instance')}>
|
||||
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
|
||||
{t('common.comingSoon')}
|
||||
</p>
|
||||
</Window>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Placeholder } from '../../components/common/Placeholder';
|
||||
|
||||
/** `/storage/maintenance` — A6 maintenance (dupes, broken files, cleanup). Scaffold only. */
|
||||
export function StorageMaintenancePage() {
|
||||
const { t } = useTranslation();
|
||||
return <Placeholder title={t('pages.storageMaintenance')} />;
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Window } from 'modern-sk';
|
||||
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,328 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge, Button, Callout, ScrollArea, Spinner } from '@olly/modern-sk';
|
||||
import {
|
||||
buildUploadFormData,
|
||||
useUploadTrackMutation,
|
||||
} from '../../api/endpoints/upload';
|
||||
|
||||
/** Pure client-side state — this is a transient upload queue, never server data. */
|
||||
type ItemStatus = 'queued' | 'uploading' | 'done' | 'duplicate' | 'error';
|
||||
|
||||
interface QueueItem {
|
||||
id: string;
|
||||
file: File;
|
||||
status: ItemStatus;
|
||||
error?: string;
|
||||
trackId?: string;
|
||||
}
|
||||
|
||||
/** The endpoint takes one file per request, so we cap concurrent requests. */
|
||||
const MAX_CONCURRENCY = 3;
|
||||
|
||||
function extractError(err: unknown): string {
|
||||
if (typeof err === 'object' && err !== null) {
|
||||
const e = err as { data?: { message?: string; detail?: string }; error?: string };
|
||||
return e.data?.message ?? e.data?.detail ?? e.error ?? 'Upload failed';
|
||||
}
|
||||
return 'Upload failed';
|
||||
}
|
||||
|
||||
/** `/upload` — A8 drag-and-drop upload of own files. */
|
||||
export function UploadPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [uploadTrack] = useUploadTrackMutation();
|
||||
|
||||
const [items, setItems] = useState<QueueItem[]>([]);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const idCounter = useRef(0);
|
||||
const activeCount = useRef(0);
|
||||
const pending = useRef<QueueItem[]>([]);
|
||||
|
||||
const patchItem = (id: string, patch: Partial<QueueItem>) =>
|
||||
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, ...patch } : it)));
|
||||
|
||||
// Ref-based concurrency pump: refs (not state) so it is safe to call from
|
||||
// async callbacks without stale closures over the queue.
|
||||
const pump = () => {
|
||||
while (activeCount.current < MAX_CONCURRENCY && pending.current.length > 0) {
|
||||
const item = pending.current.shift()!;
|
||||
activeCount.current += 1;
|
||||
patchItem(item.id, { status: 'uploading', error: undefined });
|
||||
uploadTrack(buildUploadFormData(item.file))
|
||||
.unwrap()
|
||||
.then((res) => {
|
||||
patchItem(item.id, {
|
||||
status: res.already_exists ? 'duplicate' : 'done',
|
||||
trackId: res.track_id,
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
patchItem(item.id, { status: 'error', error: extractError(err) });
|
||||
})
|
||||
.finally(() => {
|
||||
activeCount.current -= 1;
|
||||
pump();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addFiles = (files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
const newItems: QueueItem[] = files.map((file) => ({
|
||||
id: `u${idCounter.current++}`,
|
||||
file,
|
||||
status: 'queued',
|
||||
}));
|
||||
setItems((prev) => [...prev, ...newItems]);
|
||||
pending.current.push(...newItems);
|
||||
pump();
|
||||
};
|
||||
|
||||
const retry = (id: string) => {
|
||||
let target: QueueItem | undefined;
|
||||
setItems((prev) => {
|
||||
target = prev.find((it) => it.id === id);
|
||||
return prev.map((it) =>
|
||||
it.id === id ? { ...it, status: 'queued', error: undefined } : it,
|
||||
);
|
||||
});
|
||||
if (target) {
|
||||
pending.current.push({ ...target, status: 'queued', error: undefined });
|
||||
pump();
|
||||
}
|
||||
};
|
||||
|
||||
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) addFiles(Array.from(e.target.files));
|
||||
e.target.value = ''; // allow re-selecting the same file
|
||||
};
|
||||
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
if (e.dataTransfer.files) addFiles(Array.from(e.dataTransfer.files));
|
||||
};
|
||||
|
||||
const completed = items.filter(
|
||||
(it) => it.status === 'done' || it.status === 'duplicate',
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '1.25rem 1.5rem',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
|
||||
{t('upload.title')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '1.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1.25rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={onDrop}
|
||||
style={{
|
||||
border: `2px dashed ${
|
||||
dragging ? 'var(--color-accent)' : 'var(--color-border)'
|
||||
}`,
|
||||
borderRadius: 10,
|
||||
padding: '2.5rem 1.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
cursor: 'pointer',
|
||||
background: dragging ? 'var(--color-surface-2)' : 'transparent',
|
||||
transition: 'background 120ms, border-color 120ms',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '2rem' }}>⬆</div>
|
||||
<div style={{ fontWeight: 600 }}>{t('upload.dropzone.title')}</div>
|
||||
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
||||
{t('upload.dropzone.hint')}
|
||||
</div>
|
||||
<Button variant="primary" size="sm" type="button">
|
||||
{t('upload.dropzone.button')}
|
||||
</Button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="audio/*"
|
||||
onChange={onInputChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Callout variant="info">{t('upload.metadataPending')}</Callout>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>
|
||||
{t('upload.queueTitle', {
|
||||
completed,
|
||||
total: items.length,
|
||||
})}
|
||||
</span>
|
||||
{completed > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setItems((prev) =>
|
||||
prev.filter(
|
||||
(it) =>
|
||||
it.status !== 'done' && it.status !== 'duplicate',
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('upload.clearCompleted')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{items.map((item) => (
|
||||
<UploadRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
onRetry={() => retry(item.id)}
|
||||
onEditMetadata={() =>
|
||||
void navigate(`/tracks/${item.trackId}/metadata`)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadRow({
|
||||
item,
|
||||
onRetry,
|
||||
onEditMetadata,
|
||||
}: {
|
||||
item: QueueItem;
|
||||
onRetry: () => void;
|
||||
onEditMetadata: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const done = item.status === 'done' || item.status === 'duplicate';
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: '0.625rem 0.75rem',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 8,
|
||||
background: 'var(--color-surface-1)',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={item.file.name}
|
||||
>
|
||||
{item.file.name}
|
||||
</div>
|
||||
{item.status === 'error' && item.error && (
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
|
||||
{item.error}
|
||||
</div>
|
||||
)}
|
||||
{done && (
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
|
||||
{t('upload.unknownArtist')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<StatusBadge status={item.status} />
|
||||
|
||||
{item.status === 'error' && (
|
||||
<Button variant="ghost" size="sm" type="button" onClick={onRetry}>
|
||||
{t('upload.retry')}
|
||||
</Button>
|
||||
)}
|
||||
{done && item.trackId && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={onEditMetadata}
|
||||
>
|
||||
{t('upload.editMetadata')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: ItemStatus }) {
|
||||
const { t } = useTranslation();
|
||||
if (status === 'uploading') {
|
||||
return (
|
||||
<Badge variant="neutral">
|
||||
<Spinner size="sm" /> {t('upload.status.uploading')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
const cfg: Record<
|
||||
Exclude<ItemStatus, 'uploading'>,
|
||||
{ variant: 'lime' | 'ember' | 'neutral' | 'outline'; key: string }
|
||||
> = {
|
||||
queued: { variant: 'neutral', key: 'upload.status.queued' },
|
||||
done: { variant: 'lime', key: 'upload.status.done' },
|
||||
duplicate: { variant: 'outline', key: 'upload.status.duplicate' },
|
||||
error: { variant: 'ember', key: 'upload.status.error' },
|
||||
};
|
||||
const { variant, key } = cfg[status];
|
||||
return (
|
||||
<Badge variant={variant} dot>
|
||||
{t(key)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useAppSelector } from './useAppDispatch';
|
||||
|
||||
type Permission =
|
||||
export type Permission =
|
||||
| 'download'
|
||||
| 'upload'
|
||||
| 'admin'
|
||||
|
||||
@@ -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,211 @@
|
||||
const en = {
|
||||
nav: {
|
||||
library: 'Library',
|
||||
search: 'Search & download',
|
||||
downloads: 'Downloads',
|
||||
upload: 'Upload',
|
||||
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',
|
||||
login: 'Sign in',
|
||||
artist: 'Artist',
|
||||
playlists: 'Playlists',
|
||||
upload: 'Upload files',
|
||||
metadata: 'Edit metadata',
|
||||
metadataBatch: 'Edit metadata (batch)',
|
||||
storageMaintenance: 'Storage maintenance',
|
||||
queue: 'Play queue',
|
||||
},
|
||||
settings: {
|
||||
language: 'Language',
|
||||
theme: 'Theme',
|
||||
themeDark: 'Dark',
|
||||
themeLight: 'Light',
|
||||
tabs: {
|
||||
profile: 'Profile',
|
||||
playback: 'Playback',
|
||||
scrobbling: 'Scrobbling',
|
||||
instance: 'Instance',
|
||||
},
|
||||
},
|
||||
admin: {
|
||||
userDetail: 'User',
|
||||
tabs: {
|
||||
users: 'Users',
|
||||
sources: 'Sources',
|
||||
instance: 'Instance',
|
||||
},
|
||||
},
|
||||
notFound: {
|
||||
title: 'Page not found',
|
||||
description: "This screen doesn't exist yet.",
|
||||
backToLibrary: 'Back to library',
|
||||
},
|
||||
upload: {
|
||||
title: 'Upload files',
|
||||
dropzone: {
|
||||
title: 'Drag & drop audio files here',
|
||||
hint: 'or click to choose files — one or many at a time',
|
||||
button: 'Choose files',
|
||||
},
|
||||
queueTitle: 'Uploads ({{completed}}/{{total}})',
|
||||
clearCompleted: 'Clear completed',
|
||||
retry: 'Retry',
|
||||
editMetadata: 'Edit metadata',
|
||||
metadataPending:
|
||||
'Uploaded tracks land as “Unknown Artist” with metadata pending — enrich them afterwards.',
|
||||
unknownArtist: 'Unknown Artist · metadata pending',
|
||||
status: {
|
||||
queued: 'Queued',
|
||||
uploading: 'Uploading',
|
||||
done: 'Uploaded',
|
||||
duplicate: 'Already in library',
|
||||
error: 'Failed',
|
||||
},
|
||||
},
|
||||
} 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,208 @@
|
||||
import type { Translations } from './en';
|
||||
|
||||
const ru: Translations = {
|
||||
nav: {
|
||||
library: 'Библиотека',
|
||||
search: 'Поиск и загрузка',
|
||||
downloads: 'Загрузки',
|
||||
upload: 'Загрузить',
|
||||
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: 'Хранилище',
|
||||
login: 'Вход',
|
||||
artist: 'Артист',
|
||||
playlists: 'Плейлисты',
|
||||
upload: 'Загрузка файлов',
|
||||
metadata: 'Редактирование метаданных',
|
||||
metadataBatch: 'Редактирование метаданных (массово)',
|
||||
storageMaintenance: 'Обслуживание хранилища',
|
||||
queue: 'Очередь воспроизведения',
|
||||
},
|
||||
settings: {
|
||||
language: 'Язык',
|
||||
theme: 'Тема',
|
||||
themeDark: 'Тёмная',
|
||||
themeLight: 'Светлая',
|
||||
tabs: {
|
||||
profile: 'Профиль',
|
||||
playback: 'Воспроизведение',
|
||||
scrobbling: 'Скробблинг',
|
||||
instance: 'Сервер',
|
||||
},
|
||||
},
|
||||
admin: {
|
||||
userDetail: 'Пользователь',
|
||||
tabs: {
|
||||
users: 'Пользователи',
|
||||
sources: 'Источники',
|
||||
instance: 'Сервер',
|
||||
},
|
||||
},
|
||||
notFound: {
|
||||
title: 'Страница не найдена',
|
||||
description: 'Этого экрана пока нет.',
|
||||
backToLibrary: 'Вернуться в библиотеку',
|
||||
},
|
||||
upload: {
|
||||
title: 'Загрузка файлов',
|
||||
dropzone: {
|
||||
title: 'Перетащите аудиофайлы сюда',
|
||||
hint: 'или нажмите, чтобы выбрать файлы — по одному или сразу несколько',
|
||||
button: 'Выбрать файлы',
|
||||
},
|
||||
queueTitle: 'Загрузки ({{completed}}/{{total}})',
|
||||
clearCompleted: 'Убрать завершённые',
|
||||
retry: 'Повторить',
|
||||
editMetadata: 'Изменить метаданные',
|
||||
metadataPending:
|
||||
'Загруженные треки появляются как «Unknown Artist» с метаданными в ожидании — дозаполните их позже.',
|
||||
unknownArtist: 'Unknown Artist · метаданные в ожидании',
|
||||
status: {
|
||||
queued: 'В очереди',
|
||||
uploading: 'Загрузка',
|
||||
done: 'Загружено',
|
||||
duplicate: 'Уже в библиотеке',
|
||||
error: 'Ошибка',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default ru;
|
||||
+4
-3
@@ -1,12 +1,13 @@
|
||||
import 'modern-sk/styles.css';
|
||||
import 'modern-sk/fonts.css';
|
||||
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';
|
||||
import { BrowserRouter } from 'react-router';
|
||||
import { ThemeProvider, TooltipProvider } from 'modern-sk';
|
||||
import { ThemeProvider, TooltipProvider } from '@olly/modern-sk';
|
||||
import { store } from './store';
|
||||
import { AppRoutes } from './routes';
|
||||
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { Navigate } from 'react-router';
|
||||
import { useAppSelector } from '../hooks/useAppDispatch';
|
||||
import { usePermissions, type Permission } from '../hooks/usePermissions';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
requireAdmin?: boolean;
|
||||
/** Gate the route on a granular permission (e.g. download, upload). */
|
||||
requirePermission?: Permission;
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children, requireAdmin = false }: Props) {
|
||||
export function ProtectedRoute({
|
||||
children,
|
||||
requireAdmin = false,
|
||||
requirePermission,
|
||||
}: Props) {
|
||||
const auth = useAppSelector((s) => s.auth);
|
||||
const { hasPermission } = usePermissions();
|
||||
|
||||
if (!auth.accessToken || !auth.user) {
|
||||
return <Navigate to="/connect" replace />;
|
||||
@@ -17,5 +25,9 @@ export function ProtectedRoute({ children, requireAdmin = false }: Props) {
|
||||
return <Navigate to="/library" replace />;
|
||||
}
|
||||
|
||||
if (requirePermission && !hasPermission(requirePermission)) {
|
||||
return <Navigate to="/library" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
+109
-51
@@ -1,14 +1,38 @@
|
||||
import { lazy } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router';
|
||||
import { AppShell } from '../components/layout/AppShell';
|
||||
import { ProtectedRoute } from './ProtectedRoute';
|
||||
|
||||
// Public (outside the shell)
|
||||
import { ConnectPage } from '../features/connect/ConnectPage';
|
||||
import { HomePage } from '../features/home/HomePage';
|
||||
import { LoginPage } from '../features/auth/LoginPage';
|
||||
|
||||
// Core screens (eager — first paint inside the shell)
|
||||
import { LibraryPage } from '../features/library/LibraryPage';
|
||||
import { AlbumDetailPage } from '../features/album-detail/AlbumDetailPage';
|
||||
import { ArtistDetailPage } from '../features/artist-detail/ArtistDetailPage';
|
||||
import { PlaylistsPage } from '../features/playlists/PlaylistsPage';
|
||||
import { PlaylistDetailPage } from '../features/playlist-detail/PlaylistDetailPage';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { LoadingSkeleton } from '../components/common/LoadingSkeleton';
|
||||
|
||||
// Settings / Admin layouts + panels (small, eager)
|
||||
import { SettingsPage } from '../features/settings/SettingsPage';
|
||||
import {
|
||||
ProfileSettings,
|
||||
PlaybackSettings,
|
||||
ScrobblingSettings,
|
||||
InstanceSettings,
|
||||
} from '../features/settings/panels';
|
||||
import { AdminPage } from '../features/admin/AdminPage';
|
||||
import {
|
||||
AdminUsers,
|
||||
AdminUserDetail,
|
||||
AdminSources,
|
||||
AdminInstance,
|
||||
} from '../features/admin/panels';
|
||||
|
||||
import { NotFoundPage } from '../features/not-found/NotFoundPage';
|
||||
|
||||
// Secondary screens — lazily loaded (Suspense boundary lives in AppShell)
|
||||
const SearchDownloadPage = lazy(() =>
|
||||
import('../features/search-download/SearchDownloadPage').then((m) => ({
|
||||
default: m.SearchDownloadPage,
|
||||
@@ -19,30 +43,36 @@ const DownloadsManagerPage = lazy(() =>
|
||||
default: m.DownloadsManagerPage,
|
||||
})),
|
||||
);
|
||||
const UploadPage = lazy(() =>
|
||||
import('../features/upload/UploadPage').then((m) => ({ default: m.UploadPage })),
|
||||
);
|
||||
const MetadataEditorPage = lazy(() =>
|
||||
import('../features/metadata-editor/MetadataEditorPage').then((m) => ({
|
||||
default: m.MetadataEditorPage,
|
||||
})),
|
||||
);
|
||||
const StoragePage = lazy(() =>
|
||||
import('../features/storage/StoragePage').then((m) => ({
|
||||
default: m.StoragePage,
|
||||
})),
|
||||
);
|
||||
const AdminPage = lazy(() =>
|
||||
import('../features/admin/AdminPage').then((m) => ({ default: m.AdminPage })),
|
||||
);
|
||||
const SettingsPage = lazy(() =>
|
||||
import('../features/settings/SettingsPage').then((m) => ({
|
||||
default: m.SettingsPage,
|
||||
const StorageMaintenancePage = lazy(() =>
|
||||
import('../features/storage/StorageMaintenancePage').then((m) => ({
|
||||
default: m.StorageMaintenancePage,
|
||||
})),
|
||||
);
|
||||
|
||||
const Fallback = () => (
|
||||
<div style={{ padding: '2rem' }}>
|
||||
<LoadingSkeleton />
|
||||
</div>
|
||||
const QueuePage = lazy(() =>
|
||||
import('../features/queue/QueuePage').then((m) => ({ default: m.QueuePage })),
|
||||
);
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
{/* Public */}
|
||||
<Route path="/connect" element={<ConnectPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* Authenticated shell */}
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
@@ -50,57 +80,85 @@ export function AppRoutes() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route index element={<Navigate to="/library" replace />} />
|
||||
|
||||
{/* Library */}
|
||||
<Route path="/library" element={<LibraryPage />} />
|
||||
<Route path="/library/albums/:albumId" element={<AlbumDetailPage />} />
|
||||
<Route path="/albums/:albumId" element={<AlbumDetailPage />} />
|
||||
<Route path="/artists/:artistId" element={<ArtistDetailPage />} />
|
||||
|
||||
{/* Playlists */}
|
||||
<Route path="/playlists" element={<PlaylistsPage />} />
|
||||
<Route path="/playlists/:playlistId" element={<PlaylistDetailPage />} />
|
||||
|
||||
{/* Discover & downloads (permission-gated) */}
|
||||
<Route
|
||||
path="/library/playlists/:playlistId"
|
||||
element={<PlaylistDetailPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/search"
|
||||
path="/discover"
|
||||
element={
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<ProtectedRoute requirePermission="download">
|
||||
<SearchDownloadPage />
|
||||
</Suspense>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/downloads"
|
||||
element={
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<ProtectedRoute requirePermission="download">
|
||||
<DownloadsManagerPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/storage"
|
||||
element={
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<StoragePage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<SettingsPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/*"
|
||||
element={
|
||||
<ProtectedRoute requireAdmin>
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<AdminPage />
|
||||
</Suspense>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Upload & metadata */}
|
||||
<Route
|
||||
path="/upload"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="upload">
|
||||
<UploadPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/tracks/:trackId/metadata"
|
||||
element={<MetadataEditorPage />}
|
||||
/>
|
||||
<Route path="/metadata/batch" element={<MetadataEditorPage batch />} />
|
||||
|
||||
{/* Storage */}
|
||||
<Route path="/storage" element={<StoragePage />} />
|
||||
<Route path="/storage/maintenance" element={<StorageMaintenancePage />} />
|
||||
|
||||
{/* Queue (narrow viewports) */}
|
||||
<Route path="/queue" element={<QueuePage />} />
|
||||
|
||||
{/* Settings */}
|
||||
<Route path="/settings" element={<SettingsPage />}>
|
||||
<Route index element={<Navigate to="/settings/profile" replace />} />
|
||||
<Route path="profile" element={<ProfileSettings />} />
|
||||
<Route path="playback" element={<PlaybackSettings />} />
|
||||
<Route path="scrobbling" element={<ScrobblingSettings />} />
|
||||
<Route path="instance" element={<InstanceSettings />} />
|
||||
</Route>
|
||||
|
||||
{/* Admin (admin-gated) */}
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<ProtectedRoute requireAdmin>
|
||||
<AdminPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/admin/users" replace />} />
|
||||
<Route path="users" element={<AdminUsers />} />
|
||||
<Route path="users/:userId" element={<AdminUserDetail />} />
|
||||
<Route path="sources" element={<AdminSources />} />
|
||||
<Route path="instance" element={<AdminInstance />} />
|
||||
</Route>
|
||||
|
||||
{/* 404 */}
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/library" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
+58
-1
@@ -53,12 +53,14 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.22));
|
||||
border-right: 1px solid var(--hair);
|
||||
}
|
||||
.sb-scroll {
|
||||
flex: 1;
|
||||
min-height: 0; /* allow scroll inside the column flex so .sb-foot stays pinned */
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 14px 12px 6px;
|
||||
}
|
||||
@@ -90,6 +92,8 @@
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--r-md);
|
||||
font-size: 14px;
|
||||
@@ -107,6 +111,14 @@
|
||||
.nav-item .ph {
|
||||
font-size: 18px;
|
||||
color: var(--fg-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nav-item > span {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
@@ -150,6 +162,8 @@
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--r-md);
|
||||
font-size: 13px;
|
||||
@@ -263,9 +277,11 @@
|
||||
|
||||
/* connection status pill (used in sidebar foot) */
|
||||
.conn {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 5px 11px 5px 9px;
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 12px;
|
||||
@@ -707,3 +723,44 @@
|
||||
color: var(--fg-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
PAGE HEADER + SECONDARY NAV (Settings, Admin)
|
||||
============================================================ */
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display, var(--font-sans));
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--track-snug);
|
||||
color: var(--fg-1);
|
||||
}
|
||||
|
||||
.sub-nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--hair);
|
||||
}
|
||||
.sub-nav-item {
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-2);
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
transition:
|
||||
color 0.12s ease,
|
||||
border-color 0.12s ease;
|
||||
}
|
||||
.sub-nav-item:hover {
|
||||
color: var(--fg-1);
|
||||
}
|
||||
.sub-nav-item.active {
|
||||
color: var(--fg-1);
|
||||
border-bottom-color: var(--lime);
|
||||
}
|
||||
|
||||
/* Sidebar section header that doubles as a link (Playlists) */
|
||||
.sb-sec-link.active {
|
||||
color: var(--fg-1);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user