From 57044dabba45df91156234ce1baf409ed5054eb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A6=D0=B2=D1=8B=D0=BB=D0=B5=D0=B2=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20=D0=92=D0=B0=D0=B4=D0=B8?= =?UTF-8?q?=D0=BC=D0=BE=D0=B2=D0=B8=D1=87?= Date: Tue, 28 Jul 2026 15:16:19 +0300 Subject: [PATCH] feat(settings): wire Playback + Scrobbling panels to the backend Adds src/api/endpoints/settings.ts (getSettings/updateSettings, getScrobbling/updateScrobbling) plus AppSettings/ScrobblingConfig types and a Settings cache tag. Replaces the "coming soon" scaffolds: - Playback: stream-quality preference (SegmentedControl), server-synced. - Scrobbling: enable + provider (Last.fm/ListenBrainz) + username + write-only session key, with a linked/not-linked badge and save states. Built with modern-sk (Window/SegmentedControl/TextField/Button/Callout/ Badge) + the shared Loading/Error states; en + ru strings added. Server state flows through RTK Query hooks only (no fetch in components). Also drops a pre-existing unused TextField import in LibraryPage that was failing `tsc --noEmit`. Co-Authored-By: Claude Opus 4.8 --- src/api/endpoints/settings.ts | 90 ++++++++++ src/api/index.ts | 1 + src/api/types.ts | 21 +++ src/features/library/LibraryPage.tsx | 1 - src/features/settings/panels.tsx | 246 +++++++++++++++++++++++++-- src/i18n/locales/en.ts | 35 ++++ src/i18n/locales/ru.ts | 35 ++++ src/index.tsx | 1 + 8 files changed, 419 insertions(+), 11 deletions(-) create mode 100644 src/api/endpoints/settings.ts diff --git a/src/api/endpoints/settings.ts b/src/api/endpoints/settings.ts new file mode 100644 index 0000000..a0f87b6 --- /dev/null +++ b/src/api/endpoints/settings.ts @@ -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({ + 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({ + 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; diff --git a/src/api/index.ts b/src/api/index.ts index b24149c..254370f 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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 diff --git a/src/api/types.ts b/src/api/types.ts index b7bf5b7..65967ed 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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; +} diff --git a/src/features/library/LibraryPage.tsx b/src/features/library/LibraryPage.tsx index 5e7ca42..bc83298 100644 --- a/src/features/library/LibraryPage.tsx +++ b/src/features/library/LibraryPage.tsx @@ -7,7 +7,6 @@ import { TabsContent, ScrollArea, Card, - TextField, Callout, SearchField, } from '@olly/modern-sk'; diff --git a/src/features/settings/panels.tsx b/src/features/settings/panels.tsx index a015af4..4352aab 100644 --- a/src/features/settings/panels.tsx +++ b/src/features/settings/panels.tsx @@ -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 ( -

- {t('common.comingSoon')} -

+ {isLoading && } + {isError && ( + refetch()} /> + )} + {data && ( +
+ + + void updateSettings({ streamQuality: v as StreamQuality }) + } + items={STREAM_QUALITIES.map((q) => ({ + value: q, + label: t(`settings.playback.quality.${q}`), + }))} + /> + +

+ {isSaving + ? t('settings.common.saving') + : t('settings.playback.streamQualityHint')} +

+
+ )}
); } -/** `/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('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 ( -

- {t('common.comingSoon')} -

+ {isLoading && } + {isError && ( + refetch()} /> + )} + {data && ( +
+
+

+ {t('settings.scrobbling.description')} +

+ + {data.configured + ? t('settings.scrobbling.configured') + : t('settings.scrobbling.notConfigured')} + +
+ + + setEnabled(v === 'on')} + items={[ + { value: 'on', label: t('settings.scrobbling.on') }, + { value: 'off', label: t('settings.scrobbling.off') }, + ]} + /> + + + {enabled && ( + <> + + setProvider(v as ScrobbleProvider)} + items={PROVIDERS.map((p) => ({ + value: p, + label: t(`settings.scrobbling.${p}`), + }))} + /> + + +
+ + setUsername(e.target.value)} + placeholder={t('settings.scrobbling.usernamePlaceholder')} + autoComplete="off" + /> +
+ +
+ + setSessionKey(e.target.value)} + placeholder={ + data.configured + ? t('settings.scrobbling.sessionKeyKeep') + : t('settings.scrobbling.sessionKeyPlaceholder') + } + autoComplete="off" + /> + + {t('settings.scrobbling.sessionKeyHint')} + +
+ + )} + + {status === 'saved' && ( + + {t('settings.scrobbling.saved')} + + )} + {status === 'error' && ( + {t('settings.scrobbling.error')} + )} + +
+ +
+
+ )}
); } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 9d7d17b..7658e05 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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', diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 281a11c..1457ab1 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -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: 'Пользователь', diff --git a/src/index.tsx b/src/index.tsx index af4a718..95f17f1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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