Initial commit

This commit is contained in:
2025-08-04 16:33:07 +03:30
commit f798e8e35c
9595 changed files with 1208683 additions and 0 deletions

BIN
resources/js/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,288 @@
<script setup>
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
import {
VList,
VListItem,
} from 'vuetify/components/VList'
const props = defineProps({
isDialogVisible: {
type: Boolean,
required: true,
},
searchResults: {
type: Array,
required: true,
},
isLoading: {
type: Boolean,
required: false,
},
})
const emit = defineEmits([
'update:isDialogVisible',
'search',
])
// 👉 Hotkey
// eslint-disable-next-line camelcase
const { ctrl_k, meta_k } = useMagicKeys({
passive: false,
onEventFired(e) {
if (e.ctrlKey && e.key === 'k' && e.type === 'keydown')
e.preventDefault()
},
})
const refSearchList = ref()
const refSearchInput = ref()
const searchQueryLocal = ref('')
// 👉 watching control + / to open dialog
/* eslint-disable camelcase */
watch([
ctrl_k,
meta_k,
], () => {
emit('update:isDialogVisible', true)
})
/* eslint-enable */
// 👉 clear search result and close the dialog
const clearSearchAndCloseDialog = () => {
searchQueryLocal.value = ''
emit('update:isDialogVisible', false)
}
const getFocusOnSearchList = e => {
if (e.key === 'ArrowDown') {
e.preventDefault()
refSearchList.value?.focus('next')
} else if (e.key === 'ArrowUp') {
e.preventDefault()
refSearchList.value?.focus('prev')
}
}
const dialogModelValueUpdate = val => {
searchQueryLocal.value = ''
emit('update:isDialogVisible', val)
}
watch(() => props.isDialogVisible, () => {
searchQueryLocal.value = ''
})
</script>
<template>
<VDialog
max-width="600"
:model-value="props.isDialogVisible"
:height="$vuetify.display.smAndUp ? '531' : '100%'"
:fullscreen="$vuetify.display.width < 600"
class="app-bar-search-dialog"
@update:model-value="dialogModelValueUpdate"
@keyup.esc="clearSearchAndCloseDialog"
>
<VCard
height="100%"
width="100%"
class="position-relative"
>
<VCardText
class="px-4"
style="padding-block: 1rem 1.2rem;"
>
<!-- 👉 Search Input -->
<VTextField
ref="refSearchInput"
v-model="searchQueryLocal"
autofocus
density="compact"
variant="plain"
class="app-bar-search-input"
@keyup.esc="clearSearchAndCloseDialog"
@keydown="getFocusOnSearchList"
@update:model-value="$emit('search', searchQueryLocal)"
>
<!-- 👉 Prepend Inner -->
<template #prepend-inner>
<div class="d-flex align-center text-high-emphasis me-1">
<VIcon
size="24"
icon="tabler-search"
/>
</div>
</template>
<!-- 👉 Append Inner -->
<template #append-inner>
<div class="d-flex align-start">
<div
class="text-base text-disabled cursor-pointer me-3"
@click="clearSearchAndCloseDialog"
>
[esc]
</div>
<VIcon
icon="tabler-x"
size="24"
@click="clearSearchAndCloseDialog"
/>
</div>
</template>
</VTextField>
</VCardText>
<!-- 👉 Divider -->
<VDivider />
<!-- 👉 Perfect Scrollbar -->
<PerfectScrollbar
:options="{ wheelPropagation: false, suppressScrollX: true }"
class="h-100"
>
<!-- 👉 Suggestions -->
<div
v-show="!!props.searchResults && !searchQueryLocal && $slots.suggestions"
class="h-100"
>
<slot name="suggestions" />
</div>
<template v-if="!isLoading">
<!-- 👉 Search List -->
<VList
v-show="searchQueryLocal.length && !!props.searchResults.length"
ref="refSearchList"
density="compact"
class="app-bar-search-list py-0"
>
<!-- 👉 list Item /List Sub header -->
<template
v-for="item in props.searchResults"
:key="item"
>
<slot
name="searchResult"
:item="item"
>
<VListItem>
{{ item }}
</VListItem>
</slot>
</template>
</VList>
<!-- 👉 No Data found -->
<div
v-show="!props.searchResults.length && searchQueryLocal.length"
class="h-100"
>
<slot name="noData">
<VCardText class="h-100">
<div class="app-bar-search-suggestions d-flex flex-column align-center justify-center text-high-emphasis pa-12">
<VIcon
size="64"
icon="tabler-file-alert"
/>
<div class="d-flex align-center flex-wrap justify-center gap-2 text-h5 mt-3">
<span>No Result For </span>
<span>"{{ searchQueryLocal }}"</span>
</div>
<slot name="noDataSuggestion" />
</div>
</VCardText>
</slot>
</div>
</template>
<!-- 👉 Loading -->
<template v-if="isLoading">
<VSkeletonLoader
v-for="i in 3"
:key="i"
type="list-item-two-line"
/>
</template>
</PerfectScrollbar>
</VCard>
</VDialog>
</template>
<style lang="scss">
.app-bar-search-suggestions {
.app-bar-search-suggestion {
&:hover {
color: rgb(var(--v-theme-primary));
}
}
}
.app-bar-search-dialog {
.app-bar-search-input {
.v-field__input {
padding-block-start: 0.2rem;
}
}
.app-bar-search-list {
.v-list-item,
.v-list-subheader {
font-size: 0.75rem;
padding-inline: 1rem;
}
.v-list-item {
border-radius: 6px;
margin-block-end: 0.125rem;
margin-inline: 0.5rem;
.v-list-item__append {
.enter-icon {
visibility: hidden;
}
}
&:hover,
&:active,
&:focus {
.v-list-item__append {
.enter-icon {
visibility: visible;
}
}
}
}
.v-list-subheader {
line-height: 1;
min-block-size: auto;
padding-block: 16px 8px;
padding-inline-start: 1rem;
text-transform: uppercase;
}
}
}
@supports selector(:focus-visible) {
.app-bar-search-dialog {
.v-list-item:focus-visible::after {
content: none;
}
}
}
</style>
<style lang="scss" scoped>
.card-list {
--v-card-list-gap: 16px;
}
</style>

View File

@@ -0,0 +1,31 @@
<script setup>
const props = defineProps({
title: {
type: String,
required: true,
},
})
const emit = defineEmits(['cancel'])
</script>
<template>
<div class="pa-6 d-flex align-center">
<h5 class="text-h5">
{{ props.title }}
</h5>
<VSpacer />
<slot name="beforeClose" />
<IconBtn
size="small"
@click="$emit('cancel', $event)"
>
<VIcon
size="24"
icon="tabler-x"
/>
</IconBtn>
</div>
</template>

View File

@@ -0,0 +1,359 @@
<script setup>
const props = defineProps({
items: {
type: Array,
required: true,
},
currentStep: {
type: Number,
required: false,
default: 0,
},
direction: {
type: String,
required: false,
default: 'horizontal',
},
iconSize: {
type: [
String,
Number,
],
required: false,
default: 60,
},
isActiveStepValid: {
type: Boolean,
required: false,
default: undefined,
},
align: {
type: String,
required: false,
default: 'default',
},
})
const emit = defineEmits(['update:currentStep'])
const currentStep = ref(props.currentStep || 0)
const activeOrCompletedStepsClasses = computed(() => index => index < currentStep.value ? 'stepper-steps-completed' : index === currentStep.value ? 'stepper-steps-active' : '')
const isHorizontalAndNotLastStep = computed(() => index => props.direction === 'horizontal' && props.items.length - 1 !== index)
// check if validation is enabled
const isValidationEnabled = computed(() => {
return props.isActiveStepValid !== undefined
})
watchEffect(() => {
if (props.currentStep !== undefined && props.currentStep < props.items.length && props.currentStep >= 0)
currentStep.value = props.currentStep
emit('update:currentStep', currentStep.value)
})
</script>
<template>
<VSlideGroup
v-model="currentStep"
class="app-stepper"
show-arrows
:direction="props.direction"
:class="`app-stepper-${props.align} ${props.items[0].icon ? 'app-stepper-icons' : ''}`"
>
<VSlideGroupItem
v-for="(item, index) in props.items"
:key="item.title"
:value="index"
>
<div
class="cursor-pointer app-stepper-step pa-1"
:class="[
(!props.isActiveStepValid && (isValidationEnabled)) && 'stepper-steps-invalid',
activeOrCompletedStepsClasses(index),
]"
@click="!isValidationEnabled && emit('update:currentStep', index)"
>
<!-- SECTION stepper step with icon -->
<template v-if="item.icon">
<div class="stepper-icon-step text-high-emphasis d-flex align-center ">
<!-- 👉 icon and title -->
<div
class="d-flex align-center gap-x-3 step-wrapper"
:class="[props.direction === 'horizontal' && 'flex-column']"
>
<div class="stepper-icon">
<template v-if="typeof item.icon === 'object'">
<Component :is="item.icon" />
</template>
<VIcon
v-else
:icon="item.icon"
:size="item.size || props.iconSize"
/>
</div>
<div>
<p class="stepper-title font-weight-medium mb-0">
{{ item.title }}
</p>
<p
v-if="item.subtitle"
class="stepper-subtitle mb-0"
>
{{ item.subtitle }}
</p>
</div>
</div>
<!-- 👉 append chevron -->
<VIcon
v-if="isHorizontalAndNotLastStep(index)"
class="flip-in-rtl stepper-chevron-indicator mx-6"
size="20"
icon="tabler-chevron-right"
/>
</div>
</template>
<!-- !SECTION -->
<!-- SECTION stepper step without icon -->
<template v-else>
<div class="d-flex align-center gap-x-3">
<div>
<!-- 👉 custom circle icon -->
<template v-if="index >= currentStep">
<VAvatar
v-if="(!isValidationEnabled || props.isActiveStepValid || index !== currentStep)"
size="38"
rounded
:variant="index === currentStep ? 'elevated' : 'tonal'"
:color="index === currentStep ? 'primary' : 'default'"
>
<h5
class="text-h5"
:style="index === currentStep ? { color: '#fff' } : ''"
>
{{ index + 1 }}
</h5>
</VAvatar>
<VAvatar
v-else
color="error"
size="38"
rounded
>
<VIcon
icon="tabler-alert-circle"
size="22"
/>
</VAvatar>
</template>
<!-- 👉 step completed icon -->
<VAvatar
v-else
class="stepper-icon"
variant="tonal"
color="primary"
size="38"
rounded
>
<h5
class="text-h5"
style="color: rgb(var(--v-theme-primary));"
>
{{ index + 1 }}
</h5>
</VAvatar>
</div>
<!-- 👉 title and subtitle -->
<div class="d-flex flex-column justify-center">
<div class="stepper-title font-weight-medium">
{{ item.title }}
</div>
<div
v-if="item.subtitle"
class="stepper-subtitle text-sm text-disabled"
>
{{ item.subtitle }}
</div>
</div>
<!-- 👉 stepper step icon -->
<div
v-if="isHorizontalAndNotLastStep(index)"
class="stepper-step-line stepper-chevron-indicator mx-6"
>
<VIcon
icon="tabler-chevron-right"
size="20"
/>
</div>
</div>
</template>
<!-- !SECTION -->
</div>
</VSlideGroupItem>
</VSlideGroup>
</template>
<style lang="scss">
@use "@core-scss/template/mixins" as templateMixins;
.app-stepper {
// 👉 stepper step with bg color
&.stepper-icon-step-bg {
.stepper-icon-step {
.step-wrapper {
flex-direction: row !important;
}
.stepper-icon {
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
background-color: rgba(var(--v-theme-on-surface), var(--v-selected-opacity));
block-size: 2.375rem;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
inline-size: 2.375rem;
}
}
.stepper-steps-active {
.stepper-icon-step {
.stepper-icon {
@include templateMixins.custom-elevation(var(--v-theme-primary), "sm");
background-color: rgb(var(--v-theme-primary));
color: rgba(var(--v-theme-on-primary));
}
}
}
.stepper-steps-completed {
.stepper-icon-step {
.stepper-icon {
background: rgba(var(--v-theme-primary), var(--v-activated-opacity));
color: rgba(var(--v-theme-primary));
}
}
}
}
&.app-stepper-icons:not(.stepper-icon-step-bg) {
/* stylelint-disable-next-line no-descending-specificity */
.stepper-icon {
line-height: 0;
}
.step-wrapper {
padding: 1.25rem;
gap: 0.5rem;
min-inline-size: 9.375rem;
}
.stepper-chevron-indicator {
margin-inline: 1rem !important;
}
.stepper-steps-completed,
.stepper-steps-active {
.stepper-icon-step,
.stepper-step-icon,
.stepper-title,
.stepper-subtitle {
color: rgb(var(--v-theme-primary)) !important;
}
}
}
// 👉 stepper step with icon and default
.v-slide-group__content {
row-gap: 1rem;
/* stylelint-disable-next-line no-descending-specificity */
.stepper-title {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 0.9375rem;
font-weight: 500 !important;
}
/* stylelint-disable-next-line no-descending-specificity */
.stepper-subtitle {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.8125rem;
line-height: 1.25rem;
}
/* stylelint-disable-next-line no-descending-specificity */
.stepper-chevron-indicator {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
}
/* stylelint-disable-next-line no-descending-specificity */
.stepper-steps-completed {
/* stylelint-disable-next-line no-descending-specificity */
.stepper-title,
.stepper-subtitle {
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
}
.stepper-chevron-indicator {
color: rgb(var(--v-theme-primary));
}
}
/* stylelint-disable-next-line no-descending-specificity */
.stepper-steps-active {
.v-avatar.bg-primary {
@include templateMixins.custom-elevation(var(--v-theme-primary), "sm");
}
.v-avatar.bg-error {
@include templateMixins.custom-elevation(var(--v-theme-error), "sm");
}
}
.stepper-steps-invalid.stepper-steps-active {
.stepper-icon-step,
.step-number,
.stepper-title,
.stepper-subtitle {
color: rgb(var(--v-theme-error)) !important;
}
}
.app-stepper-step {
&:not(.stepper-steps-active,.stepper-steps-completed) .v-avatar--variant-tonal {
--v-activated-opacity: 0.06;
}
}
}
// 👉 stepper alignment
&.app-stepper-center {
.v-slide-group__content {
justify-content: center;
}
}
&.app-stepper-start {
.v-slide-group__content {
justify-content: start;
}
}
&.app-stepper-end {
.v-slide-group__content {
justify-content: end;
}
}
}
</style>

View File

@@ -0,0 +1,91 @@
<script setup>
const vm = getCurrentInstance()
const buyNowUrl = ref(vm?.appContext.config.globalProperties.buyNowUrl || 'https://1.envato.market/vuexy_admin')
watch(buyNowUrl, val => {
if (vm)
vm.appContext.config.globalProperties.buyNowUrl = val
})
</script>
<template>
<a
class="buy-now-button d-print-none"
role="button"
rel="noopener noreferrer"
:href="buyNowUrl"
target="_blank"
>
Buy Now
<span class="button-inner" />
</a>
</template>
<style lang="scss" scoped>
.buy-now-button,
.button-inner {
display: inline-flex;
box-sizing: border-box;
align-items: center;
justify-content: center;
border: 0;
border-radius: 6px;
margin: 0;
animation: anime 12s linear infinite;
appearance: none;
background: linear-gradient(-45deg, #ffa63d, #ff3d77, #338aff, #3cf0c5);
background-size: 600%;
color: rgba(255, 255, 255, 90%);
cursor: pointer;
font-size: 0.9375rem;
font-weight: 500;
letter-spacing: 0.43px;
line-height: 1.2;
min-inline-size: 50px;
outline: 0;
padding-block: 0.625rem;
padding-inline: 1.25rem;
text-decoration: none;
text-transform: none;
vertical-align: middle;
}
.buy-now-button {
position: fixed;
z-index: 999;
inset-block-end: 5%;
inset-inline-end: 87px;
&:hover {
color: white;
text-decoration: none;
}
.button-inner {
position: absolute;
z-index: -1;
filter: blur(12px);
inset: 0;
opacity: 0;
transition: opacity 200ms ease-in-out;
}
&:not(:hover) .button-inner {
opacity: 0.8;
}
}
@keyframes anime {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
</style>

View File

@@ -0,0 +1,44 @@
<script setup>
const props = defineProps({
title: {
type: String,
required: true,
},
color: {
type: String,
required: false,
default: 'primary',
},
icon: {
type: String,
required: true,
},
stats: {
type: String,
required: true,
},
})
</script>
<template>
<VCard>
<VCardText class="d-flex flex-column align-center justify-center">
<VAvatar
v-if="props.icon"
size="40"
variant="tonal"
rounded
:color="props.color"
>
<VIcon :icon="props.icon" />
</VAvatar>
<h5 class="text-h5 pt-2 mb-1">
{{ props.stats }}
</h5>
<div class="text-body-1">
{{ props.title }}
</div>
</VCardText>
</VCard>
</template>

View File

@@ -0,0 +1,30 @@
<script setup>
const props = defineProps({
title: {
type: String,
required: true,
},
divider: {
type: Boolean,
required: false,
default: true,
},
})
</script>
<template>
<VDivider v-if="props.divider" />
<div class="customizer-section">
<div>
<VChip
size="small"
color="primary"
>
<span class="font-weight-medium">{{ props.title }}</span>
</VChip>
</div>
<slot />
</div>
</template>

View File

@@ -0,0 +1,28 @@
<script setup>
const props = defineProps({
icon: {
type: String,
required: false,
default: 'tabler-x',
},
iconSize: {
type: String,
required: false,
default: '20',
},
})
</script>
<template>
<IconBtn
variant="elevated"
size="30"
:ripple="false"
class="v-dialog-close-btn"
>
<VIcon
:icon="props.icon"
:size="props.iconSize"
/>
</IconBtn>
</template>

View File

@@ -0,0 +1,126 @@
<script setup>
import {
useDropZone,
useFileDialog,
useObjectUrl,
} from '@vueuse/core'
const dropZoneRef = ref()
const fileData = ref([])
const { open, onChange } = useFileDialog({ accept: 'image/*' })
function onDrop(DroppedFiles) {
DroppedFiles?.forEach(file => {
if (file.type.slice(0, 6) !== 'image/') {
// eslint-disable-next-line no-alert
alert('Only image files are allowed')
return
}
fileData.value.push({
file,
url: useObjectUrl(file).value ?? '',
})
})
}
onChange(selectedFiles => {
if (!selectedFiles)
return
for (const file of selectedFiles) {
fileData.value.push({
file,
url: useObjectUrl(file).value ?? '',
})
}
})
useDropZone(dropZoneRef, onDrop)
</script>
<template>
<div class="flex">
<div class="w-full h-auto relative">
<div
ref="dropZoneRef"
class="cursor-pointer"
@click="() => open()"
>
<div
v-if="fileData.length === 0"
class="d-flex flex-column justify-center align-center gap-y-2 pa-12 drop-zone rounded"
>
<IconBtn
variant="tonal"
class="rounded-sm"
>
<VIcon icon="tabler-upload" />
</IconBtn>
<h4 class="text-h4">
Drag and drop your image here.
</h4>
<span class="text-disabled">or</span>
<VBtn
variant="tonal"
size="small"
>
Browse Images
</VBtn>
</div>
<div
v-else
class="d-flex justify-center align-center gap-3 pa-8 drop-zone flex-wrap"
>
<VRow class="match-height w-100">
<template
v-for="(item, index) in fileData"
:key="index"
>
<VCol
cols="12"
sm="4"
>
<VCard :ripple="false">
<VCardText
class="d-flex flex-column"
@click.stop
>
<VImg
:src="item.url"
width="200px"
height="150px"
class="w-100 mx-auto"
/>
<div class="mt-2">
<span class="clamp-text text-wrap">
{{ item.file.name }}
</span>
<span>
{{ item.file.size / 1000 }} KB
</span>
</div>
</VCardText>
<VCardActions>
<VBtn
variant="text"
block
@click.stop="fileData.splice(index, 1)"
>
Remove File
</VBtn>
</VCardActions>
</VCard>
</VCol>
</template>
</VRow>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.drop-zone {
border: 1px dashed rgba(var(--v-theme-on-surface), var(--v-border-opacity));
}
</style>

View File

@@ -0,0 +1,48 @@
<script setup>
const props = defineProps({
languages: {
type: Array,
required: true,
},
location: {
type: null,
required: false,
default: 'bottom end',
},
})
const { locale } = useI18n({ useScope: 'global' })
</script>
<template>
<IconBtn>
<VIcon icon="tabler-language" />
<!-- Menu -->
<VMenu
activator="parent"
:location="props.location"
offset="12px"
width="175"
>
<!-- List -->
<VList
:selected="[locale]"
color="primary"
>
<!-- List item -->
<VListItem
v-for="lang in props.languages"
:key="lang.i18nLang"
:value="lang.i18nLang"
@click="locale = lang.i18nLang"
>
<!-- Language label -->
<VListItemTitle>
{{ lang.label }}
</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>

View File

@@ -0,0 +1,40 @@
<script setup>
const props = defineProps({
menuList: {
type: Array,
required: false,
},
itemProps: {
type: Boolean,
required: false,
},
iconSize: {
type: String,
required: false,
},
class: {
type: String,
required: false,
default: 'text-disabled',
},
})
</script>
<template>
<IconBtn :class="props.class">
<VIcon
:size="iconSize"
icon="tabler-dots-vertical"
/>
<VMenu
v-if="props.menuList"
activator="parent"
>
<VList
:items="props.menuList"
:item-props="props.itemProps"
/>
</VMenu>
</IconBtn>
</template>

View File

@@ -0,0 +1,251 @@
<script setup>
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
const props = defineProps({
notifications: {
type: Array,
required: true,
},
badgeProps: {
type: Object,
required: false,
default: undefined,
},
location: {
type: null,
required: false,
default: 'bottom end',
},
})
const emit = defineEmits([
'read',
'unread',
'remove',
'click:notification',
])
const isAllMarkRead = computed(() => {
return props.notifications.some(item => item.isSeen === false)
})
const markAllReadOrUnread = () => {
const allNotificationsIds = props.notifications.map(item => item.id)
if (!isAllMarkRead.value)
emit('unread', allNotificationsIds)
else
emit('read', allNotificationsIds)
}
const totalUnseenNotifications = computed(() => {
return props.notifications.filter(item => item.isSeen === false).length
})
const toggleReadUnread = (isSeen, Id) => {
if (isSeen)
emit('unread', [Id])
else
emit('read', [Id])
}
</script>
<template>
<IconBtn id="notification-btn">
<VBadge
v-bind="props.badgeProps"
:model-value="props.notifications.some(n => !n.isSeen)"
color="error"
dot
offset-x="2"
offset-y="3"
>
<VIcon icon="tabler-bell" />
</VBadge>
<VMenu
activator="parent"
width="380px"
:location="props.location"
offset="12px"
:close-on-content-click="false"
>
<VCard class="d-flex flex-column">
<!-- 👉 Header -->
<VCardItem class="notification-section">
<VCardTitle class="text-h6">
Notifications
</VCardTitle>
<template #append>
<VChip
v-show="props.notifications.some(n => !n.isSeen)"
size="small"
color="primary"
class="me-2"
>
{{ totalUnseenNotifications }} New
</VChip>
<IconBtn
v-show="props.notifications.length"
size="34"
@click="markAllReadOrUnread"
>
<VIcon
size="20"
color="high-emphasis"
:icon="!isAllMarkRead ? 'tabler-mail' : 'tabler-mail-opened' "
/>
<VTooltip
activator="parent"
location="start"
>
{{ !isAllMarkRead ? 'Mark all as unread' : 'Mark all as read' }}
</VTooltip>
</IconBtn>
</template>
</VCardItem>
<VDivider />
<!-- 👉 Notifications list -->
<PerfectScrollbar
:options="{ wheelPropagation: false }"
style="max-block-size: 23.75rem;"
>
<VList class="notification-list rounded-0 py-0">
<template
v-for="(notification, index) in props.notifications"
:key="notification.title"
>
<VDivider v-if="index > 0" />
<VListItem
link
lines="one"
min-height="66px"
class="list-item-hover-class"
@click="$emit('click:notification', notification)"
>
<!-- Slot: Prepend -->
<!-- Handles Avatar: Image, Icon, Text -->
<div class="d-flex align-start gap-3">
<VAvatar
:color="notification.color && !notification.img ? notification.color : undefined"
:variant="notification.img ? undefined : 'tonal' "
>
<span v-if="notification.text">{{ avatarText(notification.text) }}</span>
<VImg
v-if="notification.img"
:src="notification.img"
/>
<VIcon
v-if="notification.icon"
:icon="notification.icon"
/>
</VAvatar>
<div>
<p class="text-sm font-weight-medium mb-1">
{{ notification.title }}
</p>
<p
class="text-body-2 mb-2"
style=" letter-spacing: 0.4px !important; line-height: 18px;"
>
{{ notification.subtitle }}
</p>
<p
class="text-sm text-disabled mb-0"
style=" letter-spacing: 0.4px !important; line-height: 18px;"
>
{{ notification.time }}
</p>
</div>
<VSpacer />
<div class="d-flex flex-column align-end">
<VIcon
size="10"
icon="tabler-circle-filled"
:color="!notification.isSeen ? 'primary' : '#a8aaae'"
:class="`${notification.isSeen ? 'visible-in-hover' : ''}`"
class="mb-2"
@click.stop="toggleReadUnread(notification.isSeen, notification.id)"
/>
<VIcon
size="20"
icon="tabler-x"
class="visible-in-hover"
@click="$emit('remove', notification.id)"
/>
</div>
</div>
</VListItem>
</template>
<VListItem
v-show="!props.notifications.length"
class="text-center text-medium-emphasis"
style="block-size: 56px;"
>
<VListItemTitle>No Notification Found!</VListItemTitle>
</VListItem>
</VList>
</PerfectScrollbar>
<VDivider />
<!-- 👉 Footer -->
<VCardText
v-show="props.notifications.length"
class="pa-4"
>
<VBtn
block
size="small"
>
View All Notifications
</VBtn>
</VCardText>
</VCard>
</VMenu>
</IconBtn>
</template>
<style lang="scss">
.notification-section {
padding-block: 0.75rem;
padding-inline: 1rem;
}
.list-item-hover-class {
.visible-in-hover {
display: none;
}
&:hover {
.visible-in-hover {
display: block;
}
}
}
.notification-list.v-list {
.v-list-item {
border-radius: 0 !important;
margin: 0 !important;
padding-block: 0.75rem !important;
}
}
// Badge Style Override for Notification Badge
.notification-badge {
.v-badge__badge {
/* stylelint-disable-next-line liberty/use-logical-spec */
min-width: 18px;
padding: 0;
block-size: 18px;
}
}
</style>

View File

@@ -0,0 +1,203 @@
<script setup>
import { Placeholder } from '@tiptap/extension-placeholder'
import { TextAlign } from '@tiptap/extension-text-align'
import { Underline } from '@tiptap/extension-underline'
import { StarterKit } from '@tiptap/starter-kit'
import {
EditorContent,
useEditor,
} from '@tiptap/vue-3'
const props = defineProps({
modelValue: {
type: String,
required: true,
},
placeholder: {
type: String,
required: false,
},
})
const emit = defineEmits(['update:modelValue'])
const editorRef = ref()
const editor = useEditor({
content: props.modelValue,
extensions: [
StarterKit,
TextAlign.configure({
types: [
'heading',
'paragraph',
],
}),
Placeholder.configure({ placeholder: props.placeholder ?? 'Write something here...' }),
Underline,
],
onUpdate() {
if (!editor.value)
return
emit('update:modelValue', editor.value.getHTML())
},
})
watch(() => props.modelValue, () => {
const isSame = editor.value?.getHTML() === props.modelValue
if (isSame)
return
editor.value?.commands.setContent(props.modelValue)
})
</script>
<template>
<div class="pa-6 productDescriptionEditor">
<!-- buttons -->
<div
v-if="editor"
class="d-flex gap-1 flex-wrap align-center"
>
<VBtn
size="small"
icon
rounded
:variant="editor.isActive('bold') ? 'tonal' : 'text'"
:color="editor.isActive('bold') ? 'primary' : 'default'"
@click="editor.chain().focus().toggleBold().run()"
>
<VIcon
icon="tabler-bold"
class="font-weight-medium"
/>
</VBtn>
<VBtn
size="small"
icon
rounded
:variant="editor.isActive('underline') ? 'tonal' : 'text'"
:color="editor.isActive('underline') ? 'primary' : 'default'"
@click="editor.commands.toggleUnderline()"
>
<VIcon icon="tabler-underline" />
</VBtn>
<VBtn
size="small"
icon
rounded
:variant="editor.isActive('italic') ? 'tonal' : 'text'"
:color="editor.isActive('italic') ? 'primary' : 'default'"
@click="editor.chain().focus().toggleItalic().run()"
>
<VIcon
icon="tabler-italic"
class="font-weight-medium"
/>
</VBtn>
<VBtn
size="small"
icon
rounded
:variant="editor.isActive('strike') ? 'tonal' : 'text'"
:color="editor.isActive('strike') ? 'primary' : 'default'"
@click="editor.chain().focus().toggleStrike().run()"
>
<VIcon
icon="tabler-strikethrough"
size="20"
/>
</VBtn>
<VBtn
size="small"
icon
rounded
:variant="editor.isActive({ textAlign: 'left' }) ? 'tonal' : 'text'"
:color="editor.isActive({ textAlign: 'left' }) ? 'primary' : 'default'"
@click="editor.chain().focus().setTextAlign('left').run()"
>
<VIcon
icon="tabler-align-left"
size="20"
/>
</VBtn>
<VBtn
size="small"
icon
rounded
:color="editor.isActive({ textAlign: 'center' }) ? 'primary' : 'default'"
:variant="editor.isActive({ textAlign: 'center' }) ? 'tonal' : 'text'"
@click="editor.chain().focus().setTextAlign('center').run()"
>
<VIcon
icon="tabler-align-center"
size="20"
/>
</VBtn>
<VBtn
size="small"
icon
rounded
:variant="editor.isActive({ textAlign: 'right' }) ? 'tonal' : 'text'"
:color="editor.isActive({ textAlign: 'right' }) ? 'primary' : 'default'"
@click="editor.chain().focus().setTextAlign('right').run()"
>
<VIcon
icon="tabler-align-right"
size="20"
/>
</VBtn>
<VBtn
size="small"
icon
rounded
:variant="editor.isActive({ textAlign: 'justify' }) ? 'tonal' : 'text'"
:color="editor.isActive({ textAlign: 'justify' }) ? 'primary' : 'default'"
@click="editor.chain().focus().setTextAlign('justify').run()"
>
<VIcon
icon="tabler-align-justified"
size="20"
/>
</VBtn>
</div>
<VDivider class="my-4" />
<EditorContent
ref="editorRef"
:editor="editor"
/>
</div>
</template>
<style lang="scss">
.productDescriptionEditor {
.ProseMirror {
padding: 0 !important;
min-block-size: 12vh;
p {
margin-block-end: 0;
}
p.is-editor-empty:first-child::before {
block-size: 0;
color: #adb5bd;
content: attr(data-placeholder);
float: inline-start;
pointer-events: none;
}
&-focused {
outline: none;
}
}
}
</style>

View File

@@ -0,0 +1,40 @@
<script setup>
const { y } = useWindowScroll()
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth',
})
}
</script>
<template>
<VScaleTransition
style="transform-origin: center;"
class="scroll-to-top d-print-none"
>
<VBtn
v-show="y > 200"
icon
density="comfortable"
@click="scrollToTop"
>
<VIcon
size="22"
icon="tabler-arrow-up"
/>
</VBtn>
</VScaleTransition>
</template>
<style lang="scss">
.scroll-to-top {
position: fixed !important;
// To keep button on top of v-layout. E.g. Email app
z-index: 999;
inset-block-end: 5%;
inset-inline-end: 25px;
}
</style>

View File

@@ -0,0 +1,92 @@
<script setup>
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
const props = defineProps({
togglerIcon: {
type: String,
required: false,
default: 'tabler-layout-grid-add',
},
shortcuts: {
type: Array,
required: true,
},
})
const router = useRouter()
</script>
<template>
<IconBtn>
<VIcon :icon="props.togglerIcon" />
<VMenu
activator="parent"
offset="12px"
location="bottom end"
>
<VCard
:width="$vuetify.display.smAndDown ? 330 : 380"
max-height="560"
class="d-flex flex-column"
>
<VCardItem class="py-3">
<h6 class="text-base font-weight-medium">
Shortcuts
</h6>
<template #append>
<IconBtn
size="small"
color="high-emphasis"
>
<VIcon
size="20"
icon="tabler-plus"
/>
</IconBtn>
</template>
</VCardItem>
<VDivider />
<PerfectScrollbar :options="{ wheelPropagation: false }">
<VRow class="ma-0 mt-n1">
<VCol
v-for="(shortcut, index) in props.shortcuts"
:key="shortcut.title"
cols="6"
class="text-center border-t cursor-pointer pa-6 shortcut-icon"
:class="(index + 1) % 2 ? 'border-e' : ''"
@click="router.push(shortcut.to)"
>
<VAvatar
variant="tonal"
size="50"
>
<VIcon
size="26"
color="high-emphasis"
:icon="shortcut.icon"
/>
</VAvatar>
<h6 class="text-base font-weight-medium mt-3 mb-0">
{{ shortcut.title }}
</h6>
<p class="text-sm mb-0">
{{ shortcut.subtitle }}
</p>
</VCol>
</VRow>
</PerfectScrollbar>
</VCard>
</VMenu>
</IconBtn>
</template>
<style lang="scss">
.shortcut-icon:hover {
background-color: rgba(var(--v-theme-on-surface), var(--v-hover-opacity));
}
</style>

