Public Access
feat(dashboard): Steam avatar and a banner from the most played game
This commit is contained in:
1 parent
2c360c9421
commit
23e502bf5b
11 files changed
+222
-37
No files matched your search
+1
-1
@@ -49,7 +49,7 @@ function App() {
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
{/* isolate: the background layer sits behind the content but in front of this div's own background. */}
|
{/* isolate: the background layer sits behind the content but in front of this div's own background. */}
|
||||||
<div className="isolate min-h-screen flex flex-col bg-background text-foreground font-sans">
|
<div className="isolate min-h-screen flex flex-col bg-background text-foreground font-sans">
|
||||||
{isAuthenticated && <PageBackground key={appearance.backgroundAppid} appid={appearance.backgroundAppid}/>}
|
{isAuthenticated && <PageBackground appid={appearance.backgroundAppid}/>}
|
||||||
<AppHeader isAuthenticated={isAuthenticated} onLogout={handleLogout}/>
|
<AppHeader isAuthenticated={isAuthenticated} onLogout={handleLogout}/>
|
||||||
|
|
||||||
<main className="grow flex flex-col">
|
<main className="grow flex flex-col">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {Link, NavLink, useLocation} from 'react-router-dom';
|
import {Link, NavLink, useLocation} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {Gamepad2, Languages, LayoutDashboard, Library, LogOut, Moon, Settings, Sun, UserRound} from 'lucide-react';
|
import {Gamepad2, Languages, LayoutDashboard, Library, LogOut, Moon, Settings, Sun} from 'lucide-react';
|
||||||
|
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
import {usePreferences} from '@/context/PreferencesContext.jsx';
|
import {usePreferences} from '@/context/PreferencesContext.jsx';
|
||||||
import {cn} from '@/lib/utils';
|
import {cn} from '@/lib/utils';
|
||||||
import PageContainer from './PageContainer.jsx';
|
import PageContainer from './PageContainer.jsx';
|
||||||
|
import SteamAvatar from './SteamAvatar.jsx';
|
||||||
|
import useSteamProfile from '../hooks/useSteamProfile.js';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
|
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
|
||||||
@@ -31,6 +33,7 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
|||||||
const {t, i18n} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
const {isDarkMode, setDarkMode, setLanguage} = usePreferences();
|
const {isDarkMode, setDarkMode, setLanguage} = usePreferences();
|
||||||
const {pathname} = useLocation();
|
const {pathname} = useLocation();
|
||||||
|
const steamProfile = useSteamProfile(isAuthenticated);
|
||||||
|
|
||||||
const toggleLanguage = () => setLanguage(i18n.language === 'de' ? 'en' : 'de');
|
const toggleLanguage = () => setLanguage(i18n.language === 'de' ? 'en' : 'de');
|
||||||
const toggleTheme = () => setDarkMode(!isDarkMode);
|
const toggleTheme = () => setDarkMode(!isDarkMode);
|
||||||
@@ -62,8 +65,8 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
|||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" aria-label={t('NAV.MENU')}>
|
<Button variant="ghost" size="icon" aria-label={t('NAV.MENU')} title={steamProfile?.personaName ?? undefined}>
|
||||||
<UserRound className="size-5"/>
|
<SteamAvatar url={steamProfile?.avatarUrl}/>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-52">
|
<DropdownMenuContent align="end" className="w-52">
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {useState} from 'react';
|
||||||
|
import {coverUrl, heroUrl} from '../utils/games.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wide library art of a game, else its store header, else nothing. Give it `key={appid}`, so a new game
|
||||||
|
* starts with its library art again.
|
||||||
|
*/
|
||||||
|
export default function HeroArt({appid, className}) {
|
||||||
|
const [failures, setFailures] = useState(0);
|
||||||
|
|
||||||
|
if (!appid || failures > 1) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img src={failures === 0 ? heroUrl(appid) : coverUrl(appid)} alt=""
|
||||||
|
onError={() => setFailures((count) => count + 1)} className={className}/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,25 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, describe, expect, it} from 'vitest';
|
import {afterEach, describe, expect, it} from 'vitest';
|
||||||
import {cleanup, fireEvent, render} from '@testing-library/react';
|
import {cleanup, fireEvent, render} from '@testing-library/react';
|
||||||
|
import HeroArt from './HeroArt.jsx';
|
||||||
import PageBackground from './PageBackground.jsx';
|
import PageBackground from './PageBackground.jsx';
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe('HeroArt', () => {
|
||||||
|
it('shows the library art, then the store header, then nothing', () => {
|
||||||
|
const {container} = render(<HeroArt appid={620}/>);
|
||||||
|
const img = () => container.querySelector('img');
|
||||||
|
expect(img().getAttribute('src')).toContain('/620/library_hero.jpg');
|
||||||
|
|
||||||
|
fireEvent.error(img());
|
||||||
|
expect(img().getAttribute('src')).toContain('/620/header.jpg');
|
||||||
|
|
||||||
|
fireEvent.error(img());
|
||||||
|
expect(img()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('PageBackground', () => {
|
describe('PageBackground', () => {
|
||||||
it('shows nothing without a game', () => {
|
it('shows nothing without a game', () => {
|
||||||
const {container} = render(<PageBackground appid={null}/>);
|
const {container} = render(<PageBackground appid={null}/>);
|
||||||
@@ -12,15 +27,12 @@ describe('PageBackground', () => {
|
|||||||
expect(container.innerHTML).toBe('');
|
expect(container.innerHTML).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the library art, then the store header, then nothing', () => {
|
it('starts with the library art again for another game', () => {
|
||||||
const {container} = render(<PageBackground appid={620}/>);
|
const {container, rerender} = render(<PageBackground appid={620}/>);
|
||||||
const img = () => container.querySelector('img');
|
fireEvent.error(container.querySelector('img'));
|
||||||
expect(img().getAttribute('src')).toContain('/620/library_hero.jpg');
|
|
||||||
|
|
||||||
fireEvent.error(img());
|
rerender(<PageBackground appid={220}/>);
|
||||||
expect(img().getAttribute('src')).toContain('/620/header.jpg');
|
|
||||||
|
|
||||||
fireEvent.error(img());
|
expect(container.querySelector('img').getAttribute('src')).toContain('/220/library_hero.jpg');
|
||||||
expect(container.innerHTML).toBe('');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,20 +1,14 @@
|
|||||||
import {useState} from 'react';
|
import HeroArt from './HeroArt.jsx';
|
||||||
import {coverUrl, heroUrl} from '../utils/games.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Game art behind the whole page, blurred and dimmed so text on it stays readable. Falls back to the store
|
* Game art behind the whole page, blurred and dimmed so text on it stays readable.
|
||||||
* header for apps without library art. Give it `key={appid}`, so a new game starts with its library art again.
|
|
||||||
*/
|
*/
|
||||||
export default function PageBackground({appid}) {
|
export default function PageBackground({appid}) {
|
||||||
const [failures, setFailures] = useState(0);
|
if (!appid) return null;
|
||||||
|
|
||||||
if (!appid || failures > 1) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div aria-hidden="true" className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
|
<div aria-hidden="true" className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
|
||||||
<img src={failures === 0 ? heroUrl(appid) : coverUrl(appid)} alt=""
|
<HeroArt key={appid} appid={appid} className="size-full scale-110 object-cover blur-md"/>
|
||||||
onError={() => setFailures((count) => count + 1)}
|
|
||||||
className="size-full scale-110 object-cover blur-md"/>
|
|
||||||
<div className="absolute inset-0 bg-background/80"/>
|
<div className="absolute inset-0 bg-background/80"/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import {useState} from 'react';
|
||||||
|
import {UserRound} from 'lucide-react';
|
||||||
|
import {cn} from '@/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Steam avatar, or the default user icon without one or when it does not load.
|
||||||
|
*
|
||||||
|
* @param className for the avatar image
|
||||||
|
* @param iconClassName for the default icon, which should be smaller than the image
|
||||||
|
*/
|
||||||
|
export default function SteamAvatar({url, className, iconClassName}) {
|
||||||
|
const [failedUrl, setFailedUrl] = useState(null);
|
||||||
|
|
||||||
|
if (!url || failedUrl === url) {
|
||||||
|
return <UserRound className={cn('size-5', iconClassName)}/>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<img src={url} alt="" onError={() => setFailedUrl(url)}
|
||||||
|
className={cn('size-7 rounded-full object-cover', className)}/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, describe, expect, it} from 'vitest';
|
||||||
|
import {cleanup, fireEvent, render} from '@testing-library/react';
|
||||||
|
import SteamAvatar from './SteamAvatar.jsx';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe('SteamAvatar', () => {
|
||||||
|
it('shows the avatar and the default icon when it does not load', () => {
|
||||||
|
const {container} = render(<SteamAvatar url="https://avatars.steamstatic.com/a_full.jpg"/>);
|
||||||
|
expect(container.querySelector('img').getAttribute('src')).toBe('https://avatars.steamstatic.com/a_full.jpg');
|
||||||
|
|
||||||
|
fireEvent.error(container.querySelector('img'));
|
||||||
|
|
||||||
|
expect(container.querySelector('img')).toBeNull();
|
||||||
|
expect(container.querySelector('svg')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the default icon without an avatar', () => {
|
||||||
|
const {container} = render(<SteamAvatar url={null}/>);
|
||||||
|
|
||||||
|
expect(container.querySelector('img')).toBeNull();
|
||||||
|
expect(container.querySelector('svg')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import {useEffect, useState} from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's Steam name and avatar from Core, or null while loading, without a Steam ID or on any error. Only
|
||||||
|
* decoration, so a failed call shows the default icon and never ends the session. Loads again for a new token
|
||||||
|
* (new Steam ID or another login) on the next render, e.g. the next navigation.
|
||||||
|
*/
|
||||||
|
export default function useSteamProfile(enabled) {
|
||||||
|
const [profile, setProfile] = useState(null);
|
||||||
|
const token = localStorage.getItem('tbp_jwt');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !token) {
|
||||||
|
setProfile(null);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/steam-profile`, {
|
||||||
|
headers: {'Authorization': `Bearer ${token}`},
|
||||||
|
});
|
||||||
|
const data = response.ok ? await response.json() : null;
|
||||||
|
if (!cancelled) setProfile(data);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setProfile(null);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [enabled, token]);
|
||||||
|
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, renderHook, waitFor} from '@testing-library/react';
|
||||||
|
import useSteamProfile from './useSteamProfile.js';
|
||||||
|
|
||||||
|
const ok = (body) => ({ok: true, status: 200, json: async () => body});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('tbp_jwt', 'old-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useSteamProfile', () => {
|
||||||
|
it('loads again after a new token was stored, e.g. with a new Steam ID', async () => {
|
||||||
|
globalThis.fetch = vi.fn(async (url, init) => ok(init.headers.Authorization === 'Bearer new-token'
|
||||||
|
? {personaName: 'New', avatarUrl: 'https://avatars.steamstatic.com/new_full.jpg'}
|
||||||
|
: {personaName: 'Old', avatarUrl: 'https://avatars.steamstatic.com/old_full.jpg'}));
|
||||||
|
const {result, rerender} = renderHook(() => useSteamProfile(true));
|
||||||
|
await waitFor(() => expect(result.current?.avatarUrl).toContain('old_full'));
|
||||||
|
|
||||||
|
localStorage.setItem('tbp_jwt', 'new-token');
|
||||||
|
rerender();
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current?.avatarUrl).toContain('new_full'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends nothing without a token, e.g. right after the session expired', async () => {
|
||||||
|
localStorage.removeItem('tbp_jwt');
|
||||||
|
globalThis.fetch = vi.fn(async () => ({ok: false, status: 401, json: async () => ({})}));
|
||||||
|
renderHook(() => useSteamProfile(true));
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
+34
-15
@@ -19,6 +19,10 @@ import useForceSync from '../hooks/useForceSync.js';
|
|||||||
import {formatPlaytime} from '../utils/games.js';
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
|
import HeroArt from '../components/HeroArt.jsx';
|
||||||
|
import SteamAvatar from '../components/SteamAvatar.jsx';
|
||||||
|
import useSteamProfile from '../hooks/useSteamProfile.js';
|
||||||
|
import {cn} from '@/lib/utils';
|
||||||
|
|
||||||
const TOP_GAMES = 5;
|
const TOP_GAMES = 5;
|
||||||
// The games endpoint accepts only the page sizes 20, 40 and 80.
|
// The games endpoint accepts only the page sizes 20, 40 and 80.
|
||||||
@@ -185,6 +189,8 @@ const Dashboard = () => {
|
|||||||
}, [navigate, expireSession, t, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity]);
|
}, [navigate, expireSession, t, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity]);
|
||||||
|
|
||||||
const hasSteamId = !!userData?.steamId;
|
const hasSteamId = !!userData?.steamId;
|
||||||
|
const steamProfile = useSteamProfile(hasSteamId);
|
||||||
|
const bannerAppid = activity?.recentGames?.[0]?.appid ?? null;
|
||||||
const sync = useLibrarySync({
|
const sync = useLibrarySync({
|
||||||
enabled: hasSteamId,
|
enabled: hasSteamId,
|
||||||
expectInitialSync: hasSteamId && steamData.count === 0,
|
expectInitialSync: hasSteamId && steamData.count === 0,
|
||||||
@@ -211,22 +217,35 @@ const Dashboard = () => {
|
|||||||
return (
|
return (
|
||||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||||
<Card className="relative overflow-hidden p-6 md:p-8 flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
<Card className="relative overflow-hidden p-6 md:p-8 flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
||||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/15 via-transparent to-transparent"/>
|
{/* The banner is the game played most in the last two weeks; the gradient keeps the text readable. */}
|
||||||
<div className="relative min-w-0">
|
<HeroArt key={bannerAppid} appid={bannerAppid}
|
||||||
<p className="text-sm font-medium text-muted-foreground">{t('DASHBOARD.WELCOME_BACK')}</p>
|
className="pointer-events-none absolute inset-0 size-full object-cover opacity-60"/>
|
||||||
<h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight truncate">
|
<div className={cn('pointer-events-none absolute inset-0', bannerAppid
|
||||||
{t('DASHBOARD.HELLO')} {userData?.displayName ?? userData?.email?.split('@')[0]} 👋
|
? 'bg-gradient-to-r from-card via-card/85 to-card/60'
|
||||||
</h1>
|
: 'bg-gradient-to-br from-primary/15 via-transparent to-transparent')}/>
|
||||||
{hasSteamId ? (
|
<div className="relative flex min-w-0 items-center gap-4">
|
||||||
<Badge variant="outline" className="mt-3 gap-1.5 font-mono text-muted-foreground">
|
{/* The slot is there before the avatar loads, so the title does not jump. */}
|
||||||
<Hash className="size-3"/>
|
{hasSteamId && (
|
||||||
{userData.steamId}
|
<span className="flex size-16 shrink-0 items-center justify-center rounded-full bg-muted ring-2 ring-primary/60">
|
||||||
</Badge>
|
<SteamAvatar url={steamProfile?.avatarUrl} className="size-16" iconClassName="size-8 text-muted-foreground"/>
|
||||||
) : (
|
</span>
|
||||||
<Button asChild variant="link" className="mt-2 h-auto p-0">
|
|
||||||
<Link to="/settings"><Link2 className="size-4"/>{t('DASHBOARD.NO_STEAM_ID')}</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">{t('DASHBOARD.WELCOME_BACK')}</p>
|
||||||
|
<h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight truncate">
|
||||||
|
{t('DASHBOARD.HELLO')} {userData?.displayName ?? userData?.email?.split('@')[0]} 👋
|
||||||
|
</h1>
|
||||||
|
{hasSteamId ? (
|
||||||
|
<Badge variant="outline" className="mt-3 gap-1.5 font-mono text-muted-foreground">
|
||||||
|
<Hash className="size-3"/>
|
||||||
|
{userData.steamId}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Button asChild variant="link" className="mt-2 h-auto p-0">
|
||||||
|
<Link to="/settings"><Link2 className="size-4"/>{t('DASHBOARD.NO_STEAM_ID')}</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasSteamId && (
|
{hasSteamId && (
|
||||||
|
|||||||
@@ -47,4 +47,24 @@ describe('Dashboard', () => {
|
|||||||
expect(current.location.state).toEqual({sessionExpired: true, from: '/dashboard'});
|
expect(current.location.state).toEqual({sessionExpired: true, from: '/dashboard'});
|
||||||
expect(screen.queryByText('LIBRARY.EMPTY')).toBeNull();
|
expect(screen.queryByText('LIBRARY.EMPTY')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows the Steam avatar and the most played game of the last two weeks as banner', async () => {
|
||||||
|
const avatar = 'https://avatars.steamstatic.com/abc_full.jpg';
|
||||||
|
globalThis.fetch = vi.fn(async (url) => {
|
||||||
|
if (url.includes('/api/users/me')) return ok({email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000'});
|
||||||
|
if (url.includes('/api/library/steam-profile')) return ok({personaName: 'Lobo', avatarUrl: avatar});
|
||||||
|
if (url.includes('/api/library/activity')) return ok({totalMinutes: 90, days: [{day: '2026-09-23', minutes: 0}, {day: '2026-09-24', minutes: 90}], recentGames: [
|
||||||
|
{appid: 1086940, name: "Baldur's Gate 3", playtimeMinutes: 900, playtime2Weeks: 90, status: 'PLAYING'}]});
|
||||||
|
if (url.includes('/api/library/games-count')) return ok({count: 1, hasSteamId: true});
|
||||||
|
return ok({content: [], total: 0, byStatus: {}});
|
||||||
|
});
|
||||||
|
const {container} = render(
|
||||||
|
<MemoryRouter initialEntries={['/dashboard']}><PreferencesProvider>
|
||||||
|
<Dashboard/>
|
||||||
|
</PreferencesProvider></MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(container.querySelector(`img[src="${avatar}"]`)).not.toBeNull());
|
||||||
|
expect(container.querySelector('img[src$="/1086940/library_hero.jpg"]')).not.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user