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',
|
'Like',
|
||||||
'User',
|
'User',
|
||||||
'Storage',
|
'Storage',
|
||||||
|
'Settings',
|
||||||
],
|
],
|
||||||
// Tier 2 offline: seed the cache from the persisted snapshot dispatched at
|
// Tier 2 offline: seed the cache from the persisted snapshot dispatched at
|
||||||
// startup (see `store/rtkqPersist.ts`). Returning the saved queries/mutations
|
// startup (see `store/rtkqPersist.ts`). Returning the saved queries/mutations
|
||||||
|
|||||||
@@ -258,3 +258,24 @@ export interface MetadataEdit {
|
|||||||
genre?: string;
|
genre?: string;
|
||||||
trackNumber?: number;
|
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,
|
TabsContent,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
Card,
|
Card,
|
||||||
TextField,
|
|
||||||
Callout,
|
Callout,
|
||||||
SearchField,
|
SearchField,
|
||||||
} from '@olly/modern-sk';
|
} 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 { 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 { 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. */
|
/** Labelled settings row: caption on the left, control on the right. */
|
||||||
function SettingRow({
|
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). */
|
/** `/settings/profile` — profile + app language + theme (all wired). */
|
||||||
export function ProfileSettings() {
|
export function ProfileSettings() {
|
||||||
const { t, i18n } = useTranslation();
|
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() {
|
export function PlaybackSettings() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isError, refetch } = useGetSettingsQuery();
|
||||||
|
const [updateSettings, { isLoading: isSaving }] = useUpdateSettingsMutation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Window title={t('settings.tabs.playback')}>
|
<Window title={t('settings.tabs.playback')}>
|
||||||
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
|
{isLoading && <LoadingSkeleton rows={1} height={40} />}
|
||||||
{t('common.comingSoon')}
|
{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>
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Window>
|
</Window>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `/settings/scrobbling` — last.fm / ListenBrainz linking. Scaffold. */
|
const PROVIDERS: ScrobbleProvider[] = ['lastfm', 'listenbrainz'];
|
||||||
|
|
||||||
|
/** `/settings/scrobbling` — Last.fm / ListenBrainz linking (server-synced). */
|
||||||
export function ScrobblingSettings() {
|
export function ScrobblingSettings() {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<Window title={t('settings.tabs.scrobbling')}>
|
<Window title={t('settings.tabs.scrobbling')}>
|
||||||
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
|
{isLoading && <LoadingSkeleton rows={3} height={40} />}
|
||||||
{t('common.comingSoon')}
|
{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>
|
</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>
|
</Window>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -357,6 +357,41 @@ const en = {
|
|||||||
scrobbling: 'Scrobbling',
|
scrobbling: 'Scrobbling',
|
||||||
instance: 'Instance',
|
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: {
|
admin: {
|
||||||
userDetail: 'User',
|
userDetail: 'User',
|
||||||
|
|||||||
@@ -358,6 +358,41 @@ const ru: Translations = {
|
|||||||
scrobbling: 'Скробблинг',
|
scrobbling: 'Скробблинг',
|
||||||
instance: 'Сервер',
|
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: {
|
admin: {
|
||||||
userDetail: 'Пользователь',
|
userDetail: 'Пользователь',
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import './api/endpoints/search';
|
|||||||
import './api/endpoints/likes';
|
import './api/endpoints/likes';
|
||||||
import './api/endpoints/storage';
|
import './api/endpoints/storage';
|
||||||
import './api/endpoints/admin';
|
import './api/endpoints/admin';
|
||||||
|
import './api/endpoints/settings';
|
||||||
import './api/endpoints/upload';
|
import './api/endpoints/upload';
|
||||||
|
|
||||||
// Tier 3 offline: register the audio-caching service worker (no-op if the
|
// Tier 3 offline: register the audio-caching service worker (no-op if the
|
||||||
|
|||||||
Reference in New Issue
Block a user