View File

@@ -0,0 +1,42 @@
<script setup>
const props = defineProps({
page: {
type: Number,
required: true,
},
itemsPerPage: {
type: Number,
required: true,
},
totalItems: {
type: Number,
required: true,
},
})
const emit = defineEmits(['update:page'])
const updatePage = value => {
emit('update:page', value)
}
</script>
<template>
<div>
<VDivider />
<div class="d-flex align-center justify-sm-space-between justify-center flex-wrap gap-3 px-6 py-3">
<p class="text-disabled mb-0">
{{ paginationMeta({ page, itemsPerPage }, totalItems) }}
</p>
<VPagination
:model-value="page"
active-color="primary"
:length="Math.ceil(totalItems / itemsPerPage)"
:total-visible="$vuetify.display.xs ? 1 : Math.min(Math.ceil(totalItems / itemsPerPage), 5)"
@update:model-value="updatePage"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,578 @@
<script setup>
import { useStorage } from '@vueuse/core'
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
import { useTheme } from 'vuetify'
import { useRouter, useRoute } from 'vue-router'
import {
staticPrimaryColor,
staticPrimaryDarkenColor,
} from '@/plugins/vuetify/theme'
import {
Direction,
Layout,
Skins,
Theme,
} from '@core/enums'
import { useConfigStore } from '@core/stores/config'
import {
AppContentLayoutNav,
ContentWidth,
} from '@layouts/enums'
import {
cookieRef,
namespaceConfig,
} from '@layouts/stores/config'
import { themeConfig } from '@themeConfig'
import WidgetLibrary from './WidgetLibrary.vue'
import borderSkin from '@images/customizer-icons/border-light.svg'
import collapsed from '@images/customizer-icons/collapsed-light.svg'
import compact from '@images/customizer-icons/compact-light.svg'
import defaultSkin from '@images/customizer-icons/default-light.svg'
import horizontalLight from '@images/customizer-icons/horizontal-light.svg'
import ltrSvg from '@images/customizer-icons/ltr-light.svg'
import rtlSvg from '@images/customizer-icons/rtl-light.svg'
import wideSvg from '@images/customizer-icons/wide-light.svg'
const isNavDrawerOpen = ref(false)
const isWidgetSidebarOpen = ref(false)
const configStore = useConfigStore()
const vuetifyTheme = useTheme()
const route = useRoute()
const isCrmRoute = computed(() => {
return route.path === '/dashboards/crm'
})
const colors = [
{
main: staticPrimaryColor,
darken: staticPrimaryDarkenColor,
},
{
main: '#0D9394',
darken: '#0C8485',
},
{
main: '#FFB400',
darken: '#E6A200',
},
{
main: '#FF4C51',
darken: '#E64449',
},
{
main: '#16B1FF',
darken: '#149FE6',
},
]
const customPrimaryColor = ref('#663131')
watch(() => configStore.theme, () => {
const cookiePrimaryColor = cookieRef(`${vuetifyTheme.name.value}ThemePrimaryColor`, null).value
if (cookiePrimaryColor && !colors.some(color => color.main === cookiePrimaryColor))
customPrimaryColor.value = cookiePrimaryColor
}, { immediate: true })
const setPrimaryColor = useDebounceFn(color => {
vuetifyTheme.themes.value[vuetifyTheme.name.value].colors.primary = color.main
vuetifyTheme.themes.value[vuetifyTheme.name.value].colors['primary-darken-1'] = color.darken
cookieRef(`${vuetifyTheme.name.value}ThemePrimaryColor`, null).value = color.main
cookieRef(`${vuetifyTheme.name.value}ThemePrimaryDarkenColor`, null).value = color.darken
useStorage(namespaceConfig('initial-loader-color'), null).value = color.main
}, 100)
const themeMode = computed(() => {
return [
{
bgImage: 'tabler-sun',
value: Theme.Light,
label: 'Light',
},
{
bgImage: 'tabler-moon-stars',
value: Theme.Dark,
label: 'Dark',
},
{
bgImage: 'tabler-device-desktop-analytics',
value: Theme.System,
label: 'System',
},
]
})
const themeSkin = computed(() => {
return [
{
bgImage: defaultSkin,
value: Skins.Default,
label: 'Default',
},
{
bgImage: borderSkin,
value: Skins.Bordered,
label: 'Bordered',
},
]
})
const currentLayout = ref(configStore.isVerticalNavCollapsed ? 'collapsed' : configStore.appContentLayoutNav)
const layouts = computed(() => {
return [
{
bgImage: defaultSkin,
value: Layout.Vertical,
label: 'Vertical',
},
{
bgImage: collapsed,
value: Layout.Collapsed,
label: 'Collapsed',
},
{
bgImage: horizontalLight,
value: Layout.Horizontal,
label: 'Horizontal',
},
]
})
watch(currentLayout, () => {
if (currentLayout.value === 'collapsed') {
configStore.isVerticalNavCollapsed = true
configStore.appContentLayoutNav = AppContentLayoutNav.Vertical
} else {
configStore.isVerticalNavCollapsed = false
configStore.appContentLayoutNav = currentLayout.value
}
})
watch(() => configStore.isVerticalNavCollapsed, () => {
currentLayout.value = configStore.isVerticalNavCollapsed ? 'collapsed' : configStore.appContentLayoutNav
})
const contentWidth = computed(() => {
return [
{
bgImage: compact,
value: ContentWidth.Boxed,
label: 'Compact',
},
{
bgImage: wideSvg,
value: ContentWidth.Fluid,
label: 'Wide',
},
]
})
const currentDir = ref(configStore.isAppRTL ? 'rtl' : 'ltr')
const direction = computed(() => {
return [
{
bgImage: ltrSvg,
value: Direction.Ltr,
label: 'Left to right',
},
{
bgImage: rtlSvg,
value: Direction.Rtl,
label: 'Right to left',
},
]
})
watch(currentDir, () => {
if (currentDir.value === 'rtl')
configStore.isAppRTL = true
else
configStore.isAppRTL = false
})
const isCookieHasAnyValue = ref(false)
const { locale } = useI18n({ useScope: 'global' })
const isActiveLangRTL = computed(() => {
const lang = themeConfig.app.i18n.langConfig.find(l => l.i18nLang === locale.value)
return lang?.isRTL ?? false
})
watch([
() => vuetifyTheme.current.value.colors.primary,
configStore.$state,
locale,
], () => {
const initialConfigValue = [
staticPrimaryColor,
staticPrimaryColor,
themeConfig.app.theme,
themeConfig.app.skin,
themeConfig.verticalNav.isVerticalNavSemiDark,
themeConfig.verticalNav.isVerticalNavCollapsed,
themeConfig.app.contentWidth,
isActiveLangRTL.value,
themeConfig.app.contentLayoutNav,
]
const themeConfigValue = [
vuetifyTheme.themes.value.light.colors.primary,
vuetifyTheme.themes.value.dark.colors.primary,
configStore.theme,
configStore.skin,
configStore.isVerticalNavSemiDark,
configStore.isVerticalNavCollapsed,
configStore.appContentWidth,
configStore.isAppRTL,
configStore.appContentLayoutNav,
]
currentDir.value = configStore.isAppRTL ? 'rtl' : 'ltr'
isCookieHasAnyValue.value = JSON.stringify(themeConfigValue) !== JSON.stringify(initialConfigValue)
}, {
deep: true,
immediate: true,
})
const resetCustomizer = async () => {
if (isCookieHasAnyValue.value) {
vuetifyTheme.themes.value.light.colors.primary = staticPrimaryColor
vuetifyTheme.themes.value.dark.colors.primary = staticPrimaryColor
vuetifyTheme.themes.value.light.colors['primary-darken-1'] = staticPrimaryDarkenColor
vuetifyTheme.themes.value.dark.colors['primary-darken-1'] = staticPrimaryDarkenColor
configStore.theme = themeConfig.app.theme
configStore.skin = themeConfig.app.skin
configStore.isVerticalNavSemiDark = themeConfig.verticalNav.isVerticalNavSemiDark
configStore.appContentLayoutNav = themeConfig.app.contentLayoutNav
configStore.appContentWidth = themeConfig.app.contentWidth
configStore.isAppRTL = isActiveLangRTL.value
configStore.isVerticalNavCollapsed = themeConfig.verticalNav.isVerticalNavCollapsed
useStorage(namespaceConfig('initial-loader-color'), null).value = staticPrimaryColor
currentLayout.value = themeConfig.app.contentLayoutNav
cookieRef('lightThemePrimaryColor', null).value = null
cookieRef('darkThemePrimaryColor', null).value = null
cookieRef('lightThemePrimaryDarkenColor', null).value = null
cookieRef('darkThemePrimaryDarkenColor', null).value = null
await nextTick()
isCookieHasAnyValue.value = false
customPrimaryColor.value = '#ffffff'
}
}
const isCrmEditMode = ref(false)
const handleCrmEdit = () => {
isCrmEditMode.value = !isCrmEditMode.value
console.log('CRM Edit mode:', isCrmEditMode.value ? 'Active' : 'Inactive')
const event = new CustomEvent('crm-edit-mode-changed', {
detail: {
isActive: isCrmEditMode.value
}
});
window.dispatchEvent(event);
}
</script>
<template>
<div class="d-lg-block d-none">
<VBtn icon class="app-customizer-toggler rounded-s-lg rounded-0" style="z-index: 1001;"
@click="isNavDrawerOpen = true">
<VIcon size="22" icon="tabler-settings" />
</VBtn>
<VBtn v-if="isCrmRoute" icon class="app-crm-edit-btn rounded-s-lg rounded-0"
:class="{ 'edit-mode-active': isCrmEditMode }" style="z-index: 1001;"
:color="isCrmEditMode ? 'success' : 'primary'" :variant="isCrmEditMode ? 'flat' : 'elevated'"
@click="handleCrmEdit">
<VIcon size="22" :icon="isCrmEditMode ? 'tabler-check' : 'tabler-edit'" />
<div v-if="isCrmEditMode" class="edit-pulse-ring" />
</VBtn>
<VBtn v-if="isCrmRoute" icon class="app-widget-sidebar-btn rounded-s-lg rounded-0" style="z-index: 1001;"
color="secondary" variant="elevated" @click="isWidgetSidebarOpen = true">
<VIcon size="22" icon="tabler-layout-grid" />
</VBtn>
<VNavigationDrawer v-model="isNavDrawerOpen" data-allow-mismatch temporary touchless border="none" location="end"
width="400" elevation="10" :scrim="false" class="app-customizer">
<div class="customizer-heading d-flex align-center justify-space-between">
<div>
<h6 class="text-h6">
Theme Customizer
</h6>
<p class="text-body-2 mb-0">
Customize & Preview in Real Time
</p>
</div>
<div class="d-flex align-center gap-1">
<VBtn icon variant="text" size="small" color="medium-emphasis" @click="resetCustomizer">
<VBadge v-show="isCookieHasAnyValue" dot color="error" offset-x="-29" offset-y="-14" />
<VIcon size="24" color="high-emphasis" icon="tabler-refresh" />
</VBtn>
<VBtn icon variant="text" color="medium-emphasis" size="small" @click="isNavDrawerOpen = false">
<VIcon icon="tabler-x" color="high-emphasis" size="24" />
</VBtn>
</div>
</div>
<VDivider />
<PerfectScrollbar tag="ul" :options="{ wheelPropagation: false }">
<CustomizerSection title="Theming" :divider="false">
<div class="d-flex flex-column gap-2">
<h6 class="text-h6">
Primary Color
</h6>
<div class="d-flex app-customizer-primary-colors" style="column-gap: 0.75rem; margin-block-start: 2px;">
<div v-for="color in colors" :key="color.main" style="
border-radius: 0.375rem;
outline: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
padding-block: 0.5rem;
padding-inline: 0.625rem;" class="primary-color-wrapper cursor-pointer"
:class="vuetifyTheme.current.value.colors.primary === color.main ? 'active' : ''"
:style="vuetifyTheme.current.value.colors.primary === color.main ? `outline-color: ${color.main}; outline-width:2px;` : `--v-color:${color.main}`"
@click="setPrimaryColor(color)">
<div style="border-radius: 0.375rem;block-size: 2.125rem; inline-size: 1.8938rem;"
:style="{ backgroundColor: color.main }" />
</div>
<div class="primary-color-wrapper cursor-pointer d-flex align-center" style="
border-radius: 0.375rem;
outline: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
padding-block: 0.5rem;
padding-inline: 0.625rem;"
:class="vuetifyTheme.current.value.colors.primary === customPrimaryColor ? 'active' : ''"
:style="vuetifyTheme.current.value.colors.primary === customPrimaryColor ? `outline-color: ${customPrimaryColor}; outline-width:2px;` : ''">
<VBtn icon size="30"
:color="vuetifyTheme.current.value.colors.primary === customPrimaryColor ? customPrimaryColor : $vuetify.theme.current.dark ? '#8692d029' : '#4b465c29'"
variant="flat" style="border-radius: 0.375rem;">
<VIcon size="20" icon="tabler-color-picker"
:color="vuetifyTheme.current.value.colors.primary === customPrimaryColor ? 'rgb(var(--v-theme-on-primary))' : ''" />
</VBtn>
<VMenu activator="parent" :close-on-content-click="false">
<VList>
<VListItem>
<VColorPicker v-model="customPrimaryColor" mode="hex" :modes="['hex']"
@update:model-value="setPrimaryColor({ main: customPrimaryColor, darken: customPrimaryColor })" />
</VListItem>
</VList>
</VMenu>
</div>
</div>
</div>
<div class="d-flex flex-column gap-2">
<h6 class="text-h6">
Theme
</h6>
<CustomRadiosWithImage :key="configStore.theme" v-model:selected-radio="configStore.theme"
:radio-content="themeMode" :grid-column="{ cols: '4' }" class="customizer-skins">
<template #label="item">
<span class="text-sm text-medium-emphasis mt-1">{{ item?.label }}</span>
</template>
<template #content="{ item }">
<div class="customizer-skins-icon-wrapper d-flex align-center justify-center py-3 w-100"
style="min-inline-size: 100%;">
<VIcon size="30" :icon="item.bgImage" color="high-emphasis" />
</div>
</template>
</CustomRadiosWithImage>
</div>
<div class="d-flex flex-column gap-2">
<h6 class="text-h6">
Skins
</h6>
<CustomRadiosWithImage :key="configStore.skin" v-model:selected-radio="configStore.skin"
:radio-content="themeSkin" :grid-column="{ cols: '4' }">
<template #label="item">
<span class="text-sm text-medium-emphasis">{{ item?.label }}</span>
</template>
</CustomRadiosWithImage>
</div>
<div class="align-center justify-space-between"
:class="vuetifyTheme.global.name.value === 'light' && configStore.appContentLayoutNav === AppContentLayoutNav.Vertical ? 'd-flex' : 'd-none'">
<VLabel for="customizer-semi-dark" class="text-h6 text-high-emphasis">
Semi Dark Menu
</VLabel>
<div>
<VSwitch id="customizer-semi-dark" v-model="configStore.isVerticalNavSemiDark" class="ms-2" />
</div>
</div>
</CustomizerSection>
<CustomizerSection title="Layout">
<div class="d-flex flex-column gap-2">
<h6 class="text-base font-weight-medium">
Layout
</h6>
<CustomRadiosWithImage :key="currentLayout" v-model:selected-radio="currentLayout" :radio-content="layouts"
:grid-column="{ cols: '4' }">
<template #label="item">
<span class="text-sm text-medium-emphasis">{{ item.label }}</span>
</template>
</CustomRadiosWithImage>
</div>
<div class="d-flex flex-column gap-2">
<h6 class="text-base font-weight-medium">
Content
</h6>
<CustomRadiosWithImage :key="configStore.appContentWidth"
v-model:selected-radio="configStore.appContentWidth" :radio-content="contentWidth"
:grid-column="{ cols: '4' }">
<template #label="item">
<span class="text-sm text-medium-emphasis">{{ item.label }}</span>
</template>
</CustomRadiosWithImage>
</div>
<div class="d-flex flex-column gap-2">
<h6 class="text-base font-weight-medium">
Direction
</h6>
<CustomRadiosWithImage :key="currentDir" v-model:selected-radio="currentDir" :radio-content="direction"
:grid-column="{ cols: '4' }">
<template #label="item">
<span class="text-sm text-medium-emphasis">{{ item?.label }}</span>
</template>
</CustomRadiosWithImage>
</div>
</CustomizerSection>
</PerfectScrollbar>
</VNavigationDrawer>
<WidgetLibrary v-model="isWidgetSidebarOpen" :isCrmRoute="isCrmRoute" />
</div>
</template>
<style lang="scss">
@use "@layouts/styles/mixins" as layoutMixins;
.app-customizer {
&.v-navigation-drawer--temporary:not(.v-navigation-drawer--active) {
transform: translateX(110%) !important;
@include layoutMixins.rtl {
transform: translateX(-110%) !important;
}
}
.customizer-section {
display: flex;
flex-direction: column;
padding: 1.5rem;
gap: 1.5rem;
}
.customizer-heading {
padding-block: 1rem;
padding-inline: 1.5rem;
}
.custom-input-wrapper {
.v-col {
padding-inline: 10px;
}
.v-label.custom-input {
border: none;
color: rgb(var(--v-theme-on-surface));
outline: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
}
.v-navigation-drawer__content {
display: flex;
flex-direction: column;
}
.v-label.custom-input.active {
border-color: transparent;
outline: 2px solid rgb(var(--v-theme-primary));
}
.v-label.custom-input:not(.active):hover {
border-color: rgba(var(--v-border-color), 0.22);
}
.customizer-skins {
.custom-input.active {
.customizer-skins-icon-wrapper {
background-color: rgba(var(--v-global-theme-primary), var(--v-selected-opacity));
}
}
}
.app-customizer-primary-colors {
.primary-color-wrapper:not(.active) {
&:hover {
outline-color: rgba(var(--v-border-color), 0.22) !important;
}
}
}
}
.app-customizer-toggler {
position: fixed;
top: 35%;
inset-inline-end: 0;
z-index: 1001;
transform: translateY(-50%);
}
.app-crm-edit-btn {
position: fixed;
top: calc(35% + 60px);
inset-inline-end: 0;
z-index: 1001;
transform: translateY(-50%);
transition: all 0.3s ease;
&.edit-mode-active {
animation: pulse 2s infinite;
}
.edit-pulse-ring {
position: absolute;
top: 50%;
left: 50%;
width: 40px;
height: 40px;
border: 2px solid rgb(var(--v-theme-success));
border-radius: 50%;
transform: translate(-50%, -50%);
animation: pulse-ring 1.5s ease-out infinite;
}
}
.app-widget-sidebar-btn {
position: fixed;
top: calc(35% + 120px);
inset-inline-end: 0;
z-index: 1001;
transform: translateY(-50%);
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-success), 0.7);
}
70% {
box-shadow: 0 0 0 10px rgba(var(--v-theme-success), 0);
}
100% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-success), 0);
}
}
@keyframes pulse-ring {
0% {
transform: translate(-50%, -50%) scale(0.8);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scale(1.2);
opacity: 0;
}
}
</style>

View File

@@ -0,0 +1,57 @@
<script setup>
import { useConfigStore } from '@core/stores/config'
const props = defineProps({
themes: {
type: Array,
required: true,
},
})
const configStore = useConfigStore()
const selectedItem = ref([configStore.theme])
// Update icon if theme is changed from other sources
watch(() => configStore.theme, () => {
selectedItem.value = [configStore.theme]
}, { deep: true })
</script>
<template>
<IconBtn color="rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))">
<VIcon :icon="props.themes.find(t => t.name === configStore.theme)?.icon" />
<VTooltip
activator="parent"
open-delay="1000"
scroll-strategy="close"
>
<span class="text-capitalize">{{ configStore.theme }}</span>
</VTooltip>
<VMenu
activator="parent"
offset="12px"
:width="180"
>
<VList
v-model:selected="selectedItem"
mandatory
>
<VListItem
v-for="{ name, icon } in props.themes"
:key="name"
:value="name"
:prepend-icon="icon"
color="primary"
@click="() => { configStore.theme = name }"
>
<VListItemTitle class="text-capitalize">
{{ name }}
</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>

View File

@@ -0,0 +1,171 @@
<script setup>
import { Placeholder } from '@tiptap/extension-placeholder'
import { TextAlign } from '@tiptap/extension-text-align'
import { Underline } from '@tiptap/extension-underline'
import { StarterKit } from '@tiptap/starter-kit'
import {
EditorContent,
useEditor,
} from '@tiptap/vue-3'
const props = defineProps({
modelValue: {
type: String,
required: true,
},
placeholder: {
type: String,
required: false,
},
})
const emit = defineEmits(['update:modelValue'])
const editorRef = ref()
const editor = useEditor({
content: props.modelValue,
extensions: [
StarterKit,
TextAlign.configure({
types: [
'heading',
'paragraph',
],
}),
Placeholder.configure({ placeholder: props.placeholder ?? 'Write something here...' }),
Underline,
],
onUpdate() {
if (!editor.value)
return
emit('update:modelValue', editor.value.getHTML())
},
})
watch(() => props.modelValue, () => {
const isSame = editor.value?.getHTML() === props.modelValue
if (isSame)
return
editor.value?.commands.setContent(props.modelValue)
})
</script>
<template>
<div>
<div
v-if="editor"
class="d-flex gap-2 py-2 px-6 flex-wrap align-center editor"
>
<IconBtn
size="small"
rounded
:variant="editor.isActive('bold') ? 'tonal' : 'text'"
:color="editor.isActive('bold') ? 'primary' : 'default'"
@click="editor.chain().focus().toggleBold().run()"
>
<VIcon icon="tabler-bold" />
</IconBtn>
<IconBtn
size="small"
rounded
:variant="editor.isActive('underline') ? 'tonal' : 'text'"
:color="editor.isActive('underline') ? 'primary' : 'default'"
@click="editor.commands.toggleUnderline()"
>
<VIcon icon="tabler-underline" />
</IconBtn>
<IconBtn
size="small"
rounded
:variant="editor.isActive('italic') ? 'tonal' : 'text'"
:color="editor.isActive('italic') ? 'primary' : 'default'"
@click="editor.chain().focus().toggleItalic().run()"
>
<VIcon
icon="tabler-italic"
class="font-weight-medium"
/>
</IconBtn>
<IconBtn
size="small"
rounded
:variant="editor.isActive('strike') ? 'tonal' : 'text'"
:color="editor.isActive('strike') ? 'primary' : 'default'"
@click="editor.chain().focus().toggleStrike().run()"
>
<VIcon icon="tabler-strikethrough" />
</IconBtn>
<IconBtn
size="small"
rounded
:variant="editor.isActive({ textAlign: 'left' }) ? 'tonal' : 'text'"
:color="editor.isActive({ textAlign: 'left' }) ? 'primary' : 'default'"
@click="editor.chain().focus().setTextAlign('left').run()"
>
<VIcon icon="tabler-align-left" />
</IconBtn>
<IconBtn
size="small"
rounded
:color="editor.isActive({ textAlign: 'center' }) ? 'primary' : 'default'"
:variant="editor.isActive({ textAlign: 'center' }) ? 'tonal' : 'text'"
@click="editor.chain().focus().setTextAlign('center').run()"
>
<VIcon icon="tabler-align-center" />
</IconBtn>
<IconBtn
size="small"
rounded
:variant="editor.isActive({ textAlign: 'right' }) ? 'tonal' : 'text'"
:color="editor.isActive({ textAlign: 'right' }) ? 'primary' : 'default'"
@click="editor.chain().focus().setTextAlign('right').run()"
>
<VIcon icon="tabler-align-right" />
</IconBtn>
<IconBtn
size="small"
rounded
:variant="editor.isActive({ textAlign: 'justify' }) ? 'tonal' : 'text'"
:color="editor.isActive({ textAlign: 'justify' }) ? 'primary' : 'default'"
@click="editor.chain().focus().setTextAlign('justify').run()"
>
<VIcon icon="tabler-align-justified" />
</IconBtn>
</div>
<VDivider />
<EditorContent
ref="editorRef"
:editor="editor"
/>
</div>
</template>
<style lang="scss">
.ProseMirror {
padding: 0.5rem;
min-block-size: 15vh;
outline: none;
p {
margin-block-end: 0;
}
p.is-editor-empty:first-child::before {
block-size: 0;
color: #adb5bd;
content: attr(data-placeholder);
float: inline-start;
pointer-events: none;
}
}
</style>

View File

@@ -0,0 +1,601 @@
<script setup>
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
const props = defineProps({
isCrmRoute: Boolean,
modelValue: Boolean
})
const emit = defineEmits(['update:modelValue'])
const selectedWidgets = ref([])
const isLoading = ref(false)
const searchQuery = ref('')
const availableWidgets = [
{
id: 'leads1',
name: 'Generated Leads 1',
icon: 'tabler-chart-pie',
category: 'Analytics',
description: 'Lead generation progress'
},
{
id: 'leads2',
name: 'Generated Leads 2',
icon: 'tabler-chart-donut',
category: 'Analytics',
description: 'Primary leads overview'
},
{
id: 'leads3',
name: 'Generated Leads 3',
icon: 'tabler-chart-donut-2',
category: 'Analytics',
description: 'Warning leads tracking'
},
{
id: 'project-activity',
name: 'Project Activity',
icon: 'tabler-chart-bar',
category: 'Projects',
description: 'Activity bar chart'
},
{
id: 'analysis1',
name: 'Analysis Card 1',
icon: 'tabler-chart-line',
category: 'Analytics',
description: 'Active projects progress'
},
{
id: 'analysis2',
name: 'Analysis Card 2',
icon: 'tabler-chart-area',
category: 'Analytics',
description: 'Cost overview analysis'
},
{
id: 'cost-overview',
name: 'Cost Overview',
icon: 'tabler-cash',
category: 'Finance',
description: 'Financial cost tracking'
},
{
id: 'earning-reports',
name: 'Earning Reports',
icon: 'tabler-report-money',
category: 'Finance',
description: 'Yearly overview reports'
},
{
id: 'analytics-sales',
name: 'Analytics Sales',
icon: 'tabler-trending-up',
category: 'Sales',
description: 'Sales analytics data'
},
{
id: 'sales-countries',
name: 'Sales by Countries',
icon: 'tabler-world',
category: 'Sales',
description: 'Geographic sales data'
},
{
id: 'project-status',
name: 'Project Status',
icon: 'tabler-calendar-stats',
category: 'Projects',
description: 'Project status overview'
},
{
id: 'active-project',
name: 'Active Project',
icon: 'tabler-folder-open',
category: 'Projects',
description: 'Current active projects'
},
{
id: 'recent-transactions',
name: 'Recent Transactions',
icon: 'tabler-receipt',
category: 'Finance',
description: 'Latest transaction history'
},
{
id: 'activity-timeline',
name: 'Activity Timeline',
icon: 'tabler-timeline',
category: 'Activity',
description: 'Timeline of activities'
},
{
id: 'ecommerce-congratulations',
name: 'Congratulations John',
icon: 'tabler-trophy',
category: 'Ecommerce',
description: 'Congratulations card for achievements'
},
{
id: 'ecommerce-earning-reports',
name: 'Ecommerce Earning Reports',
icon: 'tabler-report-analytics',
category: 'Ecommerce',
description: 'Detailed earning reports and analytics'
},
{
id: 'ecommerce-expenses',
name: 'Expenses Radial Chart',
icon: 'tabler-chart-donut-3',
category: 'Ecommerce',
description: 'Expense breakdown in radial chart'
},
{
id: 'ecommerce-generated-leads',
name: 'Ecommerce Generated Leads',
icon: 'tabler-users-plus',
category: 'Ecommerce',
description: 'Lead generation statistics'
},
{
id: 'ecommerce-invoice-table',
name: 'Invoice Table',
icon: 'tabler-file-invoice',
category: 'Ecommerce',
description: 'Comprehensive invoice management table'
},
{
id: 'ecommerce-order',
name: 'Order Management',
icon: 'tabler-shopping-cart',
category: 'Ecommerce',
description: 'Order tracking and management'
},
{
id: 'ecommerce-popular-products',
name: 'Popular Products',
icon: 'tabler-star',
category: 'Ecommerce',
description: 'Most popular products showcase'
},
{
id: 'ecommerce-revenue-report',
name: 'Revenue Report',
icon: 'tabler-chart-line',
category: 'Ecommerce',
description: 'Revenue analysis and trends'
},
{
id: 'ecommerce-statistics',
name: 'Ecommerce Statistics',
icon: 'tabler-chart-infographic',
category: 'Ecommerce',
description: 'Comprehensive ecommerce statistics'
},
{
id: 'ecommerce-total-profit',
name: 'Total Profit Line Chart',
icon: 'tabler-trending-up-2',
category: 'Ecommerce',
description: 'Total profit visualization'
},
{
id: 'ecommerce-transactions',
name: 'Ecommerce Transactions',
icon: 'tabler-credit-card',
category: 'Ecommerce',
description: 'Transaction history and details'
}
]
const filteredWidgets = computed(() => {
const base = availableWidgets.filter(widget =>
!(defaultWidgetIds.includes(widget.id) && selectedWidgets.value.includes(widget.id))
)
if (!searchQuery.value.trim()) {
return base
}
return base.filter(widget =>
widget.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
widget.description.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
widget.category.toLowerCase().includes(searchQuery.value.toLowerCase())
)
})
const handleDragStart = (e, widget) => {
console.log('Drag started for widget:', widget.id);
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData("text/plain", widget.id);
e.dataTransfer.setData("application/json", JSON.stringify(widget));
e.target.classList.add('dragging');
};
const handleDragEnd = (e) => {
e.target.classList.remove('dragging');
};
onMounted(() => {
syncWidgetState();
window.addEventListener('dashboard-widgets-state', (event) => {
if (event.detail && Array.isArray(event.detail.widgets)) {
selectedWidgets.value = event.detail.widgets;
}
});
window.addEventListener('widget-added-to-dashboard', (event) => {
if (event.detail && event.detail.widgetId) {
const widgetId = event.detail.widgetId;
if (!selectedWidgets.value.includes(widgetId)) {
selectedWidgets.value.push(widgetId);
}
}
});
});
const defaultWidgetIds = [
"leads1",
"leads2",
"leads3",
"project-activity",
"analysis1",
"analysis2",
"cost-overview",
"earning-reports",
"analytics-sales",
"sales-countries",
"project-status",
"active-project",
"recent-transactions",
"activity-timeline"
]
const widgetCategories = computed(() => {
const categories = {}
filteredWidgets.value.forEach(widget => {
if (!categories[widget.category]) {
categories[widget.category] = []
}
categories[widget.category].push(widget)
})
Object.keys(categories).forEach(category => {
const list = categories[category]
const normal = list.filter(w =>
!selectedWidgets.value.includes(w.id) &&
!defaultWidgetIds.includes(w.id)
)
const userAdded = list.filter(w =>
selectedWidgets.value.includes(w.id) &&
!defaultWidgetIds.includes(w.id)
)
const defaultRemoved = list.filter(w =>
defaultWidgetIds.includes(w.id) &&
!selectedWidgets.value.includes(w.id)
)
categories[category] = [
...normal,
...userAdded,
...defaultRemoved
]
})
return categories
})
const addWidgetToCard = async (widgetId) => {
if (isLoading.value || selectedWidgets.value.includes(widgetId)) return
try {
isLoading.value = true
const widget = availableWidgets.find(w => w.id === widgetId)
if (!widget) {
console.error('Widget not found:', widgetId)
return
}
const event = new CustomEvent('add-widget-to-dashboard', {
detail: { widgetId, widget }
})
window.dispatchEvent(event)
} catch (error) {
console.error('Error adding widget:', error)
} finally {
isLoading.value = false
}
}
const removeWidgetFromCard = async (widgetId) => {
if (isLoading.value) return
try {
isLoading.value = true
const widget = availableWidgets.find(w => w.id === widgetId)
if (!widget) {
console.error('Widget not found:', widgetId)
return
}
const event = new CustomEvent('remove-widget-from-dashboard', {
detail: { widgetId, widget }
})
window.dispatchEvent(event)
const index = selectedWidgets.value.indexOf(widgetId)
if (index > -1) {
selectedWidgets.value.splice(index, 1)
}
} catch (error) {
console.error('Error removing widget:', error)
} finally {
isLoading.value = false
}
}
const clearSearch = () => {
searchQuery.value = ''
}
const syncWidgetState = () => {
const event = new CustomEvent('get-dashboard-widgets')
window.dispatchEvent(event)
}
onMounted(() => {
syncWidgetState()
window.addEventListener('dashboard-widgets-state', (event) => {
if (event.detail && Array.isArray(event.detail.widgets)) {
selectedWidgets.value = event.detail.widgets
}
})
})
onUnmounted(() => {
window.removeEventListener('dashboard-widgets-state', () => { })
})
</script>
<template>
<VNavigationDrawer :model-value="modelValue" v-if="isCrmRoute" data-allow-mismatch temporary touchless border="none"
location="end" width="450" elevation="10" :scrim="false" class="widget-sidebar"
@update:model-value="val => emit('update:modelValue', val)">
<div class="widget-sidebar-header">
<div class="header-content">
<div class="header-icon">
<VIcon icon="tabler-layout-grid" size="24" />
</div>
<div class="header-text">
<h6 class="text-h6 mb-1">Widget Library</h6>
<p class="text-body-2 mb-0 text-medium-emphasis">
Add widgets to your dashboard ({{ selectedWidgets.length }} selected)
</p>
</div>
</div>
<VBtn icon variant="text" color="medium-emphasis" size="small" @click="emit('update:modelValue', false)">
<VIcon icon="tabler-x" color="high-emphasis" size="24" />
</VBtn>
</div>
<VDivider />
<div class="pa-4 pb-2">
<VTextField v-model="searchQuery" placeholder="Search widgets..." prepend-inner-icon="tabler-search"
variant="outlined" density="compact" clearable @click:clear="clearSearch" />
</div>
<PerfectScrollbar :options="{ wheelPropagation: false, suppressScrollX: true }" class="widget-sidebar-content">
<div class="pa-4 pt-2" style="min-height: 100%;">
<div v-if="Object.keys(widgetCategories).length === 0" class="empty-state">
<VIcon icon="tabler-search-off" size="48" color="medium-emphasis" class="mb-3" />
<h6 class="text-h6 text-medium-emphasis mb-2">No widgets found</h6>
<p class="text-body-2 text-medium-emphasis">
Try adjusting your search terms
</p>
</div>
<template v-else>
<template v-for="(widgets, category) in widgetCategories" :key="category">
<div class="widget-category mb-6">
<div class="category-header mb-3">
<h6 class="text-subtitle-1 text-high-emphasis font-weight-semibold">
{{ category }} ({{ widgets.length }})
</h6>
<VDivider class="mt-2" />
</div>
<div class="widgets-grid">
<div v-for="widget in widgets" :key="widget.id" class="widget-card" :class="{
'widget-selected': selectedWidgets.includes(widget.id),
'widget-loading': isLoading
}" draggable="true" @dragstart="handleDragStart($event, widget)" @dragend="handleDragEnd">
<div class="widget-preview">
<div class="widget-icon" :class="{ 'selected': selectedWidgets.includes(widget.id) }">
<VIcon :icon="widget.icon" size="20" />
</div>
<div class="widget-info">
<h6 class="widget-name">{{ widget.name }}</h6>
<p class="widget-description">{{ widget.description }}</p>
</div>
</div>
<div class="widget-actions">
<VBtn v-if="!selectedWidgets.includes(widget.id)" size="small" color="primary" variant="flat"
:loading="isLoading" :disabled="isLoading" @click="addWidgetToCard(widget.id)">
<VIcon icon="tabler-plus" size="16" class="me-1" />
Add
</VBtn>
<VBtn v-else size="small" color="error" variant="flat" :loading="isLoading" :disabled="isLoading"
@click="removeWidgetFromCard(widget.id)">
<VIcon icon="tabler-minus" size="16" class="me-1" />
Remove
</VBtn>
</div>
</div>
</div>
</div>
</template>
</template>
</div>
</PerfectScrollbar>
</VNavigationDrawer>
</template>
<style lang="scss">
@use "@layouts/styles/mixins" as layoutMixins;
.widget-sidebar {
&.v-navigation-drawer--temporary:not(.v-navigation-drawer--active) {
transform: translateX(110%) !important;
@include layoutMixins.rtl {
transform: translateX(-110%) !important;
}
}
.widget-sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
.header-content {
display: flex;
align-items: center;
gap: 0.75rem;
.header-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 0.5rem;
background-color: rgba(var(--v-theme-primary), 0.12);
color: rgb(var(--v-theme-primary));
}
.header-text {
h6 {
color: rgb(var(--v-theme-on-surface));
font-weight: 600;
}
}
}
}
.widget-sidebar-content {
flex: 1;
max-height: calc(100vh - 180px);
overflow-y: auto;
}
.widget-category {
.category-header {
h6 {
color: rgb(var(--v-theme-on-surface));
margin-bottom: 0.5rem;
}
}
.widgets-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
}
.widget-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 0.5rem;
background-color: rgb(var(--v-theme-surface));
transition: all 0.2s ease;
cursor: pointer;
&[draggable="true"] {
cursor: grab;
}
&:active {
cursor: grabbing;
}
&.dragging {
opacity: 0.5;
transform: scale(0.95);
}
&:hover {
border-color: rgba(var(--v-theme-primary), 0.3);
box-shadow: 0 2px 8px rgba(var(--v-theme-primary), 0.1);
}
&.widget-selected {
border-color: rgb(var(--v-theme-success));
background-color: rgba(var(--v-theme-success), 0.08);
}
.widget-preview {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
.widget-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 0.375rem;
background-color: rgba(var(--v-theme-primary), 0.12);
color: rgb(var(--v-theme-primary));
transition: all 0.2s ease;
&.selected {
background-color: rgba(var(--v-theme-success), 0.12);
color: rgb(var(--v-theme-success));
}
}
.widget-info {
flex: 1;
.widget-name {
font-size: 0.875rem;
font-weight: 600;
color: rgb(var(--v-theme-on-surface));
margin-bottom: 0.25rem;
line-height: 1.2;
}
.widget-description {
font-size: 0.75rem;
color: rgba(var(--v-theme-on-surface), 0.7);
margin: 0;
line-height: 1.2;
}
}
}
.widget-actions {
margin-left: 0.75rem;
}
&.widget-loading {
pointer-events: none;
opacity: 0.7;
}
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
text-align: center;
}
.v-text-field {
.v-field__outline {
border-radius: 0.5rem;
}
}
}
</style>

