Compare commits
1 Commits
master
..
57044dabba
| Author | SHA1 | Date | |
|---|---|---|---|
| 57044dabba |
@@ -0,0 +1,90 @@
|
||||
import { api } from '../index';
|
||||
import type {
|
||||
AppSettings,
|
||||
ScrobbleProvider,
|
||||
ScrobblingConfig,
|
||||
StreamQuality,
|
||||
ThemePref,
|
||||
} from '../types';
|
||||
|
||||
// Wire shapes (snake_case) from the backend, mapped to the app's camelCase types.
|
||||
interface RawSettings {
|
||||
theme: ThemePref;
|
||||
stream_quality: StreamQuality;
|
||||
}
|
||||
interface RawScrobbling {
|
||||
enabled: boolean;
|
||||
provider: ScrobbleProvider | null;
|
||||
username: string | null;
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
const toSettings = (r: RawSettings): AppSettings => ({
|
||||
theme: r.theme,
|
||||
streamQuality: r.stream_quality,
|
||||
});
|
||||
const toScrobbling = (r: RawScrobbling): ScrobblingConfig => ({
|
||||
enabled: r.enabled,
|
||||
provider: r.provider,
|
||||
username: r.username,
|
||||
configured: r.configured,
|
||||
});
|
||||
|
||||
export const settingsApi = api.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
getSettings: build.query<AppSettings, void>({
|
||||
query: () => '/settings',
|
||||
transformResponse: (raw: RawSettings) => toSettings(raw),
|
||||
providesTags: ['Settings'],
|
||||
}),
|
||||
updateSettings: build.mutation<
|
||||
AppSettings,
|
||||
{ theme?: ThemePref; streamQuality?: StreamQuality }
|
||||
>({
|
||||
query: ({ theme, streamQuality }) => ({
|
||||
url: '/settings',
|
||||
method: 'PATCH',
|
||||
body: { theme, stream_quality: streamQuality },
|
||||
}),
|
||||
transformResponse: (raw: RawSettings) => toSettings(raw),
|
||||
invalidatesTags: ['Settings'],
|
||||
}),
|
||||
getScrobbling: build.query<ScrobblingConfig, void>({
|
||||
query: () => '/settings/scrobbling',
|
||||
transformResponse: (raw: RawScrobbling) => toScrobbling(raw),
|
||||
providesTags: ['Settings'],
|
||||
}),
|
||||
updateScrobbling: build.mutation<
|
||||
ScrobblingConfig,
|
||||
{
|
||||
enabled: boolean;
|
||||
provider: ScrobbleProvider | null;
|
||||
username: string | null;
|
||||
// Write-only: send only when the user typed a new key; omit to keep the
|
||||
// stored one (the server never returns it).
|
||||
sessionKey?: string;
|
||||
}
|
||||
>({
|
||||
query: ({ enabled, provider, username, sessionKey }) => ({
|
||||
url: '/settings/scrobbling',
|
||||
method: 'PUT',
|
||||
body: {
|
||||
enabled,
|
||||
provider,
|
||||
username,
|
||||
session_key: sessionKey,
|
||||
},
|
||||
}),
|
||||
transformResponse: (raw: RawScrobbling) => toScrobbling(raw),
|
||||
invalidatesTags: ['Settings'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetSettingsQuery,
|
||||
useUpdateSettingsMutation,
|
||||
useGetScrobblingQuery,
|
||||
useUpdateScrobblingMutation,
|
||||
} = settingsApi;
|
||||
@@ -23,6 +23,7 @@ export const api = createApi({
|
||||
'Like',
|
||||
'User',
|
||||
'Storage',
|
||||
'Settings',
|
||||
],
|
||||
// Tier 2 offline: seed the cache from the persisted snapshot dispatched at
|
||||
// startup (see `store/rtkqPersist.ts`). Returning the saved queries/mutations
|
||||
|
||||
@@ -258,3 +258,24 @@ export interface MetadataEdit {
|
||||
genre?: string;
|
||||
trackNumber?: number;
|
||||
}
|
||||
|
||||
// -- user settings (`/settings`) ---------------------------------------------
|
||||
export type ThemePref = 'system' | 'light' | 'dark';
|
||||
export type StreamQuality = 'original' | 'high' | 'medium' | 'low';
|
||||
|
||||
/** General per-user preferences from `GET /settings`. */
|
||||
export interface AppSettings {
|
||||
theme: ThemePref;
|
||||
streamQuality: StreamQuality;
|
||||
}
|
||||
|
||||
export type ScrobbleProvider = 'lastfm' | 'listenbrainz';
|
||||
|
||||
/** Scrobbling config from `GET /settings/scrobbling`. The session key is
|
||||
* write-only server-side, so it never appears here — only `configured`. */
|
||||
export interface ScrobblingConfig {
|
||||
enabled: boolean;
|
||||
provider: ScrobbleProvider | null;
|
||||
username: string | null;
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
TabsContent,
|
||||
ScrollArea,
|
||||
Card,
|
||||
TextField,
|
||||
Callout,
|
||||
SearchField,
|
||||
} from '@olly/modern-sk';
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Window, SegmentedControl, useTheme } from '@olly/modern-sk';
|
||||
import {
|
||||
Window,
|
||||
SegmentedControl,
|
||||
TextField,
|
||||
Button,
|
||||
Callout,
|
||||
Badge,
|
||||
useTheme,
|
||||
} from '@olly/modern-sk';
|
||||
import { SUPPORTED_LANGUAGES, setLanguage } from '../../i18n';
|
||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||
import { ErrorState } from '../../components/common/ErrorState';
|
||||
import {
|
||||
useGetSettingsQuery,
|
||||
useUpdateSettingsMutation,
|
||||
useGetScrobblingQuery,
|
||||
useUpdateScrobblingMutation,
|
||||
} from '../../api/endpoints/settings';
|
||||
import type {
|
||||
ScrobbleProvider,
|
||||
StreamQuality,
|
||||
} from '../../api/types';
|
||||
|
||||
/** Labelled settings row: caption on the left, control on the right. */
|
||||
function SettingRow({
|
||||
@@ -27,6 +47,14 @@ function SettingRow({
|
||||
);
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 500,
|
||||
marginBottom: '0.375rem',
|
||||
color: 'var(--color-text-2)',
|
||||
};
|
||||
|
||||
/** `/settings/profile` — profile + app language + theme (all wired). */
|
||||
export function ProfileSettings() {
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -66,26 +94,224 @@ export function ProfileSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
/** `/settings/playback` — default stream quality / playback behaviour. Scaffold. */
|
||||
const STREAM_QUALITIES: StreamQuality[] = ['original', 'high', 'medium', 'low'];
|
||||
|
||||
/** `/settings/playback` — default stream quality (server-synced). */
|
||||
export function PlaybackSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isError, refetch } = useGetSettingsQuery();
|
||||
const [updateSettings, { isLoading: isSaving }] = useUpdateSettingsMutation();
|
||||
|
||||
return (
|
||||
<Window title={t('settings.tabs.playback')}>
|
||||
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
|
||||
{t('common.comingSoon')}
|
||||
{isLoading && <LoadingSkeleton rows={1} height={40} />}
|
||||
{isError && (
|
||||
<ErrorState message={t('common.error')} onRetry={() => refetch()} />
|
||||
)}
|
||||
{data && (
|
||||
<div
|
||||
style={{
|
||||
padding: '0.75rem 0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<SettingRow label={t('settings.playback.streamQuality')}>
|
||||
<SegmentedControl
|
||||
value={data.streamQuality}
|
||||
onValueChange={(v) =>
|
||||
void updateSettings({ streamQuality: v as StreamQuality })
|
||||
}
|
||||
items={STREAM_QUALITIES.map((q) => ({
|
||||
value: q,
|
||||
label: t(`settings.playback.quality.${q}`),
|
||||
}))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '0.8rem',
|
||||
color: 'var(--color-text-3)',
|
||||
}}
|
||||
>
|
||||
{isSaving
|
||||
? t('settings.common.saving')
|
||||
: t('settings.playback.streamQualityHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Window>
|
||||
);
|
||||
}
|
||||
|
||||
/** `/settings/scrobbling` — last.fm / ListenBrainz linking. Scaffold. */
|
||||
const PROVIDERS: ScrobbleProvider[] = ['lastfm', 'listenbrainz'];
|
||||
|
||||
/** `/settings/scrobbling` — Last.fm / ListenBrainz linking (server-synced). */
|
||||
export function ScrobblingSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isError, refetch } = useGetScrobblingQuery();
|
||||
const [updateScrobbling, { isLoading: isSaving }] =
|
||||
useUpdateScrobblingMutation();
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [provider, setProvider] = useState<ScrobbleProvider>('lastfm');
|
||||
const [username, setUsername] = useState('');
|
||||
const [sessionKey, setSessionKey] = useState('');
|
||||
const [status, setStatus] = useState<'idle' | 'saved' | 'error'>('idle');
|
||||
|
||||
// Seed the form from the server once it loads (and on refetch).
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setEnabled(data.enabled);
|
||||
setProvider(data.provider ?? 'lastfm');
|
||||
setUsername(data.username ?? '');
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setStatus('idle');
|
||||
try {
|
||||
await updateScrobbling({
|
||||
enabled,
|
||||
provider: enabled ? provider : null,
|
||||
username: username.trim() || null,
|
||||
sessionKey: sessionKey || undefined,
|
||||
}).unwrap();
|
||||
setSessionKey(''); // the key is write-only — don't keep it around
|
||||
setStatus('saved');
|
||||
} catch {
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Window title={t('settings.tabs.scrobbling')}>
|
||||
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
|
||||
{t('common.comingSoon')}
|
||||
{isLoading && <LoadingSkeleton rows={3} height={40} />}
|
||||
{isError && (
|
||||
<ErrorState message={t('common.error')} onRetry={() => refetch()} />
|
||||
)}
|
||||
{data && (
|
||||
<div
|
||||
style={{
|
||||
padding: '0.75rem 0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1.25rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '0.875rem',
|
||||
color: 'var(--color-text-2)',
|
||||
}}
|
||||
>
|
||||
{t('settings.scrobbling.description')}
|
||||
</p>
|
||||
<Badge variant={data.configured ? 'lime' : 'neutral'}>
|
||||
{data.configured
|
||||
? t('settings.scrobbling.configured')
|
||||
: t('settings.scrobbling.notConfigured')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<SettingRow label={t('settings.scrobbling.enabled')}>
|
||||
<SegmentedControl
|
||||
value={enabled ? 'on' : 'off'}
|
||||
onValueChange={(v) => setEnabled(v === 'on')}
|
||||
items={[
|
||||
{ value: 'on', label: t('settings.scrobbling.on') },
|
||||
{ value: 'off', label: t('settings.scrobbling.off') },
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{enabled && (
|
||||
<>
|
||||
<SettingRow label={t('settings.scrobbling.provider')}>
|
||||
<SegmentedControl
|
||||
value={provider}
|
||||
onValueChange={(v) => setProvider(v as ScrobbleProvider)}
|
||||
items={PROVIDERS.map((p) => ({
|
||||
value: p,
|
||||
label: t(`settings.scrobbling.${p}`),
|
||||
}))}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>
|
||||
{t('settings.scrobbling.username')}
|
||||
</label>
|
||||
<TextField
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={t('settings.scrobbling.usernamePlaceholder')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>
|
||||
{t('settings.scrobbling.sessionKey')}
|
||||
</label>
|
||||
<TextField
|
||||
type="password"
|
||||
value={sessionKey}
|
||||
onChange={(e) => setSessionKey(e.target.value)}
|
||||
placeholder={
|
||||
data.configured
|
||||
? t('settings.scrobbling.sessionKeyKeep')
|
||||
: t('settings.scrobbling.sessionKeyPlaceholder')
|
||||
}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--color-text-3)',
|
||||
marginTop: '0.375rem',
|
||||
}}
|
||||
>
|
||||
{t('settings.scrobbling.sessionKeyHint')}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'saved' && (
|
||||
<Callout variant="success">
|
||||
{t('settings.scrobbling.saved')}
|
||||
</Callout>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<Callout variant="danger">{t('settings.scrobbling.error')}</Callout>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={isSaving}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
{isSaving
|
||||
? t('settings.common.saving')
|
||||
: t('settings.common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Window>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -357,6 +357,41 @@ const en = {
|
||||
scrobbling: 'Scrobbling',
|
||||
instance: 'Instance',
|
||||
},
|
||||
common: {
|
||||
save: 'Save',
|
||||
saving: 'Saving…',
|
||||
},
|
||||
playback: {
|
||||
streamQuality: 'Streaming quality',
|
||||
streamQualityHint:
|
||||
'Audio quality when streaming from the server. "Original" streams the file as-is.',
|
||||
quality: {
|
||||
original: 'Original',
|
||||
high: 'High',
|
||||
medium: 'Medium',
|
||||
low: 'Low',
|
||||
},
|
||||
},
|
||||
scrobbling: {
|
||||
description: 'Send your listens to Last.fm or ListenBrainz.',
|
||||
configured: 'Linked',
|
||||
notConfigured: 'Not linked',
|
||||
enabled: 'Scrobbling',
|
||||
on: 'On',
|
||||
off: 'Off',
|
||||
provider: 'Service',
|
||||
lastfm: 'Last.fm',
|
||||
listenbrainz: 'ListenBrainz',
|
||||
username: 'Username',
|
||||
usernamePlaceholder: 'your account name',
|
||||
sessionKey: 'Session key / token',
|
||||
sessionKeyPlaceholder: 'paste your token',
|
||||
sessionKeyKeep: 'leave blank to keep the current token',
|
||||
sessionKeyHint:
|
||||
'Stored encrypted on the server and never shown again.',
|
||||
saved: 'Scrobbling settings saved.',
|
||||
error: 'Could not save. Please try again.',
|
||||
},
|
||||
},
|
||||
admin: {
|
||||
userDetail: 'User',
|
||||
|
||||
@@ -358,6 +358,41 @@ const ru: Translations = {
|
||||
scrobbling: 'Скробблинг',
|
||||
instance: 'Сервер',
|
||||
},
|
||||
common: {
|
||||
save: 'Сохранить',
|
||||
saving: 'Сохранение…',
|
||||
},
|
||||
playback: {
|
||||
streamQuality: 'Качество потока',
|
||||
streamQualityHint:
|
||||
'Качество звука при стриминге с сервера. «Оригинал» отдаёт файл как есть.',
|
||||
quality: {
|
||||
original: 'Оригинал',
|
||||
high: 'Высокое',
|
||||
medium: 'Среднее',
|
||||
low: 'Низкое',
|
||||
},
|
||||
},
|
||||
scrobbling: {
|
||||
description: 'Отправляйте свои прослушивания в Last.fm или ListenBrainz.',
|
||||
configured: 'Подключено',
|
||||
notConfigured: 'Не подключено',
|
||||
enabled: 'Скробблинг',
|
||||
on: 'Вкл',
|
||||
off: 'Выкл',
|
||||
provider: 'Сервис',
|
||||
lastfm: 'Last.fm',
|
||||
listenbrainz: 'ListenBrainz',
|
||||
username: 'Имя пользователя',
|
||||
usernamePlaceholder: 'ваш логин',
|
||||
sessionKey: 'Ключ сессии / токен',
|
||||
sessionKeyPlaceholder: 'вставьте токен',
|
||||
sessionKeyKeep: 'оставьте пустым, чтобы сохранить текущий токен',
|
||||
sessionKeyHint:
|
||||
'Хранится на сервере в зашифрованном виде и больше не показывается.',
|
||||
saved: 'Настройки скробблинга сохранены.',
|
||||
error: 'Не удалось сохранить. Попробуйте ещё раз.',
|
||||
},
|
||||
},
|
||||
admin: {
|
||||
userDetail: 'Пользователь',
|
||||
|
||||
@@ -21,6 +21,7 @@ import './api/endpoints/search';
|
||||
import './api/endpoints/likes';
|
||||
import './api/endpoints/storage';
|
||||
import './api/endpoints/admin';
|
||||
import './api/endpoints/settings';
|
||||
import './api/endpoints/upload';
|
||||
|
||||
// Tier 3 offline: register the audio-caching service worker (no-op if the
|
||||
|
||||
Reference in New Issue
Block a user