mirror of
https://github.com/Litlyx/litlyx
synced 2025-12-10 07:48:37 +01:00
add dashboard
This commit is contained in:
110
dashboard/components/AdvancedLineChart.vue
Normal file
110
dashboard/components/AdvancedLineChart.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions } from 'chart.js';
|
||||
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||
registerChartComponents();
|
||||
|
||||
const props = defineProps<{
|
||||
data: any[],
|
||||
labels: string[]
|
||||
color: string,
|
||||
}>();
|
||||
|
||||
const chartOptions = ref<ChartOptions<'line'>>({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'nearest',
|
||||
axis: 'x',
|
||||
includeInvisible: true
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
ticks: { display: true },
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
// borderDash: [5, 10]
|
||||
},
|
||||
},
|
||||
x: {
|
||||
ticks: { display: true },
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
title: { display: false },
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleFont: { size: 16, weight: 'bold' },
|
||||
bodyFont: { size: 14 },
|
||||
padding: 10,
|
||||
cornerRadius: 4,
|
||||
boxPadding: 10,
|
||||
caretPadding: 20,
|
||||
yAlign: 'bottom',
|
||||
xAlign: 'center',
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const chartData = ref<ChartData<'line'>>({
|
||||
labels: props.labels,
|
||||
datasets: [
|
||||
{
|
||||
data: props.data,
|
||||
backgroundColor: [props.color + '77'],
|
||||
borderColor: props.color,
|
||||
borderWidth: 4,
|
||||
fill: true,
|
||||
tension: 0.45,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 10,
|
||||
hoverBackgroundColor: props.color,
|
||||
hoverBorderColor: 'white',
|
||||
hoverBorderWidth: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, options: chartOptions });
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
const c = document.createElement('canvas');
|
||||
const ctx = c.getContext("2d");
|
||||
let gradient: any = `${props.color}22`;
|
||||
if (ctx) {
|
||||
gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||
gradient.addColorStop(0, `${props.color}99`);
|
||||
gradient.addColorStop(0.35, `${props.color}66`);
|
||||
gradient.addColorStop(1, `${props.color}22`);
|
||||
} else {
|
||||
console.warn('Cannot get context for gradient');
|
||||
}
|
||||
|
||||
chartData.value.datasets[0].backgroundColor = [gradient];
|
||||
|
||||
watch(props, () => {
|
||||
console.log('UPDATE')
|
||||
chartData.value.labels = props.labels;
|
||||
chartData.value.datasets[0].data = props.data;
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<LineChart ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
|
||||
</template>
|
||||
116
dashboard/components/AdvancedStackedBarChart.vue
Normal file
116
dashboard/components/AdvancedStackedBarChart.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions } from 'chart.js';
|
||||
import { useBarChart, BarChart } from 'vue-chart-3';
|
||||
registerChartComponents();
|
||||
|
||||
const props = defineProps<{
|
||||
datasets: any[],
|
||||
labels: string[],
|
||||
}>();
|
||||
|
||||
const chartOptions = ref<ChartOptions<'bar'>>({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'nearest',
|
||||
axis: 'x',
|
||||
includeInvisible: true
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
stacked: true,
|
||||
ticks: { display: true },
|
||||
grid: {
|
||||
display: false,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
},
|
||||
},
|
||||
x: {
|
||||
stacked: true,
|
||||
ticks: { display: true },
|
||||
grid: {
|
||||
display: false,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'right',
|
||||
},
|
||||
title: { display: false },
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleFont: { size: 16, weight: 'bold' },
|
||||
bodyFont: { size: 14 },
|
||||
padding: 10,
|
||||
cornerRadius: 4,
|
||||
boxPadding: 10,
|
||||
caretPadding: 20,
|
||||
yAlign: 'bottom',
|
||||
xAlign: 'center',
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const chartData = ref<ChartData<'bar'>>({
|
||||
labels: props.labels,
|
||||
datasets: props.datasets.map(e => {
|
||||
return {
|
||||
data: e.data,
|
||||
label: e.label || '?',
|
||||
backgroundColor: [e.color],
|
||||
borderWidth: 0,
|
||||
borderRadius: 8
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
const { barChartProps, barChartRef } = useBarChart({ chartData: chartData, options: chartOptions });
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
// const c = document.createElement('canvas');
|
||||
// const ctx = c.getContext("2d");
|
||||
// let gradient: any = `${props.color}22`;
|
||||
// if (ctx) {
|
||||
// gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||
// gradient.addColorStop(0, `${props.color}99`);
|
||||
// gradient.addColorStop(0.35, `${props.color}66`);
|
||||
// gradient.addColorStop(1, `${props.color}22`);
|
||||
// } else {
|
||||
// console.warn('Cannot get context for gradient');
|
||||
// }
|
||||
|
||||
// chartData.value.datasets[0].backgroundColor = [gradient];
|
||||
|
||||
watch(props, () => {
|
||||
console.log('UPDATE')
|
||||
chartData.value.labels = props.labels;
|
||||
chartData.value.datasets.length = 0;
|
||||
chartData.value.datasets = props.datasets.map(e => {
|
||||
return {
|
||||
data: e.data,
|
||||
label: e.label || '?',
|
||||
backgroundColor: [e.color],
|
||||
borderWidth: 0,
|
||||
borderRadius: 8
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<BarChart v-bind="barChartProps"> </BarChart>
|
||||
</template>
|
||||
14
dashboard/components/CButton.vue
Normal file
14
dashboard/components/CButton.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const props = defineProps<{ label: string, disabled?: boolean, loading?: boolean }>();
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div :class="{ '!bg-[#354a87] !text-text/50 !cursor-not-allowed': (disabled || loading) }"
|
||||
class="bg-accent text-text px-4 py-2 text-center cursor-pointer hover:bg-[#5075e2] hover:text-text-sub">
|
||||
<span v-show="!loading">{{ label }}</span>
|
||||
<i v-if="loading" class="fas fa-loader animate-[spin_2s_linear_infinite]"></i>
|
||||
</div>
|
||||
</template>
|
||||
31
dashboard/components/CInput.vue
Normal file
31
dashboard/components/CInput.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const props = defineProps<{ modelValue: string, placeholder?: string, readonly?: boolean }>();
|
||||
const emits = defineEmits(['update:modelValue', 'change']);
|
||||
|
||||
function updateText(e: any) {
|
||||
emits('update:modelValue', e.target.value);
|
||||
}
|
||||
|
||||
function emitChange(e: any) {
|
||||
emits('change', e.target.value);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div>
|
||||
<input @change="emitChange" :readonly="readonly" :value="modelValue" @input="updateText"
|
||||
class="placeholder:text-text-sub/70 w-full read-only:bg-white/10 read-only:text-text-sub/60 placeholder:text-text-sub border-gray-400 bg-bg border-[1px] text-text rounded-md px-4 py-2"
|
||||
:placeholder="placeholder" type="text">
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
input:focus {
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
97
dashboard/components/CVerticalNavigation.vue
Normal file
97
dashboard/components/CVerticalNavigation.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
|
||||
export type Entry = {
|
||||
label: string,
|
||||
disabled?: boolean,
|
||||
to?: string,
|
||||
icon?: string,
|
||||
action?: () => any,
|
||||
adminOnly?: boolean,
|
||||
external?: boolean
|
||||
}
|
||||
|
||||
export type Section = {
|
||||
title: string,
|
||||
entries: Entry[]
|
||||
}
|
||||
|
||||
type Props = {
|
||||
sections: Section[]
|
||||
}
|
||||
|
||||
const { isOpen, open, close, toggle } = useMenu()
|
||||
|
||||
const route = useRoute();
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const { isAdmin } = useUserRoles();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-0 md:w-[5rem] absolute top-0 md:relative h-full">
|
||||
<div @mouseover="open()" @mouseleave="close()"
|
||||
class="CVerticalNavigation absolute z-[80] bg-menu h-full overflow-hidden w-0 md:w-[5rem]"
|
||||
:class="{ '!w-[18rem] shadow-[0_0_20px_#000000] rounded-r-2xl': isOpen }">
|
||||
<div :class="{ 'w-[18rem]': isOpen }">
|
||||
<div class="flex gap-4 items-center py-6 px-[.9rem] pb-8">
|
||||
<div class="bg-accent h-[2.8rem] aspect-[1/1] flex items-center justify-center rounded-lg">
|
||||
<img class="h-[2.4rem]" :src="'/logo.png'">
|
||||
</div>
|
||||
<div v-if="isOpen" class="font-bold text-[1.4rem] text-gray-300"> Litlyx </div>
|
||||
</div>
|
||||
|
||||
<div class="pb-8" v-for="section of sections">
|
||||
|
||||
<div class="flex flex-col px-3 gap-2">
|
||||
|
||||
<template v-for="entry of section.entries">
|
||||
|
||||
<NuxtLink @click="entry.action?.()" :target="entry.external ? '_blank' : ''"
|
||||
v-if="entry.to && (!entry.adminOnly || (isAdmin && !isAdminHidden))" tag="div"
|
||||
:to="entry.to || '/'"
|
||||
class="text-[#a3a9b6] flex w-full items-center gap-3 p-3 rounded-lg cursor-pointer hover:bg-[#363638] hover:text-[#ffffff]"
|
||||
:class="{
|
||||
'brightness-[.4] pointer-events-none': entry.disabled,
|
||||
'bg-[#363638] shadow-[0px_0px_2px_#ffffff20_inset] border-[#ffffff20] border-[1px] !text-[#ffffff]': route.path == (entry.to || '#')
|
||||
}">
|
||||
<div class="flex items-center text-[1.4rem] w-[1.8rem] justify-center">
|
||||
<i :class="entry.icon"></i>
|
||||
</div>
|
||||
<div v-if="isOpen" class="text-[.9rem] font-bold manrope"> {{ entry.label }} </div>
|
||||
</NuxtLink>
|
||||
|
||||
<div v-if="!entry.to" @click="entry.action?.()"
|
||||
class="text-[#a3a9b6] flex w-full items-center gap-3 p-3 rounded-lg cursor-pointer hover:bg-[#363638] hover:text-[#ffffff]">
|
||||
<div class="flex items-center text-[1.4rem] w-[1.8rem] justify-center">
|
||||
<i :class="entry.icon"></i>
|
||||
</div>
|
||||
<div v-if="isOpen" class="text-[.9rem] font-bold manrope"> {{ entry.label }} </div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.CVerticalNavigation {
|
||||
transition: all .25s ease-in-out;
|
||||
}
|
||||
|
||||
.CVerticalNavigation * {
|
||||
font-family: 'Inter';
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
9
dashboard/components/Card.vue
Normal file
9
dashboard/components/Card.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-card card-shadow rounded-xl">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
27
dashboard/components/CardTitled.vue
Normal file
27
dashboard/components/CardTitled.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const props = defineProps<{ title: string, sub?: string }>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center">
|
||||
<div class="flex flex-col grow">
|
||||
<div class="poppins font-semibold text-[1.1rem] md:text-[1.4rem] text-text">
|
||||
{{ props.title }}
|
||||
</div>
|
||||
<div v-if="props.sub" class="poppins text-[.8rem] md:text-[1.1rem] text-text-sub">
|
||||
{{ props.sub }}
|
||||
</div>
|
||||
</div>
|
||||
<slot name="header"></slot>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
557
dashboard/components/GlobeSvg.vue
Normal file
557
dashboard/components/GlobeSvg.vue
Normal file
File diff suppressed because one or more lines are too long
15
dashboard/components/MobileOnly.vue
Normal file
15
dashboard/components/MobileOnly.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="lg:hidden">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div class="hidden lg:flex">
|
||||
<slot name="desktop"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
34
dashboard/components/SelectButton.vue
Normal file
34
dashboard/components/SelectButton.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
type Props = {
|
||||
options: { label: string }[],
|
||||
currentIndex: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(evt: 'changeIndex', newIndex: number): void;
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
|
||||
<div class="flex gap-2 border-[1px] border-gray-400 p-1 md:p-2 rounded-xl">
|
||||
<div @click="$emit('changeIndex', index)" v-for="(opt, index) of options"
|
||||
class="hover:bg-white/10 select-btn-animated cursor-pointer rounded-lg poppins font-semibold px-2 md:px-3 py-1 text-[.8rem] md:text-[1rem]"
|
||||
:class="{ 'bg-accent hover:!bg-accent': currentIndex == index }">
|
||||
{{ opt.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
<style scoped lang="scss">
|
||||
.select-btn-animated {
|
||||
transition: all .4s linear;
|
||||
}
|
||||
</style>
|
||||
15
dashboard/components/TypeWriter.vue
Normal file
15
dashboard/components/TypeWriter.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const props = defineProps<{ text: string }>();
|
||||
const currentText = ref<string>("");
|
||||
|
||||
onMounted(()=>{
|
||||
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div>{{ text }}</div>
|
||||
</template>
|
||||
156
dashboard/components/dashboard/BarsCard.vue
Normal file
156
dashboard/components/dashboard/BarsCard.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
|
||||
export type IconProvider = (id: string) => ['img' | 'icon', string] | undefined;
|
||||
|
||||
|
||||
type Props = {
|
||||
data: { _id: string, count: number }[],
|
||||
iconProvider?: IconProvider,
|
||||
elementTextTransformer?: (text: string) => string,
|
||||
label: string,
|
||||
subLabel: string,
|
||||
desc: string,
|
||||
loading?: boolean,
|
||||
interactive?: boolean,
|
||||
isDetailView?: boolean,
|
||||
rawButton?: boolean,
|
||||
hideShowMore?: boolean
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
const emits = defineEmits<{
|
||||
(e: 'dataReload'): void,
|
||||
(e: 'showDetails', id: string): void,
|
||||
(e: 'showRawData'): void,
|
||||
(e: 'showGeneral'): void,
|
||||
(e: 'showMore'): void,
|
||||
}>();
|
||||
|
||||
const maxData = computed(() => {
|
||||
const counts = props.data.map(e => e.count);
|
||||
return Math.max(...counts);
|
||||
});
|
||||
|
||||
function reloadData() {
|
||||
emits('dataReload');
|
||||
}
|
||||
|
||||
function showDetails(id: string) {
|
||||
emits('showDetails', id);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="flex">
|
||||
|
||||
<div class="text-text flex flex-col items-start gap-4 w-full relative">
|
||||
|
||||
<div class="w-full p-4 flex flex-col bg-menu rounded-xl gap-8 card-shadow">
|
||||
|
||||
<div class="flex justify-between mb-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex gap-4 items-center">
|
||||
<div class="poppins font-semibold text-[1.4rem] text-text">
|
||||
{{ label }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<i @click="reloadData()"
|
||||
class="hover:rotate-[50deg] transition-all duration-100 fas fa-refresh text-[1.2rem] cursor-pointer"></i>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="poppins text-[1rem] text-text-sub/90">
|
||||
{{ desc }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="rawButton" class="hidden lg:flex">
|
||||
<div @click="$emit('showRawData')"
|
||||
class="cursor-pointer hover:bg-accent/60 flex items-center justify-center poppins bg-accent rounded-lg py-2 px-8">
|
||||
Raw data
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex justify-between font-bold text-text-sub/80 text-[1.1rem] mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="isDetailView" class="flex items-center justify-center">
|
||||
<i @click="$emit('showGeneral')"
|
||||
class="fas fa-arrow-left text-[.9rem] hover:text-text cursor-pointer"></i>
|
||||
</div>
|
||||
<div> {{ subLabel }} </div>
|
||||
</div>
|
||||
<div> Count </div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div v-if="props.data.length > 0" class="flex justify-between" v-for="element of props.data">
|
||||
<div class="w-10/12 relative" @click="showDetails(element._id)"
|
||||
:class="{ 'cursor-pointer line-active': interactive }">
|
||||
<div class="absolute rounded-sm w-full h-full bg-[#92abcf38]"
|
||||
:style="'width:' + 100 / maxData * element.count + '%;'"></div>
|
||||
<div class="flex px-2 py-1 relative items-center gap-4">
|
||||
<div v-if="iconProvider && iconProvider(element._id) != undefined"
|
||||
class="flex items-center h-[1.3rem]">
|
||||
<img v-if="iconProvider(element._id)?.[0] == 'img'" class="h-full"
|
||||
:src="iconProvider(element._id)?.[1]">
|
||||
<i v-else :class="iconProvider(element._id)?.[1]"></i>
|
||||
</div>
|
||||
<span
|
||||
class="text-ellipsis line-clamp-1 ui-font z-[20] text-[.95rem] text-text/70">
|
||||
{{ elementTextTransformer?.(element._id) || element._id }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-text font-semibold manrope"> {{ formatNumberK(element.count) }} </div>
|
||||
</div>
|
||||
<div v-if="props.data.length == 0"
|
||||
class="flex justify-center text-text-sub font-bold text-[1.1rem]">
|
||||
No visits yet
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!hideShowMore" class="flex justify-center mt-4 text-text-sub/90 ">
|
||||
<div @click="$emit('showMore')"
|
||||
class="poppins hover:bg-black cursor-pointer w-fit px-6 py-1 rounded-lg border-[1px] border-text-sub text-[.9rem]">
|
||||
Show more
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading"
|
||||
class="backdrop-blur-[1px] z-[20] left-0 top-0 w-full h-full flex items-center justify-center font-bold rockmann absolute">
|
||||
<i
|
||||
class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
<style scoped lang="scss">
|
||||
.line-active:hover {
|
||||
.absolute {
|
||||
@apply bg-accent/20
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.ui-font {
|
||||
font-feature-settings: normal;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
|
||||
font-variation-settings: normal;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
-moz-tab-size: 4;
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4
|
||||
}
|
||||
</style>
|
||||
36
dashboard/components/dashboard/BrowsersBarCard.vue
Normal file
36
dashboard/components/dashboard/BrowsersBarCard.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { BrowsersAggregated } from '~/server/api/metrics/[project_id]/data/browsers';
|
||||
|
||||
const activeProject = await useActiveProject();
|
||||
const { data: events, pending, refresh } = await useFetch<BrowsersAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/browsers`, signHeaders());
|
||||
|
||||
|
||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||
|
||||
function showMore() {
|
||||
|
||||
|
||||
showDialog.value = true;
|
||||
dialogBarData.value = [];
|
||||
isDataLoading.value = true;
|
||||
|
||||
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/browsers`, signHeaders({
|
||||
'x-query-limit': '200'
|
||||
})).then(data => {
|
||||
dialogBarData.value = data;
|
||||
isDataLoading.value = false;
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="events || []"
|
||||
desc="The browsers most used to search your website." :dataIcons="false" :loading="pending"
|
||||
label="Top Browsers" sub-label="Browsers"></DashboardBarsCard>
|
||||
</div>
|
||||
</template>
|
||||
68
dashboard/components/dashboard/CountCard.vue
Normal file
68
dashboard/components/dashboard/CountCard.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const props = defineProps<{
|
||||
icon: string,
|
||||
value: string,
|
||||
text: string,
|
||||
avg?: string,
|
||||
trend?: number,
|
||||
color: string,
|
||||
data?: number[],
|
||||
labels?: string[],
|
||||
ready?: boolean
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<Card class="flex flex-col overflow-hidden relative max-h-[12rem] aspect-[2/1] w-full">
|
||||
<div class="flex p-4 items-start">
|
||||
<div class="flex items-center mt-2 mr-4">
|
||||
<i :style="`color: ${props.color}`" :class="icon" class="text-[1.6rem] 2xl:text-[2rem]"></i>
|
||||
</div>
|
||||
<div class="flex flex-col grow">
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="brockmann text-text-dirty text-[1.6rem] 2xl:text-[2rem]"> {{ value }} </div>
|
||||
<div class="poppins text-text-sub text-[.7rem] 2xl:text-[.85rem] mb-2"> {{ avg }} </div>
|
||||
</div>
|
||||
<div class="poppins text-text-sub text-[.9rem] 2xl:text-base"> {{ text }} </div>
|
||||
</div>
|
||||
<div v-if="trend" class="flex items-center gap-3 rounded-xl px-2 py-1"
|
||||
:style="`background-color: ${props.color}33`">
|
||||
<i :class="trend > 0 ? 'fa-arrow-trend-up' : 'fa-arrow-trend-down'" class="far text-[.9rem] 2xl:text-[1rem]"
|
||||
:style="`color: ${props.color}`"></i>
|
||||
<div :style="`color: ${props.color}`" class="font-semibold text-[.75rem] 2xl:text-[.875rem]">
|
||||
{{ trend.toFixed(0) }} %
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 w-full h-[50%] flex items-end" v-if="(props.data?.length || 0) > 0">
|
||||
<DashboardEmbedChartCard v-if="ready" :data="props.data || []" :labels="props.labels || []" :color="props.color">
|
||||
</DashboardEmbedChartCard>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- <div class="bg-menu p-4 rounded-xl flex flex-col gap-2 w-full lg:w-[20rem] relative pb-2 lg:pb-4">
|
||||
|
||||
<div class="gap-4 flex flex-row items-center lg:items-start lg:gap-2 lg:flex-col">
|
||||
<div class="w-[2.5rem] h-[2.5rem] lg:w-[3.5rem] lg:h-[3.5rem] flex items-center justify-center rounded-lg"
|
||||
:style="`background: ${props.color}`">
|
||||
<i :class="icon" class="text-[1rem] lg:text-[1.5rem]"></i>
|
||||
</div>
|
||||
<div class="text-[1rem] lg:text-[1.3rem] text-text-sub/90 poppins">
|
||||
{{ title }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center lg:items-end">
|
||||
<div class="brockmann text-text text-[2rem] lg:text-[2.8rem] grow">
|
||||
{{ text }}
|
||||
</div>
|
||||
<div class="poppins text-text-sub/90 text-[.9rem] lg:text-[1rem]"> {{ sub }} </div>
|
||||
</div>
|
||||
|
||||
|
||||
</div> -->
|
||||
|
||||
</template>
|
||||
40
dashboard/components/dashboard/CountCardOld.vue
Normal file
40
dashboard/components/dashboard/CountCardOld.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const props = defineProps<{
|
||||
icon: string,
|
||||
title: string,
|
||||
text: string,
|
||||
sub: string,
|
||||
color: string
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="bg-menu p-4 rounded-xl flex flex-col gap-2 w-full lg:w-[20rem] relative pb-2 lg:pb-4">
|
||||
|
||||
<!-- <div class="absolute flex items-center justify-center right-4 top-4 cursor-pointer hover:text-blue-400">
|
||||
<i class="fal fa-info-circle text-[.9rem] lg:text-[1.4rem]"></i>
|
||||
</div> -->
|
||||
|
||||
<div class="gap-4 flex flex-row items-center lg:items-start lg:gap-2 lg:flex-col">
|
||||
<div class="w-[2.5rem] h-[2.5rem] lg:w-[3.5rem] lg:h-[3.5rem] flex items-center justify-center rounded-lg"
|
||||
:style="`background: ${props.color}`">
|
||||
<i :class="icon" class="text-[1rem] lg:text-[1.5rem]"></i>
|
||||
</div>
|
||||
<div class="text-[1rem] lg:text-[1.3rem] text-text-sub/90 poppins">
|
||||
{{ title }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center lg:items-end">
|
||||
<div class="brockmann text-text text-[2rem] lg:text-[2.8rem] grow">
|
||||
{{ text }}
|
||||
</div>
|
||||
<div class="poppins text-text-sub/90 text-[.9rem] lg:text-[1rem]"> {{ sub }} </div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
36
dashboard/components/dashboard/DevicesBarCard.vue
Normal file
36
dashboard/components/dashboard/DevicesBarCard.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { DevicesAggregated } from '~/server/api/metrics/[project_id]/data/devices';
|
||||
|
||||
const activeProject = await useActiveProject();
|
||||
const { data: events, pending, refresh } = await useFetch<DevicesAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/devices`, signHeaders());
|
||||
|
||||
|
||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||
|
||||
function showMore() {
|
||||
|
||||
|
||||
showDialog.value = true;
|
||||
dialogBarData.value = [];
|
||||
isDataLoading.value = true;
|
||||
|
||||
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/devices`, signHeaders({
|
||||
'x-query-limit': '200'
|
||||
})).then(data => {
|
||||
dialogBarData.value = data;
|
||||
isDataLoading.value = false;
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="events || []" :dataIcons="false"
|
||||
desc="The devices most used to access your website." :loading="pending" label="Top Devices"
|
||||
sub-label="Devices"></DashboardBarsCard>
|
||||
</div>
|
||||
</template>
|
||||
36
dashboard/components/dashboard/DialogBarCard.vue
Normal file
36
dashboard/components/dashboard/DialogBarCard.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const { dialogBarData, isDataLoading } = useBarCardDialog();
|
||||
|
||||
|
||||
const columns = [
|
||||
{ key: '_id', label: 'Value' },
|
||||
{ key: 'count', label: 'Count' },
|
||||
];
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
|
||||
<div class="w-full h-full bg-bg rounded-xl p-8">
|
||||
<div class="full h-full overflow-y-auto">
|
||||
<UTable :columns="columns" :rows="dialogBarData" :loading="isDataLoading" v-if="dialogBarData">
|
||||
<template #count-data="{ row }">
|
||||
<div class="font-bold"> {{ formatNumberK(row.count) }} </div>
|
||||
</template>
|
||||
|
||||
<template #_id-data="{ row }">
|
||||
<div class="flex items-center gap-3">
|
||||
<div v-if="row.icon && row.icon[0] == 'img'" class="w-5 h-5">
|
||||
<img class="w-full h-full" :src="row.icon[1]">
|
||||
</div>
|
||||
<i v-if="row.icon && row.icon[0] == 'icon'" :class="row.icon[1]"></i>
|
||||
<div> {{ row._id }} </div>
|
||||
</div>
|
||||
</template>
|
||||
</UTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
67
dashboard/components/dashboard/EmbedChartCard.vue
Normal file
67
dashboard/components/dashboard/EmbedChartCard.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script lang="ts" setup>
|
||||
import { Chart, registerables, type ChartData, type ChartOptions } from 'chart.js';
|
||||
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||
if (process.client) Chart.register(...registerables);
|
||||
|
||||
const props = defineProps<{
|
||||
data: any[],
|
||||
labels: string[]
|
||||
color: string,
|
||||
}>();
|
||||
|
||||
const chartOptions = ref<ChartOptions<'line'>>({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
aspectRatio: 0,
|
||||
scales: {
|
||||
y: {
|
||||
ticks: { display: false },
|
||||
grid: { display: false }
|
||||
},
|
||||
x: {
|
||||
ticks: { display: false },
|
||||
grid: { display: false }
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
title: { display: false },
|
||||
tooltip: { enabled: false },
|
||||
subtitle: { display: false },
|
||||
decimation: { enabled: false },
|
||||
},
|
||||
layout: {
|
||||
padding: {
|
||||
top: 6,
|
||||
bottom: -8,
|
||||
left: -8,
|
||||
right: -8
|
||||
},
|
||||
autoPadding: false
|
||||
},
|
||||
});
|
||||
|
||||
const chartData = ref<ChartData<'line'>>({
|
||||
labels: props.labels,
|
||||
datasets: [
|
||||
{
|
||||
data: props.data,
|
||||
backgroundColor: [props.color + '77'],
|
||||
borderColor: props.color,
|
||||
borderWidth: 4,
|
||||
fill: true,
|
||||
tension: 0.45,
|
||||
pointRadius: 0
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, options: chartOptions });
|
||||
|
||||
// onMounted(() => { })
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LineChart class="max-h-full max-w-full w-full h-full" ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
|
||||
</template>
|
||||
41
dashboard/components/dashboard/EventsBarCard.vue
Normal file
41
dashboard/components/dashboard/EventsBarCard.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { CustomEventsAggregated } from '~/server/api/metrics/[project_id]/visits/events';
|
||||
|
||||
const activeProject = await useActiveProject();
|
||||
const { data: events, pending, refresh } = await useFetch<CustomEventsAggregated[]>(`/api/metrics/${activeProject.value?._id}/visits/events`, signHeaders());
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function goToView() {
|
||||
router.push('/dashboard/events');
|
||||
}
|
||||
|
||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||
|
||||
function showMore() {
|
||||
|
||||
|
||||
showDialog.value = true;
|
||||
dialogBarData.value = [];
|
||||
isDataLoading.value = true;
|
||||
|
||||
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/visits/events`, signHeaders({
|
||||
'x-query-limit': '200'
|
||||
})).then(data => {
|
||||
dialogBarData.value = data;
|
||||
isDataLoading.value = false;
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<DashboardBarsCard @showMore="showMore()" @showRawData="goToView()" desc="Most frequent user events triggered in this project" @dataReload="refresh" :data="events || []" :loading="pending" label="Top Events"
|
||||
sub-label="Events" :rawButton="!isLiveDemo()"></DashboardBarsCard>
|
||||
</div>
|
||||
</template>
|
||||
91
dashboard/components/dashboard/EventsChart.vue
Normal file
91
dashboard/components/dashboard/EventsChart.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import { Chart, registerables, type ChartData, type ChartOptions } from 'chart.js';
|
||||
import { DoughnutChart, useDoughnutChart } from 'vue-chart-3';
|
||||
import type { EventsPie } from '~/server/api/metrics/[project_id]/events_pie';
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
if (process.client) Chart.register(...registerables);
|
||||
|
||||
const chartOptions = ref<ChartOptions<'doughnut'>>({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
ticks: { display: false },
|
||||
grid: { display: false, drawBorder: false },
|
||||
},
|
||||
x: {
|
||||
ticks: { display: false },
|
||||
grid: { display: false, drawBorder: false },
|
||||
},
|
||||
// r: {
|
||||
// ticks: { display: false },
|
||||
// grid: {
|
||||
// display: true,
|
||||
// drawBorder: false,
|
||||
// color: '#CCCCCC22',
|
||||
// borderDash: [20, 8]
|
||||
// },
|
||||
// }
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
align: 'center',
|
||||
labels: {
|
||||
color: 'white',
|
||||
font: {
|
||||
family: 'Poppins',
|
||||
size: 16
|
||||
}
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: false
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const chartData = ref<ChartData<'doughnut'>>({
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
rotation: 1,
|
||||
data: [],
|
||||
backgroundColor: ['#6bbbe3','#5655d0', '#a6d5cb', '#fae0b9'],
|
||||
borderColor: ['#1d1d1f'],
|
||||
borderWidth: 2
|
||||
},
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
const { doughnutChartProps, doughnutChartRef } = useDoughnutChart({ chartData: chartData, options: chartOptions });
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
const activeProject = useActiveProject()
|
||||
|
||||
const eventsData = await $fetch<EventsPie[]>(`/api/metrics/${activeProject.value?._id}/visits/events`, signHeaders());
|
||||
chartData.value.labels = eventsData.map(e => {
|
||||
return `${e._id}`;
|
||||
});
|
||||
chartData.value.datasets[0].data = eventsData.map(e => e.count);
|
||||
doughnutChartRef.value?.update();
|
||||
|
||||
if (window.innerWidth < 800) {
|
||||
if (chartOptions.value?.plugins?.legend?.display) {
|
||||
chartOptions.value.plugins.legend.display = false;
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<DoughnutChart v-bind="doughnutChartProps"> </DoughnutChart>
|
||||
</template>
|
||||
73
dashboard/components/dashboard/EventsPie.vue
Normal file
73
dashboard/components/dashboard/EventsPie.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<!-- <script lang="ts" setup>
|
||||
|
||||
import { Chart, registerables, type ChartData, type ChartOptions } from 'chart.js';
|
||||
import { PieChart, usePieChart } from 'vue-chart-3';
|
||||
import type { EventsPie } from '~/server/api/metrics/[project_id]/events_pie';
|
||||
|
||||
if (process.client) {
|
||||
Chart.register(...registerables);
|
||||
}
|
||||
|
||||
const { project } = await useCurrentProject();
|
||||
|
||||
const { data: eventsPieData } = await useFetch<EventsPie[]>(`/api/metrics/${project.value?._id}/events_pie`, signHeaders());
|
||||
|
||||
const eventsTimelineOptions = ref<ChartOptions<'pie'>>({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Events',
|
||||
color: '#EEECF6',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const eventsTimelineData = computed<ChartData<'pie'>>(() => ({
|
||||
labels: (eventsPieData.value || []).map((e: EventsPie) => {
|
||||
return e._id;
|
||||
}),
|
||||
datasets: [
|
||||
{
|
||||
data: (eventsPieData.value || []).map((e: EventsPie) => {
|
||||
return e.count;
|
||||
}),
|
||||
backgroundColor: [
|
||||
"#295270",
|
||||
"#304F71",
|
||||
"#374C72",
|
||||
"#3E4A73",
|
||||
"#444773",
|
||||
"#4B4474",
|
||||
"#524175",
|
||||
],
|
||||
borderColor: '#222222'
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
|
||||
const { pieChartProps } = usePieChart({ chartData: eventsTimelineData, options: eventsTimelineOptions });
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<template>
|
||||
|
||||
<div>
|
||||
|
||||
|
||||
<div class="graph">
|
||||
<PieChart v-bind="pieChartProps">
|
||||
</PieChart>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</template> -->
|
||||
35
dashboard/components/dashboard/GeolocationBarCard.vue
Normal file
35
dashboard/components/dashboard/GeolocationBarCard.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { CountriesAggregated } from '~/server/api/metrics/[project_id]/data/countries';
|
||||
|
||||
const activeProject = await useActiveProject();
|
||||
const { data: countries, pending, refresh } = await useFetch<CountriesAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/countries`, signHeaders());
|
||||
|
||||
|
||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||
|
||||
function showMore() {
|
||||
|
||||
|
||||
showDialog.value = true;
|
||||
dialogBarData.value = [];
|
||||
isDataLoading.value = true;
|
||||
|
||||
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/countries`, signHeaders({
|
||||
'x-query-limit': '200'
|
||||
})).then(data => {
|
||||
dialogBarData.value = data;
|
||||
isDataLoading.value = false;
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="countries || []" :dataIcons="false" :loading="pending"
|
||||
label="Top Countries" sub-label="Countries" desc=" Lists the countries where users access your website."></DashboardBarsCard>
|
||||
</div>
|
||||
</template>
|
||||
58
dashboard/components/dashboard/MiniChart.vue
Normal file
58
dashboard/components/dashboard/MiniChart.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import { Chart, registerables, type ChartData, type ChartOptions } from 'chart.js';
|
||||
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
if (process.client) Chart.register(...registerables);
|
||||
|
||||
type Props = { xs?: any[], ys?: any[], color: string, border: string }
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const chartOptions = ref<ChartOptions<'line'>>({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
ticks: { display: false },
|
||||
grid: { display: false, drawBorder: false },
|
||||
},
|
||||
x: {
|
||||
ticks: { display: false },
|
||||
grid: { display: false, drawBorder: false }
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
title: { display: false },
|
||||
},
|
||||
});
|
||||
|
||||
const chartData = computed<ChartData<'line'>>(() => ({
|
||||
labels: (props.xs || []),
|
||||
datasets: [
|
||||
{
|
||||
data: (props.ys || []),
|
||||
backgroundColor: [props.color],
|
||||
borderColor: props.border,
|
||||
borderWidth: 3,
|
||||
fill: true,
|
||||
pointRadius: 0,
|
||||
lineTension: 0.3,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
|
||||
const { lineChartProps } = useLineChart({ chartData: chartData, options: chartOptions });
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<LineChart v-bind="lineChartProps"> </LineChart>
|
||||
</template>
|
||||
36
dashboard/components/dashboard/OssBarCard.vue
Normal file
36
dashboard/components/dashboard/OssBarCard.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { OssAggregated } from '~/server/api/metrics/[project_id]/data/oss';
|
||||
|
||||
const activeProject = await useActiveProject();
|
||||
const { data: events, pending, refresh } = await useFetch<OssAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/oss`, signHeaders());
|
||||
|
||||
|
||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||
|
||||
function showMore() {
|
||||
|
||||
|
||||
showDialog.value = true;
|
||||
dialogBarData.value = [];
|
||||
isDataLoading.value = true;
|
||||
|
||||
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/oss`, signHeaders({
|
||||
'x-query-limit': '200'
|
||||
})).then(data => {
|
||||
dialogBarData.value = data;
|
||||
isDataLoading.value = false;
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<DashboardBarsCard @showMore="showMore()" @dataReload="refresh" :data="events || []"
|
||||
desc="The operating systems most commonly used by your website's visitors." :dataIcons="false"
|
||||
:loading="pending" label="Top OS" sub-label="OSs"></DashboardBarsCard>
|
||||
</div>
|
||||
</template>
|
||||
44
dashboard/components/dashboard/ProjectSelectionCard.vue
Normal file
44
dashboard/components/dashboard/ProjectSelectionCard.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
type Prop = {
|
||||
title: string,
|
||||
subtitle: string,
|
||||
chip?: string,
|
||||
active: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Prop>();
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
|
||||
|
||||
<div
|
||||
class="flex flex-col rounded-xl overflow-hidden hover:shadow-[0_0_50px_#2969f1] hover:outline hover:outline-[2px] hover:outline-accent cursor-pointer">
|
||||
<div class="h-[14rem] aspect-[9/7] bg-[#2f2a64] flex relative">
|
||||
<img class="object-cover" :src="'/report/card_image.png'">
|
||||
|
||||
<div v-if="chip"
|
||||
class="absolute px-4 py-1 rounded-lg poppins left-2 flex gap-2 bottom-2 bg-orange-500/80 items-center">
|
||||
<div class="flex items-center"> <i class="far fa-fire text-[1.1rem]"></i></div>
|
||||
<div class="poppins text-[1rem] font-semibold"> {{ chip }} </div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="bg-[#444444cc] p-4 h-[7rem] relative">
|
||||
<div class="poppins text-[1.2rem] font-bold text-text">
|
||||
{{ title }}
|
||||
</div>
|
||||
<div class="poppins text-[1rem] text-text-sub/90">
|
||||
{{ subtitle }}
|
||||
</div>
|
||||
<div class="absolute right-4 bottom-3">
|
||||
<i class="fas fa-arrow-right text-[1.2rem]"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</template>
|
||||
51
dashboard/components/dashboard/ReferrersBarCard.vue
Normal file
51
dashboard/components/dashboard/ReferrersBarCard.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { ReferrersAggregated } from '~/server/api/metrics/[project_id]/data/referrers';
|
||||
import type { IconProvider } from './BarsCard.vue';
|
||||
|
||||
const activeProject = await useActiveProject();
|
||||
const { data: events, pending, refresh } = await useFetch<ReferrersAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/referrers`, signHeaders());
|
||||
|
||||
|
||||
function iconProvider(id: string): ReturnType<IconProvider> {
|
||||
if (id === 'self') return ['icon', 'fas fa-link'];
|
||||
return ['img', `https://s2.googleusercontent.com/s2/favicons?domain=${id}&sz=64`]
|
||||
}
|
||||
|
||||
function elementTextTransformer(element: string) {
|
||||
if (element === 'self') return 'Direct Link';
|
||||
return element;
|
||||
}
|
||||
|
||||
|
||||
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
|
||||
|
||||
function showMore() {
|
||||
|
||||
|
||||
showDialog.value = true;
|
||||
dialogBarData.value = [];
|
||||
isDataLoading.value = true;
|
||||
|
||||
$fetch<any[]>(`/api/metrics/${activeProject.value?._id}/data/referrers`, signHeaders({
|
||||
'x-query-limit': '200'
|
||||
})).then(data => {
|
||||
dialogBarData.value = data.map(e => {
|
||||
return { ...e, icon: iconProvider(e._id) }
|
||||
});
|
||||
isDataLoading.value = false;
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<DashboardBarsCard @showMore="showMore()" :elementTextTransformer="elementTextTransformer"
|
||||
:iconProvider="iconProvider" @dataReload="refresh" :data="events || []"
|
||||
desc="Where users find your website." :dataIcons="true" :loading="pending" label="Top Referrers"
|
||||
sub-label="Referrers"></DashboardBarsCard>
|
||||
</div>
|
||||
</template>
|
||||
31
dashboard/components/dashboard/SessionsLineChart.vue
Normal file
31
dashboard/components/dashboard/SessionsLineChart.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
const data = ref<number[]>([]);
|
||||
const labels = ref<string[]>([]);
|
||||
const ready = ref<boolean>(false);
|
||||
const key = ref<string>('0');
|
||||
|
||||
const props = defineProps<{ slice: SliceName }>();
|
||||
|
||||
async function loadData() {
|
||||
const response = await useTimelineData('sessions', props.slice);
|
||||
if (!response) return;
|
||||
data.value = response.data;
|
||||
labels.value = response.labels;
|
||||
ready.value = true;
|
||||
key.value = Date.now().toString();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData();
|
||||
watch(props, async () => { await loadData(); });
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<AdvancedLineChart v-if="ready" :data="data" :labels="labels" color="#f56523"></AdvancedLineChart>
|
||||
</div>
|
||||
</template>
|
||||
113
dashboard/components/dashboard/TopCards.vue
Normal file
113
dashboard/components/dashboard/TopCards.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MetricsTimeline } from '~/server/api/metrics/[project_id]/timeline/generic';
|
||||
|
||||
|
||||
const { data: metricsInfo } = useMetricsData();
|
||||
|
||||
const avgVisitDay = computed(() => {
|
||||
if (!metricsInfo.value) return '0.00';
|
||||
const days = (Date.now() - (metricsInfo.value?.firstViewDate || 0)) / 1000 / 60 / 60 / 24;
|
||||
const avg = metricsInfo.value.visitsCount / Math.max(days, 1);
|
||||
return avg.toFixed(2);
|
||||
});
|
||||
|
||||
const avgEventsDay = computed(() => {
|
||||
if (!metricsInfo.value) return '0.00';
|
||||
const days = (Date.now() - (metricsInfo.value?.firstEventDate || 0)) / 1000 / 60 / 60 / 24;
|
||||
const avg = metricsInfo.value.eventsCount / Math.max(days, 1);
|
||||
return avg.toFixed(2);
|
||||
});
|
||||
|
||||
const avgSessionsDay = computed(() => {
|
||||
if (!metricsInfo.value) return '0.00';
|
||||
const days = (Date.now() - (metricsInfo.value?.firstViewDate || 0)) / 1000 / 60 / 60 / 24;
|
||||
const avg = metricsInfo.value.sessionsVisitsCount / Math.max(days, 1);
|
||||
return avg.toFixed(2);
|
||||
});
|
||||
|
||||
|
||||
const avgSessionDuration = computed(() => {
|
||||
if (!metricsInfo.value) return '0.00';
|
||||
const avg = metricsInfo.value.avgSessionDuration;
|
||||
|
||||
let hours = 0;
|
||||
let minutes = 0;
|
||||
let seconds = 0;
|
||||
seconds += avg * 60;
|
||||
|
||||
while (seconds > 60) {
|
||||
seconds -= 60;
|
||||
minutes += 1;
|
||||
}
|
||||
|
||||
while (minutes > 60) {
|
||||
minutes -= 60;
|
||||
hours += 1;
|
||||
}
|
||||
|
||||
|
||||
return `${hours > 0 ? hours + 'h ' : ''}${minutes}m ${seconds.toFixed()}s`
|
||||
});
|
||||
|
||||
type Data = {
|
||||
data: number[],
|
||||
labels: string[],
|
||||
trend: number,
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
|
||||
const visitsData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
|
||||
const eventsData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
|
||||
const sessionsData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
|
||||
const sessionsDurationData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
|
||||
|
||||
async function loadData(timelineEndpointName: string, target: Data) {
|
||||
const response = await useTimelineData(timelineEndpointName, 'day');
|
||||
if (!response) return;
|
||||
target.data = response.data;
|
||||
target.labels = response.labels;
|
||||
target.trend = response.trend;
|
||||
target.ready = true;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
await loadData('visits', visitsData);
|
||||
await loadData('events', eventsData);
|
||||
await loadData('sessions', sessionsData);
|
||||
await loadData('sessions_duration', sessionsDurationData);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-4" v-if="metricsInfo">
|
||||
|
||||
<DashboardCountCard :ready="visitsData.ready" icon="far fa-earth" text="Total page visits"
|
||||
:value="formatNumberK(metricsInfo.visitsCount)" :avg="formatNumberK(avgVisitDay) + '/day'"
|
||||
:trend="visitsData.trend" :data="visitsData.data" :labels="visitsData.labels" color="#5655d7">
|
||||
</DashboardCountCard>
|
||||
|
||||
<DashboardCountCard :ready="eventsData.ready" icon="far fa-flag" text="Total custom events"
|
||||
:value="formatNumberK(metricsInfo.eventsCount)" :avg="formatNumberK(avgEventsDay) + '/day'"
|
||||
:trend="eventsData.trend" :data="eventsData.data" :labels="eventsData.labels" color="#1e9b86">
|
||||
</DashboardCountCard>
|
||||
|
||||
<DashboardCountCard :ready="sessionsData.ready" icon="far fa-user" text="Unique visits sessions"
|
||||
:value="formatNumberK(metricsInfo.sessionsVisitsCount)" :avg="formatNumberK(avgSessionsDay) + '/day'"
|
||||
:trend="sessionsData.trend" :data="sessionsData.data" :labels="sessionsData.labels" color="#4abde8">
|
||||
</DashboardCountCard>
|
||||
|
||||
<DashboardCountCard :ready="sessionsDurationData.ready" icon="far fa-timer" text="Avg session time"
|
||||
:value="avgSessionDuration" :trend="sessionsDurationData.trend" :data="sessionsDurationData.data"
|
||||
:labels="sessionsDurationData.labels" color="#f56523">
|
||||
</DashboardCountCard>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
42
dashboard/components/dashboard/TopSection.vue
Normal file
42
dashboard/components/dashboard/TopSection.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
const { onlineUsers, stopWatching, startWatching } = useOnlineUsers();
|
||||
onMounted(()=> startWatching());
|
||||
onUnmounted(() => stopWatching());
|
||||
|
||||
|
||||
function copyProjectId() {
|
||||
if (!navigator.clipboard) alert('NON PUOI COPIARE IN HTTP');
|
||||
navigator.clipboard.writeText((activeProject.value?._id || 0).toString());
|
||||
alert('Copiato !');
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-full px-6 py-2 lg:py-6 font-bold text-text-sub/40 flex flex-col xl:flex-row text-lg lg:text-2xl gap-2 xl:gap-12">
|
||||
|
||||
<div class="flex gap-2 items-center text-text/90">
|
||||
<div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
|
||||
<div> {{ onlineUsers }} Online users</div>
|
||||
</div>
|
||||
|
||||
<div class="grow"></div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div>Project:</div>
|
||||
<div class="text-text/90"> {{ activeProject?.name || 'Loading...' }} </div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div>Project id:</div>
|
||||
<div class="text-text/90 text-[.9rem] lg:text-2xl">
|
||||
{{ activeProject?._id || 'Loading...' }}
|
||||
</div>
|
||||
<div class="flex items-center ml-3">
|
||||
<i @click="copyProjectId()" class="far fa-copy hover:text-text cursor-pointer text-[1.2rem]"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
31
dashboard/components/dashboard/VisitsLineChart.vue
Normal file
31
dashboard/components/dashboard/VisitsLineChart.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
const data = ref<number[]>([]);
|
||||
const labels = ref<string[]>([]);
|
||||
const ready = ref<boolean>(false);
|
||||
|
||||
const props = defineProps<{ slice: SliceName }>();
|
||||
|
||||
async function loadData() {
|
||||
const response = await useTimelineDataRaw('visits', props.slice);
|
||||
if (!response) return;
|
||||
const fixed = fixMetrics(response, props.slice);
|
||||
console.log(fixed);
|
||||
data.value = fixed.data;
|
||||
labels.value = fixed.labels;
|
||||
ready.value = true;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData();
|
||||
watch(props, async () => { await loadData(); });
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<AdvancedLineChart v-if="ready" :data="data" :labels="labels" color="#5655d7"></AdvancedLineChart>
|
||||
</div>
|
||||
</template>
|
||||
61
dashboard/components/dashboard/WebsitesBarCard.vue
Normal file
61
dashboard/components/dashboard/WebsitesBarCard.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { VisitsWebsiteAggregated } from '~/server/api/metrics/[project_id]/data/websites';
|
||||
|
||||
const { data: websites, pending, refresh } = useWebsitesData();
|
||||
|
||||
const currentViewData = ref<(VisitsWebsiteAggregated[] | null)>(websites.value);
|
||||
|
||||
watch(pending, () => {
|
||||
currentViewData.value = websites.value;
|
||||
})
|
||||
|
||||
const isPagesView = ref<boolean>(false);
|
||||
const isLoading = ref<boolean>(false);
|
||||
|
||||
async function showDetails(website: string) {
|
||||
if (isPagesView.value == true) return;
|
||||
isLoading.value = true;
|
||||
isPagesView.value = true;
|
||||
|
||||
const { data: pagesData, pending } = usePagesData(website, 10);
|
||||
|
||||
watch(pending, () => {
|
||||
currentViewData.value = pagesData.value;
|
||||
isLoading.value = false;
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function goToView() {
|
||||
router.push('/dashboard/visits');
|
||||
}
|
||||
|
||||
function setDefaultData() {
|
||||
currentViewData.value = websites.value;
|
||||
isPagesView.value = false;
|
||||
}
|
||||
|
||||
async function dataReload() {
|
||||
await refresh();
|
||||
setDefaultData();
|
||||
}
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<DashboardBarsCard :hideShowMore="true" @showGeneral="setDefaultData()" @showRawData="goToView()"
|
||||
@dataReload="dataReload()" @showDetails="showDetails" :data="currentViewData || []"
|
||||
:loading="pending || isLoading" :label="isPagesView ? 'Top pages' : 'Top Websites'"
|
||||
:sub-label="isPagesView ? 'Page' : 'Website'"
|
||||
:desc="isPagesView ? 'Most visited pages' : 'Most visited website in this project'"
|
||||
:interactive="!isPagesView" :rawButton="!isLiveDemo()" :isDetailView="isPagesView">
|
||||
</DashboardBarsCard>
|
||||
</div>
|
||||
</template>
|
||||
23
dashboard/components/dashboard/events/EventsColorManager.vue
Normal file
23
dashboard/components/dashboard/events/EventsColorManager.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const builtinEvents = [
|
||||
{ name: 'INFO', color: '#33a0e8' },
|
||||
{ name: 'WARNING', color: '#ef8d44' },
|
||||
{ name: 'ERROR', color: '#e47069' },
|
||||
]
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="bg-[#2a2e34] outline outline-[1px] outline-[#bdbdbd70] rounded-lg p-2 px-4 flex gap-2 items-center w-full" v-for="e of builtinEvents">
|
||||
<div :style="`background-color: ${e.color}`" class="w-[1rem] h-[1rem] rounded-full"> </div>
|
||||
<div class="poppins"> {{ e.name }} </div>
|
||||
<div class="grow"></div>
|
||||
<!-- <div> <i class="far fa-trash"></i> </div> -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
52
dashboard/components/events/EventsStackedBarChart.vue
Normal file
52
dashboard/components/events/EventsStackedBarChart.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
const datasets = ref<any[]>([]);
|
||||
const labels = ref<string[]>([]);
|
||||
const ready = ref<boolean>(false);
|
||||
|
||||
const props = defineProps<{ slice: SliceName }>();
|
||||
|
||||
async function loadData() {
|
||||
const response = await useTimelineDataRaw('events_stacked', props.slice);
|
||||
if (!response) return;
|
||||
|
||||
const fixed = fixMetrics(response, props.slice, { advanced: true, advancedGroupKey: 'name' });
|
||||
|
||||
const parsedDatasets: any[] = [];
|
||||
const colors = ['#5655d0', '#6bbbe3', '#a6d5cb', '#fae0b9'];
|
||||
|
||||
for (let i = 0; i < fixed.allKeys.length; i++) {
|
||||
const line: any = {
|
||||
data: [],
|
||||
color: colors[i] || '#FF0000',
|
||||
label: fixed.allKeys[i]
|
||||
};
|
||||
parsedDatasets.push(line)
|
||||
fixed.data.forEach((e: { key: string, value: number }[]) => {
|
||||
const target = e.find(e => e.key == fixed.allKeys[i]);
|
||||
if (!target) return;
|
||||
line.data.push(target.value);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
datasets.value = parsedDatasets;
|
||||
labels.value = fixed.labels;
|
||||
ready.value = true;
|
||||
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData();
|
||||
watch(props, async () => { await loadData(); });
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<AdvancedStackedBarChart v-if="ready" :datasets="datasets" :labels="labels">
|
||||
</AdvancedStackedBarChart>
|
||||
</div>
|
||||
</template>
|
||||
108
dashboard/components/home/BgGrid.vue
Normal file
108
dashboard/components/home/BgGrid.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
|
||||
|
||||
type Props = {
|
||||
size: number,
|
||||
spacing: number,
|
||||
opacity: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const sizeArr = new Array(props.size).fill('a');
|
||||
|
||||
function calculateOpacity(x: number, y: number) {
|
||||
const distanceFromCenter = Math.sqrt(Math.pow(x - props.size / 2, 2) + Math.pow(y - props.size / 2, 2));
|
||||
const normalizedDistance = distanceFromCenter / (props.size / 2);
|
||||
return (1 - normalizedDistance).toFixed(1);
|
||||
}
|
||||
|
||||
const widthHeight = computed(() => {
|
||||
return 9 + props.size * props.spacing;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="w-fit h-fit">
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" :width="widthHeight" :height="widthHeight" :style="`opacity: ${props.opacity};`"
|
||||
fill="none">
|
||||
|
||||
<template v-for="(p, x) of sizeArr">
|
||||
<template v-for="(p, y) of sizeArr">
|
||||
<circle :cx="9 + (spacing * x)" :cy="9 + (spacing * y)" r="1" fill="#fff"
|
||||
:fill-opacity="calculateOpacity(x, y)" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- <circle cx="27" cy="9" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="45" cy="9" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="63" cy="9" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="81" cy="9" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="99" cy="9" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="117" cy="9" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="135" cy="9" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="9" cy="27" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="27" cy="27" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="45" cy="27" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="63" cy="27" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="81" cy="27" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="99" cy="27" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="117" cy="27" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="135" cy="27" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="9" cy="45" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="27" cy="45" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="45" cy="45" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="63" cy="45" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="81" cy="45" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="99" cy="45" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="117" cy="45" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="135" cy="45" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="9" cy="63" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="27" cy="63" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="45" cy="63" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="63" cy="63" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="81" cy="63" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="99" cy="63" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="117" cy="63" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="135" cy="63" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="9" cy="81" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="27" cy="81" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="45" cy="81" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="63" cy="81" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="81" cy="81" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="99" cy="81" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="117" cy="81" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="135" cy="81" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="9" cy="99" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="27" cy="99" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="45" cy="99" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="63" cy="99" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="81" cy="99" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="99" cy="99" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="117" cy="99" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="135" cy="99" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="9" cy="117" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="27" cy="117" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="45" cy="117" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="63" cy="117" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="81" cy="117" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="99" cy="117" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="117" cy="117" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="135" cy="117" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="9" cy="135" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="27" cy="135" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="45" cy="135" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="63" cy="135" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="81" cy="135" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="99" cy="135" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="117" cy="135" r="1" fill="#fff" fill-opacity=".9" />
|
||||
<circle cx="135" cy="135" r="1" fill="#fff" fill-opacity=".9" />
|
||||
-->
|
||||
</svg>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
23
dashboard/components/home/HomeCard.vue
Normal file
23
dashboard/components/home/HomeCard.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
|
||||
const props = defineProps<{ title: string, text: string, icon: string }>();
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="bg-menu flex flex-col justify-center items-center px-8 py-10 w-[20rem] gap-4 rounded-xl">
|
||||
<div>
|
||||
<div class="bg-[#36363f] p-6 flex items-center justify-center aspect-[1/1] rounded-2xl">
|
||||
<i :class="props.icon" class="text-text text-[1.6rem]"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-text text-[1.3rem] poppins font-semibold text-center mt-6">
|
||||
{{ props.title }}
|
||||
</div>
|
||||
<div class="text-text-sub/80 text-[1rem] poppins text-center">
|
||||
{{ props.text }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
58
dashboard/components/pricing/PricingCard.vue
Normal file
58
dashboard/components/pricing/PricingCard.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
|
||||
type Prop = {
|
||||
title: string,
|
||||
icon: string,
|
||||
list: { text: string, icon: string }[],
|
||||
price: string,
|
||||
}
|
||||
|
||||
const props = defineProps<Prop>();
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="bg-menu py-6 rounded-lg w-full h-full flex flex-col items-center justify-normal px-6 relative">
|
||||
|
||||
<div
|
||||
class="absolute rounded-full top-[-2.1rem] bg-accent w-[4.2rem] h-[4.2rem] flex items-center justify-center">
|
||||
<i :class="icon" class="text-[2.5rem]"></i>
|
||||
</div>
|
||||
|
||||
<div class="poppins mt-6 font-semibold text-[1.4rem]">
|
||||
{{ title }}
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-400/50 h-[1px] w-full mt-6 mb-10"></div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex gap-3 items-center" v-for="element of list">
|
||||
|
||||
<div class="shrink-0 flex items-center bg-accent w-[2rem] h-[2rem] justify-center rounded-full">
|
||||
<i :class="element.icon" class="text-[.9rem]"></i>
|
||||
</div>
|
||||
|
||||
<div class="poppins">
|
||||
{{ element.text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-400/50 h-[1px] w-full mt-10 mb-6"></div>
|
||||
|
||||
<div class="flex gap-2 justify-between w-full">
|
||||
<div class="flex gap-2 items-end">
|
||||
<div class="manrope text-[2.5rem] font-bold text-text"> {{ price }} </div>
|
||||
<div class="poppins text-text-sub/90 mb-1">/month</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
Tasto bello
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
Reference in New Issue
Block a user