View File

@@ -0,0 +1,58 @@
<script setup>
defineOptions({
name: 'AppAutocomplete',
inheritAttrs: false,
})
// const { class: _class, label, variant: _, ...restAttrs } = useAttrs()
const elementId = computed(() => {
const attrs = useAttrs()
const _elementIdToken = attrs.id
const _id = useId()
return _elementIdToken ? `app-autocomplete-${ _elementIdToken }` : _id
})
const label = computed(() => useAttrs().label)
</script>
<template>
<div
class="app-autocomplete flex-grow-1"
:class="$attrs.class"
>
<VLabel
v-if="label"
:for="elementId"
class="mb-1 text-body-2"
:text="label"
/>
<VAutocomplete
v-bind="{
...$attrs,
class: null,
label: undefined,
id: elementId,
variant: 'outlined',
menuProps: {
contentClass: [
'app-inner-list',
'app-autocomplete__content',
'v-autocomplete__content',
],
},
}"
>
<template
v-for="(_, name) in $slots"
#[name]="slotProps"
>
<slot
:name="name"
v-bind="slotProps || {}"
/>
</template>
</VAutocomplete>
</div>
</template>

View File

@@ -0,0 +1,58 @@
<script setup>
defineOptions({
name: 'AppCombobox',
inheritAttrs: false,
})
const elementId = computed(() => {
const attrs = useAttrs()
const _elementIdToken = attrs.id
const _id = useId()
return _elementIdToken ? `app-combobox-${ _elementIdToken }` : _id
})
const label = computed(() => useAttrs().label)
</script>
<template>
<div
class="app-combobox flex-grow-1"
:class="$attrs.class"
>
<VLabel
v-if="label"
:for="elementId"
class="mb-1 text-body-2"
:text="label"
/>
<VCombobox
v-bind="{
...$attrs,
class: null,
label: undefined,
variant: 'outlined',
id: elementId,
menuProps: {
contentClass: [
'app-inner-list',
'app-combobox__content',
'v-combobox__content',
$attrs.multiple !== undefined ? 'v-list-select-multiple' : '',
],
},
}"
>
<template
v-for="(_, name) in $slots"
#[name]="slotProps"
>
<slot
:name="name"
v-bind="slotProps || {}"
/>
</template>
</VCombobox>
</div>
</template>

View File

@@ -0,0 +1,536 @@
<script setup>
import FlatPickr from 'vue-flatpickr-component'
import { useTheme } from 'vuetify'
import {
VField,
filterFieldProps,
makeVFieldProps,
} from 'vuetify/lib/components/VField/VField'
import {
VInput,
makeVInputProps,
} from 'vuetify/lib/components/VInput/VInput'
import { filterInputAttrs } from 'vuetify/lib/util/helpers'
import { useConfigStore } from '@core/stores/config'
const props = defineProps({
autofocus: Boolean,
counter: [
Boolean,
Number,
String,
],
counterValue: Function,
prefix: String,
placeholder: String,
persistentPlaceholder: Boolean,
persistentCounter: Boolean,
suffix: String,
type: {
type: String,
default: 'text',
},
modelModifiers: Object,
...makeVInputProps({
density: 'comfortable',
hideDetails: 'auto',
}),
...makeVFieldProps({
variant: 'outlined',
color: 'primary',
}),
})
const emit = defineEmits([
'click:control',
'mousedown:control',
'update:focused',
'update:modelValue',
'click:clear',
])
defineOptions({
inheritAttrs: false,
})
const configStore = useConfigStore()
const attrs = useAttrs()
const [rootAttrs, compAttrs] = filterInputAttrs(attrs)
const inputProps = ref(VInput.filterProps(props))
const fieldProps = ref(filterFieldProps(props))
const refFlatPicker = ref()
const { focused } = useFocus(refFlatPicker)
const isCalendarOpen = ref(false)
const isInlinePicker = ref(false)
// flat picker prop manipulation
if (compAttrs.config && compAttrs.config.inline) {
isInlinePicker.value = compAttrs.config.inline
Object.assign(compAttrs, { altInputClass: 'inlinePicker' })
}
compAttrs.config = {
...compAttrs.config,
prevArrow: '<i class="tabler-chevron-left v-icon" style="font-size: 20px; height: 20px; width: 20px;"></i>',
nextArrow: '<i class="tabler-chevron-right v-icon" style="font-size: 20px; height: 20px; width: 20px;"></i>',
}
const onClear = el => {
el.stopPropagation()
nextTick(() => {
emit('update:modelValue', '')
emit('click:clear', el)
})
}
const vuetifyTheme = useTheme()
const vuetifyThemesName = Object.keys(vuetifyTheme.themes.value)
// Themes class added to flat-picker component for light and dark support
const updateThemeClassInCalendar = () => {
// Flatpickr don't render it's instance in mobile and device simulator
if (!refFlatPicker.value.fp.calendarContainer)
return
vuetifyThemesName.forEach(t => {
refFlatPicker.value.fp.calendarContainer.classList.remove(`v-theme--${ t }`)
})
refFlatPicker.value.fp.calendarContainer.classList.add(`v-theme--${ vuetifyTheme.global.name.value }`)
}
watch(() => configStore.theme, updateThemeClassInCalendar)
onMounted(() => {
updateThemeClassInCalendar()
})
const emitModelValue = val => {
emit('update:modelValue', val)
}
watch(() => props, () => {
fieldProps.value = filterFieldProps(props)
inputProps.value = VInput.filterProps(props)
}, {
deep: true,
immediate: true,
})
const elementId = computed(() => {
const _elementIdToken = fieldProps.value.id || fieldProps.value.label || inputProps.value.id
const _id = useId()
return _elementIdToken ? `app-picker-field-${ _elementIdToken }` : _id
})
</script>
<template>
<div class="app-picker-field">
<!-- v-input -->
<VLabel
v-if="fieldProps.label"
class="mb-1 text-body-2"
:for="elementId"
:text="fieldProps.label"
/>
<VInput
v-bind="{ ...inputProps, ...rootAttrs }"
:model-value="modelValue"
:hide-details="props.hideDetails"
:class="[{
'v-text-field--prefixed': props.prefix,
'v-text-field--suffixed': props.suffix,
'v-text-field--flush-details': ['plain', 'underlined'].includes(props.variant),
}, props.class]"
class="position-relative v-text-field"
:style="props.style"
>
<template #default="{ id, isDirty, isValid, isDisabled, isReadonly, validate }">
<!-- v-field -->
<VField
v-bind="{ ...fieldProps, label: undefined }"
:id="id.value"
role="textbox"
:active="focused || isDirty.value || isCalendarOpen"
:focused="focused || isCalendarOpen"
:dirty="isDirty.value || props.dirty"
:error="isValid.value === false"
:disabled="isDisabled.value"
@click:clear="onClear"
>
<template #default="{ props: vFieldProps }">
<div v-bind="vFieldProps">
<!-- flat-picker -->
<FlatPickr
v-if="!isInlinePicker"
v-bind="compAttrs"
ref="refFlatPicker"
:model-value="modelValue"
:placeholder="props.placeholder"
:readonly="isReadonly.value"
class="flat-picker-custom-style h-100 w-100"
:disabled="isReadonly.value"
@on-open="isCalendarOpen = true"
@on-close="isCalendarOpen = false; validate()"
@update:model-value="emitModelValue"
/>
<!-- simple input for inline prop -->
<input
v-if="isInlinePicker"
:value="modelValue"
:placeholder="props.placeholder"
:readonly="isReadonly.value"
class="flat-picker-custom-style h-100 w-100"
type="text"
>
</div>
</template>
</VField>
</template>
</VInput>
<!-- flat picker for inline props -->
<FlatPickr
v-if="isInlinePicker"
v-bind="compAttrs"
ref="refFlatPicker"
:model-value="modelValue"
@update:model-value="emitModelValue"
@on-open="isCalendarOpen = true"
@on-close="isCalendarOpen = false"
/>
</div>
</template>
<style lang="scss">
@use "@core-scss/template/mixins" as templateMixins;
/* stylelint-disable no-descending-specificity */
@use "flatpickr/dist/flatpickr.css";
@use "@core-scss/base/mixins";
.flat-picker-custom-style {
position: absolute;
color: inherit;
inline-size: 100%;
inset: 0;
outline: none;
padding-block: 0;
padding-inline: var(--v-field-padding-start);
}
$heading-color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));
$body-color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));
$disabled-color: rgba(var(--v-theme-on-background), var(--v-disabled-opacity));
// hide the input when your picker is inline
input[altinputclass="inlinePicker"] {
display: none;
}
.flatpickr-time input.flatpickr-hour {
font-weight: 400;
}
.flatpickr-calendar {
@include mixins.elevation(6);
background-color: rgb(var(--v-theme-surface));
inline-size: 16.875rem;
.flatpickr-day:focus {
border-color: rgba(var(--v-border-color), var(--v-border-opacity));
background: rgba(var(--v-border-color), var(--v-border-opacity));
}
.flatpickr-rContainer {
.flatpickr-weekdays {
block-size: 1.25rem;
padding-inline: 0.5625rem;
}
.flatpickr-days {
min-inline-size: 16.875rem;
.dayContainer {
justify-content: center !important;
inline-size: 16.875rem;
min-inline-size: 16.875rem;
padding-block: 0.75rem 0.5rem;
.flatpickr-day {
block-size: 2.25rem;
font-size: 0.9375rem;
line-height: 2.25rem;
margin-block-start: 0 !important;
max-inline-size: 2.25rem;
}
}
}
}
.flatpickr-day {
color: $body-color;
&.today {
&:not(.selected) {
border: none !important;
background: rgba(var(--v-theme-primary), 0.24);
color: rgb(var(--v-theme-primary));
}
&:hover {
border: none !important;
background: rgba(var(--v-theme-primary), 0.24);
color: rgb(var(--v-theme-primary));
}
}
&.selected,
&.selected:hover {
border-color: rgb(var(--v-theme-primary));
background: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
@include templateMixins.custom-elevation(var(--v-theme-primary), "sm");
}
&.inRange,
&.inRange:hover {
border: none;
background: rgba(var(--v-theme-primary), var(--v-activated-opacity)) !important;
box-shadow: none !important;
color: rgb(var(--v-theme-primary));
}
&.startRange {
@include templateMixins.custom-elevation(var(--v-theme-primary), "sm");
}
&.endRange {
@include templateMixins.custom-elevation(var(--v-theme-primary), "sm");
}
&.startRange,
&.endRange,
&.startRange:hover,
&.endRange:hover {
border-color: rgb(var(--v-theme-primary));
background: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
}
&.selected.startRange + .endRange:not(:nth-child(7n + 1)),
&.startRange.startRange + .endRange:not(:nth-child(7n + 1)),
&.endRange.startRange + .endRange:not(:nth-child(7n + 1)) {
box-shadow: -10px 0 0 rgb(var(--v-theme-primary));
}
&.flatpickr-disabled,
&.prevMonthDay:not(.startRange,.inRange),
&.nextMonthDay:not(.endRange,.inRange) {
opacity: var(--v-disabled-opacity);
}
&:hover {
border-color: transparent;
background: rgba(var(--v-theme-on-surface), 0.06);
}
}
.flatpickr-weekday {
color: $heading-color;
font-size: 0.8125rem;
font-weight: 400;
inline-size: 2.25rem;
line-height: 1.25rem;
}
.flatpickr-days {
inline-size: 16.875rem;
}
&::after,
&::before {
display: none;
}
.flatpickr-months {
.flatpickr-prev-month,
.flatpickr-next-month {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
fill: $body-color;
&:hover {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
&:hover i,
&:hover svg {
fill: $body-color;
}
}
}
.flatpickr-current-month span.cur-month {
font-weight: 300;
}
&.open {
// Open calendar above overlay
z-index: 2401;
}
&.hasTime.open {
.flatpickr-innerContainer + .flatpickr-time {
block-size: auto;
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.flatpickr-time {
border-block-start: none;
}
.flatpickr-hour,
.flatpickr-minute,
.flatpickr-am-pm {
font-size: 0.9375rem;
}
}
}
.v-theme--dark .flatpickr-calendar {
box-shadow: 0 3px 14px 0 rgb(15 20 34 / 38%);
}
// Time picker hover & focus bg color
.flatpickr-time input:hover,
.flatpickr-time .flatpickr-am-pm:hover,
.flatpickr-time input:focus,
.flatpickr-time .flatpickr-am-pm:focus {
background: transparent;
}
// Time picker
.flatpickr-time {
.flatpickr-am-pm,
.flatpickr-time-separator,
input {
color: $body-color;
}
.numInputWrapper {
span {
&.arrowUp {
&::after {
border-block-end-color: rgb(var(--v-border-color));
}
}
&.arrowDown {
&::after {
border-block-start-color: rgb(var(--v-border-color));
}
}
}
}
}
// Added bg color for flatpickr input only as it has default readonly attribute
.flatpickr-input[readonly],
.flatpickr-input ~ .form-control[readonly],
.flatpickr-human-friendly[readonly] {
background-color: inherit;
}
// week sections
.flatpickr-weekdays {
margin-block: 0.375rem;
}
// Month and year section
.flatpickr-current-month {
.flatpickr-monthDropdown-months {
appearance: none;
}
.flatpickr-monthDropdown-months,
.numInputWrapper {
padding: 2px;
border-radius: 4px;
color: $heading-color;
font-size: 0.9375rem;
font-weight: 400;
line-height: 1.375rem;
transition: all 0.15s ease-out;
span {
display: none;
}
.flatpickr-monthDropdown-month {
background-color: rgb(var(--v-theme-surface));
}
.numInput.cur-year {
font-weight: 400;
}
}
}
.flatpickr-day.flatpickr-disabled,
.flatpickr-day.flatpickr-disabled:hover {
color: $body-color;
}
.flatpickr-months {
padding-block: 0.75rem;
padding-inline: 1rem;
.flatpickr-prev-month,
.flatpickr-next-month {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border-radius: 5rem;
background: rgba(var(--v-theme-on-surface), var(--v-selected-opacity));
block-size: 1.875rem;
inline-size: 1.875rem;
inset-block-start: 15px !important;
&.flatpickr-disabled {
display: inline;
opacity: var(--v-disabled-opacity);
pointer-events: none;
}
}
.flatpickr-next-month {
inset-inline-end: 1.05rem !important;
}
.flatpickr-prev-month {
/* stylelint-disable-next-line liberty/use-logical-spec */
right: 3.65rem;
left: unset !important;
}
.flatpickr-month {
display: flex;
align-items: center;
block-size: 2.125rem;
.flatpickr-current-month {
display: flex;
align-items: center;
padding: 0;
block-size: 1.75rem;
inset-inline-start: 0;
text-align: start;
}
}
}
</style>

View File

@@ -0,0 +1,51 @@
<script setup>
defineOptions({
name: 'AppSelect',
inheritAttrs: false,
})
const elementId = computed(() => {
const attrs = useAttrs()
const _elementIdToken = attrs.id
const _id = useId()
return _elementIdToken ? `app-select-${ _elementIdToken }` : _id
})
const label = computed(() => useAttrs().label)
</script>
<template>
<div
class="app-select flex-grow-1"
:class="$attrs.class"
>
<VLabel
v-if="label"
:for="elementId"
class="mb-1 text-body-2"
style="line-height: 15px;"
:text="label"
/>
<VSelect
v-bind="{
...$attrs,
class: null,
label: undefined,
variant: 'outlined',
id: elementId,
menuProps: { contentClass: ['app-inner-list', 'app-select__content', 'v-select__content', $attrs.multiple !== undefined ? 'v-list-select-multiple' : ''] },
}"
>
<template
v-for="(_, name) in $slots"
#[name]="slotProps"
>
<slot
:name="name"
v-bind="slotProps || {}"
/>
</template>
</VSelect>
</div>
</template>

View File

@@ -0,0 +1,50 @@
<script setup>
defineOptions({
name: 'AppTextField',
inheritAttrs: false,
})
const elementId = computed(() => {
const attrs = useAttrs()
const _elementIdToken = attrs.id
const _id = useId()
return _elementIdToken ? `app-text-field-${ _elementIdToken }` : _id
})
const label = computed(() => useAttrs().label)
</script>
<template>
<div
class="app-text-field flex-grow-1"
:class="$attrs.class"
>
<VLabel
v-if="label"
:for="elementId"
class="mb-1 text-body-2 text-wrap"
style="line-height: 15px;"
:text="label"
/>
<VTextField
v-bind="{
...$attrs,
class: null,
label: undefined,
variant: 'outlined',
id: elementId,
}"
>
<template
v-for="(_, name) in $slots"
#[name]="slotProps"
>
<slot
:name="name"
v-bind="slotProps || {}"
/>
</template>
</VTextField>
</div>
</template>

View File

@@ -0,0 +1,51 @@
<script setup>
defineOptions({
name: 'AppTextarea',
inheritAttrs: false,
})
// const { class: _class, label, variant: _, ...restAttrs } = useAttrs()
const elementId = computed(() => {
const attrs = useAttrs()
const _elementIdToken = attrs.id
const _id = useId()
return _elementIdToken ? `app-textarea-${ _elementIdToken }` : _id
})
const label = computed(() => useAttrs().label)
</script>
<template>
<div
class="app-textarea flex-grow-1"
:class="$attrs.class"
>
<VLabel
v-if="label"
:for="elementId"
class="mb-1 text-body-2"
:text="label"
/>
<VTextarea
v-bind="{
...$attrs,
class: null,
label: undefined,
variant: 'outlined',
id: elementId,
}"
>
<template
v-for="(_, name) in $slots"
#[name]="slotProps"
>
<slot
:name="name"
v-bind="slotProps || {}"
/>
</template>
</VTextarea>
</div>
</template>

View File

@@ -0,0 +1,83 @@
<script setup>
const props = defineProps({
selectedCheckbox: {
type: Array,
required: true,
},
checkboxContent: {
type: Array,
required: true,
},
gridColumn: {
type: null,
required: false,
},
})
const emit = defineEmits(['update:selectedCheckbox'])
const updateSelectedOption = value => {
if (typeof value !== 'boolean' && value !== null)
emit('update:selectedCheckbox', value)
}
</script>
<template>
<VRow
v-if="props.checkboxContent && props.selectedCheckbox"
class="custom-input-wrapper"
>
<VCol
v-for="item in props.checkboxContent"
:key="item.title"
v-bind="gridColumn"
>
<VLabel
class="custom-input custom-checkbox rounded cursor-pointer"
:class="props.selectedCheckbox.includes(item.value) ? 'active' : ''"
>
<div>
<VCheckbox
:model-value="props.selectedCheckbox"
:value="item.value"
@update:model-value="updateSelectedOption"
/>
</div>
<slot :item="item">
<div class="flex-grow-1">
<div class="d-flex align-center mb-2">
<h6 class="cr-title text-base">
{{ item.title }}
</h6>
<VSpacer />
<span
v-if="item.subtitle"
class="text-disabled text-body-2"
>{{ item.subtitle }}</span>
</div>
<p class="text-sm mb-0">
{{ item.desc }}
</p>
</div>
</slot>
</VLabel>
</VCol>
</VRow>
</template>
<style lang="scss" scoped>
.custom-checkbox {
display: flex;
align-items: flex-start;
gap: 0.5rem;
.v-checkbox {
margin-block-start: -0.375rem;
}
.cr-title {
font-weight: 500;
line-height: 1.375rem;
}
}
</style>

View File

@@ -0,0 +1,97 @@
<script setup>
const props = defineProps({
selectedCheckbox: {
type: Array,
required: true,
},
checkboxContent: {
type: Array,
required: true,
},
gridColumn: {
type: null,
required: false,
},
})
const emit = defineEmits(['update:selectedCheckbox'])
const updateSelectedOption = value => {
if (typeof value !== 'boolean' && value !== null)
emit('update:selectedCheckbox', value)
}
</script>
<template>
<VRow
v-if="props.checkboxContent && props.selectedCheckbox"
class="custom-input-wrapper"
>
<VCol
v-for="item in props.checkboxContent"
:key="item.title"
v-bind="gridColumn"
>
<VLabel
class="custom-input custom-checkbox-icon rounded cursor-pointer"
:class="props.selectedCheckbox.includes(item.value) ? 'active' : ''"
>
<slot :item="item">
<div class="d-flex flex-column align-center text-center gap-2">
<VIcon
v-bind="item.icon"
class="text-high-emphasis"
/>
<h6 class="cr-title text-base">
{{ item.title }}
</h6>
<p class="text-sm clamp-text mb-0">
{{ item.desc }}
</p>
</div>
</slot>
<div>
<VCheckbox
:model-value="props.selectedCheckbox"
:value="item.value"
@update:model-value="updateSelectedOption"
/>
</div>
</VLabel>
</VCol>
</VRow>
</template>
<style lang="scss" scoped>
.custom-checkbox-icon {
display: flex;
flex-direction: column;
gap: 0.5rem;
.v-checkbox {
margin-block-end: -0.375rem;
.v-selection-control__wrapper {
margin-inline-start: 0;
}
}
.cr-title {
font-weight: 500;
line-height: 1.375rem;
}
}
</style>
<style lang="scss">
.custom-checkbox-icon {
.v-checkbox {
margin-block-end: -0.375rem;
.v-selection-control__wrapper {
margin-inline-start: 0;
}
}
}
</style>

View File

@@ -0,0 +1,95 @@
<script setup>
const props = defineProps({
selectedCheckbox: {
type: Array,
required: true,
},
checkboxContent: {
type: Array,
required: true,
},
gridColumn: {
type: null,
required: false,
},
})
const emit = defineEmits(['update:selectedCheckbox'])
const updateSelectedOption = value => {
if (typeof value !== 'boolean' && value !== null)
emit('update:selectedCheckbox', value)
}
</script>
<template>
<VRow
v-if="props.checkboxContent && props.selectedCheckbox"
class="custom-input-wrapper"
>
<VCol
v-for="item in props.checkboxContent"
:key="item.value"
v-bind="gridColumn"
>
<VLabel
class="custom-input custom-checkbox rounded cursor-pointer w-100"
:class="props.selectedCheckbox.includes(item.value) ? 'active' : ''"
>
<div>
<VCheckbox
:id="`custom-checkbox-with-img-${item.value}`"
:model-value="props.selectedCheckbox"
:value="item.value"
@update:model-value="updateSelectedOption"
/>
</div>
<img
:src="item.bgImage"
alt="bg-img"
class="custom-checkbox-image"
>
</VLabel>
<VLabel
v-if="item.label || $slots.label"
:for="`custom-checkbox-with-img-${item.value}`"
class="cursor-pointer"
>
<slot
name="label"
:label="item.label"
>
{{ item.label }}
</slot>
</VLabel>
</VCol>
</VRow>
</template>
<style lang="scss" scoped>
.custom-checkbox {
position: relative;
padding: 0;
.custom-checkbox-image {
block-size: 100%;
inline-size: 100%;
min-inline-size: 100%;
}
.v-checkbox {
position: absolute;
inset-block-start: 0;
inset-inline-end: 0;
visibility: hidden;
}
&:hover,
&.active {
.v-checkbox {
visibility: visible;
}
}
}
</style>

View File

@@ -0,0 +1,86 @@
<script setup>
const props = defineProps({
selectedRadio: {
type: String,
required: true,
},
radioContent: {
type: Array,
required: true,
},
gridColumn: {
type: null,
required: false,
},
})
const emit = defineEmits(['update:selectedRadio'])
const updateSelectedOption = value => {
if (value !== null)
emit('update:selectedRadio', value)
}
</script>
<template>
<VRadioGroup
v-if="props.radioContent"
:model-value="props.selectedRadio"
class="custom-input-wrapper"
@update:model-value="updateSelectedOption"
>
<VRow>
<VCol
v-for="item in props.radioContent"
:key="item.title"
v-bind="gridColumn"
>
<VLabel
class="custom-input custom-radio rounded cursor-pointer"
:class="props.selectedRadio === item.value ? 'active' : ''"
>
<div>
<VRadio
:name="item.value"
:value="item.value"
/>
</div>
<slot :item="item">
<div class="flex-grow-1">
<div class="d-flex align-center mb-2">
<h6 class="cr-title text-base">
{{ item.title }}
</h6>
<VSpacer />
<span
v-if="item.subtitle"
class="text-disabled text-body-2"
>{{ item.subtitle }}</span>
</div>
<p class="text-body-2 mb-0">
{{ item.desc }}
</p>
</div>
</slot>
</VLabel>
</VCol>
</VRow>
</VRadioGroup>
</template>
<style lang="scss" scoped>
.custom-radio {
display: flex;
align-items: flex-start;
gap: 0.25rem;
.v-radio {
margin-block-start: -0.45rem;
}
.cr-title {
font-weight: 500;
line-height: 1.375rem;
}
}
</style>

View File

@@ -0,0 +1,89 @@
<script setup>
const props = defineProps({
selectedRadio: {
type: String,
required: true,
},
radioContent: {
type: Array,
required: true,
},
gridColumn: {
type: null,
required: false,
},
})
const emit = defineEmits(['update:selectedRadio'])
const updateSelectedOption = value => {
if (value !== null)
emit('update:selectedRadio', value)
}
</script>
<template>
<VRadioGroup
v-if="props.radioContent"
:model-value="props.selectedRadio"
class="custom-input-wrapper"
@update:model-value="updateSelectedOption"
>
<VRow>
<VCol
v-for="item in props.radioContent"
:key="item.title"
v-bind="gridColumn"
>
<VLabel
class="custom-input custom-radio-icon rounded cursor-pointer"
:class="props.selectedRadio === item.value ? 'active' : ''"
>
<slot :item="item">
<div class="d-flex flex-column align-center text-center gap-2">
<VIcon
v-bind="item.icon"
class="text-high-emphasis"
/>
<h6 class="text-h6">
{{ item.title }}
</h6>
<p class="text-body-2 mb-0">
{{ item.desc }}
</p>
</div>
</slot>
<div>
<VRadio :value="item.value" />
</div>
</VLabel>
</VCol>
</VRow>
</VRadioGroup>
</template>
<style lang="scss" scoped>
.custom-radio-icon {
display: flex;
flex-direction: column;
gap: 0.5rem;
.v-radio {
margin-block-end: -0.5rem;
}
}
</style>
<style lang="scss">
.custom-radio-icon {
.v-radio {
margin-block-end: -0.25rem;
.v-selection-control__wrapper {
margin-inline-start: 0;
}
}
}
</style>

View File

@@ -0,0 +1,102 @@
<script setup>
const props = defineProps({
selectedRadio: {
type: String,
required: true,
},
radioContent: {
type: Array,
required: true,
},
gridColumn: {
type: null,
required: false,
},
})
const emit = defineEmits(['update:selectedRadio'])
const updateSelectedOption = value => {
if (value !== null)
emit('update:selectedRadio', value)
}
</script>
<template>
<VRadioGroup
v-if="props.radioContent"
:model-value="props.selectedRadio"
class="custom-input-wrapper"
@update:model-value="updateSelectedOption"
>
<VRow>
<VCol
v-for="item in props.radioContent"
:key="item.bgImage"
v-bind="gridColumn"
>
<VLabel
class="custom-input custom-radio rounded cursor-pointer w-100"
:class="props.selectedRadio === item.value ? 'active' : ''"
>
<slot
name="content"
:item="item"
>
<template v-if="typeof item.bgImage === 'object'">
<Component
:is="item.bgImage"
class="custom-radio-image"
/>
</template>
<img
v-else
:src="item.bgImage"
alt="bg-img"
class="custom-radio-image"
>
</slot>
<VRadio
:id="`custom-radio-with-img-${item.value}`"
:name="`custom-radio-with-img-${item.value}`"
:value="item.value"
/>
</VLabel>
<VLabel
v-if="item.label || $slots.label"
:for="`custom-radio-with-img-${item.value}`"
class="cursor-pointer"
>
<slot
name="label"
:label="item.label"
>
{{ item.label }}
</slot>
</VLabel>
</VCol>
</VRow>
</VRadioGroup>
</template>
<style lang="scss" scoped>
.custom-radio {
padding: 0 !important;
&.active {
border-width: 1px;
}
.custom-radio-image {
block-size: 100%;
inline-size: 100%;
min-inline-size: 100%;
}
.v-radio {
visibility: hidden;
}
}
</style>

View File

