Files
pipu-music/apps/web/src/pages/SettingsPage.tsx
T
mrleo1nid 11a16cd023
ci / api / api (push) Canceled after 0s
ci / module boundaries / boundaries (push) Canceled after 0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Canceled after 11m29s
ci / db / db (push) Canceled after 11m22s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 6m43s
ci / recommender / recommender (push) Canceled after 6m47s
ci / ts / ts (packages + web) (push) Canceled after 1s
feat(social): add listening activity, shared rooms and modern chat
2026-10-23 10:45:40 +03:00

381 lines
14 KiB
TypeScript

import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../app/session';
import { useDesktop } from '../desktop';
import { useErrorText, useFieldError } from '../shared/lib/errors';
import { formatDateTime } from '../shared/lib/format';
import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
import { LanguageSwitch } from '../widgets/LanguageSwitch';
import s from './page.module.css';
import { Onboarding } from './Onboarding';
import { FriendRecommendations } from '../features/social/Social';
import { DesktopOfflineSection, DesktopUpdateButton } from '../features/desktop/DesktopFeatures';
type Theme = NonNullable<components['schemas']['UiPrefs']['theme']>;
type WaveVariant = NonNullable<components['schemas']['UiPrefs']['wave_variant']>;
export function SettingsPage() {
const { t } = useTranslation();
return (
<div className={`${s.page} ${s.narrow}`}>
<div className={s.head}>
<h1>{t('settings.title')}</h1>
</div>
<ProfileSection />
<RecommendationSettings />
<AppearanceSection />
<DesktopOfflineSection />
<DesktopUpdateButton />
<PasswordSection />
<SessionsSection />
</div>
);
}
function ProfileSection() {
const { t } = useTranslation();
const session = useSession();
const user = useUser();
const errorText = useErrorText();
const [name, setName] = useState(user.display_name ?? '');
const save = useMutation({
mutationFn: async () => unwrap(await session.client.PATCH('/api/v1/me', { body: { display_name: name.trim() } })),
onSuccess: (u) => session.setUser(u),
});
return (
<section className={s.section}>
<h2>{t('settings.account')}</h2>
<form
onSubmit={(e) => {
e.preventDefault();
save.mutate();
}}
>
<div className={s.row}>
<Field
label={t('settings.display_name')}
value={name}
onChange={(v) => {
setName(v);
save.reset();
}}
maxLength={64}
hint={`@${user.username}`}
/>
<Button type="submit" busy={save.isPending} disabled={name.trim() === (user.display_name ?? '')}>
{t('settings.save')}
</Button>
</div>
{save.isSuccess && <span className={s.ok}>{t('settings.saved')}</span>}
<FormError>{save.error ? errorText(save.error) : null}</FormError>
</form>
</section>
);
}
function AppearanceSection() {
const { t } = useTranslation();
const session = useSession();
const user = useUser();
const theme: Theme = user.ui_prefs.theme ?? 'dark';
const wave: WaveVariant = user.ui_prefs.wave_variant ?? 'orb';
// Оптимистично: настройка применяется сразу, сервер подтверждает.
const setPrefs = async (prefs: components['schemas']['UiPrefs']) => {
session.setUser({ ...user, ui_prefs: { ...user.ui_prefs, ...prefs } });
try {
session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { ui_prefs: prefs } })));
} catch {
session.setUser(user);
}
};
const setTheme = (value: Theme) => setPrefs({ theme: value });
return (
<section className={s.section}>
<h2>{t('settings.appearance')}</h2>
<div className={s.settingRow}>
<span>{t('settings.theme')}</span>
<Segmented<Theme>
label={t('settings.theme')}
value={theme}
onChange={(v) => void setTheme(v)}
options={[
{ value: 'dark', label: t('settings.theme_dark') },
{ value: 'light', label: t('settings.theme_light') },
{ value: 'system', label: t('settings.theme_system') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.wave_variant')}</span>
<Segmented<WaveVariant>
label={t('settings.wave_variant')}
value={wave}
onChange={(v) => void setPrefs({ wave_variant: v })}
options={[
{ value: 'orb', label: t('settings.wave_orb') },
{ value: 'liquid', label: t('settings.wave_liquid') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.player_view')}</span>
<Segmented<'bar' | 'island'>
label={t('settings.player_view')}
value={user.ui_prefs.web_player ?? 'bar'}
onChange={(v) => void setPrefs({ web_player: v })}
options={[
{ value: 'bar', label: t('settings.player_bar') },
{ value: 'island', label: t('settings.player_island') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.search_position')}</span>
<Segmented<'left' | 'center' | 'right'>
label={t('settings.search_position')}
value={user.ui_prefs.search_position ?? 'center'}
onChange={(v) => void setPrefs({ search_position: v })}
options={[
{ value: 'left', label: t('settings.search_left') },
{ value: 'center', label: t('settings.search_center') },
{ value: 'right', label: t('settings.search_right') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.search_collapse')}</span>
<Segmented<'on' | 'off'>
label={t('settings.search_collapse')}
value={user.ui_prefs.search_collapse === true ? 'on' : 'off'}
onChange={(v) => void setPrefs({ search_collapse: v === 'on' })}
options={[
{ value: 'on', label: t('settings.on') },
{ value: 'off', label: t('settings.off') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.home_activities')}</span>
<Segmented<'on' | 'off'>
label={t('settings.home_activities')}
value={user.ui_prefs.home_activities === false ? 'off' : 'on'}
onChange={(v) => void setPrefs({ home_activities: v === 'on' })}
options={[
{ value: 'on', label: t('settings.on') },
{ value: 'off', label: t('settings.off') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.home_moods')}</span>
<Segmented<'on' | 'off'>
label={t('settings.home_moods')}
value={user.ui_prefs.home_moods === false ? 'off' : 'on'}
onChange={(v) => void setPrefs({ home_moods: v === 'on' })}
options={[
{ value: 'on', label: t('settings.on') },
{ value: 'off', label: t('settings.off') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.ambient')}</span>
<Segmented<'on' | 'off'>
label={t('settings.ambient')}
value={user.ui_prefs.ambient === false ? 'off' : 'on'}
onChange={(v) => void setPrefs({ ambient: v === 'on' })}
options={[
{ value: 'on', label: t('settings.on') },
{ value: 'off', label: t('settings.off') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.normalize')}</span>
<Segmented<'on' | 'off'>
label={t('settings.normalize')}
value={user.ui_prefs.normalize === false ? 'off' : 'on'}
onChange={(v) => void setPrefs({ normalize: v === 'on' })}
options={[
{ value: 'on', label: t('settings.on') },
{ value: 'off', label: t('settings.off') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.crossfade', { seconds: user.ui_prefs.crossfade_s ?? 0 })}</span>
<input
type="range"
min={0}
max={6}
step={1}
aria-label={t('settings.crossfade', { seconds: user.ui_prefs.crossfade_s ?? 0 })}
value={user.ui_prefs.crossfade_s ?? 0}
onChange={(e) => void setPrefs({ crossfade_s: Number(e.target.value) })}
/>
</div>
<div className={s.settingRow}>
<span>{t('language.label')}</span>
<LanguageSwitch />
</div>
</section>
);
}
function PasswordSection() {
const { t } = useTranslation(['common', 'auth']);
const session = useSession();
const user = useUser();
const queryClient = useQueryClient();
const errorText = useErrorText();
const fieldError = useFieldError();
const [current, setCurrent] = useState('');
const [next, setNext] = useState('');
const [repeat, setRepeat] = useState('');
const mismatch = repeat !== '' && repeat !== next;
const change = useMutation({
mutationFn: async () =>
unwrap(
await session.client.POST('/api/v1/me/password', { body: { current_password: current, new_password: next } }),
),
onSuccess: async (tokens) => {
await session.apply(tokens);
setCurrent('');
setNext('');
setRepeat('');
await queryClient.invalidateQueries({ queryKey: ['sessions'] });
},
});
const err = change.error;
const currentError = fieldError(err, 'current_password');
const nextError = fieldError(err, 'new_password');
const submit = (e: FormEvent) => {
e.preventDefault();
if (!mismatch) change.mutate();
};
return (
<section className={s.section}>
<h2>{t('settings.password')}</h2>
<form onSubmit={submit} noValidate>
<Field
label={t('settings.current_password')}
type="password"
value={current}
onChange={setCurrent}
autoComplete="current-password"
error={currentError}
/>
<Field
label={t('settings.new_password')}
type="password"
value={next}
onChange={setNext}
autoComplete="new-password"
hint={t('auth:setup.min_length', { min: user.role === 'user' ? 8 : 12 })}
error={nextError}
/>
<Field
label={t('settings.repeat_password')}
type="password"
value={repeat}
onChange={setRepeat}
autoComplete="new-password"
error={mismatch ? t('form.passwords_mismatch') : undefined}
/>
{change.isSuccess && <span className={s.ok}>{t('settings.password_changed')}</span>}
<FormError>{err && !currentError && !nextError ? errorText(err) : null}</FormError>
<div className={s.actions}>
<Button type="submit" variant="primary" busy={change.isPending} disabled={!current || !next || next !== repeat}>
{t('settings.change_password')}
</Button>
</div>
</form>
</section>
);
}
function SessionsSection() {
const { t, i18n } = useTranslation();
const session = useSession();
const desktop = useDesktop();
const navigate = useNavigate();
const queryClient = useQueryClient();
const errorText = useErrorText();
const sessions = useQuery({
queryKey: ['sessions'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/sessions')).items,
});
const revoke = useMutation({
mutationFn: async (id: string) =>
unwrap(await session.client.DELETE('/api/v1/me/sessions/{id}', { params: { path: { id } } })),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['sessions'] }),
});
return (
<section className={s.section}>
<h2>{t('settings.sessions')}</h2>
<div className={s.sessions}>
{sessions.data?.map((ss) => (
<div key={ss.id} className={s.session}>
<div>
<div>{ss.device_name ?? ss.client_type}</div>
<div className={s.sessionMeta}>
{ss.current
? t('settings.session_current')
: t('settings.session_last_used', { date: formatDateTime(ss.last_used_at, i18n.language) })}
</div>
</div>
{!ss.current && (
<Button variant="ghost" busy={revoke.isPending && revoke.variables === ss.id} onClick={() => revoke.mutate(ss.id)}>
{t('settings.session_end')}
</Button>
)}
</div>
))}
</div>
<FormError>{sessions.error || revoke.error ? errorText(sessions.error ?? revoke.error) : null}</FormError>
<div>
{desktop && (
<Button
onClick={() => {
void (async () => {
await desktop.clearServer();
await session.logout();
await navigate({ to: '/connect' });
})();
}}
>
{t('mobile.change_server')}
</Button>
)}
<Button variant="danger" onClick={() => void session.logout(true)}>
{t('settings.logout_all')}
</Button>
</div>
</section>
);
}
function RecommendationSettings() {
const { t } = useTranslation();
const session = useSession();
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const errorText = useErrorText();
const options = useQuery({ queryKey: ['onboarding'], queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')), staleTime: 0 });
return <section className={s.section}>
<FriendRecommendations />
{editing && options.data ? <Onboarding key={JSON.stringify(options.data.choice)} options={options.data} editing onDone={() => { setEditing(false); void queryClient.invalidateQueries({ queryKey: ['onboarding'] }); }} />
: <Button busy={options.isFetching} onClick={() => { void options.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }}>{t('onboarding.edit')}</Button>}
<FormError>{options.error ? errorText(options.error) : null}</FormError>
</section>;
}