ci / api / api (push) Successful in 40s
ci / db / db (push) Successful in 1m3s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 1m48s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 46s
ci / ts / ts (packages + web) (push) Failing after 4m57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 5m7s
Quality upgrades, batch covers, staging preview, and resume after reload are in the queue. Loudness and crossfade work on web and Android, and the documented checks now have suites. Co-authored-by: Cursor <cursoragent@cursor.com>
343 lines
12 KiB
TypeScript
343 lines
12 KiB
TypeScript
import type { components } from '@pipu/api-client';
|
|
import { unwrap } from '@pipu/core';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useState, type FormEvent } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { useSession, useUser } from '../app/session';
|
|
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';
|
|
|
|
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 />
|
|
<AppearanceSection />
|
|
<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 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>
|
|
<Button variant="danger" onClick={() => void session.logout(true)}>
|
|
{t('settings.logout_all')}
|
|
</Button>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|