@@ -0,0 +1,183 @@
<script setup>
const props = defineProps({
collapsed: {
type: Boolean,
required: false,
default: false,
},
noActions: {
type: Boolean,
required: false,
default: false,
},
actionCollapsed: {
type: Boolean,
required: false,
default: false,
},
actionRefresh: {
type: Boolean,
required: false,
default: false,
},
actionRemove: {
type: Boolean,
required: false,
default: false,
},
loading: {
type: Boolean,
required: false,
skipCheck: true,
default: undefined,
},
title: {
type: String,
required: false,
default: undefined,
},
})
const emit = defineEmits([
'collapsed',
'refresh',
'trash',
'initialLoad',
'update:loading',
])
defineOptions({
inheritAttrs: false,
})
const _loading = ref(false)
const $loading = computed({
get() {
return props.loading !== undefined ? props.loading : _loading.value
},
set(value) {
props.loading !== undefined ? emit('update:loading', value) : _loading.value = value
},
})
const isContentCollapsed = ref(props.collapsed)
const isCardRemoved = ref(false)
// stop loading
const stopLoading = () => {
$loading.value = false
}
// trigger collapse
const triggerCollapse = () => {
isContentCollapsed.value = !isContentCollapsed.value
emit('collapsed', isContentCollapsed.value)
}
// trigger refresh
const triggerRefresh = () => {
$loading.value = true
emit('refresh', stopLoading)
}
// trigger removal
const triggeredRemove = () => {
isCardRemoved.value = true
emit('trash')
}
</script>
<template>
<VExpandTransition>
<!-- TODO remove div when transition work with v-card components: https://github.com/vuetifyjs/vuetify/issues/15111 -->
<div v-if="!isCardRemoved">
<VCard v-bind="$attrs">
<VCardItem>
<VCardTitle v-if="props.title || $slots.title">
<!-- 👉 Title slot and prop -->
<slot name="title">
{{ props.title }}
</slot>
</VCardTitle>
<template #append>
<!-- 👉 Before actions slot -->
<div>
<slot name="before-actions" />
<!-- SECTION Actions buttons -->
<!-- 👉 Collapse button -->
<IconBtn
v-if="(!(actionRemove || actionRefresh) || actionCollapsed) && !noActions"
@click="triggerCollapse"
>
<VIcon
size="20"
icon="tabler-chevron-up"
:style="{ transform: isContentCollapsed ? 'rotate(-180deg)' : undefined }"
style="transition-duration: 0.28s;"
/>
</IconBtn>
<!-- 👉 Overlay button -->
<IconBtn
v-if="(!(actionRemove || actionCollapsed) || actionRefresh) && !noActions"
@click="triggerRefresh"
>
<VIcon
size="20"
icon="tabler-refresh"
/>
</IconBtn>
<!-- 👉 Close button -->
<IconBtn
v-if="(!(actionRefresh || actionCollapsed) || actionRemove) && !noActions"
@click="triggeredRemove"
>
<VIcon
size="20"
icon="tabler-x"
/>
</IconBtn>
</div>
<!-- !SECTION -->
</template>
</VCardItem>
<!-- 👉 card content -->
<VExpandTransition>
<div
v-show="!isContentCollapsed"
class="v-card-content"
>
<slot />
</div>
</VExpandTransition>
<!-- 👉 Overlay -->
<VOverlay
v-model="$loading"
contained
persistent
scroll-strategy="none"
class="align-center justify-center"
>
<VProgressCircular indeterminate />
</VOverlay>
</VCard>
</div>
</VExpandTransition>
</template>
<style lang="scss">
.v-card-item {
+.v-card-content {
.v-card-text:first-child {
padding-block-start: 0;
}
}
}
</style>

View File

@@ -0,0 +1,159 @@
<script setup>
import { getSingletonHighlighter } from 'shiki'
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
const props = defineProps({
title: {
type: String,
required: true,
},
code: {
type: Object,
required: true,
},
codeLanguage: {
type: String,
required: false,
default: 'markup',
},
noPadding: {
type: Boolean,
required: false,
default: false,
},
})
const preferredCodeLanguage = useCookie('preferredCodeLanguage', {
default: () => 'ts',
maxAge: COOKIE_MAX_AGE_1_YEAR,
})
const isCodeShown = ref(false)
const { copy, copied } = useClipboard({ source: computed(() => props.code[preferredCodeLanguage.value]) })
const highlighter = await getSingletonHighlighter({
themes: [
'dracula',
'dracula-soft',
],
langs: ['vue'],
})
const codeSnippet = highlighter.codeToHtml(props.code[preferredCodeLanguage.value], {
lang: 'vue',
theme: 'dracula',
})
</script>
<template>
<!-- eslint-disable regex/invalid -->
<VCard class="app-card-code">
<VCardItem>
<VCardTitle>{{ props.title }}</VCardTitle>
<template #append>
<IconBtn
size="small"
:color="isCodeShown ? 'primary' : 'default'"
:class="isCodeShown ? '' : 'text-disabled'"
@click="isCodeShown = !isCodeShown"
>
<VIcon
size="20"
icon="tabler-code"
/>
</IconBtn>
</template>
</VCardItem>
<slot v-if="noPadding" />
<VCardText v-else>
<slot />
</VCardText>
<VExpandTransition>
<div v-show="isCodeShown">
<VDivider />
<VCardText class="d-flex gap-y-3 flex-column">
<div class="d-flex justify-end">
<VBtnToggle
v-model="preferredCodeLanguage"
mandatory
density="compact"
>
<VBtn
value="ts"
icon
:variant="preferredCodeLanguage === 'ts' ? 'tonal' : 'text'"
:color="preferredCodeLanguage === 'ts' ? 'primary' : ''"
>
<VIcon
icon="mdi-language-typescript"
:color="preferredCodeLanguage === 'ts' ? 'primary' : 'secondary'"
/>
</VBtn>
<VBtn
value="js"
icon
:variant="preferredCodeLanguage === 'js' ? 'tonal' : 'text'"
:color="preferredCodeLanguage === 'js' ? 'primary' : ''"
>
<VIcon
icon="mdi-language-javascript"
:color="preferredCodeLanguage === 'js' ? 'primary' : 'secondary'"
/>
</VBtn>
</VBtnToggle>
</div>
<div class="position-relative">
<PerfectScrollbar
style="border-radius: 6px;max-block-size: 500px;"
:options="{ wheelPropagation: false, suppressScrollX: false }"
>
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-html="codeSnippet" />
</PerfectScrollbar>
<IconBtn
class="position-absolute app-card-code-copy-icon"
color="white"
@click="() => { copy() }"
>
<VIcon
:icon="copied ? 'tabler-check' : 'tabler-copy'"
size="20"
/>
</IconBtn>
</div>
</VCardText>
</div>
</VExpandTransition>
</VCard>
</template>
<style lang="scss">
@use "@styles/variables/vuetify";
code[class*="language-"],
pre[class*="language-"] {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 14px;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
border-radius: vuetify.$card-border-radius;
max-block-size: 500px;
}
.app-card-code-copy-icon {
inset-block-start: 1.2em;
inset-inline-end: 0.8em;
}
.app-card-code {
.shiki {
padding: 0.75rem;
text-wrap: wrap;
}
}
</style>

View File

@@ -0,0 +1,50 @@
<script setup>
const props = defineProps({
title: {
type: String,
required: true,
},
color: {
type: String,
required: false,
default: 'primary',
},
icon: {
type: String,
required: true,
},
stats: {
type: String,
required: true,
},
})
</script>
<template>
<VCard>
<VCardText class="d-flex align-center justify-space-between">
<div>
<div class="d-flex align-center flex-wrap">
<h5 class="text-h5">
{{ props.stats }}
</h5>
</div>
<div class="text-subtitle-1">
{{ props.title }}
</div>
</div>
<VAvatar
:color="props.color"
:size="42"
rounded
variant="tonal"
>
<VIcon
:icon="props.icon"
size="26"
/>
</VAvatar>
</VCardText>
</VCard>
</template>

View File

@@ -0,0 +1,66 @@
<script setup>
const props = defineProps({
title: {
type: String,
required: true,
},
color: {
type: String,
required: false,
default: 'primary',
},
icon: {
type: String,
required: true,
},
stats: {
type: String,
required: true,
},
height: {
type: Number,
required: true,
},
series: {
type: Array,
required: true,
},
chartOptions: {
type: null,
required: true,
},
})
</script>
<template>
<VCard>
<VCardText class="d-flex flex-column pb-0">
<VAvatar
v-if="props.icon"
size="42"
variant="tonal"
:color="props.color"
rounded
class="mb-2"
>
<VIcon
:icon="props.icon"
size="26"
/>
</VAvatar>
<h5 class="text-h5">
{{ props.stats }}
</h5>
<div class="text-sm">
{{ props.title }}
</div>
</VCardText>
<VueApexCharts
:series="props.series"
:options="props.chartOptions"
:height="props.height"
/>
</VCard>
</template>

View File

@@ -0,0 +1,11 @@
import { stringifyQuery } from 'ufo'
export const createUrl = (url, options) => computed(() => {
if (!options?.query)
return toValue(url)
const _url = toValue(url)
const _query = toValue(options?.query)
const queryObj = Object.fromEntries(Object.entries(_query).map(([key, val]) => [key, toValue(val)]))
return `${_url}${queryObj ? `?${stringifyQuery(queryObj)}` : ''}`
})

View File

@@ -0,0 +1,28 @@
// Ported from [Nuxt](https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/composables/cookie.ts)
import { parse, serialize } from 'cookie-es'
import { destr } from 'destr'
const CookieDefaults = {
path: '/',
watch: true,
decode: val => destr(decodeURIComponent(val)),
encode: val => encodeURIComponent(typeof val === 'string' ? val : JSON.stringify(val)),
}
export const useCookie = (name, _opts) => {
const opts = { ...CookieDefaults, ..._opts || {} }
const cookies = parse(document.cookie, opts)
const cookie = ref(cookies[name] ?? opts.default?.())
watch(cookie, () => {
document.cookie = serializeCookie(name, cookie.value, opts)
})
return cookie
}
function serializeCookie(name, value, opts = {}) {
if (value === null || value === undefined)
return serialize(name, value, { ...opts, maxAge: -1 })
return serialize(name, value, { ...opts, maxAge: 60 * 60 * 24 * 30 })
}

View File

@@ -0,0 +1,26 @@
import { useTheme } from 'vuetify'
import { useConfigStore } from '@core/stores/config'
// composable function to return the image variant as per the current theme and skin
export const useGenerateImageVariant = (imgLight, imgDark, imgLightBordered, imgDarkBordered, bordered = false) => {
const configStore = useConfigStore()
const { global } = useTheme()
return computed(() => {
if (global.name.value === 'light') {
if (configStore.skin === 'bordered' && bordered)
return imgLightBordered
else
return imgLight
}
if (global.name.value === 'dark') {
if (configStore.skin === 'bordered' && bordered)
return imgDarkBordered
else
return imgDark
}
// Add a default return statement
return imgLight
})
}

View File

@@ -0,0 +1,23 @@
import { useDisplay } from 'vuetify'
export const useResponsiveLeftSidebar = (mobileBreakpoint = undefined) => {
const { mdAndDown, name: currentBreakpoint } = useDisplay()
const _mobileBreakpoint = mobileBreakpoint || mdAndDown
const isLeftSidebarOpen = ref(true)
const setInitialValue = () => {
isLeftSidebarOpen.value = !_mobileBreakpoint.value
}
// Set the initial value of sidebar
setInitialValue()
watch(currentBreakpoint, () => {
// Reset left sidebar
isLeftSidebarOpen.value = !_mobileBreakpoint.value
})
return {
isLeftSidebarOpen,
}
}

View File

@@ -0,0 +1,37 @@
import { VThemeProvider } from 'vuetify/components/VThemeProvider'
import { useConfigStore } from '@core/stores/config'
import { AppContentLayoutNav } from '@layouts/enums'
// TODO: Use `VThemeProvider` from dist instead of lib (Using this component from dist causes navbar to loose sticky positioning)
export const useSkins = () => {
const configStore = useConfigStore()
const layoutAttrs = computed(() => ({
verticalNavAttrs: {
wrapper: h(VThemeProvider, { tag: 'div' }),
wrapperProps: {
withBackground: true,
theme: (configStore.isVerticalNavSemiDark && configStore.appContentLayoutNav === AppContentLayoutNav.Vertical)
? 'dark'
: undefined,
},
},
}))
const injectSkinClasses = () => {
if (typeof document !== 'undefined') {
const bodyClasses = document.body.classList
const genSkinClass = _skin => `skin--${_skin}`
watch(() => configStore.skin, (val, oldVal) => {
bodyClasses.remove(genSkinClass(oldVal))
bodyClasses.add(genSkinClass(val))
}, { immediate: true })
}
}
return {
injectSkinClasses,
layoutAttrs,
}
}

View File

@@ -0,0 +1,18 @@
export const Skins = {
Default: 'default',
Bordered: 'bordered',
}
export const Theme = {
Light: 'light',
Dark: 'dark',
System: 'system',
}
export const Layout = {
Vertical: 'vertical',
Horizontal: 'horizontal',
Collapsed: 'collapsed',
}
export const Direction = {
Ltr: 'ltr',
Rtl: 'rtl',
}

View File

@@ -0,0 +1,40 @@
export const defineThemeConfig = userConfig => {
return {
themeConfig: userConfig,
layoutConfig: {
app: {
title: userConfig.app.title,
logo: userConfig.app.logo,
contentWidth: userConfig.app.contentWidth,
contentLayoutNav: userConfig.app.contentLayoutNav,
overlayNavFromBreakpoint: userConfig.app.overlayNavFromBreakpoint,
i18n: {
enable: userConfig.app.i18n.enable,
},
iconRenderer: userConfig.app.iconRenderer,
},
navbar: {
type: userConfig.navbar.type,
navbarBlur: userConfig.navbar.navbarBlur,
},
footer: { type: userConfig.footer.type },
verticalNav: {
isVerticalNavCollapsed: userConfig.verticalNav.isVerticalNavCollapsed,
defaultNavItemIconProps: userConfig.verticalNav.defaultNavItemIconProps,
},
horizontalNav: {
type: userConfig.horizontalNav.type,
transition: userConfig.horizontalNav.transition,
popoverOffset: userConfig.horizontalNav.popoverOffset,
},
icons: {
chevronDown: userConfig.icons.chevronDown,
chevronRight: userConfig.icons.chevronRight,
close: userConfig.icons.close,
verticalNavPinned: userConfig.icons.verticalNavPinned,
verticalNavUnPinned: userConfig.icons.verticalNavUnPinned,
sectionTitlePlaceholder: userConfig.icons.sectionTitlePlaceholder,
},
},
}
}

View File

@@ -0,0 +1,81 @@
import { useStorage } from '@vueuse/core'
import { useTheme } from 'vuetify'
import { useConfigStore } from '@core/stores/config'
import { cookieRef, namespaceConfig } from '@layouts/stores/config'
import { themeConfig } from '@themeConfig'
const _syncAppRtl = () => {
const configStore = useConfigStore()
const storedLang = cookieRef('language', null)
const { locale } = useI18n({ useScope: 'global' })
// TODO: Handle case where i18n can't read persisted value
if (locale.value !== storedLang.value && storedLang.value)
locale.value = storedLang.value
// watch and change lang attribute of html on language change
watch(locale, val => {
// Update lang attribute of html tag
if (typeof document !== 'undefined')
document.documentElement.setAttribute('lang', val)
// Store selected language in cookie
storedLang.value = val
// set isAppRtl value based on selected language
if (themeConfig.app.i18n.langConfig && themeConfig.app.i18n.langConfig.length) {
themeConfig.app.i18n.langConfig.forEach(lang => {
if (lang.i18nLang === storedLang.value)
configStore.isAppRTL = lang.isRTL
})
}
}, { immediate: true })
}
const _handleSkinChanges = () => {
const { themes } = useTheme()
const configStore = useConfigStore()
// Create skin default color so that we can revert back to original (default skin) color when switch to default skin from bordered skin
Object.values(themes.value).forEach(t => {
t.colors['skin-default-background'] = t.colors.background
t.colors['skin-default-surface'] = t.colors.surface
})
watch(() => configStore.skin, val => {
Object.values(themes.value).forEach(t => {
t.colors.background = t.colors[`skin-${val}-background`]
t.colors.surface = t.colors[`skin-${val}-surface`]
})
}, { immediate: true })
}
/*
Set current theme's surface color in localStorage
Why? Because when initial loader is shown (before vue is ready) we need to what's the current theme's surface color.
We will use color stored in localStorage to set the initial loader's background color.
With this we will be able to show correct background color for the initial loader even before vue identify the current theme.
*/
const _syncInitialLoaderTheme = () => {
const vuetifyTheme = useTheme()
watch(() => useConfigStore().theme, () => {
// We are not using theme.current.colors.surface because watcher is independent and when this watcher is ran `theme` computed is not updated
useStorage(namespaceConfig('initial-loader-bg'), null).value = vuetifyTheme.current.value.colors.surface
useStorage(namespaceConfig('initial-loader-color'), null).value = vuetifyTheme.current.value.colors.primary
}, { immediate: true })
}
const initCore = () => {
_syncInitialLoaderTheme()
_handleSkinChanges()
// We don't want to trigger i18n in SK
if (themeConfig.app.i18n.enable)
_syncAppRtl()
}
export default initCore

View File

@@ -0,0 +1,689 @@
import { hexToRgb } from '@core/utils/colorConverter'
// 👉 Colors variables
const colorVariables = themeColors => {
const themeSecondaryTextColor = `rgba(${hexToRgb(themeColors.colors['on-surface'])},${themeColors.variables['medium-emphasis-opacity']})`
const themeDisabledTextColor = `rgba(${hexToRgb(themeColors.colors['on-surface'])},${themeColors.variables['disabled-opacity']})`
const themeBorderColor = `rgba(${hexToRgb(String(themeColors.variables['border-color']))},${themeColors.variables['border-opacity']})`
const themePrimaryTextColor = `rgba(${hexToRgb(themeColors.colors['on-surface'])},${themeColors.variables['high-emphasis-opacity']})`
return { themeSecondaryTextColor, themeDisabledTextColor, themeBorderColor, themePrimaryTextColor }
}
export const getScatterChartConfig = themeColors => {
const scatterColors = {
series1: '#ff9f43',
series2: '#7367f0',
series3: '#28c76f',
}
const { themeSecondaryTextColor, themeBorderColor, themeDisabledTextColor } = colorVariables(themeColors)
return {
chart: {
parentHeightOffset: 0,
toolbar: { show: false },
zoom: {
type: 'xy',
enabled: true,
},
},
legend: {
position: 'top',
horizontalAlign: 'left',
markers: { offsetX: -3 },
fontSize: '13px',
labels: { colors: themeSecondaryTextColor },
itemMargin: {
vertical: 3,
horizontal: 10,
},
},
colors: [scatterColors.series1, scatterColors.series2, scatterColors.series3],
grid: {
borderColor: themeBorderColor,
xaxis: {
lines: { show: true },
},
},
yaxis: {
labels: {
style: { fontSize: '0.8125rem', colors: themeDisabledTextColor },
},
},
xaxis: {
tickAmount: 10,
axisBorder: { show: false },
axisTicks: { color: themeBorderColor },
crosshairs: {
stroke: { color: themeBorderColor },
},
labels: {
style: { colors: themeDisabledTextColor },
formatter: val => Number.parseFloat(val).toFixed(1),
},
},
}
}
export const getLineChartSimpleConfig = themeColors => {
const { themeBorderColor, themeDisabledTextColor } = colorVariables(themeColors)
return {
chart: {
parentHeightOffset: 0,
zoom: { enabled: false },
toolbar: { show: false },
},
colors: ['#ff9f43'],
stroke: { curve: 'straight' },
dataLabels: { enabled: false },
markers: {
strokeWidth: 7,
strokeOpacity: 1,
colors: ['#ff9f43'],
strokeColors: ['#fff'],
},
grid: {
padding: { top: -10 },
borderColor: themeBorderColor,
xaxis: {
lines: { show: true },
},
},
tooltip: {
custom(data) {
return `<div class='bar-chart pa-2'>
<span>${data.series[data.seriesIndex][data.dataPointIndex]}%</span>
</div>`
},
},
yaxis: {
labels: {
style: { colors: themeDisabledTextColor, fontSize: '0.8125rem' },
},
},
xaxis: {
axisBorder: { show: false },
axisTicks: { color: themeBorderColor },
crosshairs: {
stroke: { color: themeBorderColor },
},
labels: {
style: { colors: themeDisabledTextColor, fontSize: '0.8125rem' },
},
categories: [
'7/12',
'8/12',
'9/12',
'10/12',
'11/12',
'12/12',
'13/12',
'14/12',
'15/12',
'16/12',
'17/12',
'18/12',
'19/12',
'20/12',
'21/12',
],
},
}
}
export const getBarChartConfig = themeColors => {
const { themeBorderColor, themeDisabledTextColor } = colorVariables(themeColors)
return {
chart: {
parentHeightOffset: 0,
toolbar: { show: false },
},
colors: ['#00cfe8'],
dataLabels: { enabled: false },
plotOptions: {
bar: {
borderRadius: 8,
barHeight: '30%',
horizontal: true,
},
},
grid: {
borderColor: themeBorderColor,
xaxis: {
lines: { show: false },
},
padding: {
top: -10,
},
},
yaxis: {
labels: {
style: { colors: themeDisabledTextColor, fontSize: '0.8125rem' },
},
},
xaxis: {
axisBorder: { show: false },
axisTicks: { color: themeBorderColor },
categories: ['MON, 11', 'THU, 14', 'FRI, 15', 'MON, 18', 'WED, 20', 'FRI, 21', 'MON, 23'],
labels: {
style: { colors: themeDisabledTextColor, fontSize: '0.8125rem' },
},
},
}
}
export const getCandlestickChartConfig = themeColors => {
const candlestickColors = {
series1: '#28c76f',
series2: '#ea5455',
}
const { themeBorderColor, themeDisabledTextColor } = colorVariables(themeColors)
return {
chart: {
parentHeightOffset: 0,
toolbar: { show: false },
},
plotOptions: {
bar: { columnWidth: '40%' },
candlestick: {
colors: {
upward: candlestickColors.series1,
downward: candlestickColors.series2,
},
},
},
grid: {
padding: { top: -10 },
borderColor: themeBorderColor,
xaxis: {
lines: { show: true },
},
},
yaxis: {
tooltip: { enabled: true },
crosshairs: {
stroke: { color: themeBorderColor },
},
labels: {
style: { colors: themeDisabledTextColor, fontSize: '0.8125rem' },
},
},
xaxis: {
type: 'datetime',
axisBorder: { show: false },
axisTicks: { color: themeBorderColor },
crosshairs: {
stroke: { color: themeBorderColor },
},
labels: {
style: { colors: themeDisabledTextColor, fontSize: '0.8125rem' },
},
},
}
}
export const getRadialBarChartConfig = themeColors => {
const radialBarColors = {
series1: '#fdd835',
series2: '#32baff',
series3: '#00d4bd',
series4: '#7367f0',
series5: '#FFA1A1',
}
const { themeSecondaryTextColor, themePrimaryTextColor } = colorVariables(themeColors)
return {
stroke: { lineCap: 'round' },
labels: ['Comments', 'Replies', 'Shares'],
legend: {
show: true,
fontSize: '13px',
position: 'bottom',
labels: {
colors: themeSecondaryTextColor,
},
markers: {
offsetX: -3,
},
itemMargin: {
vertical: 3,
horizontal: 10,
},
},
colors: [radialBarColors.series1, radialBarColors.series2, radialBarColors.series4],
plotOptions: {
radialBar: {
hollow: { size: '30%' },
track: {
margin: 15,
background: themeColors.variables['track-bg'],
},
dataLabels: {
name: {
fontSize: '2rem',
},
value: {
fontSize: '0.9375rem',
color: themeSecondaryTextColor,
},
total: {
show: true,
fontWeight: 400,
label: 'Comments',
fontSize: '1.125rem',
color: themePrimaryTextColor,
formatter(w) {
const totalValue = w.globals.seriesTotals.reduce((a, b) => {
return a + b
}, 0) / w.globals.series.length
if (totalValue % 1 === 0)
return `${totalValue}%`
else
return `${totalValue.toFixed(2)}%`
},
},
},
},
},
grid: {
padding: {
top: -30,
bottom: -25,
},
},
}
}
export const getDonutChartConfig = themeColors => {
const donutColors = {
series1: '#fdd835',
series2: '#00d4bd',
series3: '#826bf8',
series4: '#32baff',
series5: '#ffa1a1',
}
const { themeSecondaryTextColor, themePrimaryTextColor } = colorVariables(themeColors)
return {
stroke: { width: 0 },
labels: ['Operational', 'Networking', 'Hiring', 'R&D'],
colors: [donutColors.series1, donutColors.series5, donutColors.series3, donutColors.series2],
dataLabels: {
enabled: true,
formatter: val => `${Number.parseInt(val, 10)}%`,
},
legend: {
position: 'bottom',
markers: { offsetX: -3 },
fontSize: '13px',
labels: { colors: themeSecondaryTextColor },
itemMargin: {
vertical: 3,
horizontal: 10,
},
},
plotOptions: {
pie: {
donut: {
labels: {
show: true,
name: {
fontSize: '1.125rem',
},
value: {
fontSize: '1.125rem',
color: themeSecondaryTextColor,
formatter: val => `${Number.parseInt(val, 10)}`,
},
total: {
show: true,
fontSize: '1.125rem',
label: 'Operational',
formatter: () => '31%',
color: themePrimaryTextColor,
},
},
},
},
},
responsive: [
{
breakpoint: 992,
options: {
chart: {
height: 380,
},
legend: {
position: 'bottom',
},
},
},
{
breakpoint: 576,
options: {
chart: {
height: 320,
},
plotOptions: {
pie: {
donut: {
labels: {
show: true,
name: {
fontSize: '0.9375rem',
},
value: {
fontSize: '0.9375rem',
},
total: {
fontSize: '0.9375rem',
},
},
},
},
},
},
},
],
}
}
export const getAreaChartSplineConfig = themeColors => {
const areaColors = {
series3: '#e0cffe',
series2: '#b992fe',
series1: '#ab7efd',
}
const { themeSecondaryTextColor, themeBorderColor, themeDisabledTextColor } = colorVariables(themeColors)
return {
chart: {
parentHeightOffset: 0,
toolbar: { show: false },
},
tooltip: { shared: false },
dataLabels: { enabled: false },
stroke: {
show: false,
curve: 'straight',
},
legend: {
position: 'top',
horizontalAlign: 'left',
fontSize: '13px',
labels: { colors: themeSecondaryTextColor },
markers: {
offsetY: 1,
offsetX: -3,
},
itemMargin: {
vertical: 3,
horizontal: 10,
},
},
colors: [areaColors.series3, areaColors.series2, areaColors.series1],
fill: {
opacity: 1,
type: 'solid',
},
grid: {
show: true,
borderColor: themeBorderColor,
xaxis: {
lines: { show: true },
},
},
yaxis: {
labels: {
style: { colors: themeDisabledTextColor, fontSize: '0.8125rem' },
},
},
xaxis: {
axisBorder: { show: false },
axisTicks: { color: themeBorderColor },
crosshairs: {
stroke: { color: themeBorderColor },
},
labels: {
style: { colors: themeDisabledTextColor, fontSize: '0.8125rem' },
},
categories: [
'7/12',
'8/12',
'9/12',
'10/12',
'11/12',
'12/12',
'13/12',
'14/12',
'15/12',
'16/12',
'17/12',
'18/12',
'19/12',
],
},
}
}
export const getColumnChartConfig = themeColors => {
const columnColors = {
series1: '#826af9',
series2: '#d2b0ff',
bg: '#f8d3ff',
}
const { themeSecondaryTextColor, themeBorderColor, themeDisabledTextColor } = colorVariables(themeColors)
return {
chart: {
offsetX: -10,
stacked: true,
parentHeightOffset: 0,
toolbar: { show: false },
},
fill: { opacity: 1 },
dataLabels: { enabled: false },
colors: [columnColors.series1, columnColors.series2],
legend: {
position: 'top',
horizontalAlign: 'left',
fontSize: '13px',
labels: { colors: themeSecondaryTextColor },
markers: {
offsetY: 1,
offsetX: -3,
},
itemMargin: {
vertical: 3,
horizontal: 10,
},
},
stroke: {
show: true,
colors: ['transparent'],
},
plotOptions: {
bar: {
columnWidth: '15%',
colors: {
backgroundBarRadius: 10,
backgroundBarColors: [columnColors.bg, columnColors.bg, columnColors.bg, columnColors.bg, columnColors.bg],
},
},
},
grid: {
borderColor: themeBorderColor,
xaxis: {
lines: { show: true },
},
},
yaxis: {
labels: {
style: { colors: themeDisabledTextColor, fontSize: '0.8125rem' },
},
},
xaxis: {
axisBorder: { show: false },
axisTicks: { color: themeBorderColor },
categories: ['7/12', '8/12', '9/12', '10/12', '11/12', '12/12', '13/12', '14/12', '15/12'],
crosshairs: {
stroke: { color: themeBorderColor },
},
labels: {
style: { colors: themeDisabledTextColor, fontSize: '0.8125rem' },
},
},
responsive: [
{
breakpoint: 600,
options: {
plotOptions: {
bar: {
columnWidth: '35%',
},
},
},
},
],
}
}
export const getHeatMapChartConfig = themeColors => {
const { themeSecondaryTextColor, themeDisabledTextColor } = colorVariables(themeColors)
return {
chart: {
parentHeightOffset: 0,
toolbar: { show: false },
},
dataLabels: { enabled: false },
stroke: {
colors: [themeColors.colors.surface],
},
legend: {
position: 'bottom',
fontSize: '13px',
labels: {
colors: themeSecondaryTextColor,
},
markers: {
offsetY: 0,
offsetX: -3,
},
itemMargin: {
vertical: 3,
horizontal: 10,
},
},
plotOptions: {
heatmap: {
enableShades: false,
colorScale: {
ranges: [
{ to: 10, from: 0, name: '0-10', color: '#b9b3f8' },
{ to: 20, from: 11, name: '10-20', color: '#aba4f6' },
{ to: 30, from: 21, name: '20-30', color: '#9d95f5' },
{ to: 40, from: 31, name: '30-40', color: '#8f85f3' },
{ to: 50, from: 41, name: '40-50', color: '#8176f2' },
{ to: 60, from: 51, name: '50-60', color: '#7367f0' },
],
},
},
},
grid: {
padding: { top: -20 },
},
yaxis: {
labels: {
style: {
colors: themeDisabledTextColor,
fontSize: '0.8125rem',
},
},
},
xaxis: {
labels: { show: false },
axisTicks: { show: false },
axisBorder: { show: false },
},
}
}
export const getRadarChartConfig = themeColors => {
const radarColors = {
series1: '#9b88fa',
series2: '#ffa1a1',
}
const { themeSecondaryTextColor, themeBorderColor, themeDisabledTextColor } = colorVariables(themeColors)
return {
chart: {
parentHeightOffset: 0,
toolbar: { show: false },
dropShadow: {
top: 1,
blur: 8,
left: 1,
opacity: 0.2,
enabled: false,
},
},
markers: { size: 0 },
fill: { opacity: [1, 0.8] },
colors: [radarColors.series1, radarColors.series2],
stroke: {
width: 0,
show: false,
},
legend: {
fontSize: '13px',
labels: {
colors: themeSecondaryTextColor,
},
markers: {
offsetX: -3,
},
itemMargin: {
vertical: 3,
horizontal: 10,
},
},
plotOptions: {
radar: {
polygons: {
strokeColors: themeBorderColor,
connectorColors: themeBorderColor,
},
},
},
grid: {
show: false,
padding: {
top: -20,
bottom: -20,
},
},
yaxis: { show: false },
xaxis: {
categories: ['Battery', 'Brand', 'Camera', 'Memory', 'Storage', 'Display', 'OS', 'Price'],
labels: {
style: {
fontSize: '0.8125rem',
colors: [
themeDisabledTextColor,
themeDisabledTextColor,
themeDisabledTextColor,
themeDisabledTextColor,
themeDisabledTextColor,
themeDisabledTextColor,
themeDisabledTextColor,
themeDisabledTextColor,
],
},
},
},
}
}

View File

