new selfhosted version

This commit is contained in:
antonio
2025-11-28 14:11:51 +01:00
parent afda29997d
commit 951860f67e
1046 changed files with 72586 additions and 574750 deletions

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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
View 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>

View File

@@ -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>

View 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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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
View 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>

View File

@@ -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 informationit 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 datathis 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 collectiononly 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 LitLyxs 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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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
View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View File

@@ -1,15 +0,0 @@
<script lang="ts" setup>
</script>
<template>
<div>
TEST
</div>
</template>

View File

@@ -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>

View 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>