web: easier to navigate around + a lot of minor ui improvements
This commit is contained in:
@@ -1,5 +1,20 @@
|
||||
import type { HtmlProps } from '@/types';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
export function Card({ children }: HtmlProps<HTMLDivElement>) {
|
||||
return <div className="border border-border rounded">{children}</div>;
|
||||
type CardProps = HtmlProps<HTMLDivElement> & {
|
||||
hover?: boolean;
|
||||
};
|
||||
|
||||
export function Card({ children, hover }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border border-border rounded',
|
||||
hover &&
|
||||
'transition-all hover:-translate-y-0.5 hover:shadow hover:border-black'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,16 @@ export function Breadcrumbs() {
|
||||
}
|
||||
);
|
||||
|
||||
const dashboard = api.dashboard.get.useQuery(
|
||||
{
|
||||
slug: params.dashboard,
|
||||
},
|
||||
{
|
||||
enabled: !!params.dashboard,
|
||||
staleTime: Infinity,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-b border-border text-xs">
|
||||
<Container className="flex items-center gap-2 h-8">
|
||||
@@ -52,6 +62,18 @@ export function Breadcrumbs() {
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{org.data && pro.data && dashboard.data && (
|
||||
<>
|
||||
<ChevronRight size={10} />
|
||||
<Link
|
||||
shallow
|
||||
href={`/${org.data.slug}/${pro.data.slug}/${dashboard.data.slug}`}
|
||||
>
|
||||
{dashboard.data.name}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { strip } from '@/utils/object';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { NavbarUserDropdown } from './NavbarUserDropdown';
|
||||
@@ -29,7 +30,12 @@ export function NavbarMenu() {
|
||||
{params.project && (
|
||||
<Link
|
||||
shallow
|
||||
href={`/${params.organization}/${params.project}/reports`}
|
||||
href={{
|
||||
pathname: `/${params.organization}/${params.project}/reports`,
|
||||
query: strip({
|
||||
dashboard: params.dashboard,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
Create report
|
||||
</Link>
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
import { pushModal } from '@/modals';
|
||||
import { useSelector } from '@/redux';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { api, handleError } from '@/utils/api';
|
||||
import { SaveIcon } from 'lucide-react';
|
||||
|
||||
import { useReportId } from './hooks/useReportId';
|
||||
import { resetDirty } from './reportSlice';
|
||||
|
||||
export function ReportSaveButton() {
|
||||
const { reportId } = useReportId();
|
||||
const dispatch = useDispatch();
|
||||
const update = api.report.update.useMutation({
|
||||
onSuccess() {
|
||||
dispatch(resetDirty());
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Report updated.',
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import airplane from '@/lottie/airplane.json';
|
||||
import ballon from '@/lottie/ballon.json';
|
||||
import noData from '@/lottie/no-data.json';
|
||||
import { cn } from '@/utils/cn';
|
||||
import type { LottieComponentProps } from 'lottie-react';
|
||||
import Lottie from 'lottie-react';
|
||||
|
||||
const animations = {
|
||||
airplane,
|
||||
ballon,
|
||||
noData,
|
||||
};
|
||||
type Animations = keyof typeof animations;
|
||||
|
||||
@@ -15,3 +18,12 @@ export const ChartAnimation = ({
|
||||
}: Omit<LottieComponentProps, 'animationData'> & {
|
||||
name: Animations;
|
||||
}) => <Lottie animationData={animations[name]} loop={true} {...props} />;
|
||||
|
||||
export const ChartAnimationContainer = (
|
||||
props: React.ButtonHTMLAttributes<HTMLDivElement>
|
||||
) => (
|
||||
<div
|
||||
{...props}
|
||||
className={cn('border border-border rounded-md p-8', props.className)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||
import type { IChartInput } from '@/types';
|
||||
import { api } from '@/utils/api';
|
||||
|
||||
import { ChartAnimation } from './ChartAnimation';
|
||||
import { ChartAnimation, ChartAnimationContainer } from './ChartAnimation';
|
||||
import { withChartProivder } from './ChartProvider';
|
||||
import { ReportBarChart } from './ReportBarChart';
|
||||
import { ReportLineChart } from './ReportLineChart';
|
||||
@@ -45,15 +45,32 @@ export const Chart = memo(
|
||||
const anyData = Boolean(chart.data?.series?.[0]?.data);
|
||||
|
||||
if (!enabled) {
|
||||
return <p>Select events & filters to begin</p>;
|
||||
return (
|
||||
<ChartAnimationContainer>
|
||||
<ChartAnimation name="ballon" className="w-96 mx-auto" />
|
||||
<p className="text-center font-medium">
|
||||
Please select at least one event to see the chart.
|
||||
</p>
|
||||
</ChartAnimationContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (chart.isFetching) {
|
||||
return <ChartAnimation name="airplane" className="w-96 mx-auto" />;
|
||||
return (
|
||||
<ChartAnimationContainer>
|
||||
<ChartAnimation name="airplane" className="w-96 mx-auto" />
|
||||
<p className="text-center font-medium">Loading...</p>
|
||||
</ChartAnimationContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (chart.isError) {
|
||||
return <p>Error</p>;
|
||||
return (
|
||||
<ChartAnimationContainer>
|
||||
<ChartAnimation name="noData" className="w-96 mx-auto" />
|
||||
<p className="text-center font-medium">Something went wrong...</p>
|
||||
</ChartAnimationContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!chart.isSuccess) {
|
||||
@@ -61,7 +78,12 @@ export const Chart = memo(
|
||||
}
|
||||
|
||||
if (!anyData) {
|
||||
return <ChartAnimation name="ballon" className="w-96 mx-auto" />;
|
||||
return (
|
||||
<ChartAnimationContainer>
|
||||
<ChartAnimation name="noData" className="w-96 mx-auto" />
|
||||
<p className="text-center font-medium">No data</p>
|
||||
</ChartAnimationContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (chartType === 'bar') {
|
||||
@@ -72,6 +94,13 @@ export const Chart = memo(
|
||||
return <ReportLineChart interval={interval} data={chart.data} />;
|
||||
}
|
||||
|
||||
return <p>Chart type "{chartType}" is not supported yet.</p>;
|
||||
return (
|
||||
<ChartAnimationContainer>
|
||||
<ChartAnimation name="ballon" className="w-96 mx-auto" />
|
||||
<p className="text-center font-medium">
|
||||
Chart type "{chartType}" is not supported yet.
|
||||
</p>
|
||||
</ChartAnimationContainer>
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -33,6 +33,12 @@ export const reportSlice = createSlice({
|
||||
name: 'counter',
|
||||
initialState,
|
||||
reducers: {
|
||||
resetDirty(state) {
|
||||
return {
|
||||
...state,
|
||||
dirty: false,
|
||||
};
|
||||
},
|
||||
reset() {
|
||||
return initialState;
|
||||
},
|
||||
@@ -165,6 +171,7 @@ export const {
|
||||
changeInterval,
|
||||
changeDateRanges,
|
||||
changeChartType,
|
||||
resetDirty,
|
||||
} = reportSlice.actions;
|
||||
|
||||
export default reportSlice.reducer;
|
||||
|
||||
1
apps/web/src/lottie/no-data.json
Normal file
1
apps/web/src/lottie/no-data.json
Normal file
File diff suppressed because one or more lines are too long
78
apps/web/src/modals/AddDashboard.tsx
Normal file
78
apps/web/src/modals/AddDashboard.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { ButtonContainer } from '@/components/ButtonContainer';
|
||||
import { InputWithLabel } from '@/components/forms/InputWithLabel';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
import { useRefetchActive } from '@/hooks/useRefetchActive';
|
||||
import { api, handleError } from '@/utils/api';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { popModal } from '.';
|
||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||
|
||||
interface AddDashboardProps {
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
}
|
||||
|
||||
const validator = z.object({
|
||||
name: z.string().min(1, 'Required'),
|
||||
});
|
||||
|
||||
type IForm = z.infer<typeof validator>;
|
||||
|
||||
export default function AddDashboard({
|
||||
// organizationSlug,
|
||||
projectSlug,
|
||||
}: AddDashboardProps) {
|
||||
const refetch = useRefetchActive();
|
||||
|
||||
const { register, handleSubmit, formState } = useForm<IForm>({
|
||||
resolver: zodResolver(validator),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = api.dashboard.create.useMutation({
|
||||
onError: handleError,
|
||||
onSuccess() {
|
||||
refetch();
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Dashboard created.',
|
||||
});
|
||||
popModal();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ModalContent>
|
||||
<ModalHeader title="Edit client" />
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={handleSubmit(({ name }) => {
|
||||
mutation.mutate({
|
||||
name,
|
||||
projectSlug,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<InputWithLabel
|
||||
label="Name"
|
||||
placeholder="Name of the dashboard"
|
||||
{...register('name')}
|
||||
/>
|
||||
<ButtonContainer>
|
||||
<Button type="button" variant="outline" onClick={() => popModal()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!formState.isDirty}>
|
||||
Create
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
</form>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { ButtonContainer } from '@/components/ButtonContainer';
|
||||
import { InputWithLabel } from '@/components/forms/InputWithLabel';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -8,6 +9,7 @@ import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||
import { useRefetchActive } from '@/hooks/useRefetchActive';
|
||||
import type { IChartInput } from '@/types';
|
||||
import { api, handleError } from '@/utils/api';
|
||||
import { strip } from '@/utils/object';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
@@ -30,7 +32,7 @@ type IForm = z.infer<typeof validator>;
|
||||
|
||||
export default function SaveReport({ report }: SaveReportProps) {
|
||||
const router = useRouter();
|
||||
const { organization, project } = useOrganizationParams();
|
||||
const { organization, project, dashboard } = useOrganizationParams();
|
||||
const refetch = useRefetchActive();
|
||||
const save = api.report.save.useMutation({
|
||||
onError: handleError,
|
||||
@@ -41,7 +43,10 @@ export default function SaveReport({ report }: SaveReportProps) {
|
||||
});
|
||||
popModal();
|
||||
refetch();
|
||||
router.push(`/${organization}/${project}/reports/${res.id}`);
|
||||
router.push({
|
||||
pathname: `/${organization}/${project}/reports/${res.id}`,
|
||||
query: strip({ dashboard }),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -58,7 +63,7 @@ export default function SaveReport({ report }: SaveReportProps) {
|
||||
onError: handleError,
|
||||
onSuccess(res) {
|
||||
setValue('dashboardId', res.id);
|
||||
dashboasrdQuery.refetch();
|
||||
dashboardQuery.refetch();
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Dashboard created.',
|
||||
@@ -66,15 +71,24 @@ export default function SaveReport({ report }: SaveReportProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const dashboasrdQuery = api.dashboard.list.useQuery({
|
||||
const dashboardQuery = api.dashboard.list.useQuery({
|
||||
projectSlug: project,
|
||||
});
|
||||
|
||||
const dashboards = (dashboasrdQuery.data ?? []).map((item) => ({
|
||||
const dashboards = (dashboardQuery.data ?? []).map((item) => ({
|
||||
value: item.id,
|
||||
label: item.name,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (dashboard && dashboardQuery.data) {
|
||||
const match = dashboardQuery.data.find((item) => item.slug === dashboard);
|
||||
if (match) {
|
||||
setValue('dashboardId', match.id);
|
||||
}
|
||||
}
|
||||
}, [dashboard, dashboardQuery]);
|
||||
|
||||
return (
|
||||
<ModalContent>
|
||||
<ModalHeader title="Edit client" />
|
||||
|
||||
@@ -31,6 +31,9 @@ const modals = {
|
||||
SaveReport: dynamic(() => import('./SaveReport'), {
|
||||
loading: Loading,
|
||||
}),
|
||||
AddDashboard: dynamic(() => import('./AddDashboard'), {
|
||||
loading: Loading,
|
||||
}),
|
||||
};
|
||||
|
||||
const emitter = mitt<{
|
||||
|
||||
@@ -11,6 +11,7 @@ import { api } from '@/utils/api';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { timeRanges } from '@/utils/constants';
|
||||
import { getRangeLabel } from '@/utils/getRangeLabel';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export const getServerSideProps = createServerSideProps();
|
||||
@@ -60,19 +61,22 @@ export default function Dashboard() {
|
||||
key={report.id}
|
||||
>
|
||||
<Link
|
||||
href={`/${params.organization}/${params.project}/reports/${report.id}`}
|
||||
className="block border-b border-border p-4 leading-none hover:underline"
|
||||
href={`/${params.organization}/${params.project}/reports/${report.id}?dashboard=${params.dashboard}`}
|
||||
className="flex border-b border-border p-4 leading-none [&>svg]:hover:opacity-100 items-center justify-between"
|
||||
shallow
|
||||
>
|
||||
<div className="font-medium">{report.name}</div>
|
||||
{chartRange !== null && (
|
||||
<div className="mt-2 text-sm flex gap-2">
|
||||
<span className={range !== null ? 'line-through' : ''}>
|
||||
{chartRange}
|
||||
</span>
|
||||
{range !== null && <span>{getRangeLabel(range)}</span>}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium">{report.name}</div>
|
||||
{chartRange !== null && (
|
||||
<div className="mt-2 text-sm flex gap-2">
|
||||
<span className={range !== null ? 'line-through' : ''}>
|
||||
{chartRange}
|
||||
</span>
|
||||
{range !== null && <span>{getRangeLabel(range)}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="opacity-0 transition-opacity" />
|
||||
</Link>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -3,8 +3,10 @@ import { Container } from '@/components/Container';
|
||||
import { MainLayout } from '@/components/layouts/MainLayout';
|
||||
import { PageTitle } from '@/components/PageTitle';
|
||||
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||
import { pushModal } from '@/modals';
|
||||
import { createServerSideProps } from '@/server/getServerSideProps';
|
||||
import { api } from '@/utils/api';
|
||||
import { Plus } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export const getServerSideProps = createServerSideProps();
|
||||
@@ -27,16 +29,30 @@ export default function Home() {
|
||||
<PageTitle>Dashboards</PageTitle>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
{dashboards.map((item) => (
|
||||
<Card key={item.id}>
|
||||
<Card key={item.id} hover>
|
||||
<Link
|
||||
href={`/${params.organization}/${params.project}/${item.slug}`}
|
||||
className="block p-4 font-medium leading-none hover:underline"
|
||||
className="block p-4 font-medium leading-none"
|
||||
shallow
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
</Card>
|
||||
))}
|
||||
<Card hover>
|
||||
<button
|
||||
className="flex items-center justify-between w-full p-4 font-medium leading-none"
|
||||
onClick={() => {
|
||||
pushModal('AddDashboard', {
|
||||
projectSlug: params.project,
|
||||
organizationSlug: params.organization,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Create new dashboard
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</Card>
|
||||
</div>
|
||||
</Container>
|
||||
</MainLayout>
|
||||
|
||||
@@ -29,10 +29,10 @@ export default function Home() {
|
||||
<PageTitle>Projects</PageTitle>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
{projects.map((item) => (
|
||||
<Card key={item.id}>
|
||||
<Card key={item.id} hover>
|
||||
<Link
|
||||
href={`/${params.organization}/${item.slug}`}
|
||||
className="block p-4 font-medium leading-none hover:underline"
|
||||
className="block p-4 font-medium leading-none"
|
||||
shallow
|
||||
>
|
||||
{item.name}
|
||||
|
||||
@@ -455,8 +455,6 @@ async function getChartData({
|
||||
);
|
||||
}
|
||||
|
||||
console.log(sql);
|
||||
|
||||
// group by sql label
|
||||
const series = result.reduce(
|
||||
(acc, item) => {
|
||||
@@ -528,8 +526,8 @@ function fillEmptySpotsInTimeline(
|
||||
}
|
||||
|
||||
if (interval === 'month') {
|
||||
clonedStartDate.setDate(1);
|
||||
clonedEndDate.setDate(1);
|
||||
clonedStartDate.setUTCDate(1);
|
||||
clonedEndDate.setUTCDate(1);
|
||||
}
|
||||
|
||||
// Force if interval is month and the start date is the same month as today
|
||||
@@ -537,11 +535,17 @@ function fillEmptySpotsInTimeline(
|
||||
interval === 'month' &&
|
||||
clonedStartDate.getUTCFullYear() === today.getUTCFullYear() &&
|
||||
clonedStartDate.getUTCMonth() === today.getUTCMonth();
|
||||
|
||||
let prev = undefined;
|
||||
while (
|
||||
shouldForce() ||
|
||||
clonedStartDate.getTime() <= clonedEndDate.getTime()
|
||||
) {
|
||||
if (prev === clonedStartDate.getTime()) {
|
||||
console.log('GET OUT NOW!');
|
||||
break;
|
||||
}
|
||||
prev = clonedStartDate.getTime();
|
||||
|
||||
const getYear = (date: Date) => date.getUTCFullYear();
|
||||
const getMonth = (date: Date) => date.getUTCMonth();
|
||||
const getDay = (date: Date) => date.getUTCDate();
|
||||
@@ -598,19 +602,19 @@ function fillEmptySpotsInTimeline(
|
||||
|
||||
switch (interval) {
|
||||
case 'day': {
|
||||
clonedStartDate.setDate(clonedStartDate.getUTCDate() + 1);
|
||||
clonedStartDate.setUTCDate(clonedStartDate.getUTCDate() + 1);
|
||||
break;
|
||||
}
|
||||
case 'hour': {
|
||||
clonedStartDate.setHours(clonedStartDate.getUTCHours() + 1);
|
||||
clonedStartDate.setUTCHours(clonedStartDate.getUTCHours() + 1);
|
||||
break;
|
||||
}
|
||||
case 'minute': {
|
||||
clonedStartDate.setMinutes(clonedStartDate.getUTCMinutes() + 1);
|
||||
clonedStartDate.setUTCMinutes(clonedStartDate.getUTCMinutes() + 1);
|
||||
break;
|
||||
}
|
||||
case 'month': {
|
||||
clonedStartDate.setMonth(clonedStartDate.getUTCMonth() + 1);
|
||||
clonedStartDate.setUTCMonth(clonedStartDate.getUTCMonth() + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
|
||||
import { db } from '@/server/db';
|
||||
import { getDashboardBySlug } from '@/server/services/dashboard.service';
|
||||
import { getProjectBySlug } from '@/server/services/project.service';
|
||||
import { slug } from '@/utils/slug';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const dashboardRouter = createTRPCRouter({
|
||||
get: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
slug: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input: { slug } }) => {
|
||||
return getDashboardBySlug(slug);
|
||||
}),
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z
|
||||
|
||||
@@ -12,7 +12,10 @@ import type { Client, Project } from '@prisma/client';
|
||||
import type { TooltipProps } from 'recharts';
|
||||
import type { z } from 'zod';
|
||||
|
||||
export type HtmlProps<T> = React.DetailedHTMLProps<React.HTMLAttributes<T>, T>;
|
||||
export type HtmlProps<T> = Omit<
|
||||
React.DetailedHTMLProps<React.HTMLAttributes<T>, T>,
|
||||
'ref'
|
||||
>;
|
||||
|
||||
export type IChartInput = z.infer<typeof zChartInput>;
|
||||
export type IChartInputWithDates = z.infer<typeof zChartInputWithDates>;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { anyPass, isEmpty, isNil, reject } from 'ramda';
|
||||
|
||||
export function toDots(
|
||||
obj: Record<string, unknown>,
|
||||
path = ''
|
||||
@@ -16,3 +18,5 @@ export function toDots(
|
||||
};
|
||||
}, {});
|
||||
}
|
||||
|
||||
export const strip = reject(anyPass([isEmpty, isNil]));
|
||||
|
||||
Reference in New Issue
Block a user