@@ -0,0 +1,372 @@
import { hexToRgb } from '@core/utils/colorConverter'
// 👉 Colors variables
const colorVariables = themeColors => {
const themeSecondaryTextColor = `rgba(${hexToRgb(themeColors.colors['on-surface'])},${themeColors.variables['medium-emphasis-opacity']})`
const themeDisabledTextColor = `rgba(${hexToRgb(themeColors.colors['on-surface'])},${themeColors.variables['disabled-opacity']})`
const themeBorderColor = `rgba(${hexToRgb(String(themeColors.variables['border-color']))},${themeColors.variables['border-opacity']})`
return { labelColor: themeDisabledTextColor, borderColor: themeBorderColor, legendColor: themeSecondaryTextColor }
}
// SECTION config
// 👉 Latest Bar Chart Config
export const getLatestBarChartConfig = themeColors => {
const { borderColor, labelColor } = colorVariables(themeColors)
return {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 500 },
scales: {
x: {
grid: {
borderColor,
drawBorder: false,
color: borderColor,
},
ticks: { color: labelColor },
},
y: {
min: 0,
max: 400,
grid: {
borderColor,
drawBorder: false,
color: borderColor,
},
ticks: {
stepSize: 100,
color: labelColor,
},
},
},
plugins: {
legend: { display: false },
},
}
}
// 👉 Horizontal Bar Chart Config
export const getHorizontalBarChartConfig = themeColors => {
const { borderColor, labelColor, legendColor } = colorVariables(themeColors)
return {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
animation: { duration: 500 },
elements: {
bar: {
borderRadius: {
topRight: 15,
bottomRight: 15,
},
},
},
layout: {
padding: { top: -4 },
},
scales: {
x: {
min: 0,
grid: {
drawTicks: false,
drawBorder: false,
color: borderColor,
},
ticks: { color: labelColor },
},
y: {
grid: {
borderColor,
display: false,
drawBorder: false,
},
ticks: { color: labelColor },
},
},
plugins: {
legend: {
align: 'end',
position: 'top',
labels: { color: legendColor },
},
},
}
}
// 👉 Line Chart Config
export const getLineChartConfig = themeColors => {
const { borderColor, labelColor, legendColor } = colorVariables(themeColors)
return {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
ticks: { color: labelColor },
grid: {
borderColor,
drawBorder: false,
color: borderColor,
},
},
y: {
min: 0,
max: 400,
ticks: {
stepSize: 100,
color: labelColor,
},
grid: {
borderColor,
drawBorder: false,
color: borderColor,
},
},
},
plugins: {
legend: {
align: 'end',
position: 'top',
labels: {
padding: 25,
boxWidth: 10,
color: legendColor,
usePointStyle: true,
},
},
},
}
}
// 👉 Radar Chart Config
export const getRadarChartConfig = themeColors => {
const { borderColor, labelColor, legendColor } = colorVariables(themeColors)
return {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 500 },
layout: {
padding: { top: -20 },
},
scales: {
r: {
ticks: {
display: false,
maxTicksLimit: 1,
color: labelColor,
},
grid: { color: borderColor },
pointLabels: { color: labelColor },
angleLines: { color: borderColor },
},
},
plugins: {
legend: {
position: 'top',
labels: {
padding: 25,
color: legendColor,
},
},
},
}
}
// 👉 Polar Chart Config
export const getPolarChartConfig = themeColors => {
const { legendColor } = colorVariables(themeColors)
return {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 500 },
layout: {
padding: {
top: -5,
bottom: -45,
},
},
scales: {
r: {
grid: { display: false },
ticks: { display: false },
},
},
plugins: {
legend: {
position: 'right',
labels: {
padding: 25,
boxWidth: 9,
color: legendColor,
usePointStyle: true,
},
},
},
}
}
// 👉 Bubble Chart Config
export const getBubbleChartConfig = themeColors => {
const { borderColor, labelColor } = colorVariables(themeColors)
return {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
min: 0,
max: 140,
grid: {
borderColor,
drawBorder: false,
color: borderColor,
},
ticks: {
stepSize: 10,
color: labelColor,
},
},
y: {
min: 0,
max: 400,
grid: {
borderColor,
drawBorder: false,
color: borderColor,
},
ticks: {
stepSize: 100,
color: labelColor,
},
},
},
plugins: {
legend: { display: false },
},
}
}
// 👉 Doughnut Chart Config
export const getDoughnutChartConfig = () => {
return {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 500 },
cutout: 80,
plugins: {
legend: {
display: false,
},
},
}
}
// 👉 Scatter Chart Config
export const getScatterChartConfig = themeColors => {
const { borderColor, labelColor, legendColor } = colorVariables(themeColors)
return {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 800 },
layout: {
padding: { top: -20 },
},
scales: {
x: {
min: 0,
max: 140,
grid: {
borderColor,
drawTicks: false,
drawBorder: false,
color: borderColor,
},
ticks: {
stepSize: 10,
color: labelColor,
},
},
y: {
min: 0,
max: 400,
grid: {
borderColor,
drawTicks: false,
drawBorder: false,
color: borderColor,
},
ticks: {
stepSize: 100,
color: labelColor,
},
},
},
plugins: {
legend: {
align: 'start',
position: 'top',
labels: {
padding: 25,
boxWidth: 9,
color: legendColor,
usePointStyle: true,
},
},
},
}
}
// 👉 Line Area Chart Config
export const getLineAreaChartConfig = themeColors => {
const { borderColor, labelColor, legendColor } = colorVariables(themeColors)
return {
responsive: true,
maintainAspectRatio: false,
layout: {
padding: { top: -20 },
},
scales: {
x: {
grid: {
borderColor,
color: 'transparent',
},
ticks: { color: labelColor },
},
y: {
min: 0,
max: 400,
grid: {
borderColor,
color: 'transparent',
},
ticks: {
stepSize: 100,
color: labelColor,
},
},
},
plugins: {
legend: {
align: 'start',
position: 'top',
labels: {
padding: 25,
boxWidth: 9,
color: legendColor,
usePointStyle: true,
},
},
},
}
}
// !SECTION

View File

@@ -0,0 +1,54 @@
import { BarElement, CategoryScale, Chart as ChartJS, Legend, LinearScale, Title, Tooltip } from 'chart.js'
import { defineComponent } from 'vue'
import { Bar } from 'vue-chartjs'
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale)
export default defineComponent({
name: 'BarChart',
props: {
chartId: {
type: String,
default: 'bar-chart',
},
width: {
type: Number,
default: 400,
},
height: {
type: Number,
default: 400,
},
cssClasses: {
default: '',
type: String,
},
styles: {
type: Object,
default: () => ({}),
},
plugins: {
type: Array,
default: () => ([]),
},
chartData: {
type: Object,
default: () => ({}),
},
chartOptions: {
type: Object,
default: () => ({}),
},
},
setup(props) {
return () => h(h(Bar), {
data: props.chartData,
options: props.chartOptions,
chartId: props.chartId,
width: props.width,
height: props.height,
cssClasses: props.cssClasses,
styles: props.styles,
plugins: props.plugins,
})
},
})

View File

@@ -0,0 +1,54 @@
import { Chart as ChartJS, Legend, LinearScale, PointElement, Title, Tooltip } from 'chart.js'
import { defineComponent } from 'vue'
import { Bubble } from 'vue-chartjs'
ChartJS.register(Title, Tooltip, Legend, PointElement, LinearScale)
export default defineComponent({
name: 'BubbleChart',
props: {
chartId: {
type: String,
default: 'bubble-chart',
},
width: {
type: Number,
default: 400,
},
height: {
type: Number,
default: 400,
},
cssClasses: {
default: '',
type: String,
},
styles: {
type: Object,
default: () => ({}),
},
plugins: {
type: Array,
default: () => [],
},
chartData: {
type: Object,
default: () => ({}),
},
chartOptions: {
type: Object,
default: () => ({}),
},
},
setup(props) {
return () => h(h(Bubble), {
data: props.chartData,
options: props.chartOptions,
chartId: props.chartId,
width: props.width,
height: props.height,
cssClasses: props.cssClasses,
styles: props.styles,
plugins: props.plugins,
})
},
})

View File

@@ -0,0 +1,54 @@
import { ArcElement, CategoryScale, Chart as ChartJS, Legend, Title, Tooltip } from 'chart.js'
import { defineComponent } from 'vue'
import { Doughnut } from 'vue-chartjs'
ChartJS.register(Title, Tooltip, Legend, ArcElement, CategoryScale)
export default defineComponent({
name: 'DoughnutChart',
props: {
chartId: {
type: String,
default: 'doughnut-chart',
},
width: {
type: Number,
default: 400,
},
height: {
type: Number,
default: 400,
},
cssClasses: {
default: '',
type: String,
},
styles: {
type: Object,
default: () => ({}),
},
plugins: {
type: Array,
default: () => [],
},
chartData: {
type: Object,
default: () => ({}),
},
chartOptions: {
type: Object,
default: () => ({}),
},
},
setup(props) {
return () => h(h(Doughnut), {
data: props.chartData,
options: props.chartOptions,
chartId: props.chartId,
width: props.width,
height: props.height,
cssClasses: props.cssClasses,
styles: props.styles,
plugins: props.plugins,
})
},
})

View File

@@ -0,0 +1,54 @@
import { CategoryScale, Chart as ChartJS, Legend, LineElement, LinearScale, PointElement, Title, Tooltip } from 'chart.js'
import { defineComponent } from 'vue'
import { Line } from 'vue-chartjs'
ChartJS.register(Title, Tooltip, Legend, LineElement, LinearScale, PointElement, CategoryScale)
export default defineComponent({
name: 'LineChart',
props: {
chartId: {
type: String,
default: 'line-chart',
},
width: {
type: Number,
default: 400,
},
height: {
type: Number,
default: 400,
},
cssClasses: {
default: '',
type: String,
},
styles: {
type: Object,
default: () => ({}),
},
plugins: {
type: Array,
default: () => [],
},
chartData: {
type: Object,
default: () => ({}),
},
chartOptions: {
type: Object,
default: () => ({}),
},
},
setup(props) {
return () => h(h(Line), {
chartId: props.chartId,
width: props.width,
height: props.height,
cssClasses: props.cssClasses,
styles: props.styles,
plugins: props.plugins,
options: props.chartOptions,
data: props.chartData,
})
},
})

View File

@@ -0,0 +1,54 @@
import { ArcElement, Chart as ChartJS, Legend, RadialLinearScale, Title, Tooltip } from 'chart.js'
import { defineComponent } from 'vue'
import { PolarArea } from 'vue-chartjs'
ChartJS.register(Title, Tooltip, Legend, ArcElement, RadialLinearScale)
export default defineComponent({
name: 'PolarAreaChart',
props: {
chartId: {
type: String,
default: 'line-chart',
},
width: {
type: Number,
default: 400,
},
height: {
type: Number,
default: 400,
},
cssClasses: {
default: '',
type: String,
},
styles: {
type: Object,
default: () => ({}),
},
plugins: {
type: Array,
default: () => [],
},
chartData: {
type: Object,
default: () => ({}),
},
chartOptions: {
type: Object,
default: () => ({}),
},
},
setup(props) {
return () => h(h(PolarArea), {
data: props.chartData,
options: props.chartOptions,
chartId: props.chartId,
width: props.width,
height: props.height,
cssClasses: props.cssClasses,
styles: props.styles,
plugins: props.plugins,
})
},
})

View File

@@ -0,0 +1,54 @@
import { Chart as ChartJS, Filler, Legend, LineElement, PointElement, RadialLinearScale, Title, Tooltip } from 'chart.js'
import { defineComponent } from 'vue'
import { Radar } from 'vue-chartjs'
ChartJS.register(Title, Tooltip, Legend, PointElement, RadialLinearScale, LineElement, Filler)
export default defineComponent({
name: 'RadarChart',
props: {
chartId: {
type: String,
default: 'radar-chart',
},
width: {
type: Number,
default: 400,
},
height: {
type: Number,
default: 400,
},
cssClasses: {
default: '',
type: String,
},
styles: {
type: Object,
default: () => ({}),
},
plugins: {
type: Array,
default: () => [],
},
chartData: {
type: Object,
default: () => ({}),
},
chartOptions: {
type: Object,
default: () => ({}),
},
},
setup(props) {
return () => h(h(Radar), {
data: props.chartData,
options: props.chartOptions,
chartId: props.chartId,
width: props.width,
height: props.height,
cssClasses: props.cssClasses,
styles: props.styles,
plugins: props.plugins,
})
},
})

View File

@@ -0,0 +1,54 @@
import { CategoryScale, Chart as ChartJS, Legend, LineElement, LinearScale, PointElement, Title, Tooltip } from 'chart.js'
import { defineComponent } from 'vue'
import { Scatter } from 'vue-chartjs'
ChartJS.register(Title, Tooltip, Legend, PointElement, LineElement, CategoryScale, LinearScale)
export default defineComponent({
name: 'ScatterChart',
props: {
chartId: {
type: String,
default: 'scatter-chart',
},
width: {
type: Number,
default: 400,
},
height: {
type: Number,
default: 400,
},
cssClasses: {
default: '',
type: String,
},
styles: {
type: Object,
default: () => ({}),
},
plugins: {
type: Array,
default: () => [],
},
chartData: {
type: Object,
default: () => ({}),
},
chartOptions: {
type: Object,
default: () => ({}),
},
},
setup(props) {
return () => h(h(Scatter), {
data: props.chartData,
options: props.chartOptions,
chartId: props.chartId,
width: props.width,
height: props.height,
cssClasses: props.cssClasses,
styles: props.styles,
plugins: props.plugins,
})
},
})

View File

@@ -0,0 +1,63 @@
import { storeToRefs } from 'pinia'
import { useTheme } from 'vuetify'
import { cookieRef, useLayoutConfigStore } from '@layouts/stores/config'
import { themeConfig } from '@themeConfig'
// SECTION Store
export const useConfigStore = defineStore('config', () => {
// 👉 Theme
const userPreferredColorScheme = usePreferredColorScheme()
const cookieColorScheme = cookieRef('color-scheme', 'light')
watch(userPreferredColorScheme, val => {
if (val !== 'no-preference')
cookieColorScheme.value = val
}, { immediate: true })
const theme = cookieRef('theme', themeConfig.app.theme)
// 👉 isVerticalNavSemiDark
const isVerticalNavSemiDark = cookieRef('isVerticalNavSemiDark', themeConfig.verticalNav.isVerticalNavSemiDark)
// 👉 isVerticalNavSemiDark
const skin = cookieRef('skin', themeConfig.app.skin)
// We need to use `storeToRefs` to forward the state
const { isLessThanOverlayNavBreakpoint, appContentWidth, navbarType, isNavbarBlurEnabled, appContentLayoutNav, isVerticalNavCollapsed, footerType, isAppRTL } = storeToRefs(useLayoutConfigStore())
return {
theme,
isVerticalNavSemiDark,
skin,
// @layouts exports
isLessThanOverlayNavBreakpoint,
appContentWidth,
navbarType,
isNavbarBlurEnabled,
appContentLayoutNav,
isVerticalNavCollapsed,
footerType,
isAppRTL,
}
})
// !SECTION
// SECTION Init
export const initConfigStore = () => {
const userPreferredColorScheme = usePreferredColorScheme()
const vuetifyTheme = useTheme()
const configStore = useConfigStore()
watch([() => configStore.theme, userPreferredColorScheme], () => {
vuetifyTheme.global.name.value = configStore.theme === 'system'
? userPreferredColorScheme.value === 'dark'
? 'dark'
: 'light'
: configStore.theme
})
onMounted(() => {
if (configStore.theme === 'system')
vuetifyTheme.global.name.value = userPreferredColorScheme.value
})
}
// !SECTION

View File

@@ -0,0 +1 @@
export {}

View File

@@ -0,0 +1,31 @@
/**
* Convert Hex color to rgb
* @param hex
*/
export const hexToRgb = hex => {
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i
hex = hex.replace(shorthandRegex, (m, r, g, b) => {
return r + r + g + g + b + b
})
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result ? `${Number.parseInt(result[1], 16)},${Number.parseInt(result[2], 16)},${Number.parseInt(result[3], 16)}` : null
}
/**
*RGBA color to Hex color with / without opacity
*/
export const rgbaToHex = (rgba, forceRemoveAlpha = false) => {
return (`#${rgba
.replace(/^rgba?\(|\s+|\)$/g, '') // Get's rgba / rgb string values
.split(',') // splits them at ","
.filter((string, index) => !forceRemoveAlpha || index !== 3)
.map(string => Number.parseFloat(string)) // Converts them to numbers
.map((number, index) => (index === 3 ? Math.round(number * 255) : number)) // Converts alpha to 255 number
.map(number => number.toString(16)) // Converts numbers to hex
.map(string => (string.length === 1 ? `0${string}` : string)) // Adds 0 when length of one number is 1
.join('')}`)
}

View File

@@ -0,0 +1,46 @@
import { isToday } from './helpers'
export const avatarText = value => {
if (!value)
return ''
const nameArray = value.split(' ')
return nameArray.map(word => word.charAt(0).toUpperCase()).join('')
}
// TODO: Try to implement this: https://twitter.com/fireship_dev/status/1565424801216311297
export const kFormatter = num => {
const regex = /\B(?=(\d{3})+(?!\d))/g
return Math.abs(num) > 9999 ? `${Math.sign(num) * +((Math.abs(num) / 1000).toFixed(1))}k` : Math.abs(num).toFixed(0).replace(regex, ',')
}
/**
* Format and return date in Humanize format
* Intl docs: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/format
* Intl Constructor: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat
* @param {string} value date to format
* @param {Intl.DateTimeFormatOptions} formatting Intl object to format with
*/
export const formatDate = (value, formatting = { month: 'short', day: 'numeric', year: 'numeric' }) => {
if (!value)
return value
return new Intl.DateTimeFormat('en-US', formatting).format(new Date(value))
}
/**
* Return short human friendly month representation of date
* Can also convert date to only time if date is of today (Better UX)
* @param {string} value date to format
* @param {boolean} toTimeForCurrentDay Shall convert to time if day is today/current
*/
export const formatDateToMonthShort = (value, toTimeForCurrentDay = true) => {
const date = new Date(value)
let formatting = { month: 'short', day: 'numeric' }
if (toTimeForCurrentDay && isToday(date))
formatting = { hour: 'numeric', minute: 'numeric' }
return new Intl.DateTimeFormat('en-US', formatting).format(new Date(value))
}
export const prefixWithPlus = value => value > 0 ? `+${value}` : value

View File

@@ -0,0 +1,29 @@
// 👉 IsEmpty
export const isEmpty = value => {
if (value === null || value === undefined || value === '')
return true
return !!(Array.isArray(value) && value.length === 0)
}
// 👉 IsNullOrUndefined
export const isNullOrUndefined = value => {
return value === null || value === undefined
}
// 👉 IsEmptyArray
export const isEmptyArray = arr => {
return Array.isArray(arr) && arr.length === 0
}
// 👉 IsObject
export const isObject = obj => obj !== null && !!obj && typeof obj === 'object' && !Array.isArray(obj)
// 👉 IsToday
export const isToday = date => {
const today = new Date()
return (date.getDate() === today.getDate()
&& date.getMonth() === today.getMonth()
&& date.getFullYear() === today.getFullYear())
}

View File

@@ -0,0 +1,49 @@
/**
* This is helper function to register plugins like a nuxt
* To register a plugin just export a const function `defineVuePlugin` that takes `app` as argument and call `app.use`
* For Scanning plugins it will include all files in `src/plugins` and `src/plugins/**\/index.ts`
*
*
* @param {App} app Vue app instance
*
* @example
* ```ts
* // File: src/plugins/vuetify/index.ts
*
* import type { App } from 'vue'
* import { createVuetify } from 'vuetify'
*
* const vuetify = createVuetify({ ... })
*
* export default function (app: App) {
* app.use(vuetify)
* }
* ```
*
* All you have to do is use this helper function in `main.ts` file like below:
* ```ts
* // File: src/main.ts
* import { registerPlugins } from '@core/utils/plugins'
* import { createApp } from 'vue'
* import App from '@/App.vue'
*
* // Create vue app
* const app = createApp(App)
*
* // Register plugins
* registerPlugins(app) // [!code focus]
*
* // Mount vue app
* app.mount('#app')
* ```
*/
export const registerPlugins = app => {
const imports = import.meta.glob(['../../plugins/*.{ts,js}', '../../plugins/*/index.{ts,js}'], { eager: true })
const importPaths = Object.keys(imports).sort()
importPaths.forEach(path => {
const pluginImportModule = imports[path]
pluginImportModule.default?.(app)
})
}

View File

@@ -0,0 +1,95 @@
import { isEmpty, isEmptyArray, isNullOrUndefined } from './helpers'
// 👉 Required Validator
export const requiredValidator = value => {
if (isNullOrUndefined(value) || isEmptyArray(value) || value === false)
return 'This field is required'
return !!String(value).trim().length || 'This field is required'
}
// 👉 Email Validator
export const emailValidator = value => {
if (isEmpty(value))
return true
const re = /^(?:[^<>()[\]\\.,;:\s@"]+(?:\.[^<>()[\]\\.,;:\s@"]+)*|".+")@(?:\[\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\]|(?:[a-z\-\d]+\.)+[a-z]{2,})$/i
if (Array.isArray(value))
return value.every(val => re.test(String(val))) || 'The Email field must be a valid email'
return re.test(String(value)) || 'The Email field must be a valid email'
}
// 👉 Password Validator
export const passwordValidator = password => {
const regExp = /(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[!@#$%&*()]).{8,}/
const validPassword = regExp.test(password)
return validPassword || 'Field must contain at least one uppercase, lowercase, special character and digit with min 8 chars'
}
// 👉 Confirm Password Validator
export const confirmedValidator = (value, target) => value === target || 'The Confirm Password field confirmation does not match'
// 👉 Between Validator
export const betweenValidator = (value, min, max) => {
const valueAsNumber = Number(value)
return (Number(min) <= valueAsNumber && Number(max) >= valueAsNumber) || `Enter number between ${min} and ${max}`
}
// 👉 Integer Validator
export const integerValidator = value => {
if (isEmpty(value))
return true
if (Array.isArray(value))
return value.every(val => /^-?\d+$/.test(String(val))) || 'This field must be an integer'
return /^-?\d+$/.test(String(value)) || 'This field must be an integer'
}
// 👉 Regex Validator
export const regexValidator = (value, regex) => {
if (isEmpty(value))
return true
let regeX = regex
if (typeof regeX === 'string')
regeX = new RegExp(regeX)
if (Array.isArray(value))
return value.every(val => regexValidator(val, regeX))
return regeX.test(String(value)) || 'The Regex field format is invalid'
}
// 👉 Alpha Validator
export const alphaValidator = value => {
if (isEmpty(value))
return true
return /^[A-Z]*$/i.test(String(value)) || 'The Alpha field may only contain alphabetic characters'
}
// 👉 URL Validator
export const urlValidator = value => {
if (isEmpty(value))
return true
const re = /^https?:\/\/[^\s$.?#].\S*$/
return re.test(String(value)) || 'URL is invalid'
}
// 👉 Length Validator
export const lengthValidator = (value, length) => {
if (isEmpty(value))
return true
return String(value).length === length || `"The length of the Characters field must be ${length} characters."`
}
// 👉 Alpha-dash Validator
export const alphaDashValidator = value => {
if (isEmpty(value))
return true
const valueAsString = String(value)
return /^[\w-]*$/.test(valueAsString) || 'All Character are not valid'
}

View File

@@ -0,0 +1,12 @@
import { cookieRef } from '@layouts/stores/config'
export const resolveVuetifyTheme = defaultTheme => {
const cookieColorScheme = cookieRef('color-scheme', usePreferredDark().value ? 'dark' : 'light')
const storedTheme = cookieRef('theme', defaultTheme).value
return storedTheme === 'system'
? cookieColorScheme.value === 'dark'
? 'dark'
: 'light'
: storedTheme
}

View File

@@ -0,0 +1,11 @@
export { default as HorizontalNav } from './components/HorizontalNav.vue'
export { default as HorizontalNavGroup } from './components/HorizontalNavGroup.vue'
export { default as HorizontalNavLayout } from './components/HorizontalNavLayout.vue'
export { default as HorizontalNavLink } from './components/HorizontalNavLink.vue'
export { default as HorizontalNavPopper } from './components/HorizontalNavPopper.vue'
export { default as TransitionExpand } from './components/TransitionExpand.vue'
export { default as VerticalNav } from './components/VerticalNav.vue'
export { default as VerticalNavGroup } from './components/VerticalNavGroup.vue'
export { default as VerticalNavLayout } from './components/VerticalNavLayout.vue'
export { default as VerticalNavLink } from './components/VerticalNavLink.vue'
export { default as VerticalNavSectionTitle } from './components/VerticalNavSectionTitle.vue'

View File

@@ -0,0 +1,41 @@
<script setup>
import {
HorizontalNavGroup,
HorizontalNavLink,
} from '@layouts/components'
const props = defineProps({
navItems: {
type: null,
required: true,
},
})
const resolveNavItemComponent = item => {
if ('children' in item)
return HorizontalNavGroup
return HorizontalNavLink
}
</script>
<template>
<ul class="nav-items">
<Component
:is="resolveNavItemComponent(item)"
v-for="(item, index) in navItems"
:key="index"
data-allow-mismatch
:item="item"
/>
</ul>
</template>
<style lang="scss">
.layout-wrapper.layout-nav-type-horizontal {
.nav-items {
display: flex;
flex-wrap: wrap;
}
}
</style>

View File

@@ -0,0 +1,117 @@
<script setup>
import { layoutConfig } from '@layouts'
import {
HorizontalNavLink,
HorizontalNavPopper,
} from '@layouts/components'
import { canViewNavMenuGroup } from '@layouts/plugins/casl'
import { useLayoutConfigStore } from '@layouts/stores/config'
import {
getDynamicI18nProps,
isNavGroupActive,
} from '@layouts/utils'
const props = defineProps({
item: {
type: null,
required: true,
},
childrenAtEnd: {
type: Boolean,
required: false,
default: false,
},
isSubItem: {
type: Boolean,
required: false,
default: false,
},
})
defineOptions({
name: 'HorizontalNavGroup',
})
const route = useRoute()
const router = useRouter()
const configStore = useLayoutConfigStore()
const isGroupActive = ref(false)
/*Watch for route changes, more specifically route path. Do note that this won't trigger if route's query is updated.
updates isActive & isOpen based on active state of group.
*/
watch(() => route.path, () => {
const isActive = isNavGroupActive(props.item.children, router)
isGroupActive.value = isActive
}, { immediate: true })
</script>
<template>
<HorizontalNavPopper
v-if="canViewNavMenuGroup(item)"
:is-rtl="configStore.isAppRTL"
class="nav-group"
tag="li"
content-container-tag="ul"
:class="[{
'active': isGroupActive,
'children-at-end': childrenAtEnd,
'sub-item': isSubItem,
'disabled': item.disable,
}]"
:popper-inline-end="childrenAtEnd"
>
<div class="nav-group-label">
<Component
:is="layoutConfig.app.iconRenderer || 'div'"
class="nav-item-icon"
v-bind="item.icon || layoutConfig.verticalNav.defaultNavItemIconProps"
/>
<Component
:is="layoutConfig.app.i18n.enable ? 'i18n-t' : 'span'"
v-bind="getDynamicI18nProps(item.title, 'span')"
class="nav-item-title"
>
{{ item.title }}
</Component>
<Component
v-bind="layoutConfig.icons.chevronDown"
:is="layoutConfig.app.iconRenderer || 'div'"
class="nav-group-arrow"
/>
</div>
<template #content>
<Component
:is="'children' in child ? 'HorizontalNavGroup' : HorizontalNavLink"
v-for="child in item.children"
:key="child.title"
:item="child"
children-at-end
is-sub-item
/>
</template>
</HorizontalNavPopper>
</template>
<style lang="scss">
.layout-horizontal-nav {
.nav-group {
.nav-group-label {
display: flex;
align-items: center;
cursor: pointer;
}
.popper-content {
z-index: 1;
> div {
overflow: hidden auto;
}
}
}
}
</style>

View File

@@ -0,0 +1,156 @@
<script setup>
import { HorizontalNav } from '@layouts/components'
// Using import from `@layouts` causing build to hangup
// import { useLayouts } from '@layouts'
import { useLayoutConfigStore } from '@layouts/stores/config'
const props = defineProps({
navItems: {
type: null,
required: true,
},
})
const configStore = useLayoutConfigStore()
</script>
<template>
<div
class="layout-wrapper"
data-allow-mismatch
:class="configStore._layoutClasses"
>
<div
class="layout-navbar-and-nav-container"
:class="configStore.isNavbarBlurEnabled && 'header-blur'"
>
<!-- 👉 Navbar -->
<div class="layout-navbar">
<div class="navbar-content-container">
<slot name="navbar" />
</div>
</div>
<!-- 👉 Navigation -->
<div class="layout-horizontal-nav">
<div class="horizontal-nav-content-container">
<HorizontalNav :nav-items="navItems" />
</div>
</div>
</div>
<main class="layout-page-content">
<slot />
</main>
<!-- 👉 Footer -->
<footer class="layout-footer"
v-if="!$route.meta.hideFooter"
>
<div class="footer-content-container">
<slot name="footer" />
</div>
</footer>
</div>
</template>
<style lang="scss">
@use "@configured-variables" as variables;
@use "@layouts/styles/placeholders";
@use "@layouts/styles/mixins";
.layout-wrapper {
&.layout-nav-type-horizontal {
display: flex;
flex-direction: column;
// // TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
// min-height: 100%;
min-block-size: 100dvh;
.layout-navbar-and-nav-container {
z-index: 1;
}
.layout-navbar {
z-index: variables.$layout-horizontal-nav-layout-navbar-z-index;
block-size: variables.$layout-horizontal-nav-navbar-height;
// For now we are not independently managing navbar and horizontal nav so we won't use below style to avoid conflicting with combo style of navbar and horizontal nav
// If we add independent style of navbar & horizontal nav then we have to add :not for avoiding conflict with combo styles
// .layout-navbar-sticky & {
// @extend %layout-navbar-sticky;
// }
// For now we are not independently managing navbar and horizontal nav so we won't use below style to avoid conflicting with combo style of navbar and horizontal nav
// If we add independent style of navbar & horizontal nav then we have to add :not for avoiding conflict with combo styles
// .layout-navbar-hidden & {
// @extend %layout-navbar-hidden;
// }
}
// 👉 Navbar
.navbar-content-container {
@include mixins.boxed-content;
}
// 👉 Content height fixed
&.layout-content-height-fixed {
max-block-size: 100dvh;
.layout-page-content {
overflow: hidden;
> :first-child {
max-block-size: 100%;
overflow-y: auto;
}
}
}
// 👉 Footer
// Boxed content
.layout-footer {
.footer-content-container {
@include mixins.boxed-content;
}
}
}
// If both navbar & horizontal nav sticky
&.layout-navbar-sticky.horizontal-nav-sticky {
.layout-navbar-and-nav-container {
position: sticky;
inset-block-start: 0;
will-change: transform;
}
}
&.layout-navbar-hidden.horizontal-nav-hidden {
.layout-navbar-and-nav-container {
display: none;
}
}
}
// 👉 Horizontal nav nav
.layout-horizontal-nav {
z-index: variables.$layout-horizontal-nav-z-index;
// .horizontal-nav-sticky & {
// width: 100%;
// will-change: transform;
// position: sticky;
// top: 0;
// }
// .horizontal-nav-hidden & {
// display: none;
// }
.horizontal-nav-content-container {
@include mixins.boxed-content(true);
}
}
</style>

View File

@@ -0,0 +1,60 @@
<script setup>
import { layoutConfig } from '@layouts'
import { can } from '@layouts/plugins/casl'
import {
getComputedNavLinkToProp,
getDynamicI18nProps,
isNavLinkActive,
} from '@layouts/utils'
const props = defineProps({
item: {
type: null,
required: true,
},
isSubItem: {
type: Boolean,
required: false,
default: false,
},
})
</script>
<template>
<li
v-if="can(item.action, item.subject)"
class="nav-link"
:class="[{
'sub-item': props.isSubItem,
'disabled': item.disable,
}]"
>
<Component
:is="item.to ? 'RouterLink' : 'a'"
v-bind="getComputedNavLinkToProp(item)"
:class="{ 'router-link-active router-link-exact-active': isNavLinkActive(item, $router) }"
>
<Component
:is="layoutConfig.app.iconRenderer || 'div'"
class="nav-item-icon"
v-bind="item.icon || layoutConfig.verticalNav.defaultNavItemIconProps"
/>
<Component
:is="layoutConfig.app.i18n.enable ? 'i18n-t' : 'span'"
class="nav-item-title"
v-bind="getDynamicI18nProps(item.title, 'span')"
>
{{ item.title }}
</Component>
</Component>
</li>
</template>
<style lang="scss">
.layout-horizontal-nav {
.nav-link a {
display: flex;
align-items: center;
}
}
</style>

View File

@@ -0,0 +1,214 @@
<script setup>
import {
computePosition,
flip,
offset,
shift,
} from '@floating-ui/dom'
import { useLayoutConfigStore } from '@layouts/stores/config'
import { themeConfig } from '@themeConfig'
const props = defineProps({
popperInlineEnd: {
type: Boolean,
required: false,
default: false,
},
tag: {
type: String,
required: false,
default: 'div',
},
contentContainerTag: {
type: String,
required: false,
default: 'div',
},
isRtl: {
type: Boolean,
required: false,
},
})
const configStore = useLayoutConfigStore()
const refPopperContainer = ref()
const refPopper = ref()
const popperContentStyles = ref({
left: '0px',
top: '0px',
/* Why we are not using fixed positioning?
`position: fixed` doesn't work as expected when some CSS properties like `transform` is applied on its parent element.
Docs: https://developer.mozilla.org/en-US/docs/Web/CSS/position#values <= See `fixed` value description
Hence, when we use transitions where transition apply `transform` on its parent element, fixed positioning will not work.
(Popper content moves away from the element when parent element transition)
To avoid this, we use `position: absolute` instead of `position: fixed`.
NOTE: This issue starts from third level children (Top Level > Sub item > Sub item).
*/
// strategy: 'fixed',
})
const updatePopper = async () => {
if (refPopperContainer.value !== undefined && refPopper.value !== undefined) {
const { x, y } = await computePosition(refPopperContainer.value, refPopper.value, {
placement: props.popperInlineEnd ? props.isRtl ? 'left-start' : 'right-start' : 'bottom-start',
middleware: [
...configStore.horizontalNavPopoverOffset ? [offset(configStore.horizontalNavPopoverOffset)] : [],
flip({
boundary: document.querySelector('body'),
padding: { bottom: 16 },
}),
shift({
boundary: document.querySelector('body'),
padding: { bottom: 16 },
}),
],
/* Why we are not using fixed positioning?
`position: fixed` doesn't work as expected when some CSS properties like `transform` is applied on its parent element.
Docs: https://developer.mozilla.org/en-US/docs/Web/CSS/position#values <= See `fixed` value description
Hence, when we use transitions where transition apply `transform` on its parent element, fixed positioning will not work.
(Popper content moves away from the element when parent element transition)
To avoid this, we use `position: absolute` instead of `position: fixed`.
NOTE: This issue starts from third level children (Top Level > Sub item > Sub item).
*/
// strategy: 'fixed',
})
popperContentStyles.value.left = `${ x }px`
popperContentStyles.value.top = `${ y }px`
}
}
until(() => configStore.horizontalNavType).toMatch(type => type === 'static').then(() => {
useEventListener('scroll', updatePopper)
/* Why we are not using fixed positioning?
`position: fixed` doesn't work as expected when some CSS properties like `transform` is applied on its parent element.
Docs: https://developer.mozilla.org/en-US/docs/Web/CSS/position#values <= See `fixed` value description
Hence, when we use transitions where transition apply `transform` on its parent element, fixed positioning will not work.
(Popper content moves away from the element when parent element transition)
To avoid this, we use `position: absolute` instead of `position: fixed`.
NOTE: This issue starts from third level children (Top Level > Sub item > Sub item).
*/
// strategy: 'fixed',
})
const isContentShown = ref(false)
const showContent = () => {
isContentShown.value = true
updatePopper()
}
const hideContent = () => {
isContentShown.value = false
}
onMounted(updatePopper)
// Recalculate popper position when it's triggerer changes its position
watch([
() => configStore.isAppRTL,
() => configStore.appContentWidth,
], updatePopper)
// Watch for route changes and close popper content if route is changed
const route = useRoute()
watch(() => route.fullPath, hideContent)
</script>
<template>
<div
class="nav-popper"
:class="[{
'popper-inline-end': popperInlineEnd,
'show-content': isContentShown,
}]"
>
<div
ref="refPopperContainer"
class="popper-triggerer"
@mouseenter="showContent"
@mouseleave="hideContent"
>
<slot />
</div>
<!-- SECTION Popper Content -->
<!-- 👉 Without transition -->
<template v-if="!themeConfig.horizontalNav.transition">
<div
ref="refPopper"
class="popper-content"
:style="popperContentStyles"
@mouseenter="showContent"
@mouseleave="hideContent"
>
<div>
<slot name="content" />
</div>
</div>
</template>
<!-- 👉 CSS Transition -->
<template v-else-if="typeof themeConfig.horizontalNav.transition === 'string'">
<Transition :name="themeConfig.horizontalNav.transition">
<div
v-show="isContentShown"
ref="refPopper"
class="popper-content"
:style="popperContentStyles"
@mouseenter="showContent"
@mouseleave="hideContent"
>
<div>
<slot name="content" />
</div>
</div>
</Transition>
</template>
<!-- 👉 Transition Component -->
<template v-else>
<Component :is="themeConfig.horizontalNav.transition">
<div
v-show="isContentShown"
ref="refPopper"
class="popper-content"
:style="popperContentStyles"
@mouseenter="showContent"
@mouseleave="hideContent"
>
<div>
<slot name="content" />
</div>
</div>
</Component>
</template>
<!-- !SECTION -->
</div>
</template>
<style lang="scss">
.popper-content {
position: absolute;
}
</style>

