mirror of
https://github.com/Litlyx/litlyx
synced 2025-12-10 15:58:38 +01:00
admin panel
This commit is contained in:
@@ -1,15 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
type CItem = { label: string, slot: string }
|
||||
const props = defineProps<{ items: CItem[] }>();
|
||||
export type CItem = { label: string, slot: string }
|
||||
const props = defineProps<{ items: CItem[], manualScroll?:boolean }>();
|
||||
|
||||
const activeTabIndex = ref<number>(0);
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex overflow-y-auto hide-scrollbars">
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="flex overflow-x-auto hide-scrollbars">
|
||||
<div class="flex">
|
||||
<div v-for="(tab, index) of items" @click="activeTabIndex = index"
|
||||
class="px-6 pb-3 poppins font-medium text-lyx-lightmode-text dark:text-lyx-text-darker border-b-[1px] border-lyx-text-darker"
|
||||
@@ -24,7 +24,7 @@ const activeTabIndex = ref<number>(0);
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div :class="{'overflow-y-hidden': manualScroll }" class="overflow-y-auto h-full">
|
||||
<slot :name="props.items[activeTabIndex].slot"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
15
dashboard/components/LyxUi/Separator.vue
Normal file
15
dashboard/components/LyxUi/Separator.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
|
||||
|
||||
const props = defineProps<{ size?: string }>();
|
||||
|
||||
const widgetStyle = computed(() => {
|
||||
return `height: ${props.size ?? '1px'}`;
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :style="widgetStyle" class="bg-lyx-widget-light"></div>
|
||||
</template>
|
||||
271
dashboard/components/admin/MiniChart.vue
Normal file
271
dashboard/components/admin/MiniChart.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<script lang="ts" setup>
|
||||
import DateService, { type Slice } from '@services/DateService';
|
||||
import type { ChartData, ChartOptions, TooltipModel } from 'chart.js';
|
||||
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||
import * as fns from 'date-fns';
|
||||
|
||||
const props = defineProps<{ pid: string }>();
|
||||
|
||||
const errorData = ref<{ errored: boolean, text: string }>({ errored: false, text: '' })
|
||||
|
||||
function createGradient(startColor: string) {
|
||||
const c = document.createElement('canvas');
|
||||
const ctx = c.getContext("2d");
|
||||
let gradient: any = `${startColor}22`;
|
||||
if (ctx) {
|
||||
gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||
gradient.addColorStop(0, `${startColor}99`);
|
||||
gradient.addColorStop(0.35, `${startColor}66`);
|
||||
gradient.addColorStop(1, `${startColor}22`);
|
||||
} else {
|
||||
console.warn('Cannot get context for gradient');
|
||||
}
|
||||
|
||||
return gradient;
|
||||
}
|
||||
|
||||
const chartOptions = ref<ChartOptions<'line'>>({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: (false as any),
|
||||
scales: {
|
||||
y: {
|
||||
ticks: { display: true },
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
// borderDash: [5, 10]
|
||||
},
|
||||
beginAtZero: true,
|
||||
},
|
||||
x: {
|
||||
ticks: { display: true },
|
||||
stacked: false,
|
||||
offset: false,
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
title: { display: false },
|
||||
tooltip: { enabled: false }
|
||||
},
|
||||
});
|
||||
|
||||
const chartData = ref<ChartData<'line' | 'bar' | 'bubble'>>({
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Visits',
|
||||
data: [],
|
||||
backgroundColor: ['#5655d7'],
|
||||
borderColor: '#5655d7',
|
||||
borderWidth: 4,
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 10,
|
||||
hoverBackgroundColor: '#5655d7',
|
||||
hoverBorderColor: 'white',
|
||||
hoverBorderWidth: 2,
|
||||
segment: {
|
||||
borderColor(ctx, options) {
|
||||
const todayIndex = visitsData.data.value?.todayIndex;
|
||||
if (!todayIndex || todayIndex == -1) return '#5655d7';
|
||||
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
|
||||
return '#5655d7'
|
||||
},
|
||||
borderDash(ctx, options) {
|
||||
const todayIndex = visitsData.data.value?.todayIndex;
|
||||
if (!todayIndex || todayIndex == -1) return undefined;
|
||||
if (ctx.p1DataIndex == todayIndex - 1) return [3, 5];
|
||||
return undefined;
|
||||
},
|
||||
backgroundColor(ctx, options) {
|
||||
const todayIndex = visitsData.data.value?.todayIndex;
|
||||
if (!todayIndex || todayIndex == -1) return createGradient('#5655d7');
|
||||
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
|
||||
return createGradient('#5655d7');
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Unique visitors',
|
||||
data: [],
|
||||
backgroundColor: ['#4abde8'],
|
||||
borderColor: '#4abde8',
|
||||
borderWidth: 2,
|
||||
hoverBackgroundColor: '#4abde8',
|
||||
hoverBorderColor: '#4abde8',
|
||||
hoverBorderWidth: 2,
|
||||
type: 'bar',
|
||||
// barThickness: 20,
|
||||
borderSkipped: ['bottom'],
|
||||
},
|
||||
{
|
||||
label: 'Events',
|
||||
data: [],
|
||||
backgroundColor: ['#fbbf24'],
|
||||
borderWidth: 2,
|
||||
hoverBackgroundColor: '#fbbf24',
|
||||
hoverBorderColor: '#fbbf24',
|
||||
hoverBorderWidth: 2,
|
||||
type: 'bubble',
|
||||
stack: 'combined',
|
||||
borderColor: ["#fbbf24"]
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { lineChartProps, lineChartRef, update: updateChart } = useLineChart({ chartData: (chartData as any), options: chartOptions });
|
||||
|
||||
const selectedSlice: Slice = 'day'
|
||||
|
||||
const allDatesFull = ref<string[]>([]);
|
||||
|
||||
|
||||
function transformResponse(input: { _id: string, count: number }[]) {
|
||||
const data = input.map(e => e.count);
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), selectedSlice));
|
||||
if (input.length > 0) allDatesFull.value = input.map(e => e._id.toString());
|
||||
|
||||
const todayIndex = input.findIndex(e => new Date(e._id).getTime() > (Date.now() - new Date().getTimezoneOffset() * 1000 * 60));
|
||||
|
||||
return { data, labels, todayIndex }
|
||||
}
|
||||
|
||||
function onResponseError(e: any) {
|
||||
let message = e.response._data.message ?? 'Generic error';
|
||||
if (message == 'internal server error') message = 'Please change slice';
|
||||
errorData.value = { errored: true, text: message }
|
||||
}
|
||||
|
||||
function onResponse(e: any) {
|
||||
if (e.response.status != 500) errorData.value = { errored: false, text: '' }
|
||||
}
|
||||
|
||||
|
||||
const headers = computed(() => {
|
||||
return {
|
||||
'x-from': fns.startOfWeek(fns.subWeeks(Date.now(), 1)).toISOString(),
|
||||
'x-to': fns.endOfWeek(fns.subWeeks(Date.now(), 1)).toISOString(),
|
||||
'x-pid': props.pid
|
||||
}
|
||||
});
|
||||
|
||||
const visitsData = useFetch(`/api/timeline/visits?pid=${props.pid}`, {
|
||||
headers: useComputedHeaders({
|
||||
slice: selectedSlice,
|
||||
custom: { ...headers.value },
|
||||
useActivePid: false,
|
||||
useActiveDomain: false
|
||||
}),
|
||||
lazy: true,
|
||||
transform: transformResponse, onResponseError, onResponse
|
||||
});
|
||||
|
||||
const sessionsData = useFetch(`/api/timeline/sessions?pid=${props.pid}`, {
|
||||
headers: useComputedHeaders({
|
||||
slice: selectedSlice,
|
||||
custom: { ...headers.value },
|
||||
useActivePid: false,
|
||||
useActiveDomain: false
|
||||
}), lazy: true,
|
||||
transform: transformResponse, onResponseError, onResponse
|
||||
});
|
||||
|
||||
const eventsData = useFetch(`/api/timeline/events?pid=${props.pid}`, {
|
||||
headers: useComputedHeaders({
|
||||
slice: selectedSlice,
|
||||
custom: { ...headers.value },
|
||||
useActivePid: false,
|
||||
useActiveDomain: false
|
||||
}), lazy: true,
|
||||
transform: transformResponse, onResponseError, onResponse
|
||||
});
|
||||
|
||||
const readyToDisplay = computed(() => !visitsData.pending.value && !eventsData.pending.value && !sessionsData.pending.value);
|
||||
|
||||
watch(readyToDisplay, () => {
|
||||
if (readyToDisplay.value === true) onDataReady();
|
||||
})
|
||||
|
||||
|
||||
function onDataReady() {
|
||||
if (!visitsData.data.value) return;
|
||||
if (!eventsData.data.value) return;
|
||||
if (!sessionsData.data.value) return;
|
||||
|
||||
chartData.value.labels = visitsData.data.value.labels;
|
||||
|
||||
const maxChartY = Math.max(...visitsData.data.value.data, ...sessionsData.data.value.data);
|
||||
const maxEventSize = Math.max(...eventsData.data.value.data)
|
||||
|
||||
chartData.value.datasets[0].data = visitsData.data.value.data;
|
||||
chartData.value.datasets[1].data = sessionsData.data.value.data;
|
||||
|
||||
chartData.value.datasets[2].data = eventsData.data.value.data.map(e => {
|
||||
const rValue = 20 / maxEventSize * e;
|
||||
return { x: 0, y: maxChartY + 20, r: isNaN(rValue) ? 0 : rValue, r2: e }
|
||||
});
|
||||
|
||||
|
||||
chartData.value.datasets[0].backgroundColor = [createGradient('#5655d7')];
|
||||
chartData.value.datasets[1].backgroundColor = [createGradient('#4abde8')];
|
||||
chartData.value.datasets[2].backgroundColor = [createGradient('#fbbf24')];
|
||||
|
||||
|
||||
(chartData.value.datasets[1] as any).borderSkipped = sessionsData.data.value.data.map((e, i) => {
|
||||
const todayIndex = eventsData.data.value?.todayIndex || 0;
|
||||
if (i == todayIndex - 1) return true;
|
||||
return 'bottom';
|
||||
});
|
||||
|
||||
chartData.value.datasets[2].borderColor = eventsData.data.value.data.map((e, i) => {
|
||||
const todayIndex = eventsData.data.value?.todayIndex || 0;
|
||||
if (i == todayIndex - 1) return '#fbbf2400';
|
||||
return '#fbbf24';
|
||||
});
|
||||
|
||||
updateChart();
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="h-[10rem] w-full flex">
|
||||
<div v-if="!readyToDisplay" class="w-full flex justify-center items-center">
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-end w-full" v-if="readyToDisplay && !errorData.errored">
|
||||
<LineChart ref="lineChartRef" class="w-full h-full" v-bind="lineChartProps"> </LineChart>
|
||||
</div>
|
||||
|
||||
<div v-if="errorData.errored" class="flex items-center justify-center py-8">
|
||||
{{ errorData.text }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
#external-tooltip {
|
||||
border-radius: 3px;
|
||||
color: white;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
transform: translate(-50%, 0);
|
||||
transition: all .1s ease;
|
||||
}
|
||||
</style>
|
||||
109
dashboard/components/admin/Overview.vue
Normal file
109
dashboard/components/admin/Overview.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script lang="ts" setup>
|
||||
import type { TAdminProject } from '~/server/api/admin/projects';
|
||||
|
||||
import { PREMIUM_PLAN, getPlanFromId } from '@data/PREMIUM'
|
||||
import { useSelectMenuStyle } from '~/composables/ui/useSelectMenuStyle';
|
||||
|
||||
const page = ref<number>(0);
|
||||
const limit = ref<number>(20);
|
||||
|
||||
const ordersList = [
|
||||
{ label: 'created_at -->', id: '{ "created_at": 1 }' },
|
||||
{ label: 'created_at <--', id: '{ "created_at": -1 }' },
|
||||
|
||||
{ label: 'visits -->', id: '{ "visits": 1 }' },
|
||||
{ label: 'visits <--', id: '{ "visits": -1 }' },
|
||||
|
||||
{ label: 'events -->', id: '{ "events": 1 }' },
|
||||
{ label: 'events <--', id: '{ "events": -1 }' },
|
||||
|
||||
{ label: 'sessions -->', id: '{ "sessions": 1 }' },
|
||||
{ label: 'sessions <--', id: '{ "sessions": -1 }' },
|
||||
|
||||
{ label: 'usage total -->', id: '{ "limit_total": 1 }' },
|
||||
{ label: 'usage total <--', id: '{ "limit_total": -1 }' },
|
||||
|
||||
{ label: 'usage visits -->', id: '{ "limit_visits": 1 }' },
|
||||
{ label: 'usage visits <--', id: '{ "limit_visits": -1 }' },
|
||||
|
||||
{ label: 'usage events -->', id: '{ "limit_events": 1 }' },
|
||||
{ label: 'usage events <--', id: '{ "limit_events": -1 }' },
|
||||
|
||||
{ label: 'usage ai -->', id: '{ "limit_ai_messages": 1 }' },
|
||||
{ label: 'usage ai <--', id: '{ "limit_ai_messages": -1 }' },
|
||||
|
||||
{ label: 'plan -->', id: '{ "premium_type": 1 }' },
|
||||
{ label: 'plan <--', id: '{ "premium_type": -1 }' },
|
||||
|
||||
]
|
||||
|
||||
const order = ref<string>('{ "created_at": -1 }');
|
||||
|
||||
|
||||
const { data: projects, pending: pendingProjects } = await useFetch<TAdminProject[]>(
|
||||
() => `/api/admin/projects?page=${page.value}&limit=${limit.value}&sortQuery=${order.value}`,
|
||||
signHeaders()
|
||||
);
|
||||
|
||||
|
||||
|
||||
const { uiMenu } = useSelectMenuStyle();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-6 h-full">
|
||||
|
||||
|
||||
<div class="flex items-center gap-10 px-10">
|
||||
|
||||
<div class="flex gap-2 items-center">
|
||||
<div>Order:</div>
|
||||
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Order" :options="ordersList"
|
||||
value-attribute="id" option-attribute="label" v-model="order">
|
||||
</USelectMenu>
|
||||
</div>
|
||||
|
||||
<!-- TODO: Move to metrics tab -->
|
||||
<!-- TODO: Add project details button -->
|
||||
<!-- TODO: Add project utilities -->
|
||||
<div class="flex gap-2 items-center">
|
||||
<div> Projects: </div>
|
||||
<div> 123 </div>
|
||||
<div> Premium: </div>
|
||||
<div> 123 </div>
|
||||
<div> Active: </div>
|
||||
<div> 123 </div>
|
||||
<div> Dead: </div>
|
||||
<div> 123 </div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center">
|
||||
<div> Users: </div>
|
||||
<div> 123 </div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center">
|
||||
<div> Total Visits: </div>
|
||||
<div> 123 </div>
|
||||
<div> Total Events: </div>
|
||||
<div> 123 </div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div
|
||||
class="cursor-default flex justify-center flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
|
||||
|
||||
<AdminOverviewProjectCard v-if="!pendingProjects" :key="project._id.toString()" :project="project"
|
||||
class="w-[26rem]" v-for="project of projects" />
|
||||
|
||||
<div v-if="pendingProjects"> Loading...</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
104
dashboard/components/admin/OverviewOld.vue
Normal file
104
dashboard/components/admin/OverviewOld.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<script lang="ts" setup>
|
||||
import type { TAdminUserProjectInfo } from '~/server/api/admin/users_projects';
|
||||
|
||||
|
||||
import { PREMIUM_PLAN, getPlanFromId } from '@data/PREMIUM'
|
||||
|
||||
const page = ref<number>(0);
|
||||
const limit = ref<number>(10);
|
||||
|
||||
const sortQuery = computed(() => {
|
||||
return JSON.stringify({ created_at: 1 })
|
||||
})
|
||||
|
||||
const { data: usersWithProjects } = await useFetch<TAdminUserProjectInfo[]>(
|
||||
() => `/api/admin/users_projects?page=${page.value}&limit=${limit.value}&sortQuery=${sortQuery.value}`,
|
||||
signHeaders()
|
||||
);
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-2 cursor-default">
|
||||
<div v-for="user of usersWithProjects" class="py-6">
|
||||
|
||||
{{ user.email }}
|
||||
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<div class="w-[22rem] outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md"
|
||||
v-for="project of user.projects">
|
||||
|
||||
<div class="flex gap-4 justify-center">
|
||||
<UTooltip :text="`PRICE_ID: ${project.premium_type.toString()}`">
|
||||
<div class="font-medium">
|
||||
{{ getPlanFromId(project.premium_type)?.TAG ?? 'ERROR' }}
|
||||
</div>
|
||||
</UTooltip>
|
||||
<div class="text-lyx-text-darker">
|
||||
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-5 justify-center">
|
||||
<div class="font-medium">
|
||||
{{ project.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center mt-4">
|
||||
<div class="flex gap-4">
|
||||
<div class="flex gap-2">
|
||||
<div class="text-right"> Visits:</div>
|
||||
<div>{{ formatNumberK(project.visits) }}</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="text-right"> Events:</div>
|
||||
<div>{{ formatNumberK(project.events) }}</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="text-right"> Sessions:</div>
|
||||
<div>{{ formatNumberK(project.sessions) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LyxUiSeparator class="my-2" />
|
||||
|
||||
<AdminMiniChart :pid="project._id.toString()"></AdminMiniChart>
|
||||
|
||||
<LyxUiSeparator class="my-2" />
|
||||
|
||||
|
||||
<div class="flex gap-6 justify-around">
|
||||
<div class="flex gap-1">
|
||||
<div>
|
||||
{{ formatNumberK(project.limits[0].visits + project.limits[0].events) }}
|
||||
/ {{ formatNumberK(project.limits[0].limit) }}
|
||||
</div>
|
||||
<div class="text-lyx-text-dark">
|
||||
~ {{
|
||||
(100 / project.limits[0].limit * (project.limits[0].visits +
|
||||
project.limits[0].events)).toFixed(1)
|
||||
}}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div>
|
||||
{{ formatNumberK(project.limits[0].ai_messages) }}
|
||||
/ {{ formatNumberK(project.limits[0].ai_limit) }}
|
||||
</div>
|
||||
<div class="text-lyx-text-dark">
|
||||
~ {{(100 / project.limits[0].ai_limit * project.limits[0].ai_messages).toFixed(1) }}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
94
dashboard/components/admin/overview/ProjectCard.vue
Normal file
94
dashboard/components/admin/overview/ProjectCard.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { TAdminProject } from '~/server/api/admin/projects';
|
||||
import { getPlanFromId } from '~/shared/data/PREMIUM';
|
||||
|
||||
const props = defineProps<{ project: TAdminProject }>();
|
||||
|
||||
|
||||
const usageLabel = computed(() => {
|
||||
return formatNumberK(props.project.limit_total) + ' / ' + formatNumberK(props.project.limit_max)
|
||||
});
|
||||
|
||||
const usagePercentLabel = computed(() => {
|
||||
const percent = 100 / props.project.limit_max * props.project.limit_total;
|
||||
return `~ ${percent.toFixed(1)}%`;
|
||||
});
|
||||
|
||||
const usageAiLabel = computed(() => {
|
||||
return formatNumberK(props.project.limit_ai_messages) + ' / ' + formatNumberK(props.project.limit_ai_max);
|
||||
}
|
||||
|
||||
); const usageAiPercentLabel = computed(() => {
|
||||
const percent = 100 / props.project.limit_ai_max * props.project.limit_ai_messages;
|
||||
return `~ ${percent.toFixed(1)}%`
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="poppins outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md">
|
||||
|
||||
<div class="flex gap-4 justify-center">
|
||||
<UTooltip :text="`PRICE_ID: ${project.premium_type}`">
|
||||
<div class="font-medium">
|
||||
{{ getPlanFromId(project.premium_type)?.TAG ?? 'ERROR' }}
|
||||
</div>
|
||||
</UTooltip>
|
||||
<div class="text-lyx-text-darker">
|
||||
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-5 justify-center">
|
||||
<div class="font-medium">
|
||||
{{ project.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center mt-4">
|
||||
<div class="flex gap-4">
|
||||
<div class="flex gap-2">
|
||||
<div class="text-right"> Visits:</div>
|
||||
<div>{{ formatNumberK(project.visits || 0) }}</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="text-right"> Events:</div>
|
||||
<div>{{ formatNumberK(project.events || 0) }}</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="text-right"> Sessions:</div>
|
||||
<div>{{ formatNumberK(project.sessions || 0) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LyxUiSeparator class="my-2" />
|
||||
|
||||
<div class="mb-2">
|
||||
<UProgress :value="project.limit_visits + project.limit_events" :max="project.limit_max"></UProgress>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6 justify-around">
|
||||
<div class="flex gap-1">
|
||||
<div>
|
||||
{{ usageLabel }}
|
||||
</div>
|
||||
<div class="text-lyx-text-dark">
|
||||
{{ usagePercentLabel }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div>
|
||||
{{ usageAiLabel }}
|
||||
</div>
|
||||
<div class="text-lyx-text-dark">
|
||||
{{ usageAiPercentLabel }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -96,7 +96,7 @@ const todayIndex = computed(() => {
|
||||
|
||||
|
||||
<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">
|
||||
<div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 m-cards-wrap:grid-cols-4">
|
||||
|
||||
<DashboardCountCard :todayIndex="todayIndex" :ready="!visitsData.pending.value" icon="far fa-earth"
|
||||
text="Total visits" :value="formatNumberK(visitsData.data.value?.data.reduce((a, e) => a + e, 0) || '...')"
|
||||
|
||||
Reference in New Issue
Block a user