Compare commits
3 Commits
feat/backe
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a332703e2f | ||
| 1d89e2bce2 | |||
|
|
800fbfa560 |
@@ -1 +1,11 @@
|
|||||||
export {};
|
export {
|
||||||
|
formatDistance,
|
||||||
|
formatRaceDate,
|
||||||
|
getPaceLabel,
|
||||||
|
getRaceCountdownLabel,
|
||||||
|
getRaceStatusLabel,
|
||||||
|
parseFinishTimeToSeconds,
|
||||||
|
sortByDateAsc,
|
||||||
|
sortByDateDesc,
|
||||||
|
splitRacesByDate,
|
||||||
|
} from "./raceMetrics";
|
||||||
|
|||||||
110
frontend/src/lib/raceMetrics.ts
Normal file
110
frontend/src/lib/raceMetrics.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import type { Race } from "../api";
|
||||||
|
|
||||||
|
const MS_IN_DAY = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function parseRaceDate(date: string): Date {
|
||||||
|
return new Date(`${date}T00:00:00`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFinishTimeToSeconds(value: string | null): number | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = value.split(":").map((part) => Number(part));
|
||||||
|
if (parts.some((part) => Number.isNaN(part) || part < 0)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 2) {
|
||||||
|
const [minutes, seconds] = parts;
|
||||||
|
return minutes * 60 + seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 3) {
|
||||||
|
const [hours, minutes, seconds] = parts;
|
||||||
|
return hours * 3600 + minutes * 60 + seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDistance(distanceKm: number): string {
|
||||||
|
return `${distanceKm.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} км`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRaceDate(date: string): string {
|
||||||
|
return parseRaceDate(date).toLocaleDateString("ru-RU", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortByDateAsc(races: Race[]): Race[] {
|
||||||
|
return [...races].sort((left, right) => parseRaceDate(left.date).getTime() - parseRaceDate(right.date).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortByDateDesc(races: Race[]): Race[] {
|
||||||
|
return [...races].sort((left, right) => parseRaceDate(right.date).getTime() - parseRaceDate(left.date).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitRacesByDate(races: Race[], now: Date = new Date()): { upcoming: Race[]; past: Race[] } {
|
||||||
|
const today = new Date(now);
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const upcoming: Race[] = [];
|
||||||
|
const past: Race[] = [];
|
||||||
|
|
||||||
|
for (const race of races) {
|
||||||
|
if (parseRaceDate(race.date).getTime() >= today.getTime()) {
|
||||||
|
upcoming.push(race);
|
||||||
|
} else {
|
||||||
|
past.push(race);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
upcoming: sortByDateAsc(upcoming),
|
||||||
|
past: sortByDateDesc(past),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRaceCountdownLabel(date: string, now: Date = new Date()): string {
|
||||||
|
const today = new Date(now);
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const target = parseRaceDate(date);
|
||||||
|
const days = Math.ceil((target.getTime() - today.getTime()) / MS_IN_DAY);
|
||||||
|
|
||||||
|
if (days <= 0) {
|
||||||
|
return "сегодня";
|
||||||
|
}
|
||||||
|
if (days === 1) {
|
||||||
|
return "через 1 день";
|
||||||
|
}
|
||||||
|
if (days < 5) {
|
||||||
|
return `через ${days} дня`;
|
||||||
|
}
|
||||||
|
return `через ${days} дней`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPaceLabel(finishTime: string | null, distanceKm: number): string | null {
|
||||||
|
const totalSeconds = parseFinishTimeToSeconds(finishTime);
|
||||||
|
if (!totalSeconds || distanceKm <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paceSeconds = Math.round(totalSeconds / distanceKm);
|
||||||
|
const paceMinutes = Math.floor(paceSeconds / 60);
|
||||||
|
const paceRemainder = paceSeconds % 60;
|
||||||
|
|
||||||
|
return `${String(paceMinutes).padStart(2, "0")}:${String(paceRemainder).padStart(2, "0")} /км`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRaceStatusLabel(status: Race["status"]): string {
|
||||||
|
if (status === "completed") {
|
||||||
|
return "пробежал";
|
||||||
|
}
|
||||||
|
return "планирую";
|
||||||
|
}
|
||||||
@@ -1,8 +1,167 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import type { Race } from "../api";
|
||||||
|
import { ApiError, getRaces } from "../api";
|
||||||
|
import {
|
||||||
|
formatDistance,
|
||||||
|
formatRaceDate,
|
||||||
|
getRaceCountdownLabel,
|
||||||
|
parseFinishTimeToSeconds,
|
||||||
|
splitRacesByDate,
|
||||||
|
} from "../lib";
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return "Не удалось загрузить данные dashboard.";
|
||||||
|
}
|
||||||
|
|
||||||
export function DashboardPage(): JSX.Element {
|
export function DashboardPage(): JSX.Element {
|
||||||
|
const [races, setRaces] = useState<Race[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
async function loadDashboardData(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const items = await getRaces();
|
||||||
|
if (!isMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRaces(items);
|
||||||
|
setErrorMessage(null);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setErrorMessage(getErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadDashboardData();
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dashboardMetrics = useMemo(() => {
|
||||||
|
const { upcoming, past } = splitRacesByDate(races);
|
||||||
|
const completed = races.filter((race) => race.status === "completed");
|
||||||
|
|
||||||
|
const nextRace = upcoming[0] ?? null;
|
||||||
|
const lastResult = past.find((race) => race.status === "completed") ?? null;
|
||||||
|
|
||||||
|
let personalRecord: Race | null = null;
|
||||||
|
let personalRecordSeconds = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
|
for (const race of completed) {
|
||||||
|
const finishSeconds = parseFinishTimeToSeconds(race.finishTime);
|
||||||
|
if (!finishSeconds) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = finishSeconds / race.distanceKm;
|
||||||
|
if (candidate < personalRecordSeconds) {
|
||||||
|
personalRecordSeconds = candidate;
|
||||||
|
personalRecord = race;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const seasonRaces = races.filter((race) => new Date(`${race.date}T00:00:00`).getFullYear() === currentYear);
|
||||||
|
const seasonCompleted = seasonRaces.filter((race) => race.status === "completed");
|
||||||
|
|
||||||
|
return {
|
||||||
|
nextRace,
|
||||||
|
lastResult,
|
||||||
|
personalRecord,
|
||||||
|
seasonTotal: seasonRaces.length,
|
||||||
|
seasonCompletedCount: seasonCompleted.length,
|
||||||
|
};
|
||||||
|
}, [races]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<section className="page page--dashboard">
|
<section className="page page--dashboard" aria-busy="true">
|
||||||
<h1 className="page__title">Dashboard</h1>
|
<h1 className="page__title">Dashboard</h1>
|
||||||
<p className="page__subtitle">Overview cards and quick actions will be added in the next task.</p>
|
<p className="page__subtitle">Загружаем ваши старты...</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorMessage) {
|
||||||
|
return (
|
||||||
|
<section className="page page--dashboard" role="alert">
|
||||||
|
<h1 className="page__title">Dashboard</h1>
|
||||||
|
<p className="page__subtitle page__subtitle--error">{errorMessage}</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page page--dashboard">
|
||||||
|
<h1 className="page__title">Dashboard</h1>
|
||||||
|
<p className="page__subtitle">Ключевые метрики по вашему календарю стартов.</p>
|
||||||
|
|
||||||
|
<div className="dashboard-grid" aria-label="Ключевые метрики">
|
||||||
|
<article className="dashboard-card">
|
||||||
|
<h2 className="dashboard-card__title">Ближайший старт</h2>
|
||||||
|
{dashboardMetrics.nextRace ? (
|
||||||
|
<>
|
||||||
|
<p className="dashboard-card__value">{dashboardMetrics.nextRace.title}</p>
|
||||||
|
<p className="dashboard-card__meta">
|
||||||
|
{formatRaceDate(dashboardMetrics.nextRace.date)} · {formatDistance(dashboardMetrics.nextRace.distanceKm)}
|
||||||
|
</p>
|
||||||
|
<p className="dashboard-card__hint">{getRaceCountdownLabel(dashboardMetrics.nextRace.date)}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="dashboard-card__empty">Нет запланированных стартов.</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="dashboard-card">
|
||||||
|
<h2 className="dashboard-card__title">Последний результат</h2>
|
||||||
|
{dashboardMetrics.lastResult ? (
|
||||||
|
<>
|
||||||
|
<p className="dashboard-card__value">{dashboardMetrics.lastResult.finishTime ?? "время не указано"}</p>
|
||||||
|
<p className="dashboard-card__meta">
|
||||||
|
{dashboardMetrics.lastResult.title} · {formatDistance(dashboardMetrics.lastResult.distanceKm)}
|
||||||
|
</p>
|
||||||
|
<p className="dashboard-card__hint">{formatRaceDate(dashboardMetrics.lastResult.date)}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="dashboard-card__empty">Пока нет завершённых стартов.</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="dashboard-card">
|
||||||
|
<h2 className="dashboard-card__title">Личный рекорд</h2>
|
||||||
|
{dashboardMetrics.personalRecord ? (
|
||||||
|
<>
|
||||||
|
<p className="dashboard-card__value">{dashboardMetrics.personalRecord.finishTime ?? "время не указано"}</p>
|
||||||
|
<p className="dashboard-card__meta">
|
||||||
|
{dashboardMetrics.personalRecord.title} · {formatDistance(dashboardMetrics.personalRecord.distanceKm)}
|
||||||
|
</p>
|
||||||
|
<p className="dashboard-card__hint">Лучший темп среди завершённых стартов.</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="dashboard-card__empty">Недостаточно данных для PR.</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="dashboard-card">
|
||||||
|
<h2 className="dashboard-card__title">Сезон</h2>
|
||||||
|
<p className="dashboard-card__value">{dashboardMetrics.seasonTotal}</p>
|
||||||
|
<p className="dashboard-card__meta">стартов в этом году</p>
|
||||||
|
<p className="dashboard-card__hint">Завершено: {dashboardMetrics.seasonCompletedCount}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,113 @@
|
|||||||
export function RacesPage(): JSX.Element {
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import type { Race } from "../api";
|
||||||
|
import { ApiError, getRaces } from "../api";
|
||||||
|
import { formatDistance, formatRaceDate, getRaceStatusLabel, splitRacesByDate } from "../lib";
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return "Не удалось загрузить календарь стартов.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function RaceList(props: { title: string; races: Race[] }): JSX.Element {
|
||||||
|
const { title, races } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page page--races">
|
<section className="race-list" aria-label={title}>
|
||||||
<h1 className="page__title">Races</h1>
|
<h2 className="race-list__title">{title}</h2>
|
||||||
<p className="page__subtitle">Upcoming and completed race lists will be added in the next task.</p>
|
{races.length > 0 ? (
|
||||||
|
<ul className="race-list__items">
|
||||||
|
{races.map((race) => (
|
||||||
|
<li key={race.id} className="race-card">
|
||||||
|
<div className="race-card__main">
|
||||||
|
<p className="race-card__title">{race.title}</p>
|
||||||
|
<p className="race-card__meta">
|
||||||
|
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
race.status === "completed"
|
||||||
|
? "race-card__status race-card__status--completed"
|
||||||
|
: "race-card__status race-card__status--planned"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{getRaceStatusLabel(race.status)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="race-list__empty">Пока нет данных в этом разделе.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RacesPage(): JSX.Element {
|
||||||
|
const [races, setRaces] = useState<Race[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
async function loadRaces(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const items = await getRaces();
|
||||||
|
if (!isMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRaces(items);
|
||||||
|
setErrorMessage(null);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setErrorMessage(getErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadRaces();
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { upcoming, past } = useMemo(() => splitRacesByDate(races), [races]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<section className="page page--races" aria-busy="true">
|
||||||
|
<h1 className="page__title">Календарь стартов</h1>
|
||||||
|
<p className="page__subtitle">Загружаем данные...</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorMessage) {
|
||||||
|
return (
|
||||||
|
<section className="page page--races" role="alert">
|
||||||
|
<h1 className="page__title">Календарь стартов</h1>
|
||||||
|
<p className="page__subtitle page__subtitle--error">{errorMessage}</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page page--races">
|
||||||
|
<h1 className="page__title">Календарь стартов</h1>
|
||||||
|
<p className="page__subtitle">Будущие и прошедшие старты в одном месте.</p>
|
||||||
|
|
||||||
|
<div className="race-lists">
|
||||||
|
<RaceList title="Будущие" races={upcoming} />
|
||||||
|
<RaceList title="Прошедшие" races={past} />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,3 +91,127 @@ a {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page__subtitle--error {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid {
|
||||||
|
margin-top: var(--space-6);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-5);
|
||||||
|
background: #fcfdff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card__title {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card__value {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-h2);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card__meta {
|
||||||
|
margin: var(--space-2) 0 0;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card__hint,
|
||||||
|
.dashboard-card__empty {
|
||||||
|
margin: var(--space-2) 0 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-lists {
|
||||||
|
margin-top: var(--space-6);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-list {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-5);
|
||||||
|
background: #fcfdff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-list__title {
|
||||||
|
margin: 0 0 var(--space-4);
|
||||||
|
font-size: var(--font-size-h2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-list__items {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-list__empty {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-card {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: var(--space-3);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-card__title {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-card__meta {
|
||||||
|
margin: var(--space-2) 0 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-card__status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-card__status--planned {
|
||||||
|
background: #edf3ff;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-card__status--completed {
|
||||||
|
background: #ecf8f1;
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.dashboard-grid,
|
||||||
|
.race-lists {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user