View File

@@ -0,0 +1,87 @@
<!-- Thanks: https://markus.oberlehner.net/blog/transition-to-height-auto-with-vue/ -->
<script>
import { Transition } from 'vue'
export default defineComponent({
name: 'TransitionExpand',
setup(_, { slots }) {
const onEnter = element => {
const width = getComputedStyle(element).width
element.style.width = width
element.style.position = 'absolute'
element.style.visibility = 'hidden'
element.style.height = 'auto'
const height = getComputedStyle(element).height
element.style.width = ''
element.style.position = ''
element.style.visibility = ''
element.style.height = '0px'
// Force repaint to make sure the
// animation is triggered correctly.
// eslint-disable-next-line no-unused-expressions
getComputedStyle(element).height
// Trigger the animation.
// We use `requestAnimationFrame` because we need
// to make sure the browser has finished
// painting after setting the `height`
// to `0` in the line above.
requestAnimationFrame(() => {
element.style.height = height
})
}
const onAfterEnter = element => {
element.style.height = 'auto'
}
const onLeave = element => {
const height = getComputedStyle(element).height
element.style.height = height
// Force repaint to make sure the
// animation is triggered correctly.
// eslint-disable-next-line no-unused-expressions
getComputedStyle(element).height
requestAnimationFrame(() => {
element.style.height = '0px'
})
}
return () => h(h(Transition), {
name: 'expand',
onEnter,
onAfterEnter,
onLeave,
}, () => slots.default?.())
},
})
</script>
<style>
.expand-enter-active,
.expand-leave-active {
overflow: hidden;
transition: block-size var(--expand-transition-duration, 0.25s) ease;
}
.expand-enter-from,
.expand-leave-to {
block-size: 0;
}
</style>
<style scoped>
* {
backface-visibility: hidden;
perspective: 1000px;
transform: translateZ(0);
will-change: block-size;
}
</style>

View File

@@ -0,0 +1,12 @@
export const VNodeRenderer = defineComponent({
name: 'VNodeRenderer',
props: {
nodes: {
type: [Array, Object],
required: true,
},
},
setup(props) {
return () => props.nodes
},
})

View File

@@ -0,0 +1,249 @@
<script setup>
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
import { VNodeRenderer } from './VNodeRenderer'
import { layoutConfig } from '@layouts'
import {
VerticalNavGroup,
VerticalNavLink,
VerticalNavSectionTitle,
} from '@layouts/components'
import { useLayoutConfigStore } from '@layouts/stores/config'
import { injectionKeyIsVerticalNavHovered } from '@layouts/symbols'
const props = defineProps({
tag: {
type: null,
required: false,
default: 'aside',
},
navItems: {
type: null,
required: true,
},
isOverlayNavActive: {
type: Boolean,
required: true,
},
toggleIsOverlayNavActive: {
type: Function,
required: true,
},
})
const refNav = ref()
const isHovered = useElementHover(refNav)
provide(injectionKeyIsVerticalNavHovered, isHovered)
const configStore = useLayoutConfigStore()
const resolveNavItemComponent = item => {
if ('heading' in item)
return VerticalNavSectionTitle
if ('children' in item)
return VerticalNavGroup
return VerticalNavLink
}
/* Close overlay side when route is changed
Close overlay vertical nav when link is clicked
*/
const route = useRoute()
watch(() => route.name, () => {
props.toggleIsOverlayNavActive(false)
})
const isVerticalNavScrolled = ref(false)
const updateIsVerticalNavScrolled = val => isVerticalNavScrolled.value = val
const handleNavScroll = evt => {
isVerticalNavScrolled.value = evt.target.scrollTop > 0
}
const hideTitleAndIcon = configStore.isVerticalNavMini(isHovered)
</script>
<template>
<Component
:is="props.tag"
ref="refNav"
data-allow-mismatch
class="layout-vertical-nav"
:class="[
{
'overlay-nav': configStore.isLessThanOverlayNavBreakpoint,
'hovered': isHovered,
'visible': isOverlayNavActive,
'scrolled': isVerticalNavScrolled,
},
]"
>
<!-- 👉 Header -->
<div class="nav-header">
<slot name="nav-header">
<RouterLink
to="/"
class="app-logo app-title-wrapper"
>
<VNodeRenderer :nodes="layoutConfig.app.logo" />
<Transition name="vertical-nav-app-title">
<h1
v-show="!hideTitleAndIcon"
class="app-logo-title"
>
{{ layoutConfig.app.title }}
</h1>
</Transition>
</RouterLink>
<!-- 👉 Vertical nav actions -->
<!-- Show toggle collapsible in >md and close button in <md -->
<div class="header-action">
<Component
:is="layoutConfig.app.iconRenderer || 'div'"
v-show="configStore.isVerticalNavCollapsed"
class="d-none nav-unpin"
:class="configStore.isVerticalNavCollapsed && 'd-lg-block'"
v-bind="layoutConfig.icons.verticalNavUnPinned"
@click="configStore.isVerticalNavCollapsed = !configStore.isVerticalNavCollapsed"
/>
<Component
:is="layoutConfig.app.iconRenderer || 'div'"
v-show="!configStore.isVerticalNavCollapsed"
class="d-none nav-pin"
:class="!configStore.isVerticalNavCollapsed && 'd-lg-block'"
v-bind="layoutConfig.icons.verticalNavPinned"
@click="configStore.isVerticalNavCollapsed = !configStore.isVerticalNavCollapsed"
/>
<Component
:is="layoutConfig.app.iconRenderer || 'div'"
class="d-lg-none"
v-bind="layoutConfig.icons.close"
@click="toggleIsOverlayNavActive(false)"
/>
</div>
</slot>
</div>
<slot name="before-nav-items">
<div class="vertical-nav-items-shadow" />
</slot>
<slot
name="nav-items"
:update-is-vertical-nav-scrolled="updateIsVerticalNavScrolled"
>
<PerfectScrollbar
:key="configStore.isAppRTL"
tag="ul"
class="nav-items"
:options="{ wheelPropagation: false }"
@ps-scroll-y="handleNavScroll"
>
<Component
:is="resolveNavItemComponent(item)"
v-for="(item, index) in navItems"
:key="index"
:item="item"
/>
</PerfectScrollbar>
</slot>
<slot name="after-nav-items" />
</Component>
</template>
<style lang="scss" scoped>
.app-logo {
display: flex;
align-items: center;
column-gap: 0.75rem;
.app-logo-title {
font-size: 1.375rem;
font-weight: 700;
letter-spacing: 0.25px;
line-height: 1.5rem;
text-transform: capitalize;
}
}
</style>
<style lang="scss">
@use "@configured-variables" as variables;
@use "@layouts/styles/mixins";
// 👉 Vertical Nav
.layout-vertical-nav {
position: fixed;
z-index: variables.$layout-vertical-nav-z-index;
display: flex;
flex-direction: column;
block-size: 100%;
inline-size: variables.$layout-vertical-nav-width;
inset-block-start: 0;
inset-inline-start: 0;
transition: inline-size 0.25s ease-in-out, box-shadow 0.25s ease-in-out;
will-change: transform, inline-size;
.nav-header {
display: flex;
align-items: center;
.header-action {
cursor: pointer;
@at-root {
#{variables.$selector-vertical-nav-mini} .nav-header .header-action {
&.nav-pin,
&.nav-unpin {
display: none !important;
}
}
}
}
}
.app-title-wrapper {
margin-inline-end: auto;
}
.nav-items {
block-size: 100%;
// We no loner needs this overflow styles as perfect scrollbar applies it
// overflow-x: hidden;
// // We used `overflow-y` instead of `overflow` to mitigate overflow x. Revert back if any issue found.
// overflow-y: auto;
}
.nav-item-title {
overflow: hidden;
margin-inline-end: auto;
text-overflow: ellipsis;
white-space: nowrap;
}
// 👉 Collapsed
.layout-vertical-nav-collapsed & {
&:not(.hovered) {
inline-size: variables.$layout-vertical-nav-collapsed-width;
}
}
}
// Small screen vertical nav transition
@media (max-width: 1279px) {
.layout-vertical-nav {
&:not(.visible) {
transform: translateX(-#{variables.$layout-vertical-nav-width});
@include mixins.rtl {
transform: translateX(variables.$layout-vertical-nav-width);
}
}
transition: transform 0.25s ease-in-out;
}
}
</style>

View File

@@ -0,0 +1,205 @@
<script setup>
import { TransitionGroup } from 'vue'
import { layoutConfig } from '@layouts'
import {
TransitionExpand,
VerticalNavLink,
} from '@layouts/components'
import { canViewNavMenuGroup } from '@layouts/plugins/casl'
import { useLayoutConfigStore } from '@layouts/stores/config'
import { injectionKeyIsVerticalNavHovered } from '@layouts/symbols'
import {
getDynamicI18nProps,
isNavGroupActive,
openGroups,
} from '@layouts/utils'
const props = defineProps({
item: {
type: null,
required: true,
},
})
defineOptions({
name: 'VerticalNavGroup',
})
const route = useRoute()
const router = useRouter()
const configStore = useLayoutConfigStore()
const hideTitleAndBadge = configStore.isVerticalNavMini()
/* We provided default value `ref(false)` because inject will return `T | undefined`
Docs: https://vuejs.org/api/composition-api-dependency-injection.html#inject
*/
const isVerticalNavHovered = inject(injectionKeyIsVerticalNavHovered, ref(false))
const isGroupActive = ref(false)
const isGroupOpen = ref(false)
const isAnyChildOpen = children => {
return children.some(child => {
let result = openGroups.value.includes(child.title)
if ('children' in child)
result = isAnyChildOpen(child.children) || result
return result
})
}
const collapseChildren = children => {
children.forEach(child => {
if ('children' in child)
collapseChildren(child.children)
openGroups.value = openGroups.value.filter(group => group !== child.title)
})
}
/*Watch for route changes, more specifically route path. Do note that this won't trigger if route's query is updated.
updates isActive & isOpen based on active state of group.
*/
watch(() => route.path, () => {
const isActive = isNavGroupActive(props.item.children, router)
// Don't open group if vertical nav is collapsed and window size is more than overlay nav breakpoint
isGroupOpen.value = isActive && !configStore.isVerticalNavMini(isVerticalNavHovered).value
isGroupActive.value = isActive
}, { immediate: true })
watch(isGroupOpen, val => {
// Find group index for adding/removing group from openGroups array
const grpIndex = openGroups.value.indexOf(props.item.title)
// update openGroups array for addition/removal of current group
// If group is opened => Add it to `openGroups` array
if (val && grpIndex === -1) {
openGroups.value.push(props.item.title)
} else if (!val && grpIndex !== -1) {
openGroups.value.splice(grpIndex, 1)
collapseChildren(props.item.children)
}
}, { immediate: true })
/*Watch for openGroups
It will help in making vertical nav adapting the behavior of accordion.
If we open multiple groups without navigating to any route we must close the inactive or temporarily opened groups.
😵‍💫 Gotchas:
* If we open inactive group then it will auto close that group because we close groups based on active state.
Goal of this watcher is auto close groups which are not active when openGroups array is updated.
So, we have to find a way to do not close recently opened inactive group.
For this we will fetch recently added group in openGroups array and won't perform closing operation if recently added group is current group
*/
watch(openGroups, val => {
// Prevent closing recently opened inactive group.
const lastOpenedGroup = val.at(-1)
if (lastOpenedGroup === props.item.title)
return
const isActive = isNavGroupActive(props.item.children, router)
// Goal of this watcher is to close inactive groups. So don't do anything for active groups.
if (isActive)
return
// We won't close group if any of child group is open in current group
if (isAnyChildOpen(props.item.children))
return
isGroupOpen.value = isActive
isGroupActive.value = isActive
}, { deep: true })
// Previously instead of below watcher we were using two individual watcher for `isVerticalNavHovered`, `isVerticalNavCollapsed` & `isLessThanOverlayNavBreakpoint`
watch(configStore.isVerticalNavMini(isVerticalNavHovered), val => {
isGroupOpen.value = val ? false : isGroupActive.value
})
</script>
<template>
<li
v-if="canViewNavMenuGroup(item)"
class="nav-group"
:class="[
{
active: isGroupActive,
open: isGroupOpen,
disabled: item.disable,
},
]"
>
<div
class="nav-group-label"
@click="isGroupOpen = !isGroupOpen"
>
<Component
:is="layoutConfig.app.iconRenderer || 'div'"
v-bind="item.icon || layoutConfig.verticalNav.defaultNavItemIconProps"
class="nav-item-icon"
/>
<Component
:is="TransitionGroup"
name="transition-slide-x"
>
<!-- 👉 Title -->
<Component
:is=" layoutConfig.app.i18n.enable ? 'i18n-t' : 'span'"
v-bind="getDynamicI18nProps(item.title, 'span')"
v-show="!hideTitleAndBadge"
key="title"
class="nav-item-title"
>
{{ item.title }}
</Component>
<!-- 👉 Badge -->
<Component
:is="layoutConfig.app.i18n.enable ? 'i18n-t' : 'span'"
v-bind="getDynamicI18nProps(item.badgeContent, 'span')"
v-show="!hideTitleAndBadge"
v-if="item.badgeContent"
key="badge"
class="nav-item-badge"
:class="item.badgeClass"
>
{{ item.badgeContent }}
</Component>
<Component
:is="layoutConfig.app.iconRenderer || 'div'"
v-show="!hideTitleAndBadge"
v-bind="layoutConfig.icons.chevronRight"
key="arrow"
class="nav-group-arrow"
/>
</Component>
</div>
<TransitionExpand>
<ul
v-show="isGroupOpen"
class="nav-group-children"
>
<Component
:is="'children' in child ? 'VerticalNavGroup' : VerticalNavLink"
v-for="child in item.children"
:key="child.title"
:item="child"
/>
</ul>
</TransitionExpand>
</li>
</template>
<style lang="scss">
.layout-vertical-nav {
.nav-group {
&-label {
display: flex;
align-items: center;
cursor: pointer;
}
}
}
</style>

View File

