mirror of
https://github.com/Litlyx/litlyx
synced 2025-12-11 00:08:37 +01:00
new selfhosted version
This commit is contained in:
@@ -1,34 +1,16 @@
|
||||
<script lang="ts" setup>
|
||||
<script setup lang="ts">
|
||||
|
||||
const { clear } = useUserSession();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const project_id = route.query.project_id;
|
||||
if (!project_id) throw Error('project_id is required');
|
||||
const res = await $fetch('/api/project/members/accept', {
|
||||
headers: useComputedHeaders({
|
||||
custom: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}).value,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ project_id })
|
||||
});
|
||||
router.push('/');
|
||||
} catch (ex) {
|
||||
console.error('ERROR');
|
||||
console.error(ex);
|
||||
alert('An error occurred');
|
||||
}
|
||||
await clear();
|
||||
router.push('/');
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
You will be redirected soon.
|
||||
<div class="flex flex-col items-center mt-[10vh]">
|
||||
</div>
|
||||
</template>
|
||||
81
dashboard/pages/account.vue
Normal file
81
dashboard/pages/account.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script lang="ts" setup>
|
||||
import { DialogDeleteAccount } from '#components';
|
||||
import { Trash } from 'lucide-vue-next';
|
||||
import type { TUserMe } from '~/server/api/user/me';
|
||||
|
||||
const { data: me } = useAuthFetch<TUserMe>('/api/user/me');
|
||||
|
||||
definePageMeta({ layout: 'sidebar' });
|
||||
|
||||
const newPassword = ref<string>('');
|
||||
|
||||
const { clear } = useUserSession();
|
||||
const router = useRouter();
|
||||
const dialog = useDialog();
|
||||
|
||||
async function showDeleteAccountDialog() {
|
||||
dialog.open({
|
||||
body: DialogDeleteAccount,
|
||||
title: 'Delete account',
|
||||
async onSuccess() {
|
||||
deleteAccount();
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteAccount() {
|
||||
await useCatch({
|
||||
toast: true,
|
||||
toastTitle: 'Error deleting account data',
|
||||
async action() {
|
||||
await useAuthFetchSync('/api/user/delete', { method: 'DELETE' })
|
||||
},
|
||||
async onSuccess(_, showToast) {
|
||||
showToast('Deleting scheduled', { description: 'Account deleted successfully.', position: 'top-right' })
|
||||
dialog.close();
|
||||
await clear();
|
||||
router.push('/');
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="p-4 space-y-4 poppins">
|
||||
<PageHeader title="Account Settings"
|
||||
description="Manage your account"/>
|
||||
<Card>
|
||||
<CardContent class="flex flex-col gap-8">
|
||||
|
||||
<div class="flex flex-col">
|
||||
<PageHeader title="Change account password"/>
|
||||
<div v-if="!me" class="flex gap-4">
|
||||
<p class="text-gray-500 text-sm lg:text-md dark:text-gray-400"><Loader class="size-4"/></p>
|
||||
</div>
|
||||
<div v-if="me && me.email_login" class="flex gap-4">
|
||||
<p class="text-gray-500 text-sm lg:text-md dark:text-gray-400"> You can change your password <NuxtLink to="/forgot_password"> here </NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="me && !me.email_login">
|
||||
<PageHeader description="You cannot change the password for accounts created using social login
|
||||
options."/>
|
||||
</div>
|
||||
</div>
|
||||
<Separator/>
|
||||
<div class="flex justify-between items-center">
|
||||
<PageHeader title="Delete account" description="Deleting your account, Analytics, Events will be removed"/>
|
||||
<Button @click="showDeleteAccountDialog()" variant="destructive">
|
||||
<Trash></Trash>
|
||||
Delete account
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
@@ -1,46 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { CItem } from '~/components/CustomTab.vue';
|
||||
definePageMeta({ layout: 'sidebar' });
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
|
||||
const details = ref<any>();
|
||||
const showDetails = ref<boolean>(false);
|
||||
|
||||
const tabs: CItem[] = [
|
||||
{ label: 'Overview', slot: 'overview' },
|
||||
{ label: 'Users', slot: 'users' },
|
||||
{ label: 'Feedbacks', slot: 'feedbacks' },
|
||||
{ label: 'OnBoarding', slot: 'onboarding' },
|
||||
{ label: 'Backend', slot: 'backend' }
|
||||
]
|
||||
const activeTab = ref<string>('overview');
|
||||
const { user } = useUserSession();
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="bg-bg overflow-y-hidden w-full p-6 gap-6 flex flex-col h-full">
|
||||
<Unauthorized v-if="!user || user.email !== 'helplitlyx@gmail.com'" authorization="User Limitation">
|
||||
</Unauthorized>
|
||||
<div v-else class="p-4 h-full overflow-y-hidden">
|
||||
<Tabs v-model="activeTab" class="w-full h-full overflow-y-hidden">
|
||||
|
||||
<CustomTab :items="tabs" :manualScroll="true">
|
||||
<template #overview>
|
||||
<AdminOverview></AdminOverview>
|
||||
</template>
|
||||
<template #users>
|
||||
<AdminUsers></AdminUsers>
|
||||
</template>
|
||||
<template #feedbacks>
|
||||
<AdminFeedbacks></AdminFeedbacks>
|
||||
</template>
|
||||
<template #onboarding>
|
||||
<AdminOnboardings></AdminOnboardings>
|
||||
</template>
|
||||
<template #backend>
|
||||
<AdminBackend></AdminBackend>
|
||||
</template>
|
||||
</CustomTab>
|
||||
<TabsList class="w-full mb-4">
|
||||
<TabsTrigger value="overview">
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="feedbacks">
|
||||
Feedbacks
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="onboarding">
|
||||
Onboarding
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="aichats">
|
||||
Ai Chats
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="backend">
|
||||
Backend
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" class="overflow-y-hidden h-full">
|
||||
<LazyAdminOverview></LazyAdminOverview>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="feedbacks" class="overflow-y-hidden h-full">
|
||||
<LazyAdminFeedbacks></LazyAdminFeedbacks>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="onboarding" class="overflow-y-hidden h-full">
|
||||
<LazyAdminOnboarding></LazyAdminOnboarding>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
<TabsContent value="aichats" class="overflow-y-hidden h-full">
|
||||
<LazyAdminAiChat></LazyAdminAiChat>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="backend" class="overflow-y-hidden h-full">
|
||||
<LazyAdminBackend></LazyAdminBackend>
|
||||
</TabsContent>
|
||||
|
||||
</Tabs>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
</template>
|
||||
@@ -1,287 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import type { AdminProjectsList } from '~/server/api/admin/projects';
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
|
||||
const filterPremium = ref<boolean>(false);
|
||||
const filterAppsumo = ref<boolean>(false);
|
||||
|
||||
|
||||
|
||||
const timeRange = ref<number>(9);
|
||||
|
||||
function setTimeRange(n: number) {
|
||||
timeRange.value = n;
|
||||
}
|
||||
|
||||
const timeRangeTimestamp = computed(() => {
|
||||
if (timeRange.value == 1) return Date.now() - 1000 * 60 * 60 * 24;
|
||||
if (timeRange.value == 2) return Date.now() - 1000 * 60 * 60 * 24 * 7;
|
||||
if (timeRange.value == 3) return Date.now() - 1000 * 60 * 60 * 24 * 30;
|
||||
return 0;
|
||||
})
|
||||
|
||||
|
||||
const { data: projectsAggregatedResponseData } = await useFetch<AdminProjectsList[]>('/api/admin/projects', signHeaders());
|
||||
const { data: counts } = await useFetch(() => `/api/admin/counts?from=${timeRangeTimestamp.value}`, signHeaders());
|
||||
|
||||
|
||||
|
||||
function onHideClicked() {
|
||||
isAdminHidden.value = true;
|
||||
}
|
||||
|
||||
|
||||
function isAppsumoType(type: number) {
|
||||
return type > 6000 && type < 6004
|
||||
}
|
||||
|
||||
const projectsAggregated = computed(() => {
|
||||
|
||||
let pool = projectsAggregatedResponseData.value ? [...projectsAggregatedResponseData.value] : [];
|
||||
|
||||
let shownPool: AdminProjectsList[] = [];
|
||||
|
||||
|
||||
for (const element of pool) {
|
||||
|
||||
shownPool.push({ ...element, projects: [...element.projects] });
|
||||
|
||||
if (filterAppsumo.value === true) {
|
||||
shownPool.forEach(e => {
|
||||
e.projects = e.projects.filter(project => {
|
||||
return isAppsumoType(project.premium_type)
|
||||
})
|
||||
})
|
||||
|
||||
shownPool = shownPool.filter(e => {
|
||||
return e.projects.length > 0;
|
||||
})
|
||||
|
||||
} else if (filterPremium.value === true) {
|
||||
shownPool.forEach(e => {
|
||||
e.projects = e.projects.filter(project => {
|
||||
return project.premium === true;
|
||||
})
|
||||
})
|
||||
|
||||
shownPool = shownPool.filter(e => {
|
||||
return e.projects.length > 0;
|
||||
})
|
||||
|
||||
} else {
|
||||
console.log('NO DATA')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return shownPool.sort((a, b) => {
|
||||
const sumVisitsA = a.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0) + (pe.counts?.events || 0), 0);
|
||||
const sumVisitsB = b.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0) + (pe.counts?.events || 0), 0);
|
||||
return sumVisitsB - sumVisitsA;
|
||||
}).filter(e => {
|
||||
return new Date(e.created_at).getTime() >= timeRangeTimestamp.value
|
||||
});
|
||||
})
|
||||
|
||||
const premiumCount = computed(() => {
|
||||
let premiums = 0;
|
||||
projectsAggregated.value?.forEach(e => {
|
||||
e.projects.forEach(p => {
|
||||
if (p.premium) premiums++;
|
||||
});
|
||||
|
||||
})
|
||||
return premiums;
|
||||
})
|
||||
|
||||
|
||||
const activeProjects = computed(() => {
|
||||
let actives = 0;
|
||||
|
||||
projectsAggregated.value?.forEach(e => {
|
||||
e.projects.forEach(p => {
|
||||
if (!p.counts) return;
|
||||
if (!p.counts.updated_at) return;
|
||||
const updated_at = new Date(p.counts.updated_at).getTime();
|
||||
if (updated_at < Date.now() - 1000 * 60 * 60 * 24) return;
|
||||
actives++;
|
||||
});
|
||||
})
|
||||
return actives;
|
||||
});
|
||||
|
||||
|
||||
|
||||
const totalVisits = computed(() => {
|
||||
return projectsAggregated.value?.reduce((a, e) => {
|
||||
return a + e.projects.reduce((pa, pe) => pa + (pe.counts?.visits || 0), 0);
|
||||
}, 0) || 0;
|
||||
});
|
||||
|
||||
const totalEvents = computed(() => {
|
||||
return projectsAggregated.value?.reduce((a, e) => {
|
||||
return a + e.projects.reduce((pa, pe) => pa + (pe.counts?.events || 0), 0);
|
||||
}, 0) || 0;
|
||||
});
|
||||
|
||||
|
||||
|
||||
const details = ref<any>();
|
||||
const showDetails = ref<boolean>(false);
|
||||
async function getProjectDetails(project_id: string) {
|
||||
details.value = await $fetch(`/api/admin/details?project_id=${project_id}`, signHeaders());
|
||||
showDetails.value = true;
|
||||
}
|
||||
|
||||
async function resetCount(project_id: string) {
|
||||
await $fetch(`/api/admin/reset_count?project_id=${project_id}`, signHeaders());
|
||||
}
|
||||
|
||||
|
||||
function dateDiffDays(a: string) {
|
||||
return (Date.now() - new Date(a).getTime()) / (1000 * 60 * 60 * 24)
|
||||
}
|
||||
|
||||
function getLogBg(last_logged_at?: string) {
|
||||
|
||||
const day = 1000 * 60 * 60 * 24;
|
||||
const week = 1000 * 60 * 60 * 24 * 7;
|
||||
|
||||
const lastLoggedAtDate = new Date(last_logged_at || 0);
|
||||
|
||||
if (lastLoggedAtDate.getTime() > Date.now() - day) {
|
||||
return 'bg-green-500'
|
||||
} else if (lastLoggedAtDate.getTime() > Date.now() - week) {
|
||||
return 'bg-yellow-500'
|
||||
} else {
|
||||
return 'bg-red-500'
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="bg-bg overflow-y-auto w-full h-dvh p-6 gap-6 flex flex-col">
|
||||
|
||||
<div v-if="showDetails"
|
||||
class="w-full md:px-40 h-full fixed top-0 left-0 bg-black/90 backdrop-blur-[2px] z-[20] overflow-y-auto">
|
||||
<div class="cursor-pointer bg-red-400 w-fit px-10 py-2 rounded-lg font-semibold my-3"
|
||||
@click="showDetails = false">
|
||||
Close
|
||||
</div>
|
||||
<div class="whitespace-pre-wrap poppins">
|
||||
{{ JSON.stringify(details, null, 3) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div @click="onHideClicked()" v-if="!isAdminHidden"
|
||||
class="bg-menu hover:bg-menu/70 cursor-pointer flex gap-2 rounded-lg w-fit px-6 py-4 text-text-sub">
|
||||
<div class="text-text-sub/90"> <i class="far fa-eye"></i> </div>
|
||||
<div> Hide from the bar </div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<Card class="p-2 flex gap-10 items-center justify-center">
|
||||
<div :class="{ 'text-red-200': timeRange == 1 }" @click="setTimeRange(1)"> Last day </div>
|
||||
<div :class="{ 'text-red-200': timeRange == 2 }" @click="setTimeRange(2)"> Last week </div>
|
||||
<div :class="{ 'text-red-200': timeRange == 3 }" @click="setTimeRange(3)"> Last month </div>
|
||||
<div :class="{ 'text-red-200': timeRange == 9 }" @click="setTimeRange(9)"> All </div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-2 flex gap-10 items-center justify-center">
|
||||
<UCheckbox v-model="filterPremium" label="Filter Premium"></UCheckbox>
|
||||
<UCheckbox v-model="filterAppsumo" label="Filter Appsumo"></UCheckbox>
|
||||
</Card>
|
||||
|
||||
<Card class="p-4">
|
||||
|
||||
<div class="grid grid-cols-2 gap-1">
|
||||
<div>
|
||||
Users: {{ counts?.users }}
|
||||
</div>
|
||||
<div>
|
||||
Projects: {{ counts?.projects }} ( {{ premiumCount }} premium )
|
||||
</div>
|
||||
<div>
|
||||
Total visits: {{ formatNumberK(totalVisits) }}
|
||||
</div>
|
||||
<div>
|
||||
Active: {{ activeProjects }} |
|
||||
Dead: {{ (counts?.projects || 0) - activeProjects }}
|
||||
</div>
|
||||
<div>
|
||||
Total events: {{ formatNumberK(totalEvents) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</Card>
|
||||
|
||||
|
||||
<div v-for="item of projectsAggregated || []"
|
||||
class="bg-menu p-4 rounded-xl flex flex-col gap-2 w-full relative">
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div> {{ item.email }} </div>
|
||||
<div> {{ item.name }} </div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-evenly flex-col lg:grid lg:grid-cols-3 gap-2 lg:gap-4">
|
||||
|
||||
<div v-for="project of item.projects" :class="{
|
||||
'outline outline-[2px] outline-yellow-400': isAppsumoType(project.premium_type)
|
||||
}" class="flex relative flex-col items-center bg-bg p-6 rounded-xl">
|
||||
|
||||
<div class="absolute left-2 top-2 flex items-center gap-2">
|
||||
<div :class="getLogBg(project?.counts?.updated_at)" class="h-3 w-3 rounded-full"> </div>
|
||||
<div> {{ dateDiffDays(project?.counts?.updated_at || '0').toFixed(0) }} days </div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<div class="font-bold">
|
||||
{{ project.premium ? 'PREMIUM' : 'FREE' }}
|
||||
</div>
|
||||
<div class="text-text-sub/90">
|
||||
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="text-ellipsis line-clamp-1"> {{ project.name }} </div>
|
||||
<div class="flex gap-2">
|
||||
<div> Visits: </div>
|
||||
<div> {{ formatNumberK(project.counts?.visits || 0) }} </div>
|
||||
<div> Events: </div>
|
||||
<div> {{ formatNumberK(project.counts?.events || 0) }} </div>
|
||||
<div> Sessions: </div>
|
||||
<div> {{ formatNumberK(project.counts?.sessions || 0) }} </div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 items-center mt-4">
|
||||
<LyxUiButton type="secondary" @click="getProjectDetails(project._id)">
|
||||
Payment details
|
||||
</LyxUiButton>
|
||||
<LyxUiButton type="danger" @click="resetCount(project._id)">
|
||||
Refresh counts
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
264
dashboard/pages/ai.vue
Normal file
264
dashboard/pages/ai.vue
Normal file
@@ -0,0 +1,264 @@
|
||||
<script lang="ts" setup>
|
||||
import { DialogDangerGeneric } from '#components';
|
||||
import { SendHorizonal, MessageSquareText } from 'lucide-vue-next';
|
||||
import ChatsList from '~/components/complex/ai/ChatsList.vue';
|
||||
import AiChat from '~/components/complex/ai/AiChat.vue';
|
||||
import type { TAiNewChatSchema } from '~/shared/schema/ai/AiNewChatSchema';
|
||||
import EmptyAiChat from '~/components/complex/ai/EmptyAiChat.vue';
|
||||
|
||||
|
||||
export type ReadableChatMessage = {
|
||||
role: string,
|
||||
content: string,
|
||||
name?: string,
|
||||
tool_calls?: { id: string, function: { name: string, arguments: string } }[],
|
||||
created_at?: string,
|
||||
downvoted?: boolean
|
||||
}
|
||||
|
||||
export type ReadableChat = {
|
||||
title: string,
|
||||
project_id: string,
|
||||
status: string,
|
||||
created_at: string,
|
||||
messages: ReadableChatMessage[],
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
definePageMeta({ layout: 'sidebar' });
|
||||
|
||||
const { data: chats, refresh: refreshChats } = useAuthFetch<TAiNewChatSchema[]>('/api/ai/list');
|
||||
const { permissions } = useProjectStore();
|
||||
const premium = usePremiumStore();
|
||||
|
||||
const currentChatId = ref<string>('');
|
||||
const currentChat = ref<ReadableChat>();
|
||||
|
||||
const message = ref<string>('');
|
||||
|
||||
|
||||
const sheetOpen = ref<boolean>(false);
|
||||
|
||||
async function getCurrentChatData() {
|
||||
if (currentChatId.value === 'null' || currentChatId.value.length == 0) {
|
||||
currentChat.value = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await useAuthFetchSync<TAiNewChatSchema>(`/api/ai/chat?id=${currentChatId.value}`)
|
||||
const data = createReadableChat(res);
|
||||
currentChat.value = data;
|
||||
}
|
||||
|
||||
function selectChat(chat_id: string) {
|
||||
currentChatId.value = chat_id;
|
||||
sheetOpen.value = false;
|
||||
getCurrentChatData();
|
||||
pollChat();
|
||||
}
|
||||
|
||||
|
||||
const canAskAi = computed(() => {
|
||||
if (message.value.trim().length == 0) return false;
|
||||
if (!currentChatId.value) return true;
|
||||
if (!currentChat.value?.status) return false;
|
||||
if (!isFinished(currentChat.value.status)) return false;
|
||||
return true;
|
||||
})
|
||||
|
||||
let pollingInterval: any | undefined;
|
||||
|
||||
function isFinished(status: string) {
|
||||
return status.startsWith('COMPLETED') || status.startsWith('ERRORED');
|
||||
}
|
||||
|
||||
function pollChat() {
|
||||
if (currentChat.value && !isFinished(currentChat.value.status)) {
|
||||
pollingInterval = setInterval(async () => {
|
||||
await getCurrentChatData();
|
||||
if (currentChat.value && isFinished(currentChat.value.status)) {
|
||||
if (pollingInterval) {
|
||||
clearInterval(pollingInterval);
|
||||
pollingInterval = undefined;
|
||||
}
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
function createReadableChat(schema: TAiNewChatSchema) {
|
||||
|
||||
const result: ReadableChat = {
|
||||
title: schema.title,
|
||||
project_id: schema.project_id.toString(),
|
||||
status: schema.status,
|
||||
created_at: schema.created_at.toString(),
|
||||
updated_at: schema.updated_at.toString(),
|
||||
messages: []
|
||||
};
|
||||
|
||||
for (let i = 0; i < schema.messages.length; i++) {
|
||||
const message = schema.messages[i];
|
||||
|
||||
const resultMessage: ReadableChatMessage = {
|
||||
content: message.content,
|
||||
role: message.role,
|
||||
name: message.name,
|
||||
tool_calls: message.tool_calls,
|
||||
created_at: message.created_at,
|
||||
downvoted: message.downvoted ?? false
|
||||
};
|
||||
|
||||
result.messages.push(resultMessage);
|
||||
}
|
||||
|
||||
|
||||
|
||||
return result;
|
||||
|
||||
}
|
||||
|
||||
async function downvoteMessage(message_index: number) {
|
||||
await useAuthFetchSync(`/api/ai/downvote_message?chat_id=${currentChatId.value}&message_index=${message_index}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
async function askAi() {
|
||||
|
||||
const currentMessage = message.value;
|
||||
|
||||
if (currentMessage.trim().length == 0) return;
|
||||
|
||||
message.value = '';
|
||||
|
||||
const { chat_id } = await useAuthFetchSync(`/api/ai/ask?chat_id=${currentChatId.value}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: { message: currentMessage }
|
||||
});
|
||||
|
||||
currentChatId.value = chat_id;
|
||||
|
||||
await getCurrentChatData();
|
||||
await refreshChats();
|
||||
|
||||
setTimeout(async () => {
|
||||
await getCurrentChatData();
|
||||
pollChat();
|
||||
}, 1000)
|
||||
|
||||
|
||||
}
|
||||
|
||||
function onKeyPress(e: any) {
|
||||
if (e.key === 'Enter') askAi();
|
||||
}
|
||||
|
||||
const { open } = useDialog();
|
||||
|
||||
async function deleteChat(chat_id: string) {
|
||||
await open({
|
||||
body: DialogDangerGeneric,
|
||||
title: 'Deleting chat',
|
||||
props: {
|
||||
label: 'Are you sure to delete the chat?'
|
||||
},
|
||||
async onSuccess(data, close) {
|
||||
await useCatch({
|
||||
toast: true,
|
||||
toastTitle: 'Error deleting chat',
|
||||
async action() {
|
||||
return await useAuthFetchSync(`/api/ai/delete_chat?chat_id=${chat_id}`)
|
||||
},
|
||||
onSuccess(data, showToast) {
|
||||
showToast('Chat deleted', {});
|
||||
refreshChats();
|
||||
selectChat('null');
|
||||
close();
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
async function deleteAllChats() {
|
||||
await open({
|
||||
body: DialogDangerGeneric,
|
||||
title: 'Deleting all chat',
|
||||
props: {
|
||||
label: 'Are you sure to delete all the chats?'
|
||||
},
|
||||
async onSuccess(data, close) {
|
||||
await useCatch({
|
||||
toast: true,
|
||||
toastTitle: 'Error deleting chats',
|
||||
async action() {
|
||||
return await useAuthFetchSync(`/api/ai/delete_all_chats`)
|
||||
},
|
||||
onSuccess(data, showToast) {
|
||||
showToast('Chats deleted', {});
|
||||
refreshChats();
|
||||
selectChat('null');
|
||||
close();
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleSend(p: string) {
|
||||
message.value = p;
|
||||
askAi()
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<Unauthorized v-if="permissions?.ai === false || [0].includes(premium.planInfo?.ID ?? -1)"
|
||||
authorization="PLAN or AUTH">
|
||||
</Unauthorized>
|
||||
|
||||
<div v-else class="h-full flex flex-col gap-2 poppins">
|
||||
|
||||
<AiChat class="grow" :messages="currentChat?.messages" :status="currentChat?.status"
|
||||
@downvoted="downvoteMessage($event)">
|
||||
</AiChat>
|
||||
|
||||
<EmptyAiChat v-if="!currentChat?.status" @sendprompt="handleSend" @open-sheet="sheetOpen = true" />
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-4 shrink-0" :class="currentChat?.status ? '' : 'flex justify-end'">
|
||||
<Button v-if="currentChat?.status" :disabled="!canAskAi"
|
||||
class="absolute bottom-6 right-22 size-8 rounded" @click="askAi()">
|
||||
<SendHorizonal class="size-4" />
|
||||
</Button>
|
||||
<Input v-if="currentChat?.status" @keypress="onKeyPress" v-model="message" placeholder="Message"
|
||||
class="h-12 text-lg pr-12 bg-white dark:bg-black" />
|
||||
<Sheet v-model:open="sheetOpen">
|
||||
<SheetTrigger as-child>
|
||||
<Button v-if="currentChat?.status" class="size-12" variant="outline">
|
||||
<MessageSquareText />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent class="overflow-hidden">
|
||||
<SheetHeader>
|
||||
<SheetTitle> Chats </SheetTitle>
|
||||
<SheetDescription>
|
||||
Assistant chats history
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div class="p-4 h-full" v-if="chats">
|
||||
<ChatsList @delete-all-chats="deleteAllChats()" @deleteChat="deleteChat($event)"
|
||||
@selectChat="selectChat" :chats="chats"></ChatsList>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
@@ -1,540 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import VueMarkdown from 'vue-markdown-render';
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
const selfhosted = useSelfhosted();
|
||||
|
||||
const { permission, canSeeAi } = usePermission();
|
||||
|
||||
const debugModeAi = ref<boolean>(false);
|
||||
|
||||
const { userRoles } = useLoggedUser();
|
||||
|
||||
const { project } = useProject();
|
||||
|
||||
const { data: chatsList, refresh: reloadChatsList } = useFetch(`/api/ai/chats_list`, {
|
||||
headers: useComputedHeaders({ useSnapshotDates: false })
|
||||
});
|
||||
|
||||
const viewChatsList = computed(() => (chatsList.value || []).toReversed());
|
||||
|
||||
const { data: chatsRemaining, refresh: reloadChatsRemaining } = useFetch(`/api/ai/chats_remaining`, {
|
||||
headers: useComputedHeaders({ useSnapshotDates: false })
|
||||
});
|
||||
|
||||
const currentText = ref<string>("");
|
||||
const loading = ref<boolean>(false);
|
||||
const canSend = ref<boolean>(true);
|
||||
|
||||
const currentChatId = ref<string>("");
|
||||
const currentChatMessages = ref<{ role: string, content: string, charts?: any[], tool_calls?: any }[]>([]);
|
||||
const currentChatMessageDelta = ref<string>("");
|
||||
|
||||
|
||||
const typer = useTextType({ ms: 10, increase: 2 }, () => {
|
||||
const cleanMessage = currentChatMessageDelta.value.replace(/\[(data:(.*?))\]/g, '');
|
||||
if (typer.index.value >= cleanMessage.length) typer.pause();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
typer.stop();
|
||||
})
|
||||
|
||||
const currentChatMessageDeltaTextVisible = computed(() => {
|
||||
const cleanMessage = currentChatMessageDelta.value.replace(/\[(data:(.*?))\]/g, '');
|
||||
const textVisible = cleanMessage.substring(0, typer.index.value);
|
||||
setTimeout(() => scrollToBottom(), 1);
|
||||
return textVisible;
|
||||
});
|
||||
|
||||
const currentChatMessageDeltaShowLoader = computed(() => {
|
||||
const lastData = currentChatMessageDelta.value.match(/\[(data:(.*?))\]$/);
|
||||
return lastData != null;
|
||||
});
|
||||
|
||||
const scroller = ref<HTMLDivElement | null>(null);
|
||||
|
||||
|
||||
async function pollSendMessageStatus(chat_id: string, times: number, updateStatus: (status: string) => any) {
|
||||
|
||||
if (times > 100) return;
|
||||
|
||||
const res = await $fetch(`/api/ai/${chat_id}/status`, {
|
||||
headers: useComputedHeaders({
|
||||
useSnapshotDates: false,
|
||||
}).value
|
||||
});
|
||||
if (!res) throw Error('Error during status request');
|
||||
|
||||
updateStatus(res.status);
|
||||
|
||||
|
||||
typer.resume();
|
||||
|
||||
|
||||
if (res.completed === false) {
|
||||
setTimeout(() => pollSendMessageStatus(chat_id, times + 1, updateStatus), (times > 10 ? 2000 : 1000));
|
||||
} else {
|
||||
|
||||
canSend.value = true;
|
||||
|
||||
typer.stop();
|
||||
|
||||
const messages = await $fetch(`/api/ai/${chat_id}/get_messages`, {
|
||||
headers: useComputedHeaders({ useSnapshotDates: false }).value
|
||||
});
|
||||
if (!messages) return;
|
||||
|
||||
currentChatMessages.value = messages.map(e => ({ ...e, charts: e.charts.map(k => JSON.parse(k)) })) as any;
|
||||
currentChatMessageDelta.value = '';
|
||||
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
|
||||
if (canSend.value === false) return;
|
||||
|
||||
if (loading.value) return;
|
||||
if (!project.value) return;
|
||||
|
||||
if (currentText.value.length == 0) return;
|
||||
|
||||
loading.value = true;
|
||||
|
||||
const body: any = { text: currentText.value, timeOffset: new Date().getTimezoneOffset() }
|
||||
if (currentChatId.value) body.chat_id = currentChatId.value
|
||||
|
||||
currentChatMessages.value.push({ role: 'user', content: currentText.value });
|
||||
|
||||
setTimeout(() => scrollToBottom(), 1);
|
||||
currentText.value = '';
|
||||
|
||||
|
||||
try {
|
||||
|
||||
canSend.value = false;
|
||||
|
||||
const res = await $fetch<{ chat_id: string }>(`/api/ai/send_message`, { method: 'POST', body: JSON.stringify(body), headers: useComputedHeaders({ useSnapshotDates: false, custom: { 'Content-Type': 'application/json' } }).value });
|
||||
currentChatId.value = res.chat_id;
|
||||
|
||||
await reloadChatsRemaining();
|
||||
await reloadChatsList();
|
||||
|
||||
await new Promise(e => setTimeout(e, 200));
|
||||
|
||||
|
||||
typer.start();
|
||||
|
||||
await pollSendMessageStatus(res.chat_id, 0, status => {
|
||||
if (!status) return;
|
||||
if (status.length > 0) loading.value = false;
|
||||
currentChatMessageDelta.value = status;
|
||||
});
|
||||
|
||||
|
||||
|
||||
} catch (ex: any) {
|
||||
|
||||
if (ex.message.includes('CHAT_LIMIT_REACHED')) {
|
||||
currentChatMessages.value.push({
|
||||
role: 'assistant',
|
||||
content: `Chat limit reached.
|
||||
Upgrade your plan to continue chatting.`
|
||||
});
|
||||
} else if (ex.message.includes('Unauthorized')) {
|
||||
currentChatMessages.value.push({
|
||||
role: 'assistant',
|
||||
content: 'To use AI you need to provide AI_ORG, AI_PROJECT and AI_KEY in docker compose',
|
||||
});
|
||||
} else {
|
||||
currentChatMessages.value.push({ role: 'assistant', content: ex.message, });
|
||||
}
|
||||
loading.value = false;
|
||||
|
||||
canSend.value = true;
|
||||
|
||||
}
|
||||
|
||||
|
||||
setTimeout(() => scrollToBottom(), 1);
|
||||
|
||||
}
|
||||
|
||||
async function openChat(chat_id?: string) {
|
||||
menuOpen.value = false;
|
||||
if (!project.value) return;
|
||||
|
||||
typer.stop();
|
||||
|
||||
canSend.value = true;
|
||||
currentChatMessages.value = [];
|
||||
currentChatMessageDelta.value = '';
|
||||
|
||||
if (!chat_id) {
|
||||
currentChatId.value = '';
|
||||
return;
|
||||
}
|
||||
currentChatId.value = chat_id;
|
||||
const messages = await $fetch(`/api/ai/${chat_id}/get_messages`, {
|
||||
headers: useComputedHeaders({ useSnapshotDates: false }).value
|
||||
});
|
||||
if (!messages) return;
|
||||
|
||||
currentChatMessages.value = messages.map(e => ({ ...e, charts: e.charts.map(k => JSON.parse(k)) })) as any;
|
||||
setTimeout(() => scrollToBottom(), 1);
|
||||
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (!scroller.value) return;
|
||||
scroller.value.scrollTo({ behavior: 'smooth', top: 999999 })
|
||||
}
|
||||
|
||||
|
||||
function parseMessageContent(content: string) {
|
||||
return content.replace(/\*\*(.*?)\*\*/g, '<b class="text-text">$1</b>');
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.code !== 'Enter') return;
|
||||
if (e.shiftKey === true) return;
|
||||
sendMessage();
|
||||
}
|
||||
|
||||
const menuOpen = ref<boolean>(false);
|
||||
|
||||
const defaultPrompts = [
|
||||
"What can you do and how can you help me ?",
|
||||
"Show me an example line chart with random data",
|
||||
"How many visits did I get last week?",
|
||||
"Create a line chart of last week's visits"
|
||||
]
|
||||
|
||||
async function deleteChat(chat_id: string) {
|
||||
if (!project.value) return;
|
||||
const sure = confirm("Are you sure to delete the chat ?");
|
||||
if (!sure) return;
|
||||
if (currentChatId.value === chat_id) {
|
||||
currentChatId.value = "";
|
||||
currentChatMessages.value = [];
|
||||
currentChatMessageDelta.value = '';
|
||||
}
|
||||
await $fetch(`/api/ai/${chat_id}/delete`, {
|
||||
headers: useComputedHeaders({ useSnapshotDates: false }).value
|
||||
});
|
||||
await reloadChatsList();
|
||||
}
|
||||
|
||||
const { showDrawer } = useDrawer();
|
||||
|
||||
|
||||
async function clearAllChats() {
|
||||
const sure = confirm(`Are you sure to delete all ${(chatsList.value?.length || 0)} chats ?`);
|
||||
if (!sure) return;
|
||||
await $fetch(`/api/ai/delete_all_chats`, {
|
||||
headers: useComputedHeaders({ useSnapshotDates: false }).value
|
||||
});
|
||||
await reloadChatsList();
|
||||
|
||||
menuOpen.value = false;
|
||||
typer.stop();
|
||||
canSend.value = true;
|
||||
currentChatMessages.value = [];
|
||||
currentChatMessageDelta.value = '';
|
||||
currentChatId.value = '';
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div v-if="!canSeeAi" class="h-full w-full flex mt-[20vh] justify-center">
|
||||
<div> You need AI permission to view this page </div>
|
||||
</div>
|
||||
|
||||
<div v-if="canSeeAi" class="w-full h-full overflow-y-hidden">
|
||||
|
||||
<div class="flex flex-row h-full overflow-y-hidden">
|
||||
|
||||
<div class="flex-[5] py-8 flex h-full flex-col items-center relative overflow-y-hidden">
|
||||
|
||||
|
||||
<div class="flex flex-col items-center xl:mt-[20vh] px-8 xl:px-28"
|
||||
v-if="currentChatMessages.length == 0">
|
||||
<div class="w-[7rem] xl:w-[10rem]">
|
||||
<img :src="'analyst.png'" class="w-full h-full">
|
||||
</div>
|
||||
<div class="poppins text-[1.2rem] text-center">
|
||||
Ask me anything about your data
|
||||
</div>
|
||||
<div class="flex flex-col xl:grid xl:grid-cols-2 gap-4 mt-6">
|
||||
<div v-for="prompt of defaultPrompts" @click="currentText = prompt"
|
||||
class="
|
||||
bg-lyx-lightmode-widget-light dark:bg-lyx-widget-light hover:bg-lyx-lightmode-widget dark:hover:bg-lyx-widget outline-[1px] outline outline-lyx-lightmode-widget dark:outline-none
|
||||
cursor-pointer p-4 rounded-lg poppins text-center whitespace-pre-wrap flex items-center justify-center text-[.9rem]">
|
||||
{{ prompt }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div ref="scroller" class="flex flex-col w-full gap-6 px-6 xl:px-28 overflow-y-auto pb-20">
|
||||
|
||||
<div class="flex w-full flex-col" v-for="(message, messageIndex) of currentChatMessages">
|
||||
|
||||
<div v-if="message.role === 'user'" class="flex justify-end w-full poppins text-[1.1rem]">
|
||||
<div class="bg-lyx-lightmode-widget dark:bg-lyx-widget-light px-5 py-3 rounded-lg">
|
||||
{{ message.content }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="message.role === 'assistant' && (debugModeAi ? true : message.content)"
|
||||
class="flex items-center gap-3 justify-start w-full poppins text-[1.1rem]">
|
||||
<div class="flex items-center justify-center shrink-0">
|
||||
<img class="h-[3.5rem] w-auto" :src="'analyst.png'">
|
||||
</div>
|
||||
<div class="max-w-[70%] text-lyx-lightmode-text dark:text-text/90 ai-message">
|
||||
|
||||
<vue-markdown v-if="message.content" :source="message.content" :options="{
|
||||
html: true,
|
||||
breaks: true,
|
||||
}" />
|
||||
|
||||
|
||||
<div v-if="debugModeAi && !message.content">
|
||||
<div class="flex flex-col"
|
||||
v-if="message.tool_calls && message.tool_calls.length > 0">
|
||||
<div> {{ message.tool_calls[0].function.name }}</div>
|
||||
<div> {{ message.tool_calls[0].function.arguments }} </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="debugModeAi && !message.content"
|
||||
class="text-[.8rem] flex gap-1 items-center w-fit hover:text-[#CCCCCC] cursor-pointer">
|
||||
<i class="fas fa-info text-[.7rem]"></i>
|
||||
<div class="mt-1">Debug</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div v-if="message.charts && message.charts.length > 0"
|
||||
class="flex items-center gap-3 justify-start w-full poppins text-[1.1rem] flex-col mt-4">
|
||||
<div v-for="chart of message.charts" class="w-full">
|
||||
<AnalystComposableChart :datasets="chart.datasets" :labels="chart.labels"
|
||||
:title="chart.title">
|
||||
</AnalystComposableChart>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="flex items-center gap-3 justify-start w-full poppins text-[1.1rem]"
|
||||
v-if="currentChatMessageDelta">
|
||||
|
||||
<div class="flex items-center justify-center shrink-0">
|
||||
<img class="h-[3.5rem] w-auto" :src="'analyst.png'">
|
||||
</div>
|
||||
|
||||
<div class="max-w-[70%] text-text/90 ai-message">
|
||||
<div v-if="currentChatMessageDeltaShowLoader" class="flex items-center gap-1">
|
||||
<i class="fas fa-loader animate-spin"></i>
|
||||
<div> Loading </div>
|
||||
</div>
|
||||
<vue-markdown :source="currentChatMessageDeltaTextVisible" :options="{
|
||||
html: true,
|
||||
breaks: true,
|
||||
}" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div v-if="loading"
|
||||
class="flex items-center mt-10 gap-3 justify-center w-full poppins text-[1.1rem]">
|
||||
<div class="flex items-center justify-center">
|
||||
<img class="animate-bounce h-[3.5rem] w-auto" :src="'analyst.png'">
|
||||
</div>
|
||||
<div class="poppins "> Loading </div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center md:absolute fixed bottom-8 left-0 w-full px-10 xl:px-28">
|
||||
<input @keydown="onKeyDown" v-model="currentText"
|
||||
class="bg-lyx-lightmode-widget-light dark:bg-lyx-widget-light w-full dark:focus:outline-none px-4 py-2 rounded-lg outline-[1px] outline outline-lyx-lightmode-widget dark:outline-none"
|
||||
type="text">
|
||||
<div @click="sendMessage()"
|
||||
class="bg-lyx-lightmode-widget-light hover:bg-lyx-lightmode-widget dark:bg-lyx-widget-light dark:hover:bg-lyx-widget-light cursor-pointer px-4 py-2 rounded-full">
|
||||
<i class="far fa-arrow-up"></i>
|
||||
</div>
|
||||
<div @click="menuOpen = !menuOpen"
|
||||
class="bg-lyx-widget-light xl:hidden hhover:bg-lyx-widget-light cursor-pointer px-4 py-2 rounded-full">
|
||||
<i class="far fa-message"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div :class="{
|
||||
'absolute top-0 left-0 w-full': menuOpen,
|
||||
'hidden xl:flex': !menuOpen
|
||||
}"
|
||||
class="flex-[2] bg-lyx-lightmode-background border-l-[1px] dark:border-l-0 dark:bg-lyx-background-light p-6 flex flex-col gap-4 h-full overflow-hidden">
|
||||
|
||||
<div class="gap-2 flex flex-col">
|
||||
<div class="xl:hidden absolute right-6 top-2 text-[1.5rem]">
|
||||
<i @click="menuOpen = false" class="fas fa-close cursor-pointer"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="{ '!text-green-500': debugModeAi }" class="cursor-pointer text-red-500 w-fit"
|
||||
v-if="userRoles.isAdmin.value" @click="debugModeAi = !debugModeAi"> Debug mode </div>
|
||||
|
||||
<div class="flex pt-3 px-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- <div class="bg-accent w-4 h-4 rounded-full animate-pulse">
|
||||
</div> -->
|
||||
<div class="manrope font-semibold text-lyx-lightmode-text dark:text-text-dirty">
|
||||
{{ chatsRemaining }} messages left
|
||||
</div>
|
||||
</div>
|
||||
<div class="grow"></div>
|
||||
<LyxUiButton v-if="!selfhosted" type="primary" class="text-[.9rem] text-center "
|
||||
@click="showDrawer('PRICING')">
|
||||
Upgrade
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
<div class="dark:bg-lyx-widget-light bg-lyx-lightmode-widget-light h-[1px]"></div>
|
||||
|
||||
<div class="flex items-center gap-4 px-4 mt-4">
|
||||
<div class="poppins font-semibold text-[1.1rem]"> History </div>
|
||||
<div class="grow"></div>
|
||||
<LyxUiButton v-if="chatsList && chatsList.length > 0" @click="clearAllChats()" type="secondary"
|
||||
class="text-center text-[.8rem]">
|
||||
Clear all chats
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
<div class="px-2">
|
||||
<div @click="openChat()"
|
||||
class="bg-lyx-lightmode-widget-light hover:bg-lyx-lightmode-widget dark:bg-lyx-widget-light cursor-pointer dark:hover:bg-lyx-widget rounded-lg px-4 py-3 poppins flex gap-4 items-center outline-[1px] outline outline-lyx-lightmode-widget dark:outline-none">
|
||||
<div> <i class="fas fa-plus"></i> </div>
|
||||
<div> New chat </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="overflow-y-auto">
|
||||
<div class="flex flex-col gap-2 px-2">
|
||||
<div :class="{ '!bg-accent/60': chat._id.toString() === currentChatId }"
|
||||
class="flex text-lyx-lightmode-text-dark dark:text-lyx-text-dark text-[.9rem] font-light rounded-lg items-center gap-4 w-full px-4 bg-lyx-lightmode-widget-light dark:bg-lyx-widget-light hover:bg-lyx-lightmode-widget dark:hover:bg-lyx-widget outline-[1px] outline outline-lyx-lightmode-widget dark:outline-none"
|
||||
v-for="chat of viewChatsList">
|
||||
<i @click="deleteChat(chat._id.toString())"
|
||||
class="far fa-trash hover:text-gray-300 cursor-pointer"></i>
|
||||
<div @click="openChat(chat._id.toString())"
|
||||
class="py-3 w-full cursor-pointer poppins rounded-lg">
|
||||
{{ chat.title }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.ai-message {
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-weight: bold;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
p:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.8;
|
||||
margin-bottom: 1em;
|
||||
max-width: 750px;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 1.5em 10px;
|
||||
padding: 10px 20px;
|
||||
color: #555;
|
||||
border-left: 5px solid #ccc;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: #f4f4f4;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: #f1f1f1;
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin-left: 30px;
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #007acc;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 1px solid #ddd;
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
72
dashboard/pages/billing.vue
Normal file
72
dashboard/pages/billing.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
definePageMeta({ layout: 'sidebar' });
|
||||
const projectStore = useProjectStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Unauthorized v-if="!projectStore.isOwner" authorization="Guest user limitation">
|
||||
</Unauthorized>
|
||||
<div v-else class="p-4">
|
||||
|
||||
<Label class="text-2xl"> Billing </Label>
|
||||
|
||||
<div class="flex mt-12">
|
||||
<div class="flex-[2]">
|
||||
<div class="font-semibold text-lg"> Current plan </div>
|
||||
<div class="text-muted-foreground"> Manage current plan for this project </div>
|
||||
</div>
|
||||
|
||||
<div class="flex-[3]">
|
||||
<BillingPlanView></BillingPlanView>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator class="my-8"> </Separator>
|
||||
|
||||
<div class="flex mt-12">
|
||||
<div class="flex-[2]">
|
||||
<div class="font-semibold text-lg"> Usage </div>
|
||||
<div class="text-muted-foreground"> Show usage of current project </div>
|
||||
</div>
|
||||
|
||||
<div class="flex-[3]">
|
||||
<BillingUsageView></BillingUsageView>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator class="my-8"> </Separator>
|
||||
|
||||
|
||||
<div v-if="!isSelfhosted()" class="flex mt-12">
|
||||
<div class="flex-[2]">
|
||||
<div class="font-semibold text-lg"> Billing address </div>
|
||||
<div class="text-muted-foreground">
|
||||
This will be reflected in every upcoming invoice,
|
||||
<br>
|
||||
past invoices are not affected
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-[3]">
|
||||
<BillingAddress></BillingAddress>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator v-if="!isSelfhosted()" class="my-8"> </Separator>
|
||||
|
||||
<div v-if="!isSelfhosted()" class="flex mt-12">
|
||||
<div class="flex-[2]">
|
||||
<div class="font-semibold text-lg"> Invoices </div>
|
||||
<div class="text-muted-foreground">
|
||||
Visualize invoices of current project
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-[3]">
|
||||
<BillingInvoicesView></BillingInvoicesView>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,29 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
const iframecal = ref<any | undefined>(null);
|
||||
|
||||
const loaded = ref<boolean>(false);
|
||||
|
||||
onMounted(() => {
|
||||
loaded.value = false;
|
||||
if (!iframecal.value) return;
|
||||
iframecal.value.onload = () => {
|
||||
loaded.value = true;
|
||||
}
|
||||
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="w-full h-full bg-[#101010]">
|
||||
<iframe v-show="loaded" ref="iframecal" class="w-full h-full" src="https://cal.com/litlyx/30min">
|
||||
</iframe>
|
||||
<div class="w-full h-full flex gap-2 justify-center items-center poppins font-bold text-[1.6rem]" v-if="!loaded">
|
||||
<div class="animate-pulse"> Loading... </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
153
dashboard/pages/checkout.vue
Normal file
153
dashboard/pages/checkout.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import { type PLAN_DATA } from '@data/PLANS';
|
||||
|
||||
|
||||
definePageMeta({ layout: 'sidebar' });
|
||||
|
||||
const { getPlanUsingPrice } = usePremiumStore();
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const preview = ref<any>();
|
||||
|
||||
const new_plan_data = ref<PLAN_DATA>();
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await useAuthFetchSync(`/api/payments/preview_upgrade?plan_tag=${route.query.plan_tag}`);
|
||||
preview.value = res;
|
||||
new_plan_data.value = getPlanUsingPrice(res.lines.data[1].pricing.price_details.price);
|
||||
});
|
||||
|
||||
const upgrading = ref<boolean>(false);
|
||||
|
||||
async function confirmUpgrade() {
|
||||
upgrading.value = true;
|
||||
await useCatch({
|
||||
toast: true,
|
||||
toastTitle: 'Error during upgrade',
|
||||
async action() {
|
||||
return await useAuthFetchSync(`/api/payments/upgrade?plan_tag=${route.query.plan_tag}`);
|
||||
},
|
||||
onSuccess(data, showToast) {
|
||||
showToast('Payment confirmed', { description: 'Payment confirmed' })
|
||||
setTimeout(() => {
|
||||
location.href = '/billing';
|
||||
}, 1000)
|
||||
},
|
||||
onGenericError(ex: any) {
|
||||
upgrading.value = false;
|
||||
},
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-center">
|
||||
|
||||
<Loader class="mt-[20vh]" v-if="!preview || !new_plan_data"></Loader>
|
||||
|
||||
<div class="max-w-[50rem] w-full flex flex-col gap-4 mt-10" v-else>
|
||||
<Card class="poppins">
|
||||
<CardContent>
|
||||
<div>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="font-bold text-xl">
|
||||
Upgrade to {{ new_plan_data.NAME }}
|
||||
</div>
|
||||
<div>
|
||||
<NuxtLink to="/plans">
|
||||
<Button variant="secondary" size="sm"> Cancel </Button>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<Separator class="my-4"></Separator>
|
||||
<div class="flex justify-between">
|
||||
<div>
|
||||
<div class="text-secondary-foreground font-medium">
|
||||
{{ new_plan_data.NAME }} Plan
|
||||
</div>
|
||||
<div class="text-muted-foreground">
|
||||
€{{ new_plan_data.COST / 100 }} per
|
||||
{{ new_plan_data.TAG.endsWith('ANNUAL') ? 'year' : 'month' }}
|
||||
billed
|
||||
{{ new_plan_data.TAG.endsWith('ANNUAL') ? 'yearly' : 'monthly' }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
€{{ new_plan_data.COST / 100 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="poppins">
|
||||
<CardContent>
|
||||
<div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-secondary-foreground">
|
||||
Summary
|
||||
</div>
|
||||
<div class="text-muted-foreground">
|
||||
Your plan will renew on {{ new Date(preview.period_end * 1000).toLocaleDateString() }}
|
||||
</div>
|
||||
</div>
|
||||
<Separator class="my-4"></Separator>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-secondary-foreground">
|
||||
Proration
|
||||
</div>
|
||||
<div class="text-muted-foreground w-[70%]">
|
||||
We deduct what you already paid for whithin the current plan, plus the unused period
|
||||
between the start of this billing cycle and now.
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
-€{{ -preview.lines.data[0].amount / 100 }}
|
||||
</div>
|
||||
</div>
|
||||
<Separator class="my-4"></Separator>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-secondary-foreground">
|
||||
Subtotal
|
||||
</div>
|
||||
<div class="text-secondary-foreground">
|
||||
VAT
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 text-right">
|
||||
<div class="text-secondary-foreground">
|
||||
€{{ (preview.total / 100) }}
|
||||
</div>
|
||||
<div class="text-secondary-foreground">
|
||||
€0.00
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator class="my-4"></Separator>
|
||||
<div class="flex justify-between">
|
||||
<div class="text-secondary-foreground">
|
||||
Pay now
|
||||
</div>
|
||||
<div class="text-secondary-foreground">
|
||||
€{{ (preview.total / 100) }}
|
||||
</div>
|
||||
</div>
|
||||
<Button :disabled="upgrading" @click="confirmUpgrade()"
|
||||
class="w-full mt-10 !text-white !bg-[#7537F3]">
|
||||
<span v-if="upgrading">
|
||||
<Loader class="!size-4"></Loader>
|
||||
</span>
|
||||
<span v-else>Confirm & Pay</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
76
dashboard/pages/create_project.vue
Normal file
76
dashboard/pages/create_project.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { GalleryVerticalEnd, TriangleAlert, Layers2 } from 'lucide-vue-next';
|
||||
import { toast } from 'vue-sonner';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
if (route.query.first !== 'true') {
|
||||
setPageLayout('sidebar');
|
||||
}
|
||||
|
||||
const projectStore = useProjectStore();
|
||||
|
||||
const projectName = ref<string>();
|
||||
|
||||
const canCreate = computed(() => {
|
||||
if (creating.value) return false;
|
||||
return projectName.value && projectName.value.length > 2 && projectName.value.length < 24;
|
||||
});
|
||||
|
||||
const creating = ref<boolean>(false);
|
||||
|
||||
const { loadData } = useAppStart();
|
||||
|
||||
async function createProject() {
|
||||
|
||||
if (creating.value) return;
|
||||
creating.value = true;
|
||||
|
||||
await useCatch({
|
||||
toast: true,
|
||||
toastTitle: 'Error creating project',
|
||||
async action() {
|
||||
return await useAuthFetchSync('/api/project/create', {
|
||||
method: 'POST',
|
||||
body: { name: projectName.value }
|
||||
});
|
||||
},
|
||||
async onSuccess(data, showToast) {
|
||||
if (route.query.first === 'true') await loadData();
|
||||
await projectStore.fetchProjects();
|
||||
const target = projectStore.projects.at(-1)?._id.toString();
|
||||
if (target) await projectStore.setActive(target);
|
||||
router.push('/');
|
||||
showToast("Success", { description: 'Workspace successfully created' })
|
||||
},
|
||||
});
|
||||
|
||||
creating.value = false;
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-6">
|
||||
|
||||
|
||||
<div class="flex flex-col items-center gap-2 mt-[20vh]">
|
||||
<div class="flex flex-col items-center gap-2 font-medium">
|
||||
<div class="flex size-8 items-center justify-center rounded-md">
|
||||
<Layers2 class="size-8" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-xl font-bold">
|
||||
{{ route.query.first === 'true' ? 'Create your first Workspace':'Create new Workspace' }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="max-w-[80vw] w-[20rem] flex flex-col items-center gap-4">
|
||||
<Input v-model="projectName" placeholder="Workspace name"></Input>
|
||||
<Button @click="createProject()" :disabled="!canCreate" class="w-full"> Create </Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,169 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { MetricsCounts } from '~/server/api/metrics/[project_id]/counts';
|
||||
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
const { project } = useProject();
|
||||
|
||||
const isPremium = computed(() => (project.value?.premium_type || 0) > 0);
|
||||
const selfhosted = useSelfhosted();
|
||||
const canDownload = computed(() => {
|
||||
if (selfhosted) return true;
|
||||
return isPremium.value;
|
||||
});
|
||||
|
||||
const metricsInfo = ref<number>(0);
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Name', sortable: true },
|
||||
{ key: 'metadata', label: 'Metadata' },
|
||||
{ key: 'created_at', label: 'Creation', sortable: true }
|
||||
]
|
||||
|
||||
const sort = ref<any>({
|
||||
column: 'created_at',
|
||||
direction: 'desc'
|
||||
})
|
||||
|
||||
const selectedColumns = ref([...columns]);
|
||||
|
||||
const page = ref<number>(1);
|
||||
const itemsPerPage = 50;
|
||||
const totalItems = computed(() => metricsInfo.value);
|
||||
|
||||
|
||||
const { data: tableData, pending: loadingData } = await useFetch<any[]>(() =>
|
||||
`/api/metrics/${project.value?._id}/query?type=1&orderBy=${sort.value.column}&order=${sort.value.direction}&page=${page.value}&limit=${itemsPerPage}`, {
|
||||
...signHeaders(), lazy: true
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const counts = await $fetch<MetricsCounts>(`/api/metrics/${project.value?._id}/counts`, signHeaders());
|
||||
metricsInfo.value = counts.eventsCount;
|
||||
});
|
||||
|
||||
const creatingCsv = ref<boolean>(false);
|
||||
|
||||
async function downloadCSV() {
|
||||
creatingCsv.value = true;
|
||||
const result = await $fetch(`/api/project/generate_csv?mode=events&slice=${options.indexOf(selectedTimeFrom.value)}`, {
|
||||
headers: useComputedHeaders({ useSnapshotDates: false }).value
|
||||
});
|
||||
const blob = new Blob([result as any], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'ReportEvents.csv';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
creatingCsv.value = false;
|
||||
}
|
||||
|
||||
const options = ['Last day', 'Last week', 'Last month', 'Total']
|
||||
const selectedTimeFrom = ref<string>(options[0]);
|
||||
|
||||
const showWarning = computed(() => {
|
||||
return options.indexOf(selectedTimeFrom.value) > 1
|
||||
})
|
||||
|
||||
|
||||
const { showDrawer } = useDrawer();
|
||||
|
||||
function goToUpgrade() {
|
||||
showDrawer('PRICING');
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<template>
|
||||
|
||||
|
||||
|
||||
<div class="w-full h-dvh flex flex-col">
|
||||
|
||||
<div v-if="creatingCsv"
|
||||
class="fixed z-[100] flex items-center justify-center left-0 top-0 w-full h-full bg-lyx-lightmode-background-light dark:bg-black/60 backdrop-blur-[4px]">
|
||||
<div class="poppins text-[2rem]">
|
||||
Creating csv...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex justify-end px-12 py-3 items-center gap-2">
|
||||
|
||||
<div v-if="showWarning" class="text-orange-400 flex gap-2 items-center">
|
||||
<i class="far fa-warning "></i>
|
||||
<div> It can take a few minutes </div>
|
||||
</div>
|
||||
<div class="w-[15rem] flex flex-col gap-0">
|
||||
<USelectMenu :uiMenu="{
|
||||
select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
|
||||
base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
|
||||
option: {
|
||||
base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||
active: '!bg-lyx-lightmode-widget-light dark:!bg-lyx-widget-lighter'
|
||||
}
|
||||
}" v-model="selectedTimeFrom" :options="options"></USelectMenu>
|
||||
</div>
|
||||
|
||||
<div v-if="canDownload" @click="downloadCSV()"
|
||||
class="bg-[#57c78fc0] hover:bg-[#57c78fab] cursor-pointer text-text poppins font-semibold px-8 py-1 rounded-lg">
|
||||
Download CSV
|
||||
</div>
|
||||
|
||||
<div v-if="!canDownload" @click="goToUpgrade()"
|
||||
class="bg-[#57c78f46] hover:bg-[#57c78f42] flex gap-4 items-center cursor-pointer text-text poppins font-semibold px-8 py-2 rounded-lg">
|
||||
<i class="far fa-lock"></i>
|
||||
Upgrade plan for CSV
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<UTable v-if="tableData" class="utable px-8" :ui="{
|
||||
wrapper: 'overflow-auto w-full h-full',
|
||||
thead: 'sticky top-0 bg-lyx-lightmode-background-light dark:bg-menu',
|
||||
td: {
|
||||
color: 'text-lyx-lightmode-text dark:text-[#ffffffb3]',
|
||||
base: 'border-r border-l border-lyx-lightmode-widget dark:border-gray-300/20'
|
||||
},
|
||||
th: { color: 'text-lyx-lightmode-text dark:text-text-sub' },
|
||||
tbody: 'divide-y divide-lyx-lightmode-widget dark:divide-gray-300/20',
|
||||
divide: '',
|
||||
}" v-model:sort="sort" :columns="selectedColumns" :rows="tableData" :loading="loadingData" sort-mode="manual">
|
||||
|
||||
<template #metadata-data="{ row }">
|
||||
<div v-if="row.metadata" class="flex flex-col gap-1">
|
||||
<div v-for="(value, key) in row.metadata">
|
||||
<span class="font-bold">{{ key }}</span>: {{ value }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #created_at-data="{ row }">
|
||||
<span> {{ new Date(row.created_at).toLocaleString() }} </span>
|
||||
</template>
|
||||
|
||||
|
||||
</UTable>
|
||||
|
||||
|
||||
|
||||
<div class="flex justify-end px-3 py-3 border-t border-gray-300/30 dark:border-gray-700">
|
||||
<UPagination v-model="page" :page-count="itemsPerPage" :total="totalItems" />
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.card {
|
||||
@apply border text-2xl text-text bg-transparent border-gray-500 border-[1] w-fit px-8 h-[7rem] flex flex-col items-center justify-center gap-1 rounded-xl;
|
||||
}
|
||||
</style>
|
||||
@@ -1,178 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { MetricsCounts } from '~/server/api/metrics/[project_id]/counts';
|
||||
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
const { project } = useProject();
|
||||
|
||||
const isPremium = computed(() => (project.value?.premium_type || 0) > 0);
|
||||
const selfhosted = useSelfhosted();
|
||||
const canDownload = computed(() => {
|
||||
if (selfhosted) return true;
|
||||
return isPremium.value;
|
||||
});
|
||||
|
||||
const metricsInfo = ref<number>(0);
|
||||
|
||||
const columns = [
|
||||
{ key: 'website', label: 'Domain', sortable: true },
|
||||
{ key: 'page', label: 'Page', sortable: true },
|
||||
{ key: 'referrer', label: 'Referrer', sortable: true },
|
||||
{ key: 'browser', label: 'Browser', sortable: true },
|
||||
{ key: 'os', label: 'OS', sortable: true },
|
||||
{ key: 'continent', label: 'Continent', sortable: true },
|
||||
{ key: 'country', label: 'Country', sortable: true },
|
||||
{ key: 'device', label: 'Device', sortable: true },
|
||||
{ key: 'created_at', label: 'Date', sortable: true }
|
||||
]
|
||||
|
||||
const sort = ref<any>({
|
||||
column: 'created_at',
|
||||
direction: 'desc'
|
||||
})
|
||||
|
||||
const selectedColumns = ref([...columns]);
|
||||
|
||||
const page = ref<number>(1);
|
||||
const itemsPerPage = 50;
|
||||
const totalItems = computed(() => metricsInfo.value);
|
||||
|
||||
|
||||
const { data: tableData, pending: loadingData } = await useFetch<any[]>(() =>
|
||||
`/api/metrics/${project.value?._id}/query?type=0&orderBy=${sort.value.column}&order=${sort.value.direction}&page=${page.value}&limit=${itemsPerPage}`, {
|
||||
...signHeaders(), lazy: true
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const counts = await $fetch<MetricsCounts>(`/api/metrics/${project.value?._id}/counts`, signHeaders());
|
||||
metricsInfo.value = counts.visitsCount;
|
||||
});
|
||||
|
||||
|
||||
const creatingCsv = ref<boolean>(false);
|
||||
|
||||
async function downloadCSV() {
|
||||
creatingCsv.value = true;
|
||||
const result = await $fetch(`/api/project/generate_csv?mode=visits&slice=${options.indexOf(selectedTimeFrom.value)}`, {
|
||||
headers: useComputedHeaders({ useSnapshotDates: false }).value
|
||||
});
|
||||
const blob = new Blob([result as any], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'ReportVisits.csv';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
creatingCsv.value = false;
|
||||
}
|
||||
|
||||
|
||||
const options = ['Last day', 'Last week', 'Last month', 'Total']
|
||||
const selectedTimeFrom = ref<string>(options[0]);
|
||||
|
||||
const showWarning = computed(() => {
|
||||
return options.indexOf(selectedTimeFrom.value) > 1
|
||||
})
|
||||
|
||||
const { showDrawer } = useDrawer();
|
||||
|
||||
function goToUpgrade() {
|
||||
showDrawer('PRICING');
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
|
||||
<div class="w-full h-dvh flex flex-col">
|
||||
|
||||
<div v-if="creatingCsv"
|
||||
class="fixed z-[100] flex items-center justify-center left-0 top-0 w-full h-full bg-lyx-lightmode-background-light dark:bg-black/60 backdrop-blur-[4px]">
|
||||
<div class="poppins text-[2rem]">
|
||||
Creating csv...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex justify-end px-12 py-3 items-center gap-2">
|
||||
|
||||
<div v-if="showWarning" class="text-orange-400 flex gap-2 items-center">
|
||||
<i class="far fa-warning "></i>
|
||||
<div> It can take a few minutes </div>
|
||||
</div>
|
||||
<div class="w-[15rem] flex flex-col gap-0">
|
||||
<USelectMenu :uiMenu="{
|
||||
select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
|
||||
base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
|
||||
option: {
|
||||
base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
|
||||
active: '!bg-lyx-lightmode-widget-light dark:!bg-lyx-widget-lighter'
|
||||
}
|
||||
}" v-model="selectedTimeFrom" :options="options"></USelectMenu>
|
||||
</div>
|
||||
|
||||
<div v-if="canDownload" @click="downloadCSV()"
|
||||
class="bg-[#57c78fc0] hover:bg-[#57c78fab] cursor-pointer text-text poppins font-semibold px-8 py-1 rounded-lg">
|
||||
Download CSV
|
||||
</div>
|
||||
|
||||
<div v-if="!canDownload" @click="goToUpgrade()"
|
||||
class="bg-[#57c78f46] hover:bg-[#57c78f42] flex gap-4 items-center cursor-pointer text-text poppins font-semibold px-8 py-2 rounded-lg">
|
||||
<i class="far fa-lock"></i>
|
||||
Upgrade plan for CSV
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<UTable v-if="tableData" class="utable px-8" :ui="{
|
||||
wrapper: 'overflow-auto w-full h-full',
|
||||
thead: 'sticky top-0 bg-lyx-lightmode-background-light dark:bg-menu',
|
||||
td: {
|
||||
color: 'text-lyx-lightmode-text dark:text-[#ffffffb3]',
|
||||
base: 'border-r border-l border-lyx-lightmode-widget dark:border-gray-300/20'
|
||||
},
|
||||
th: { color: 'text-lyx-lightmode-text dark:text-text-sub' },
|
||||
tbody: 'divide-y divide-lyx-lightmode-widget dark:divide-gray-300/20',
|
||||
divide: '',
|
||||
}" v-model:sort="sort" :columns="selectedColumns" :rows="tableData" :loading="loadingData" sort-mode="manual">
|
||||
|
||||
<template #metadata-data="{ row }">
|
||||
<div v-if="row.metadata" class="flex flex-col gap-1">
|
||||
<div v-for="(value, key) in JSON.parse(row.metadata)">
|
||||
<span class="font-bold">{{ key }}</span>: {{ value }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #screen-data="{ row }">
|
||||
<span> {{ row.screenWidth }}x{{ row.screenHeight }} </span>
|
||||
</template>
|
||||
|
||||
<template #userAgent-data="{ row }">
|
||||
<span> {{ row.userAgent }} </span>
|
||||
</template>
|
||||
|
||||
<template #created_at-data="{ row }">
|
||||
<span> {{ new Date(row.created_at).toLocaleString() }} </span>
|
||||
</template>
|
||||
|
||||
</UTable>
|
||||
|
||||
<div class="flex justify-end px-3 py-3 border-t border-gray-300/30 dark:border-gray-700">
|
||||
<UPagination v-model="page" :page-count="itemsPerPage" :total="totalItems" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.card {
|
||||
@apply border text-2xl text-text bg-transparent border-gray-500 border-[1] w-fit px-8 h-[7rem] flex flex-col items-center justify-center gap-1 rounded-xl;
|
||||
}
|
||||
</style>
|
||||
@@ -1,104 +1,69 @@
|
||||
<script lang="ts" setup>
|
||||
import EventsFunnelChart from '~/components/events/EventsFunnelChart.vue';
|
||||
import DateService, { type Slice } from '@services/DateService';
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
import EventsStackedChart from '~/components/complex/EventsStackedChart.vue';
|
||||
import EventDoughnutChart from '~/components/complex/EventDoughnutChart.vue';
|
||||
import EventsUserFlow from '~/components/complex/EventsUserFlow.vue';
|
||||
import EventsMetadataAnalyzer from '~/components/complex/EventsMetadataAnalyzer.vue';
|
||||
import EventsFunnelChart from '~/components/complex/EventsFunnelChart.vue';
|
||||
import LineDataNew from '~/components/complex/LineDataNew.vue';
|
||||
|
||||
const { permission, canSeeEvents } = usePermission();
|
||||
definePageMeta({ layout: 'sidebar' });
|
||||
|
||||
const { snapshotDuration } = useSnapshot();
|
||||
const { data: events } = useAuthFetch<{ _id: string, count: number }[]>('/api/data/events', { headers: { 'x-limit': '9999999' } });
|
||||
|
||||
const selectedLabelIndex = ref<number>(1);
|
||||
|
||||
const selectLabels: { label: string, value: Slice }[] = [
|
||||
{ label: 'Hour', value: 'hour' },
|
||||
{ label: 'Day', value: 'day' },
|
||||
{ label: 'Month', value: 'month' },
|
||||
];
|
||||
|
||||
const selectLabelsAvailable = computed<{ label: string, value: Slice, disabled: boolean }[]>(() => {
|
||||
return selectLabels.map(e => {
|
||||
return { ...e, disabled: !DateService.canUseSliceFromDays(snapshotDuration.value, e.value)[0] }
|
||||
});
|
||||
})
|
||||
|
||||
const eventsData = await useFetch(`/api/data/count`, {
|
||||
headers: useComputedHeaders({ custom: { 'x-schema': 'events' } }),
|
||||
lazy: true
|
||||
});
|
||||
const { permissions } = useProjectStore();
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
|
||||
<div v-if="!canSeeEvents" class="h-full w-full flex mt-[20vh] justify-center">
|
||||
<div> You need events permission to view this page </div>
|
||||
</div>
|
||||
<Unauthorized v-if="permissions?.events === false" authorization="Guest user limitation Events">
|
||||
</Unauthorized>
|
||||
|
||||
<div v-if="canSeeEvents" class="w-full h-full overflow-y-auto pb-20 p-6 gap-6 flex flex-col">
|
||||
|
||||
|
||||
<LyxUiCard class="w-full flex justify-between items-center lg:flex-row flex-col gap-6 lg:gap-0">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div>
|
||||
Total events: {{ eventsData.data.value?.[0]?.count || '0' }}
|
||||
<div v-else class="flex flex-col gap-4 poppins">
|
||||
<div class="bg-gradient-to-r from-violet-500/20 to-transparent rounded-md">
|
||||
<div class=" m-[1px] p-4 rounded-md flex justify-between">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<Badge class="h-8 bg-gray-100 dark:bg-white/20 text-black dark:text-white text-md font-normal">
|
||||
<span v-if="events">Total events: {{events.reduce((a, e) => a + e.count, 0)}}</span>
|
||||
<Loader v-else class="h-8" />
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<LyxUiButton type="secondary" target="_blank" to="https://docs.litlyx.com/custom-events">
|
||||
Trigger your first event
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
</LyxUiCard>
|
||||
|
||||
|
||||
<div>
|
||||
<BarCardEvents :key="refreshKey"></BarCardEvents>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6 flex-col xl:flex-row xl:h-full">
|
||||
|
||||
<CardTitled :key="refreshKey" class="p-4 xl:flex-[4] w-full h-full" title="Events"
|
||||
sub="Events stacked bar chart.">
|
||||
<template #header>
|
||||
|
||||
<SelectButton class="w-fit" @changeIndex="selectedLabelIndex = $event"
|
||||
:currentIndex="selectedLabelIndex" :options="selectLabelsAvailable">
|
||||
</SelectButton>
|
||||
|
||||
</template>
|
||||
<div class="h-full">
|
||||
<EventsStackedBarChart :slice="(selectLabelsAvailable[selectedLabelIndex].value as any)">
|
||||
</EventsStackedBarChart>
|
||||
<div class="flex gap-4">
|
||||
<NuxtLink to="/raw_events"><Button variant="outline">Raw Data</Button></NuxtLink>
|
||||
<NuxtLink to="https://docs.litlyx.com/custom-events" target="_blank">
|
||||
<Button> Trigger your first event </Button>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</CardTitled>
|
||||
|
||||
<CardTitled :key="refreshKey" class="p-4 xl:flex-[2] w-full h-full" title="Top events"
|
||||
sub="Displays key events.">
|
||||
<DashboardEventsChart class="w-full"> </DashboardEventsChart>
|
||||
</CardTitled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col xl:flex-row xl:h-full gap-4">
|
||||
<LineDataNew class="xl:flex-[4]" type="events" />
|
||||
<EventDoughnutChart class="p-4 xl:flex-[2] w-full h-full"></EventDoughnutChart>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<EventsStackedChart class="w-full"></EventsStackedChart>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex">
|
||||
<EventsFunnelChart></EventsFunnelChart>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="flex">
|
||||
<EventsFunnelChart :key="refreshKey" class="w-full"></EventsFunnelChart>
|
||||
<EventsUserFlow></EventsUserFlow>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="flex">
|
||||
<EventsUserFlow :key="refreshKey"></EventsUserFlow>
|
||||
<EventsMetadataAnalyzer></EventsMetadataAnalyzer>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex">
|
||||
<EventsMetadataAnalyzer :key="refreshKey"></EventsMetadataAnalyzer>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,150 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
const email = ref<string>();
|
||||
const email_confirm = ref<string>();
|
||||
const colorMode = useColorMode()
|
||||
|
||||
definePageMeta({ layout: 'none' });
|
||||
|
||||
const email = ref<string>("");
|
||||
|
||||
const emailSended = ref<boolean>(false);
|
||||
|
||||
const { createAlert } = useAlert();
|
||||
|
||||
async function resetPassword() {
|
||||
|
||||
try {
|
||||
const res = await $fetch('/api/user/password/reset', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email: email.value })
|
||||
})
|
||||
|
||||
if (!res) throw Error('No response');
|
||||
|
||||
if (res.error) return createAlert('Error', res.message, 'far fa-triangle-exclamation', 5000);
|
||||
emailSended.value = true;
|
||||
return createAlert('Success', 'Email sent', 'far fa-circle-check', 5000);
|
||||
|
||||
} catch (ex) {
|
||||
console.error(ex);
|
||||
createAlert('Error', 'Internal error', 'far fa-triangle-exclamation', 5000);
|
||||
}
|
||||
|
||||
|
||||
const canReset = computed(() => {
|
||||
if (!email.value) return false;
|
||||
if (!email.value.includes('@')) return false;
|
||||
if (!email.value.includes('.')) return false;
|
||||
if (email.value.length == 0) return false;
|
||||
if (email.value != email_confirm.value) return false;
|
||||
return true;
|
||||
})
|
||||
|
||||
async function sendForgotPasswordEmail() {
|
||||
await useCatch({
|
||||
toast: true,
|
||||
toastTitle: 'Error during request',
|
||||
async action() {
|
||||
await useAuthFetchSync('/api/user/forgot_password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: { email: email.value }
|
||||
});
|
||||
email.value = '';
|
||||
email_confirm.value = '';
|
||||
},
|
||||
onSuccess(_, showToast) {
|
||||
showToast('Success', { description: 'An email was sent to reset the password.' })
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
|
||||
<div class="home w-full h-full">
|
||||
|
||||
<div class="flex h-full">
|
||||
|
||||
<div class="flex-1 flex flex-col items-center pt-20 xl:pt-[22vh]">
|
||||
|
||||
<div class="rotating-thing absolute top-0"></div>
|
||||
|
||||
<div class="mb-8 bg-black rounded-xl">
|
||||
<img class="w-[5rem]" :src="'logo.png'">
|
||||
</div>
|
||||
|
||||
<div class="text-lyx-lightmode-text dark:text-lyx-text text-[2.2rem] font-bold poppins">
|
||||
Reset password
|
||||
</div>
|
||||
|
||||
<div class="text-lyx-lightmode-text dark:text-lyx-text/80 text-[1.2rem] font-light text-center w-[70%] poppins mt-2">
|
||||
Enter your user account's verified email address and we will send you a temporary password.
|
||||
</div>
|
||||
|
||||
<div class="mt-12">
|
||||
|
||||
<div v-if="!emailSended" class="flex flex-col gap-2 z-[110]">
|
||||
|
||||
|
||||
<div class="flex flex-col gap-4 z-[100] w-[20vw] min-w-[20rem]">
|
||||
<LyxUiInput class="px-3 py-2" placeholder="Email" v-model="email"></LyxUiInput>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mt-4">
|
||||
<LyxUiButton @click="resetPassword()" class="text-center z-[110]" type="primary">
|
||||
Reset password
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
<NuxtLink to="/login"
|
||||
class="mt-4 text-center text-lyx-lightmode-text dark:text-lyx-text-dark underline cursor-pointer z-[110]">
|
||||
Go back
|
||||
</NuxtLink>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div v-if="emailSended" class="mt-12 flex flex-col text-center text-[1.1rem] z-[100]">
|
||||
<div>
|
||||
Check your email inbox.
|
||||
</div>
|
||||
<RouterLink tag="div" to="/login"
|
||||
class="mt-6 text-center text-lyx-lightmode-text dark:text-lyx-text-dark underline cursor-pointer z-[110]">
|
||||
Go back
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="grow flex-1 items-center justify-center hidden lg:flex">
|
||||
|
||||
<!-- <GlobeSvg></GlobeSvg> -->
|
||||
|
||||
<img :src="'image-bg.png'" class="h-full py-6">
|
||||
|
||||
</div>
|
||||
<div class="flex justify-center h-dvh items-center ">
|
||||
<div class='flex flex-col gap-6 max-w-[80dvw] md:max-w-[60dvw]'>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="flex items-center gap-2 font-medium">
|
||||
<img :src="colorMode.value==='dark' ? '/logo-white.svg' : '/logo-black.svg'" class="h-16">
|
||||
</div>
|
||||
<div class="text-center text-sm">
|
||||
Have an account?
|
||||
<NuxtLink to="/login" class="underline underline-offset-4">
|
||||
Sign in
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- <div class="flex flex-col items-center justify-center mt-40 gap-20">
|
||||
<div class="google-login text-gray-700" :class="{ disabled: !isReady }" @click="login">
|
||||
<div class="icon">
|
||||
<i class="fab fa-google"></i>
|
||||
<div>
|
||||
<Label class="text-lg"> Reset Your Password </Label>
|
||||
<div class="text-muted-foreground">
|
||||
Please enter your email address below to which we can send you instructions.
|
||||
</div>
|
||||
<div> Continua con Google </div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label>Email</Label>
|
||||
<Input v-model="email"></Input>
|
||||
<div class="my-2"></div>
|
||||
<Label>Confirm Email</Label>
|
||||
<Input v-model="email_confirm"></Input>
|
||||
<Button @click="sendForgotPasswordEmail()" :disabled="!canReset" class="mt-4"> Send Instructions
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div
|
||||
class="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
|
||||
By clicking continue, you agree to our <a href="https://litlyx.com/terms-of-service" target="_blank"> Terms of
|
||||
Service</a>
|
||||
and <a href="https://litlyx.com/privacy-policy" target="_blank">Privacy Policy</a>.
|
||||
</div>
|
||||
|
||||
</div></div>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rotating-thing {
|
||||
height: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
opacity: 0.15;
|
||||
background: radial-gradient(51.24% 31.29% at 50% 50%, rgb(51, 58, 232) 0%, rgba(51, 58, 232, 0) 100%);
|
||||
animation: 12s linear 0s infinite normal none running spin;
|
||||
}
|
||||
|
||||
.google-login {
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
background-color: #fcefed;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 1rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
|
||||
&.disabled {
|
||||
filter: brightness(50%);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,129 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
import ActionableChart from '~/components/complex/ActionableChart.vue';
|
||||
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||
import GuidedSetup from '~/components/complex/GuidedSetup.vue';
|
||||
import LineDataNew from '~/components/complex/LineDataNew.vue';
|
||||
import { RefreshCwIcon } from 'lucide-vue-next';
|
||||
|
||||
definePageMeta({ layout: 'sidebar' });
|
||||
|
||||
|
||||
const route = useRoute();
|
||||
const { project, projectList, projectId } = useProject();
|
||||
const projectStore = useProjectStore();
|
||||
|
||||
const justLogged = computed(() => route.query.just_logged);
|
||||
const jwtLogin = computed(() => route.query.jwt_login as string);
|
||||
const { insight, insightRefresh, insightStatus } = useInsight();
|
||||
|
||||
const { token, setToken } = useAccessToken();
|
||||
|
||||
const { refreshingDomains } = useDomain();
|
||||
const { permission, canSeeWeb, canSeeEvents } = usePermission();
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
if (jwtLogin.value) {
|
||||
setToken(jwtLogin.value);
|
||||
const user = await $fetch<any>('/api/user/me', { headers: { 'Authorization': 'Bearer ' + token.value } })
|
||||
const loggedUser = useLoggedUser();
|
||||
loggedUser.user = user;
|
||||
}
|
||||
|
||||
if (justLogged.value) { setTimeout(() => { location.href = '/' }, 500) }
|
||||
|
||||
})
|
||||
|
||||
const firstInteraction = useFetch<boolean>('/api/project/first_interaction', {
|
||||
lazy: true, headers: useComputedHeaders({ useSnapshotDates: false })
|
||||
});
|
||||
|
||||
const showDashboard = computed(() => project.value && firstInteraction.data.value);
|
||||
|
||||
const selfhosted = useSelfhosted();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div v-if="!canSeeWeb" class="h-full w-full flex mt-[20vh] justify-center">
|
||||
<div> You need webAnalytics permission to view this page </div>
|
||||
|
||||
<Unauthorized v-if="projectStore.permissions?.webAnalytics === false" authorization="webAnalytics">
|
||||
</Unauthorized>
|
||||
|
||||
<div v-if="projectStore.permissions?.webAnalytics && !projectStore.firstInteraction && !isSelfhosted()">
|
||||
<GuidedSetup />
|
||||
</div>
|
||||
|
||||
<div v-if="canSeeWeb && refreshingDomains">
|
||||
<div class="w-full flex justify-center items-center mt-[20vh]">
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="canSeeWeb && !refreshingDomains" class="dashboard w-full h-full overflow-y-auto overflow-x-hidden pb-[7rem] md:pt-4 lg:pt-0">
|
||||
|
||||
<div v-if="showDashboard">
|
||||
|
||||
<div class="w-full px-4 py-2 gap-2 flex flex-col">
|
||||
<BannerLimitsInfo v-if="!selfhosted" :key="refreshKey"></BannerLimitsInfo>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<DashboardTopSection :key="refreshKey"></DashboardTopSection>
|
||||
<DashboardTopCards :key="refreshKey"></DashboardTopCards>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-4 poppins">
|
||||
|
||||
|
||||
<div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row w-full">
|
||||
<DashboardActionableChart v-if="canSeeWeb && canSeeEvents" :key="refreshKey"></DashboardActionableChart>
|
||||
<LyxUiCard v-else class="flex justify-center w-full py-4">
|
||||
You need events permission to view this widget
|
||||
</LyxUiCard>
|
||||
</div>
|
||||
|
||||
<Card v-if="!isSelfhosted()">
|
||||
<CardContent class="flex items-center">
|
||||
<img class="w-5 h-auto mr-4" :src="'ai/pixel-boy.png'">
|
||||
<div v-if="insightStatus === 'success'"> {{ insight }} </div>
|
||||
<div v-else> Generating your insight... </div>
|
||||
<div class="grow"></div>
|
||||
<RefreshCwIcon v-if="insightStatus === 'success'" @click="insightRefresh()"
|
||||
class="size-5 hover:rotate-45 transition-all duration-150"></RefreshCwIcon>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
<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-1">
|
||||
<BarCardReferrers :key="refreshKey"></BarCardReferrers>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<BarCardPages :key="refreshKey"></BarCardPages>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Accordion type="single" collapsible class="relative lg:hidden border rounded-xl px-5 bg-card">
|
||||
|
||||
<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-1">
|
||||
<BarCardGeolocations :key="refreshKey"></BarCardGeolocations>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<BarCardDevices :key="refreshKey"></BarCardDevices>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<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-1">
|
||||
<BarCardBrowsers :key="refreshKey"></BarCardBrowsers>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<BarCardOperatingSystems :key="refreshKey"></BarCardOperatingSystems>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AccordionItem value=" top-cards">
|
||||
|
||||
<AccordionTrigger class="text-md">
|
||||
Top Charts
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<DashboardTopCards class="grid grid-cols-2" />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<div class="hidden lg:block">
|
||||
<DashboardTopCards />
|
||||
</div>
|
||||
|
||||
<FirstInteraction v-if="!justLogged" :refresh-interaction="firstInteraction.refresh"
|
||||
:first-interaction="(firstInteraction.data.value || false)"></FirstInteraction>
|
||||
|
||||
<div class="text-text/85 mt-8 ml-8 poppis text-[1.2rem]"
|
||||
v-if="projectList && projectList.length == 0 && !justLogged">
|
||||
Create your first project...
|
||||
|
||||
<ActionableChart></ActionableChart>
|
||||
|
||||
<div class="flex w-full justify-center">
|
||||
<div class="flex w-full gap-4 flex-col xl:flex-row">
|
||||
<LineDataNew class="flex-1" type="referrers" select />
|
||||
<LineDataNew class="flex-1" type="pages" select />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="justLogged" class="text-[2rem] w-full h-full flex items-center justify-center">
|
||||
<div
|
||||
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-[#727272] animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
<div class="flex w-full justify-center">
|
||||
<div class="flex w-full gap-4 flex-col xl:flex-row">
|
||||
<LineDataNew class="flex-1" type="countries" select />
|
||||
<LineDataNew class="flex-1" type="devices" select />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
definePageMeta({ layout: 'none' });
|
||||
|
||||
const route = useRoute();
|
||||
const jwtLogin = computed(() => route.query.jwt_login as string);
|
||||
const { token, setToken } = useAccessToken();
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
if (jwtLogin.value) {
|
||||
setToken(jwtLogin.value);
|
||||
const user = await $fetch<any>('/api/user/me', { headers: { 'Authorization': 'Bearer ' + token.value } })
|
||||
const loggedUser = useLoggedUser();
|
||||
loggedUser.user = user;
|
||||
setTimeout(() => { location.href = '/project_creation?just_logged=true' }, 100);
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div> You will be redirected soon </div>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -1,187 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
definePageMeta({ layout: 'none' });
|
||||
|
||||
const { snapshot, snapshots } = useSnapshot();
|
||||
|
||||
const { data: project } = useLiveDemo();
|
||||
|
||||
const ready = ref<boolean>(false);
|
||||
|
||||
let interval: any;
|
||||
|
||||
onMounted(async () => {
|
||||
await getOnlineUsers();
|
||||
snapshot.value = snapshots.value[0];
|
||||
interval = setInterval(async () => {
|
||||
await getOnlineUsers();
|
||||
}, 20000);
|
||||
|
||||
setTimeout(() => {
|
||||
ready.value = true;
|
||||
}, 2000);
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
try {
|
||||
if (interval) clearInterval(interval);
|
||||
} catch (ex) {
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
async function getOnlineUsers() {
|
||||
if (!project.value) return;
|
||||
const online = await $fetch<number>(`/api/metrics/${project.value._id}/live_users`, signHeaders());
|
||||
onlineUsers.value = online;
|
||||
}
|
||||
|
||||
const onlineUsers = ref<number | undefined>();
|
||||
|
||||
const mainChartSelectIndex = ref<number>(1);
|
||||
const sessionsChartSelectIndex = ref<number>(1);
|
||||
const eventsStackedSelectIndex = ref<number>(0);
|
||||
|
||||
const selectLabels = [
|
||||
{ label: 'Hour', value: 'hour' },
|
||||
{ label: 'Day', value: 'day' }
|
||||
];
|
||||
|
||||
const selectLabelsEvents = [
|
||||
{ label: 'Day', value: 'day' },
|
||||
{ label: 'Month', value: 'month' },
|
||||
];
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
|
||||
<div class="dashboard w-full h-full overflow-y-auto pb-20">
|
||||
<div v-if="project && ready">
|
||||
|
||||
<div
|
||||
class="bg-bg w-full px-6 py-6 text-text/90 flex flex-collg:flex-row text-lg lg:text-2xl gap-2 lg:gap-12">
|
||||
|
||||
<div class="flex items-center w-full flex-col md:flex-row">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="poppins font-semibold">
|
||||
Litlyx open metrics
|
||||
</div>
|
||||
<div v-if="project" class="flex gap-2 items-center text-text/90">
|
||||
<div class="animate-pulse w-[.8rem] h-[.8rem] bg-green-400 rounded-full"> </div>
|
||||
<div> {{ onlineUsers }} Online users</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grow"></div>
|
||||
<div class="flex gap-2 md:pt-0 pt-4">
|
||||
<LyxUiButton link="/" type="primary"
|
||||
class="poppins font-semibold text-[.9rem] lg:text-[1.2rem] flex items-center !px-14 py-4">
|
||||
Go to dashboard
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<DashboardTopCards :key="refreshKey"></DashboardTopCards>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 px-6 hidden lg:flex gap-6 flex-col 2xl:flex-row w-full">
|
||||
<DashboardActionableChart :key="refreshKey"></DashboardActionableChart>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex gap-6 flex-col xl:flex-row p-6">
|
||||
|
||||
<CardTitled class="p-4 flex-[4] w-full h-full" title="Events" sub="Events stacked bar chart.">
|
||||
<template #header>
|
||||
<SelectButton @changeIndex="eventsStackedSelectIndex = $event"
|
||||
:currentIndex="eventsStackedSelectIndex" :options="selectLabelsEvents">
|
||||
</SelectButton>
|
||||
</template>
|
||||
<div>
|
||||
<EventsStackedBarChart :slice="(selectLabelsEvents[eventsStackedSelectIndex].value as any)">
|
||||
</EventsStackedBarChart>
|
||||
</div>
|
||||
</CardTitled>
|
||||
|
||||
<CardTitled title="Top events" sub=" Displays key events." class="p-4 flex-[2] w-full h-full">
|
||||
<div>
|
||||
<DashboardEventsChart class="w-full"> </DashboardEventsChart>
|
||||
</div>
|
||||
</CardTitled>
|
||||
</div>
|
||||
|
||||
|
||||
<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-1">
|
||||
<BarCardWebsites :key="refreshKey"></BarCardWebsites>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<BarCardReferrers :key="refreshKey"></BarCardReferrers>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-1">
|
||||
<BarCardBrowsers :key="refreshKey"></BarCardBrowsers>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<BarCardOperatingSystems :key="refreshKey"></BarCardOperatingSystems>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-1">
|
||||
<BarCardGeolocations :key="refreshKey"></BarCardGeolocations>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<BarCardDevices :key="refreshKey"></BarCardDevices>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="justify-center mt-14 lg:mt-40 flex flex-col items-center gap-14 lg:gap-20">
|
||||
<div class="poppins text-[1.3rem]">
|
||||
Made with ❤ in Italy
|
||||
</div>
|
||||
<div class="flex flex-col lg:flex-row justify-between w-full items-center gap-10 lg:gap-0 lg:px-10">
|
||||
|
||||
<div class="text-[1.9rem] lg:text-[2.2rem] text-center lg:text-left px-2 lg:px-0">
|
||||
<div class="poppins font-semibold text-accent">
|
||||
Do you want this analytics for your website ?
|
||||
</div>
|
||||
<div class="poppins font-semibold text-text-sub">
|
||||
Start now and discover more.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 flex-col md:flex-row">
|
||||
<LyxUiButton link="/" type="primary"
|
||||
class="poppins font-semibold text-[.9rem] lg:text-[1.2rem] flex items-center !px-14 py-4">
|
||||
Get started for free
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<div v-if="!ready || !project" class="flex justify-center py-40">
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -1,347 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
definePageMeta({ layout: 'none' });
|
||||
|
||||
import { Lit } from 'litlyx-js';
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const isNoAuth = ref<boolean>(config.public.AUTH_MODE == 'NO_AUTH');
|
||||
|
||||
const useCodeClientWrapper = isNoAuth.value === false ?
|
||||
useCodeClient :
|
||||
(...args: any) => {
|
||||
return { isReady: false, login: () => { } }
|
||||
}
|
||||
|
||||
const { isReady, login } = useCodeClientWrapper({ onSuccess: handleOnSuccess, onError: handleOnError, });
|
||||
|
||||
const { fetch: refreshSession, user } = useUserSession()
|
||||
const router = useRouter();
|
||||
|
||||
const { token, setToken } = useAccessToken();
|
||||
|
||||
const { createErrorAlert } = useAlert();
|
||||
const loading = ref<boolean>(false);
|
||||
const { loadData } = useAppStart();
|
||||
|
||||
async function handleOnSuccess(response: any) {
|
||||
|
||||
try {
|
||||
const result = await $fetch('/api/auth/google_login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: response.code })
|
||||
})
|
||||
|
||||
Lit.event('google_login_signup');
|
||||
|
||||
if (result.error) return alert('Error during login, please try again');
|
||||
|
||||
setToken(result.access_token);
|
||||
|
||||
const user = await $fetch<any>('/api/user/me', { headers: { 'Authorization': 'Bearer ' + token.value } })
|
||||
const loggedUser = useLoggedUser();
|
||||
loggedUser.user = user;
|
||||
|
||||
console.log('LOGIN DONE - USER', loggedUser.user);
|
||||
|
||||
const isFirstTime = await $fetch<boolean>('/api/user/is_first_time', { headers: { 'Authorization': 'Bearer ' + token.value } })
|
||||
|
||||
if (isFirstTime === true) {
|
||||
router.push('/project_creation?just_logged=true');
|
||||
} else {
|
||||
router.push('/?just_logged=true');
|
||||
}
|
||||
|
||||
|
||||
} catch (ex) {
|
||||
alert('Google sta avendo problemi con il login, ci scusiamo per il problema ma non dipende da noi.');
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
function handleOnError(errorResponse: any) {
|
||||
alert('Error' + errorResponse);
|
||||
};
|
||||
|
||||
function getRandomHex(size: number) {
|
||||
const bytes = new Uint8Array(size);
|
||||
window.crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes)
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
async function login(event: { email: string, password: string }) {
|
||||
loading.value = true;
|
||||
await useCatch({
|
||||
toast: true,
|
||||
toastTitle: 'Error during login',
|
||||
async action() {
|
||||
await useAuthFetchSync('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: { email: event.email, password: event.password },
|
||||
})
|
||||
},
|
||||
async onSuccess() {
|
||||
await refreshSession();
|
||||
const ok = await loadData()
|
||||
if (ok) router.push('/');
|
||||
},
|
||||
})
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
function githubLogin() {
|
||||
const client_id = config.public.GITHUB_CLIENT_ID;
|
||||
const redirect_uri = window.location.origin + '/api';
|
||||
console.log({ redirect_uri })
|
||||
const state = getRandomHex(16);
|
||||
localStorage.setItem("latestCSRFToken", state);
|
||||
const link = `https://github.com/login/oauth/authorize?client_id=${client_id}&response_type=code&scope=repo&redirect_uri=${redirect_uri}/integrations/github/oauth2/callback&state=${state}`;
|
||||
window.location.assign(link);
|
||||
async function oauth(provider: 'google') {
|
||||
location.href = `/api/auth/${provider}/authenticate`;
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
const bgRef = ref<HTMLElement | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.github_access_token) {
|
||||
//TODO: Something
|
||||
const bg = bgRef.value
|
||||
if (!bg || window.innerWidth < 768) return
|
||||
|
||||
let mouseX = 0
|
||||
let mouseY = 0
|
||||
let currentX = 0
|
||||
let currentY = 0
|
||||
|
||||
const lerp = (start: number, end: number, amt: number) => start + (end - start) * amt
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const x = (e.clientX / window.innerWidth - 0.5) * 2 // range: -1 to 1
|
||||
const y = (e.clientY / window.innerHeight - 0.5) * 2
|
||||
mouseX = x * 20 // max 20px offset
|
||||
mouseY = y * 20
|
||||
}
|
||||
|
||||
const animate = () => {
|
||||
currentX = lerp(currentX, mouseX, 0.1)
|
||||
currentY = lerp(currentY, mouseY, 0.1)
|
||||
|
||||
if (bg) {
|
||||
bg.style.transform = `translate(${currentX}px, ${currentY}px) scale(1.1)`
|
||||
}
|
||||
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
animate()
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
})
|
||||
})
|
||||
|
||||
const isEmailLogin = ref<boolean>(false);
|
||||
const email = ref<string>("");
|
||||
const password = ref<string>("");
|
||||
|
||||
function goBackToEmailLogin() {
|
||||
isEmailLogin.value = false;
|
||||
email.value = '';
|
||||
password.value = '';
|
||||
function getRandomPercent(min: number, max: number): string {
|
||||
return `${Math.random() * (max - min) + min}%`;
|
||||
}
|
||||
|
||||
async function signInSelfhosted() {
|
||||
try {
|
||||
const result: any = await $fetch(`/api/auth/no_auth`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.value, password: password.value })
|
||||
});
|
||||
if (result.error) {
|
||||
if (result.errorMessage) return alert(result.errorMessage);
|
||||
return alert('Error during login, please try again');
|
||||
}
|
||||
|
||||
setToken(result.access_token);
|
||||
|
||||
const user = await $fetch<any>('/api/user/me', { headers: { 'Authorization': 'Bearer ' + token.value } })
|
||||
const loggedUser = useLoggedUser();
|
||||
loggedUser.user = user;
|
||||
|
||||
console.log('LOGIN DONE - USER', loggedUser.user);
|
||||
|
||||
const isFirstTime = await $fetch<boolean>('/api/user/is_first_time', { headers: { 'Authorization': 'Bearer ' + token.value } })
|
||||
|
||||
if (isFirstTime === true) {
|
||||
router.push('/project_creation?just_logged=true');
|
||||
} else {
|
||||
router.push('/?just_logged=true');
|
||||
}
|
||||
|
||||
} catch (ex: any) {
|
||||
createErrorAlert('Error', 'Error during login.' + ex.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function signInWithCredentials() {
|
||||
|
||||
try {
|
||||
const result = await $fetch<{ error: true, message: string } | { error: false, access_token: string }>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.value, password: password.value })
|
||||
})
|
||||
|
||||
if (result.error) return createErrorAlert('Error', result.message);
|
||||
|
||||
setToken(result.access_token);
|
||||
|
||||
const user = await $fetch<any>('/api/user/me', { headers: { 'Authorization': 'Bearer ' + token.value } })
|
||||
const loggedUser = useLoggedUser();
|
||||
loggedUser.user = user;
|
||||
|
||||
console.log('LOGIN DONE - USER', loggedUser.user);
|
||||
|
||||
const isFirstTime = await $fetch<boolean>('/api/user/is_first_time', { headers: { 'Authorization': 'Bearer ' + token.value } })
|
||||
|
||||
if (isFirstTime === true) {
|
||||
router.push('/project_creation?just_logged=true');
|
||||
} else {
|
||||
router.push('/?just_logged=true');
|
||||
}
|
||||
|
||||
|
||||
} catch (ex: any) {
|
||||
createErrorAlert('Error', 'Something went wrong.' + ex.message);
|
||||
}
|
||||
function getRandomSeconds(min: number, max: number): string {
|
||||
return `${Math.random() * (max - min) + min}s`;
|
||||
}
|
||||
|
||||
const totalStars = 6;
|
||||
|
||||
const stars = ref(
|
||||
Array.from({ length: totalStars }).map(() => ({
|
||||
top: getRandomPercent(20, 70), // da 20% a 70% in verticale
|
||||
left: getRandomPercent(65, 90), // solo nella zona destra
|
||||
delay: getRandomSeconds(0, 8),
|
||||
duration: getRandomSeconds(8, 12)
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="relative flex overflow-hidden min-h-svh flex-col items-center justify-center">
|
||||
<!-- Sfondo dinamico ingrandito -->
|
||||
<div ref="bgRef" class="absolute inset-0 -z-10 w-full h-full overflow-hidden">
|
||||
<img src="/planet.png" alt="bg" class="w-full h-full object-cover object-bottom scale-120 pointer-events-none" />
|
||||
|
||||
<div class="home w-full h-full">
|
||||
|
||||
<div class="flex h-full bg-lyx-lightmode-background dark:bg-lyx-background">
|
||||
|
||||
<div class="flex-1 flex flex-col items-center pt-20 xl:pt-[22vh]">
|
||||
|
||||
<!-- <div class="rotating-thing absolute top-0"></div> -->
|
||||
|
||||
<div class="mb-8 bg-black rounded-xl">
|
||||
<img class="w-[5rem]" :src="'logo.png'">
|
||||
</div>
|
||||
|
||||
<div class="text-lyx-lightmode-text dark:text-lyx-text text-[2.2rem] font-bold poppins">
|
||||
Sign in
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="text-lyx-lightmode-text/80 dark:text-lyx-text/80 text-[1.2rem] font-light text-center w-[70%] poppins mt-2">
|
||||
Track web analytics and custom events
|
||||
with extreme simplicity in under 30 sec.
|
||||
<br>
|
||||
<!-- <div class="font-bold poppins mt-4">
|
||||
Start for Free now! Up to 3k visits/events monthly.
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<div class="mt-12">
|
||||
|
||||
<div v-if="!isNoAuth && isEmailLogin" class="flex flex-col gap-2">
|
||||
|
||||
|
||||
<div class="flex flex-col gap-4 z-[100] w-[20vw] min-w-[20rem]">
|
||||
<LyxUiInput class="px-3 py-2" placeholder="Email" v-model="email"></LyxUiInput>
|
||||
<LyxUiInput class="px-3 py-2" placeholder="Password" v-model="password" type="password">
|
||||
</LyxUiInput>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<RouterLink tag="div" to="/forgot_password"
|
||||
class="text-center text-lyx-lightmode-text dark:text-lyx-text-dark underline cursor-pointer z-[110]">
|
||||
Forgot password?
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mt-4 z-[100]">
|
||||
<LyxUiButton @click="signInWithCredentials()" class="text-center" type="primary">
|
||||
Sign in
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
<div @click="goBackToEmailLogin()"
|
||||
class="mt-4 text-center text-lyx-lightmode-text dark:text-lyx-text-dark underline cursor-pointer z-[100]">
|
||||
Go back
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div v-if="!isNoAuth && !isEmailLogin"
|
||||
class="flex flex-col text-lyx-lightmode-text dark:text-lyx-text gap-2">
|
||||
|
||||
<div @click="login"
|
||||
class="hover:bg-lyx-primary bg-white dark:bg-transparent cursor-pointer flex text-[1.3rem] gap-4 items-center border-[1px] border-gray-400 rounded-lg px-8 py-3 relative z-[2]">
|
||||
<div class="flex items-center">
|
||||
<i class="fab fa-google"></i>
|
||||
</div>
|
||||
Continue with Google
|
||||
</div>
|
||||
|
||||
<div @click="isEmailLogin = true"
|
||||
class="hover:bg-[#d3d3d3] dark:hover:bg-[#262626] bg-white dark:bg-transparent cursor-pointer flex text-[1.3rem] gap-4 items-center border-[1px] border-gray-400 rounded-lg px-8 py-3 relative z-[2]">
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-envelope"></i>
|
||||
</div>
|
||||
Sign in with Email
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex flex-col gap-2 mt-4">
|
||||
|
||||
<RouterLink tag="div" to="/register"
|
||||
class="text-center text-lyx-lightmode-text-dark dark:text-lyx-text-dark underline cursor-pointer z-[100]">
|
||||
You don't have an account ? Sign up
|
||||
</RouterLink>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div v-if="isNoAuth"
|
||||
class="flex text-[1.3rem] flex-col gap-4 items-center px-8 py-3 relative z-[2]">
|
||||
<div class="flex flex-col gap-4 z-[100] w-[20vw] min-w-[20rem]">
|
||||
<LyxUiInput class="px-3 py-2" placeholder="Email" v-model="email"></LyxUiInput>
|
||||
<LyxUiInput class="px-3 py-2" placeholder="Password" v-model="password" type="password">
|
||||
</LyxUiInput>
|
||||
</div>
|
||||
<div class="flex justify-center mt-4 z-[100]">
|
||||
<LyxUiButton @click="signInSelfhosted()" class="text-center" type="primary">
|
||||
Sign in
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="text-[.9rem] poppins mt-20 text-lyx-lightmode-text-dark dark:text-lyx-text-dark text-center relative z-[2]">
|
||||
By continuing you are accepting
|
||||
<br>
|
||||
our
|
||||
<a class="underline" href="https://litlyx.com/terms" target="_blank">Terms of Service</a> and
|
||||
<a class="underline" href="https://litlyx.com/privacy" target="_blank">Privacy Policy</a>.
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="grow flex-1 items-center justify-center hidden lg:flex">
|
||||
|
||||
<!-- <GlobeSvg></GlobeSvg> -->
|
||||
|
||||
<img :src="'image-bg.png'" class="h-full py-6">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- <div class="flex flex-col items-center justify-center mt-40 gap-20">
|
||||
<div class="google-login text-gray-700" :class="{ disabled: !isReady }" @click="login">
|
||||
<div class="icon">
|
||||
<i class="fab fa-google"></i>
|
||||
</div>
|
||||
<div> Continua con Google </div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<!-- Stelle generate dinamicamente -->
|
||||
<div v-for="(star, index) in stars" :key="index" class="twinkle-star" :style="{
|
||||
top: star.top,
|
||||
left: star.left,
|
||||
animationDelay: star.delay,
|
||||
animationDuration: star.duration
|
||||
}"></div>
|
||||
</div>
|
||||
|
||||
<!-- Contenuto sopra -->
|
||||
<div class="flex flex-col items-center justify-center gap-6 p-6 md:p-10 relative z-10">
|
||||
<div
|
||||
class="w-full max-w-sm px-4 py-4 bg-violet-400/20 backdrop-blur-xl rounded-xl border border-violet-400/40 shadow-xl shadow-violet-400/10">
|
||||
<AuthLoginForm :loading="loading" @submit="login($event)" @oauth="oauth($event)" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="!text-violet-100/80 *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4 w-100">
|
||||
<span v-if="!isSelfhosted()">
|
||||
By clicking continue, you agree to our
|
||||
<a href="https://litlyx.com/terms-of-service" target="_blank">Terms of Service</a>
|
||||
and
|
||||
<a href="https://litlyx.com/privacy-policy" target="_blank">Privacy Policy</a>.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rotating-thing {
|
||||
height: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
opacity: 0.15;
|
||||
background: radial-gradient(51.24% 31.29% at 50% 50%, rgb(51, 58, 232) 0%, rgba(51, 58, 232, 0) 100%);
|
||||
animation: 12s linear 0s infinite normal none running spin;
|
||||
<style scoped>
|
||||
.twinkle-star {
|
||||
position: absolute;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 6px 1px white;
|
||||
opacity: 0;
|
||||
animation-name: twinkle;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
.google-login {
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
background-color: #fcefed;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 1rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
@keyframes twinkle {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
filter: brightness(50%);
|
||||
}
|
||||
5% {
|
||||
opacity: 1;
|
||||
transform: scale(1.4);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
10% {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,212 +1,241 @@
|
||||
<script setup lang="ts">
|
||||
import { DialogPermissionManager } from '#components';
|
||||
|
||||
import { DialogManagePermissions } from '#components';
|
||||
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Settings, TrashIcon, UserIcon, UserPlus, UserX } from 'lucide-vue-next';
|
||||
import KickUser from '~/components/dialog/KickUser.vue';
|
||||
import type { MemberWithPermissions } from '~/server/api/members/list';
|
||||
import type { TPermission } from '~/shared/schema/TeamMemberSchema';
|
||||
|
||||
const { projectId, isGuest } = useProject();
|
||||
const projectStore = useProjectStore();
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
definePageMeta({ layout: 'sidebar' });
|
||||
|
||||
const columns = [
|
||||
{ key: 'me', label: '' },
|
||||
{ key: 'email', label: 'Email' },
|
||||
{ key: 'permission', label: 'Permission' },
|
||||
{ key: 'pending', label: 'Status' },
|
||||
{ key: 'action', label: 'Actions' },
|
||||
]
|
||||
const { data: members, error: membersError, refresh: membersRefresh } = useAuthFetch<MemberWithPermissions[]>('api/members/list');
|
||||
|
||||
const { data: members, refresh: refreshMembers } = useFetch('/api/project/members/list', {
|
||||
headers: useComputedHeaders({ useSnapshotDates: false })
|
||||
});
|
||||
const premium = usePremiumStore();
|
||||
const dialog = useDialog();
|
||||
const open = ref<boolean>(false);
|
||||
|
||||
const showAddMember = ref<boolean>(false);
|
||||
async function kickUser(email: string) {
|
||||
open.value = false;
|
||||
dialog.open({
|
||||
body: KickUser,
|
||||
title: 'Remove User from Workspace',
|
||||
props: {
|
||||
email
|
||||
},
|
||||
async onSuccess(data, close) {
|
||||
await useCatch({
|
||||
toast: true,
|
||||
async action() {
|
||||
return await useAuthFetchSync<void>('/api/members/kick', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: { email }
|
||||
});
|
||||
},
|
||||
async onSuccess(_, toast) {
|
||||
toast('Member kicked', { description: 'Member kicked successfully', position: 'top-right' });
|
||||
await membersRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
const addMemberEmail = ref<string>("");
|
||||
|
||||
const { createErrorAlert } = useAlert();
|
||||
|
||||
async function kickMember(email: string) {
|
||||
const sure = confirm('Are you sure to kick ' + email + ' ?');
|
||||
if (!sure) return;
|
||||
try {
|
||||
await $fetch('/api/project/members/kick', {
|
||||
method: 'POST',
|
||||
...signHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
'x-pid': projectId.value ?? ''
|
||||
}),
|
||||
body: JSON.stringify({ email }),
|
||||
onResponseError({ request, response, options }) {
|
||||
createErrorAlert('Error', response.statusText);
|
||||
}
|
||||
});
|
||||
|
||||
refreshMembers();
|
||||
} catch (ex: any) { }
|
||||
close();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function addMember() {
|
||||
const email = ref<string>('');
|
||||
|
||||
if (addMemberEmail.value.length === 0) return;
|
||||
|
||||
try {
|
||||
|
||||
showAddMember.value = false;
|
||||
|
||||
await $fetch('/api/project/members/add', {
|
||||
method: 'POST',
|
||||
...signHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
'x-pid': projectId.value ?? ''
|
||||
}),
|
||||
body: JSON.stringify({ email: addMemberEmail.value }),
|
||||
onResponseError({ request, response, options }) {
|
||||
createErrorAlert('Error', response.statusText);
|
||||
}
|
||||
});
|
||||
|
||||
addMemberEmail.value = '';
|
||||
|
||||
refreshMembers();
|
||||
|
||||
} catch (ex: any) { }
|
||||
async function addUser() {
|
||||
|
||||
await useCatch({
|
||||
toast: true,
|
||||
async action() {
|
||||
return await useAuthFetchSync<void>('/api/members/add', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: { email: email.value }
|
||||
});
|
||||
},
|
||||
async onSuccess(_, toast) {
|
||||
toast('Member invited', { description: 'Member invited successfully', position: 'top-right' });
|
||||
await membersRefresh();
|
||||
email.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
const modal = useModal();
|
||||
async function showManagePermission(member: MemberWithPermissions) {
|
||||
dialog.open({
|
||||
body: DialogManagePermissions,
|
||||
props: { member },
|
||||
title: 'Manage permissions',
|
||||
description: 'Choose what this member can do on this project.',
|
||||
onSuccess(data, close) {
|
||||
editPermissions(member, data);
|
||||
close();
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function openPermissionManagerDialog(member_id: string) {
|
||||
modal.open(DialogPermissionManager, {
|
||||
preventClose: true,
|
||||
member_id,
|
||||
onSuccess: () => {
|
||||
modal.close();
|
||||
refreshMembers();
|
||||
},
|
||||
onCancel: () => {
|
||||
modal.close();
|
||||
refreshMembers();
|
||||
async function editPermissions(member: MemberWithPermissions, permissions: TPermission) {
|
||||
await useCatch({
|
||||
toast: true,
|
||||
async action() {
|
||||
return await useAuthFetchSync<void>('/api/members/edit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: { member_id: member.id, ...permissions }
|
||||
});
|
||||
},
|
||||
async onSuccess(_, toast) {
|
||||
toast('Permissions updated', { description: 'Permissions updated successfully', position: 'top-right' });
|
||||
await membersRefresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function permissionToString(permission: TPermission) {
|
||||
const result: string[] = [];
|
||||
if (permission.webAnalytics) result.push('w');
|
||||
if (permission.events) result.push('e');
|
||||
if (permission.ai) result.push('a');
|
||||
if (permission.domains.includes('All domains')) {
|
||||
result.push('+');
|
||||
} else {
|
||||
result.push(permission.domains.length.toString());
|
||||
}
|
||||
return result.join('');
|
||||
}
|
||||
|
||||
async function leaveProject() {
|
||||
try {
|
||||
await $fetch('/api/project/members/leave', {
|
||||
headers: useComputedHeaders({}).value
|
||||
});
|
||||
location.reload();
|
||||
} catch (ex: any) {
|
||||
alert(ex.message);
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 pt-10">
|
||||
|
||||
<div v-if="!isGuest" class="flex flex-col gap-8">
|
||||
<Unauthorized v-if="projectStore.isActiveProjectGuest || [0, 7006, 8001, 8002].includes(premium.planInfo?.ID ?? -1)" authorization="PLAN or AUTH">
|
||||
</Unauthorized>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div class="flex gap-4 items-center">
|
||||
<LyxUiInput class="px-4 py-1 w-full" placeholder="Add a new member" v-model="addMemberEmail">
|
||||
</LyxUiInput>
|
||||
<LyxUiButton @click="addMember" type="secondary"> Add </LyxUiButton>
|
||||
<div v-else class="flex flex-col gap-2 poppins">
|
||||
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Workspace Members <span class="text-[10px] text-muted-foreground">({{`${members?.length ?? 0}/${premium.planInfo?.features.members}` }})</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage the members of your workspace
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center gap-4">
|
||||
<Input v-model="email" placeholder="Email"></Input>
|
||||
<ProButton @action="addUser()" v-if="members" :disabled="email.length < 5" title="Add Member"
|
||||
:locked="members.length >= (premium.planInfo?.features.members ?? 0)">
|
||||
<UserPlus />
|
||||
</ProButton>
|
||||
</div>
|
||||
<div class="poppins text-[.8rem] text-muted-foreground pl-1 pt-[.5rem]">
|
||||
We will send an invitation email to the user you wish to add to this project.
|
||||
</div>
|
||||
</div>
|
||||
<div class="poppins text-[.8rem] mt-2 dark:text-lyx-text-dark">
|
||||
We will send an invitation email to the user you wish to add to this project.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<UTable :rows="members || []" :columns="columns">
|
||||
<div class="my-15"></div>
|
||||
|
||||
<template #me-data="e">
|
||||
<i v-if="e.row.me" class="far fa-user text-lyx-lightmode-text dark:text-lyx-text"></i>
|
||||
<i v-if="!e.row.me"></i>
|
||||
</template>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-[5%]"> </TableHead>
|
||||
<TableHead class="w-[25%]"> Email </TableHead>
|
||||
<TableHead class="w-[20%]">Pemissions</TableHead>
|
||||
<TableHead class="w-[35%]"> Domains </TableHead>
|
||||
<TableHead class="w-[8%]">Status</TableHead>
|
||||
<TableHead class="w-[12%]"> Actions </TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="member of members" class="h-[2rem]">
|
||||
<TableCell>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<UserIcon class="size-4" v-if="member.me"></UserIcon>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Owner</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{{ member.email }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="flex gap-4">
|
||||
<div class="flex items-center gap-[.3rem]">
|
||||
<div :class="{ 'bg-green-400': member.permission.webAnalytics, 'bg-red-400': !member.permission.webAnalytics }"
|
||||
class="size-3 mt-[2px] rounded-full"></div>
|
||||
<div> web </div>
|
||||
</div>
|
||||
<div class="flex items-center gap-[.3rem]">
|
||||
<div :class="{ 'bg-green-400': member.permission.events, 'bg-red-400': !member.permission.events }"
|
||||
class="size-3 mt-[2px] rounded-full"></div>
|
||||
<div> events </div>
|
||||
</div>
|
||||
<div class="flex items-center gap-[.3rem]">
|
||||
<div :class="{ 'bg-green-400': member.permission.ai, 'bg-red-400': !member.permission.ai }"
|
||||
class="size-3 mt-[2px] rounded-full"></div>
|
||||
<div> ai </div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div v-if="member.permission.domains.includes('*')"
|
||||
class="border-solid border-[2px] w-fit rounded-md px-2 py-[.2rem]">
|
||||
ALL DOMAINS
|
||||
</div>
|
||||
<div v-else-if="Array.isArray(member.permission.domains) && member.permission.domains.length === 0"
|
||||
class="border-dashed border-[2px] w-fit rounded-md px-2 py-[.2rem]">
|
||||
NO DOMAINS
|
||||
</div>
|
||||
<div v-else class="flex gap-2 flex-wrap">
|
||||
<div v-for="domain of member.permission.domains"
|
||||
class="border-solid border-[2px] w-fit rounded-md px-2 py-[.2rem]">
|
||||
{{ domain }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #email-data="e">
|
||||
<div class="text-lyx-lightmode-text dark:text-lyx-text">
|
||||
{{ e.row.email }}
|
||||
</div>
|
||||
</template>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge class="w-full" v-if="!member.me"
|
||||
:class="member.pending ? 'border-amber-400 bg-amber-300 text-amber-800' : 'border-green-400 bg-green-300 text-green-800'">
|
||||
{{ member.pending ? 'Pending' : 'Accepted' }}</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="flex gap-2">
|
||||
<TooltipProvider >
|
||||
<Tooltip v-if="!member.me && member.role === 'GUEST'">
|
||||
<TooltipTrigger> <Button @click="showManagePermission(member)" class="size-7"
|
||||
size="icon" variant="outline">
|
||||
<Settings class="size-4" />
|
||||
|
||||
<template #pending-data="e">
|
||||
<div class="text-lyx-lightmode-text dark:text-lyx-text">
|
||||
{{ e.row.pending ? 'Pending' : 'Accepted' }}
|
||||
</div>
|
||||
</template>
|
||||
</Button></TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Manage permissions</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider v-if="!member.me">
|
||||
<Tooltip>
|
||||
<TooltipTrigger> <Button @click="kickUser(member.email)" class="size-7"
|
||||
size="icon" variant="destructive">
|
||||
<UserX class="size-4" />
|
||||
|
||||
<template #permission-data="e">
|
||||
<div class="text-lyx-lightmode-text dark:text-lyx-text flex gap-2">
|
||||
<div v-if="e.row.role !== 'OWNER' && !isGuest">
|
||||
<LyxUiButton class="!px-2" type="secondary"
|
||||
@click="openPermissionManagerDialog(e.row.id.toString())">
|
||||
<UTooltip text="Manage permissions">
|
||||
<i class="far fa-gear"></i>
|
||||
</UTooltip>
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
</Button></TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Remove from Workspace</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<UBadge variant="outline" size="sm" color="yellow"
|
||||
v-if="!e.row.permission.webAnalytics && !e.row.permission.events && !e.row.permission.ai && e.row.permission.domains.length == 0">
|
||||
No permission given
|
||||
</UBadge>
|
||||
<UBadge variant="outline" size="sm" v-if="e.row.permission.webAnalytics"
|
||||
label="Analytics"> </UBadge>
|
||||
<UBadge variant="outline" size="sm" v-if="e.row.permission.events" label="Events">
|
||||
</UBadge>
|
||||
<UBadge variant="outline" size="sm" v-if="e.row.permission.ai" label="AI"> </UBadge>
|
||||
<UBadge variant="outline" color="blue" size="sm"
|
||||
v-if="e.row.permission.domains.includes('All domains')" label="All domains">
|
||||
</UBadge>
|
||||
|
||||
<UBadge variant="outline" size="sm" color="blue"
|
||||
v-if="!e.row.permission.domains.includes('All domains')"
|
||||
v-for="domain of e.row.permission.domains" :label="domain"> </UBadge>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<template #action-data="e" v-if="!isGuest">
|
||||
<div @click="kickMember(e.row.email)" v-if="e.row.role != 'OWNER'"
|
||||
class="text-red-500 hover:bg-black/20 cursor-pointer outline outline-[1px] outline-red-500 px-3 py-1 rounded-lg text-center">
|
||||
Remove
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</UTable>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div v-if="isGuest" class="flex flex-col gap-8 mt-[10vh]">
|
||||
<div class="flex flex-col gap-4 items-center">
|
||||
<div class="text-[1.2rem]"> Leave this project </div>
|
||||
<LyxUiButton @click="leaveProject()" type="primary"> Leave </LyxUiButton>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
29
dashboard/pages/payment_error.vue
Normal file
29
dashboard/pages/payment_error.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts" setup>
|
||||
const colorMode = useColorMode();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-center items-center h-dvh poppins">
|
||||
|
||||
<Card class="flex items-center justify-center min-w-[52dvw] min-h-[72dvh]">
|
||||
|
||||
<CardContent class="flex flex-col gap-4 text-center m-8 z-2">
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
:src="colorMode.value === 'dark' ? '/logo-white.svg' : '/logo-black.svg'"
|
||||
class="object-contain w-40"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Payment Error!"
|
||||
description="An error occurred while payment"
|
||||
/>
|
||||
</div>
|
||||
<NuxtLink to="/plans"><Button class="w-full">Plans</Button></NuxtLink>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,32 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
const colorMode = useColorMode();
|
||||
const confetti = ref<HTMLElement | null>(null)
|
||||
|
||||
definePageMeta({ layout: 'none' });
|
||||
onMounted(() => {
|
||||
const total = 100
|
||||
for (let i = 0; i < total; i++) {
|
||||
const el = document.createElement('div')
|
||||
el.className = 'confetti'
|
||||
|
||||
// Posizione iniziale al centro in basso
|
||||
el.style.left = '50%'
|
||||
el.style.bottom = '40%'
|
||||
|
||||
// Colore random
|
||||
el.style.backgroundColor = ['#F44336', '#FFC107', '#4CAF50', '#2196F3'][Math.floor(Math.random() * 4)]
|
||||
|
||||
// Movimento random verso l'alto con angolazione
|
||||
const angle = Math.random() * 2 * Math.PI
|
||||
const distance = 100 + Math.random() * 200 // px
|
||||
const x = Math.cos(angle) * distance
|
||||
const y = Math.sin(angle) * distance
|
||||
|
||||
el.style.setProperty('--x', `${x}px`)
|
||||
el.style.setProperty('--y', `${-Math.abs(y)}px`)
|
||||
el.style.animationDuration = `${1 + Math.random()}s`
|
||||
|
||||
confetti.value?.appendChild(el)
|
||||
|
||||
setTimeout(() => el.remove(), 2000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="relative flex justify-center items-center h-dvh poppins">
|
||||
<!-- Confetti container -->
|
||||
<div ref="confetti" class="absolute inset-0 pointer-events-none z-0 overflow-hidden" />
|
||||
|
||||
<div class="w-full h-full">
|
||||
|
||||
|
||||
<div class="flex items-center h-full flex-col gap-4">
|
||||
|
||||
<div class="text-accent mt-[20vh] poppins font-semibold text-[1.5rem]">
|
||||
Payment success
|
||||
</div>
|
||||
|
||||
<div class="poppins">
|
||||
We hope Lilyx can help you make better metrics-driven decision to help your business.
|
||||
</div>
|
||||
|
||||
<NuxtLink to="/?just_logged=true" class="text-accent mt-10 bg-menu px-6 py-2 rounded-lg hover:bg-black font-semibold poppins cursor-pointer">
|
||||
Go back to dashboard
|
||||
</NuxtLink>
|
||||
|
||||
<Card class="flex items-center justify-center min-w-[52dvw] min-h-[72dvh]">
|
||||
<CardContent class="flex flex-col gap-4 text-center m-8 z-2">
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
:src="colorMode.value === 'dark' ? '/logo-white.svg' : '/logo-black.svg'"
|
||||
class="object-contain w-40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Payment Successfull!"
|
||||
description="Thanks for choosing Litlyx. You're ready to go!"
|
||||
/>
|
||||
</div>
|
||||
<NuxtLink to="/"><Button class="w-full">Dashboard</Button></NuxtLink>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.confetti {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: red;
|
||||
border-radius: 50%;
|
||||
opacity: 0.9;
|
||||
transform: translate(-50%, 0);
|
||||
animation: explode 2s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes explode {
|
||||
0% {
|
||||
transform: translate(-50%, 0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate(calc(-50% + var(--x)), var(--y)) scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
16
dashboard/pages/plans.vue
Normal file
16
dashboard/pages/plans.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
|
||||
definePageMeta({ layout: 'sidebar' });
|
||||
const projectStore = useProjectStore();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Unauthorized v-if="!projectStore.isOwner" authorization="Guest user limitation">
|
||||
</Unauthorized>
|
||||
<div v-else class="flex flex-col gap-8">
|
||||
<ManagePlans />
|
||||
<PlansCardQuestions/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,122 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
|
||||
definePageMeta({ layout: 'header' });
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
|
||||
|
||||
<div class="tutto-poppins flex flex-col gap-3 px-96 mt-20 text-[1.2rem] leading-[2rem]">
|
||||
|
||||
<div class="font-bold text-[2rem]">
|
||||
LitLyx Privacy Policy
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-500/90 h-[1px] w-full my-6"></div>
|
||||
|
||||
<div>
|
||||
For our beloved litlyx.com visitors and users.
|
||||
This document outlines our commitment to privacy for all visitors and users.
|
||||
</div>
|
||||
<div>
|
||||
At LitLyx Analytics, your privacy is really important. We avoid using cookies and never gather personal
|
||||
information. Should you opt to register an account, only essential details are requested, and these are
|
||||
shared
|
||||
exclusively with crucial services required for app functionality.
|
||||
</div>
|
||||
<div>
|
||||
LitLyx Analytics (SaaS) adheres strictly to GDPR, CCPA, PECR, and other relevant privacy standards both on
|
||||
our site and within our analytics tool. We prioritize the confidentiality of your information—it belongs to
|
||||
you, not us. This policy details the types of data we gather, the handling process, and your rights over
|
||||
your data. We are committed to never selling your data—this has always been our stance.
|
||||
</div>
|
||||
<div>
|
||||
For those using the LitLyx Analytics script on their sites, refer to our detailed data policy to understand
|
||||
what
|
||||
we collect on your behalf regarding site visitors.
|
||||
</div>
|
||||
<div class="font-bold mb-1 mt-4">Visitor privacy on litlyx.com includes:</div>
|
||||
<ul>
|
||||
<div class="ml-8"> • No collection of personal details</div>
|
||||
<div class="ml-8"> • No browser cookie storage </div>
|
||||
<div class="ml-8"> • No data sharing with third parties or advertisers </div>
|
||||
<div class="ml-8"> • No collection or analysis of personal or behavioral trends </div>
|
||||
<div class="ml-8"> • No monetization of data </div>
|
||||
</ul>
|
||||
|
||||
<div>
|
||||
We deploy the LitLyx Analytics script solely to accumulate anonymous statistical data, aiming to analyze
|
||||
general
|
||||
website traffic trends without tracking individual users. All collected data is aggregated, and no personal
|
||||
information is captured. Our live demo page showcases the accessible data, including referral sources, top
|
||||
visited pages, session durations, and device specifics like type, OS, country, and browser.
|
||||
</div>
|
||||
<div class="font-bold mb-1 mt-4"> As a LitLyx Analytics subscriber: </div>
|
||||
<div>
|
||||
Our core principle is minimal data collection—only what is essential for delivering the services you
|
||||
register
|
||||
for. We select trusted external providers who comply with stringent data security and privacy regulations.
|
||||
Information shared with them is strictly necessary for their services, and they are contractually obliged to
|
||||
maintain confidentiality and adhere to our processing directives.
|
||||
</div>
|
||||
<div>
|
||||
Here's a practical overview
|
||||
</div>
|
||||
|
||||
<div class="font-bold mb-1 mt-4"> Collected data and usage:</div>
|
||||
<ul>
|
||||
<div class="ml-8">
|
||||
• Email address: Required for account setup to enable login, personalization, and to send you necessary
|
||||
communications like invoices or updates.
|
||||
</div>
|
||||
<div class="ml-8">
|
||||
• Persistent first-party cookie: Facilitates seamless login across sessions, improving usability. You
|
||||
control cookie settings via your browser, including their deletion.
|
||||
</div>
|
||||
<div class="ml-8">
|
||||
• Data security: All collected data is securely encrypted and stored on renewable
|
||||
energy-powered servers in Nuremberg, Germany, adhering to EU data privacy laws. Your data does not
|
||||
leave the EU.
|
||||
</div>
|
||||
<div class="ml-8">
|
||||
•Payment processing: Handled by Stripe, with detailed privacy information
|
||||
available on their site.
|
||||
</div>
|
||||
<div class="ml-8">
|
||||
• Email communication: Managed by European providers Gmail or Brevo, with disabled tracking features.
|
||||
Full privacy details are available on their respective sites.
|
||||
</div>
|
||||
</ul>
|
||||
|
||||
<div class="font-bold mb-1 mt-4"> Data retention: </div>
|
||||
<div>
|
||||
Your data remains with us as long as your account is active or as needed to deliver services. It is used in
|
||||
line
|
||||
with this policy and retained to fulfill legal obligations, resolve disputes, and protect LitLyx’s rights.
|
||||
You
|
||||
may delete your account anytime, which results in immediate and permanent data deletion.
|
||||
</div>
|
||||
<div class="font-bold mb-1 mt-4"> Updates and inquiries: </div>
|
||||
<div>
|
||||
We update this policy as necessary to stay compliant and reflect new practices. Significant changes are
|
||||
communicated through our blog, social media, and direct emails.
|
||||
</div>
|
||||
<div>
|
||||
Please reach out to us at <a href="mailto:help@litlyx.com" class=text-blue-400>help@litlyx.com</a> with any questions or concerns regarding this privacy policy
|
||||
or
|
||||
your rights.
|
||||
</div>
|
||||
<div class="font-bold mb-1 mt-4"> Last updated: May 1, 2024 </div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
<style scoped lang=scss>
|
||||
.tutto-poppins * {
|
||||
font-family: "Poppins";
|
||||
}
|
||||
</style>
|
||||
@@ -1,104 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
|
||||
const projectName = ref<string>("");
|
||||
const creating = ref<boolean>(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { projectList, actions } = useProject();
|
||||
const isFirstProject = computed(() => { return projectList.value?.length == 0; })
|
||||
|
||||
definePageMeta({ layout: 'none' });
|
||||
|
||||
|
||||
import { Lit } from 'litlyx-js';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.just_logged) return location.href = '/project_creation';
|
||||
setPageLayout(isFirstProject.value ? 'none' : 'dashboard');
|
||||
})
|
||||
|
||||
|
||||
async function createProject() {
|
||||
if (projectName.value.trim().length < 2) return;
|
||||
|
||||
Lit.event('create_project');
|
||||
|
||||
creating.value = true;
|
||||
|
||||
try {
|
||||
|
||||
await $fetch('/api/project/create', {
|
||||
method: 'POST',
|
||||
...signHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ name: projectName.value.trim() })
|
||||
});
|
||||
|
||||
await actions.refreshProjectsList();
|
||||
|
||||
const newActiveProjectId = projectList.value?.[projectList.value?.length - 1]._id.toString();
|
||||
|
||||
if (newActiveProjectId) {
|
||||
await actions.setActiveProject(newActiveProjectId);
|
||||
console.log('Set active project', newActiveProjectId);
|
||||
}
|
||||
|
||||
setPageLayout('dashboard');
|
||||
router.push('/');
|
||||
|
||||
} catch (ex: any) {
|
||||
alert(ex.message);
|
||||
}
|
||||
|
||||
creating.value = false;
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
|
||||
<div class="home relative h-full overflow-y-auto lg:overflow-hidden">
|
||||
|
||||
<div class="absolute w-full h-full z-[8] flex justify-center items-center">
|
||||
<HomeBgGrid :size="120" :spacing="12" opacity="0.2"></HomeBgGrid>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center justify-center pt-[12rem] gap-12 relative z-[10]">
|
||||
|
||||
<div class="text-[3rem] font-semibold text-center text-lyx-lightmode-text dark:text-lyx-text">
|
||||
Create {{ isFirstProject ? '' : 'a new' }} {{ isFirstProject ? 'your first' : '' }} project
|
||||
</div>
|
||||
|
||||
<div v-if="isFirstProject" class="text-[1.5rem]">
|
||||
Welcome to Litlyx. Setup your project in less than 30 seconds.
|
||||
</div>
|
||||
|
||||
<div class="w-[20rem] flex flex-col gap-2">
|
||||
<div class="text-lg text-lyx-lightmode-text-dark dark:text-text-sub font-semibold">
|
||||
{{ isFirstProject ? 'Choose a name' : 'Project name' }}
|
||||
</div>
|
||||
<!-- <CInput placeholder="ProjectName" :readonly="creating" v-model="projectName"></CInput> -->
|
||||
<LyxUiInput class="py-2 px-2" placeholder="Insert" :readonly="creating" v-model="projectName">
|
||||
</LyxUiInput>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
<LyxUiButton type="primary" @click="createProject()" :disabled="projectName.trim().length < 2">
|
||||
Create
|
||||
</LyxUiButton>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
97
dashboard/pages/raw_events.vue
Normal file
97
dashboard/pages/raw_events.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import type { TEvent } from '~/shared/schema/metrics/EventSchema';
|
||||
|
||||
|
||||
definePageMeta({ layout: 'sidebar' });
|
||||
|
||||
const currentPage = ref<number>(1);
|
||||
|
||||
const exporting = ref<boolean>(false);
|
||||
|
||||
const { data: events, status: eventsStatus } = useAuthFetch<{ count: number, data: TEvent[] }>(() => `/api/raw/events?limit=10&page=${currentPage.value}`);
|
||||
|
||||
const { permissions } = useProjectStore();
|
||||
|
||||
function onPageChange(newPage: number) {
|
||||
currentPage.value = newPage;
|
||||
}
|
||||
|
||||
async function exportCsv() {
|
||||
if (exporting.value) return;
|
||||
exporting.value = true;
|
||||
const result = await useAuthFetchSync(`/api/raw/export_events`);
|
||||
const blob = new Blob([result as any], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'ReportEvents.csv';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
exporting.value = false;
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<Unauthorized v-if="permissions?.webAnalytics === false" authorization="webAnalytics">
|
||||
</Unauthorized>
|
||||
|
||||
<div v-if="permissions?.webAnalytics === true && eventsStatus !== 'success'" class="flex justify-center mt-20">
|
||||
<Loader></Loader>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-4 p-4">
|
||||
<div class="flex justify-between">
|
||||
<PageHeader title="Raw Events"/>
|
||||
<Button @click="exportCsv()" class="!w-[7rem]">
|
||||
<Loader v-if="exporting" class="!size-4"></Loader>
|
||||
<span v-else> Export CSV</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead> Domain </TableHead>
|
||||
<TableHead> Name </TableHead>
|
||||
<TableHead> Metadata </TableHead>
|
||||
<TableHead> Date </TableHead>
|
||||
<TableHead> Session </TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="event of events?.data ?? []">
|
||||
<TableCell>{{ event.website }}</TableCell>
|
||||
<TableCell>{{ event.name }}</TableCell>
|
||||
<TableCell>{{ event.metadata }}</TableCell>
|
||||
<TableCell>{{ event.created_at }}</TableCell>
|
||||
<TableCell>{{ event.session }}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
|
||||
<Pagination class="mt-8" v-if="events" @update:page="onPageChange" v-slot="{ page }" :items-per-page="10"
|
||||
:total="events.count" :default-page="currentPage">
|
||||
<PaginationContent v-slot="{ items }">
|
||||
<PaginationPrevious />
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<PaginationItem v-if="item.type === 'page'" :value="item.value"
|
||||
:is-active="item.value === page">
|
||||
{{ item.value }}
|
||||
</PaginationItem>
|
||||
</template>
|
||||
<PaginationEllipsis :index="4" />
|
||||
<PaginationNext />
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
110
dashboard/pages/raw_visits.vue
Normal file
110
dashboard/pages/raw_visits.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import type { TVisit } from '~/shared/schema/metrics/VisitSchema';
|
||||
|
||||
|
||||
definePageMeta({ layout: 'sidebar' });
|
||||
|
||||
const currentPage = ref<number>(1);
|
||||
|
||||
const exporting = ref<boolean>(false);
|
||||
|
||||
const { data: visits, status: visitsStatus } = useAuthFetch<{ count: number, data: TVisit[] }>(() => `/api/raw/visits?limit=10&page=${currentPage.value}`);
|
||||
|
||||
const { permissions } = useProjectStore();
|
||||
|
||||
function onPageChange(newPage: number) {
|
||||
currentPage.value = newPage;
|
||||
}
|
||||
|
||||
async function exportCsv() {
|
||||
if (exporting.value) return;
|
||||
exporting.value = true;
|
||||
const result = await useAuthFetchSync(`/api/raw/export_visits`);
|
||||
const blob = new Blob([result as any], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'ReportVisits.csv';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
exporting.value = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<Unauthorized v-if="permissions?.webAnalytics === false" authorization="webAnalytics">
|
||||
</Unauthorized>
|
||||
|
||||
<div v-if="permissions?.webAnalytics === true && visitsStatus !== 'success'" class="flex justify-center mt-20">
|
||||
<Loader></Loader>
|
||||
</div>
|
||||
|
||||
|
||||
<div v-else class="flex flex-col gap-4 p-4">
|
||||
<div class="flex justify-between">
|
||||
<PageHeader title="Raw Visits"/>
|
||||
<Button @click="exportCsv()" class="!w-[7rem]">
|
||||
<Loader v-if="exporting" class="!size-4"></Loader>
|
||||
<span v-else> Export CSV</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-[15%]"> Domain </TableHead>
|
||||
<TableHead class="w-[15%]"> Page </TableHead>
|
||||
<TableHead class="w-[15%]"> Referrer </TableHead>
|
||||
<TableHead class="w-[5%]"> Browser </TableHead>
|
||||
<TableHead class="w-[5%]"> Os </TableHead>
|
||||
<TableHead class="w-[5%]"> Continent </TableHead>
|
||||
<TableHead class="w-[5%]"> Country </TableHead>
|
||||
<TableHead class="w-[5%]"> Device </TableHead>
|
||||
<TableHead class="w-[15%]"> Date </TableHead>
|
||||
<TableHead class="w-[15%]"> Session </TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="visit of visits?.data ?? []">
|
||||
<TableCell class="w-[15%]">{{ visit.website }}</TableCell>
|
||||
<TableCell class="w-[15%]">{{ visit.page }}</TableCell>
|
||||
<TableCell class="w-[15%]">{{ visit.referrer }}</TableCell>
|
||||
<TableCell class="w-[5%]">{{ visit.browser }}</TableCell>
|
||||
<TableCell class="w-[5%]">{{ visit.os }}</TableCell>
|
||||
<TableCell class="w-[5%]">{{ visit.continent }}</TableCell>
|
||||
<TableCell class="w-[5%]">{{ visit.country }}</TableCell>
|
||||
<TableCell class="w-[5%]">{{ visit.device }}</TableCell>
|
||||
<TableCell class="w-[15%]">{{ visit.created_at }}</TableCell>
|
||||
<TableCell class="w-[15%]">{{ visit.session }}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<Pagination class="mt-8" v-if="visits" @update:page="onPageChange" v-slot="{ page }" :items-per-page="10"
|
||||
:total="visits.count" :default-page="currentPage">
|
||||
<PaginationContent v-slot="{ items }">
|
||||
<PaginationPrevious />
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<PaginationItem v-if="item.type === 'page'" :value="item.value"
|
||||
:is-active="item.value === page">
|
||||
{{ item.value }}
|
||||
</PaginationItem>
|
||||
</template>
|
||||
<PaginationEllipsis :index="4" />
|
||||
<PaginationNext />
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
@@ -1,174 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
definePageMeta({ layout: 'none' });
|
||||
const router = useRouter();
|
||||
|
||||
import { Lit } from 'litlyx-js';
|
||||
const loading = ref<boolean>(false);
|
||||
|
||||
const emailSended = ref<boolean>(false);
|
||||
async function register(event: { email: string, password: string }) {
|
||||
|
||||
const email = ref<string>("");
|
||||
const password = ref<string>("");
|
||||
const passwordConfirm = ref<string>("");
|
||||
loading.value = true;
|
||||
|
||||
const canRegister = computed(() => {
|
||||
if (email.value.length == 0) return false;
|
||||
if (!email.value.includes('@')) return false;
|
||||
if (!email.value.includes('.')) return false;
|
||||
if (password.value !== passwordConfirm.value) return false;
|
||||
if (password.value.length < 6) return false;
|
||||
return true;
|
||||
});
|
||||
await useCatch({
|
||||
toast: true,
|
||||
toastTitle: 'Error during login',
|
||||
async action() {
|
||||
await useAuthFetchSync('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: event,
|
||||
})
|
||||
},
|
||||
onSuccess(_, showToast) {
|
||||
showToast('Registration completed', {
|
||||
description: 'Registration completed, check your inbox to confirm your account',
|
||||
position: 'top-right'
|
||||
});
|
||||
router.push('/');
|
||||
},
|
||||
})
|
||||
|
||||
async function registerAccount() {
|
||||
|
||||
try {
|
||||
const res = await $fetch<any>('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email: email.value, password: password.value })
|
||||
});
|
||||
if (res.error === true) return alert(res.message);
|
||||
|
||||
Lit.event('email_signup');
|
||||
|
||||
emailSended.value = true;
|
||||
} catch (ex) {
|
||||
alert('Something went wrong');
|
||||
}
|
||||
loading.value = false;
|
||||
|
||||
}
|
||||
|
||||
async function oauth(provider: 'google') {
|
||||
location.href = `/api/auth/${provider}/authenticate`;
|
||||
}
|
||||
|
||||
const bgRef = ref<HTMLElement | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
const bg = bgRef.value
|
||||
if (!bg || window.innerWidth < 768) return
|
||||
|
||||
let mouseX = 0
|
||||
let mouseY = 0
|
||||
let currentX = 0
|
||||
let currentY = 0
|
||||
|
||||
const lerp = (start: number, end: number, amt: number) => start + (end - start) * amt
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const x = (e.clientX / window.innerWidth - 0.5) * 2 // range: -1 to 1
|
||||
const y = (e.clientY / window.innerHeight - 0.5) * 2
|
||||
mouseX = x * 20 // max 20px offset
|
||||
mouseY = y * 20
|
||||
}
|
||||
|
||||
const animate = () => {
|
||||
currentX = lerp(currentX, mouseX, 0.1)
|
||||
currentY = lerp(currentY, mouseY, 0.1)
|
||||
|
||||
if (bg) {
|
||||
bg.style.transform = `translate(${currentX}px, ${currentY}px) scale(1.1)`
|
||||
}
|
||||
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
animate()
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
})
|
||||
})
|
||||
|
||||
function getRandomPercent(min: number, max: number): string {
|
||||
return `${Math.random() * (max - min) + min}%`;
|
||||
}
|
||||
|
||||
function getRandomSeconds(min: number, max: number): string {
|
||||
return `${Math.random() * (max - min) + min}s`;
|
||||
}
|
||||
|
||||
const totalStars = 6;
|
||||
|
||||
const stars = ref(
|
||||
Array.from({ length: totalStars }).map(() => ({
|
||||
top: getRandomPercent(20, 70), // da 20% a 70% in verticale
|
||||
left: getRandomPercent(65, 90), // solo nella zona destra
|
||||
delay: getRandomSeconds(0, 8),
|
||||
duration: getRandomSeconds(8, 12)
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="relative flex overflow-hidden min-h-svh flex-col items-center justify-center">
|
||||
<!-- Sfondo dinamico ingrandito -->
|
||||
<div ref="bgRef" class="absolute inset-0 -z-10 w-full h-full overflow-hidden">
|
||||
<img src="/planet.png" alt="bg"
|
||||
class="w-full h-full object-cover object-bottom scale-120 pointer-events-none" />
|
||||
|
||||
<div class="home w-full h-full">
|
||||
|
||||
<div class="flex h-full bg-lyx-lightmode-background dark:bg-lyx-background">
|
||||
|
||||
<div class="flex-1 flex flex-col items-center pt-20 xl:pt-[22vh]">
|
||||
|
||||
<!-- <div class="rotating-thing absolute top-0"></div> -->
|
||||
|
||||
<div class="mb-8 bg-black rounded-xl">
|
||||
<img class="w-[5rem]" :src="'logo.png'">
|
||||
</div>
|
||||
|
||||
<div class="text-lyx-lightmode-text dark:text-lyx-text text-[2.2rem] font-bold poppins">
|
||||
Sign up
|
||||
</div>
|
||||
|
||||
<div class="text-lyx-lightmode-text dark:text-lyx-text/80 text-[1.2rem] font-light text-center w-[70%] poppins mt-2">
|
||||
Track web analytics and custom events
|
||||
with extreme simplicity in under 30 sec.
|
||||
<br>
|
||||
<!-- <div class="font-bold poppins mt-4">
|
||||
Start for Free now! Up to 3k visits/events monthly.
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<div v-if="!emailSended" class="mt-12">
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
<div class="flex flex-col gap-4 z-[100] w-[20vw] min-w-[20rem]">
|
||||
<LyxUiInput class="px-3 py-2" placeholder="Email" v-model="email">
|
||||
</LyxUiInput>
|
||||
<LyxUiInput class="px-3 py-2" placeholder="Password" v-model="password" type="password">
|
||||
</LyxUiInput>
|
||||
<LyxUiInput class="px-3 py-2" placeholder="Confirm password" v-model="passwordConfirm"
|
||||
type="password">
|
||||
</LyxUiInput>
|
||||
</div>
|
||||
|
||||
<div class="text-lyx-text-darker text-end text-[.8rem]">
|
||||
Password must be at least 6 chars long
|
||||
</div>
|
||||
<div class="flex justify-center mt-4 z-[100]">
|
||||
<LyxUiButton :disabled="!canRegister" @click="registerAccount()" class="text-center"
|
||||
type="primary">
|
||||
Sign up
|
||||
</LyxUiButton>
|
||||
</div>
|
||||
|
||||
<RouterLink to="/login"
|
||||
class="mt-4 text-center text-lyx-lightmode-text dark:text-lyx-text-dark underline cursor-pointer z-[100]">
|
||||
Go back to login
|
||||
</RouterLink>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div v-if="emailSended" class="mt-12 flex flex-col text-center text-[1.1rem] z-[100]">
|
||||
<div>
|
||||
We sent you a confirm email.
|
||||
</div>
|
||||
<div>
|
||||
Please check your inbox to confirm your account and complete your registration.
|
||||
</div>
|
||||
<RouterLink tag="div" to="/login"
|
||||
class="mt-6 text-center text-lyx-lightmode-text dark:text-lyx-text-dark underline cursor-pointer">
|
||||
Go back
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div v-if="!emailSended"
|
||||
class="text-[.9rem] poppins mt-5 xl:mt-20 text-lyx-lightmode-text dark:text-lyx-text-dark text-center relative z-[2]">
|
||||
By continuing you are accepting
|
||||
<br>
|
||||
our
|
||||
<a class="underline" href="https://litlyx.com/terms" target="_blank">Terms of Service</a> and
|
||||
<a class="underline" href="https://litlyx.com/privacy" target="_blank">Privacy Policy</a>.
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="grow flex-1 items-center justify-center hidden lg:flex">
|
||||
|
||||
<!-- <GlobeSvg></GlobeSvg> -->
|
||||
|
||||
<img :src="'image-bg.png'" class="h-full py-6">
|
||||
|
||||
</div>
|
||||
<!-- Stelle generate dinamicamente -->
|
||||
<div v-for="(star, index) in stars" :key="index" class="twinkle-star" :style="{
|
||||
top: star.top,
|
||||
left: star.left,
|
||||
animationDelay: star.delay,
|
||||
animationDuration: star.duration
|
||||
}"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- Contenuto sopra -->
|
||||
<div class="flex flex-col items-center justify-center gap-6 p-6 md:p-10 relative z-10">
|
||||
<div
|
||||
class="w-full max-w-sm px-4 py-4 bg-violet-400/20 backdrop-blur-xl rounded-xl border border-violet-400/40 shadow-xl shadow-violet-400/10">
|
||||
<AuthRegisterForm :loading="loading" @submit="register($event)" @oauth="oauth($event)" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="!text-violet-100/80 *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
|
||||
By clicking continue, you agree to our
|
||||
<a href="https://litlyx.com/terms-of-service" target="_blank">Terms of Service</a>
|
||||
and
|
||||
<a href="https://litlyx.com/privacy-policy" target="_blank">Privacy Policy</a>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rotating-thing {
|
||||
height: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
opacity: 0.15;
|
||||
background: radial-gradient(51.24% 31.29% at 50% 50%, rgb(51, 58, 232) 0%, rgba(51, 58, 232, 0) 100%);
|
||||
animation: 12s linear 0s infinite normal none running spin;
|
||||
<style scoped>
|
||||
.twinkle-star {
|
||||
position: absolute;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 6px 1px white;
|
||||
opacity: 0;
|
||||
animation-name: twinkle;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
.google-login {
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
background-color: #fcefed;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 1rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
|
||||
&.disabled {
|
||||
filter: brightness(50%);
|
||||
@keyframes twinkle {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 1.5rem;
|
||||
5% {
|
||||
opacity: 1;
|
||||
transform: scale(1.4);
|
||||
}
|
||||
|
||||
10% {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,93 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
|
||||
async function generatePDF() {
|
||||
|
||||
try {
|
||||
const res = await $fetch<Blob>('/api/project/generate_pdf', {
|
||||
...signHeaders(),
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(res);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `Report.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (ex: any) {
|
||||
alert(ex.message);
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
|
||||
<div class="home w-full h-full px-10 lg:px-0 overflow-y-auto pb-[12rem] md:pb-0">
|
||||
|
||||
<DialogCreateSnapshot></DialogCreateSnapshot>
|
||||
|
||||
<!-- <div class="flex flex-col items-center justify-center mt-20 gap-20">
|
||||
|
||||
<div class="flex flex-col items-center justify-center gap-10">
|
||||
<div class="poppins text-[2.4rem] font-bold text-text">
|
||||
Project Report
|
||||
</div>
|
||||
<div class="poppins text-[1.4rem] text-center lg:text-[1.8rem] text-text-sub/90">
|
||||
One-Click, Comprehensive KPI PDF for Your Investors or Team.
|
||||
</div>
|
||||
<div v-if="activeProject" class="flex md:gap-2 flex-col md:flex-row">
|
||||
<div class="poppins text-[1.4rem] font-semibold text-text-sub/90">
|
||||
Relative to:
|
||||
</div>
|
||||
<div class="poppins text-[1.4rem] font-semibold text-text">
|
||||
{{ activeProject.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
|
||||
<div @click="generatePDF()"
|
||||
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
|
||||
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"> Popular </div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="bg-[#444444cc] p-4 h-[7rem] relative">
|
||||
<div class="poppins text-[1.2rem] font-bold text-text">
|
||||
Generate
|
||||
</div>
|
||||
<div class="poppins text-[1rem] text-text-sub/90">
|
||||
Create your report now
|
||||
</div>
|
||||
<div class="absolute right-4 bottom-3">
|
||||
<i class="fas fa-arrow-right text-[1.2rem]"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
</div> -->
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
289
dashboard/pages/reports.vue
Normal file
289
dashboard/pages/reports.vue
Normal file
@@ -0,0 +1,289 @@
|
||||
<script lang="ts" setup>
|
||||
import { InfoIcon, Upload, Trash2, Lock } from 'lucide-vue-next';
|
||||
import { toast } from 'vue-sonner';
|
||||
|
||||
|
||||
definePageMeta({ layout: 'sidebar' });
|
||||
|
||||
const theme = ref<string>('');
|
||||
const reportType = ref<string>('');
|
||||
const loading = ref<boolean>(false);
|
||||
const currentImageB64 = ref<string>('');
|
||||
|
||||
const pdfUrl = ref<string>('');
|
||||
|
||||
const snapshotStore = useSnapshotStore();
|
||||
const projectStore = useProjectStore();
|
||||
const domainStore = useDomainStore();
|
||||
const premium = usePremiumStore();
|
||||
|
||||
const domain = ref<string>();
|
||||
|
||||
const canGenerate = computed(() => {
|
||||
if (reportType.value === 'custom') return theme.value && reportType.value;
|
||||
if (reportType.value === 'advanced') return domain.value && reportType.value;
|
||||
return reportType.value;
|
||||
})
|
||||
|
||||
|
||||
async function getInputLogo(e: any) {
|
||||
if (!e.target.files) return;
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const b64 = await new Promise<string>(resolve => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = function () {
|
||||
const base64 = reader.result;
|
||||
if (!base64) throw Error('Error reading image');
|
||||
resolve(base64 as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
})
|
||||
|
||||
currentImageB64.value = b64;
|
||||
|
||||
|
||||
}
|
||||
|
||||
async function generateReport() {
|
||||
loading.value = true;
|
||||
|
||||
if (pdfUrl.value !== '') {
|
||||
URL.revokeObjectURL(pdfUrl.value);
|
||||
pdfUrl.value = '';
|
||||
}
|
||||
const res = await useAuthFetchSync<any>(reportType.value === 'advanced' ? `/api/project/generate_pdf_adv?domain=${domain.value}` : `/api/project/generate_pdf?theme=${theme.value}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: {
|
||||
snapshotName: snapshotStore.activeSnapshot?.name ?? 'NO_NAME',
|
||||
customLogo: currentImageB64.value
|
||||
},
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const blob = new Blob([res], { type: 'application/pdf' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
toast.info('Report', { position: 'top-right', description: `Report succesfully generated! (${reportType.value} report)` });
|
||||
|
||||
pdfUrl.value = url
|
||||
loading.value = false;
|
||||
reportType.value = '';
|
||||
theme.value = '';
|
||||
currentImageB64.value = '';
|
||||
}
|
||||
|
||||
async function resetPdfUrl() {
|
||||
URL.revokeObjectURL(pdfUrl.value);
|
||||
pdfUrl.value = '';
|
||||
}
|
||||
|
||||
async function downloadPdf() {
|
||||
const a = document.createElement('a');
|
||||
a.href = pdfUrl.value;
|
||||
a.download = `Litlyx_Report${reportType.value === 'advanced' ? '_Advanced' : ''}.pdf`;
|
||||
a.click();
|
||||
resetPdfUrl()
|
||||
toast.success('Success', { position: 'top-right', description: 'Report succesfully downloaded' });
|
||||
}
|
||||
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const triggerFileInput = () => {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Unauthorized v-if="!projectStore.isOwner" authorization="Guest user limitation Reports">
|
||||
</Unauthorized>
|
||||
<div v-else class="grid grid-cols-1 poppins">
|
||||
<Card v-if="!pdfUrl" class="w-full justify-self-center">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex gap-2">
|
||||
Generate a report
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<InfoIcon class="size-4"></InfoIcon>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
The report follows the current Timeframe
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</CardTitle>
|
||||
<CardDescription>Generate a report of your workspace</CardDescription>
|
||||
<CardAction>
|
||||
<div>
|
||||
<div v-if="!loading" class="flex items-center gap-2">
|
||||
<Button @click="generateReport()" :disabled="!canGenerate">Generate report</Button>
|
||||
</div>
|
||||
<Button v-else disabled>
|
||||
<Loader class="!size-4"></Loader> Generating...
|
||||
</Button>
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent class="flex justify-center lg:justify-start">
|
||||
<div class="gap-4"
|
||||
:class="reportType === 'custom' ? 'grid grid-cols-1 lg:grid-cols-2' : 'flex flex-col'">
|
||||
<div class="flex flex-col gap-4">
|
||||
<Label>Report type </Label>
|
||||
<Select v-model="reportType" :disabled="loading">
|
||||
<SelectTrigger class="w-[20rem]">
|
||||
<SelectValue placeholder="Report types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Types</SelectLabel>
|
||||
<SelectItem value="easy">
|
||||
Easy report
|
||||
</SelectItem>
|
||||
|
||||
|
||||
|
||||
<SelectItem value="locked" disabled as-child
|
||||
v-if="!premium.planInfo?.features.customizable_report">
|
||||
<NuxtLink to="/plans"
|
||||
class="flex items-center gap-2 pl-4 py-2 rounded-md text-violet-200 bg-violet-500 hover:!bg-violet-600/80 dark:bg-violet-500/20 hover:dark:!bg-violet-500/30">
|
||||
<Lock class="size-4 text-yellow-500" /> Custom report
|
||||
</NuxtLink>
|
||||
</SelectItem>
|
||||
<SelectItem value="custom" v-else>
|
||||
Custom Report
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value="locked" disabled as-child
|
||||
v-if="!premium.planInfo?.features.customizable_report">
|
||||
<NuxtLink to="/plans"
|
||||
class="flex items-center gap-2 pl-4 py-2 rounded-md text-violet-200 bg-violet-500 hover:!bg-violet-600/80 dark:bg-violet-500/20 hover:dark:!bg-violet-500/30">
|
||||
<Lock class="size-4 text-yellow-500" /> Advanced report
|
||||
</NuxtLink>
|
||||
</SelectItem>
|
||||
<SelectItem value="advanced" v-else>
|
||||
Advanced Report
|
||||
</SelectItem>
|
||||
|
||||
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div v-if="reportType === 'custom'" class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label>Report variant</Label>
|
||||
<Select v-model="theme" :disabled="loading">
|
||||
<SelectTrigger class="w-[20rem]">
|
||||
<SelectValue placeholder="Report variants" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="black">
|
||||
Black
|
||||
</SelectItem>
|
||||
<SelectItem value="white">
|
||||
White
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<!-- <div class="flex flex-col gap-2">
|
||||
<Label>Custom Logo</Label>
|
||||
<Input @input="getInputLogo" class="w-[20rem]" type="file"></Input>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="reportType === 'custom'">
|
||||
<div class="flex flex-col gap-4">
|
||||
<Label>Your logo</Label>
|
||||
<div
|
||||
class="group bg-muted h-80 w-80 p-4 rounded-lg cursor-pointer hover:bg-muted/80 duration-300">
|
||||
|
||||
<input type="file" ref="fileInput" class="hidden" accept="image/*"
|
||||
@input="getInputLogo" />
|
||||
<div v-if="currentImageB64 === ''" @click="triggerFileInput"
|
||||
class="flex justify-center items-center border-4 border-muted-foreground border-dashed h-full w-full rounded-sm group-hover:animate-pulse">
|
||||
<Upload
|
||||
class="size-8 group-hover:size-9 transition-all duration-300 text-muted-foreground" />
|
||||
</div>
|
||||
<div v-else class="relative">
|
||||
<Button :disabled="loading" class="absolute top-0 right-0"
|
||||
@click="currentImageB64 = ''" variant="ghost" size="icon">
|
||||
<Trash2 class="size-4" />
|
||||
</Button>
|
||||
<img :src="currentImageB64" alt="Preview"
|
||||
class="h-full w-full object-cover rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div v-if="reportType === 'advanced'">
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<Label>Select domain</Label>
|
||||
|
||||
<Select v-model="domain">
|
||||
<SelectTrigger class="w-[20rem]">
|
||||
<SelectValue placeholder="Domain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="domain of domainStore.domains.slice(1)" :value="domain._id">
|
||||
{{ domain.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
||||
<Label>Your logo</Label>
|
||||
<div
|
||||
class="group bg-muted h-80 w-80 p-4 rounded-lg cursor-pointer hover:bg-muted/80 duration-300">
|
||||
|
||||
<input type="file" ref="fileInput" class="hidden" accept="image/*"
|
||||
@input="getInputLogo" />
|
||||
<div v-if="currentImageB64 === ''" @click="triggerFileInput"
|
||||
class="flex justify-center items-center border-4 border-muted-foreground border-dashed h-full w-full rounded-sm group-hover:animate-pulse">
|
||||
<Upload
|
||||
class="size-8 group-hover:size-9 transition-all duration-300 text-muted-foreground" />
|
||||
</div>
|
||||
<div v-else class="relative">
|
||||
<Button :disabled="loading" class="absolute top-0 right-0"
|
||||
@click="currentImageB64 = ''" variant="ghost" size="icon">
|
||||
<Trash2 class="size-4" />
|
||||
</Button>
|
||||
<img :src="currentImageB64" alt="Preview"
|
||||
class="h-full w-full object-cover rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card v-if="pdfUrl">
|
||||
<CardHeader class="flex justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<CardTitle>
|
||||
Report Preview
|
||||
</CardTitle>
|
||||
<CardDescription>Your report is now ready</CardDescription>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<Button variant="outline" @click="resetPdfUrl()">New Report</Button>
|
||||
<Button @click="downloadPdf">Download</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<iframe :src="pdfUrl" type="application/pdf" class="w-full h-[50rem]" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
93
dashboard/pages/reset_password.vue
Normal file
93
dashboard/pages/reset_password.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const password = ref<string>();
|
||||
const password_confirm = ref<string>();
|
||||
|
||||
const canReset = computed(() => {
|
||||
if (!password.value) return false;
|
||||
if (password.value.length < 6) return false;
|
||||
if (password.value.length > 64) return false;
|
||||
if (password.value != password_confirm.value) return false;
|
||||
return true;
|
||||
})
|
||||
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const data = computed(() => {
|
||||
if (!route.query.code) return;
|
||||
if (!route.query.mail) return;
|
||||
|
||||
return {
|
||||
email: atob(route.query.mail as string),
|
||||
code: route.query.code as string
|
||||
};
|
||||
})
|
||||
|
||||
async function setNewPassword() {
|
||||
await useCatch({
|
||||
toast: true,
|
||||
toastTitle: 'Error during request',
|
||||
async action() {
|
||||
await useAuthFetchSync('/api/user/set_new_password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: { email: data.value?.email, jwt: data.value?.code, password: password.value }
|
||||
});
|
||||
password.value = '';
|
||||
password_confirm.value = '';
|
||||
},
|
||||
onSuccess(_, showToast) {
|
||||
showToast('Success', { description: 'New password has been set.' })
|
||||
router.push('/login');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-center h-dvh items-center">
|
||||
<div v-if="data" class='flex flex-col gap-6'>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="flex items-center gap-2 font-medium">
|
||||
<img :src="'logo-white.svg'" class="h-16">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="text-lg">Create new password</Label>
|
||||
<span class="text-muted-foreground">
|
||||
Please create a new password for your account <strong>{{ data.email }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label>Password</Label>
|
||||
<InputPassword v-model="password" />
|
||||
<div class="my-2"></div>
|
||||
<Label>Confirm Password</Label>
|
||||
<InputPassword v-model="password_confirm" />
|
||||
<Button @click="setNewPassword()" :disabled="!canReset" class="mt-4">
|
||||
Set new password
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div
|
||||
class="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
|
||||
By clicking continue, you agree to our <a href="https://litlyx.com/terms-of-service" target="_blank"> Terms of
|
||||
Service</a>
|
||||
and <a href="https://litlyx.com/privacy-policy" target="_blank">Privacy Policy</a>.
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex flex-col items-center gap-4">
|
||||
<img :src="'logo-white.svg'" class="h-16">
|
||||
|
||||
<span>Something went wrong, contact us on <strong>help@litlyx.com</strong>.</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,102 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
|
||||
const reportList = useFetch(`/api/security/list`, { headers: useComputedHeaders({ useSnapshotDates: false }) });
|
||||
|
||||
const { createAlert } = useAlert();
|
||||
|
||||
const rows = computed(() => reportList.data.value || [])
|
||||
|
||||
const columns = [
|
||||
{ key: 'scan', label: 'Scan date' },
|
||||
{ key: 'type', label: 'Type' },
|
||||
{ key: 'data', label: 'Data' },
|
||||
];
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
|
||||
<div class="home w-full h-full px-10 pt-6 overflow-y-auto">
|
||||
|
||||
<!-- <div class="flex gap-2 items-center text-lyx-lightmode-text dark:text-text/90 justify-end">
|
||||
<div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
|
||||
<div class="poppins font-regular text-[1rem]"> AI Anomaly Detector </div>
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-info-circle text-[.9rem] hover:text-lyx-primary cursor-pointer"
|
||||
@click="showAnomalyInfoAlert"></i>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div class="pb-[10rem]">
|
||||
<UTable :rows="rows" :columns="columns">
|
||||
|
||||
|
||||
<template #scan-data="{ row }">
|
||||
<div class="text-lyx-lightmode-text dark:text-lyx-text-dark">
|
||||
{{ new Date(row.data.created_at).toLocaleString() }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #type-data="{ row }">
|
||||
<UBadge color="white" class="w-[4rem] flex justify-center">
|
||||
{{ row.type }}
|
||||
</UBadge>
|
||||
</template>
|
||||
|
||||
<template #data-data="{ row }">
|
||||
<div class="text-lyx-lightmode-text dark:text-lyx-text-dark">
|
||||
<div v-if="row.type === 'domain'">
|
||||
{{ row.data.domain }}
|
||||
</div>
|
||||
<div v-if="row.type === 'visit'">
|
||||
{{ row.data.visit }}
|
||||
</div>
|
||||
<div v-if="row.type === 'event'">
|
||||
{{ row.data.event }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- <template #actions-data="{ row }">
|
||||
<UDropdown :items="items(row)">
|
||||
<UButton color="gray" variant="ghost" icon="i-heroicons-ellipsis-horizontal-20-solid" />
|
||||
</UDropdown>
|
||||
</template> -->
|
||||
|
||||
</UTable>
|
||||
</div>
|
||||
|
||||
<!-- <div class="w-full py-8 px-12 pb-[10rem]">
|
||||
<div v-if="reportList.data.value" class="flex flex-col gap-2">
|
||||
<div v-for="entry of reportList.data.value" class="flex flex-col gap-4">
|
||||
<div v-if="entry.type === 'event'" class="flex gap-2 flex-col lg:flex-row items-center lg:items-start">
|
||||
<div class="text-lyx-text-darker">{{ new Date(entry.data.created_at).toLocaleString() }}</div>
|
||||
<UBadge class="w-[4rem] flex justify-center"> {{ entry.type }} </UBadge>
|
||||
<div class="text-lyx-text-dark">
|
||||
Event date: {{ new Date(entry.data.eventDate).toLocaleString() }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="entry.type === 'visit'" class="flex gap-2 flex-col lg:flex-row items-center lg:items-start">
|
||||
<div class="text-lyx-text-darker">{{ new Date(entry.data.created_at).toLocaleString() }}</div>
|
||||
<UBadge class="w-[4rem] flex justify-center"> {{ entry.type }} </UBadge>
|
||||
<div class="text-lyx-text-dark">
|
||||
Visit date: {{ new Date(entry.data.visitDate).toLocaleString() }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="entry.type === 'domain'" class="flex gap-2 flex-col py-2 lg:flex-row items-center lg:items-start">
|
||||
<div class="text-lyx-text-darker">{{ new Date(entry.data.created_at).toLocaleString() }}</div>
|
||||
<UBadge class="w-[4rem] flex justify-center"> {{ entry.type }} </UBadge>
|
||||
<div class="text-lyx-text-dark">
|
||||
{{ entry.data.domain }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
@@ -1,49 +1,50 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
definePageMeta({ layout: 'dashboard' });
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
|
||||
const selfhosted = useSelfhosted();
|
||||
definePageMeta({ layout: 'sidebar' });
|
||||
|
||||
const items = [
|
||||
{ label: 'General', slot: 'general', tab: 'general' },
|
||||
{ label: 'Domains', slot: 'domains', tab: 'domains' },
|
||||
{ label: 'Billing', slot: 'billing', tab: 'billing' },
|
||||
{ label: 'Codes', slot: 'codes', tab: 'codes' },
|
||||
{ label: 'Account', slot: 'account', tab: 'account' }
|
||||
]
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const activeTab = ref<string>(route.query.tab?.toString() ?? 'general');
|
||||
router.push({ query: { tab: activeTab.value } });
|
||||
|
||||
watch(activeTab, () => {
|
||||
router.push({ query: { tab: activeTab.value } });
|
||||
})
|
||||
|
||||
const projectStore = useProjectStore();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lg:px-10 h-full lg:py-8 overflow-hidden hide-scrollbars">
|
||||
<Unauthorized v-if="!projectStore.isOwner" authorization="Guest user limitation Workspace Settings">
|
||||
</Unauthorized>
|
||||
<div v-else class="poppins">
|
||||
<PageHeader title="Settings"
|
||||
description="Manage domains, preferences and controls to customize this workspace." />
|
||||
|
||||
<Tabs v-model="activeTab" class="w-full mt-4">
|
||||
|
||||
<div class="poppins font-semibold text-[1.3rem] lg:px-0 px-4 lg:py-0 py-4"> Settings </div>
|
||||
<TabsList class="w-full mb-4">
|
||||
<TabsTrigger value="general">
|
||||
General
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="domains">
|
||||
Domains
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<CustomTab :items="items" :route="true" class="mt-8">
|
||||
<template #general>
|
||||
<SettingsGeneral :key="refreshKey"></SettingsGeneral>
|
||||
</template>
|
||||
<template #domains>
|
||||
<SettingsData :key="refreshKey"></SettingsData>
|
||||
</template>
|
||||
<template #billing>
|
||||
<SettingsBilling v-if="!selfhosted" :key="refreshKey"></SettingsBilling>
|
||||
<div class="flex popping text-[1.2rem] font-semibold justify-center mt-[20vh] text-lyx-lightmode-text dark:text-lyx-text"
|
||||
v-if="selfhosted">
|
||||
Billing disabled in self-host mode
|
||||
</div>
|
||||
</template>
|
||||
<template #codes>
|
||||
<SettingsCodes v-if="!selfhosted" :key="refreshKey"></SettingsCodes>
|
||||
<div class="flex popping text-[1.2rem] font-semibold justify-center mt-[20vh] text-lyx-lightmode-text dark:text-lyx-text"
|
||||
v-if="selfhosted">
|
||||
Codes disabled in self-host mode
|
||||
</div>
|
||||
</template>
|
||||
<template #account>
|
||||
<SettingsAccount :key="refreshKey"></SettingsAccount>
|
||||
</template>
|
||||
</CustomTab>
|
||||
<TabsContent value="general">
|
||||
<LazySettingsGeneral></LazySettingsGeneral>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="domains">
|
||||
<LazySettingsDomains></LazySettingsDomains>
|
||||
</TabsContent>
|
||||
|
||||
</Tabs>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
216
dashboard/pages/shareable_links.vue
Normal file
216
dashboard/pages/shareable_links.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<script setup lang="ts">
|
||||
import { DialogDangerGeneric } from '#components';
|
||||
import { Copy, Trash, Lock } from 'lucide-vue-next';
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
definePageMeta({ layout: 'sidebar' });
|
||||
|
||||
const projectStore = useProjectStore();
|
||||
const premiumStore = usePremiumStore();
|
||||
|
||||
const { data: links, refresh: linksRefresh } = useAuthFetch('/api/share/list');
|
||||
const { data: domains } = useAuthFetch('/api/domains/list')
|
||||
|
||||
const { open } = useDialog()
|
||||
|
||||
|
||||
const hostname = computed(() => {
|
||||
if (location.protocol === 'https') {
|
||||
return `${location.protocol}//${location.hostname}`;
|
||||
} else {
|
||||
return `${location.protocol}//${location.hostname}:${location.port}`;
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
function copyLink(link: string) {
|
||||
if (!navigator.clipboard) return alert('Error with clipboard.');
|
||||
navigator.clipboard.writeText(link);
|
||||
toast('Link copied', { description: 'The link is now on your clipboard', position: 'top-right' });
|
||||
}
|
||||
|
||||
function deleteLink(id: string) {
|
||||
open({
|
||||
body: DialogDangerGeneric,
|
||||
title: 'Are you sure ?',
|
||||
async onSuccess(data, close) {
|
||||
await useAuthFetchSync(`/api/share/delete?id=${data.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
close();
|
||||
linksRefresh();
|
||||
toast('Link deleted', { description: 'Link deleted successfully', position: 'top-right' });
|
||||
},
|
||||
props: {
|
||||
label: 'Are you sure to delete the link ?',
|
||||
metadata: {
|
||||
id
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const linkPassword = ref<string>("");
|
||||
const linkDomain = ref<string>("*");
|
||||
const linkDesc = ref<string>("");
|
||||
const linkPublic = ref<boolean>(true);
|
||||
const creating = ref<boolean>(false);
|
||||
|
||||
const canCreate = computed(() => {
|
||||
if (linkDesc.value.length > 250) return false;
|
||||
if (linkPublic.value === false && linkPassword.value.length < 3) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
async function createLink() {
|
||||
creating.value = true;
|
||||
await useCatch({
|
||||
async action() {
|
||||
await useAuthFetchSync('/api/share/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: { isPublic: linkPublic.value, password: linkPassword.value, description: linkDesc.value, domain: linkDomain.value }
|
||||
});
|
||||
},
|
||||
toast: true,
|
||||
toastTitle: 'Error creating link',
|
||||
onSuccess(_, showToast) {
|
||||
linkPassword.value = '';
|
||||
linkDomain.value = '*';
|
||||
linkDesc.value = '';
|
||||
linkPublic.value = true;
|
||||
showToast('Link created successfully', {});
|
||||
linksRefresh();
|
||||
},
|
||||
});
|
||||
creating.value = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Unauthorized v-if="projectStore.isActiveProjectGuest" authorization="OWNER">
|
||||
</Unauthorized>
|
||||
|
||||
<div v-else class="flex flex-col gap-2 poppins">
|
||||
|
||||
<Card v-if="links">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Shared links
|
||||
<span class="text-[10px] text-muted-foreground">
|
||||
{{ links.length }}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage all shared links within your workspace
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
|
||||
<!-- -->
|
||||
<div class="flex justify-center">
|
||||
<div v-if="domains" class="p-4 flex flex-col gap-3 w-[20rem]">
|
||||
<Label> Create new shareable link </Label>
|
||||
<Select v-model="linkDomain">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue class="w-full" placeholder="Select a domain">
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="domain of domains" :value="domain._id">
|
||||
{{ domain.name }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-if="![0, 7006, 8001, 8002, 8003, 8004].includes(premiumStore.planInfo?.ID ?? -1)"
|
||||
class="flex items-center gap-2">
|
||||
<Switch v-model="linkPublic"></Switch>
|
||||
<Label> {{ linkPublic ? 'Public' : 'Protected' }} link</Label>
|
||||
</div>
|
||||
<div v-else >
|
||||
<Tooltip>
|
||||
<TooltipTrigger class="flex items-center gap-2">
|
||||
<Switch :model-value="true" disabled></Switch>
|
||||
<Label> Public
|
||||
<Lock class="size-4 text-yellow-500" />
|
||||
</Label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Pro feature</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
</div>
|
||||
<Label v-if="!linkPublic"> Password </Label>
|
||||
<CustomPasswordInput v-if="!linkPublic" v-model="linkPassword">
|
||||
</CustomPasswordInput>
|
||||
<label>Description</label>
|
||||
<Input v-model="linkDesc" placeholder="Description"></Input>
|
||||
<Button @click="createLink()" v-if="!creating" :disabled="!canCreate">
|
||||
Create link </Button>
|
||||
<Button v-else disabled>
|
||||
<Loader class="!size-4"></Loader>
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<Loader></Loader>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- -->
|
||||
|
||||
<div class="my-15"></div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-[25%]"> Domain </TableHead>
|
||||
<TableHead class="w-[15%]"> Password </TableHead>
|
||||
<TableHead class="w-[25%]"> Link </TableHead>
|
||||
<TableHead class="w-[30%]"> Description </TableHead>
|
||||
<TableHead class="w-[5%]"> Actions </TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="link of links" class="h-[2rem]">
|
||||
<TableCell class="max-w-[20dvw] overflow-x-auto">
|
||||
{{ "*".includes(link.domain) ? "All Domains" : link.domain }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CustomPasswordInput v-if="link.password" readonly :model-value="link.password">
|
||||
</CustomPasswordInput>
|
||||
<div v-else class="ml-1"> No password </div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="flex items-center gap-2 relative w-[95%]">
|
||||
<Input class="pr-8" readonly
|
||||
:model-value="`${hostname}/shared/${link.link}`"></Input>
|
||||
<Copy @click="copyLink(`${hostname}/shared/${link.link}`)"
|
||||
class="size-4 absolute right-2 cursor-pointer"></Copy>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="max-w-[20dvw] overflow-x-auto">
|
||||
{{ link.description ?? 'No description' }}
|
||||
</TableCell>
|
||||
<TableCell class="flex justify-end">
|
||||
<Button @click="deleteLink(link._id.toString())" variant="destructive" size="icon">
|
||||
<Trash class="size-4" />
|
||||
</Button>
|
||||
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div v-else>
|
||||
<Loader></Loader>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
184
dashboard/pages/shared/[linkid].vue
Normal file
184
dashboard/pages/shared/[linkid].vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<script lang="ts" setup>
|
||||
import LineDataNew from '~/components/complex/LineDataNew.vue';
|
||||
import { CalendarIcon, MoonIcon, SunIcon, Flame } from 'lucide-vue-next'
|
||||
import { CalendarDate, DateFormatter, getLocalTimeZone } from '@internationalized/date'
|
||||
import ActionableChart from '~/components/complex/ActionableChart.vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
|
||||
const route = useRoute();
|
||||
const sharedLinkId = route.params.linkid;
|
||||
const { sharedLink, timeValue, sharedPassword, needPassword } = useShared();
|
||||
const df = new DateFormatter('en-US', { dateStyle: 'medium' })
|
||||
const popoverOpen = ref<boolean>(false);
|
||||
|
||||
|
||||
const { data: live_users, status: live_users_status, refresh: live_users_refresh } = useAuthFetch(`/api/share/live_users?linkId=${sharedLinkId}`);
|
||||
|
||||
let interval: any;
|
||||
|
||||
onMounted(async () => {
|
||||
const info = await useAuthFetchSync(`/api/share/info?linkId=${sharedLinkId}`);
|
||||
needPassword.value = info.hasPassword;
|
||||
sharedLink.value = sharedLinkId.toString();
|
||||
interval = setInterval(() => {
|
||||
live_users_refresh();
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (interval) clearInterval(interval)
|
||||
})
|
||||
|
||||
|
||||
function setDays(days: number) {
|
||||
const start = new Date(Date.now() - 1000 * 60 * 60 * 24 * days);
|
||||
const end = new Date();
|
||||
timeValue.value.start = new CalendarDate(start.getUTCFullYear(), start.getUTCMonth() + 1, start.getUTCDate());
|
||||
timeValue.value.end = new CalendarDate(end.getUTCFullYear(), end.getUTCMonth() + 1, end.getUTCDate());
|
||||
}
|
||||
|
||||
const currentPassword = ref<string>("");
|
||||
|
||||
async function reloadData() {
|
||||
const ok = await useAuthFetchSync(`/api/share/verify?linkId=${sharedLinkId}&password=${currentPassword.value}`);
|
||||
if (ok === true) {
|
||||
sharedPassword.value = currentPassword.value;
|
||||
} else {
|
||||
currentPassword.value = '';
|
||||
toast('Error', { description: 'Password wrong', position: 'top-right' });
|
||||
}
|
||||
}
|
||||
|
||||
const showDashboard = computed(() => {
|
||||
if (!sharedLink.value) return false;
|
||||
if (needPassword.value && sharedPassword.value) return true;
|
||||
if (!needPassword.value) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
const colorMode = useColorMode()
|
||||
const isDark = computed({
|
||||
get() {
|
||||
return colorMode.value === 'dark'
|
||||
},
|
||||
set() {
|
||||
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="h-dvh w-full overflow-hidden flex flex-col poppins bg-gray-100 dark:bg-background">
|
||||
<div v-if="showDashboard" class="h-dvh w-full overflow-hidden flex flex-col poppins">
|
||||
<div class="w-full flex justify-between pl-4 pr-6 py-4 bg-sidebar border-solid border-b-1">
|
||||
<div class="flex gap-4">
|
||||
<Popover v-model:open="popoverOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline">
|
||||
<CalendarIcon class="mr-2 h-4 w-4" />
|
||||
<template v-if="timeValue.start">
|
||||
<template v-if="timeValue.end">
|
||||
{{ df.format(timeValue.start.toDate(getLocalTimeZone())) }} - {{
|
||||
df.format(timeValue.end.toDate(getLocalTimeZone())) }}
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
{{ df.format(timeValue.start.toDate(getLocalTimeZone())) }}
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
Pick a date
|
||||
</template>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-4 flex flex-col items-end relative z-[90]">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<Button @click="setDays(1)"> Today </Button>
|
||||
<Button @click="setDays(7)"> Last week </Button>
|
||||
<Button @click="setDays(30)"> Last 30 days </Button>
|
||||
<Button @click="setDays(60)"> Last 60 days </Button>
|
||||
<Button @click="setDays(90)"> Last 90 days </Button>
|
||||
</div>
|
||||
<RangeCalendar v-model="timeValue" initial-focus :number-of-months="1"
|
||||
@update:start-value="(startDate) => timeValue.start = startDate" />
|
||||
</div>
|
||||
<Button @click="popoverOpen = false;"> Confirm </Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div class="flex items-center gap-1 poppins bg-border/20 py-1 px-2 rounded-md">
|
||||
<div class="size-2 bg-green-500 rounded-full animate-pulse mr-1"></div>
|
||||
<div v-if="live_users != undefined && live_users_status === 'success'">{{ live_users }}</div>
|
||||
<Loader v-else class="!size-4"></Loader>
|
||||
<div> live </div>
|
||||
</div>
|
||||
</div>
|
||||
<Button @click="isDark = !isDark" variant="outline">
|
||||
<SunIcon v-if="isDark"></SunIcon>
|
||||
<MoonIcon v-else />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="p-4 flex flex-col gap-4 grow poppins overflow-y-auto h-full">
|
||||
<Accordion type="single" collapsible class="relative lg:hidden border rounded-xl px-5 bg-card">
|
||||
<AccordionItem value=" top-cards">
|
||||
<AccordionTrigger class="text-md">
|
||||
Top Charts
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<DashboardTopCards class="grid grid-cols-2" />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<div class="hidden lg:block">
|
||||
<DashboardTopCards />
|
||||
</div>
|
||||
|
||||
|
||||
<ActionableChart></ActionableChart>
|
||||
|
||||
|
||||
<div class="flex w-full justify-center">
|
||||
<div class="flex w-full gap-4 flex-col xl:flex-row">
|
||||
<LineDataNew :shared-link="sharedLink.toString()" class="flex-1" type="referrers" select />
|
||||
<LineDataNew :shared-link="sharedLink.toString()" class="flex-1" type="pages" select />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full justify-center">
|
||||
<div class="flex w-full gap-4 flex-col xl:flex-row">
|
||||
<LineDataNew :shared-link="sharedLink.toString()" class="flex-1" type="countries" select />
|
||||
<LineDataNew :shared-link="sharedLink.toString()" class="flex-1" type="devices" select />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div
|
||||
class="bg-[url('/planet.png')] bg-center bg-cover rounded-xl min-h-40 lg:min-h-60 flex items-center justify-evenly px-6 lg:px-12">
|
||||
<div class="flex flex-col gap-2 ">
|
||||
<span class="text-lg lg:text-5xl font-semibold text-white">
|
||||
Want these metrics for your website?
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<NuxtLink to="https://dashboard.litlyx.com/register" target="_blank">
|
||||
<Button size="lg" class="bg-white text-black hover:bg-gray-200 transition p-8 text-lg">
|
||||
Try for free
|
||||
</Button>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="p-8 flex items-center justify-center h-full flex-col gap-4">
|
||||
<label>Enter Password</label>
|
||||
<CustomPasswordInput class="w-[60dvw] lg:w-[32dvw]" v-model="currentPassword"></CustomPasswordInput>
|
||||
<Button class="w-[60dvw] lg:w-[32dvw]" size="lg" @click="reloadData()"> Enter </Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
45
dashboard/pages/shields.vue
Normal file
45
dashboard/pages/shields.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
definePageMeta({ layout: 'sidebar' });
|
||||
|
||||
const activeTab = ref<any>('domains');
|
||||
const projectStore = useProjectStore();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Unauthorized v-if="!projectStore.isOwner" authorization="Guest user limitation Shields">
|
||||
</Unauthorized>
|
||||
<div v-else class=" poppins">
|
||||
<PageHeader title="Shields"
|
||||
description="Exclude specific domains, internal IP addresses, and bot traffic (including crawlers) to keep your analytics focused on real users."/>
|
||||
<Tabs v-model="activeTab" class="w-full mt-4">
|
||||
|
||||
<TabsList class="w-full mb-4">
|
||||
<TabsTrigger value="domains">
|
||||
Domains
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="ips">
|
||||
IP Addresses
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="bot">
|
||||
Bot traffic
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="domains">
|
||||
<LazyShieldsDomains></LazyShieldsDomains>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ips">
|
||||
<LazyShieldsAddresses></LazyShieldsAddresses>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="bot">
|
||||
<LazyShieldsBots></LazyShieldsBots>
|
||||
</TabsContent>
|
||||
|
||||
</Tabs>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
|
||||
|
||||
<div>
|
||||
|
||||
TEST
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const { data: links } = useFetch('/api/project/links/list', {
|
||||
headers: useComputedHeaders()
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-for="link of links">
|
||||
{{ link }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
248
dashboard/pages/workspaces.vue
Normal file
248
dashboard/pages/workspaces.vue
Normal file
@@ -0,0 +1,248 @@
|
||||
<script lang="ts" setup>
|
||||
import { EllipsisVertical, LockIcon, LogOut, MoonIcon, SunIcon, Settings, Search, Plus, Crown, Sparkles, ChartSpline, ChartColumnIncreasing } from 'lucide-vue-next';
|
||||
import dateServiceInstance from '~/shared/services/DateService';
|
||||
|
||||
|
||||
const colorMode = useColorMode()
|
||||
const isDark = computed({
|
||||
get() {
|
||||
return colorMode.value === 'dark'
|
||||
},
|
||||
set() {
|
||||
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const projects = useProjectStore();
|
||||
const premium = usePremiumStore();
|
||||
|
||||
const projectStats = ref<Record<string, any>>({});
|
||||
|
||||
const { user, clear } = useUserSession();
|
||||
|
||||
|
||||
const searchInput = ref('')
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
// Se la ricerca è vuota, mostra tutto
|
||||
if (!searchInput.value.trim()) {
|
||||
return projects.projects
|
||||
}
|
||||
|
||||
// Altrimenti filtra
|
||||
return projects.projects.filter(item =>
|
||||
item.name.toLowerCase().includes(searchInput.value.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
for (const project of projects.projects) {
|
||||
useAuthFetchSync(`/api/project/stats?pid=${project._id.toString()}`).then(data => {
|
||||
const parsed = data;
|
||||
parsed.chart.labels = parsed.chart.labels.map((e: any) => dateServiceInstance.getChartLabelFromISO(e, 'hour'));
|
||||
projectStats.value[project._id.toString()] = data;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function logout() {
|
||||
await clear();
|
||||
router.push('/login');
|
||||
}
|
||||
|
||||
const feedbackText = ref<string>('');
|
||||
const feedbackOpen = ref<boolean>(false);
|
||||
function sendFeedback() {
|
||||
useCatch({
|
||||
toast: true,
|
||||
toastTitle: 'Error sending feedback',
|
||||
async action() {
|
||||
await useAuthFetchSync('/api/feedback/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: { text: feedbackText.value }
|
||||
});
|
||||
},
|
||||
onSuccess(_, showToast) {
|
||||
feedbackOpen.value = false;
|
||||
feedbackText.value = '';
|
||||
showToast('Feedback sent', { description: 'Feedback sent successfully', position: 'top-right' });
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full h-dvh flex flex-col p-8 gap-4 overflow-auto poppins bg-gray-100 dark:bg-black">
|
||||
<div class="flex w-full flex-col items-center gap-4 md:flex-row md:justify-between poppins mb-8 ">
|
||||
<img class="h-[5dvh]" :src="isDark ? 'logo-white.svg' : 'logo-black.svg'">
|
||||
<div>
|
||||
<div class="flex gap-2">
|
||||
<Button @click="isDark = !isDark" variant="outline" >
|
||||
<SunIcon v-if="isDark"></SunIcon>
|
||||
<MoonIcon v-else/>
|
||||
</Button>
|
||||
|
||||
<Popover v-model:open="feedbackOpen">
|
||||
<PopoverTrigger>
|
||||
<Button variant="outline"> Feedback </Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Label> Share everything with us. </Label>
|
||||
<Textarea v-model="feedbackText" placeholder="Leave your feedback here"
|
||||
class="resize-none h-24"></Textarea>
|
||||
<Button @click="sendFeedback()"> Send </Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline">
|
||||
Account
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent :side-offset="10" class="w-56">
|
||||
<DropdownMenuLabel class="truncate px-2">
|
||||
{{ user?.email }}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem @click="logout()">
|
||||
<LogOut></LogOut>
|
||||
<span> Log out </span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-[16px] font-semibold lg:text-lg">Workspaces <span
|
||||
class="text-[10px] text-muted-foreground">({{`${projects.projects.filter(e =>
|
||||
!e.guest).length}/${premium.planInfo?.features.workspaces === 999 ? 'Unlimited' :
|
||||
(premium.planInfo?.features.workspaces ?? 0)}` }})</span></h1>
|
||||
<p class="text-gray-500 text-sm lg:text-md dark:text-gray-400"> Here's a list of all your workspaces. </p>
|
||||
<Separator />
|
||||
<div class="flex justify-between items-center gap-4">
|
||||
<div class="relative">
|
||||
<span class="absolute top-1/2 left-2 -translate-y-1/2">
|
||||
<Search class="size-5" />
|
||||
</span>
|
||||
<Input placeholder="Search Workspace" class="bg-white dark:bg-black pl-10 pr-4 h-10"
|
||||
v-model="searchInput" />
|
||||
</div>
|
||||
<ProButton title="Add Workspace" link="/create_project"
|
||||
:locked="projects.projects.length >= (premium.planInfo?.features.workspaces ?? 0)">
|
||||
<Plus />
|
||||
</ProButton>
|
||||
|
||||
</div>
|
||||
<div v-if="filteredItems.length === 0">
|
||||
<PageHeader title="No workspaces found"
|
||||
:description="`Seems like the workspace you're looking for doesn't exist, try using another name`" />
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
|
||||
<Card v-for="work of filteredItems" :key="work._id.toString()" class="cursor-pointer"
|
||||
@click="() => { projects.setActive(work._id.toString()); router.push('/'); }"
|
||||
:class="{ 'ring-1 ring-violet-500/50 !bg-violet-500/10': work._id === projects.activeProject?._id }">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ work.name }}</CardTitle>
|
||||
<CardAction class="flex flex-row gap-2">
|
||||
<Badge variant="outline" v-if="work.guest">
|
||||
Guest
|
||||
</Badge>
|
||||
<Badge class="border-violet-500/50 bg-violet-400 rounded-sm"
|
||||
v-if="work._id === projects.activeProject?._id">Actual</Badge>
|
||||
<DropdownMenu >
|
||||
<DropdownMenuTrigger @click.stop>
|
||||
<EllipsisVertical class="size-4"/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="w-40">
|
||||
<DropdownMenuLabel>{{work.name}}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="projects.setActive(work._id.toString())" :disabled="work._id === projects.activeProject?._id">{{work._id === projects.activeProject?._id?'Actual':'Set Active'}}</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="()=>{projects.setActive(work._id.toString());navigateTo('/settings')}" v-if="!work.guest">Settings</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div v-if="!work.guest && projectStats[work._id.toString()]">
|
||||
<WorkspacesVisitsChart
|
||||
class="h-20 pr-2 w-full border rounded-md flex items-center justify-center"
|
||||
:data="projectStats[work._id.toString()].chart"></WorkspacesVisitsChart>
|
||||
</div>
|
||||
<div v-if="!work.guest && !projectStats[work._id.toString()]"
|
||||
class="h-20 w-full border rounded-md flex items-center justify-center">
|
||||
<Loader></Loader>
|
||||
</div>
|
||||
<div v-if="work.guest">
|
||||
<div class="h-20 w-full border rounded-md flex items-center justify-center">
|
||||
GUEST PROJECT
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<p class="text-sm text-muted-foreground pt-2">Last 24 hours chart</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
<!-- Sparkles,ChartSpline,ChartColumnIncreasing -->
|
||||
<!-- <CardFooter class="flex justify-between gap-4">
|
||||
<div v-if="work.guest" class="flex flex-row gap-4">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<ChartSpline class="size-4"
|
||||
:class="{ 'text-yellow-500': projects.permissions?.webAnalytics }" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span>Web Analytics <strong>{{ projects.permissions?.webAnalytics ? 'Active' :
|
||||
'Disabled'
|
||||
}}</strong></span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<ChartColumnIncreasing class="size-4"
|
||||
:class="{ 'text-yellow-500': projects.permissions?.events }" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span>Web Analytics <strong>{{ projects.permissions?.events ? 'Active' : 'Disabled'
|
||||
}}</strong></span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Sparkles class="size-4" :class="{ 'text-yellow-500': projects.permissions?.ai }" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span>
|
||||
AI Assistant
|
||||
<strong>{{ projects.permissions?.ai ? 'Active' : 'Disabled' }}
|
||||
</strong>
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
</CardFooter> -->
|
||||
</Card>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</template>
|
||||
Reference in New Issue
Block a user