mirror of
https://github.com/Litlyx/litlyx
synced 2025-12-10 15:58:38 +01:00
fix reactivity
This commit is contained in:
@@ -256,7 +256,7 @@ watch(selected, () => {
|
|||||||
<div v-if="(!entry.adminOnly || (isAdmin && !isAdminHidden))"
|
<div v-if="(!entry.adminOnly || (isAdmin && !isAdminHidden))"
|
||||||
class="bg-lyx-background cursor-pointer text-lyx-text-dark py-[.35rem] px-2 rounded-lg text-[.95rem] flex items-center"
|
class="bg-lyx-background cursor-pointer text-lyx-text-dark py-[.35rem] px-2 rounded-lg text-[.95rem] flex items-center"
|
||||||
:class="{
|
:class="{
|
||||||
'text-gray-700 pointer-events-none': entry.disabled,
|
'!text-lyx-text-darker pointer-events-none': entry.disabled,
|
||||||
'bg-lyx-background-lighter !text-lyx-text/90': route.path == (entry.to || '#'),
|
'bg-lyx-background-lighter !text-lyx-text/90': route.path == (entry.to || '#'),
|
||||||
'hover:bg-lyx-background-light hover:!text-lyx-text/90': route.path != (entry.to || '#'),
|
'hover:bg-lyx-background-light hover:!text-lyx-text/90': route.path != (entry.to || '#'),
|
||||||
}">
|
}">
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
import type { IconProvider } from './BarsCard.vue';
|
import type { IconProvider } from './BarsCard.vue';
|
||||||
|
|
||||||
const { data: countries, pending, refresh } = useGeolocationData(10);
|
|
||||||
|
|
||||||
function iconProvider(id: string): ReturnType<IconProvider> {
|
function iconProvider(id: string): ReturnType<IconProvider> {
|
||||||
if (id === 'self') return ['icon', 'fas fa-link'];
|
if (id === 'self') return ['icon', 'fas fa-link'];
|
||||||
return [
|
return [
|
||||||
@@ -14,32 +12,51 @@ function iconProvider(id: string): ReturnType<IconProvider> {
|
|||||||
|
|
||||||
const customIconStyle = `width: 2rem; padding: 1px;`
|
const customIconStyle = `width: 2rem; padding: 1px;`
|
||||||
|
|
||||||
|
const activeProject = useActiveProject();
|
||||||
|
|
||||||
|
const { safeSnapshotDates } = useSnapshot()
|
||||||
|
|
||||||
|
const isShowMore = ref<boolean>(false);
|
||||||
|
|
||||||
|
const headers = computed(() => {
|
||||||
|
return {
|
||||||
|
'x-from': safeSnapshotDates.value.from,
|
||||||
|
'x-to': safeSnapshotDates.value.to,
|
||||||
|
Authorization: authorizationHeaderComputed.value,
|
||||||
|
limit: isShowMore.value === true ? '200' : '10'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const geolocationData = useFetch(`/api/metrics/${activeProject.value?._id}/data/countries`, {
|
||||||
|
method: 'POST', headers, lazy: true, immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||||
|
|
||||||
function showMore() {
|
function showMore() {
|
||||||
|
|
||||||
|
isShowMore.value = true;
|
||||||
showDialog.value = true;
|
showDialog.value = true;
|
||||||
dialogBarData.value = [];
|
|
||||||
isDataLoading.value = true;
|
|
||||||
|
|
||||||
const moreRes = useGeolocationData(200);
|
dialogBarData.value = geolocationData.data.value?.map(e => {
|
||||||
|
|
||||||
moreRes.onResponse(data => {
|
|
||||||
dialogBarData.value = data.value?.map(e => {
|
|
||||||
return { ...e, icon: iconProvider(e._id) }
|
return { ...e, icon: iconProvider(e._id) }
|
||||||
}) || [];
|
}) || [];
|
||||||
isDataLoading.value = false;
|
isDataLoading.value = false;
|
||||||
})
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
geolocationData.execute();
|
||||||
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="countries || []" :dataIcons="false"
|
<DashboardBarsCard @showMore="showMore()" @dataReload="geolocationData.refresh()" :data="geolocationData.data.value || []" :dataIcons="false"
|
||||||
:loading="pending" label="Top Countries" sub-label="Countries" :iconProvider="iconProvider"
|
:loading="geolocationData.pending.value" label="Top Countries" sub-label="Countries" :iconProvider="iconProvider"
|
||||||
:customIconStyle="customIconStyle" desc=" Lists the countries where users access your website.">
|
:customIconStyle="customIconStyle" desc=" Lists the countries where users access your website.">
|
||||||
</DashboardBarsCard>
|
</DashboardBarsCard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,11 +34,11 @@ const referrersData = useFetch(`/api/metrics/${activeProject.value?._id}/data/re
|
|||||||
|
|
||||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||||
|
|
||||||
const customDialog = useCustomDialog();
|
// const customDialog = useCustomDialog();
|
||||||
|
|
||||||
function onShowDetails(referrer: string) {
|
// function onShowDetails(referrer: string) {
|
||||||
customDialog.openDialog(ReferrerBarChart, { slice: 'day', referrer });
|
// customDialog.openDialog(ReferrerBarChart, { slice: 'day', referrer });
|
||||||
}
|
// }
|
||||||
|
|
||||||
function showMore() {
|
function showMore() {
|
||||||
|
|
||||||
@@ -59,10 +59,10 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<DashboardBarsCard @showDetails="onShowDetails" @showMore="showMore()"
|
<DashboardBarsCard @showMore="showMore()"
|
||||||
:elementTextTransformer="elementTextTransformer" :iconProvider="iconProvider"
|
:elementTextTransformer="elementTextTransformer" :iconProvider="iconProvider"
|
||||||
@dataReload="referrersData.refresh()" :showLink=true :data="referrersData.data.value || []"
|
@dataReload="referrersData.refresh()" :showLink=true :data="referrersData.data.value || []"
|
||||||
:interactive="true" desc="Where users find your website." :dataIcons="true" :loading="referrersData.pending.value"
|
:interactive="false" desc="Where users find your website." :dataIcons="true" :loading="referrersData.pending.value"
|
||||||
label="Top Referrers" sub-label="Referrers"></DashboardBarsCard>
|
label="Top Referrers" sub-label="Referrers"></DashboardBarsCard>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 m-cards-wrap:grid-cols-4" v-if="metricsInfo">
|
<div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 m-cards-wrap:grid-cols-4">
|
||||||
|
|
||||||
<DashboardCountCard :ready="!visitsData.pending.value" icon="far fa-earth" text="Total page visits"
|
<DashboardCountCard :ready="!visitsData.pending.value" icon="far fa-earth" text="Total page visits"
|
||||||
:value="formatNumberK(visitsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
:value="formatNumberK(visitsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
||||||
|
|||||||
@@ -2,41 +2,57 @@
|
|||||||
|
|
||||||
import type { VisitsWebsiteAggregated } from '~/server/api/metrics/[project_id]/data/websites';
|
import type { VisitsWebsiteAggregated } from '~/server/api/metrics/[project_id]/data/websites';
|
||||||
|
|
||||||
const { data: websites, pending, refresh } = useWebsitesData();
|
const activeProject = useActiveProject();
|
||||||
|
|
||||||
const currentViewData = ref<(VisitsWebsiteAggregated[] | undefined)>(websites.value);
|
const { safeSnapshotDates } = useSnapshot()
|
||||||
|
|
||||||
|
const isShowMore = ref<boolean>(false);
|
||||||
|
|
||||||
|
const currentWebsite = ref<string>("");
|
||||||
|
|
||||||
|
const websitesHeaders = computed(() => {
|
||||||
|
return {
|
||||||
|
'x-from': safeSnapshotDates.value.from,
|
||||||
|
'x-to': safeSnapshotDates.value.to,
|
||||||
|
Authorization: authorizationHeaderComputed.value,
|
||||||
|
limit: isShowMore.value === true ? '200' : '10'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pagesHeaders = computed(() => {
|
||||||
|
return {
|
||||||
|
'x-from': safeSnapshotDates.value.from,
|
||||||
|
'x-to': safeSnapshotDates.value.to,
|
||||||
|
Authorization: authorizationHeaderComputed.value,
|
||||||
|
limit: isShowMore.value === true ? '200' : '10',
|
||||||
|
'x-website-name': currentWebsite.value
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const websitesData = useFetch(`/api/metrics/${activeProject.value?._id}/data/websites`, {
|
||||||
|
method: 'POST', headers: websitesHeaders, lazy: true, immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const pagesData = useFetch(`/api/metrics/${activeProject.value?._id}/data/pages`, {
|
||||||
|
method: 'POST', headers: pagesHeaders, lazy: true, immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
const isPagesView = ref<boolean>(false);
|
const isPagesView = ref<boolean>(false);
|
||||||
const isLoading = ref<boolean>(false);
|
|
||||||
|
|
||||||
|
const currentData = computed(() => {
|
||||||
const { snapshot } = useSnapshot()
|
return isPagesView.value ? pagesData : websitesData
|
||||||
|
})
|
||||||
watch(pending, () => {
|
|
||||||
isLoading.value = true;
|
|
||||||
currentViewData.value = websites.value;
|
|
||||||
isLoading.value = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(snapshot, () => {
|
|
||||||
refresh();
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
async function showDetails(website: string) {
|
async function showDetails(website: string) {
|
||||||
if (isPagesView.value == true) return;
|
currentWebsite.value = website;
|
||||||
isLoading.value = true;
|
pagesData.execute();
|
||||||
isPagesView.value = true;
|
isPagesView.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
const { data: pagesData, pending } = usePagesData(website, 10);
|
async function showGeneral() {
|
||||||
|
websitesData.execute();
|
||||||
watch(pending, () => {
|
isPagesView.value = false;
|
||||||
isLoading.value = true;
|
|
||||||
currentViewData.value = pagesData.value as any;
|
|
||||||
isLoading.value = false;
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -45,26 +61,18 @@ function goToView() {
|
|||||||
router.push('/dashboard/visits');
|
router.push('/dashboard/visits');
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDefaultData() {
|
onMounted(()=>{
|
||||||
currentViewData.value = websites.value;
|
websitesData.execute();
|
||||||
isPagesView.value = false;
|
})
|
||||||
}
|
|
||||||
|
|
||||||
async function dataReload() {
|
|
||||||
await refresh();
|
|
||||||
setDefaultData();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-2 h-full">
|
<div class="flex flex-col gap-2 h-full">
|
||||||
<DashboardBarsCard :hideShowMore="true" @showGeneral="setDefaultData()" @showRawData="goToView()"
|
<DashboardBarsCard :hideShowMore="true" @showGeneral="showGeneral()" @showRawData="goToView()"
|
||||||
@dataReload="dataReload()" @showDetails="showDetails" :data="currentViewData || []"
|
@dataReload="currentData.refresh()" @showDetails="showDetails" :data="currentData.data.value || []"
|
||||||
:loading="pending || isLoading" :label="isPagesView ? 'Top pages' : 'Top Websites'"
|
:loading="currentData.pending.value" :label="isPagesView ? 'Top pages' : 'Top Websites'"
|
||||||
:sub-label="isPagesView ? 'Page' : 'Website'"
|
:sub-label="isPagesView ? 'Page' : 'Website'"
|
||||||
:desc="isPagesView ? 'Most visited pages' : 'Most visited website in this project'"
|
:desc="isPagesView ? 'Most visited pages' : 'Most visited website in this project'"
|
||||||
:interactive="!isPagesView" :rawButton="!isLiveDemo()" :isDetailView="isPagesView">
|
:interactive="!isPagesView" :rawButton="!isLiveDemo()" :isDetailView="isPagesView">
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ const body = computed(() => {
|
|||||||
from: safeSnapshotDates.value.from,
|
from: safeSnapshotDates.value.from,
|
||||||
to: safeSnapshotDates.value.to,
|
to: safeSnapshotDates.value.to,
|
||||||
slice: slice.value,
|
slice: slice.value,
|
||||||
Authorization: authorizationHeaderComputed.value,
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,7 +53,7 @@ function transformResponse(input: { _id: string, name: string, count: number }[]
|
|||||||
}
|
}
|
||||||
|
|
||||||
const eventsStackedData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/events_stacked`, {
|
const eventsStackedData = useFetch(`/api/metrics/${activeProject.value?._id}/timeline/events_stacked`, {
|
||||||
method: 'POST', body, lazy: true, immediate: false, transform: transformResponse
|
method: 'POST', body, lazy: true, immediate: false, transform: transformResponse, ...signHeaders()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,30 +2,30 @@
|
|||||||
import { onMounted } from 'vue';
|
import { onMounted } from 'vue';
|
||||||
import DateService, { type Slice } from '@services/DateService';
|
import DateService, { type Slice } from '@services/DateService';
|
||||||
|
|
||||||
const data = ref<number[]>([]);
|
// const data = ref<number[]>([]);
|
||||||
const labels = ref<string[]>([]);
|
// const labels = ref<string[]>([]);
|
||||||
const ready = ref<boolean>(false);
|
// const ready = ref<boolean>(false);
|
||||||
|
|
||||||
const props = defineProps<{ slice: Slice, referrer: string }>();
|
// const props = defineProps<{ slice: Slice, referrer: string }>();
|
||||||
|
|
||||||
async function loadData() {
|
// async function loadData() {
|
||||||
const response = await useReferrersTimeline(props.referrer, props.slice);
|
// const response = await useReferrersTimeline(props.referrer, props.slice);
|
||||||
if (!response) return;
|
// if (!response) return;
|
||||||
data.value = response.map(e => e.count);
|
// data.value = response.map(e => e.count);
|
||||||
labels.value = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
|
// labels.value = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
|
||||||
ready.value = true;
|
// ready.value = true;
|
||||||
}
|
// }
|
||||||
|
|
||||||
onMounted(async () => {
|
// onMounted(async () => {
|
||||||
await loadData();
|
// await loadData();
|
||||||
watch(props, async () => { await loadData(); });
|
// watch(props, async () => { await loadData(); });
|
||||||
})
|
// })
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<AdvancedBarChart v-if="ready" :data="data" :labels="labels" color="#5680f8">
|
<!-- <AdvancedBarChart v-if="ready" :data="data" :labels="labels" color="#5680f8">
|
||||||
</AdvancedBarChart>
|
</AdvancedBarChart> -->
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -22,148 +22,148 @@ export function useMetricsData() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
const { safeSnapshotDates, snapshot } = useSnapshot()
|
// const { safeSnapshotDates, snapshot } = useSnapshot()
|
||||||
const activeProject = useActiveProject();
|
// const activeProject = useActiveProject();
|
||||||
|
|
||||||
const createFromToHeaders = (headers: Record<string, string> = {}) => ({
|
// const createFromToHeaders = (headers: Record<string, string> = {}) => ({
|
||||||
'x-from': safeSnapshotDates.value.from,
|
// 'x-from': safeSnapshotDates.value.from,
|
||||||
'x-to': safeSnapshotDates.value.to,
|
// 'x-to': safeSnapshotDates.value.to,
|
||||||
...headers
|
// ...headers
|
||||||
});
|
// });
|
||||||
|
|
||||||
const createFromToBody = (body: Record<string, any> = {}) => ({
|
// const createFromToBody = (body: Record<string, any> = {}) => ({
|
||||||
from: safeSnapshotDates.value.from,
|
// from: safeSnapshotDates.value.from,
|
||||||
to: safeSnapshotDates.value.to,
|
// to: safeSnapshotDates.value.to,
|
||||||
...body
|
// ...body
|
||||||
});
|
// });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function useFirstInteractionData() {
|
// export function useFirstInteractionData() {
|
||||||
const activeProject = useActiveProject();
|
// const activeProject = useActiveProject();
|
||||||
const metricsInfo = useFetch<boolean>(`/api/metrics/${activeProject.value?._id}/first_interaction`, signHeaders());
|
// const metricsInfo = useFetch<boolean>(`/api/metrics/${activeProject.value?._id}/first_interaction`, signHeaders());
|
||||||
return metricsInfo;
|
// return metricsInfo;
|
||||||
}
|
// }
|
||||||
|
|
||||||
|
|
||||||
export function useTimelineAdvanced<T>(endpoint: string, slice: Ref<Slice>, customBody: Object = {}) {
|
// export function useTimelineAdvanced<T>(endpoint: string, slice: Ref<Slice>, customBody: Object = {}) {
|
||||||
const response = useCustomFetch<T>(
|
// const response = useCustomFetch<T>(
|
||||||
`/api/metrics/${activeProject.value?._id}/timeline/${endpoint}`,
|
// `/api/metrics/${activeProject.value?._id}/timeline/${endpoint}`,
|
||||||
() => signHeaders({ 'Content-Type': 'application/json' }).headers, {
|
// () => signHeaders({ 'Content-Type': 'application/json' }).headers, {
|
||||||
method: 'POST',
|
// method: 'POST',
|
||||||
getBody: () => createFromToBody({ slice: slice.value, ...customBody }),
|
// getBody: () => createFromToBody({ slice: slice.value, ...customBody }),
|
||||||
lazy: true,
|
// lazy: true,
|
||||||
watchProps: [snapshot, slice]
|
// watchProps: [snapshot, slice]
|
||||||
});
|
// });
|
||||||
return response;
|
// return response;
|
||||||
}
|
// }
|
||||||
|
|
||||||
|
|
||||||
export function useTimeline(endpoint: 'visits' | 'sessions' | 'referrers' | 'events_stacked', slice: Ref<Slice>) {
|
// export function useTimeline(endpoint: 'visits' | 'sessions' | 'referrers' | 'events_stacked', slice: Ref<Slice>) {
|
||||||
return useTimelineAdvanced<{ _id: string, count: number }[]>(endpoint, slice);
|
// return useTimelineAdvanced<{ _id: string, count: number }[]>(endpoint, slice);
|
||||||
}
|
// }
|
||||||
|
|
||||||
export async function useReferrersTimeline(referrer: string, slice: Ref<Slice>) {
|
// export async function useReferrersTimeline(referrer: string, slice: Ref<Slice>) {
|
||||||
return await useTimelineAdvanced<{ _id: string, count: number }[]>('referrers', slice, { referrer });
|
// return await useTimelineAdvanced<{ _id: string, count: number }[]>('referrers', slice, { referrer });
|
||||||
}
|
// }
|
||||||
|
|
||||||
export function useEventsStackedTimeline(slice: Ref<Slice>) {
|
// export function useEventsStackedTimeline(slice: Ref<Slice>) {
|
||||||
return useTimelineAdvanced<{ _id: string, name: string, count: number }[]>('events_stacked', slice);
|
// return useTimelineAdvanced<{ _id: string, name: string, count: number }[]>('events_stacked', slice);
|
||||||
}
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export async function useTimelineDataRaw(timelineEndpointName: string, slice: SliceName) {
|
// export async function useTimelineDataRaw(timelineEndpointName: string, slice: SliceName) {
|
||||||
const activeProject = useActiveProject();
|
// const activeProject = useActiveProject();
|
||||||
|
|
||||||
const response = await $fetch<{ data: MetricsTimeline[], from: string, to: string }>(
|
// const response = await $fetch<{ data: MetricsTimeline[], from: string, to: string }>(
|
||||||
`/api/metrics/${activeProject.value?._id}/timeline/${timelineEndpointName}`, {
|
// `/api/metrics/${activeProject.value?._id}/timeline/${timelineEndpointName}`, {
|
||||||
method: 'POST',
|
// method: 'POST',
|
||||||
...signHeaders({ 'Content-Type': 'application/json' }),
|
// ...signHeaders({ 'Content-Type': 'application/json' }),
|
||||||
body: JSON.stringify({ slice }),
|
// body: JSON.stringify({ slice }),
|
||||||
});
|
// });
|
||||||
|
|
||||||
return response;
|
// return response;
|
||||||
}
|
// }
|
||||||
|
|
||||||
export async function useTimelineData(timelineEndpointName: string, slice: SliceName) {
|
// export async function useTimelineData(timelineEndpointName: string, slice: SliceName) {
|
||||||
const response = await useTimelineDataRaw(timelineEndpointName, slice);
|
// const response = await useTimelineDataRaw(timelineEndpointName, slice);
|
||||||
if (!response) return;
|
// if (!response) return;
|
||||||
const fixed = fixMetrics(response, slice);
|
// const fixed = fixMetrics(response, slice);
|
||||||
return fixed;
|
// return fixed;
|
||||||
}
|
// }
|
||||||
|
|
||||||
export function usePagesData(website: string, limit: number = 10) {
|
// export function usePagesData(website: string, limit: number = 10) {
|
||||||
const activeProject = useActiveProject();
|
// const activeProject = useActiveProject();
|
||||||
|
|
||||||
const res = useFetch<VisitsWebsiteAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/pages`, {
|
// const res = useFetch<VisitsWebsiteAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/pages`, {
|
||||||
...signHeaders({
|
// ...signHeaders({
|
||||||
'x-query-limit': limit.toString(),
|
// 'x-query-limit': limit.toString(),
|
||||||
'x-website-name': website
|
// 'x-website-name': website
|
||||||
}),
|
// }),
|
||||||
key: `pages_data:${website}:${limit}`,
|
// key: `pages_data:${website}:${limit}`,
|
||||||
lazy: true
|
// lazy: true
|
||||||
});
|
// });
|
||||||
|
|
||||||
return res;
|
// return res;
|
||||||
|
|
||||||
}
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function useWebsitesData(limit: number = 10) {
|
// export function useWebsitesData(limit: number = 10) {
|
||||||
const res = useCustomFetch<VisitsWebsiteAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/websites`,
|
// const res = useCustomFetch<VisitsWebsiteAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/websites`,
|
||||||
() => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
||||||
{ lazy: false, watchProps: [snapshot] }
|
// { lazy: false, watchProps: [snapshot] }
|
||||||
);
|
// );
|
||||||
return res;
|
// return res;
|
||||||
}
|
// }
|
||||||
|
|
||||||
export function useEventsData(limit: number = 10) {
|
// export function useEventsData(limit: number = 10) {
|
||||||
const res = useCustomFetch<CustomEventsAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/events`,
|
// const res = useCustomFetch<CustomEventsAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/events`,
|
||||||
() => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
||||||
{ lazy: false, watchProps: [snapshot] }
|
// { lazy: false, watchProps: [snapshot] }
|
||||||
);
|
// );
|
||||||
return res;
|
// return res;
|
||||||
}
|
// }
|
||||||
|
|
||||||
export function useReferrersData(limit: number = 10) {
|
// export function useReferrersData(limit: number = 10) {
|
||||||
const res = useCustomFetch<ReferrersAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/referrers`,
|
// const res = useCustomFetch<ReferrersAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/referrers`,
|
||||||
() => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
||||||
{ lazy: false, watchProps: [snapshot] }
|
// { lazy: false, watchProps: [snapshot] }
|
||||||
);
|
// );
|
||||||
return res;
|
// return res;
|
||||||
}
|
// }
|
||||||
|
|
||||||
export function useBrowsersData(limit: number = 10) {
|
// export function useBrowsersData(limit: number = 10) {
|
||||||
const res = useCustomFetch<BrowsersAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/browsers`,
|
// const res = useCustomFetch<BrowsersAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/browsers`,
|
||||||
() => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
||||||
{ lazy: false, watchProps: [snapshot] }
|
// { lazy: false, watchProps: [snapshot] }
|
||||||
);
|
// );
|
||||||
return res;
|
// return res;
|
||||||
}
|
// }
|
||||||
|
|
||||||
export function useOssData(limit: number = 10) {
|
// export function useOssData(limit: number = 10) {
|
||||||
const res = useCustomFetch<OssAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/oss`,
|
// const res = useCustomFetch<OssAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/oss`,
|
||||||
() => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
||||||
{ lazy: false, watchProps: [snapshot] }
|
// { lazy: false, watchProps: [snapshot] }
|
||||||
);
|
// );
|
||||||
return res;
|
// return res;
|
||||||
}
|
// }
|
||||||
|
|
||||||
export function useGeolocationData(limit: number = 10) {
|
// export function useGeolocationData(limit: number = 10) {
|
||||||
const res = useCustomFetch<CountriesAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/countries`,
|
// const res = useCustomFetch<CountriesAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/countries`,
|
||||||
() => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
||||||
{ lazy: false, watchProps: [snapshot] }
|
// { lazy: false, watchProps: [snapshot] }
|
||||||
);
|
// );
|
||||||
return res;
|
// return res;
|
||||||
}
|
// }
|
||||||
|
|
||||||
export function useDevicesData(limit: number = 10) {
|
// export function useDevicesData(limit: number = 10) {
|
||||||
const res = useCustomFetch<DevicesAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/devices`,
|
// const res = useCustomFetch<DevicesAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/devices`,
|
||||||
() => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
// () => signHeaders(createFromToHeaders({ 'x-query-limit': limit.toString() })).headers,
|
||||||
{ lazy: false, watchProps: [snapshot] }
|
// { lazy: false, watchProps: [snapshot] }
|
||||||
);
|
// );
|
||||||
return res;
|
// return res;
|
||||||
}
|
// }
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const remoteSnapshots = useFetch<TProjectSnapshot[]>('/api/project/snapshots', {
|
|||||||
const activeProject = useActiveProject();
|
const activeProject = useActiveProject();
|
||||||
watch(activeProject, async () => {
|
watch(activeProject, async () => {
|
||||||
await remoteSnapshots.refresh();
|
await remoteSnapshots.refresh();
|
||||||
snapshot.value = snapshots.value[1];
|
snapshot.value = isLiveDemo() ? snapshots.value[0] : snapshots.value[1];
|
||||||
});
|
});
|
||||||
|
|
||||||
const snapshots = computed(() => {
|
const snapshots = computed(() => {
|
||||||
@@ -56,7 +56,7 @@ const snapshots = computed(() => {
|
|||||||
];
|
];
|
||||||
})
|
})
|
||||||
|
|
||||||
const snapshot = ref<TProjectSnapshot>(snapshots.value[1]);
|
const snapshot = ref<TProjectSnapshot>(isLiveDemo() ? snapshots.value[0] : snapshots.value[1]);
|
||||||
|
|
||||||
const safeSnapshotDates = computed(() => {
|
const safeSnapshotDates = computed(() => {
|
||||||
const from = new Date(snapshot.value?.from || 0).toISOString();
|
const from = new Date(snapshot.value?.from || 0).toISOString();
|
||||||
|
|||||||
@@ -2,47 +2,21 @@
|
|||||||
|
|
||||||
import type { Section } from '~/components/CVerticalNavigation.vue';
|
import type { Section } from '~/components/CVerticalNavigation.vue';
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const { setToken } = useAccessToken();
|
|
||||||
|
|
||||||
import { Lit } from 'litlyx-js';
|
import { Lit } from 'litlyx-js';
|
||||||
|
|
||||||
const sections: Section[] = [
|
const sections: Section[] = [
|
||||||
{
|
|
||||||
title: 'General',
|
|
||||||
entries: [
|
|
||||||
// { label: 'Projects', icon: 'far fa-table-layout', to: '/project_selector' },
|
|
||||||
// { label: 'Members', icon: 'far fa-users', to: '/members' },
|
|
||||||
// { label: 'Admin', icon: 'fas fa-cat', adminOnly: true, to: '/admin' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: 'Project',
|
title: 'Project',
|
||||||
entries: [
|
entries: [
|
||||||
{ label: 'Dashboard', to: '/', icon: 'fal fa-table-layout' },
|
{ label: 'Dashboard', to: '/', icon: 'fal fa-table-layout' },
|
||||||
{ label: 'Events', to: '/events', icon: 'fal fa-square-bolt' },
|
{ label: 'Events', to: '/events', icon: 'fal fa-square-bolt' },
|
||||||
{ label: 'Analyst', to: '/analyst', icon: 'fal fa-microchip-ai' },
|
{ label: 'Analyst', to: '/analyst', icon: 'fal fa-microchip-ai' },
|
||||||
{ label: 'Settings', to: '/settings', icon: 'fal fa-gear' },
|
{ label: 'Insights (soon)', to: '#', icon: 'fal fa-lightbulb', disabled: true },
|
||||||
// { label: 'Report', to: '/report', icon: 'far fa-notes' },
|
|
||||||
// { label: 'AI', to: '/dashboard/settings', icon: 'far fa-robot brightness-[.4]' },
|
|
||||||
// { label: 'Visits', to: '/dashboard/visits', icon: 'far fa-eye' },
|
|
||||||
// { label: 'Events', to: '/dashboard/events', icon: 'far fa-line-chart' },
|
|
||||||
{
|
{
|
||||||
label: 'Docs', to: 'https://docs.litlyx.com', icon: 'fal fa-book', external: true,
|
label: 'Docs', to: 'https://docs.litlyx.com', icon: 'fal fa-book', external: true,
|
||||||
action() { Lit.event('docs_clicked') },
|
action() { Lit.event('docs_clicked') },
|
||||||
},
|
},
|
||||||
]
|
{ label: 'Settings', to: '/settings', icon: 'fal fa-gear' },
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Non si vede',
|
|
||||||
entries: [
|
|
||||||
|
|
||||||
// {
|
|
||||||
// label: 'Github', to: 'https://github.com/litlyx/litlyx', icon: 'fab fa-github', external: true,
|
|
||||||
// action() { Lit.event('git_clicked') },
|
|
||||||
// },
|
|
||||||
// { label: 'Billing', to: '/plans', icon: 'far fa-wallet' },
|
|
||||||
// { label: 'Book a demo', to: '/book_demo', icon: 'far fa-calendar' },
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ const selectLabelsEvents = [
|
|||||||
];
|
];
|
||||||
const eventsStackedSelectIndex = ref<number>(0);
|
const eventsStackedSelectIndex = ref<number>(0);
|
||||||
|
|
||||||
|
const activeProject = useActiveProject();
|
||||||
|
const { snapshot } = useSnapshot();
|
||||||
|
|
||||||
|
const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProject.value?._id.toString()}`);
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -17,7 +22,7 @@ const eventsStackedSelectIndex = ref<number>(0);
|
|||||||
|
|
||||||
<div class="flex gap-6 flex-col xl:flex-row">
|
<div class="flex gap-6 flex-col xl:flex-row">
|
||||||
|
|
||||||
<CardTitled class="p-4 flex-[4] w-full" title="Events" sub="Events stacked bar chart.">
|
<CardTitled :key="refreshKey" class="p-4 flex-[4] w-full" title="Events" sub="Events stacked bar chart.">
|
||||||
<template #header>
|
<template #header>
|
||||||
<SelectButton @changeIndex="eventsStackedSelectIndex = $event"
|
<SelectButton @changeIndex="eventsStackedSelectIndex = $event"
|
||||||
:currentIndex="eventsStackedSelectIndex" :options="selectLabelsEvents">
|
:currentIndex="eventsStackedSelectIndex" :options="selectLabelsEvents">
|
||||||
@@ -29,18 +34,19 @@ const eventsStackedSelectIndex = ref<number>(0);
|
|||||||
</div>
|
</div>
|
||||||
</CardTitled>
|
</CardTitled>
|
||||||
|
|
||||||
<CardTitled class="p-4 flex-[2] w-full h-full" title="Top events" sub="Displays key events.">
|
<CardTitled :key="refreshKey" class="p-4 flex-[2] w-full h-full" title="Top events"
|
||||||
|
sub="Displays key events.">
|
||||||
<DashboardEventsChart class="w-full"> </DashboardEventsChart>
|
<DashboardEventsChart class="w-full"> </DashboardEventsChart>
|
||||||
</CardTitled>
|
</CardTitled>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<EventsUserFlow></EventsUserFlow>
|
<EventsUserFlow :key="refreshKey"></EventsUserFlow>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<EventsMetadataAnalyzer></EventsMetadataAnalyzer>
|
<EventsMetadataAnalyzer :key="refreshKey"></EventsMetadataAnalyzer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -54,14 +54,14 @@ function copyScript() {
|
|||||||
createAlert('Success', 'Script copied successfully.', 'far fa-circle-check', 5000);
|
createAlert('Success', 'Script copied successfully.', 'far fa-circle-check', 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: firstInteraction, pending, refresh } = useFirstInteractionData();
|
const firstInteractionUrl = computed(() => {
|
||||||
|
return `/api/metrics/${activeProject.value?._id}/first_interaction`
|
||||||
|
});
|
||||||
|
|
||||||
watch(pending, () => {
|
const firstInteraction = useFetch<boolean>(firstInteractionUrl, {
|
||||||
if (pending.value === true) return;
|
...signHeaders(),
|
||||||
if (firstInteraction.value === false) {
|
lazy: true
|
||||||
setTimeout(() => { refresh(); }, 2000);
|
});
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectLabels = [
|
const selectLabels = [
|
||||||
{ label: 'Hour', value: 'hour' },
|
{ label: 'Hour', value: 'hour' },
|
||||||
@@ -70,16 +70,10 @@ const selectLabels = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
const limitAlertActions: any[] = [
|
|
||||||
{
|
|
||||||
label: 'Upgrade', variant: "outline", color: 'white',
|
|
||||||
trailing: true, action: () => { }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const { snapshot } = useSnapshot();
|
const { snapshot } = useSnapshot();
|
||||||
|
|
||||||
const refreshKey = computed(() => `${snapshot.value._id.toString()}`);
|
const refreshKey = computed(() => `${snapshot.value._id.toString() + activeProject.value?._id.toString()}`);
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
@@ -88,16 +82,9 @@ const refreshKey = computed(() => `${snapshot.value._id.toString()}`);
|
|||||||
|
|
||||||
<div class="dashboard w-full h-full overflow-y-auto pb-20 md:pt-4 lg:pt-0">
|
<div class="dashboard w-full h-full overflow-y-auto pb-20 md:pt-4 lg:pt-0">
|
||||||
|
|
||||||
<div :key="'home-' + isLiveDemo()" v-if="projects && activeProject && firstInteraction">
|
<div :key="'home-' + isLiveDemo()" v-if="projects && activeProject && firstInteraction.data.value">
|
||||||
|
|
||||||
<div class="w-full px-4 py-2">
|
<div class="w-full px-4 py-2">
|
||||||
<!-- <div v-if="limitsInfo && !limitsInfo.limited"
|
|
||||||
class="bg-orange-600 justify-center flex gap-2 py-2 px-4 font-semibold text-[1.2rem] rounded-lg">
|
|
||||||
<div class="poppins text-text"> Limit reached </div>
|
|
||||||
<NuxtLink to="/plans" class="poppins text-[#393972] underline cursor-pointer">
|
|
||||||
Upgrade project
|
|
||||||
</NuxtLink>
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
|
|
||||||
<div v-if="limitsInfo && limitsInfo.limited"
|
<div v-if="limitsInfo && limitsInfo.limited"
|
||||||
@@ -155,10 +142,10 @@ const refreshKey = computed(() => `${snapshot.value._id.toString()}`);
|
|||||||
<div class="flex w-full justify-center mt-6 px-6">
|
<div class="flex w-full justify-center mt-6 px-6">
|
||||||
<div class="flex w-full gap-6 flex-col xl:flex-row">
|
<div class="flex w-full gap-6 flex-col xl:flex-row">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<DashboardWebsitesBarCard></DashboardWebsitesBarCard>
|
<DashboardWebsitesBarCard :key="refreshKey"></DashboardWebsitesBarCard>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<DashboardEventsBarCard></DashboardEventsBarCard>
|
<DashboardEventsBarCard :key="refreshKey"></DashboardEventsBarCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -167,10 +154,10 @@ const refreshKey = computed(() => `${snapshot.value._id.toString()}`);
|
|||||||
<div class="flex w-full justify-center mt-6 px-6">
|
<div class="flex w-full justify-center mt-6 px-6">
|
||||||
<div class="flex w-full gap-6 flex-col xl:flex-row">
|
<div class="flex w-full gap-6 flex-col xl:flex-row">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<DashboardReferrersBarCard></DashboardReferrersBarCard>
|
<DashboardReferrersBarCard :key="refreshKey"></DashboardReferrersBarCard>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<DashboardBrowsersBarCard></DashboardBrowsersBarCard>
|
<DashboardBrowsersBarCard :key="refreshKey"></DashboardBrowsersBarCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,10 +165,10 @@ const refreshKey = computed(() => `${snapshot.value._id.toString()}`);
|
|||||||
<div class="flex w-full justify-center mt-6 px-6">
|
<div class="flex w-full justify-center mt-6 px-6">
|
||||||
<div class="flex w-full gap-6 flex-col xl:flex-row">
|
<div class="flex w-full gap-6 flex-col xl:flex-row">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<DashboardOssBarCard></DashboardOssBarCard>
|
<DashboardOssBarCard :key="refreshKey"></DashboardOssBarCard>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<DashboardGeolocationBarCard></DashboardGeolocationBarCard>
|
<DashboardGeolocationBarCard :key="refreshKey"></DashboardGeolocationBarCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -189,7 +176,7 @@ const refreshKey = computed(() => `${snapshot.value._id.toString()}`);
|
|||||||
<div class="flex w-full justify-center mt-6 px-6">
|
<div class="flex w-full justify-center mt-6 px-6">
|
||||||
<div class="flex w-full gap-6 flex-col xl:flex-row">
|
<div class="flex w-full gap-6 flex-col xl:flex-row">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<DashboardDevicesBarCard></DashboardDevicesBarCard>
|
<DashboardDevicesBarCard :key="refreshKey"></DashboardDevicesBarCard>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
</div>
|
</div>
|
||||||
@@ -198,7 +185,7 @@ const refreshKey = computed(() => `${snapshot.value._id.toString()}`);
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!firstInteraction && activeProject" class="mt-[20vh] lg:mt-[36vh] flex flex-col gap-6">
|
<div v-if="!firstInteraction.data.value && activeProject" class="mt-[20vh] lg:mt-[36vh] flex flex-col gap-6">
|
||||||
<div class="flex gap-4 items-center justify-center">
|
<div class="flex gap-4 items-center justify-center">
|
||||||
<div class="animate-pulse w-[1.5rem] h-[1.5rem] bg-accent rounded-full"> </div>
|
<div class="animate-pulse w-[1.5rem] h-[1.5rem] bg-accent rounded-full"> </div>
|
||||||
<div class="text-text/90 poppins text-[1.3rem] font-semibold">
|
<div class="text-text/90 poppins text-[1.3rem] font-semibold">
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const selectLabelsEvents = [
|
|||||||
{ label: 'Month', value: 'month' },
|
{ label: 'Month', value: 'month' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { snapshot } = useSnapshot();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ const selectLabelsEvents = [
|
|||||||
|
|
||||||
|
|
||||||
<div class="flex gap-6 flex-col xl:flex-row p-6">
|
<div class="flex gap-6 flex-col xl:flex-row p-6">
|
||||||
<CardTitled class="p-4 flex-[4]" title="Events" sub="Events stacked bar chart.">
|
<!-- <CardTitled class="p-4 flex-[4]" title="Events" sub="Events stacked bar chart.">
|
||||||
<template #header>
|
<template #header>
|
||||||
<SelectButton @changeIndex="eventsStackedSelectIndex = $event"
|
<SelectButton @changeIndex="eventsStackedSelectIndex = $event"
|
||||||
:currentIndex="eventsStackedSelectIndex" :options="selectLabelsEvents">
|
:currentIndex="eventsStackedSelectIndex" :options="selectLabelsEvents">
|
||||||
@@ -128,7 +128,7 @@ const selectLabelsEvents = [
|
|||||||
<EventsStackedBarChart :slice="(selectLabelsEvents[eventsStackedSelectIndex].value as any)">
|
<EventsStackedBarChart :slice="(selectLabelsEvents[eventsStackedSelectIndex].value as any)">
|
||||||
</EventsStackedBarChart>
|
</EventsStackedBarChart>
|
||||||
</div>
|
</div>
|
||||||
</CardTitled>
|
</CardTitled> -->
|
||||||
|
|
||||||
<div class="bg-menu p-4 rounded-xl flex-[2] flex flex-col gap-10 h-full">
|
<div class="bg-menu p-4 rounded-xl flex-[2] flex flex-col gap-10 h-full">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user