@@ -0,0 +1,216 @@
<script setup>
import { VerticalNav } from '@layouts/components'
import { useLayoutConfigStore } from '@layouts/stores/config'
const props = defineProps({
navItems: {
type: null,
required: true,
},
verticalNavAttrs: {
type: Object,
required: false,
default: () => ({}),
},
})
const { width: windowWidth } = useWindowSize()
const configStore = useLayoutConfigStore()
const isOverlayNavActive = ref(false)
const isLayoutOverlayVisible = ref(false)
const toggleIsOverlayNavActive = useToggle(isOverlayNavActive)
// This is alternative to below two commented watcher
// We want to show overlay if overlay nav is visible and want to hide overlay if overlay is hidden and vice versa.
syncRef(isOverlayNavActive, isLayoutOverlayVisible)
// })
// Hide overlay if user open overlay nav in <md and increase the window width without closing overlay nav
watch(windowWidth, () => {
if (!configStore.isLessThanOverlayNavBreakpoint && isLayoutOverlayVisible.value)
isLayoutOverlayVisible.value = false
})
const verticalNavAttrs = computed(() => {
const vNavAttrs = toRef(props, 'verticalNavAttrs')
const {
wrapper: verticalNavWrapper,
wrapperProps: verticalNavWrapperProps,
...additionalVerticalNavAttrs
} = vNavAttrs.value
return {
verticalNavWrapper,
verticalNavWrapperProps,
additionalVerticalNavAttrs,
}
})
</script>
<template>
<div
class="layout-wrapper"
data-allow-mismatch
:class="configStore._layoutClasses"
>
<component
:is="verticalNavAttrs.verticalNavWrapper ? verticalNavAttrs.verticalNavWrapper : 'div'"
v-bind="verticalNavAttrs.verticalNavWrapperProps"
class="vertical-nav-wrapper"
>
<VerticalNav
:is-overlay-nav-active="isOverlayNavActive"
:toggle-is-overlay-nav-active="toggleIsOverlayNavActive"
:nav-items="props.navItems"
v-bind="{ ...verticalNavAttrs.additionalVerticalNavAttrs }"
>
<template #nav-header>
<slot name="vertical-nav-header" />
</template>
<template #before-nav-items>
<slot name="before-vertical-nav-items" />
</template>
</VerticalNav>
</component>
<div class="layout-content-wrapper">
<header
class="layout-navbar"
:class="[{ 'navbar-blur': configStore.isNavbarBlurEnabled }]"
>
<div class="navbar-content-container">
<slot
name="navbar"
:toggle-vertical-overlay-nav-active="toggleIsOverlayNavActive"
/>
</div>
</header>
<main class="layout-page-content">
<div class="page-content-container">
<slot />
</div>
</main>
<footer class="layout-footer"
v-if="!$route.meta.hideFooter">
<div class="footer-content-container">
<slot name="footer" />
</div>
</footer>
</div>
<div
class="layout-overlay"
:class="[{ visible: isLayoutOverlayVisible }]"
@click="() => { isLayoutOverlayVisible = !isLayoutOverlayVisible }"
/>
</div>
</template>
<style lang="scss">
@use "@configured-variables" as variables;
@use "@layouts/styles/placeholders";
@use "@layouts/styles/mixins";
.layout-wrapper.layout-nav-type-vertical {
// TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
block-size: 100%;
.layout-content-wrapper {
display: flex;
flex-direction: column;
flex-grow: 1;
min-block-size: 100dvh;
transition: padding-inline-start 0.2s ease-in-out;
will-change: padding-inline-start;
@media screen and (min-width: 1280px) {
padding-inline-start: variables.$layout-vertical-nav-width;
}
}
.layout-navbar {
z-index: variables.$layout-vertical-nav-layout-navbar-z-index;
.navbar-content-container {
block-size: variables.$layout-vertical-nav-navbar-height;
}
@at-root {
.layout-wrapper.layout-nav-type-vertical {
.layout-navbar {
@if variables.$layout-vertical-nav-navbar-is-contained {
@include mixins.boxed-content;
}
/* stylelint-disable-next-line @stylistic/indentation */
@else {
.navbar-content-container {
@include mixins.boxed-content;
}
}
}
}
}
}
&.layout-navbar-sticky .layout-navbar {
@extend %layout-navbar-sticky;
}
&.layout-navbar-hidden .layout-navbar {
@extend %layout-navbar-hidden;
}
// 👉 Footer
.layout-footer {
@include mixins.boxed-content;
}
// 👉 Layout overlay
.layout-overlay {
position: fixed;
z-index: variables.$layout-overlay-z-index;
background-color: rgb(0 0 0 / 60%);
cursor: pointer;
inset: 0;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease-in-out;
will-change: opacity;
&.visible {
opacity: 1;
pointer-events: auto;
}
}
// Adjust right column pl when vertical nav is collapsed
&.layout-vertical-nav-collapsed .layout-content-wrapper {
@media screen and (min-width: 1280px) {
padding-inline-start: variables.$layout-vertical-nav-collapsed-width;
}
}
// 👉 Content height fixed
&.layout-content-height-fixed {
.layout-content-wrapper {
max-block-size: 100dvh;
}
.layout-page-content {
display: flex;
overflow: hidden;
.page-content-container {
inline-size: 100%;
> :first-child {
max-block-size: 100%;
overflow-y: auto;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,74 @@
<script setup>
import { layoutConfig } from '@layouts'
import { can } from '@layouts/plugins/casl'
import { useLayoutConfigStore } from '@layouts/stores/config'
import {
getComputedNavLinkToProp,
getDynamicI18nProps,
isNavLinkActive,
} from '@layouts/utils'
const props = defineProps({
item: {
type: null,
required: true,
},
})
const configStore = useLayoutConfigStore()
const hideTitleAndBadge = configStore.isVerticalNavMini()
</script>
<template>
<li
v-if="can(item.action, item.subject)"
class="nav-link"
:class="{ disabled: item.disable }"
>
<Component
:is="item.to ? 'RouterLink' : 'a'"
v-bind="getComputedNavLinkToProp(item)"
:class="{ 'router-link-active router-link-exact-active': isNavLinkActive(item, $router) }"
>
<Component
:is="layoutConfig.app.iconRenderer || 'div'"
v-bind="item.icon || layoutConfig.verticalNav.defaultNavItemIconProps"
class="nav-item-icon"
/>
<TransitionGroup name="transition-slide-x">
<!-- 👉 Title -->
<Component
:is="layoutConfig.app.i18n.enable ? 'i18n-t' : 'span'"
v-show="!hideTitleAndBadge"
key="title"
class="nav-item-title"
v-bind="getDynamicI18nProps(item.title, 'span')"
>
{{ item.title }}
</Component>
<!-- 👉 Badge -->
<Component
:is="layoutConfig.app.i18n.enable ? 'i18n-t' : 'span'"
v-if="item.badgeContent"
v-show="!hideTitleAndBadge"
key="badge"
class="nav-item-badge"
:class="item.badgeClass"
v-bind="getDynamicI18nProps(item.badgeContent, 'span')"
>
{{ item.badgeContent }}
</Component>
</TransitionGroup>
</Component>
</li>
</template>
<style lang="scss">
.layout-vertical-nav {
.nav-link a {
display: flex;
align-items: center;
}
}
</style>

View File

@@ -0,0 +1,39 @@
<script setup>
import { layoutConfig } from '@layouts'
import { can } from '@layouts/plugins/casl'
import { useLayoutConfigStore } from '@layouts/stores/config'
import { getDynamicI18nProps } from '@layouts/utils'
const props = defineProps({
item: {
type: null,
required: true,
},
})
const configStore = useLayoutConfigStore()
const shallRenderIcon = configStore.isVerticalNavMini()
</script>
<template>
<li
v-if="can(item.action, item.subject)"
class="nav-section-title"
>
<div class="title-wrapper">
<Transition
name="vertical-nav-section-title"
mode="out-in"
>
<Component
:is="shallRenderIcon ? layoutConfig.app.iconRenderer : layoutConfig.app.i18n.enable ? 'i18n-t' : 'span'"
:key="shallRenderIcon"
:class="shallRenderIcon ? 'placeholder-icon' : 'title-text'"
v-bind="{ ...layoutConfig.icons.sectionTitlePlaceholder, ...getDynamicI18nProps(item.heading, 'span') }"
>
{{ !shallRenderIcon ? item.heading : null }}
</Component>
</Transition>
</div>
</li>
</template>

View File

@@ -0,0 +1,42 @@
import { breakpointsVuetify } from '@vueuse/core'
import { AppContentLayoutNav, ContentWidth, FooterType, HorizontalNavType, NavbarType } from '@layouts/enums'
export const layoutConfig = {
app: {
title: 'my-layout',
logo: h('img', { src: '/src/assets/logo.svg' }),
contentWidth: ContentWidth.Boxed,
contentLayoutNav: AppContentLayoutNav.Vertical,
overlayNavFromBreakpoint: breakpointsVuetify.md,
// isRTL: false,
i18n: {
enable: true,
},
iconRenderer: h('div'),
},
navbar: {
type: NavbarType.Sticky,
navbarBlur: true,
},
footer: {
type: FooterType.Static,
},
verticalNav: {
isVerticalNavCollapsed: false,
defaultNavItemIconProps: { icon: 'tabler-circle' },
},
horizontalNav: {
type: HorizontalNavType.Sticky,
transition: 'none',
popoverOffset: 0,
},
icons: {
chevronDown: { icon: 'tabler-chevron-down' },
chevronRight: { icon: 'tabler-chevron-right' },
close: { icon: 'tabler-x' },
verticalNavPinned: { icon: 'tabler-circle-dot' },
verticalNavUnPinned: { icon: 'tabler-circle' },
sectionTitlePlaceholder: { icon: 'tabler-minus' },
},
}

View File

@@ -0,0 +1,23 @@
export const ContentWidth = {
Fluid: 'fluid',
Boxed: 'boxed',
}
export const NavbarType = {
Sticky: 'sticky',
Static: 'static',
Hidden: 'hidden',
}
export const FooterType = {
Sticky: 'sticky',
Static: 'static',
Hidden: 'hidden',
}
export const AppContentLayoutNav = {
Vertical: 'vertical',
Horizontal: 'horizontal',
}
export const HorizontalNavType = {
Sticky: 'sticky',
Static: 'static',
Hidden: 'hidden',
}

View File

@@ -0,0 +1,44 @@
import { layoutConfig } from '@layouts/config'
import { cookieRef, useLayoutConfigStore } from '@layouts/stores/config'
import { _setDirAttr } from '@layouts/utils'
// 🔌 Plugin
export const createLayouts = userConfig => {
return () => {
const configStore = useLayoutConfigStore()
// Non reactive Values
layoutConfig.app.title = userConfig.app?.title ?? layoutConfig.app.title
layoutConfig.app.logo = userConfig.app?.logo ?? layoutConfig.app.logo
layoutConfig.app.overlayNavFromBreakpoint = userConfig.app?.overlayNavFromBreakpoint ?? layoutConfig.app.overlayNavFromBreakpoint
layoutConfig.app.i18n.enable = userConfig.app?.i18n?.enable ?? layoutConfig.app.i18n.enable
layoutConfig.app.iconRenderer = userConfig.app?.iconRenderer ?? layoutConfig.app.iconRenderer
layoutConfig.verticalNav.defaultNavItemIconProps = userConfig.verticalNav?.defaultNavItemIconProps ?? layoutConfig.verticalNav.defaultNavItemIconProps
layoutConfig.icons.chevronDown = userConfig.icons?.chevronDown ?? layoutConfig.icons.chevronDown
layoutConfig.icons.chevronRight = userConfig.icons?.chevronRight ?? layoutConfig.icons.chevronRight
layoutConfig.icons.close = userConfig.icons?.close ?? layoutConfig.icons.close
layoutConfig.icons.verticalNavPinned = userConfig.icons?.verticalNavPinned ?? layoutConfig.icons.verticalNavPinned
layoutConfig.icons.verticalNavUnPinned = userConfig.icons?.verticalNavUnPinned ?? layoutConfig.icons.verticalNavUnPinned
layoutConfig.icons.sectionTitlePlaceholder = userConfig.icons?.sectionTitlePlaceholder ?? layoutConfig.icons.sectionTitlePlaceholder
// Reactive Values (Store)
configStore.$patch({
appContentLayoutNav: cookieRef('appContentLayoutNav', userConfig.app?.contentLayoutNav ?? layoutConfig.app.contentLayoutNav).value,
appContentWidth: cookieRef('appContentWidth', userConfig.app?.contentWidth ?? layoutConfig.app.contentWidth).value,
footerType: cookieRef('footerType', userConfig.footer?.type ?? layoutConfig.footer.type).value,
navbarType: cookieRef('navbarType', userConfig.navbar?.type ?? layoutConfig.navbar.type).value,
isNavbarBlurEnabled: cookieRef('isNavbarBlurEnabled', userConfig.navbar?.navbarBlur ?? layoutConfig.navbar.navbarBlur).value,
isVerticalNavCollapsed: cookieRef('isVerticalNavCollapsed', userConfig.verticalNav?.isVerticalNavCollapsed ?? layoutConfig.verticalNav.isVerticalNavCollapsed).value,
// isAppRTL: userConfig.app?.isRTL ?? config.app.isRTL,
// isLessThanOverlayNavBreakpoint: false,
horizontalNavType: cookieRef('horizontalNavType', userConfig.horizontalNav?.type ?? layoutConfig.horizontalNav.type).value,
})
// _setDirAttr(config.app.isRTL ? 'rtl' : 'ltr')
_setDirAttr(configStore.isAppRTL ? 'rtl' : 'ltr')
}
}
export * from './components'
export { layoutConfig }

View File

@@ -0,0 +1,50 @@
import { useAbility } from '@casl/vue'
/**
* Returns ability result if ACL is configured or else just return true
* We should allow passing string | undefined to can because for admin ability we omit defining action & subject
*
* Useful if you don't know if ACL is configured or not
* Used in @core files to handle absence of ACL without errors
*
* @param {string} action CASL Actions // https://casl.js.org/v4/en/guide/intro#basics
* @param {string} subject CASL Subject // https://casl.js.org/v4/en/guide/intro#basics
*/
export const can = (action, subject) => {
const vm = getCurrentInstance()
if (!vm)
return false
const localCan = vm.proxy && '$can' in vm.proxy
return localCan ? vm.proxy?.$can(action, subject) : true
}
/**
* Check if user can view item based on it's ability
* Based on item's action and subject & Hide group if all of it's children are hidden
* @param {object} item navigation object item
*/
export const canViewNavMenuGroup = item => {
const hasAnyVisibleChild = item.children.some(i => can(i.action, i.subject))
// If subject and action is defined in item => Return based on children visibility (Hide group if no child is visible)
// Else check for ability using provided subject and action along with checking if has any visible child
if (!(item.action && item.subject))
return hasAnyVisibleChild
return can(item.action, item.subject) && hasAnyVisibleChild
}
export const canNavigate = to => {
const ability = useAbility()
// Get the most specific route (last one in the matched array)
const targetRoute = to.matched[to.matched.length - 1]
// If the target route has specific permissions, check those first
if (targetRoute?.meta?.action && targetRoute?.meta?.subject)
return ability.can(targetRoute.meta.action, targetRoute.meta.subject)
// If no specific permissions, fall back to checking if any parent route allows access
return to.matched.some(route => ability.can(route.meta.action, route.meta.subject))
}

View File

@@ -0,0 +1,130 @@
import { AppContentLayoutNav, NavbarType } from '@layouts/enums'
import { injectionKeyIsVerticalNavHovered } from '@layouts/symbols'
import { _setDirAttr } from '@layouts/utils'
// We should not import themeConfig here but in urgency we are doing it for now
import { layoutConfig } from '@themeConfig'
export const namespaceConfig = str => `${layoutConfig.app.title}-${str}`
export const cookieRef = (key, defaultValue) => {
return useCookie(namespaceConfig(key), { default: () => defaultValue })
}
export const useLayoutConfigStore = defineStore('layoutConfig', () => {
const route = useRoute()
// 👉 Navbar Type
const navbarType = ref(layoutConfig.navbar.type)
// 👉 Navbar Type
const isNavbarBlurEnabled = cookieRef('isNavbarBlurEnabled', layoutConfig.navbar.navbarBlur)
// 👉 Vertical Nav Collapsed
const isVerticalNavCollapsed = cookieRef('isVerticalNavCollapsed', layoutConfig.verticalNav.isVerticalNavCollapsed)
// 👉 App Content Width
const appContentWidth = cookieRef('appContentWidth', layoutConfig.app.contentWidth)
// 👉 App Content Layout Nav
const appContentLayoutNav = ref(layoutConfig.app.contentLayoutNav)
watch(appContentLayoutNav, val => {
// If Navbar type is hidden while switching to horizontal nav => Reset it to sticky
if (val === AppContentLayoutNav.Horizontal) {
if (navbarType.value === NavbarType.Hidden)
navbarType.value = NavbarType.Sticky
isVerticalNavCollapsed.value = false
}
})
// 👉 Horizontal Nav Type
const horizontalNavType = ref(layoutConfig.horizontalNav.type)
// 👉 Horizontal Nav Popover Offset
const horizontalNavPopoverOffset = ref(layoutConfig.horizontalNav.popoverOffset)
// 👉 Footer Type
const footerType = ref(layoutConfig.footer.type)
// 👉 Misc
const breakpointRef = ref(false)
// Sync with `useMediaQuery`
watchEffect(() => {
breakpointRef.value = useMediaQuery(`(max-width: ${layoutConfig.app.overlayNavFromBreakpoint}px)`).value
})
const isLessThanOverlayNavBreakpoint = computed({
get() {
return breakpointRef.value // Getter for reactive state
},
set(value) {
breakpointRef.value = value // Allow manual mutation
},
})
// 👉 Layout Classes
const _layoutClasses = computed(() => {
const { y: windowScrollY } = useWindowScroll()
return [
`layout-nav-type-${appContentLayoutNav.value}`,
`layout-navbar-${navbarType.value}`,
`layout-footer-${footerType.value}`,
{
'layout-vertical-nav-collapsed': isVerticalNavCollapsed.value
&& appContentLayoutNav.value === 'vertical'
&& !isLessThanOverlayNavBreakpoint.value,
},
{ [`horizontal-nav-${horizontalNavType.value}`]: appContentLayoutNav.value === 'horizontal' },
`layout-content-width-${appContentWidth.value}`,
{ 'layout-overlay-nav': isLessThanOverlayNavBreakpoint.value },
{ 'window-scrolled': unref(windowScrollY) },
route.meta.layoutWrapperClasses ? route.meta.layoutWrapperClasses : null,
]
})
// 👉 RTL
// const isAppRTL = ref(layoutConfig.app.isRTL)
const isAppRTL = ref(false)
watch(isAppRTL, val => {
_setDirAttr(val ? 'rtl' : 'ltr')
})
// 👉 Is Vertical Nav Mini
/*
This function will return true if current state is mini. Mini state means vertical nav is:
- Collapsed
- Isn't hovered by mouse
- nav is not less than overlay breakpoint (hence, isn't overlay menu)
We are getting `isVerticalNavHovered` as param instead of via `inject` because
we are using this in `VerticalNav.vue` component which provide it and I guess because
same component is providing & injecting we are getting undefined error
*/
const isVerticalNavMini = (isVerticalNavHovered = null) => {
const isVerticalNavHoveredLocal = isVerticalNavHovered || inject(injectionKeyIsVerticalNavHovered) || ref(false)
return computed(() => isVerticalNavCollapsed.value && !isVerticalNavHoveredLocal.value && !isLessThanOverlayNavBreakpoint.value)
}
return {
appContentWidth,
appContentLayoutNav,
navbarType,
isNavbarBlurEnabled,
isVerticalNavCollapsed,
horizontalNavType,
horizontalNavPopoverOffset,
footerType,
isLessThanOverlayNavBreakpoint,
isAppRTL,
_layoutClasses,
isVerticalNavMini,
}
})

View File

@@ -0,0 +1,3 @@
.cursor-pointer {
cursor: pointer;
}

View File

@@ -0,0 +1,35 @@
// These are styles which are both common in layout w/ vertical nav & horizontal nav
@use "@layouts/styles/rtl";
@use "@layouts/styles/placeholders";
@use "@layouts/styles/mixins";
@use "@configured-variables" as variables;
html,
body {
min-block-size: 100%;
}
.layout-page-content {
@include mixins.boxed-content(true);
flex-grow: 1;
// TODO: Use grid gutter variable here
padding-block: 1.5rem;
}
.layout-footer {
.footer-content-container {
block-size: variables.$layout-vertical-nav-footer-height;
}
.layout-footer-sticky & {
position: sticky;
inset-block-end: 0;
will-change: transform;
}
.layout-footer-hidden & {
display: none;
}
}

View File

@@ -0,0 +1,10 @@
*,
::before,
::after {
box-sizing: inherit;
background-repeat: no-repeat;
}
html {
box-sizing: border-box;
}

View File

@@ -0,0 +1,30 @@
@use "placeholders";
@use "@configured-variables" as variables;
@mixin rtl {
@if variables.$enable-rtl-styles {
[dir="rtl"] & {
@content;
}
}
}
@mixin boxed-content($nest-selector: false) {
& {
@extend %boxed-content-spacing;
@at-root {
@if $nest-selector == false {
.layout-content-width-boxed#{&} {
@extend %boxed-content;
}
}
// stylelint-disable-next-line @stylistic/indentation
@else {
.layout-content-width-boxed & {
@extend %boxed-content;
}
}
}
}
}

View File

@@ -0,0 +1,53 @@
// placeholders
@use "@configured-variables" as variables;
%boxed-content {
@at-root #{&}-spacing {
// TODO: Use grid gutter variable here
padding-inline: 1.5rem;
}
inline-size: 100%;
margin-inline: auto;
max-inline-size: variables.$layout-boxed-content-width;
}
%layout-navbar-hidden {
display: none;
}
// We created this placeholder even it is being used in just layout w/ vertical nav because in future we might apply style to both navbar & horizontal nav separately
%layout-navbar-sticky {
position: sticky;
inset-block-start: 0;
// will-change: transform;
// inline-size: 100%;
}
%style-scroll-bar {
/* width */
&::-webkit-scrollbar {
background: rgb(var(--v-theme-surface));
block-size: 8px;
border-end-end-radius: 14px;
border-start-end-radius: 14px;
inline-size: 4px;
}
/* Track */
&::-webkit-scrollbar-track {
background: transparent;
}
/* Handle */
&::-webkit-scrollbar-thumb {
border-radius: 0.5rem;
background: rgb(var(--v-theme-perfect-scrollbar-thumb));
}
&::-webkit-scrollbar-corner {
display: none;
}
}

View File

@@ -0,0 +1,7 @@
@use "./mixins";
.layout-vertical-nav .nav-group-arrow {
@include mixins.rtl {
transform: rotate(180deg);
}
}

View File

@@ -0,0 +1,29 @@
// @use "@styles/style.scss";
// 👉 Vertical nav
$layout-vertical-nav-z-index: 12 !default;
$layout-vertical-nav-width: 260px !default;
$layout-vertical-nav-collapsed-width: 80px !default;
$selector-vertical-nav-mini: ".layout-vertical-nav-collapsed .layout-vertical-nav:not(:hover)";
// 👉 Horizontal nav
$layout-horizontal-nav-z-index: 11 !default;
$layout-horizontal-nav-navbar-height: 64px !default;
// 👉 Navbar
$layout-vertical-nav-navbar-height: 64px !default;
$layout-vertical-nav-navbar-is-contained: true !default;
$layout-vertical-nav-layout-navbar-z-index: 11 !default;
$layout-horizontal-nav-layout-navbar-z-index: 11 !default;
// 👉 Main content
$layout-boxed-content-width: 1440px !default;
// 👉Footer
$layout-vertical-nav-footer-height: 56px !default;
// 👉 Layout overlay
$layout-overlay-z-index: 11 !default;
// 👉 RTL
$enable-rtl-styles: true !default;

View File

@@ -0,0 +1,3 @@
@use "global";
@use "vue3-perfect-scrollbar/style.css";
@use "classes";

View File

@@ -0,0 +1 @@
export const injectionKeyIsVerticalNavHovered = Symbol('isVerticalNavHovered')

View File

@@ -0,0 +1 @@
export {}

View File

@@ -0,0 +1,169 @@
import { layoutConfig } from '@layouts/config'
import { AppContentLayoutNav } from '@layouts/enums'
import { useLayoutConfigStore } from '@layouts/stores/config'
export const openGroups = ref([])
/**
* Return nav link props to use
// @param {Object, String} item navigation routeName or route Object provided in navigation data
*/
export const getComputedNavLinkToProp = computed(() => link => {
const props = {
target: link.target,
rel: link.rel,
}
// If route is string => it assumes string is route name => Create route object from route name
// If route is not string => It assumes it's route object => returns passed route object
if (link.to)
props.to = typeof link.to === 'string' ? { name: link.to } : link.to
else
props.href = link.href
return props
})
/**
* Return route name for navigation link
* If link is string then it will assume it is route-name
* IF link is object it will resolve the object and will return the link
// @param {Object, String} link navigation link object/string
*/
export const resolveNavLinkRouteName = (link, router) => {
if (!link.to)
return null
if (typeof link.to === 'string')
return link.to
return router.resolve(link.to).name
}
/**
* Check if nav-link is active
* @param {object} link nav-link object
*/
export const isNavLinkActive = (link, router) => {
// Matched routes array of current route
const matchedRoutes = router.currentRoute.value.matched
// Check if provided route matches route's matched route
const resolveRoutedName = resolveNavLinkRouteName(link, router)
if (!resolveRoutedName)
return false
return matchedRoutes.some(route => {
return route.name === resolveRoutedName || route.meta.navActiveLink === resolveRoutedName
})
}
/**
* Check if nav group is active
* @param {Array} children Group children
*/
export const isNavGroupActive = (children, router) => children.some(child => {
// If child have children => It's group => Go deeper(recursive)
if ('children' in child)
return isNavGroupActive(child.children, router)
// else it's link => Check for matched Route
return isNavLinkActive(child, router)
})
/**
* Change `dir` attribute based on direction
* @param dir 'ltr' | 'rtl'
*/
export const _setDirAttr = dir => {
// Check if document exists for SSR
if (typeof document !== 'undefined')
document.documentElement.setAttribute('dir', dir)
}
/**
* Return dynamic i18n props based on i18n plugin is enabled or not
* @param key i18n translation key
* @param tag tag to wrap the translation with
*/
export const getDynamicI18nProps = (key, tag = 'span') => {
if (!layoutConfig.app.i18n.enable)
return {}
return {
keypath: key,
tag,
scope: 'global',
}
}
export const switchToVerticalNavOnLtOverlayNavBreakpoint = () => {
const configStore = useLayoutConfigStore()
/*
This is flag will hold nav type need to render when switching between lgAndUp from mdAndDown window width
Requirement: When we nav is set to `horizontal` and we hit the `mdAndDown` breakpoint nav type shall change to `vertical` nav
Now if we go back to `lgAndUp` breakpoint from `mdAndDown` how we will know which was previous nav type in large device?
Let's assign value of `appContentLayoutNav` as default value of lgAndUpNav. Why 🤔?
If template is viewed in lgAndUp
We will assign `appContentLayoutNav` value to `lgAndUpNav` because at this point both constant is same
Hence, for `lgAndUpNav` it will take value from theme config file
else
It will always show vertical nav and if user increase the window width it will fallback to `appContentLayoutNav` value
But `appContentLayoutNav` will be value set in theme config file
*/
const lgAndUpNav = ref(configStore.appContentLayoutNav)
/*
There might be case where we manually switch from vertical to horizontal nav and vice versa in `lgAndUp` screen
So when user comes back from `mdAndDown` to `lgAndUp` we can set updated nav type
For this we need to update the `lgAndUpNav` value if screen is `lgAndUp`
*/
watch(() => configStore.appContentLayoutNav, value => {
if (!configStore.isLessThanOverlayNavBreakpoint)
lgAndUpNav.value = value
})
/*
This is layout switching part
If it's `mdAndDown` => We will use vertical nav no matter what previous nav type was
Or if it's `lgAndUp` we need to switch back to `lgAndUp` nav type. For this we will tracker property `lgAndUpNav`
*/
watch(() => configStore.isLessThanOverlayNavBreakpoint, val => {
configStore.appContentLayoutNav = val ? AppContentLayoutNav.Vertical : lgAndUpNav.value
}, { immediate: true })
}
/**
* Convert Hex color to rgb
* @param hex
*/
export const hexToRgb = hex => {
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i
hex = hex.replace(shorthandRegex, (m, r, g, b) => {
return r + r + g + g + b + b
})
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result ? `${Number.parseInt(result[1], 16)},${Number.parseInt(result[2], 16)},${Number.parseInt(result[3], 16)}` : null
}
/**
*RGBA color to Hex color with / without opacity
*/
export const rgbaToHex = (rgba, forceRemoveAlpha = false) => {
return (`#${rgba
.replace(/^rgba?\(|\s+|\)$/g, '') // Get's rgba / rgb string values
.split(',') // splits them at ","
.filter((string, index) => !forceRemoveAlpha || index !== 3)
.map(string => Number.parseFloat(string)) // Converts them to numbers
.map((number, index) => (index === 3 ? Math.round(number * 255) : number)) // Converts alpha to 255 number
.map(number => number.toString(16)) // Converts numbers to hex
.map(string => (string.length === 1 ? `0${string}` : string)) // Adds 0 when length of one number is 1
.join('')}`)
}

28
resources/js/App.vue Normal file
View File

@@ -0,0 +1,28 @@
<script setup>
import { useTheme } from 'vuetify'
import ScrollToTop from '@core/components/ScrollToTop.vue'
import initCore from '@core/initCore'
import {
initConfigStore,
useConfigStore,
} from '@core/stores/config'
import { hexToRgb } from '@core/utils/colorConverter'
const { global } = useTheme()
// Sync current theme with initial loader theme
initCore()
initConfigStore()
const configStore = useConfigStore()
</script>
<template>
<VLocaleProvider :rtl="configStore.isAppRTL">
<!-- This is required to set the background color of active nav link based on currently active global theme's primary -->
<VApp :style="`--v-global-theme-primary: ${hexToRgb(global.current.value.colors.primary)}`">
<RouterView />
<ScrollToTop />
</VApp>
</VLocaleProvider>
</template>

View File

@@ -0,0 +1,442 @@
<template>
<VCard class="h-100 overflow-visible position-relative" :style="cardBackgroundStyle">
<VCardText class="pa-6 h-100 d-flex flex-column">
<div class="d-flex justify-center align-center mb-4 flex-wrap gap-2">
<VChip color="primary" variant="tonal" size="small">
<VIcon icon="tabler-calendar" size="14" class="me-1" />
{{ getCurrentDate() }}
</VChip>
<VChip color="secondary" variant="outlined" size="small" v-if="chartName">
<VIcon icon="tabler-chart-line" size="14" class="me-1" />
{{ chartName }}
</VChip>
</div>
<div class="text-center mb-6">
<div class="progress-circle-container mb-4">
<VProgressCircular
:model-value="completionPercent"
:size="120"
:width="8"
color="success"
class="progress-main"
>
<div class="text-center">
<h3 class="text-h3 font-weight-bold">{{ completionPercent }}%</h3>
<span class="text-caption text-medium-emphasis">Completed</span>
</div>
</VProgressCircular>
</div>
</div>
<div class="mb-4">
<VBtn
block
color="primary"
variant="elevated"
size="large"
class="btn-analyze"
:loading="loading"
@click="onAnalyzeClick"
>
<VIcon
:icon="loading ? 'tabler-loader-2' : 'tabler-chart-bar'"
class="me-2"
:class="{ 'rotating': loading }"
/>
{{ loading ? "Analyzing..." : "Analyze" }}
</VBtn>
</div>
<div class="flex-grow-1 d-flex flex-column" style="min-height: 0; height: 250px;">
<VCard
variant="tonal"
color="surface"
class="analysis-container d-flex flex-column h-100"
>
<VCardText class="pa-4 d-flex flex-column h-100" style="min-height: 0;">
<div class="d-flex align-center mb-3">
<VIcon icon="tabler-report-analytics" class="me-2" />
<h6 class="text-h6">Analysis Results {{ chartName ? `- ${chartName}` : '' }}</h6>
</div>
<div
class="analysis-content custom-scrollbar"
style="overflow-y: auto; height: 1px; flex: 1; color: white;"
v-html="analysisResult"
></div>
</VCardText>
</VCard>
</div>
</VCardText>
</VCard>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useTheme } from 'vuetify'
import { hexToRgb } from '@layouts/utils'
// Define props
const props = defineProps({
chartName: {
type: String,
default: '',
required: false
}
})
const vuetifyTheme = useTheme()
const loading = ref(false)
const completionPercent = ref(25)
const analysisResult = ref(`
<div class="text-center text-medium-emphasis py-8">
<div class="mb-3">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M3 3v18h18"/>
<path d="M18.7 8l-5.1 5.2-2.8-2.7L7 14.3"/>
</svg>
</div>
<p class="mb-0">Click 'Analyze ${props.chartName || 'Projects'}' to generate detailed insights</p>
</div>
`)
const getCurrentDate = () => {
const today = new Date()
const options = {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
}
return today.toLocaleDateString('en-US', options)
}
const cardBackgroundStyle = computed(() => {
const currentTheme = vuetifyTheme.current.value.colors
const createGradientColor = (color, opacity = 0.08) =>
color.startsWith('#') ? `rgba(${hexToRgb(color)}, ${opacity})` : `rgba(${hexToRgb(color)}, ${opacity})`
return {
background: `linear-gradient(135deg,
${createGradientColor(currentTheme.primary, 0.12)} 0%,
${createGradientColor(currentTheme.success, 0.06)} 50%,
${createGradientColor(currentTheme.primary, 0.04)} 100%)`
}
})
function onAnalyzeClick() {
loading.value = true
analysisResult.value = `
<div class="text-center py-4">
<div class="mb-3">
<svg class="rotating" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 11-6.219-8.56"/>
</svg>
</div>
<p class="text-medium-emphasis">Analyzing ${props.chartName || 'project'} data...</p>
</div>
`
setTimeout(() => {
loading.value = false
analysisResult.value = `
<div class="analysis-results">
<div class="result-section mb-4">
<h6 class="text-subtitle-1 mb-3 d-flex align-center">
<span class="result-icon success me-2">📊</span>
${props.chartName || 'Project'} Overview
</h6>
<div class="result-grid">
<div class="result-item">
<span class="result-label">Avg. Completion Time</span>
<span class="result-value text-primary">14 days</span>
</div>
<div class="result-item">
<span class="result-label">Success Rate</span>
<span class="result-value text-success">85%</span>
</div>
</div>
</div>
<div class="result-section mb-4">
<h6 class="text-subtitle-1 mb-3 d-flex align-center">
<span class="result-icon warning me-2">📈</span>
Current Status
</h6>
<div class="status-list">
<div class="status-item">
<div class="status-dot success"></div>
<span>Completed ${props.chartName ? props.chartName.toLowerCase() : 'projects'}: <strong>1</strong></span>
</div>
<div class="status-item">
<div class="status-dot warning"></div>
<span>Pending ${props.chartName ? props.chartName.toLowerCase() : 'projects'}: <strong>2</strong></span>
</div>
<div class="status-item">
<div class="status-dot primary"></div>
<span>Average Progress: <strong>40%</strong></span>
</div>
</div>
</div>
<div class="result-section mb-4">
<h6 class="text-subtitle-1 mb-3 d-flex align-center">
<span class="result-icon info me-2">💡</span>
Recommendations
</h6>
<ul class="recommendation-list">
<li>Focus on completing pending ${props.chartName ? props.chartName.toLowerCase() : 'projects'}</li>
<li>Optimize resource allocation for better efficiency</li>
<li>Set up automated progress tracking</li>
<li>Review ${props.chartName ? props.chartName.toLowerCase() : 'project'} timelines and deadlines</li>
<li>Implement better team communication</li>
<li>Consider hiring additional resources</li>
</ul>
</div>
<div class="result-section mb-4">
<h6 class="text-subtitle-1 mb-3 d-flex align-center">
<span class="result-icon me-2">📅</span>
Detailed Timeline
</h6>
<div class="timeline-items">
<div class="timeline-item">
<div class="timeline-dot"></div>
<div class="timeline-content">
<strong>Alpha ${props.chartName || 'Project'}</strong> - Started 10 days ago, 80% complete
</div>
</div>
<div class="timeline-item">
<div class="timeline-dot"></div>
<div class="timeline-content">
<strong>Beta ${props.chartName || 'Project'}</strong> - Started 5 days ago, 30% complete
</div>
</div>
<div class="timeline-item">
<div class="timeline-dot"></div>
<div class="timeline-content">
<strong>Gamma ${props.chartName || 'Project'}</strong> - Starting next week, 0% complete
</div>
</div>
</div>
</div>
<div class="result-section">
<h6 class="text-subtitle-1 mb-3 d-flex align-center">
<span class="result-icon me-2">⚡</span>
Performance Metrics
</h6>
<div class="metrics-grid">
<div class="metric-item">
<span class="metric-label">Tasks per Day</span>
<span class="metric-value">3.2</span>
</div>
<div class="metric-item">
<span class="metric-label">Efficiency Score</span>
<span class="metric-value">92%</span>
</div>
<div class="metric-item">
<span class="metric-label">Team Velocity</span>
<span class="metric-value">High</span>
</div>
<div class="metric-item">
<span class="metric-label">Quality Score</span>
<span class="metric-value">8.7/10</span>
</div>
</div>
</div>
</div>
`
}, 2000)
}
</script>
<style scoped>
.progress-circle-container {
position: relative;
display: inline-block;
}
.floating-stats {
position: absolute;
top: -10px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
}
.stat-chip {
backdrop-filter: blur(10px);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.btn-analyze {
box-shadow: 0 4px 12px rgba(var(--v-theme-primary), 0.3);
transition: all 0.3s ease;
}
.btn-analyze:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(var(--v-theme-primary), 0.4);
}
.analysis-container {
backdrop-filter: blur(10px);
border: 1px solid rgba(var(--v-border-color), 0.1);
}
.analysis-content {
scrollbar-width: thin;
scrollbar-color: rgba(var(--v-theme-primary), 0.3) transparent;
color: white !important;
}
.analysis-content * {
color: white !important;
}
.analysis-content .text-medium-emphasis {
color: rgba(255, 255, 255, 0.7) !important;
}
.analysis-content .text-primary {
color: rgb(var(--v-theme-primary)) !important;
}
.analysis-content .text-success {
color: rgb(var(--v-theme-success)) !important;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(var(--v-theme-primary), 0.3);
border-radius: 4px;
}
.rotating {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.result-section {
background: rgba(var(--v-theme-surface), 0.5);
border-radius: 8px;
padding: 16px;
border: 1px solid rgba(var(--v-border-color), 0.1);
}
.result-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.result-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.result-label {
font-size: 0.75rem;
opacity: 0.7;
}
.result-value {
font-weight: 600;
font-size: 0.875rem;
}
.status-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.status-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.875rem;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.success { background-color: rgb(var(--v-theme-success)); }
.status-dot.warning { background-color: rgb(var(--v-theme-warning)); }
.status-dot.primary { background-color: rgb(var(--v-theme-primary)); }
.result-icon {
font-size: 1.2rem;
}
.recommendation-list {
margin: 0;
padding-left: 16px;
}
.recommendation-list li {
margin-bottom: 6px;
font-size: 0.875rem;
opacity: 0.8;
}
.timeline-items {
display: flex;
flex-direction: column;
gap: 12px;
}
.timeline-item {
display: flex;
align-items: center;
gap: 12px;
}
.timeline-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: rgb(var(--v-theme-primary));
flex-shrink: 0;
}
.timeline-content {
font-size: 0.875rem;
}
.metrics-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.metric-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.metric-label {
font-size: 0.75rem;
opacity: 0.7;
}
.metric-value {
font-weight: 600;
font-size: 0.875rem;
color: rgb(var(--v-theme-primary));
}
</style>

View File

@@ -0,0 +1,63 @@
<script setup>
const bufferValue = ref(20)
const progressValue = ref(10)
const isFallbackState = ref(false)
const interval = ref()
const showProgress = ref(false)
watch([
progressValue,
isFallbackState,
], () => {
if (progressValue.value > 80 && isFallbackState.value)
progressValue.value = 82
startBuffer()
})
function startBuffer() {
clearInterval(interval.value)
interval.value = setInterval(() => {
progressValue.value += Math.random() * (15 - 5) + 5
bufferValue.value += Math.random() * (15 - 5) + 6
}, 800)
}
const fallbackHandle = () => {
showProgress.value = true
progressValue.value = 10
isFallbackState.value = true
startBuffer()
}
const resolveHandle = () => {
isFallbackState.value = false
progressValue.value = 100
setTimeout(() => {
clearInterval(interval.value)
progressValue.value = 0
bufferValue.value = 20
showProgress.value = false
}, 300)
}
defineExpose({
fallbackHandle,
resolveHandle,
})
</script>
<template>
<!-- loading state via #fallback slot -->
<div
v-if="showProgress"
class="position-fixed"
style="z-index: 9999; inset-block-start: 0; inset-inline: 0 0;"
>
<VProgressLinear
v-model="progressValue"
:buffer-value="bufferValue"
color="primary"
height="2"
bg-color="background"
/>
</div>
</template>

View File

@@ -0,0 +1,278 @@
<script setup>
import safeBoxWithGoldenCoin from '@images/misc/3d-safe-box-with-golden-dollar-coins.png'
import spaceRocket from '@images/misc/3d-space-rocket-with-smoke.png'
import dollarCoinPiggyBank from '@images/misc/dollar-coins-flying-pink-piggy-bank.png'
const props = defineProps({
title: {
type: String,
required: false,
},
xs: {
type: [
Number,
String,
],
required: false,
},
sm: {
type: [
Number,
String,
],
required: false,
},
md: {
type: [
String,
Number,
],
required: false,
},
lg: {
type: [
String,
Number,
],
required: false,
},
xl: {
type: [
String,
Number,
],
required: false,
},
})
const annualMonthlyPlanPriceToggler = ref(true)
const pricingPlans = [
{
name: 'Basic',
tagLine: 'A simple start for everyone',
logo: dollarCoinPiggyBank,
monthlyPrice: 0,
yearlyPrice: 0,
isPopular: false,
current: true,
features: [
'100 responses a month',
'Unlimited forms and surveys',
'Unlimited fields',
'Basic form creation tools',
'Up to 2 subdomains',
],
},
{
name: 'Standard',
tagLine: 'For small to medium businesses',
logo: safeBoxWithGoldenCoin,
monthlyPrice: 49,
yearlyPrice: 499,
isPopular: true,
current: false,
features: [
'Unlimited responses',
'Unlimited forms and surveys',
'Instagram profile page',
'Google Docs integration',
'Custom “Thank you” page',
],
},
{
name: 'Enterprise',
tagLine: 'Solution for big organizations',
logo: spaceRocket,
monthlyPrice: 99,
yearlyPrice: 999,
isPopular: false,
current: false,
features: [
'PayPal payments',
'Logic Jumps',
'File upload with 5GB storage',
'Custom domain support',
'Stripe integration',
],
},
]
</script>
<template>
<!-- 👉 Title and subtitle -->
<div class="text-center">
<h3 class="text-h3 pricing-title mb-2">
{{ props.title ? props.title : 'Pricing Plans' }}
</h3>
<p class="mb-0">
All plans include 40+ advanced tools and features to boost your product.
</p>
<p class="mb-2">
Choose the best plan to fit your needs.
</p>
</div>
<!-- 👉 Annual and monthly price toggler -->
<div class="d-flex font-weight-medium text-body-1 align-center justify-center mx-auto mt-12 mb-6">
<VLabel
for="pricing-plan-toggle"
class="me-3"
>
Monthly
</VLabel>
<div class="position-relative">
<VSwitch
id="pricing-plan-toggle"
v-model="annualMonthlyPlanPriceToggler"
>
<template #label>
<div class="text-body-1 font-weight-medium">
Annually
</div>
</template>
</VSwitch>
<div class="save-upto-chip position-absolute align-center d-none d-md-flex gap-1">
<VIcon
icon="tabler-corner-left-down"
size="24"
class="flip-in-rtl mt-2 text-disabled"
/>
<VChip
label
color="primary"
size="small"
>
Save up to 10%
</VChip>
</div>
</div>
</div>
<!-- SECTION pricing plans -->
<VRow>
<VCol
v-for="plan in pricingPlans"
:key="plan.logo"
v-bind="props"
cols="12"
>
<!-- 👉 Card -->
<VCard
flat
border
:class="plan.isPopular ? 'border-primary border-opacity-100' : ''"
>
<VCardText
style="block-size: 3.75rem;"
class="text-end"
>
<!-- 👉 Popular -->
<VChip
v-show="plan.isPopular"
label
color="primary"
size="small"
>
Popular
</VChip>
</VCardText>
<!-- 👉 Plan logo -->
<VCardText>
<VImg
:height="120"
:width="120"
:src="plan.logo"
class="mx-auto mb-5"
/>
<!-- 👉 Plan name -->
<h4 class="text-h4 mb-1 text-center">
{{ plan.name }}
</h4>
<p class="mb-0 text-body-1 text-center">
{{ plan.tagLine }}
</p>
<!-- 👉 Plan price -->
<div class="position-relative">
<div class="d-flex justify-center pt-5 pb-10">
<div class="text-body-1 align-self-start font-weight-medium">
$
</div>
<h1 class="text-h1 font-weight-medium text-primary">
{{ annualMonthlyPlanPriceToggler ? Math.floor(Number(plan.yearlyPrice) / 12) : plan.monthlyPrice }}
</h1>
<div class="text-body-1 font-weight-medium align-self-end">
/month
</div>
</div>
<!-- 👉 Annual Price -->
<span
v-show="annualMonthlyPlanPriceToggler"
class="annual-price-text position-absolute text-caption text-disabled pb-4"
>
{{ plan.yearlyPrice === 0 ? 'free' : `USD ${plan.yearlyPrice}/Year` }}
</span>
</div>
<!-- 👉 Plan features -->
<VList class="card-list mb-4">
<VListItem
v-for="feature in plan.features"
:key="feature"
>
<template #prepend>
<VIcon
size="8"
icon="tabler-circle-filled"
color="rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity))"
/>
</template>
<VListItemTitle class="text-body-1">
{{ feature }}
</VListItemTitle>
</VListItem>
</VList>
<!-- 👉 Plan actions -->
<VBtn
block
:color="plan.current ? 'success' : 'primary'"
:variant="plan.isPopular ? 'elevated' : 'tonal'"
:to="{ name: 'front-pages-payment' }"
:active="false"
>
{{ plan.yearlyPrice === 0 ? 'Your Current Plan' : 'Upgrade' }}
</VBtn>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- !SECTION -->
</template>
<style lang="scss" scoped>
.card-list {
--v-card-list-gap: 1rem;
}
.save-upto-chip {
inset-block-start: -2.4rem;
inset-inline-end: -6rem;
}
.annual-price-text {
inset-block-end: 3%;
inset-inline-start: 50%;
transform: translateX(-50%);
}
</style>

View File

@@ -0,0 +1,92 @@
<script setup>
import AppSearchHeaderBg from '@images/pages/app-search-header-bg.png'
const props = defineProps({
title: {
type: String,
required: false,
},
subtitle: {
type: String,
required: false,
},
customClass: {
type: String,
required: false,
},
placeholder: {
type: String,
required: false,
},
density: {
type: String,
required: false,
default: 'comfortable',
},
isReverse: {
type: Boolean,
required: false,
default: false,
},
})
defineOptions({
inheritAttrs: false,
})
</script>
<template>
<!-- 👉 Search Banner -->
<VCard
flat
class="text-center search-header"
:class="props.customClass"
:style="`background: url(${AppSearchHeaderBg});`"
>
<VCardText>
<slot name="title">
<h4 class="text-h4 mb-2 font-weight-medium">
{{ props.title }}
</h4>
</slot>
<div
class="d-flex"
:class="isReverse ? 'flex-column' : 'flex-column-reverse' "
>
<p class="mb-0">
{{ props.subtitle }}
</p>
<!-- 👉 Search Input -->
<div>
<AppTextField
v-bind="$attrs"
class="search-header-input mx-auto my-4"
:placeholder="props.placeholder"
:density="props.density"
prepend-inner-icon="tabler-search"
/>
</div>
</div>
</VCardText>
</VCard>
</template>
<style lang="scss">
.search-header {
padding: 4rem !important;
background-size: cover !important;
}
// search input
.search-header-input {
border-radius: 0.375rem !important;
background-color: rgb(var(--v-theme-surface));
max-inline-size: 28.125rem !important;
}
@media (max-width: 37.5rem) {
.search-header {
padding: 1.5rem !important;
}
}
</style>

View File

@@ -0,0 +1,779 @@
<template>
<div class="components-library-container">
<!-- Toggle Button -->
<VBtn
v-if="!isLibraryOpen"
@click="toggleLibrary"
class="library-toggle-btn"
color="primary"
variant="elevated"
size="large"
>
<VIcon icon="tabler-layout-dashboard" size="20" class="me-2" />
Components Library
<VIcon icon="tabler-chevron-up" size="16" class="ms-2" />
</VBtn>
<!-- Components Library Panel -->
<VCard
v-if="isLibraryOpen"
class="components-library-panel"
:class="{ 'panel-open': isLibraryOpen }"
elevation="8"
>
<VCardTitle class="library-header">
<div class="header-content">
<div class="header-left">
<VIcon icon="tabler-layout-dashboard" size="24" class="me-2" />
<span>Dashboard Components</span>
</div>
<VBtn
@click="toggleLibrary"
variant="text"
size="small"
icon
class="close-btn"
>
<VIcon icon="tabler-x" size="20" />
</VBtn>
</div>
</VCardTitle>
<VCardText class="library-content">
<div class="search-section">
<VTextField
v-model="searchQuery"
placeholder="Search components..."
variant="outlined"
density="compact"
prepend-inner-icon="tabler-search"
clearable
class="search-input"
/>
</div>
<div class="categories-tabs">
<VChipGroup
v-model="selectedCategory"
selected-class="text-primary"
class="category-chips"
>
<VChip
v-for="category in categories"
:key="category"
:value="category"
variant="outlined"
size="small"
>
{{ category }}
</VChip>
</VChipGroup>
</div>
<div class="components-grid">
<div
v-for="component in filteredComponents"
:key="component.id"
class="component-item"
:draggable="isEditMode"
@dragstart="handleDragStart($event, component)"
@dragend="handleDragEnd"
:class="{ 'draggable': isEditMode, 'disabled': !isEditMode }"
>
<div class="component-preview">
<VIcon :icon="component.icon" size="32" :color="component.color" />
<div v-if="isEditMode" class="component-overlay">
<VIcon icon="tabler-plus" size="20" color="white" />
</div>
<div v-if="!isEditMode" class="disabled-overlay">
<VIcon icon="tabler-lock" size="16" color="grey" />
</div>
</div>
<div class="component-info">
<h4 class="component-title">{{ component.title }}</h4>
<p class="component-description">{{ component.description }}</p>
<div class="component-meta">
<VChip
:color="component.color"
size="x-small"
variant="outlined"
class="component-type-chip"
>
{{ component.category }}
</VChip>
<VChip
size="x-small"
variant="outlined"
color="grey"
class="size-chip"
>
{{ component.defaultSize.cols }}×{{ Math.round(component.defaultSize.height) }}
</VChip>
</div>
</div>
</div>
</div>
<div v-if="filteredComponents.length === 0" class="no-results">
<VIcon icon="tabler-layout-dashboard" size="48" color="grey-lighten-1" />
<h3>No components found</h3>
<p>Try adjusting your search or category filter</p>
</div>
<div v-if="!isEditMode" class="edit-mode-notice">
<VAlert
type="info"
variant="tonal"
class="ma-2"
>
<template #prepend>
<VIcon icon="tabler-info-circle" />
</template>
<div>
<strong>Edit Mode Required</strong>
<br>
Enable edit mode to add components to your dashboard
</div>
</VAlert>
</div>
</VCardText>
</VCard>
<!-- Drop Zone Overlay -->
<div
v-if="isDragging"
class="drop-zone-overlay"
@dragover="handleDragOver"
@drop="handleDrop"
@dragleave="handleDragLeave"
>
<div class="drop-zone-content">
<VIcon icon="tabler-download" size="64" color="primary" />
<h2>Drop component here to add to dashboard</h2>
<p>Release to add the component to your dashboard</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
const props = defineProps({
isEditMode: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['add-component'])
const isLibraryOpen = ref(false)
const isDragging = ref(false)
const searchQuery = ref('')
const selectedCategory = ref('All')
const categories = ['All', 'CRM', 'Ecommerce', 'Analytics', 'Reports', 'Activity']
const availableComponents = ref([
{
id: 'crm-active-project',
title: 'Active Project',
description: 'Shows current active project details',
icon: 'tabler-folder-open',
color: 'primary',
category: 'CRM',
component: 'CrmActiveProject',
defaultSize: { cols: 4, height: 33.33 },
defaultProps: {}
},
{
id: 'crm-activity-timeline',
title: 'Activity Timeline',
description: 'Recent activities and updates timeline',
icon: 'tabler-timeline',
color: 'info',
category: 'Activity',
component: 'CrmActivityTimeline',
defaultSize: { cols: 6, height: 33.33 },
defaultProps: {}
},
{
id: 'crm-analytics-sales',
title: 'Sales Analytics',
description: 'Sales performance and analytics',
icon: 'tabler-chart-line',
color: 'success',
category: 'Analytics',
component: 'CrmAnalyticsSales',
defaultSize: { cols: 4, height: 33.33 },
defaultProps: {}
},
{
id: 'crm-earning-reports',
title: 'Earning Reports',
description: 'Yearly earning overview and reports',
icon: 'tabler-report-money',
color: 'warning',
category: 'Reports',
component: 'CrmEarningReportsYearlyOverview',
defaultSize: { cols: 8, height: 33.33 },
defaultProps: {}
},
{
id: 'crm-project-status',
title: 'Project Status',
description: 'Current status of all projects',
icon: 'tabler-checkup-list',
color: 'deep-purple',
category: 'CRM',
component: 'CrmProjectStatus',
defaultSize: { cols: 4, height: 33.33 },
defaultProps: {}
},
{
id: 'crm-recent-transactions',
title: 'Recent Transactions',
description: 'Latest financial transactions',
icon: 'tabler-credit-card',
color: 'teal',
category: 'CRM',
component: 'CrmRecentTransactions',
defaultSize: { cols: 6, height: 33.33 },
defaultProps: {}
},
{
id: 'crm-sales-countries',
title: 'Sales by Countries',
description: 'Geographic sales distribution',
icon: 'tabler-world',
color: 'indigo',
category: 'Analytics',
component: 'CrmSalesByCountries',
defaultSize: { cols: 4, height: 33.33 },
defaultProps: {}
},
{
id: 'project-activity-bar',
title: 'Project Activity Chart',
description: 'Bar chart showing project activities',
icon: 'tabler-chart-bar',
color: 'cyan',
category: 'Analytics',
component: 'ProjectActivityBarChart',
defaultSize: { cols: 8, height: 33.33 },
defaultProps: {}
},
{
id: 'analysis-card-1',
title: 'Analysis Card (Projects)',
description: 'Active projects progress analysis',
icon: 'tabler-analytics',
color: 'pink',
category: 'Analytics',
component: 'AnalysisCard',
defaultSize: { cols: 4, height: 33.33 },
defaultProps: { chartName: 'Active Projects Progress' }
},
{
id: 'analysis-card-2',
title: 'Analysis Card (Cost)',
description: 'Cost overview analysis',
icon: 'tabler-analytics',
color: 'pink',
category: 'Analytics',
component: 'AnalysisCard',
defaultSize: { cols: 4, height: 33.33 },
defaultProps: { chartName: 'Cost overview' }
},
{
id: 'cost-overview',
title: 'Cost Overview',
description: 'Detailed cost breakdown and overview',
icon: 'tabler-coin',
color: 'orange',
category: 'Reports',
component: 'CostOverview',
defaultSize: { cols: 8, height: 33.33 },
defaultProps: {}
},
{
id: 'generated-leads-1',
title: 'Generated Leads (Primary)',
description: 'Lead generation progress - Primary theme',
icon: 'tabler-users',
color: 'primary',
category: 'Ecommerce',
component: 'GeneratedLeadsCard',
defaultSize: { cols: 4, height: 33.33 },
defaultProps: { progress: 33 }
},
{
id: 'generated-leads-2',
title: 'Generated Leads (Success)',
description: 'Lead generation progress - Success theme',
icon: 'tabler-users',
color: 'success',
category: 'Ecommerce',
component: 'GeneratedLeadsCard',
defaultSize: { cols: 4, height: 33.33 },
defaultProps: { donutColors: ["primary"], progress: 71 }
},
{
id: 'generated-leads-3',
title: 'Generated Leads (Warning)',
description: 'Lead generation progress - Warning theme',
icon: 'tabler-users',
color: 'warning',
category: 'Ecommerce',
component: 'GeneratedLeadsCard',
defaultSize: { cols: 4, height: 33.33 },
defaultProps: { donutColors: ["warning"], progress: 56 }
}
])
const filteredComponents = computed(() => {
let components = availableComponents.value
// Filter by category
if (selectedCategory.value !== 'All') {
components = components.filter(comp => comp.category === selectedCategory.value)
}
// Filter by search query
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
components = components.filter(comp =>
comp.title.toLowerCase().includes(query) ||
comp.description.toLowerCase().includes(query) ||
comp.category.toLowerCase().includes(query)
)
}
return components
})
const toggleLibrary = () => {
isLibraryOpen.value = !isLibraryOpen.value
}
const handleDragStart = (event, component) => {
if (!props.isEditMode) {
event.preventDefault()
return
}
isDragging.value = true
event.dataTransfer.setData('application/json', JSON.stringify(component))
event.dataTransfer.effectAllowed = 'copy'
// Create drag image
const dragImage = event.target.cloneNode(true)
dragImage.style.transform = 'rotate(5deg) scale(0.8)'
dragImage.style.opacity = '0.8'
dragImage.style.zIndex = '9999'
document.body.appendChild(dragImage)
event.dataTransfer.setDragImage(dragImage, 50, 50)
setTimeout(() => {
if (document.body.contains(dragImage)) {
document.body.removeChild(dragImage)
}
}, 0)
}
const handleDragEnd = () => {
isDragging.value = false
}
const handleDragOver = (event) => {
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
}
const handleDrop = (event) => {
event.preventDefault()
isDragging.value = false
try {
const componentData = JSON.parse(event.dataTransfer.getData('application/json'))
emit('add-component', componentData)
// Show success feedback
console.log('Component added successfully:', componentData.title)
} catch (error) {
console.error('Error adding component:', error)
}
}
const handleDragLeave = (event) => {
// Only hide if we're leaving the drop zone entirely
if (!event.currentTarget.contains(event.relatedTarget)) {
isDragging.value = false
}
}
// Close library when clicking outside
const handleClickOutside = (event) => {
if (isLibraryOpen.value && !event.target.closest('.components-library-panel, .library-toggle-btn')) {
isLibraryOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<style scoped>
.components-library-container {
position: fixed;
bottom: 2rem;
right: 2rem;
z-index: 1000;
}
.library-toggle-btn {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1), 0 8px 32px rgba(var(--v-theme-primary), 0.2) !important;
border-radius: 12px !important;
padding: 12px 20px !important;
font-weight: 600 !important;
text-transform: none !important;
backdrop-filter: blur(10px);
animation: bounceIn 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
.library-toggle-btn:hover {
transform: translateY(-2px) scale(1.02);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15), 0 12px 40px rgba(var(--v-theme-primary), 0.3) !important;
}
.components-library-panel {
position: absolute;
bottom: 80px;
right: 0;
width: 450px;
max-height: 600px;
border-radius: 16px !important;
backdrop-filter: blur(20px);
background: rgba(255, 255, 255, 0.95) !important;
border: 1px solid rgba(255, 255, 255, 0.2);
animation: slideUp 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.library-header {
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.1), rgba(var(--v-theme-secondary), 0.05));
border-radius: 16px 16px 0 0;
padding: 1rem 1.5rem;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.header-left {
display: flex;
align-items: center;
font-weight: 600;
font-size: 1.1rem;
}
.close-btn {
opacity: 0.7;
transition: all 0.2s ease;
}
.close-btn:hover {
opacity: 1;
transform: scale(1.1);
}
.library-content {
max-height: 500px;
overflow-y: auto;
padding: 1rem 1.5rem;
}
.search-section {
margin-bottom: 1rem;
}
.search-input {
border-radius: 12px;
}
.categories-tabs {
margin-bottom: 1.5rem;
}
.category-chips {
gap: 0.5rem;
}
.components-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
.component-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
background: rgba(255, 255, 255, 0.7);
transition: all 0.3s ease;
position: relative;
}
.component-item.draggable {
cursor: grab;
}
.component-item.draggable:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
border-color: rgba(var(--v-theme-primary), 0.3);
background: rgba(var(--v-theme-primary), 0.02);
}
.component-item.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.component-item:active.draggable {
cursor: grabbing;
transform: scale(0.98);
}
.component-preview {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 60px;
height: 60px;
background: rgba(var(--v-theme-surface), 0.8);
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.1);
flex-shrink: 0;
}
.component-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(var(--v-theme-primary), 0.9);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: all 0.2s ease;
}
.disabled-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(128, 128, 128, 0.3);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.component-item.draggable:hover .component-overlay {
opacity: 1;
}
.component-info {
flex: 1;
min-width: 0;
}
.component-title {
font-size: 0.95rem;
font-weight: 600;
margin: 0 0 0.25rem 0;
color: rgba(0, 0, 0, 0.87);
line-height: 1.2;
}
.component-description {
font-size: 0.8rem;
color: rgba(0, 0, 0, 0.6);
margin: 0 0 0.5rem 0;
line-height: 1.3;
}
.component-meta {
display: flex;
gap: 0.5rem;
align-items: center;
}
.component-type-chip,
.size-chip {
font-size: 0.7rem !important;
height: 20px !important;
}
.no-results {
text-align: center;
padding: 2rem 1rem;
color: rgba(0, 0, 0, 0.6);
}
.no-results h3 {
margin: 1rem 0 0.5rem 0;
font-size: 1.1rem;
}
.no-results p {
margin: 0;
font-size: 0.9rem;
}
.edit-mode-notice {
margin-top: 1rem;
}
.drop-zone-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(var(--v-theme-primary), 0.1);
backdrop-filter: blur(4px);
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease;
}
.drop-zone-content {
text-align: center;
padding: 3rem;
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
border: 3px dashed rgba(var(--v-theme-primary), 0.5);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
animation: dropZonePulse 1.5s ease-in-out infinite;
}
.drop-zone-content h2 {
margin: 1rem 0 0.5rem 0;
color: rgba(var(--v-theme-primary), 0.9);
font-size: 1.5rem;
font-weight: 600;
}
.drop-zone-content p {
margin: 0;
color: rgba(0, 0, 0, 0.7);
font-size: 1rem;
}
@keyframes bounceIn {
0% {
opacity: 0;
transform: scale(0.3) translateY(100px);
}
50% {
opacity: 1;
transform: scale(1.05);
}
70% {
transform: scale(0.9);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dropZonePulse {
0%, 100% {
transform: scale(1);
border-color: rgba(var(--v-theme-primary), 0.5);
}
50% {
transform: scale(1.02);
border-color: rgba(var(--v-theme-primary), 0.8);
}
}
@media (max-width: 768px) {
.components-library-container {
bottom: 1rem;
right: 1rem;
left: 1rem;
}
.components-library-panel {
width: 100%;
max-width: none;
right: 0;
left: 0;
}
.library-toggle-btn {
width: 100%;
justify-content: center;
}
.component-item {
padding: 0.75rem;
}
.component-preview {
width: 50px;
height: 50px;
}
.component-title {
font-size: 0.9rem;
}
.component-description {
font-size: 0.75rem;
}
}
</style>

View File

@@ -0,0 +1,346 @@
<template>
<VCard
class="overflow-visible"
:style="cardBackgroundStyle"
>
<VCardText class="d-flex flex-column align-center pa-4">
<!-- Header Section - Responsive -->
<div class="d-flex flex-column flex-sm-row justify-space-between align-start align-sm-center w-100 mb-4 gap-3">
<div class="text-center text-sm-start">
<h5 class="text-h5 text-wrap mb-2">
Cost Overview
</h5>
<div class="text-body-1 mb-2 mb-sm-4 text-medium-emphasis">
Monthly Breakdown
</div>
</div>
<div class="text-center text-sm-end">
<h3 class="text-h4 text-sm-h3 mb-1">
{{ totalCostFormatted }}
</h3>
<div class="d-flex align-center justify-center justify-sm-end">
<VIcon
icon="tabler-trending-up"
color="success"
size="20"
class="me-1"
/>
<span class="text-success font-weight-medium text-sm">
+12.5%
</span>
</div>
</div>
</div>
<!-- Chart Container - Responsive -->
<div
class="d-flex justify-center position-relative chart-container"
>
<VueApexCharts
:options="chartOptions"
:series="series"
type="donut"
width="100%"
height="100%"
class="chart-responsive"
/>
<!-- Center Content -->
<div class="chart-center-content">
<div class="text-center">
<h4 class="text-h5 text-sm-h4 mb-1">{{ totalCostFormatted }}</h4>
<span class="text-body-2 text-medium-emphasis">Total Cost</span>
</div>
</div>
</div>
<!-- Stats Cards - Responsive Grid -->
<div class="stats-grid w-100 mt-4">
<div
v-for="(item, index) in costBreakdown"
:key="index"
class="stat-item pa-3 rounded-lg"
:style="getItemCardStyle(index)"
>
<div class="d-flex align-center justify-center mb-2">
<div
class="dot me-2"
:style="{ backgroundColor: chartColors[index] }"
></div>
<span class="text-body-2 text-medium-emphasis text-center">{{ item.label }}</span>
</div>
<h6 class="text-h6 text-center">{{ formatCurrency(item.value) }}</h6>
<div class="text-caption text-center">{{ item.percentage }}%</div>
</div>
</div>
</VCardText>
</VCard>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useTheme } from 'vuetify'
import { hexToRgb } from '@layouts/utils'
import VueApexCharts from 'vue3-apexcharts'
const vuetifyTheme = useTheme()
const currentTheme = computed(() => vuetifyTheme.current.value.colors)
const labels = ['Human Resources', 'Materials', 'Equipment']
const series = ref([3000, 5000, 2000])
const totalCost = computed(() =>
series.value.reduce((a, b) => a + b, 0)
)
const totalCostFormatted = computed(() =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0
}).format(totalCost.value)
)
const formatCurrency = (value) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0
}).format(value)
}
const costBreakdown = computed(() => {
return labels.map((label, index) => ({
label,
value: series.value[index],
percentage: Math.round((series.value[index] / totalCost.value) * 100)
}))
})
const chartColors = computed(() => [
`rgba(${hexToRgb(currentTheme.value.primary)}, 1)`,
`rgba(${hexToRgb(currentTheme.value.success)}, 1)`,
`rgba(${hexToRgb(currentTheme.value.warning)}, 1)`
])
// Card Background Style
const cardBackgroundStyle = computed(() => {
const primaryColor = currentTheme.value.primary
const createGradientColor = (color, opacity = 0.08) => {
if (color.startsWith('#')) {
return `rgba(${hexToRgb(color)}, ${opacity})`
}
return `rgba(${hexToRgb(color)}, ${opacity})`
}
const gradientColor1 = createGradientColor(primaryColor, 0.1)
const gradientColor2 = createGradientColor(primaryColor, 0.03)
return {
background: `linear-gradient(135deg,
${gradientColor1} 0%,
${gradientColor2} 50%,
${gradientColor1} 100%)`
}
})
const getItemCardStyle = (index) => {
const color = chartColors.value[index]
const bgColor = color.replace('1)', '0.1)')
return {
backgroundColor: bgColor,
border: `1px solid ${color.replace('1)', '0.2)')}`
}
}
const chartOptions = computed(() => {
const labelColor = `rgba(${hexToRgb(currentTheme.value['on-surface'])}, ${vuetifyTheme.current.value.variables['disabled-opacity']})`
return {
labels,
colors: chartColors.value,
chart: {
type: 'donut',
toolbar: { show: false },
sparkline: { enabled: false }
},
stroke: {
colors: ['transparent'],
width: 0
},
legend: {
show: false
},
tooltip: {
theme: vuetifyTheme.current.value.dark ? 'dark' : 'light',
style: { fontFamily: 'inherit' },
y: {
formatter: (val) => formatCurrency(val)
}
},
dataLabels: {
enabled: true,
formatter: (val) => Math.round(val) + '%',
style: {
fontSize: '12px',
fontWeight: 'bold',
colors: ['#fff']
}
},
plotOptions: {
pie: {
donut: {
size: '75%',
labels: {
show: false
}
}
}
},
responsive: [
{
breakpoint: 600,
options: {
chart: {
width: 280,
height: 280
},
dataLabels: {
style: {
fontSize: '10px'
}
}
}
},
{
breakpoint: 480,
options: {
chart: {
width: 250,
height: 250
},
dataLabels: {
style: {
fontSize: '9px'
}
}
}
}
]
}
})
</script>
<style scoped>
/* Chart Container - Responsive */
.chart-container {
width: 100%;
max-width: 330px;
height: 330px;
}
@media (max-width: 599px) {
.chart-container {
max-width: 280px;
height: 280px;
}
}
@media (max-width: 479px) {
.chart-container {
max-width: 250px;
height: 250px;
}
}
.chart-responsive {
width: 100% !important;
height: 100% !important;
}
.chart-center-content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
/* Stats Grid - Responsive */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
@media (max-width: 767px) {
.stats-grid {
grid-template-columns: 1fr;
gap: 0.75rem;
}
}
@media (max-width: 599px) and (min-width: 480px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.stat-item {
min-height: fit-content;
display: flex;
flex-direction: column;
justify-content: center;
}
/* Responsive Typography */
@media (max-width: 599px) {
.text-h5 {
font-size: 1.25rem !important;
}
.text-h4 {
font-size: 1.5rem !important;
}
.text-h6 {
font-size: 1rem !important;
}
}
@media (max-width: 479px) {
.text-h5 {
font-size: 1.125rem !important;
}
.text-h4 {
font-size: 1.25rem !important;
}
.chart-center-content h4 {
font-size: 1.125rem !important;
}
}
/* Card padding adjustments for mobile */
@media (max-width: 599px) {
.pa-4 {
padding: 1rem !important;
}
}
@media (max-width: 479px) {
.pa-4 {
padding: 0.75rem !important;
}
}
</style>

View File

@@ -0,0 +1,50 @@
<script setup>
const props = defineProps({
statusCode: {
type: [
String,
Number,
],
required: false,
},
title: {
type: String,
required: false,
},
description: {
type: String,
required: false,
},
})
</script>
<template>
<div class="text-center">
<!-- 👉 Title and subtitle -->
<h1
v-if="props.statusCode"
class="header-title font-weight-medium mb-2"
>
{{ props.statusCode }}
</h1>
<h4
v-if="props.title"
class="text-h4 font-weight-medium mb-2"
>
{{ props.title }}
</h4>
<p
v-if="props.description"
class="text-body-1 mb-6"
>
{{ props.description }}
</p>
</div>
</template>
<style lang="scss" scoped>
.header-title {
font-size: clamp(3rem, 5vw, 6rem);
line-height: clamp(3rem, 5vw, 6rem);
}
</style>

View File

@@ -0,0 +1,508 @@
<template>
<VCard
class="overflow-visible position-relative"
:style="cardBackgroundStyle"
elevation="8"
>
<!-- Decorative elements -->
<div class="position-absolute decorative-circle-1"></div>
<div class="position-absolute decorative-circle-2"></div>
<div class="position-absolute decorative-dots"></div>
<VCardItem class="pb-4">
<div class="d-flex align-center justify-space-between">
<div>
<VCardTitle class="text-h5 font-weight-bold mb-1">
<VIcon icon="mdi-chart-line" class="me-2 text-primary"></VIcon>
Active Projects Progress
</VCardTitle>
<VCardSubtitle class="text-subtitle-1 opacity-80">This Week Overview</VCardSubtitle>
</div>
<VChip
:color="changePercent >= 0 ? 'success' : 'error'"
variant="tonal"
size="small"
class="font-weight-bold"
>
{{ formattedChange }}
</VChip>
</div>
</VCardItem>
<VCardText class="pb-2">
<!-- Progress Summary -->
<div class="progress-summary-card mb-6">
<div class="d-flex align-center justify-center mb-2">
<div class="progress-circle">
<h2 class="text-h2 font-weight-bold text-primary mb-0">{{ totalProgress }}%</h2>
</div>
</div>
<p class="text-center text-subtitle-2 mb-0 opacity-80">Average Progress</p>
</div>
<VueApexCharts
:options="chartOptions"
:series="series"
:height="330"
width="100%"
class="enhanced-chart"
/>
<!-- Project Stats -->
<!-- <div class="project-stats mt-4">
<div class="d-flex justify-space-between align-center mb-2">
<span class="text-subtitle-2 opacity-80">Projects Status</span>
<VChip size="x-small" color="primary" variant="text">
{{ values.length }} Total
</VChip>
</div>
<div class="d-flex flex-wrap gap-2">
<VChip
v-for="(value, index) in values"
:key="index"
:color="getProjectStatusColor(value)"
variant="tonal"
size="small"
class="font-weight-medium"
>
{{ labels[index] }}: {{ value }}%
</VChip>
</div>
</div> -->
</VCardText>
</VCard>
</template>
<script setup>
import { computed } from 'vue'
import { useTheme } from 'vuetify'
import { hexToRgb } from '@layouts/utils'
import VueApexCharts from 'vue3-apexcharts'
const vuetifyTheme = useTheme()
const labels = [
'Project Alpha',
'Project Beta',
'Project Gamma',
'Project Delta',
'Project Epsilon',
]
const values = [45, 70, 30, 85, 55]
const series = [
{
name: 'Progress Percentage',
data: values,
},
]
const totalProgress = computed(() =>
Math.round(values.reduce((sum, v) => sum + v, 0) / values.length)
)
const changePercent = computed(() => {
const last = values[values.length - 1]
const prev = values[values.length - 2] ?? last
return prev === 0 ? 0 : ((last - prev) / prev) * 100
})
const formattedChange = computed(() => {
const sign = changePercent.value >= 0 ? '+' : ''
return `${sign}${changePercent.value.toFixed(1)}%`
})
const getProjectStatusColor = (value) => {
if (value >= 80) return 'success'
if (value >= 60) return 'warning'
if (value >= 40) return 'info'
return 'error'
}
const cardBackgroundStyle = computed(() => {
const currentTheme = vuetifyTheme.current.value.colors
const primaryColor = currentTheme.primary
const createGradientColor = (color, opacity = 0.08) => {
if (color.includes('rgba')) {
return color.replace(/[\d\.]+\)$/g, `${opacity})`)
}
if (color.startsWith('#')) {
return `rgba(${hexToRgb(color)}, ${opacity})`
}
if (color.includes('rgb')) {
return color.replace('rgb', 'rgba').replace(')', `, ${opacity})`)
}
return `rgba(${hexToRgb(color)}, ${opacity})`
}
const gradientColor1 = createGradientColor(primaryColor, 0.15)
const gradientColor2 = createGradientColor(primaryColor, 0.02)
const gradientColor3 = createGradientColor(primaryColor, 0.08)
return {
background: `linear-gradient(135deg,
${gradientColor1} 0%,
${gradientColor2} 25%,
${gradientColor3} 50%,
${gradientColor2} 75%,
${gradientColor1} 100%)`,
backdropFilter: 'blur(10px)',
border: `1px solid ${createGradientColor(primaryColor, 0.2)}`
}
})
const chartOptions = computed(() => ({
chart: {
type: 'bar',
parentHeightOffset: 0,
toolbar: { show: false },
background: 'transparent',
animations: {
enabled: true,
easing: 'easeinout',
speed: 800,
animateGradually: {
enabled: true,
delay: 150
},
dynamicAnimation: {
enabled: true,
speed: 350
}
}
},
plotOptions: {
bar: {
horizontal: false,
columnWidth: "35%",
borderRadius: 20,
borderRadiusApplication: 'end',
borderRadiusWhenStacked: 'last',
dataLabels: {
position: 'top'
}
},
},
colors: ['rgba(var(--v-theme-primary), 1)'],
grid: {
show: true,
borderColor: 'rgba(var(--v-theme-on-surface), 0.08)',
strokeDashArray: 3,
position: 'back',
xaxis: {
lines: {
show: false
}
},
yaxis: {
lines: {
show: true
}
},
row: {
colors: undefined,
opacity: 0.5
},
column: {
colors: undefined,
opacity: 0.5
},
padding: {
top: 0,
right: 10,
bottom: 0,
left: 10
}
},
dataLabels: {
enabled: true,
offsetY: -20,
style: {
fontSize: '12px',
fontWeight: 'bold',
colors: ['rgba(var(--v-theme-primary), 1)']
},
formatter: function (val) {
return val + '%'
}
},
legend: { show: false },
tooltip: {
enabled: true,
theme: 'dark',
style: {
fontSize: '12px',
fontFamily: 'Arial, sans-serif'
},
y: {
formatter: function (val) {
return val + '%'
}
},
marker: {
show: true
}
},
xaxis: {
categories: labels,
axisBorder: {
show: true,
color: 'rgba(var(--v-theme-on-surface), 0.12)'
},
axisTicks: {
show: true,
color: 'rgba(var(--v-theme-on-surface), 0.12)'
},
labels: {
show: true,
rotate: -45,
rotateAlways: false,
hideOverlappingLabels: true,
showDuplicates: false,
trim: false,
style: {
fontSize: '12px',
fontWeight: '500',
colors: 'rgba(var(--v-theme-on-surface), 0.7)',
fontFamily: 'Arial, sans-serif',
},
},
},
yaxis: {
labels: {
show: true,
style: {
fontSize: '11px',
colors: 'rgba(var(--v-theme-on-surface), 0.6)',
fontFamily: 'Arial, sans-serif',
},
formatter: function (val) {
return val + '%'
}
},
min: 0,
max: 100
},
responsive: [
{
breakpoint: 1441,
options: {
plotOptions: {
bar: {
columnWidth: '45%',
borderRadius: 18,
},
},
},
},
{
breakpoint: 1368,
options: {
plotOptions: {
bar: {
columnWidth: '40%',
borderRadius: 16,
}
},
},
},
{
breakpoint: 1264,
options: {
plotOptions: {
bar: {
columnWidth: '35%',
borderRadius: 20,
},
},
},
},
{
breakpoint: 960,
options: {
plotOptions: {
bar: {
columnWidth: '30%',
borderRadius: 18,
},
},
},
},
{
breakpoint: 883,
options: {
plotOptions: {
bar: {
columnWidth: '35%',
borderRadius: 16,
}
}
},
},
{
breakpoint: 768,
options: {
plotOptions: {
bar: {
columnWidth: '40%',
borderRadius: 14,
}
},
dataLabels: {
offsetY: -15
}
},
},
{
breakpoint: 600,
options: {
plotOptions: {
bar: {
columnWidth: '45%',
borderRadius: 16,
},
},
dataLabels: {
enabled: false
}
},
},
{
breakpoint: 479,
options: {
plotOptions: {
bar: {
borderRadius: 12,
},
},
grid: {
padding: { right: 5, left: 5, bottom: 10 },
},
dataLabels: {
enabled: false
}
},
},
{
breakpoint: 400,
options: {
plotOptions: {
bar: {
borderRadius: 10,
}
},
xaxis: {
labels: {
rotate: -90
}
}
},
},
],
}))
</script>
<style scoped>
.decorative-circle-1 {
top: -20px;
right: -20px;
width: 80px;
height: 80px;
background: radial-gradient(circle, rgba(var(--v-theme-primary), 0.1) 0%, transparent 70%);
border-radius: 50%;
z-index: 0;
}
.decorative-circle-2 {
bottom: -30px;
left: -30px;
width: 120px;
height: 120px;
background: radial-gradient(circle, rgba(var(--v-theme-secondary), 0.08) 0%, transparent 70%);
border-radius: 50%;
z-index: 0;
}
.decorative-dots {
top: 50%;
right: 20px;
width: 6px;
height: 40px;
background-image: radial-gradient(circle, rgba(var(--v-theme-primary), 0.3) 1px, transparent 1px);
background-size: 6px 8px;
z-index: 0;
}
.progress-summary-card {
background: rgba(var(--v-theme-surface), 0.6);
border-radius: 16px;
padding: 20px;
text-align: center;
backdrop-filter: blur(10px);
border: 1px solid rgba(var(--v-theme-primary), 0.1);
position: relative;
overflow: hidden;
}
.progress-summary-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg,
rgba(var(--v-theme-primary), 0.3) 0%,
rgba(var(--v-theme-primary), 0.8) 50%,
rgba(var(--v-theme-primary), 0.3) 100%);
}
.progress-circle {
position: relative;
display: inline-block;
padding: 10px;
}
.progress-circle::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80px;
height: 80px;
border: 3px solid rgba(var(--v-theme-primary), 0.2);
border-radius: 50%;
z-index: -1;
}
.project-stats {
background: rgba(var(--v-theme-surface), 0.4);
border-radius: 12px;
padding: 16px;
backdrop-filter: blur(5px);
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.enhanced-chart {
position: relative;
z-index: 1;
}
/* Hover effects */
.v-card:hover {
transform: translateY(-2px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.v-card:hover .decorative-circle-1 {
transform: scale(1.1);
transition: transform 0.3s ease;
}
.v-card:hover .decorative-circle-2 {
transform: scale(1.05);
transition: transform 0.3s ease;
}
</style>

View File

@@ -0,0 +1,106 @@
<script setup>
import themeselectionQr from '@images/pages/themeselection-qr.png'
const props = defineProps({
authCode: {
type: String,
required: false,
},
isDialogVisible: {
type: Boolean,
required: true,
},
})
const emit = defineEmits([
'update:isDialogVisible',
'submit',
])
const authCode = ref(structuredClone(toRaw(props.authCode)))
const formSubmit = () => {
if (authCode.value) {
emit('submit', authCode.value)
emit('update:isDialogVisible', false)
}
}
const resetAuthCode = () => {
authCode.value = structuredClone(toRaw(props.authCode))
emit('update:isDialogVisible', false)
}
</script>
<template>
<VDialog
:width="$vuetify.display.smAndDown ? 'auto' : 900"
:model-value="props.isDialogVisible"
@update:model-value="(val) => $emit('update:isDialogVisible', val)"
>
<!-- Dialog close btn -->
<DialogCloseBtn @click="$emit('update:isDialogVisible', false)" />
<VCard class="pa-2 pa-sm-10">
<VCardText>
<!-- 👉 Title -->
<h4 class="text-h4 text-center mb-6">
Add Authenticator App
</h4>
<h5 class="text-h5 mb-2">
Authenticator Apps
</h5>
<p class="text-body-1 mb-6">
Using an authenticator app like Google Authenticator, Microsoft Authenticator, Authy, or 1Password, scan the QR code. It will generate a 6 digit code for you to enter below.
</p>
<div class="mb-6">
<VImg
width="150"
:src="themeselectionQr"
class="mx-auto"
/>
</div>
<VAlert
title="ASDLKNASDA9AHS678dGhASD78AB"
text="If you are unable to scan the QR code, you can manually enter the secret key below."
variant="tonal"
color="warning"
/>
<VForm @submit.prevent="() => {}">
<AppTextField
v-model="authCode"
name="auth-code"
label="Enter Authentication Code"
placeholder="123 456"
class="mt-4 mb-6"
/>
<div class="d-flex justify-end flex-wrap gap-4">
<VBtn
color="secondary"
variant="tonal"
@click="resetAuthCode"
>
Cancel
</VBtn>
<VBtn
type="submit"
@click="formSubmit"
>
Continue
<VIcon
end
icon="tabler-arrow-right"
class="flip-in-rtl"
/>
</VBtn>
</div>
</VForm>
</VCardText>
</VCard>
</VDialog>
</template>

Some files were not shown because too many files have changed in this diff Show More