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
381 lines
14 KiB
TypeScript
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>;
|
|
}
|