mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-21 02:37:05 +02:00
feat: dragging
This commit is contained in:
1 parent
aee1ddb948
commit
71c1f8dac9
7 files changed
+316
-206
No files matched your search
@@ -1,5 +1,5 @@
|
||||
.tank-search-card {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -10,6 +10,10 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tank-search-card[data-provide-link='true'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tank-search-card .link {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
@@ -1,215 +1,105 @@
|
||||
import {
|
||||
asset,
|
||||
fetchModelDefinitions,
|
||||
normalizeBoundingBox,
|
||||
resolveDpm,
|
||||
tankIcon,
|
||||
TankType,
|
||||
unionBoundingBox,
|
||||
type TankDefinition,
|
||||
} from '@blitzkit/core';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { Flex, Link, Text } from '@radix-ui/themes';
|
||||
import { asset, tankIcon, TankType, type TankDefinition } from '@blitzkit/core';
|
||||
import { Flex, Link, Text, type TextProps } from '@radix-ui/themes';
|
||||
import { uniq } from 'lodash-es';
|
||||
import { useMemo } from 'react';
|
||||
import { resolveReload } from '../../core/blitzkit/resolveReload';
|
||||
import { forwardRef } from 'react';
|
||||
import { TankopediaPersistent } from '../../stores/tankopediaPersistent';
|
||||
import { $tankopediaSort } from '../../stores/tankopediaSort';
|
||||
import { classIcons } from '../ClassIcon';
|
||||
import { MAX_RECENTLY_VIEWED } from '../TankSearch/constants';
|
||||
import './index.css';
|
||||
|
||||
interface TankCardProps {
|
||||
type TankCardProps = TextProps & {
|
||||
tank: TankDefinition;
|
||||
onSelect?: (tank: TankDefinition) => void;
|
||||
}
|
||||
onTankSelect?: (tank: TankDefinition) => void;
|
||||
discriminator?: string;
|
||||
noLink?: boolean;
|
||||
};
|
||||
|
||||
const modelDefinitions = await fetchModelDefinitions();
|
||||
export const TankCard = forwardRef<HTMLSpanElement, TankCardProps>(
|
||||
(
|
||||
{
|
||||
tank,
|
||||
discriminator,
|
||||
onTankSelect: onSelect,
|
||||
noLink,
|
||||
style,
|
||||
...props
|
||||
}: TankCardProps,
|
||||
ref,
|
||||
) => {
|
||||
const provideLink = !noLink && onSelect === undefined;
|
||||
const mutateTankopediaPersistent = TankopediaPersistent.useMutation();
|
||||
const Icon = classIcons[tank.class];
|
||||
|
||||
export function TankCard({ tank, onSelect }: TankCardProps) {
|
||||
const mutateTankopediaPersistent = TankopediaPersistent.useMutation();
|
||||
const Icon = classIcons[tank.class];
|
||||
const tankopediaSort = useStore($tankopediaSort);
|
||||
const discriminator = useMemo(() => {
|
||||
if (tankopediaSort.by.startsWith('meta')) return undefined;
|
||||
|
||||
const turret = tank.turrets.at(-1)!;
|
||||
const gun = turret.guns.at(-1)!;
|
||||
const shell0 = gun.gun_type!.value.base.shells[0];
|
||||
const shell1 = gun.gun_type!.value.base.shells[1];
|
||||
const tracks = tank.tracks.at(-1)!;
|
||||
const engine = tank.engines.at(-1)!;
|
||||
const tankModelDefinition = modelDefinitions.models[tank.id];
|
||||
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
|
||||
const gunModelDefinition =
|
||||
turretModelDefinition.guns[gun.gun_type!.value.base.id];
|
||||
|
||||
switch (tankopediaSort.by) {
|
||||
case 'fire.aimTime':
|
||||
return gun.gun_type!.value.base.aim_time.toFixed(2);
|
||||
case 'fire.caliber':
|
||||
return shell0.caliber.toFixed(0);
|
||||
case 'fire.damage':
|
||||
return shell0.armor_damage.toFixed(0);
|
||||
case 'fire.moduleDamage':
|
||||
return shell0.armor_damage.toFixed(0);
|
||||
case 'fire.dispersionMoving':
|
||||
return tracks.dispersion_move.toFixed(3);
|
||||
case 'fire.dispersionStill':
|
||||
return gun.gun_type!.value.base.dispersion_base.toFixed(3);
|
||||
case 'fire.dpm':
|
||||
return resolveDpm(gun, shell0).toFixed(0);
|
||||
case 'fire.dpmPremium':
|
||||
return shell1 ? resolveDpm(gun, shell1).toFixed(0) : '--';
|
||||
case 'fire.reload':
|
||||
return resolveReload(gun).toFixed(2);
|
||||
case 'fire.standardPenetration':
|
||||
return shell0.penetration.near.toFixed(0);
|
||||
case 'fire.premiumPenetration':
|
||||
return shell1 ? shell1.penetration.near.toFixed(0) : '--';
|
||||
case 'fire.shellVelocity':
|
||||
return shell0.velocity.toFixed(0);
|
||||
case 'fire.shellCapacity':
|
||||
return gun.gun_type!.value.base.shell_capacity.toFixed(0);
|
||||
case 'fire.shellRange':
|
||||
return shell0.range.toFixed(0);
|
||||
case 'fire.gunDepression':
|
||||
return (
|
||||
gunModelDefinition.pitch.max +
|
||||
(tankModelDefinition.initial_turret_rotation?.pitch ?? 0)
|
||||
).toFixed(1);
|
||||
case 'fire.gunElevation':
|
||||
return (
|
||||
-gunModelDefinition.pitch.min -
|
||||
(tankModelDefinition.initial_turret_rotation?.pitch ?? 0)
|
||||
).toFixed(1);
|
||||
case 'maneuverability.forwardsSpeed':
|
||||
return tank.speed_forwards.toFixed(0);
|
||||
case 'maneuverability.backwardsSpeed':
|
||||
return tank.speed_backwards.toFixed(0);
|
||||
case 'maneuverability.power':
|
||||
return engine.power.toFixed(0);
|
||||
case 'maneuverability.powerToWeight':
|
||||
return (
|
||||
(1000 * engine.power) /
|
||||
(tank.weight +
|
||||
engine.weight +
|
||||
tracks.weight +
|
||||
turret.weight +
|
||||
gun.gun_type!.value.base.weight)
|
||||
).toFixed(1);
|
||||
case 'maneuverability.weight':
|
||||
return (
|
||||
(tank.weight +
|
||||
engine.weight +
|
||||
tracks.weight +
|
||||
turret.weight +
|
||||
gun.gun_type!.value.base.weight) /
|
||||
1000
|
||||
).toFixed(1);
|
||||
case 'maneuverability.traverseSpeed':
|
||||
return tracks.traverse_speed.toFixed(1);
|
||||
case 'survivability.health':
|
||||
return (tank.health + turret.health).toFixed(0);
|
||||
case 'survivability.viewRange':
|
||||
return turret.view_range.toFixed(0);
|
||||
case 'survivability.camouflageStill':
|
||||
return (tank.camouflage_still * 100).toFixed(0);
|
||||
case 'survivability.camouflageMoving':
|
||||
return (tank.camouflage_moving * 100).toFixed(0);
|
||||
case 'survivability.camouflageShooting':
|
||||
return (
|
||||
tank.camouflage_still *
|
||||
gun.gun_type!.value.base.camouflage_loss *
|
||||
100
|
||||
).toFixed(0);
|
||||
case 'survivability.volume': {
|
||||
const dimensions = normalizeBoundingBox(
|
||||
unionBoundingBox(
|
||||
tankModelDefinition.bounding_box,
|
||||
turretModelDefinition.bounding_box,
|
||||
),
|
||||
);
|
||||
|
||||
return (dimensions.x * dimensions.y * dimensions.z).toFixed(0);
|
||||
}
|
||||
|
||||
case 'survivability.length': {
|
||||
const bounds = normalizeBoundingBox(
|
||||
unionBoundingBox(
|
||||
tankModelDefinition.bounding_box,
|
||||
turretModelDefinition.bounding_box,
|
||||
),
|
||||
);
|
||||
|
||||
return Math.max(bounds.x, bounds.y, bounds.z).toFixed(0);
|
||||
}
|
||||
}
|
||||
}, [tankopediaSort.by]);
|
||||
|
||||
return (
|
||||
<Text
|
||||
tabIndex={onSelect ? 0 : undefined}
|
||||
size="1"
|
||||
color={
|
||||
tank.type === TankType.COLLECTOR
|
||||
? 'blue'
|
||||
: tank.type === TankType.PREMIUM
|
||||
? 'amber'
|
||||
: 'gray'
|
||||
}
|
||||
highContrast={tank.type === TankType.RESEARCHABLE}
|
||||
onClick={() => {
|
||||
onSelect?.(tank);
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.recentlyViewed = uniq([tank.id, ...draft.recentlyViewed]).slice(
|
||||
0,
|
||||
MAX_RECENTLY_VIEWED,
|
||||
);
|
||||
});
|
||||
}}
|
||||
className="tank-search-card"
|
||||
style={{
|
||||
backgroundImage: `url(${asset(`flags/scratched/${tank.nation}.webp`)})`,
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
className="link"
|
||||
underline="hover"
|
||||
href={onSelect ? '#' : `/tools/tankopedia/${tank.id}`}
|
||||
onClick={(event) => {
|
||||
if (onSelect) event.preventDefault();
|
||||
return (
|
||||
<Text
|
||||
ref={ref}
|
||||
tabIndex={onSelect ? 0 : undefined}
|
||||
size="1"
|
||||
color={
|
||||
tank.type === TankType.COLLECTOR
|
||||
? 'blue'
|
||||
: tank.type === TankType.PREMIUM
|
||||
? 'amber'
|
||||
: 'gray'
|
||||
}
|
||||
highContrast={tank.type === TankType.RESEARCHABLE}
|
||||
onClick={() => {
|
||||
onSelect?.(tank);
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.recentlyViewed = uniq([
|
||||
tank.id,
|
||||
...draft.recentlyViewed,
|
||||
]).slice(0, MAX_RECENTLY_VIEWED);
|
||||
});
|
||||
}}
|
||||
className="tank-search-card"
|
||||
data-provide-link={provideLink}
|
||||
style={{
|
||||
backgroundImage: `url(${asset(`flags/scratched/${tank.nation}.webp`)})`,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<img alt={tank.name} src={tankIcon(tank.id)} className="image" />
|
||||
|
||||
<Flex
|
||||
justify="center"
|
||||
gap="1"
|
||||
align="center"
|
||||
overflow="hidden"
|
||||
width="100%"
|
||||
<Link
|
||||
className="link"
|
||||
underline="hover"
|
||||
href={provideLink ? `/tools/tankopedia/${tank.id}` : undefined}
|
||||
>
|
||||
<Icon className="class-icon" />
|
||||
<Text align="center" className="name">
|
||||
{tank.name}
|
||||
</Text>
|
||||
</Flex>
|
||||
<img
|
||||
alt={tank.name}
|
||||
src={tankIcon(tank.id)}
|
||||
className="image"
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{discriminator && (
|
||||
<Text
|
||||
color="gray"
|
||||
<Flex
|
||||
justify="center"
|
||||
gap="1"
|
||||
align="center"
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
overflow="hidden"
|
||||
width="100%"
|
||||
>
|
||||
{discriminator}
|
||||
</Text>
|
||||
)}
|
||||
</Link>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
<Icon className="class-icon" />
|
||||
<Text align="center" className="name">
|
||||
{tank.name}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{discriminator && (
|
||||
<Text
|
||||
color="gray"
|
||||
align="center"
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{discriminator}
|
||||
</Text>
|
||||
)}
|
||||
</Link>
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
fetchModelDefinitions,
|
||||
normalizeBoundingBox,
|
||||
resolveDpm,
|
||||
unionBoundingBox,
|
||||
type TankDefinition,
|
||||
} from '@blitzkit/core';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useMemo } from 'react';
|
||||
import { resolveReload } from '../../../core/blitzkit/resolveReload';
|
||||
import { $tankopediaSort } from '../../../stores/tankopediaSort';
|
||||
import { TankCard } from '../../TankCard';
|
||||
|
||||
interface TankSearchCardProps {
|
||||
tank: TankDefinition;
|
||||
onSelect?: (tank: TankDefinition) => void;
|
||||
}
|
||||
|
||||
const modelDefinitions = await fetchModelDefinitions();
|
||||
|
||||
export function TankSearchCard({ tank, onSelect }: TankSearchCardProps) {
|
||||
const tankopediaSort = useStore($tankopediaSort);
|
||||
const discriminator = useMemo(() => {
|
||||
if (tankopediaSort.by.startsWith('meta')) return undefined;
|
||||
|
||||
const turret = tank.turrets.at(-1)!;
|
||||
const gun = turret.guns.at(-1)!;
|
||||
const shell0 = gun.gun_type!.value.base.shells[0];
|
||||
const shell1 = gun.gun_type!.value.base.shells[1];
|
||||
const tracks = tank.tracks.at(-1)!;
|
||||
const engine = tank.engines.at(-1)!;
|
||||
const tankModelDefinition = modelDefinitions.models[tank.id];
|
||||
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
|
||||
const gunModelDefinition =
|
||||
turretModelDefinition.guns[gun.gun_type!.value.base.id];
|
||||
|
||||
switch (tankopediaSort.by) {
|
||||
case 'fire.aimTime':
|
||||
return gun.gun_type!.value.base.aim_time.toFixed(2);
|
||||
case 'fire.caliber':
|
||||
return shell0.caliber.toFixed(0);
|
||||
case 'fire.damage':
|
||||
return shell0.armor_damage.toFixed(0);
|
||||
case 'fire.moduleDamage':
|
||||
return shell0.armor_damage.toFixed(0);
|
||||
case 'fire.dispersionMoving':
|
||||
return tracks.dispersion_move.toFixed(3);
|
||||
case 'fire.dispersionStill':
|
||||
return gun.gun_type!.value.base.dispersion_base.toFixed(3);
|
||||
case 'fire.dpm':
|
||||
return resolveDpm(gun, shell0).toFixed(0);
|
||||
case 'fire.dpmPremium':
|
||||
return shell1 ? resolveDpm(gun, shell1).toFixed(0) : '--';
|
||||
case 'fire.reload':
|
||||
return resolveReload(gun).toFixed(2);
|
||||
case 'fire.standardPenetration':
|
||||
return shell0.penetration.near.toFixed(0);
|
||||
case 'fire.premiumPenetration':
|
||||
return shell1 ? shell1.penetration.near.toFixed(0) : '--';
|
||||
case 'fire.shellVelocity':
|
||||
return shell0.velocity.toFixed(0);
|
||||
case 'fire.shellCapacity':
|
||||
return gun.gun_type!.value.base.shell_capacity.toFixed(0);
|
||||
case 'fire.shellRange':
|
||||
return shell0.range.toFixed(0);
|
||||
case 'fire.gunDepression':
|
||||
return (
|
||||
gunModelDefinition.pitch.max +
|
||||
(tankModelDefinition.initial_turret_rotation?.pitch ?? 0)
|
||||
).toFixed(1);
|
||||
case 'fire.gunElevation':
|
||||
return (
|
||||
-gunModelDefinition.pitch.min -
|
||||
(tankModelDefinition.initial_turret_rotation?.pitch ?? 0)
|
||||
).toFixed(1);
|
||||
case 'maneuverability.forwardsSpeed':
|
||||
return tank.speed_forwards.toFixed(0);
|
||||
case 'maneuverability.backwardsSpeed':
|
||||
return tank.speed_backwards.toFixed(0);
|
||||
case 'maneuverability.power':
|
||||
return engine.power.toFixed(0);
|
||||
case 'maneuverability.powerToWeight':
|
||||
return (
|
||||
(1000 * engine.power) /
|
||||
(tank.weight +
|
||||
engine.weight +
|
||||
tracks.weight +
|
||||
turret.weight +
|
||||
gun.gun_type!.value.base.weight)
|
||||
).toFixed(1);
|
||||
case 'maneuverability.weight':
|
||||
return (
|
||||
(tank.weight +
|
||||
engine.weight +
|
||||
tracks.weight +
|
||||
turret.weight +
|
||||
gun.gun_type!.value.base.weight) /
|
||||
1000
|
||||
).toFixed(1);
|
||||
case 'maneuverability.traverseSpeed':
|
||||
return tracks.traverse_speed.toFixed(1);
|
||||
case 'survivability.health':
|
||||
return (tank.health + turret.health).toFixed(0);
|
||||
case 'survivability.viewRange':
|
||||
return turret.view_range.toFixed(0);
|
||||
case 'survivability.camouflageStill':
|
||||
return (tank.camouflage_still * 100).toFixed(0);
|
||||
case 'survivability.camouflageMoving':
|
||||
return (tank.camouflage_moving * 100).toFixed(0);
|
||||
case 'survivability.camouflageShooting':
|
||||
return (
|
||||
tank.camouflage_still *
|
||||
gun.gun_type!.value.base.camouflage_loss *
|
||||
100
|
||||
).toFixed(0);
|
||||
case 'survivability.volume': {
|
||||
const dimensions = normalizeBoundingBox(
|
||||
unionBoundingBox(
|
||||
tankModelDefinition.bounding_box,
|
||||
turretModelDefinition.bounding_box,
|
||||
),
|
||||
);
|
||||
|
||||
return (dimensions.x * dimensions.y * dimensions.z).toFixed(0);
|
||||
}
|
||||
|
||||
case 'survivability.length': {
|
||||
const bounds = normalizeBoundingBox(
|
||||
unionBoundingBox(
|
||||
tankModelDefinition.bounding_box,
|
||||
turretModelDefinition.bounding_box,
|
||||
),
|
||||
);
|
||||
|
||||
return Math.max(bounds.x, bounds.y, bounds.z).toFixed(0);
|
||||
}
|
||||
}
|
||||
}, [tankopediaSort.by]);
|
||||
|
||||
return (
|
||||
<TankCard
|
||||
key={tank.id}
|
||||
onTankSelect={onSelect}
|
||||
tank={tank}
|
||||
discriminator={discriminator}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from '../../stores/tankopediaPersistent/constants';
|
||||
import { $tankopediaSort } from '../../stores/tankopediaSort';
|
||||
import { ExperimentIcon } from '../ExperimentIcon';
|
||||
import { TankCard } from '../TankCard';
|
||||
import { TankSearchCard } from './components/Card';
|
||||
import { FilterControl } from './components/FilterControl';
|
||||
import { NoResults } from './components/NoResults';
|
||||
import { RecentlyViewed } from './components/RecentlyViewed';
|
||||
@@ -504,7 +504,11 @@ export const TankSearch = memo<TankSearchProps>(
|
||||
{tanks.length > 0 && (
|
||||
<TankCardWrapper>
|
||||
{tanks.map((tank) => (
|
||||
<TankCard key={tank.id} onSelect={onSelect} tank={tank} />
|
||||
<TankSearchCard
|
||||
tank={tank}
|
||||
key={tank.id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
|
||||
{times(
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { TankDefinition } from '@blitzkit/core';
|
||||
import {
|
||||
useCallback,
|
||||
useRef,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from 'react';
|
||||
import { TankCard } from '../TankCard';
|
||||
|
||||
interface TierListTileProps {
|
||||
tank: TankDefinition;
|
||||
}
|
||||
|
||||
export function TierListTile({ tank }: TierListTileProps) {
|
||||
const card = useRef<HTMLDivElement>(null);
|
||||
const lastPosition = useRef({ x: 0, y: 0 });
|
||||
const handlePointerDown = useCallback(
|
||||
(event: ReactPointerEvent<HTMLSpanElement>) => {
|
||||
if (!card.current) return;
|
||||
|
||||
const rect = card.current.getBoundingClientRect();
|
||||
|
||||
card.current.style.position = 'fixed';
|
||||
card.current.style.cursor = 'grabbing';
|
||||
card.current.style.zIndex = '1';
|
||||
card.current.style.left = `${rect.left}px`;
|
||||
card.current.style.top = `${rect.top}px`;
|
||||
|
||||
lastPosition.current = { x: event.clientX, y: event.clientY };
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handlePointerMove = useCallback((event: PointerEvent) => {
|
||||
if (!card.current) return;
|
||||
|
||||
const rect = card.current.getBoundingClientRect();
|
||||
const dx = event.clientX - lastPosition.current.x;
|
||||
const dy = event.clientY - lastPosition.current.y;
|
||||
|
||||
card.current.style.left = `${rect.left + dx}px`;
|
||||
card.current.style.top = `${rect.top + dy}px`;
|
||||
|
||||
lastPosition.current = { x: event.clientX, y: event.clientY };
|
||||
}, []);
|
||||
const handlePointerUp = useCallback(() => {
|
||||
if (!card.current) return;
|
||||
|
||||
card.current.style.position = 'static';
|
||||
card.current.style.zIndex = 'unset';
|
||||
card.current.style.cursor = 'grab';
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TankCard
|
||||
ref={card}
|
||||
noLink
|
||||
tank={tank}
|
||||
style={{ cursor: 'grab' }}
|
||||
onPointerDown={handlePointerDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fetchTankDefinitions } from '@blitzkit/core';
|
||||
import { TankCard } from '../TankCard';
|
||||
import { TankCardWrapper } from '../TankSearch/components/TankCardWrapper';
|
||||
import { TierListTile } from './Tile';
|
||||
|
||||
const tankDefinitions = await fetchTankDefinitions();
|
||||
const tanks = Object.values(tankDefinitions.tanks);
|
||||
@@ -9,7 +9,7 @@ export function TierListTiles() {
|
||||
return (
|
||||
<TankCardWrapper>
|
||||
{tanks.map((tank) => (
|
||||
<TankCard tank={tank} />
|
||||
<TierListTile key={tank.id} tank={tank} />
|
||||
))}
|
||||
</TankCardWrapper>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PageWrapper } from '../../../components/PageWrapper';
|
||||
import { TierListTable } from '../../../components/TierList/Table';
|
||||
import { TierListTiles } from '../../../components/TierList/Tiles';
|
||||
import { TierListTable } from '../../../components/TierList/Table';
|
||||
import { TankopediaPersistent } from '../../../stores/tankopediaPersistent';
|
||||
|
||||
export function Page() {
|
||||
|
||||
Reference in new issue
Block a user