initial commit

This commit is contained in:
2026-06-23 13:28:38 +07:00
commit 966ed115b2
590 changed files with 111412 additions and 0 deletions
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
@@ -0,0 +1,396 @@
<script setup>
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
import {
VList,
VListItem,
VListSubheader,
} from 'vuetify/components/VList'
const props = defineProps({
isDialogVisible: {
type: Boolean,
required: true,
},
searchQuery: {
type: String,
required: true,
},
searchResults: {
type: Array,
required: true,
},
suggestions: {
type: Array,
required: false,
},
noDataSuggestion: {
type: Array,
required: false,
},
})
const emit = defineEmits([
'update:isDialogVisible',
'update:searchQuery',
'itemSelected',
])
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 searchQuery = ref(structuredClone(toRaw(props.searchQuery)))
const refSearchInput = ref()
const isLocalDialogVisible = ref(structuredClone(toRaw(props.isDialogVisible)))
const searchResults = ref(structuredClone(toRaw(props.searchResults)))
// 👉 Watching props change
watch(props, () => {
isLocalDialogVisible.value = structuredClone(toRaw(props.isDialogVisible))
searchResults.value = structuredClone(toRaw(props.searchResults))
searchQuery.value = structuredClone(toRaw(props.searchQuery))
})
watch([
ctrl_k,
meta_k,
], () => {
isLocalDialogVisible.value = true
emit('update:isDialogVisible', true)
})
// 👉 clear search result and close the dialog
const clearSearchAndCloseDialog = () => {
emit('update:isDialogVisible', false)
emit('update:searchQuery', '')
}
watchEffect(() => {
if (!searchQuery.value.length)
searchResults.value = []
})
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 => {
emit('update:isDialogVisible', val)
emit('update:searchQuery', '')
}
const resolveCategories = val => {
if (val === 'dashboards')
return 'Dashboards'
if (val === 'appsPages')
return 'Apps & Pages'
if (val === 'userInterface')
return 'User Interface'
if (val === 'formsTables')
return 'Forms Tables'
if (val === 'chartsMisc')
return 'Charts Misc'
return 'Misc'
}
</script>
<template>
<VDialog
max-width="600"
:model-value="isLocalDialogVisible"
:height="$vuetify.display.smAndUp ? '550' : '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="pt-1"
style="min-block-size: 65px;"
>
<!-- 👉 Search Input -->
<VTextField
ref="refSearchInput"
v-model="searchQuery"
autofocus
density="comfortable"
variant="plain"
class="app-bar-autocomplete-box"
@keyup.esc="clearSearchAndCloseDialog"
@keydown="getFocusOnSearchList"
@update:model-value="$emit('update:searchQuery', searchQuery)"
>
<!-- 👉 Prepend Inner -->
<template #prepend-inner>
<div class="d-flex align-center text-high-emphasis me-1">
<VIcon
size="22"
icon="tabler-search"
class="mt-1"
style="opacity: 1;"
/>
</div>
</template>
<!-- 👉 Append Inner -->
<template #append-inner>
<div class="d-flex align-center">
<div
class="text-base text-disabled cursor-pointer me-1"
@click="clearSearchAndCloseDialog"
>
[esc]
</div>
<IconBtn
size="small"
@click="clearSearchAndCloseDialog"
>
<VIcon icon="tabler-x" />
</IconBtn>
</div>
</template>
</VTextField>
</VCardText>
<!-- 👉 Divider -->
<VDivider />
<!-- 👉 Perfect Scrollbar -->
<PerfectScrollbar
:options="{ wheelPropagation: false, suppressScrollX: true }"
class="h-100"
>
<!-- 👉 Search List -->
<VList
v-show="searchQuery.length && !!searchResults.length"
ref="refSearchList"
density="compact"
class="app-bar-search-list"
>
<!-- 👉 list Item /List Sub header -->
<template
v-for="item in searchResults"
:key="item.title"
>
<VListSubheader
v-if="'header' in item"
class="text-disabled"
>
{{ resolveCategories(item.title) }}
</VListSubheader>
<template v-else>
<slot
name="searchResult"
:item="item"
>
<VListItem
link
@click="$emit('itemSelected', item)"
>
<template #prepend>
<VIcon
size="20"
:icon="item.icon"
class="me-3"
/>
</template>
<template #append>
<VIcon
size="20"
icon="tabler-corner-down-left"
class="enter-icon text-disabled"
/>
</template>
<VListItemTitle>
{{ item.title }}
</VListItemTitle>
</VListItem>
</slot>
</template>
</template>
</VList>
<!-- 👉 Suggestions -->
<div
v-show="!!searchResults && !searchQuery"
class="h-100"
>
<slot name="suggestions">
<VCardText class="app-bar-search-suggestions h-100 pa-10">
<VRow
v-if="props.suggestions"
class="gap-y-4"
>
<VCol
v-for="suggestion in props.suggestions"
:key="suggestion.title"
cols="12"
sm="6"
class="ps-6"
>
<p class="text-xs text-disabled text-uppercase">
{{ suggestion.title }}
</p>
<VList class="card-list">
<VListItem
v-for="item in suggestion.content"
:key="item.title"
link
:title="item.title"
class="app-bar-search-suggestion"
@click="$emit('itemSelected', item)"
>
<template #prepend>
<VIcon
:icon="item.icon"
size="20"
class="me-2"
/>
</template>
</VListItem>
</VList>
</VCol>
</VRow>
</VCardText>
</slot>
</div>
<!-- 👉 No Data found -->
<div
v-show="!searchResults.length && searchQuery.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 h-100">
<VIcon
size="75"
icon="tabler-file-x"
/>
<div class="d-flex align-center flex-wrap justify-center gap-2 text-h6 my-3">
<span>No Result For </span>
<span>"{{ searchQuery }}"</span>
</div>
<div
v-if="props.noDataSuggestion"
class="mt-8"
>
<span class="d-flex justify-center text-disabled">Try searching for</span>
<h6
v-for="suggestion in props.noDataSuggestion"
:key="suggestion.title"
class="app-bar-search-suggestion text-sm font-weight-regular cursor-pointer mt-3"
@click="$emit('itemSelected', suggestion)"
>
<VIcon
size="20"
:icon="suggestion.icon"
class="me-3"
/>
<span class="text-sm">{{ suggestion.title }}</span>
</h6>
</div>
</div>
</VCardText>
</slot>
</div>
</PerfectScrollbar>
</VCard>
</VDialog>
</template>
<style lang="scss">
.app-bar-search-suggestions {
.app-bar-search-suggestion {
&:hover {
color: rgb(var(--v-theme-primary));
}
}
}
.app-bar-autocomplete-box {
.v-field__input {
padding-block-end: 0.425rem;
padding-block-start: 1.16rem;
}
.v-field__append-inner,
.v-field__prepend-inner {
padding-block-start: 0.95rem;
}
.v-field__field input {
text-align: start !important;
}
}
.app-bar-search-dialog {
.v-overlay__scrim {
backdrop-filter: blur(4px);
}
.v-list-item-title {
font-size: 0.875rem !important;
}
.app-bar-search-list {
.v-list-item,
.v-list-subheader {
font-size: 0.75rem;
padding-inline: 1.5rem !important;
}
.v-list-item {
.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: 0.6875rem 0.3125rem;
text-transform: uppercase;
}
}
}
</style>
<style lang="scss" scoped>
.card-list {
--v-card-list-gap: 16px;
}
</style>
@@ -0,0 +1,28 @@
<script setup>
const props = defineProps({
title: {
type: String,
required: true,
},
})
const emit = defineEmits(['cancel'])
</script>
<template>
<div class="px-5 py-3 d-flex align-center">
<h3 class="font-weight-medium text-xl">
{{ props.title }}
</h3>
<VSpacer />
<slot name="beforeClose" />
<IconBtn @click="$emit('cancel')">
<VIcon
size="18"
icon="tabler-x"
/>
</IconBtn>
</div>
</template>
@@ -0,0 +1,290 @@
<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: 52,
},
isActiveStepValid: {
type: Boolean,
required: false,
default: undefined,
},
})
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"
>
<VSlideGroupItem
v-for="(item, index) in props.items"
:key="item.title"
:value="index"
>
<div
class="cursor-pointer mx-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 gap-2">
<!-- 👉 icon and title -->
<div
class="d-flex align-center gap-4 step-wrapper"
:class="[props.direction === 'horizontal' && 'flex-column']"
>
<div class="stepper-icon">
<VIcon
:icon="item.icon"
:size="item.size || props.iconSize"
/>
</div>
<div>
<p class="stepper-title font-weight-medium mb-0">
{{ item.title }}
</p>
<span
v-if="item.subtitle"
class="stepper-subtitle"
>
<span class="text-sm">{{ item.subtitle }}</span>
</span>
</div>
</div>
<!-- 👉 append chevron -->
<VIcon
v-if="isHorizontalAndNotLastStep(index)"
class="flip-in-rtl stepper-chevron-indicator mx-6"
size="24"
icon="tabler-chevron-right"
/>
</div>
</template>
<!-- !SECTION -->
<!-- SECTION stepper step without icon -->
<template v-else>
<div class="d-flex align-center gap-x-2">
<div class="d-flex align-center gap-2">
<div
class="d-flex align-center justify-center"
style="block-size: 24px; inline-size: 24px;"
>
<!-- 👉 custom circle icon -->
<template v-if="index >= currentStep">
<div
v-if="(!isValidationEnabled || props.isActiveStepValid || index !== currentStep)"
class="stepper-step-indicator"
/>
<VIcon
v-else
icon="tabler-alert-circle"
size="24"
color="error"
/>
</template>
<!-- 👉 step completed icon -->
<VIcon
v-else
icon="custom-check-circle"
class="stepper-step-icon"
size="24"
/>
</div>
<!-- 👉 Step Number -->
<h4 class="text-h4 step-number">
{{ (index + 1).toString().padStart(2, '0') }}
</h4>
</div>
<!-- 👉 title and subtitle -->
<div style="line-height: 0;">
<h6 class="text-sm font-weight-medium step-title">
{{ item.title }}
</h6>
<span
v-if="item.subtitle"
class="text-xs step-subtitle"
>
{{ item.subtitle }}
</span>
</div>
<!-- 👉 stepper step line -->
<div
v-if="isHorizontalAndNotLastStep(index)"
class="stepper-step-line"
/>
</div>
</template>
<!-- !SECTION -->
</div>
</VSlideGroupItem>
</VSlideGroup>
</template>
<style lang="scss">
.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.3125rem;
background-color: rgba(var(--v-theme-on-surface), var(--v-selected-opacity));
block-size: 2.5rem;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
inline-size: 2.5rem;
margin-inline-end: 0.3rem;
}
.stepper-title,
.stepper-subtitle {
line-height: normal;
}
.stepper-title {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 0.9375rem;
font-weight: 500 !important;
}
.stepper-subtitle {
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
font-size: 0.875rem;
}
}
.stepper-steps-active {
.stepper-icon-step {
.stepper-icon {
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), 0.08);
color: rgba(var(--v-theme-primary));
}
}
}
}
// 👉 stepper step with icon and default
.v-slide-group__content {
justify-content: center;
row-gap: 1.5rem;
.stepper-step-indicator {
border: 0.3125rem solid rgb(var(--v-theme-primary));
border-radius: 50%;
background-color: rgb(var(--v-theme-surface));
block-size: 1.25rem;
inline-size: 1.25rem;
opacity: var(--v-activated-opacity);
}
.stepper-step-line {
border-radius: 0.1875rem;
background-color: rgb(var(--v-theme-primary));
block-size: 0.1875rem;
inline-size: 3.75rem;
opacity: var(--v-activated-opacity);
}
.stepper-chevron-indicator {
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
}
.stepper-steps-completed,
.stepper-steps-active {
.stepper-icon-step,
.stepper-step-icon {
color: rgb(var(--v-theme-primary)) !important;
}
.stepper-step-indicator {
opacity: 1;
}
}
.stepper-steps-completed {
.stepper-step-line {
opacity: 1;
}
.stepper-chevron-indicator {
color: rgb(var(--v-theme-primary));
}
}
.stepper-steps-invalid.stepper-steps-active {
.stepper-icon-step,
.step-number,
.step-title,
.step-subtitle {
color: rgb(var(--v-theme-error)) !important;
}
}
}
}
</style>
+41
View File
@@ -0,0 +1,41 @@
<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>
<VBtn
id="buy-now-btn"
color="error"
class="product-buy-now"
:href="buyNowUrl"
target="_blank"
rel="noopener noreferrer"
>
Buy Now
</VBtn>
</template>
<style lang="scss" scoped>
.product-buy-now {
position: fixed;
// To keep buy now button on top of v-layout. E.g. Email app
z-index: 999;
inset-block-end: 5%;
inset-inline-end: 79px;
.v-application &.v-btn.v-btn--elevated {
box-shadow: 0 1px 20px 1px rgb(var(--v-theme-error)) !important;
&:hover {
box-shadow: none !important;
}
}
}
</style>
@@ -0,0 +1,41 @@
<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="42"
variant="tonal"
:color="props.color"
>
<VIcon :icon="props.icon" />
</VAvatar>
<h5 class="text-h5 my-2">
{{ props.stats }}
</h5>
<span class="text-body-2">{{ props.title }}</span>
</VCardText>
</VCard>
</template>
@@ -0,0 +1,25 @@
<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">
<p class="text-caption">
{{ props.title }}
</p>
<slot />
</div>
</template>
@@ -0,0 +1,26 @@
<script setup>
const props = defineProps({
icon: {
type: String,
required: false,
default: 'tabler-x',
},
iconSize: {
type: String,
required: false,
default: '22',
},
})
</script>
<template>
<IconBtn
variant="elevated"
class="v-dialog-close-btn"
>
<VIcon
:icon="props.icon"
:size="props.iconSize"
/>
</IconBtn>
</template>
+56
View File
@@ -0,0 +1,56 @@
<script setup>
const props = defineProps({
languages: {
type: Array,
required: true,
},
location: {
type: null,
required: false,
default: 'bottom end',
},
})
const emit = defineEmits(['change'])
const { locale } = useI18n({ useScope: 'global' })
watch(locale, val => {
document.documentElement.setAttribute('lang', val)
})
const currentLang = ref(['en'])
</script>
<template>
<IconBtn>
<VIcon
size="26"
icon="tabler-language"
/>
<!-- Menu -->
<VMenu
activator="parent"
:location="props.location"
offset="14px"
>
<!-- List -->
<VList
v-model:selected="currentLang"
min-width="175px"
>
<!-- List item -->
<VListItem
v-for="lang in props.languages"
:key="lang.i18nLang"
:value="lang.i18nLang"
@click="locale = lang.i18nLang; $emit('change', lang.i18nLang)"
>
<!-- Language label -->
<VListItemTitle>{{ lang.label }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
+31
View File
@@ -0,0 +1,31 @@
<script setup>
const props = defineProps({
menuList: {
type: Array,
required: false,
},
itemProps: {
type: Boolean,
required: false,
},
})
</script>
<template>
<IconBtn
density="compact"
color="disabled"
>
<VIcon icon="tabler-dots-vertical" />
<VMenu
v-if="props.menuList"
activator="parent"
>
<VList
:items="props.menuList"
:item-props="props.itemProps"
/>
</VMenu>
</IconBtn>
</template>
@@ -0,0 +1,220 @@
<script setup>
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
import { avatarText } from '@core/utils/formatters'
const props = defineProps({
notifications: {
type: Array,
required: true,
},
badgeProps: {
type: null,
required: false,
default: undefined,
},
location: {
type: null,
required: false,
default: 'bottom end',
},
})
const emit = defineEmits([
'read',
'unread',
'remove',
'click:notification',
])
const isAllMarkRead = computed(() => 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
})
</script>
<template>
<IconBtn id="notification-btn">
<VBadge
v-bind="props.badgeProps"
:model-value="props.notifications.some(n => !n.isSeen)"
color="error"
:content="totalUnseenNotifications"
class="notification-badge"
>
<VIcon
size="26"
icon="tabler-bell"
/>
</VBadge>
<VMenu
activator="parent"
width="380px"
:location="props.location"
offset="14px"
:close-on-content-click="false"
>
<VCard class="d-flex flex-column">
<!-- 👉 Header -->
<VCardItem class="notification-section">
<VCardTitle class="text-lg">
Notifications
</VCardTitle>
<template #append>
<IconBtn
v-show="props.notifications.length"
@click="markAllReadOrUnread"
>
<VIcon :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 -->
<template #prepend>
<VListItemAction start>
<VAvatar
size="40"
:color="notification.color && notification.icon ? notification.color : undefined"
:image="notification.img || undefined"
:icon="notification.icon || undefined"
:variant="notification.img ? undefined : 'tonal' "
>
<span v-if="notification.text">{{ avatarText(notification.text) }}</span>
</VAvatar>
</VListItemAction>
</template>
<VListItemTitle>{{ notification.title }}</VListItemTitle>
<VListItemSubtitle>{{ notification.subtitle }}</VListItemSubtitle>
<span class="text-xs text-disabled">{{ notification.time }}</span>
<!-- Slot: Append -->
<template #append>
<div class="d-flex flex-column align-center gap-4">
<VBadge
dot
:color="!notification.isSeen ? 'primary' : '#a8aaae'"
:class="`${notification.isSeen ? 'visible-in-hover' : ''} ms-1`"
@click.stop="$emit(notification.isSeen ? 'unread' : 'read', [notification.id])"
/>
<div style="block-size: 28px; inline-size: 28px;">
<IconBtn
size="small"
class="visible-in-hover"
@click="$emit('remove', notification.id)"
>
<VIcon
size="20"
icon="tabler-x"
/>
</IconBtn>
</div>
</div>
</template>
</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 -->
<VCardActions
v-show="props.notifications.length"
class="notification-footer"
>
<VBtn block>
View All Notifications
</VBtn>
</VCardActions>
</VCard>
</VMenu>
</IconBtn>
</template>
<style lang="scss">
.notification-section {
padding: 14px !important;
}
.notification-footer {
padding: 6px !important;
}
.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;
}
}
// 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>
@@ -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>
@@ -0,0 +1,81 @@
<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
size="26"
:icon="props.togglerIcon"
/>
<VMenu
activator="parent"
offset="14px"
location="bottom end"
>
<VCard
width="340"
max-height="560"
class="d-flex flex-column"
>
<VCardItem class="py-4">
<VCardTitle>Shortcuts</VCardTitle>
<template #append>
<IconBtn>
<VIcon icon="tabler-layout-grid-add" />
</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-4 shortcut-icon"
:class="(index + 1) % 2 ? 'border-e' : ''"
@click="router.push(shortcut.to)"
>
<VAvatar
variant="tonal"
size="48"
>
<VIcon :icon="shortcut.icon" />
</VAvatar>
<h6 class="text-base font-weight-medium mt-2 mb-0">
{{ shortcut.title }}
</h6>
<span class="text-sm">{{ shortcut.subtitle }}</span>
</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>
@@ -0,0 +1,367 @@
<script setup>
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
import { useTheme } from 'vuetify'
import { staticPrimaryColor } from '@/plugins/vuetify/theme'
import { useThemeConfig } from '@core/composable/useThemeConfig'
import {
RouteTransitions,
Skins,
} from '@core/enums'
import {
AppContentLayoutNav,
ContentWidth,
FooterType,
NavbarType,
} from '@layouts/enums'
import { themeConfig } from '@themeConfig'
const isNavDrawerOpen = ref(false)
const { theme, skin, appRouteTransition, navbarType, footerType, isVerticalNavCollapsed, isVerticalNavSemiDark, appContentWidth, appContentLayoutNav, isAppRtl, isNavbarBlurEnabled, isLessThanOverlayNavBreakpoint } = useThemeConfig()
// 👉 Primary Color
const vuetifyTheme = useTheme()
// const vuetifyThemesName = Object.keys(vuetifyTheme.themes.value)
const initialThemeColors = JSON.parse(JSON.stringify(vuetifyTheme.current.value.colors))
const colors = [
'primary',
'secondary',
'success',
'info',
'warning',
'error',
]
const setPrimaryColor = color => {
const currentThemeName = vuetifyTheme.name.value
vuetifyTheme.themes.value[currentThemeName].colors.primary = color
localStorage.setItem(`${ themeConfig.app.title }-${ currentThemeName }ThemePrimaryColor`, color)
localStorage.setItem(`${ themeConfig.app.title }-initial-loader-color`, color)
}
const getBoxColor = (color, index) => index ? color : staticPrimaryColor
const { width: windowWidth } = useWindowSize()
const headerValues = computed(() => {
const entries = Object.entries(NavbarType)
if (appContentLayoutNav.value === AppContentLayoutNav.Horizontal)
return entries.filter(([_, val]) => val !== NavbarType.Hidden)
return entries
})
</script>
<template>
<template v-if="!isLessThanOverlayNavBreakpoint(windowWidth)">
<VBtn
icon
size="small"
class="app-customizer-toggler rounded-s-lg rounded-0"
style="z-index: 1001;"
@click="isNavDrawerOpen = true"
>
<VIcon
size="22"
icon="tabler-settings"
/>
</VBtn>
<VNavigationDrawer
v-model="isNavDrawerOpen"
temporary
border="0"
location="end"
width="400"
:scrim="false"
class="app-customizer"
>
<!-- 👉 Header -->
<div class="customizer-heading d-flex align-center justify-space-between">
<div>
<h6 class="text-h6">
THEME CUSTOMIZER
</h6>
<span class="text-body-1">Customize & Preview in Real Time</span>
</div>
<IconBtn @click="isNavDrawerOpen = false">
<VIcon
icon="tabler-x"
size="20"
/>
</IconBtn>
</div>
<VDivider />
<PerfectScrollbar
tag="ul"
:options="{ wheelPropagation: false }"
>
<!-- SECTION Theming -->
<CustomizerSection
title="THEMING"
:divider="false"
>
<!-- 👉 Skin -->
<h6 class="text-base font-weight-regular">
Skins
</h6>
<VRadioGroup
v-model="skin"
inline
>
<VRadio
v-for="[key, val] in Object.entries(Skins)"
:key="key"
:label="key"
:value="val"
/>
</VRadioGroup>
<!-- 👉 Theme -->
<h6 class="mt-3 text-base font-weight-regular">
Theme
</h6>
<VRadioGroup
v-model="theme"
inline
>
<VRadio
v-for="themeOption in ['system', 'light', 'dark']"
:key="themeOption"
:label="themeOption"
:value="themeOption"
class="text-capitalize"
/>
</VRadioGroup>
<!-- 👉 Primary color -->
<h6 class="mt-3 text-base font-weight-regular">
Primary Color
</h6>
<div class="d-flex gap-x-4 mt-2">
<div
v-for="(color, index) in colors"
:key="color"
style=" border-radius: 0.5rem; block-size: 2.5rem;inline-size: 2.5rem; transition: all 0.25s ease;"
:style="{ backgroundColor: getBoxColor(initialThemeColors[color], index) }"
class="cursor-pointer d-flex align-center justify-center"
:class="{ 'elevation-4': vuetifyTheme.current.value.colors.primary === getBoxColor(initialThemeColors[color], index) }"
@click="setPrimaryColor(getBoxColor(initialThemeColors[color], index))"
>
<VFadeTransition>
<VIcon
v-show="vuetifyTheme.current.value.colors.primary === (getBoxColor(initialThemeColors[color], index))"
icon="tabler-check"
color="white"
/>
</VFadeTransition>
</div>
</div>
</CustomizerSection>
<!-- !SECTION -->
<!-- SECTION LAYOUT -->
<CustomizerSection title="LAYOUT">
<!-- 👉 Content Width -->
<h6 class="text-base font-weight-regular">
Content width
</h6>
<VRadioGroup
v-model="appContentWidth"
inline
>
<VRadio
v-for="[key, val] in Object.entries(ContentWidth)"
:key="key"
:label="key"
:value="val"
/>
</VRadioGroup>
<!-- 👉 Navbar Type -->
<h6 class="mt-3 text-base font-weight-regular">
{{ appContentLayoutNav === AppContentLayoutNav.Vertical ? 'Navbar' : 'Header' }} Type
</h6>
<VRadioGroup
v-model="navbarType"
inline
>
<VRadio
v-for="[key, val] in headerValues"
:key="key"
:label="key"
:value="val"
/>
</VRadioGroup>
<!-- 👉 Footer Type -->
<h6 class="mt-3 text-base font-weight-regular">
Footer Type
</h6>
<VRadioGroup
v-model="footerType"
inline
>
<VRadio
v-for="[key, val] in Object.entries(FooterType)"
:key="key"
:label="key"
:value="val"
/>
</VRadioGroup>
<!-- 👉 Navbar blur -->
<div class="mt-4 d-flex align-center justify-space-between">
<VLabel
for="customizer-navbar-blur"
class="text-high-emphasis"
>
Navbar Blur
</VLabel>
<div>
<VSwitch
id="customizer-navbar-blur"
v-model="isNavbarBlurEnabled"
class="ms-2"
/>
</div>
</div>
</CustomizerSection>
<!-- !SECTION -->
<!-- SECTION Menu -->
<CustomizerSection title="MENU">
<!-- 👉 Menu Type -->
<h6 class="text-base font-weight-regular">
Menu Type
</h6>
<VRadioGroup
v-model="appContentLayoutNav"
inline
>
<VRadio
v-for="[key, val] in Object.entries(AppContentLayoutNav)"
:key="key"
:label="key"
:value="val"
/>
</VRadioGroup>
<!-- 👉 Collapsed Menu -->
<div
v-if="appContentLayoutNav === AppContentLayoutNav.Vertical"
class="mt-4 d-flex align-center justify-space-between"
>
<VLabel
for="customizer-menu-collapsed"
class="text-high-emphasis"
>
Collapsed Menu
</VLabel>
<div>
<VSwitch
id="customizer-menu-collapsed"
v-model="isVerticalNavCollapsed"
class="ms-2"
/>
</div>
</div>
<!-- 👉 Semi Dark Menu -->
<div
class="mt-4 align-center justify-space-between"
:class="vuetifyTheme.global.name.value === 'light' && appContentLayoutNav === AppContentLayoutNav.Vertical ? 'd-flex' : 'd-none'"
>
<VLabel
for="customizer-menu-semi-dark"
class="text-high-emphasis"
>
Semi Dark Menu
</VLabel>
<div>
<VSwitch
id="customizer-menu-semi-dark"
v-model="isVerticalNavSemiDark"
class="ms-2"
/>
</div>
</div>
</CustomizerSection>
<!-- !SECTION -->
<!-- SECTION MISC -->
<CustomizerSection title="MISC">
<!-- 👉 RTL -->
<div class="d-flex align-center justify-space-between">
<VLabel
for="customizer-rtl"
class="text-high-emphasis"
>
RTL
</VLabel>
<div>
<VSwitch
id="customizer-rtl"
v-model="isAppRtl"
class="ms-2"
/>
</div>
</div>
<!-- 👉 Route Transition -->
<div class="mt-6">
<VRow>
<VCol
cols="5"
class="d-flex align-center"
>
<VLabel
for="route-transition"
class="text-high-emphasis"
>
Router Transition
</VLabel>
</VCol>
<VCol cols="7">
<AppSelect
id="route-transition"
v-model="appRouteTransition"
:items="Object.entries(RouteTransitions).map(([key, value]) => ({ key, value }))"
item-title="key"
item-value="value"
single-line
/>
</VCol>
</VRow>
</div>
</CustomizerSection>
<!-- !SECTION -->
</PerfectScrollbar>
</VNavigationDrawer>
</template>
</template>
<style lang="scss">
.app-customizer {
.customizer-section {
padding: 1.25rem;
}
.customizer-heading {
padding-block: 0.875rem;
padding-inline: 1.25rem;
}
.v-navigation-drawer__content {
display: flex;
flex-direction: column;
}
}
.app-customizer-toggler {
position: fixed !important;
inset-block-start: 50%;
inset-inline-end: 0;
}
</style>
@@ -0,0 +1,43 @@
<script setup>
import { useThemeConfig } from '@core/composable/useThemeConfig'
const props = defineProps({
themes: {
type: Array,
required: true,
},
})
const { theme } = useThemeConfig()
const {
state: currentThemeName,
next: getNextThemeName,
index: currentThemeIndex,
} = useCycleList(props.themes.map(t => t.name), { initialValue: theme.value })
const changeTheme = () => {
theme.value = getNextThemeName()
}
// Update icon if theme is changed from other sources
watch(theme, val => {
currentThemeName.value = val
})
</script>
<template>
<IconBtn @click="changeTheme">
<VIcon
size="26"
:icon="props.themes[currentThemeIndex].icon"
/>
<VTooltip
activator="parent"
open-delay="1000"
scroll-strategy="close"
>
<span class="text-capitalize">{{ currentThemeName }}</span>
</VTooltip>
</IconBtn>
</template>
@@ -0,0 +1,56 @@
<script setup>
defineOptions({
name: 'AppAutocomplete',
inheritAttrs: false,
})
// const { class: _class, label, variant: _, ...restAttrs } = useAttrs()
const elementId = computed(() => {
const attrs = useAttrs()
const _elementIdToken = attrs.id || attrs.label
return _elementIdToken ? `app-autocomplete-${ _elementIdToken }-${ Math.random().toString(36).slice(2, 7) }` : undefined
})
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-high-emphasis"
: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>
@@ -0,0 +1,57 @@
<script setup>
defineOptions({
name: 'AppCombobox',
inheritAttrs: false,
})
const elementId = computed(() => {
const attrs = useAttrs()
const _elementIdToken = attrs.id || attrs.label
return _elementIdToken ? `app-combobox-${ _elementIdToken }-${ Math.random().toString(36).slice(2, 7) }` : undefined
})
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-high-emphasis"
: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>
@@ -0,0 +1,502 @@
<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 { useThemeConfig } from '@core/composable/useThemeConfig'
import { filterInputAttrs } from 'vuetify/lib/util/helpers'
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: 'compact',
hideDetails: 'auto',
}),
...makeVFieldProps({
variant: 'outlined',
color: 'primary',
}),
})
const emit = defineEmits([
'click:control',
'mousedown:control',
'update:focused',
'update:modelValue',
'click:clear',
])
defineOptions({ inheritAttrs: false })
const attrs = useAttrs()
const [rootAttrs, compAttrs] = filterInputAttrs(attrs)
const [{
modelValue: _,
...inputProps
}] = VInput.filterProps(props)
const [fieldProps] = 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' })
}
const onClear = el => {
el.stopPropagation()
nextTick(() => {
emit('update:modelValue', '')
emit('click:clear', el)
})
}
const { theme } = useThemeConfig()
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(theme, updateThemeClassInCalendar)
onMounted(() => {
updateThemeClassInCalendar()
})
const emitModelValue = val => {
emit('update:modelValue', val)
}
const elementId = computed(() => {
const _elementIdToken = fieldProps.id || fieldProps.label
return _elementIdToken ? `app-picker-field-${ _elementIdToken }-${ Math.random().toString(36).slice(2, 7) }` : undefined
})
</script>
<template>
<div class="app-picker-field">
<!-- v-input -->
<VLabel
v-if="fieldProps.label"
class="mb-1 text-body-2 text-high-emphasis"
: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 }">
<!-- 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"
:id="elementId"
ref="refFlatPicker"
:model-value="modelValue"
:placeholder="props.placeholder"
class="flat-picker-custom-style"
:disabled="isReadonly.value"
@on-open="isCalendarOpen = true"
@on-close="isCalendarOpen = false"
@update:model-value="emitModelValue"
/>
<!-- simple input for inline prop -->
<input
v-if="isInlinePicker"
:value="modelValue"
:placeholder="props.placeholder"
class="flat-picker-custom-style"
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">
/* 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);
height: 100%;
}
$heading-color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));
$body-color: rgba(var(--v-theme-on-background), var(--v-medium-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-calendar {
background-color: rgb(var(--v-theme-surface));
inline-size: 16.625rem;
margin-block-start: 0.1875rem;
@include mixins.elevation(4);
.flatpickr-rContainer {
.flatpickr-weekdays {
block-size: 2.125rem;
padding-inline: 0.875rem;
}
.flatpickr-days {
min-inline-size: 16.625rem;
.dayContainer {
justify-content: center !important;
inline-size: 16.625rem;
min-inline-size: 16.625rem;
padding-block-end: 0.75rem;
padding-block-start: 0;
.flatpickr-day {
block-size: 2.125rem;
font-size: 0.9375rem;
line-height: 2.125rem;
margin-block-start: 0 !important;
max-inline-size: 2.125rem;
}
}
}
}
.flatpickr-day {
color: $body-color;
&.today {
border-color: rgb(var(--v-theme-primary));
&:hover {
border-color: rgb(var(--v-theme-primary));
background: transparent;
color: $body-color;
}
}
&.selected,
&.selected:hover {
border-color: rgb(var(--v-theme-primary));
background: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
@include mixins.elevation(2);
}
&.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 mixins.elevation(2);
}
&.endRange {
@include mixins.elevation(2);
}
&.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.08);
}
}
.flatpickr-weekday {
color: $heading-color;
font-size: 0.8125rem;
font-weight: 500;
}
.flatpickr-days {
inline-size: 16.625rem;
}
&::after,
&::before {
display: none;
}
.flatpickr-months {
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
.flatpickr-prev-month,
.flatpickr-next-month {
fill: $body-color;
&: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-time {
border-color: rgba(var(--v-border-color), var(--v-border-opacity));
block-size: auto;
}
.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;
opacity: 1 !important;
}
// week sections
.flatpickr-weekdays {
margin-block: 12px;
}
// 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: 500;
transition: all 0.15s ease-out;
span {
display: none;
}
.flatpickr-monthDropdown-month {
background-color: rgb(var(--v-theme-surface));
}
.numInput.cur-year {
font-weight: 500;
}
}
}
.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;
border-radius: 5rem;
background: rgba(var(--v-theme-surface-variant), var(--v-selected-opacity));
block-size: 1.75rem;
inline-size: 1.75rem;
inset-block-start: 0.75rem !important;
margin-block: 0.1875rem;
padding-block: 0.25rem;
padding-inline: 0.4375rem;
}
.flatpickr-next-month {
inset-inline-end: 1.05rem !important;
}
.flatpickr-prev-month {
/* stylelint-disable-next-line liberty/use-logical-spec */
right: 3.8rem;
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;
}
}
}
// Update hour font-weight
.flatpickr-time input.flatpickr-hour {
font-weight: 400;
}
</style>
@@ -0,0 +1,78 @@
<script setup>
const props = defineProps({
totalInput: {
type: Number,
required: false,
default: 6,
},
default: {
type: String,
required: false,
default: '',
},
})
const emit = defineEmits(['updateOtp'])
const digits = ref([])
const refOtpComp = ref(null)
digits.value = props.default.split('')
const defaultStyle = { style: 'max-width: 54px; text-align: center;' }
// eslint-disable-next-line sonarjs/cognitive-complexity
const handleKeyDown = (event, index) => {
if (event.code !== 'Tab' && event.code !== 'ArrowRight' && event.code !== 'ArrowLeft')
event.preventDefault()
if (event.code === 'Backspace') {
digits.value[index - 1] = ''
if (refOtpComp.value !== null && index > 1) {
const inputEl = refOtpComp.value.children[index - 2].querySelector('input')
if (inputEl)
inputEl.focus()
}
}
const numberRegExp = /^([0-9])$/
if (numberRegExp.test(event.key)) {
digits.value[index - 1] = event.key
if (refOtpComp.value !== null && index !== 0 && index < refOtpComp.value.children.length) {
const inputEl = refOtpComp.value.children[index].querySelector('input')
if (inputEl)
inputEl.focus()
}
}
emit('updateOtp', digits.value.join(''))
}
</script>
<template>
<div>
<h6 class="text-h6 mb-3">
Type your 6 digit security code
</h6>
<div
ref="refOtpComp"
class="d-flex align-center gap-4"
>
<AppTextField
v-for="i in props.totalInput"
:key="i"
:model-value="digits[i - 1]"
v-bind="defaultStyle"
maxlength="1"
@keydown="handleKeyDown($event, i)"
/>
</div>
</div>
</template>
<style lang="scss">
.v-field__field {
input {
padding: 0.5rem;
font-size: 1.25rem;
text-align: center;
}
}
</style>
@@ -0,0 +1,49 @@
<script setup>
defineOptions({
name: 'AppSelect',
inheritAttrs: false,
})
const elementId = computed(() => {
const attrs = useAttrs()
const _elementIdToken = attrs.id || attrs.label
return _elementIdToken ? `app-select-${ _elementIdToken }-${ Math.random().toString(36).slice(2, 7) }` : undefined
})
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 text-high-emphasis"
: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>
@@ -0,0 +1,48 @@
<script setup>
defineOptions({
name: 'AppTextField',
inheritAttrs: false,
})
const elementId = computed(() => {
const attrs = useAttrs()
const _elementIdToken = attrs.id || attrs.label
return _elementIdToken ? `app-text-field-${ _elementIdToken }-${ Math.random().toString(36).slice(2, 7) }` : undefined
})
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-high-emphasis"
: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>
@@ -0,0 +1,49 @@
<script setup>
defineOptions({
name: 'AppTextarea',
inheritAttrs: false,
})
// const { class: _class, label, variant: _, ...restAttrs } = useAttrs()
const elementId = computed(() => {
const attrs = useAttrs()
const _elementIdToken = attrs.id || attrs.label
return _elementIdToken ? `app-textarea-${ _elementIdToken }-${ Math.random().toString(36).slice(2, 7) }` : undefined
})
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-high-emphasis"
: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>
@@ -0,0 +1,82 @@
<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 selectedOption = ref(structuredClone(toRaw(props.selectedCheckbox)))
watch(selectedOption, () => {
emit('update:selectedCheckbox', selectedOption.value)
})
</script>
<template>
<VRow
v-if="props.checkboxContent && selectedOption"
v-model="selectedOption"
>
<VCol
v-for="item in props.checkboxContent"
:key="item.title"
v-bind="gridColumn"
>
<VLabel
class="custom-input custom-checkbox rounded cursor-pointer"
:class="selectedOption.includes(item.value) ? 'active' : ''"
>
<div>
<VCheckbox
v-model="selectedOption"
:value="item.value"
/>
</div>
<slot :item="item">
<div class="flex-grow-1">
<div class="d-flex align-center mb-1">
<h6 class="cr-title text-base">
{{ item.title }}
</h6>
<VSpacer />
<span
v-if="item.subtitle"
class="text-disabled text-base"
>{{ 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;
}
}
</style>
@@ -0,0 +1,96 @@
<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 selectedOption = ref(structuredClone(toRaw(props.selectedCheckbox)))
watch(selectedOption, () => {
emit('update:selectedCheckbox', selectedOption.value)
})
</script>
<template>
<VRow
v-if="props.checkboxContent && selectedOption"
v-model="selectedOption"
>
<VCol
v-for="item in props.checkboxContent"
:key="item.title"
v-bind="gridColumn"
>
<VLabel
class="custom-input custom-checkbox-icon rounded cursor-pointer"
:class="selectedOption.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
v-model="selectedOption"
:value="item.value"
/>
</div>
</VLabel>
</VCol>
</VRow>
</template>
<style lang="scss" scoped>
.custom-checkbox-icon {
display: flex;
flex-direction: column;
gap: 0.375rem;
.v-checkbox {
margin-block-end: -0.375rem;
.v-selection-control__wrapper {
margin-inline-start: 0;
}
}
.cr-title {
font-weight: 500;
}
}
</style>
<style lang="scss">
.custom-checkbox-icon {
.v-checkbox {
margin-block-end: -0.375rem;
.v-selection-control__wrapper {
margin-inline-start: 0;
}
}
}
</style>
@@ -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 selectedOption = ref(structuredClone(toRaw(props.selectedCheckbox)))
watch(selectedOption, () => {
emit('update:selectedCheckbox', selectedOption.value)
})
</script>
<template>
<VRow
v-if="props.checkboxContent && selectedOption"
v-model="selectedOption"
>
<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="selectedOption.includes(item.value) ? 'active' : ''"
>
<div>
<VCheckbox
v-model="selectedOption"
:value="item.value"
/>
</div>
<img
:src="item.bgImage"
alt="bg-img"
class="custom-checkbox-image"
>
</VLabel>
</VCol>
</VRow>
</template>
<style lang="scss" scoped>
.custom-checkbox {
position: relative;
padding: 0;
border-width: 2px;
transition: all 0.5s;
.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>
@@ -0,0 +1,81 @@
<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 selectedOption = ref(structuredClone(toRaw(props.selectedRadio)))
watch(selectedOption, () => {
emit('update:selectedRadio', selectedOption.value)
})
</script>
<template>
<VRadioGroup
v-if="props.radioContent"
v-model="selectedOption"
>
<VRow>
<VCol
v-for="item in props.radioContent"
:key="item.title"
v-bind="gridColumn"
>
<VLabel
class="custom-input custom-radio rounded cursor-pointer"
:class="selectedOption === item.value ? 'active' : ''"
>
<div>
<VRadio :value="item.value" />
</div>
<slot :item="item">
<div class="flex-grow-1">
<div class="d-flex align-center mb-1">
<h6 class="cr-title text-base">
{{ item.title }}
</h6>
<VSpacer />
<span
v-if="item.subtitle"
class="text-disabled text-base"
>{{ item.subtitle }}</span>
</div>
<p class="text-sm 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.375rem;
.v-radio {
margin-block-start: -0.25rem;
}
.cr-title {
font-weight: 500;
}
}
</style>
@@ -0,0 +1,92 @@
<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 selectedOption = ref(structuredClone(toRaw(props.selectedRadio)))
watch(selectedOption, () => {
emit('update:selectedRadio', selectedOption.value)
})
</script>
<template>
<VRadioGroup
v-if="props.radioContent"
v-model="selectedOption"
>
<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="selectedOption === 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 mb-0 clamp-text">
{{ 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.375rem;
.v-radio {
margin-block-end: -0.25rem;
}
.cr-title {
font-weight: 500;
}
}
</style>
<style lang="scss">
.custom-radio-icon {
.v-radio {
margin-block-end: -0.25rem;
.v-selection-control__wrapper {
margin-inline-start: 0;
}
}
}
</style>
@@ -0,0 +1,68 @@
<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 selectedOption = ref(structuredClone(toRaw(props.selectedRadio)))
watch(selectedOption, () => {
emit('update:selectedRadio', selectedOption.value)
})
</script>
<template>
<VRadioGroup
v-if="props.radioContent"
v-model="selectedOption"
>
<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="selectedOption === item.value ? 'active' : ''"
>
<img
:src="item.bgImage"
alt="bg-img"
class="custom-radio-image"
>
<VRadio :value="item.value" />
</VLabel>
</VCol>
</VRow>
</VRadioGroup>
</template>
<style lang="scss" scoped>
.custom-radio {
padding: 0;
border-width: 2px;
.custom-radio-image {
block-size: 100%;
inline-size: 100%;
min-inline-size: 100%;
}
.v-radio {
visibility: hidden;
}
}
</style>
@@ -0,0 +1,162 @@
<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,
},
title: {
type: String,
required: false,
default: undefined,
},
})
const emit = defineEmits([
'collapsed',
'refresh',
'trash',
])
defineOptions({ inheritAttrs: false })
const isContentCollapsed = ref(props.collapsed)
const isCardRemoved = ref(false)
const isOverlayVisible = ref(false)
// hiding overlay
const hideOverlay = () => {
isOverlayVisible.value = false
}
// trigger collapse
const triggerCollapse = () => {
isContentCollapsed.value = !isContentCollapsed.value
emit('collapsed', isContentCollapsed.value)
}
// trigger refresh
const triggerRefresh = () => {
isOverlayVisible.value = true
emit('refresh', hideOverlay)
}
// 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)' : null }"
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="isOverlayVisible"
contained
persistent
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>
@@ -0,0 +1,128 @@
<script setup>
import 'prismjs'
import 'prismjs/themes/prism-tomorrow.css'
import Prism from 'vue-prism-component'
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 = useStorage('preferredCodeLanguage', 'ts')
const isCodeShown = ref(false)
const { copy, copied } = useClipboard({ source: computed(() => props.code[preferredCodeLanguage.value]) })
</script>
<template>
<VCard>
<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
variant="outlined"
density="compact"
>
<VBtn
size="x-small"
value="ts"
:color="preferredCodeLanguage === 'ts' ? 'primary' : 'default'"
>
<VIcon
size="x-large"
icon="custom-typescript"
:color="preferredCodeLanguage === 'ts' ? 'primary' : 'secondary'"
/>
</VBtn>
<VBtn
size="x-small"
value="js"
:color="preferredCodeLanguage === 'js' ? 'primary' : 'default'"
>
<VIcon
size="x-large"
icon="custom-javascript"
:color="preferredCodeLanguage === 'js' ? 'primary' : 'secondary'"
/>
</VBtn>
</VBtnToggle>
</div>
<div class="position-relative">
<Prism
:key="props.code[preferredCodeLanguage]"
:language="props.codeLanguage"
:style="$vuetify.locale.isRtl ? 'text-align: right' : 'text-align: left'"
>
{{ props.code[preferredCodeLanguage] }}
</Prism>
<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.scss";
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
border-radius: vuetify.$card-border-radius;
}
.app-card-code-copy-icon {
inset-block-start: 1.2em;
inset-inline-end: 0.8em;
}
</style>
@@ -0,0 +1,41 @@
<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">
<span class="text-h5">{{ props.stats }}</span>
</div>
<span class="text-body-2">{{ props.title }}</span>
</div>
<VAvatar
:icon="props.icon"
:color="props.color"
:size="42"
variant="tonal"
/>
</VCardText>
</VCard>
</template>
@@ -0,0 +1,61 @@
<script setup>
import VueApexCharts from 'vue3-apexcharts'
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"
:icon="props.icon"
class="mb-3"
/>
<h6 class="text-lg font-weight-medium">
{{ props.stats }}
</h6>
<span class="text-sm">{{ props.title }}</span>
</VCardText>
<VueApexCharts
:series="props.series"
:options="props.chartOptions"
:height="props.height"
/>
</VCard>
</template>
@@ -0,0 +1,24 @@
import { useTheme } from 'vuetify'
import { useThemeConfig } from '@core/composable/useThemeConfig'
const { skin } = useThemeConfig()
// composable function to return the image variant as per the current theme and skin
export const useGenerateImageVariant = (imgLight, imgDark, imgLightBordered, imgDarkBordered, bordered = false) => {
const { global } = useTheme()
return computed(() => {
if (global.name.value === 'light') {
if (skin.value === 'bordered' && bordered)
return imgLightBordered
else
return imgLight
}
if (global.name.value === 'dark') {
if (skin.value === 'bordered' && bordered)
return imgDarkBordered
else
return imgDark
}
})
}
@@ -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,
}
}
+36
View File
@@ -0,0 +1,36 @@
import { VThemeProvider } from 'vuetify/components/VThemeProvider'
import { AppContentLayoutNav } from '@layouts/enums'
// TODO: Use `VThemeProvider` from dist instead of lib (Using this component from dist causes navbar to loose sticky positioning)
import { useThemeConfig } from '@core/composable/useThemeConfig'
export const useSkins = () => {
const { isVerticalNavSemiDark, skin, appContentLayoutNav } = useThemeConfig()
const layoutAttrs = computed(() => ({
verticalNavAttrs: {
wrapper: h(VThemeProvider, { tag: 'aside' }),
wrapperProps: {
withBackground: true,
theme: (isVerticalNavSemiDark.value && appContentLayoutNav.value === AppContentLayoutNav.Vertical)
? 'dark'
: undefined,
},
},
}))
const injectSkinClasses = () => {
const bodyClasses = document.body.classList
const genSkinClass = _skin => `skin--${_skin}`
watch(skin, (val, oldVal) => {
bodyClasses.remove(genSkinClass(oldVal))
bodyClasses.add(genSkinClass(val))
}, { immediate: true })
}
return {
injectSkinClasses,
layoutAttrs,
}
}
@@ -0,0 +1,157 @@
import { useTheme } from 'vuetify'
import { useLayouts } from '@layouts'
import { themeConfig } from '@themeConfig'
export const isDarkPreferred = usePreferredDark()
export const useThemeConfig = () => {
const theme = computed({
get() {
return themeConfig.app.theme.value
},
set(value) {
themeConfig.app.theme.value = value
localStorage.setItem(`${themeConfig.app.title}-theme`, value.toString())
// ️ We will not reset semi dark value when turning off dark mode because some user think it as bug
// if (value !== 'light')
// isVerticalNavSemiDark.value = false
},
})
const isVerticalNavSemiDark = computed({
get() {
return themeConfig.verticalNav.isVerticalNavSemiDark.value
},
set(value) {
themeConfig.verticalNav.isVerticalNavSemiDark.value = value
localStorage.setItem(`${themeConfig.app.title}-isVerticalNavSemiDark`, value.toString())
},
})
const syncVuetifyThemeWithTheme = () => {
const vuetifyTheme = useTheme()
watch([theme, isDarkPreferred], ([val, _]) => {
vuetifyTheme.global.name.value = val === 'system'
? isDarkPreferred.value
? 'dark'
: 'light'
: val
})
}
/*
️ 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(theme, () => {
// ️ We are not using theme.current.colors.surface because watcher is independent and when this watcher is ran `theme` computed is not updated
localStorage.setItem(`${themeConfig.app.title}-initial-loader-bg`, vuetifyTheme.current.value.colors.surface)
localStorage.setItem(`${themeConfig.app.title}-initial-loader-color`, vuetifyTheme.current.value.colors.primary)
}, {
immediate: true,
})
}
const skin = computed({
get() {
return themeConfig.app.skin.value
},
set(value) {
themeConfig.app.skin.value = value
localStorage.setItem(`${themeConfig.app.title}-skin`, value)
},
})
const handleSkinChanges = () => {
const { themes } = useTheme()
// 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(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 })
}
const appRouteTransition = computed({
get() {
return themeConfig.app.routeTransition.value
},
set(value) {
themeConfig.app.routeTransition.value = value
localStorage.setItem(`${themeConfig.app.title}-transition`, value)
},
})
// `@layouts` exports
const { navbarType, isNavbarBlurEnabled, footerType, isVerticalNavCollapsed, appContentWidth, appContentLayoutNav, horizontalNavType, isLessThanOverlayNavBreakpoint, isAppRtl, switchToVerticalNavOnLtOverlayNavBreakpoint } = useLayouts()
// const syncRtlWithRtlLang = (rtlLangs: string[], rtlDefaultLocale: string, ltrDefaultLocale: string) => {
// const { locale } = useI18n({ useScope: 'global' })
// watch(isAppRtl, val => {
// if (val)
// locale.value = rtlDefaultLocale
// else locale.value = ltrDefaultLocale
// })
// watch(locale, val => {
// if (rtlLangs.includes(val))
// isAppRtl.value = true
// else isAppRtl.value = false
// })
// watch(
// [isAppRtl, locale],
// ([valIsAppRTL, valLocale], [oldValIsAppRtl, oldValLocale]) => {
// const isRtlUpdated = valIsAppRTL !== oldValIsAppRtl
// if (isRtlUpdated) {
// if (valIsAppRTL)
// locale.value = rtlDefaultLocale
// else locale.value = ltrDefaultLocale
// }
// else {
// if (rtlLangs.includes(valLocale))
// isAppRtl.value = true
// else isAppRtl.value = false
// }
// },
// )
// }
return {
theme,
isVerticalNavSemiDark,
syncVuetifyThemeWithTheme,
syncInitialLoaderTheme,
skin,
handleSkinChanges,
appRouteTransition,
// @layouts exports
navbarType,
isNavbarBlurEnabled,
footerType,
isVerticalNavCollapsed,
appContentWidth,
appContentLayoutNav,
horizontalNavType,
isLessThanOverlayNavBreakpoint,
isAppRtl,
switchToVerticalNavOnLtOverlayNavBreakpoint,
// syncRtlWithRtlLang,
}
}
+13
View File
@@ -0,0 +1,13 @@
export const Skins = {
Default: 'default',
Bordered: 'bordered',
}
export const RouteTransitions = {
Fade: 'app-transition-fade',
None: 'none',
// 'Zoom Fade': 'app-transition-zoom-fade',
// 'Fade Bottom': 'app-transition-fade-bottom',
// 'Slide Fade': 'app-transition-slide-fade',
// 'Zoom out': 'app-transition-zoom-out',
}
+93
View File
@@ -0,0 +1,93 @@
import { RouteTransitions, Skins } from '@core/enums'
export const defineThemeConfig = userConfig => {
const localStorageTheme = localStorage.getItem(`${userConfig.app.title}-theme`)
const localStorageIsVerticalNavSemiDark = localStorage.getItem(`${userConfig.app.title}-isVerticalNavSemiDark`)
const localStorageSkin = (() => {
const storageValue = localStorage.getItem(`${userConfig.app.title}-skin`)
return Object.values(Skins).find(v => v === storageValue)
})()
const localStorageTransition = (() => {
const storageValue = localStorage.getItem(`${userConfig.app.title}-transition`)
return Object.values(RouteTransitions).find(v => v === storageValue)
})()
return {
themeConfig: {
app: {
title: userConfig.app.title,
logo: userConfig.app.logo,
contentWidth: ref(userConfig.app.contentWidth),
contentLayoutNav: ref(userConfig.app.contentLayoutNav),
overlayNavFromBreakpoint: userConfig.app.overlayNavFromBreakpoint,
enableI18n: userConfig.app.enableI18n,
theme: ref(localStorageTheme || userConfig.app.theme),
isRtl: ref(userConfig.app.isRtl),
skin: ref(localStorageSkin || userConfig.app.skin),
routeTransition: ref(localStorageTransition || userConfig.app.routeTransition),
iconRenderer: userConfig.app.iconRenderer,
},
navbar: {
type: ref(userConfig.navbar.type),
navbarBlur: ref(userConfig.navbar.navbarBlur),
},
footer: { type: ref(userConfig.footer.type) },
verticalNav: {
isVerticalNavCollapsed: ref(userConfig.verticalNav.isVerticalNavCollapsed),
defaultNavItemIconProps: userConfig.verticalNav.defaultNavItemIconProps,
isVerticalNavSemiDark: ref(localStorageIsVerticalNavSemiDark ? JSON.parse(localStorageIsVerticalNavSemiDark) : userConfig.verticalNav.isVerticalNavSemiDark),
},
horizontalNav: {
type: ref(userConfig.horizontalNav.type),
transition: userConfig.horizontalNav.transition,
},
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,
},
},
layoutConfig: {
app: {
title: userConfig.app.title,
logo: userConfig.app.logo,
contentWidth: userConfig.app.contentWidth,
contentLayoutNav: userConfig.app.contentLayoutNav,
overlayNavFromBreakpoint: userConfig.app.overlayNavFromBreakpoint,
enableI18n: userConfig.app.enableI18n,
isRtl: userConfig.app.isRtl,
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,
},
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,
},
},
}
}
@@ -0,0 +1,683 @@
import { hexToRgb } from '@layouts/utils'
// 👉 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, fontSize: '0.8125rem' },
formatter: val => 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: 'smooth' },
dataLabels: { enabled: false },
markers: {
strokeWidth: 7,
strokeOpacity: 1,
colors: ['#ff9f43'],
strokeColors: ['#fff'],
},
grid: {
padding: { top: -10 },
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',
'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,
startingShape: 'rounded',
},
},
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.colors['grey-100'],
},
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 => `${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 => `${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,
],
},
},
},
}
}
@@ -0,0 +1,373 @@
import { hexToRgb } from '@layouts/utils'
// 👉 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
@@ -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,
})
},
})
@@ -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,
})
},
})
@@ -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,
})
},
})
@@ -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,
})
},
})
@@ -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,
})
},
})
@@ -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,
})
},
})
@@ -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,
})
},
})
+46
View File
@@ -0,0 +1,46 @@
import { isToday } from './index'
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
+31
View File
@@ -0,0 +1,31 @@
// 👉 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)
export const isToday = date => {
const today = new Date()
return (
/* eslint-disable operator-linebreak */
date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear()
/* eslint-enable */
)
}
+98
View File
@@ -0,0 +1,98 @@
import { isEmpty, isEmptyArray, isNullOrUndefined } from './index'
// 👉 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@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
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 (
// eslint-disable-next-line operator-linebreak
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 => /^-?[0-9]+$/.test(String(val))) || 'This field must be an integer'
return /^-?[0-9]+$/.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 = /^(http[s]?:\/\/){0,1}(www\.){0,1}[a-zA-Z0-9\.\-]+\.[a-zA-Z]{2,5}[\.]{0,1}/
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 Min Character field must be at least ${length} characters`
}
// 👉 Alpha-dash Validator
export const alphaDashValidator = value => {
if (isEmpty(value))
return true
const valueAsString = String(value)
return /^[0-9A-Z_-]*$/i.test(valueAsString) || 'All Character are not valid'
}
+12
View File
@@ -0,0 +1,12 @@
import { isDarkPreferred } from '@core/composable/useThemeConfig'
import { themeConfig } from '@themeConfig'
export const resolveVuetifyTheme = () => {
const storedTheme = localStorage.getItem(`${themeConfig.app.title}-theme`) || themeConfig.app.theme.value
return storedTheme === 'system'
? isDarkPreferred.value
? 'dark'
: 'light'
: storedTheme
}
+232
View File
@@ -0,0 +1,232 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
/**
* This is an advanced example for creating icon bundles for Iconify SVG Framework.
*
* It creates a bundle from:
* - All SVG files in a directory.
* - Custom JSON files.
* - Iconify icon sets.
* - SVG framework.
*
* This example uses Iconify Tools to import and clean up icons.
* For Iconify Tools documentation visit https://docs.iconify.design/tools/tools2/
*/
const node_fs_1 = require("node:fs");
const node_path_1 = require("node:path");
// Installation: npm install --save-dev @iconify/tools @iconify/utils @iconify/json @iconify/iconify
const tools_1 = require("@iconify/tools");
const utils_1 = require("@iconify/utils");
const sources = {
svg: [
{
dir: 'resources/images/iconify-svg',
monotone: false,
prefix: 'custom',
},
// {
// dir: 'emojis',
// monotone: false,
// prefix: 'emoji',
// },
],
icons: [
// 'mdi:home',
// 'mdi:account',
// 'mdi:login',
// 'mdi:logout',
// 'octicon:book-24',
// 'octicon:code-square-24',
],
json: [
// Custom JSON file
// 'json/gg.json',
// Iconify JSON file (@iconify/json is a package name, /json/ is directory where files are, then filename)
require.resolve('@iconify-json/tabler/icons.json'),
{
filename: require.resolve('@iconify-json/fa/icons.json'),
icons: [
'facebook',
'google',
'twitter',
'circle',
],
},
// Custom file with only few icons
// {
// filename: require.resolve('@iconify-json/line-md/icons.json'),
// icons: [
// 'home-twotone-alt',
// 'github',
// 'document-list',
// 'document-code',
// 'image-twotone',
// ],
// },
],
};
// Iconify component (this changes import statement in generated file)
// Available options: '@iconify/react' for React, '@iconify/vue' for Vue 3, '@iconify/vue2' for Vue 2, '@iconify/svelte' for Svelte
const component = '@iconify/vue';
// Set to true to use require() instead of import
const commonJS = false;
// File to save bundle to
const target = (0, node_path_1.join)(__dirname, 'icons-bundle.js');
/**
* Do stuff!
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
(async function () {
let bundle = commonJS
? `const { addCollection } = require('${component}');\n\n`
: `import { addCollection } from '${component}';\n\n`;
// Create directory for output if missing
const dir = (0, node_path_1.dirname)(target);
try {
await node_fs_1.promises.mkdir(dir, {
recursive: true,
});
}
catch (err) {
//
}
/**
* Convert sources.icons to sources.json
*/
if (sources.icons) {
const sourcesJSON = sources.json ? sources.json : (sources.json = []);
// Sort icons by prefix
const organizedList = organizeIconsList(sources.icons);
for (const prefix in organizedList) {
const filename = require.resolve(`@iconify/json/json/${prefix}.json`);
sourcesJSON.push({
filename,
icons: organizedList[prefix],
});
}
}
/**
* Bundle JSON files
*/
if (sources.json) {
for (let i = 0; i < sources.json.length; i++) {
const item = sources.json[i];
// Load icon set
const filename = typeof item === 'string' ? item : item.filename;
let content = JSON.parse(await node_fs_1.promises.readFile(filename, 'utf8'));
// Filter icons
if (typeof item !== 'string' && item.icons?.length) {
const filteredContent = (0, utils_1.getIcons)(content, item.icons);
if (!filteredContent)
throw new Error(`Cannot find required icons in ${filename}`);
content = filteredContent;
}
// Remove metadata and add to bundle
removeMetaData(content);
for (const key in content) {
if (key === 'prefix' && content.prefix === 'tabler') {
for (const k in content.icons)
content.icons[k].body = content.icons[k].body.replace(/stroke-width="2"/g, 'stroke-width="1.5"');
}
}
(0, utils_1.minifyIconSet)(content);
bundle += `addCollection(${JSON.stringify(content)});\n`;
console.log(`Bundled icons from ${filename}`);
}
}
/**
* Custom SVG
*/
if (sources.svg) {
for (let i = 0; i < sources.svg.length; i++) {
const source = sources.svg[i];
// Import icons
const iconSet = await (0, tools_1.importDirectory)(source.dir, {
prefix: source.prefix,
});
// Validate, clean up, fix palette and optimise
await iconSet.forEach(async (name, type) => {
if (type !== 'icon')
return;
// Get SVG instance for parsing
const svg = iconSet.toSVG(name);
if (!svg) {
// Invalid icon
iconSet.remove(name);
return;
}
// Clean up and optimise icons
try {
// Clean up icon code
await (0, tools_1.cleanupSVG)(svg);
if (source.monotone) {
// Replace color with currentColor, add if missing
// If icon is not monotone, remove this code
await (0, tools_1.parseColors)(svg, {
defaultColor: 'currentColor',
callback: (attr, colorStr, color) => {
return (!color || (0, tools_1.isEmptyColor)(color))
? colorStr
: 'currentColor';
},
});
}
// Optimise
await (0, tools_1.runSVGO)(svg);
}
catch (err) {
// Invalid icon
console.error(`Error parsing ${name} from ${source.dir}:`, err);
iconSet.remove(name);
return;
}
// Update icon from SVG instance
iconSet.fromSVG(name, svg);
});
console.log(`Bundled ${iconSet.count()} icons from ${source.dir}`);
// Export to JSON
const content = iconSet.export();
bundle += `addCollection(${JSON.stringify(content)});\n`;
}
}
// Save to file
await node_fs_1.promises.writeFile(target, bundle, 'utf8');
console.log(`Saved ${target} (${bundle.length} bytes)`);
})().catch(err => {
console.error(err);
});
/**
* Remove metadata from icon set
*/
function removeMetaData(iconSet) {
const props = [
'info',
'chars',
'categories',
'themes',
'prefixes',
'suffixes',
];
props.forEach(prop => {
delete iconSet[prop];
});
}
/**
* Sort icon names by prefix
*/
function organizeIconsList(icons) {
const sorted = Object.create(null);
icons.forEach(icon => {
const item = (0, utils_1.stringToIcon)(icon);
if (!item)
return;
const prefix = item.prefix;
const prefixList = sorted[prefix]
? sorted[prefix]
: (sorted[prefix] = []);
const name = item.name;
if (!prefixList.includes(name))
prefixList.push(name);
});
return sorted;
}
File diff suppressed because one or more lines are too long
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "CommonJS",
"declaration": false,
"declarationMap": false,
"sourceMap": false,
"composite": false,
"strict": true,
"moduleResolution": "node",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"exclude": [
"./*.js"
]
}
BIN
View File
Binary file not shown.
+13
View File
@@ -0,0 +1,13 @@
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 TopNavLayout } from './components/TopNavLayout.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'
@@ -0,0 +1,40 @@
<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"
:item="item"
/>
</ul>
</template>
<style lang="scss">
.layout-wrapper.layout-nav-type-horizontal {
.nav-items {
display: flex;
flex-wrap: wrap;
}
}
</style>
@@ -0,0 +1,109 @@
<script setup>
import { useLayouts } from '@layouts'
import {
HorizontalNavLink,
HorizontalNavPopper,
} from '@layouts/components'
import { config } from '@layouts/config'
import { canViewNavMenuGroup } from '@layouts/plugins/casl'
import { 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 { dynamicI18nProps, isAppRtl } = useLayouts()
const isGroupActive = ref(false)
watch(() => route.path, () => {
const isActive = isNavGroupActive(props.item.children, router)
isGroupActive.value = isActive
}, { immediate: true })
</script>
<template>
<HorizontalNavPopper
v-if="canViewNavMenuGroup(item)"
:is-rtl="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="config.app.iconRenderer || 'div'"
class="nav-item-icon"
v-bind="item.icon || config.verticalNav.defaultNavItemIconProps"
/>
<Component
:is="config.app.enableI18n ? 'i18n-t' : 'span'"
v-bind="dynamicI18nProps(item.title, 'span')"
class="nav-item-title"
>
{{ item.title }}
</Component>
<Component
v-bind="config.icons.chevronDown"
:is="config.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-x: hidden;
overflow-y: auto;
}
}
}
}
</style>
@@ -0,0 +1,176 @@
<script setup>
import { HorizontalNav } from '@layouts/components'
// import { useLayouts } from '@layouts'
import { useLayouts } from '@layouts/composable/useLayouts'
const props = defineProps({
navItems: {
type: null,
required: true,
},
})
const { y: windowScrollY } = useWindowScroll()
const { width: windowWidth } = useWindowSize()
const router = useRouter()
const shallShowPageLoading = ref(false)
router.beforeEach(() => {
shallShowPageLoading.value = true
})
router.afterEach(() => {
shallShowPageLoading.value = false
})
const {
_layoutClasses: layoutClasses,
isNavbarBlurEnabled,
} = useLayouts()
</script>
<template>
<div
class="layout-wrapper"
:class="layoutClasses(windowWidth, windowScrollY)"
>
<div
class="layout-navbar-and-nav-container"
:class="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">
<template v-if="$slots['content-loading']">
<template v-if="shallShowPageLoading">
<slot name="content-loading" />
</template>
<template v-else>
<slot />
</template>
</template>
<template v-else>
<slot />
</template>
</main>
<!-- 👉 Footer -->
<footer class="layout-footer">
<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: calc(var(--vh, 1vh) * 100);
.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: calc(var(--vh) * 100);
.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>
@@ -0,0 +1,62 @@
<script setup>
import { useLayouts } from '@layouts'
import { config } from '@layouts/config'
import { can } from '@layouts/plugins/casl'
import {
getComputedNavLinkToProp,
isNavLinkActive,
} from '@layouts/utils'
const props = defineProps({
item: {
type: null,
required: true,
},
isSubItem: {
type: Boolean,
required: false,
default: false,
},
})
const { dynamicI18nProps } = useLayouts()
</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="config.app.iconRenderer || 'div'"
class="nav-item-icon"
v-bind="item.icon || config.verticalNav.defaultNavItemIconProps"
/>
<Component
:is="config.app.enableI18n ? 'i18n-t' : 'span'"
class="nav-item-title"
v-bind="dynamicI18nProps(item.title, 'span')"
>
{{ item.title }}
</Component>
</Component>
</li>
</template>
<style lang="scss">
.layout-horizontal-nav {
.nav-link a {
display: flex;
align-items: center;
}
}
</style>
@@ -0,0 +1,166 @@
<script setup>
import {
computePosition,
flip,
shift,
} from '@floating-ui/dom'
import { useLayouts } from '@layouts/composable/useLayouts'
import { config } from '@layouts/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 refPopperContainer = ref()
const refPopper = ref()
const popperContentStyles = ref({
left: '0px',
top: '0px',
// strategy: 'fixed',
})
const updatePopper = async () => {
const { x, y } = await computePosition(refPopperContainer.value, refPopper.value, {
placement: props.popperInlineEnd ? props.isRtl ? 'left-start' : 'right-start' : 'bottom-start',
middleware: [
flip({ boundary: document.querySelector('body') }),
shift({ boundary: document.querySelector('body') }),
],
// strategy: 'fixed',
})
popperContentStyles.value.left = `${ x }px`
popperContentStyles.value.top = `${ y }px`
}
until(config.horizontalNav.type).toMatch(type => type === 'static').then(() => {
useEventListener('scroll', updatePopper)
// strategy: 'fixed',
})
const isContentShown = ref(false)
const showContent = () => {
isContentShown.value = true
updatePopper()
}
const hideContent = () => {
isContentShown.value = false
}
onMounted(updatePopper)
const { isAppRtl, appContentWidth } = useLayouts()
watch([
isAppRtl,
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>
@@ -0,0 +1,148 @@
<script>
import { useLayouts } from '@layouts'
export default defineComponent({
setup(props, { slots }) {
const { y: windowScrollY } = useWindowScroll()
const { width: windowWidth } = useWindowSize()
const { _layoutClasses: layoutClasses, isNavbarBlurEnabled } = useLayouts()
const router = useRouter()
const shallShowPageLoading = ref(false)
return () => {
// 👉 Navbar
const navbar = h('header', { class: ['layout-navbar', { 'navbar-blur': isNavbarBlurEnabled.value }] }, [
h('div', { class: 'navbar-content-container' }, slots.navbar?.()),
])
// 👉 Content area
let mainChildren = slots.default?.()
// 💡 Only show loading and attach `beforeEach` & `afterEach` hooks if `content-loading` slot is used
if (slots['content-loading']) {
router.beforeEach(() => {
console.info('setting to true')
shallShowPageLoading.value = true
})
router.afterEach(() => {
console.info('setting to false')
shallShowPageLoading.value = false
})
mainChildren = shallShowPageLoading.value ? slots['content-loading']?.() : slots.default?.()
}
const main = h('main', { class: 'layout-page-content' }, h('div', { class: 'page-content-container' }, mainChildren))
return h('div', { class: ['layout-wrapper', ...layoutClasses.value(windowWidth.value, windowScrollY.value)] }, [
h('div', {}, [
navbar,
main,
]),
])
}
},
})
</script>
<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: calc(var(--vh, 1vh) * 100);
transition: padding-inline-start 0.2s ease-in-out;
will-change: padding-inline-start;
}
.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;
} @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: transform;
&.visible {
opacity: 1;
pointer-events: auto;
}
}
&:not(.layout-overlay-nav) .layout-content-wrapper {
padding-inline-start: variables.$layout-vertical-nav-width;
}
// Adjust right column pl when vertical nav is collapsed
&.layout-vertical-nav-collapsed .layout-content-wrapper {
padding-inline-start: variables.$layout-vertical-nav-collapsed-width;
}
// 👉 Content height fixed
&.layout-content-height-fixed {
.layout-content-wrapper {
max-block-size: calc(var(--vh) * 100);
}
.layout-page-content {
display: flex;
overflow: hidden;
.page-content-container {
inline-size: 100%;
> :first-child {
max-block-size: 100%;
overflow-y: auto;
}
}
}
}
}
</style>
@@ -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>
@@ -0,0 +1,12 @@
export const VNodeRenderer = defineComponent({
name: 'VNodeRenderer',
props: {
nodes: {
type: [Array, Object],
required: true,
},
},
setup(props) {
return () => props.nodes
},
})
@@ -0,0 +1,226 @@
<script setup>
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
import { VNodeRenderer } from './VNodeRenderer'
import {
injectionKeyIsVerticalNavHovered,
useLayouts,
} from '@layouts'
import {
VerticalNavGroup,
VerticalNavLink,
VerticalNavSectionTitle,
} from '@layouts/components'
import { config } from '@layouts/config'
const props = defineProps({
tag: {
type: [
String,
null,
],
required: false,
default: 'aside',
},
navItems: {
type: null,
required: true,
},
isOverlayNavActive: {
type: Boolean,
required: true,
},
toggleIsOverlayNavActive: {
type: Function,
required: true,
},
})
const refNav = ref()
const { width: windowWidth } = useWindowSize()
const isHovered = useElementHover(refNav)
provide(injectionKeyIsVerticalNavHovered, isHovered)
const {
isVerticalNavCollapsed: isCollapsed,
isLessThanOverlayNavBreakpoint,
isVerticalNavMini,
isAppRtl,
} = useLayouts()
const hideTitleAndIcon = isVerticalNavMini(windowWidth, isHovered)
const resolveNavItemComponent = item => {
if ('heading' in item)
return VerticalNavSectionTitle
if ('children' in item)
return VerticalNavGroup
return VerticalNavLink
}
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
}
</script>
<template>
<Component
:is="props.tag"
ref="refNav"
class="layout-vertical-nav"
:class="[
{
'overlay-nav': isLessThanOverlayNavBreakpoint(windowWidth),
'hovered': isHovered,
'visible': isOverlayNavActive,
'scrolled': isVerticalNavScrolled,
},
]"
>
<!-- 👉 Header -->
<div class="nav-header">
<slot name="nav-header">
<a
href="/"
class="app-logo d-flex align-center gap-x-3 app-title-wrapper"
>
<VNodeRenderer :nodes="config.app.logo" />
<Transition name="vertical-nav-app-title">
<h1
v-show="!hideTitleAndIcon"
class="app-title font-weight-bold text-capitalize leading-normal text-xl"
>
{{ config.app.title }}
</h1>
</Transition>
</a>
<!-- 👉 Vertical nav actions -->
<!-- Show toggle collapsible in >md and close button in <md -->
<template v-if="!isLessThanOverlayNavBreakpoint(windowWidth)">
<Component
:is="config.app.iconRenderer || 'div'"
v-show="isCollapsed && !hideTitleAndIcon"
class="header-action"
v-bind="config.icons.verticalNavUnPinned"
@click="isCollapsed = !isCollapsed"
/>
<Component
:is="config.app.iconRenderer || 'div'"
v-show="!isCollapsed && !hideTitleAndIcon"
class="header-action"
v-bind="config.icons.verticalNavPinned"
@click="isCollapsed = !isCollapsed"
/>
</template>
<template v-else>
<Component
:is="config.app.iconRenderer || 'div'"
class="header-action"
v-bind="config.icons.close"
@click="toggleIsOverlayNavActive(false)"
/>
</template>
</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="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>
</Component>
</template>
<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: transform 0.25s ease-in-out, 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;
}
}
.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;
}
}
// 👉 Overlay nav
&.overlay-nav {
&:not(.visible) {
transform: translateX(-#{variables.$layout-vertical-nav-width});
@include mixins.rtl {
transform: translateX(variables.$layout-vertical-nav-width);
}
}
}
}
</style>
@@ -0,0 +1,180 @@
<script setup>
import {
injectionKeyIsVerticalNavHovered,
useLayouts,
} from '@layouts'
import {
TransitionExpand,
VerticalNavLink,
} from '@layouts/components'
import { config } from '@layouts/config'
import { canViewNavMenuGroup } from '@layouts/plugins/casl'
import {
isNavGroupActive,
openGroups,
} from '@layouts/utils'
const props = defineProps({
item: {
type: null,
required: true,
},
})
defineOptions({ name: 'VerticalNavGroup' })
const route = useRoute()
const router = useRouter()
const { width: windowWidth } = useWindowSize()
const { isVerticalNavMini, dynamicI18nProps } = useLayouts()
const hideTitleAndBadge = isVerticalNavMini(windowWidth)
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(() => 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 && !isVerticalNavMini(windowWidth, 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)
// 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(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(isVerticalNavMini(windowWidth, 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="config.app.iconRenderer || 'div'"
v-bind="item.icon || config.verticalNav.defaultNavItemIconProps"
class="nav-item-icon"
/>
<TransitionGroup name="transition-slide-x">
<!-- 👉 Title -->
<Component
:is=" config.app.enableI18n ? 'i18n-t' : 'span'"
v-bind="dynamicI18nProps(item.title, 'span')"
v-show="!hideTitleAndBadge"
key="title"
class="nav-item-title"
>
{{ item.title }}
</Component>
<!-- 👉 Badge -->
<Component
:is="config.app.enableI18n ? 'i18n-t' : 'span'"
v-bind="dynamicI18nProps(item.badgeContent, 'span')"
v-show="!hideTitleAndBadge"
v-if="item.badgeContent"
key="badge"
class="nav-item-badge"
:class="item.badgeClass"
>
{{ item.badgeContent }}
</Component>
<Component
:is="config.app.iconRenderer || 'div'"
v-show="!hideTitleAndBadge"
v-bind="config.icons.chevronRight"
key="arrow"
class="nav-group-arrow"
/>
</TransitionGroup>
</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>
@@ -0,0 +1,211 @@
<script>
import { useLayouts } from '@layouts'
import { VerticalNav } from '@layouts/components'
export default defineComponent({
props: {
navItems: {
type: Array,
required: true,
},
verticalNavAttrs: {
type: Object,
default: () => ({}),
},
},
setup(props, { slots }) {
const { y: windowScrollY } = useWindowScroll()
const { width: windowWidth } = useWindowSize()
const { _layoutClasses: layoutClasses, isLessThanOverlayNavBreakpoint, isNavbarBlurEnabled } = useLayouts()
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)
// watch(isOverlayNavActive, value => {
// // Sync layout overlay with overlay nav
// isLayoutOverlayVisible.value = value
// })
// watch(isLayoutOverlayVisible, value => {
// // If overlay is closed via click, close hide overlay nav
// if (!value) isOverlayNavActive.value = false
// })
// ️ Hide overlay if user open overlay nav in <md and increase the window width without closing overlay nav
watch(windowWidth, value => {
if (!isLessThanOverlayNavBreakpoint.value(value) && isLayoutOverlayVisible.value)
isLayoutOverlayVisible.value = false
})
const router = useRouter()
const shallShowPageLoading = ref(false)
return () => {
const verticalNavAttrs = toRef(props, 'verticalNavAttrs')
const { wrapper: verticalNavWrapper, wrapperProps: verticalNavWrapperProps, ...additionalVerticalNavAttrs } = verticalNavAttrs.value
// 👉 Vertical nav
const verticalNav = h(VerticalNav, { isOverlayNavActive: isOverlayNavActive.value, toggleIsOverlayNavActive, navItems: props.navItems, ...additionalVerticalNavAttrs }, {
'nav-header': () => slots['vertical-nav-header']?.(),
'before-nav-items': () => slots['before-vertical-nav-items']?.(),
})
// 👉 Navbar
const navbar = h('header', { class: ['layout-navbar', { 'navbar-blur': isNavbarBlurEnabled.value }] }, [
h('div', { class: 'navbar-content-container' }, slots.navbar?.({
toggleVerticalOverlayNavActive: toggleIsOverlayNavActive,
})),
])
// 👉 Content area
let mainChildren = slots.default?.()
// 💡 Only show loading and attach `beforeEach` & `afterEach` hooks if `content-loading` slot is used
if (slots['content-loading']) {
router.beforeEach(() => {
console.info('setting to true')
shallShowPageLoading.value = true
})
router.afterEach(() => {
console.info('setting to false')
shallShowPageLoading.value = false
})
mainChildren = shallShowPageLoading.value ? slots['content-loading']?.() : slots.default?.()
}
const main = h('main', { class: 'layout-page-content' }, h('div', { class: 'page-content-container' }, mainChildren))
// 👉 Footer
const footer = h('footer', { class: 'layout-footer' }, [
h('div', { class: 'footer-content-container' }, slots.footer?.()),
])
// 👉 Overlay
const layoutOverlay = h('div', {
class: ['layout-overlay', { visible: isLayoutOverlayVisible.value }],
onClick: () => { isLayoutOverlayVisible.value = !isLayoutOverlayVisible.value },
})
return h('div', { class: ['layout-wrapper', ...layoutClasses.value(windowWidth.value, windowScrollY.value)] }, [
verticalNavWrapper ? h(verticalNavWrapper, verticalNavWrapperProps, { default: () => verticalNav }) : verticalNav,
h('div', { class: 'layout-content-wrapper' }, [
navbar,
main,
footer,
]),
layoutOverlay,
])
}
},
})
</script>
<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: calc(var(--vh, 1vh) * 100);
transition: padding-inline-start 0.2s ease-in-out;
will-change: padding-inline-start;
}
.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;
} @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: transform;
&.visible {
opacity: 1;
pointer-events: auto;
}
}
&:not(.layout-overlay-nav) .layout-content-wrapper {
padding-inline-start: variables.$layout-vertical-nav-width;
}
// Adjust right column pl when vertical nav is collapsed
&.layout-vertical-nav-collapsed .layout-content-wrapper {
padding-inline-start: variables.$layout-vertical-nav-collapsed-width;
}
// 👉 Content height fixed
&.layout-content-height-fixed {
.layout-content-wrapper {
max-block-size: calc(var(--vh) * 100);
}
.layout-page-content {
display: flex;
overflow: hidden;
.page-content-container {
inline-size: 100%;
> :first-child {
max-block-size: 100%;
overflow-y: auto;
}
}
}
}
}
</style>
@@ -0,0 +1,74 @@
<script setup>
import { useLayouts } from '@layouts'
import { config } from '@layouts/config'
import { can } from '@layouts/plugins/casl'
import {
getComputedNavLinkToProp,
isNavLinkActive,
} from '@layouts/utils'
const props = defineProps({
item: {
type: null,
required: true,
},
})
const { width: windowWidth } = useWindowSize()
const { isVerticalNavMini, dynamicI18nProps } = useLayouts()
const hideTitleAndBadge = isVerticalNavMini(windowWidth)
</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="config.app.iconRenderer || 'div'"
v-bind="item.icon || config.verticalNav.defaultNavItemIconProps"
class="nav-item-icon"
/>
<TransitionGroup name="transition-slide-x">
<!-- 👉 Title -->
<Component
:is="config.app.enableI18n ? 'i18n-t' : 'span'"
v-show="!hideTitleAndBadge"
key="title"
class="nav-item-title"
v-bind="dynamicI18nProps(item.title, 'span')"
>
{{ item.title }}
</Component>
<!-- 👉 Badge -->
<Component
:is="config.app.enableI18n ? 'i18n-t' : 'span'"
v-if="item.badgeContent"
v-show="!hideTitleAndBadge"
key="badge"
class="nav-item-badge"
:class="item.badgeClass"
v-bind="dynamicI18nProps(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>
@@ -0,0 +1,40 @@
<script setup>
import { useLayouts } from '@layouts'
import { config } from '@layouts/config'
import { can } from '@layouts/plugins/casl'
const props = defineProps({
item: {
type: null,
required: true,
},
})
const { isVerticalNavMini, dynamicI18nProps } = useLayouts()
const { width: windowWidth } = useWindowSize()
const shallRenderIcon = isVerticalNavMini(windowWidth)
</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"
>
<!-- eslint-disable vue/no-v-text-v-html-on-component -->
<Component
:is="shallRenderIcon ? config.app.iconRenderer : config.app.enableI18n ? 'i18n-t' : 'span'"
:key="shallRenderIcon"
:class="shallRenderIcon ? 'placeholder-icon' : 'title-text'"
v-bind="{ ...config.icons.sectionTitlePlaceholder, ...dynamicI18nProps(item.heading, 'span') }"
v-text="!shallRenderIcon ? item.heading : null"
/>
<!-- eslint-enable vue/no-v-text-v-html-on-component -->
</Transition>
</div>
</li>
</template>
@@ -0,0 +1,14 @@
// Thanks: https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
export const useDynamicVhCssProperty = () => {
const vh = ref(0)
const updateVh = () => {
vh.value = window.innerHeight * 0.01
document.documentElement.style.setProperty('--vh', `${vh.value}px`)
}
tryOnBeforeMount(() => {
updateVh()
useEventListener('resize', updateVh)
})
}
@@ -0,0 +1,204 @@
import { AppContentLayoutNav, NavbarType } from '../enums'
import { config } from '@layouts/config'
import { injectionKeyIsVerticalNavHovered } from '@layouts'
export const useLayouts = () => {
const navbarType = computed({
get() {
return config.navbar.type.value
},
set(value) {
config.navbar.type.value = value
},
})
const isNavbarBlurEnabled = computed({
get() {
return config.navbar.navbarBlur.value
},
set(value) {
config.navbar.navbarBlur.value = value
localStorage.setItem(`${config.app.title}-navbarBlur`, value.toString())
},
})
const _setAppDir = dir => {
document.documentElement.setAttribute('dir', dir)
}
const footerType = computed({
get() {
return config.footer.type.value
},
set(value) {
config.footer.type.value = value
},
})
const isVerticalNavCollapsed = computed({
get() {
return config.verticalNav.isVerticalNavCollapsed.value
},
set(val) {
config.verticalNav.isVerticalNavCollapsed.value = val
localStorage.setItem(`${config.app.title}-isVerticalNavCollapsed`, val.toString())
},
})
const appContentWidth = computed({
get() {
return config.app.contentWidth.value
},
set(val) {
config.app.contentWidth.value = val
localStorage.setItem(`${config.app.title}-contentWidth`, val.toString())
},
})
const appContentLayoutNav = computed({
get() {
return config.app.contentLayoutNav.value
},
set(val) {
config.app.contentLayoutNav.value = 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
}
},
})
const horizontalNavType = computed({
get() {
return config.horizontalNav.type.value
},
set(value) {
config.horizontalNav.type.value = value
},
})
const isLessThanOverlayNavBreakpoint = computed(() => {
return windowWidth => unref(windowWidth) < config.app.overlayNavFromBreakpoint
})
const _layoutClasses = computed(() => (windowWidth, windowScrollY) => {
const route = useRoute()
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(windowWidth),
},
{ [`horizontal-nav-${horizontalNavType.value}`]: appContentLayoutNav.value === 'horizontal' },
`layout-content-width-${appContentWidth.value}`,
{ 'layout-overlay-nav': isLessThanOverlayNavBreakpoint.value(windowWidth) },
{ 'window-scrolled': unref(windowScrollY) },
route.meta.layoutWrapperClasses ? route.meta.layoutWrapperClasses : null,
]
})
const switchToVerticalNavOnLtOverlayNavBreakpoint = windowWidth => {
/*
️ 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(appContentLayoutNav.value)
/*
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(appContentLayoutNav, value => {
if (!isLessThanOverlayNavBreakpoint.value(windowWidth))
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(() => isLessThanOverlayNavBreakpoint.value(windowWidth), val => {
if (!val)
appContentLayoutNav.value = lgAndUpNav.value
else
appContentLayoutNav.value = AppContentLayoutNav.Vertical
}, { immediate: true })
}
/*
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 = (windowWidth, isVerticalNavHovered = null) => {
const isVerticalNavHoveredLocal = isVerticalNavHovered || inject(injectionKeyIsVerticalNavHovered) || ref(false)
return computed(() => isVerticalNavCollapsed.value && !isVerticalNavHoveredLocal.value && !isLessThanOverlayNavBreakpoint.value(unref(windowWidth)))
}
const dynamicI18nProps = computed(() => (key, tag = 'span') => {
if (config.app.enableI18n) {
return {
keypath: key,
tag,
scope: 'global',
}
}
return {}
})
const isAppRtl = computed({
get() {
return config.app.isRtl.value
},
set(value) {
config.app.isRtl.value = value
localStorage.setItem(`${config.app.title}-isRtl`, value.toString())
_setAppDir(value ? 'rtl' : 'ltr')
},
})
return {
navbarType,
isNavbarBlurEnabled,
footerType,
isVerticalNavCollapsed,
appContentWidth,
appContentLayoutNav,
horizontalNavType,
isLessThanOverlayNavBreakpoint,
_layoutClasses,
switchToVerticalNavOnLtOverlayNavBreakpoint,
isVerticalNavMini,
dynamicI18nProps,
isAppRtl,
_setAppDir,
}
}
+36
View File
@@ -0,0 +1,36 @@
import { breakpointsVuetify } from '@vueuse/core'
import { AppContentLayoutNav, ContentWidth, FooterType, NavbarType } from '@layouts/enums'
export const config = {
app: {
title: 'title',
logo: h('img', { src: '/src/assets/logo.svg' }),
// logo: () => h('img', { src: 'assets/colored-logo.png' }, null),
contentWidth: ref(ContentWidth.Boxed),
contentLayoutNav: ref(AppContentLayoutNav.Vertical),
overlayNavFromBreakpoint: breakpointsVuetify.md,
enableI18n: true,
isRtl: ref(false),
},
navbar: {
type: ref(NavbarType.Sticky),
navbarBlur: ref(true),
},
footer: { type: ref(FooterType.Static) },
verticalNav: {
isVerticalNavCollapsed: ref(false),
defaultNavItemIconProps: { icon: 'tabler-circle' },
},
horizontalNav: {
type: ref('sticky'),
},
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' },
},
}
+18
View File
@@ -0,0 +1,18 @@
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',
}
+52
View File
@@ -0,0 +1,52 @@
import { useDynamicVhCssProperty } from './composable/useDynamicVhCssProperty'
import { config } from './config'
import { ContentWidth } from './enums'
import { useLayouts } from '@layouts'
const { _setAppDir } = useLayouts()
// 🔌 Plugin
export const createLayouts = userConfig => {
const localStorageIsRtl = localStorage.getItem(`${userConfig.app.title}-isRtl`)
const localStorageIsVerticalNavCollapsed = localStorage.getItem(`${userConfig.app.title}-isVerticalNavCollapsed`)
const localStorageContentWidth = (() => {
const storageValue = localStorage.getItem(`${userConfig.app.title}-contentWidth`)
return Object.values(ContentWidth).find(v => v === storageValue)
})()
const localStorageNavbarBlur = localStorage.getItem(`${userConfig.app.title}-navbarBlur`)
config.app.title = userConfig.app.title
config.app.logo = userConfig.app.logo
config.app.contentWidth.value = localStorageContentWidth || userConfig.app.contentWidth
config.app.contentLayoutNav.value = userConfig.app.contentLayoutNav
config.app.overlayNavFromBreakpoint = userConfig.app.overlayNavFromBreakpoint
config.app.enableI18n = userConfig.app.enableI18n
config.app.isRtl.value = localStorageIsRtl ? JSON.parse(localStorageIsRtl) : userConfig.app.isRtl
config.app.iconRenderer = userConfig.app.iconRenderer
config.navbar.type.value = userConfig.navbar.type
config.navbar.navbarBlur.value = localStorageNavbarBlur ? JSON.parse(localStorageNavbarBlur) : userConfig.navbar.navbarBlur
config.footer.type.value = userConfig.footer.type
config.verticalNav.isVerticalNavCollapsed.value
= localStorageIsVerticalNavCollapsed
? JSON.parse(localStorageIsVerticalNavCollapsed)
: userConfig.verticalNav.isVerticalNavCollapsed
config.verticalNav.defaultNavItemIconProps = userConfig.verticalNav.defaultNavItemIconProps
config.horizontalNav.type.value = userConfig.horizontalNav.type
config.icons.chevronDown = userConfig.icons.chevronDown
config.icons.chevronRight = userConfig.icons.chevronRight
config.icons.close = userConfig.icons.close
config.icons.verticalNavPinned = userConfig.icons.verticalNavPinned
config.icons.verticalNavUnPinned = userConfig.icons.verticalNavUnPinned
config.icons.sectionTitlePlaceholder = userConfig.icons.sectionTitlePlaceholder
return () => {
useDynamicVhCssProperty()
_setAppDir(config.app.isRtl.value ? 'rtl' : 'ltr')
}
}
export const injectionKeyIsVerticalNavHovered = Symbol('isVerticalNavHovered')
export * from './components'
export { useLayouts } from './composable/useLayouts'
+39
View File
@@ -0,0 +1,39 @@
import ability from '@/plugins/casl/ability'
/**
* 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 => {
return to.matched.some(route => ability.can(route.meta.action, route.meta.subject))
}
@@ -0,0 +1,3 @@
.cursor-pointer {
cursor: pointer;
}
@@ -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;
}
}
+10
View File
@@ -0,0 +1,10 @@
*,
::before,
::after {
box-sizing: inherit;
background-repeat: no-repeat;
}
html {
box-sizing: border-box;
}
+28
View File
@@ -0,0 +1,28 @@
@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;
}
} @else {
.layout-content-width-boxed & {
@extend %boxed-content;
}
}
}
}
}
@@ -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;
}
}
+7
View File
@@ -0,0 +1,7 @@
@use "./mixins";
.layout-vertical-nav .nav-group-arrow {
@include mixins.rtl {
transform: rotate(180deg);
}
}
@@ -0,0 +1,28 @@
// @use "@styles/style.scss";
// 👉 Vertical nav
$layout-vertical-nav-z-index: 12 !default;
$layout-vertical-nav-width: 260px !default;
$layout-vertical-nav-collapsed-width: 84px !default;
// 👉 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;
+3
View File
@@ -0,0 +1,3 @@
@use "_global";
@use "vue3-perfect-scrollbar/dist/vue3-perfect-scrollbar.min.css";
@use "_classes";
+100
View File
@@ -0,0 +1,100 @@
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)
})
/**
* 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 ? `${parseInt(result[1], 16)},${parseInt(result[2], 16)},${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 => 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('')}`)
}
+43
View File
@@ -0,0 +1,43 @@
<script setup>
import { useAuthStore } from '@/stores/auth'
import ScrollToTop from '@core/components/ScrollToTop.vue'
import { useThemeConfig } from '@core/composable/useThemeConfig'
import { hexToRgb } from '@layouts/utils'
import { useTheme } from 'vuetify'
const {
syncInitialLoaderTheme,
syncVuetifyThemeWithTheme: syncConfigThemeWithVuetifyTheme,
isAppRtl,
handleSkinChanges,
} = useThemeConfig()
const { global } = useTheme()
// ️ Sync current theme with initial loader theme
syncInitialLoaderTheme()
syncConfigThemeWithVuetifyTheme()
handleSkinChanges()
const authStore = useAuthStore()
if (!authStore.user || !authStore.role) {
authStore.getUser()
authStore.getRoleUser()
}
</script>
<template>
<VLocaleProvider :rtl="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('#3a418d')}`">
<RouterView />
<ScrollToTop />
</VApp>
</VLocaleProvider>
</template>
<style lang="scss">
.swal2-container {
z-index: 2500 !important;
}
</style>
+259
View File
@@ -0,0 +1,259 @@
<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: 42,
yearlyPrice: 460,
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: 84,
yearlyPrice: 690,
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">
<h4 class="text-h2 pricing-title mb-4">
{{ props.title ? props.title : 'Pricing Plans' }}
</h4>
<p class="mb-0">
All plans include 40+ advanced tools and features to boost your product.
</p>
<p>Choose the best plan to fit your needs.</p>
</div>
<!-- 👉 Annual and monthly price toggler -->
<div class="d-flex align-center justify-center mx-auto my-10">
<VLabel
for="pricing-plan-toggle"
class="me-2"
>
Monthly
</VLabel>
<div class="position-relative">
<VSwitch
id="pricing-plan-toggle"
v-model="annualMonthlyPlanPriceToggler"
label="Annual"
/>
<div class="save-upto-chip position-absolute align-center d-none d-md-flex gap-1">
<VIcon
icon="tabler-corner-left-down"
class="flip-in-rtl"
/>
<VChip
label
color="primary"
>
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: 4.125rem;"
class="text-end"
>
<!-- 👉 Popular -->
<VChip
v-show="plan.isPopular"
label
color="primary"
size="small"
>
Popular
</VChip>
</VCardText>
<!-- 👉 Plan logo -->
<VCardText class="text-center">
<VImg
:height="140"
:src="plan.logo"
class="mx-auto mb-5"
/>
<!-- 👉 Plan name -->
<h5 class="text-h5 mb-2">
{{ plan.name }}
</h5>
<p class="mb-0">
{{ plan.tagLine }}
</p>
</VCardText>
<!-- 👉 Plan price -->
<VCardText class="position-relative text-center">
<div class="d-flex justify-center align-center">
<sup class="text-sm font-weight-medium me-1">$</sup>
<h1 class="text-5xl font-weight-medium text-primary">
{{ annualMonthlyPlanPriceToggler ? Math.floor(Number(plan.yearlyPrice) / 12) : plan.monthlyPrice }}
</h1>
<sub class="text-sm font-weight-medium ms-1 mt-4">/month</sub>
</div>
<!-- 👉 Annual Price -->
<span
v-show="annualMonthlyPlanPriceToggler"
class="position-absolute text-caption font-weight-medium mt-1"
style="inset-inline: 0;"
>
{{ plan.yearlyPrice === 0 ? 'free' : `USD ${plan.yearlyPrice}/Year` }}
</span>
</VCardText>
<!-- 👉 Plan features -->
<VCardText class="mt-5">
<VList class="card-list">
<VListItem
v-for="feature in plan.features"
:key="feature"
>
<template #prepend>
<VIcon
:size="14"
icon="tabler-circle"
class="me-3"
/>
</template>
<VListItemTitle>
{{ feature }}
</VListItemTitle>
</VListItem>
</VList>
</VCardText>
<!-- 👉 Plan actions -->
<VCardActions>
<VBtn
block
:color="plan.current ? 'success' : 'primary'"
:variant="plan.isPopular ? 'elevated' : 'tonal'"
>
{{ plan.yearlyPrice === 0 ? 'Your Current Plan' : 'Upgrade' }}
</VBtn>
</VCardActions>
</VCard>
</VCol>
</VRow>
<!-- !SECTION -->
</template>
<style lang="scss" scoped>
.card-list {
--v-card-list-gap: 0.75rem;
}
.save-upto-chip {
inset-block-start: -1.5rem;
inset-inline-end: -7rem;
}
</style>
@@ -0,0 +1,82 @@
<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,
},
})
defineOptions({ inheritAttrs: false })
</script>
<template>
<!-- 👉 Search Banner -->
<VCard
flat
class="text-center search-header"
:class="props.customClass"
:style="`background: url(${AppSearchHeaderBg});`"
>
<VCardText>
<h5 class="text-h3 font-weight-medium">
{{ props.title }}
</h5>
<!-- 👉 Search Input -->
<AppTextField
v-bind="$attrs"
placeholder="Search a question..."
class="search-header-input mx-auto my-3"
density="comfortable"
>
<template #prepend-inner>
<VIcon
icon="tabler-search"
size="23"
/>
</template>
</AppTextField>
<p class="mb-0">
{{ props.subtitle }}
</p>
</VCardText>
</VCard>
</template>
<style lang="scss">
.search-header {
padding: 4rem !important;
background-size: cover !important;
}
// search input
.search-header-input {
border-radius: 0.25rem !important;
border-radius: 0.375rem !important;
background-color: rgb(var(--v-theme-surface));
max-inline-size: 40.125rem !important;
.v-field__prepend-inner {
i {
inset-block-start: 3px !important;
}
}
}
@media (max-width: 37.5rem) {
.search-header {
padding: 1.5rem !important;
}
}
</style>
+37
View File
@@ -0,0 +1,37 @@
<script setup>
const props = defineProps({
errorCode: {
type: String,
required: false,
},
errorTitle: {
type: String,
required: false,
},
errorDescription: {
type: String,
required: false,
},
})
</script>
<template>
<div class="text-center">
<!-- 👉 Title and subtitle -->
<h1
v-if="props.errorCode"
class="text-h1 font-weight-medium"
>
{{ props.errorCode }}
</h1>
<h4
v-if="props.errorTitle"
class="text-h4 font-weight-medium mb-3"
>
{{ props.errorTitle }}
</h4>
<p v-if="props.errorDescription">
{{ props.errorDescription }}
</p>
</div>
</template>
+84
View File
@@ -0,0 +1,84 @@
<script setup>
import { onBeforeUnmount, onMounted, watchEffect } from 'vue'
import WaveSurfer from 'wavesurfer.js'
// eslint-disable-next-line import/extensions
import TimelinePlugin from 'wavesurfer.js/dist/plugins/timeline.js'
const props = defineProps({
url: {
type: String,
required: true,
},
})
const zoom = ref(50)
const audioEl = ref()
const waveEl = ref()
onMounted(()=>{
const audio = new Audio()
audio.controls = true
audio.src = props.url
const topTimeline = TimelinePlugin.create({
height: 20,
insertPosition: 'beforebegin',
timeInterval: 0.1,
primaryLabelInterval: 5,
secondaryLabelInterval: 1,
style: {
fontSize: '8px',
color: '#2D5B88',
},
})
const ws = WaveSurfer.create({
container: waveEl.value,
waveColor: '#4F4A85',
progressColor: '#383351',
url: props.url,
media: audio,
plugins: [topTimeline],
minPxPerSec: 50,
normalize: true,
})
ws.once('decode', () => {
watchEffect(() => {
ws.zoom(zoom.value)
})
})
audio.style.width = '100%'
audioEl.value.appendChild(audio)
onBeforeUnmount(() => {
ws.destroy()
audioEl.value.removeChild(audio)
})
})
</script>
<template>
<div>
<VSlider
v-model="zoom"
label="Zoom"
min="50"
max="200"
step="1"
class="mx-0"
prepend-icon="tabler-circle-minus"
append-icon="tabler-circle-plus"
@click:prepend="zoom = Math.max(zoom - 10, 50)"
@click:append="zoom = Math.min(zoom + 10, 200)"
/>
<div ref="waveEl" />
<div
ref="audioEl"
style="width: 100%;"
/>
</div>
</template>
@@ -0,0 +1,110 @@
<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
max-width="787"
:model-value="props.isDialogVisible"
@update:model-value="(val) => $emit('update:isDialogVisible', val)"
>
<!-- Dialog close btn -->
<DialogCloseBtn @click="$emit('update:isDialogVisible', false)" />
<VCard class="pa-5 pa-sm-8">
<VCardItem>
<VCardTitle class="text-h5 font-weight-medium text-center">
Add Authenticator App
</VCardTitle>
</VCardItem>
<VCardText class="pt-3">
<h6 class="text-lg font-weight-medium mb-2">
Authenticator Apps
</h6>
<p class="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-4">
<VImg
width="122"
:src="themeselectionQr"
class="mx-auto"
/>
</div>
<VAlert
color="light-warning"
class="text-warning"
>
<span class="text-lg font-weight-medium">ASDLKNASDA9AHS678dGhASD78AB</span>
<p class="mb-0">
If you are unable to scan the QR code, you can manually enter the secret key below.
</p>
</VAlert>
<VForm @submit.prevent="() => {}">
<AppTextField
v-model="authCode"
name="auth-code"
label="Enter Authentication Code"
class="my-4"
/>
<div class="d-flex justify-end flex-wrap gap-3">
<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>
@@ -0,0 +1,231 @@
<script setup>
const props = defineProps({
billingAddress: {
type: Object,
required: false,
default: () => ({
companyName: '',
billingEmail: '',
taxID: '',
vatNumber: '',
address: '',
contact: '',
country: null,
state: '',
zipCode: null,
}),
},
isDialogVisible: {
type: Boolean,
required: true,
},
})
const emit = defineEmits([
'update:isDialogVisible',
'submit',
])
const billingAddress = ref(structuredClone(toRaw(props.billingAddress)))
const resetForm = () => {
emit('update:isDialogVisible', false)
billingAddress.value = structuredClone(toRaw(props.billingAddress))
}
const onFormSubmit = () => {
emit('update:isDialogVisible', false)
emit('submit', billingAddress.value)
}
const selectedAddress = ref('Home')
const addressTypes = [
{
icon: {
icon: 'custom-home',
size: '40',
},
title: 'Home',
desc: 'Delivery Time (7am - 9pm)',
value: 'Home',
},
{
icon: {
icon: 'custom-office',
size: '40',
},
title: 'Office',
desc: 'Delivery Time (10am - 6pm)',
value: 'Office',
},
]
</script>
<template>
<VDialog
:width="$vuetify.display.smAndDown ? 'auto' : 610 "
:model-value="props.isDialogVisible"
@update:model-value="val => $emit('update:isDialogVisible', val)"
>
<!-- 👉 Dialog close btn -->
<DialogCloseBtn @click="$emit('update:isDialogVisible', false)" />
<VCard
v-if="props.billingAddress"
class="pa-sm-8 pa-5"
>
<!-- 👉 Title -->
<VCardItem>
<VCardTitle class="text-h4 text-center">
{{ props.billingAddress.address ? 'Edit' : 'Add New' }} Address
</VCardTitle>
</VCardItem>
<VCardText>
<!-- 👉 Subtitle -->
<VCardSubtitle class="text-center mb-6">
<span class="text-base">
Add new address for express delivery
</span>
</VCardSubtitle>
<div class="d-flex">
<CustomRadiosWithIcon
v-model:selected-radio="selectedAddress"
:radio-content="addressTypes"
:grid-column="{ sm: '6', cols: '12' }"
/>
</div>
<!-- 👉 Form -->
<VForm
class="mt-4"
@submit.prevent="onFormSubmit"
>
<VRow>
<!-- 👉 Company Name -->
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="billingAddress.companyName"
label="Company Name"
/>
</VCol>
<!-- 👉 Email -->
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="billingAddress.billingEmail"
label="Email"
/>
</VCol>
<!-- 👉 Tax ID -->
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="billingAddress.taxID"
label="Tax ID"
/>
</VCol>
<!-- 👉 VAT Number -->
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="billingAddress.vatNumber"
label="VAT Number"
/>
</VCol>
<!-- 👉 Billing Address -->
<VCol cols="12">
<AppTextarea
v-model="billingAddress.address"
rows="2"
label="Billing Address"
/>
</VCol>
<!-- 👉 Contact -->
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="billingAddress.contact"
label="Contact"
/>
</VCol>
<!-- 👉 Country -->
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="billingAddress.country"
label="Country"
/>
</VCol>
<!-- 👉 State -->
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="billingAddress.state"
label="State"
/>
</VCol>
<!-- 👉 Zip Code -->
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="billingAddress.zipCode"
label="Zip Code"
type="number"
/>
</VCol>
<!-- 👉 Submit and Cancel button -->
<VCol
cols="12"
class="text-center"
>
<VBtn
type="submit"
class="me-3"
>
submit
</VBtn>
<VBtn
variant="tonal"
color="secondary"
@click="resetForm"
>
Cancel
</VBtn>
</VCol>
</VRow>
</VForm>
</VCardText>
</VCard>
</VDialog>
</template>
@@ -0,0 +1,97 @@
<script setup>
const props = defineProps({
isDialogVisible: {
type: Boolean,
required: true,
},
permissionName: {
type: String,
required: false,
default: '',
},
})
const emit = defineEmits([
'update:isDialogVisible',
'update:permissionName',
])
const permissionName = ref('')
const onReset = () => {
emit('update:isDialogVisible', false)
permissionName.value = ''
}
const onSubmit = () => {
emit('update:isDialogVisible', false)
emit('update:permissionName', permissionName.value)
}
watch(props, () => {
permissionName.value = props.permissionName
})
</script>
<template>
<VDialog
:width="$vuetify.display.smAndDown ? 'auto' : 600"
:model-value="props.isDialogVisible"
@update:model-value="onReset"
>
<!-- 👉 dialog close btn -->
<DialogCloseBtn @click="onReset" />
<VCard class="pa-sm-8 pa-5">
<!-- 👉 Title -->
<VCardItem class="text-center">
<VCardTitle class="text-h5">
{{ props.permissionName ? 'Edit' : 'Add' }} Permission
</VCardTitle>
<VCardSubtitle>
{{ props.permissionName ? 'Edit' : 'Add' }} permission as per your requirements.
</VCardSubtitle>
</VCardItem>
<VCardText class="mt-1">
<!-- 👉 Form -->
<VForm>
<VAlert
type="warning"
title="Warning!"
variant="tonal"
class="mb-6"
>
By editing the permission name, you might break the system permissions functionality. Please ensure you're absolutely certain before proceeding.
</VAlert>
<!-- 👉 Role name -->
<div class="d-flex align-end gap-3 mb-3">
<AppTextField
v-model="permissionName"
density="compact"
label="Permission Name"
placeholder="Enter Permission Name"
/>
<VBtn @click="onSubmit">
Update
</VBtn>
</div>
<VCheckbox label="Set as core permission" />
</VForm>
</VCardText>
</VCard>
</VDialog>
</template>
<style lang="scss">
.permission-table {
td {
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
padding-block: 0.5rem;
padding-inline: 0;
}
}
</style>
@@ -0,0 +1,287 @@
<script setup>
import { VForm } from 'vuetify/components/VForm'
const props = defineProps({
rolePermissions: {
type: Object,
required: false,
default: () => ({
name: '',
permissions: [],
}),
},
isDialogVisible: {
type: Boolean,
required: true,
},
})
const emit = defineEmits([
'update:isDialogVisible',
'update:rolePermissions',
])
// 👉 Permission List
const permissions = ref([
{
name: 'User Management',
read: false,
write: false,
create: false,
},
{
name: 'Content Management',
read: false,
write: false,
create: false,
},
{
name: 'Disputes Management',
read: false,
write: false,
create: false,
},
{
name: 'Database Management',
read: false,
write: false,
create: false,
},
{
name: 'Financial Management',
read: false,
write: false,
create: false,
},
{
name: 'Reporting',
read: false,
write: false,
create: false,
},
{
name: 'API Control',
read: false,
write: false,
create: false,
},
{
name: 'Repository Management',
read: false,
write: false,
create: false,
},
{
name: 'Payroll',
read: false,
write: false,
create: false,
},
])
const isSelectAll = ref(false)
const role = ref('')
const refPermissionForm = ref()
const checkedCount = computed(() => {
let counter = 0
permissions.value.forEach(permission => {
Object.entries(permission).forEach(([key, value]) => {
if (key !== 'name' && value)
counter++
})
})
return counter
})
const isIndeterminate = computed(() => checkedCount.value > 0 && checkedCount.value < permissions.value.length * 3)
// select all
watch(isSelectAll, val => {
permissions.value = permissions.value.map(permission => ({
...permission,
read: val,
write: val,
create: val,
}))
})
// if Indeterminate is false, then set isSelectAll to false
watch(isIndeterminate, () => {
if (!isIndeterminate.value)
isSelectAll.value = false
})
// if all permissions are checked, then set isSelectAll to true
watch(permissions, () => {
if (checkedCount.value === permissions.value.length * 3)
isSelectAll.value = true
}, { deep: true })
// if rolePermissions is not empty, then set permissions
watch(props, () => {
if (props.rolePermissions && props.rolePermissions.permissions.length) {
role.value = props.rolePermissions.name
permissions.value = permissions.value.map(permission => {
const rolePermission = props.rolePermissions?.permissions.find(item => item.name === permission.name)
if (rolePermission) {
return {
...permission,
...rolePermission,
}
}
return permission
})
}
})
const onSubmit = () => {
const rolePermissions = {
name: role.value,
permissions: permissions.value,
}
emit('update:rolePermissions', rolePermissions)
emit('update:isDialogVisible', false)
isSelectAll.value = false
refPermissionForm.value?.reset()
}
const onReset = () => {
emit('update:isDialogVisible', false)
isSelectAll.value = false
refPermissionForm.value?.reset()
}
</script>
<template>
<VDialog
:width="$vuetify.display.smAndDown ? 'auto' : 900"
:model-value="props.isDialogVisible"
@update:model-value="onReset"
>
<!-- 👉 Dialog close btn -->
<DialogCloseBtn @click="onReset" />
<VCard class="pa-sm-8 pa-5">
<!-- 👉 Title -->
<VCardItem class="text-center">
<VCardTitle class="text-h3 mb-3">
{{ props.rolePermissions.name ? 'Edit' : 'Add New' }} Role
</VCardTitle>
<p class="text-base mb-0">
Set Role Permissions
</p>
</VCardItem>
<VCardText class="mt-6">
<!-- 👉 Form -->
<VForm ref="refPermissionForm">
<!-- 👉 Role name -->
<AppTextField
v-model="role"
label="Role Name"
placeholder="Enter Role Name"
/>
<h6 class="text-h4 mt-8 mb-3">
Role Permissions
</h6>
<!-- 👉 Role Permissions -->
<VTable class="permission-table text-no-wrap">
<!-- 👉 Admin -->
<tr>
<td>
Administrator Access
</td>
<td colspan="3">
<div class="d-flex justify-end">
<VCheckbox
v-model="isSelectAll"
v-model:indeterminate="isIndeterminate"
label="Select All"
/>
</div>
</td>
</tr>
<!-- 👉 Other permission loop -->
<template
v-for="permission in permissions"
:key="permission.name"
>
<tr>
<td>{{ permission.name }}</td>
<td>
<div class="d-flex justify-end">
<VCheckbox
v-model="permission.read"
label="Read"
/>
</div>
</td>
<td>
<div class="d-flex justify-end">
<VCheckbox
v-model="permission.write"
label="Write"
/>
</div>
</td>
<td>
<div class="d-flex justify-end">
<VCheckbox
v-model="permission.create"
label="Create"
/>
</div>
</td>
</tr>
</template>
</VTable>
<!-- 👉 Actions button -->
<div class="d-flex align-center justify-center gap-3 mt-6">
<VBtn @click="onSubmit">
Submit
</VBtn>
<VBtn
color="secondary"
variant="tonal"
@click="onReset"
>
Cancel
</VBtn>
</div>
</VForm>
</VCardText>
</VCard>
</VDialog>
</template>
<style lang="scss">
.permission-table {
td {
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
padding-block: 0.5rem;
.v-checkbox {
min-inline-size: 4.75rem;
}
&:not(:first-child) {
padding-inline: 0.5rem;
}
.v-label {
white-space: nowrap;
}
}
}
</style>
@@ -0,0 +1,140 @@
<script setup>
const props = defineProps({
cardDetails: {
type: Object,
required: false,
default: () => ({
number: '',
name: '',
expiry: '',
cvv: '',
isPrimary: false,
type: '',
}),
},
isDialogVisible: {
type: Boolean,
required: true,
},
})
const emit = defineEmits([
'submit',
'update:isDialogVisible',
])
const cardDetails = ref(structuredClone(toRaw(props.cardDetails)))
watch(props, () => {
cardDetails.value = structuredClone(toRaw(props.cardDetails))
})
const formSubmit = () => {
emit('submit', cardDetails.value)
}
const dialogModelValueUpdate = val => {
emit('update:isDialogVisible', val)
}
</script>
<template>
<VDialog
:width="$vuetify.display.smAndDown ? 'auto' : 580"
:model-value="props.isDialogVisible"
@update:model-value="dialogModelValueUpdate"
>
<!-- Dialog close btn -->
<DialogCloseBtn @click="dialogModelValueUpdate(false)" />
<VCard class="pa-5 pa-sm-8">
<!-- 👉 Title -->
<VCardItem class="text-center">
<VCardTitle class="text-h5 font-weight-medium mb-3">
{{ props.cardDetails.name ? 'Edit Card' : 'Add New Card' }}
</VCardTitle>
<p class="mb-0">
{{ props.cardDetails.name ? 'Edit your saved card details' : 'Add your saved card details' }}
</p>
</VCardItem>
<VCardText class="pt-6">
<VForm @submit.prevent="() => {}">
<VRow>
<!-- 👉 Card Number -->
<VCol cols="12">
<AppTextField
v-model="cardDetails.number"
label="Card Number"
type="number"
/>
</VCol>
<!-- 👉 Card Name -->
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="cardDetails.name"
label="Name"
/>
</VCol>
<!-- 👉 Card Expiry -->
<VCol
cols="6"
md="3"
>
<AppTextField
v-model="cardDetails.expiry"
label="Expiry"
/>
</VCol>
<!-- 👉 Card CVV -->
<VCol
cols="6"
md="3"
>
<AppTextField
v-model="cardDetails.cvv"
type="number"
label="CVV"
/>
</VCol>
<!-- 👉 Card Primary Set -->
<VCol cols="12">
<VSwitch
v-model="cardDetails.isPrimary"
label="Set as primary card"
/>
</VCol>
<!-- 👉 Card actions -->
<VCol
cols="12"
class="text-center"
>
<VBtn
class="me-3"
type="submit"
@click="formSubmit"
>
Submit
</VBtn>
<VBtn
color="secondary"
variant="tonal"
@click="$emit('update:isDialogVisible', false)"
>
Cancel
</VBtn>
</VCol>
</VRow>
</VForm>
</VCardText>
</VCard>
</VDialog>
</template>
@@ -0,0 +1,164 @@
<script setup>
const props = defineProps({
confirmationQuestion: {
type: String,
required: true,
},
isDialogVisible: {
type: Boolean,
required: true,
},
confirmTitle: {
type: String,
required: true,
},
confirmMsg: {
type: String,
required: true,
},
cancelTitle: {
type: String,
required: true,
},
cancelMsg: {
type: String,
required: true,
},
})
const emit = defineEmits([
'update:isDialogVisible',
'confirm',
])
const unsubscribed = ref(false)
const cancelled = ref(false)
const updateModelValue = val => {
emit('update:isDialogVisible', val)
}
const onConfirmation = () => {
emit('confirm', true)
updateModelValue(false)
unsubscribed.value = true
}
const onCancel = () => {
emit('confirm', false)
emit('update:isDialogVisible', false)
cancelled.value = true
}
</script>
<template>
<!-- 👉 Confirm Dialog -->
<VDialog
max-width="500"
:model-value="props.isDialogVisible"
@update:model-value="updateModelValue"
>
<VCard class="text-center px-10 py-6">
<VCardText>
<VBtn
icon
variant="outlined"
color="warning"
class="my-4"
style=" block-size: 88px;inline-size: 88px; pointer-events: none;"
>
<span class="text-5xl">!</span>
</VBtn>
<h6 class="text-lg font-weight-medium">
{{ props.confirmationQuestion }}
</h6>
</VCardText>
<VCardText class="d-flex align-center justify-center gap-2">
<VBtn
variant="elevated"
@click="onConfirmation"
>
Confirm
</VBtn>
<VBtn
color="secondary"
variant="tonal"
@click="onCancel"
>
Cancel
</VBtn>
</VCardText>
</VCard>
</VDialog>
<!-- Unsubscribed -->
<VDialog
v-model="unsubscribed"
max-width="500"
>
<VCard>
<VCardText class="text-center px-10 py-6">
<VBtn
icon
variant="outlined"
color="success"
class="my-4"
style=" block-size: 88px;inline-size: 88px; pointer-events: none;"
>
<span class="text-3xl">
<VIcon icon="tabler-check" />
</span>
</VBtn>
<h1 class="text-h4 mb-4">
{{ props.confirmTitle }}
</h1>
<p>{{ props.confirmMsg }}</p>
<VBtn
color="success"
@click="unsubscribed = false"
>
Ok
</VBtn>
</VCardText>
</VCard>
</VDialog>
<!-- Cancelled -->
<VDialog
v-model="cancelled"
max-width="500"
>
<VCard>
<VCardText class="text-center px-10 py-6">
<VBtn
icon
variant="outlined"
color="error"
class="my-4"
style=" block-size: 88px;inline-size: 88px; pointer-events: none;"
>
<span class="text-5xl font-weight-light">X</span>
</VBtn>
<h1 class="text-h4 mb-4">
{{ props.cancelTitle }}
</h1>
<p>{{ props.cancelMsg }}</p>
<VBtn
color="success"
@click="cancelled = false"
>
Ok
</VBtn>
</VCardText>
</VCard>
</VDialog>
</template>
@@ -0,0 +1,437 @@
<script setup>
import laptopGirl from '@images/illustrations/laptop-girl.png'
const props = defineProps({
isDialogVisible: {
type: Boolean,
required: true,
},
})
const emit = defineEmits([
'update:isDialogVisible',
'updatedData',
])
const currentStep = ref(0)
const createApp = [
{
icon: 'tabler-clipboard',
title: 'Details',
subtitle: 'Enter Details',
},
{
icon: 'tabler-box',
title: 'Frameworks',
subtitle: 'Select Framework',
},
{
icon: 'tabler-database',
title: 'Database',
subtitle: 'Select Database',
},
{
icon: 'tabler-credit-card',
title: 'Billing',
subtitle: 'Payment Details',
},
{
icon: 'tabler-check',
title: 'Submit',
subtitle: 'submit',
},
]
const categories = [
{
icon: 'tabler-briefcase',
color: 'info',
title: 'CRM Application',
subtitle: 'Scales with any business',
slug: 'crm-application',
},
{
icon: 'tabler-shopping-cart',
color: 'success',
title: 'Ecommerce Platforms',
subtitle: 'Grow Your Business With App',
slug: 'ecommerce-application',
},
{
icon: 'tabler-device-laptop',
color: 'error',
title: 'Online Learning platform',
subtitle: 'Start learning today',
slug: 'online-learning-application',
},
]
const frameworks = [
{
icon: 'tabler-brand-react-native',
color: 'info',
title: 'React Native',
subtitle: 'Create truly native apps',
slug: 'react-framework',
},
{
icon: 'tabler-brand-angular',
color: 'error',
title: 'Angular',
subtitle: 'Most suited for your application',
slug: 'angular-framework',
},
{
icon: 'tabler-brand-html5',
color: 'warning',
title: 'HTML',
subtitle: 'Progressive Framework',
slug: 'html-framework',
},
{
icon: 'tabler-brand-python',
color: 'primary',
title: 'Python',
subtitle: 'js web frameworks',
slug: 'js-framework',
},
]
const databases = [
{
icon: 'tabler-brand-firebase',
color: 'error',
title: 'Firebase',
subtitle: 'Cloud Firestore',
slug: 'firebase-database',
},
{
icon: 'tabler-brand-amazon',
color: 'warning',
title: 'AWS',
subtitle: 'Amazon Fast NoSQL Database',
slug: 'aws-database',
},
{
icon: 'tabler-database',
color: 'info',
title: 'MySQL',
subtitle: 'Basic MySQL database',
slug: 'mysql-database',
},
]
const createAppData = ref({
category: 'crm-application',
framework: 'vue-framework',
database: 'firebase-database',
cardNumber: null,
cardName: '',
cardExpiry: '',
cardCvv: '',
isSave: false,
})
const dialogVisibleUpdate = val => {
emit('update:isDialogVisible', val)
currentStep.value = 0
}
watch(props, () => {
if (!props.isDialogVisible)
currentStep.value = 0
})
const onSubmit = () => {
// eslint-disable-next-line no-alert
alert('submitted...!!')
emit('updatedData', createAppData.value)
}
</script>
<template>
<VDialog
:model-value="props.isDialogVisible"
max-width="900"
@update:model-value="dialogVisibleUpdate"
>
<!-- 👉 dialog close btn -->
<DialogCloseBtn
size="small"
@click="emit('update:isDialogVisible', false)"
/>
<VCard class="create-app-dialog">
<VCardText class="pa-5 pa-sm-10">
<h5 class="text-h5 text-center mb-2">
Create App
</h5>
<p class="text-sm text-center mb-8">
Provide data with this form to create your app.
</p>
<VRow>
<VCol
cols="12"
sm="5"
md="4"
lg="3"
>
<AppStepper
v-model:current-step="currentStep"
direction="vertical"
:items="createApp"
icon-size="24"
class="stepper-icon-step-bg"
/>
</VCol>
<VCol
cols="12"
sm="7"
md="8"
lg="9"
>
<VWindow
v-model="currentStep"
class="disable-tab-transition stepper-content"
>
<!-- 👉 category -->
<VWindowItem>
<AppTextField label="Application Name" />
<h6 class="text-h6 my-4">
Category
</h6>
<VRadioGroup v-model="createAppData.category">
<VList class="card-list">
<VListItem
v-for="category in categories"
:key="category.title"
@click="createAppData.category = category.slug"
>
<template #prepend>
<VAvatar
size="48"
rounded
variant="tonal"
:color="category.color"
:icon="category.icon"
/>
</template>
<VListItemTitle class="mb-1">
{{ category.title }}
</VListItemTitle>
<VListItemSubtitle>
{{ category.subtitle }}
</VListItemSubtitle>
<template #append>
<VRadio :value="category.slug" />
</template>
</VListItem>
</VList>
</VRadioGroup>
</VWindowItem>
<!-- 👉 Frameworks -->
<VWindowItem>
<h6 class="text-h6 mb-4">
Select Framework
</h6>
<VRadioGroup v-model="createAppData.framework">
<VList class="card-list">
<VListItem
v-for="framework in frameworks"
:key="framework.title"
@click="createAppData.framework = framework.slug"
>
<template #prepend>
<VAvatar
size="48"
rounded
variant="tonal"
:color="framework.color"
>
<VIcon :icon="framework.icon" />
</VAvatar>
</template>
<VListItemTitle class="mb-1">
{{ framework.title }}
</VListItemTitle>
<VListItemSubtitle>
{{ framework.subtitle }}
</VListItemSubtitle>
<template #append>
<VRadio :value="framework.slug" />
</template>
</VListItem>
</VList>
</VRadioGroup>
</VWindowItem>
<!-- 👉 Database Engine -->
<VWindowItem>
<AppTextField label="Database Name" />
<h6 class="text-h6 my-4">
Select Database Engine
</h6>
<VRadioGroup v-model="createAppData.database">
<VList class="card-list">
<VListItem
v-for="database in databases"
:key="database.title"
@click="createAppData.database = database.slug"
>
<template #prepend>
<VAvatar
size="48"
rounded
variant="tonal"
:color="database.color"
>
<VIcon :icon="database.icon" />
</VAvatar>
</template>
<VListItemTitle class="mb-1">
{{ database.title }}
</VListItemTitle>
<VListItemSubtitle>
{{ database.subtitle }}
</VListItemSubtitle>
<template #append>
<VRadio :value="database.slug" />
</template>
</VListItem>
</VList>
</VRadioGroup>
</VWindowItem>
<!-- 👉 Billing form -->
<VWindowItem>
<h6 class="text-h6 mb-4">
Payment Details
</h6>
<VForm>
<VRow>
<VCol cols="12">
<AppTextField
v-model="createAppData.cardNumber"
label="Card Number"
type="number"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="createAppData.cardName"
label="Name on Card"
/>
</VCol>
<VCol
cols="6"
md="3"
>
<AppTextField
v-model="createAppData.cardExpiry"
label="Expiry"
/>
</VCol>
<VCol
cols="6"
md="3"
>
<AppTextField
v-model="createAppData.cardCvv"
label="CVV"
/>
</VCol>
<VCol cols="12">
<VSwitch
v-model="createAppData.isSave"
label="Save Card for future billing?"
/>
</VCol>
</VRow>
</VForm>
</VWindowItem>
<VWindowItem class="text-center">
<h6 class="text-h6 mb-2">
Submit 🥳
</h6>
<p class="text-sm mb-6">
Submit to kickstart your project.
</p>
<VImg
:src="laptopGirl"
width="176"
class="mx-auto"
/>
</VWindowItem>
</VWindow>
<div class="d-flex justify-space-between mt-8">
<VBtn
variant="tonal"
color="secondary"
:disabled="currentStep === 0"
@click="currentStep--"
>
<VIcon
icon="tabler-arrow-left"
start
class="flip-in-rtl"
/>
Previous
</VBtn>
<VBtn
v-if="createApp.length - 1 === currentStep"
color="success"
@click="onSubmit"
>
submit
<VIcon
icon="tabler-check"
end
class="flip-in-rtl"
/>
</VBtn>
<VBtn
v-else
@click="currentStep++"
>
Next
<VIcon
icon="tabler-arrow-right"
end
class="flip-in-rtl"
/>
</VBtn>
</div>
</VCol>
</VRow>
</VCardText>
</VCard>
</VDialog>
</template>
<style lang="scss">
.stepper-content .card-list {
--v-card-list-gap: 24px;
}
</style>
@@ -0,0 +1,93 @@
<script setup>
const props = defineProps({
mobileNumber: {
type: String,
required: false,
},
isDialogVisible: {
type: Boolean,
required: true,
},
})
const emit = defineEmits([
'update:isDialogVisible',
'submit',
])
const phoneNumber = ref(structuredClone(toRaw(props.mobileNumber)))
const formSubmit = () => {
if (phoneNumber.value) {
emit('submit', phoneNumber.value)
emit('update:isDialogVisible', false)
}
}
const resetPhoneNumber = () => {
phoneNumber.value = structuredClone(toRaw(props.mobileNumber))
emit('update:isDialogVisible', false)
}
const dialogModelValueUpdate = val => {
emit('update:isDialogVisible', val)
}
</script>
<template>
<VDialog
max-width="787"
:model-value="props.isDialogVisible"
@update:model-value="dialogModelValueUpdate"
>
<!-- Dialog close btn -->
<DialogCloseBtn @click="dialogModelValueUpdate(false)" />
<VCard class="pa-5 pa-sm-8">
<VCardItem class="text-start">
<VCardTitle class="text-h6 font-weight-medium mb-2">
Verify Your Mobile Number for SMS
</VCardTitle>
<VCardSubtitle>
<span class="text-base">
Enter your mobile phone number with country code and we will send you a verification code.
</span>
</VCardSubtitle>
</VCardItem>
<VCardText class="pt-6">
<VForm @submit.prevent="() => {}">
<AppTextField
v-model="phoneNumber"
name="mobile"
label="Phone Number"
type="number"
placeholder="202 555 0111"
class="mb-5"
/>
<div class="d-flex flex-wrap justify-end gap-4">
<VBtn
color="secondary"
variant="tonal"
@click="resetPhoneNumber"
>
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>
@@ -0,0 +1,34 @@
<script setup>
const props = defineProps({
isDialogVisible: {
type: Boolean,
required: true,
},
})
const emit = defineEmits(['update:isDialogVisible'])
const dialogVisibleUpdate = val => {
emit('update:isDialogVisible', val)
}
</script>
<template>
<VDialog
:model-value="props.isDialogVisible"
class="v-dialog-xl"
@update:model-value="dialogVisibleUpdate"
>
<!-- 👉 Dialog close btn -->
<DialogCloseBtn @click="$emit('update:isDialogVisible', false)" />
<VCard class="pricing-dialog pa-5 pa-sm-8">
<VCardText>
<AppPricing
title="Subscription Plan"
md="4"
/>
</VCardText>
</VCard>
</VDialog>
</template>
@@ -0,0 +1,182 @@
<script setup>
const props = defineProps({
isDialogVisible: {
type: Boolean,
required: true,
},
})
const emit = defineEmits(['update:isDialogVisible'])
const dialogVisibleUpdate = val => {
emit('update:isDialogVisible', val)
}
const referAndEarnSteps = [
{
icon: 'tabler-send',
title: 'Send Invitation 👍🏻',
subtitle: 'Send your referral link to your friend',
},
{
icon: 'tabler-rocket',
title: 'Registration 😎',
subtitle: 'Let them register to our services',
},
{
icon: 'tabler-keyboard',
title: 'Free Trial 🎉',
subtitle: 'Your friend will get 30 days free trial',
},
]
</script>
<template>
<VDialog
:model-value="props.isDialogVisible"
max-width="740"
@update:model-value="dialogVisibleUpdate"
>
<!-- 👉 Dialog close btn -->
<DialogCloseBtn @click="$emit('update:isDialogVisible', false)" />
<VCard class="refer-and-earn-dialog">
<VCardText class="px-5 px-sm-16 pt-16 pb-10">
<h5 class="text-h5 text-center mb-3">
Refer & Earn
</h5>
<p class="text-sm-body-1 text-center">
Invite your friend to vuexy, if they sign up, you and your friend will get 30 days free trial
</p>
<VRow class="text-center mt-8">
<VCol
v-for="step in referAndEarnSteps"
:key="step.title"
cols="12"
sm="4"
>
<VAvatar
variant="tonal"
size="82"
color="primary"
rounded
>
<VIcon
size="40"
:icon="step.icon"
/>
</VAvatar>
<h6 class="text-lg mt-4 mb-2">
{{ step.title }}
</h6>
<span class="text-base">{{ step.subtitle }}</span>
</VCol>
</VRow>
</VCardText>
<VDivider />
<VCardText class="px-5 px-sm-16 pt-10 pb-16">
<h6 class="text-lg mb-4">
Invite your friends
</h6>
<p class="mb-1 text-sm">
Enter your friend's email address and invite them to join Vuexy 😍
</p>
<VForm
class="d-flex align-center gap-4"
@submit.prevent="() => {}"
>
<AppTextField
density="compact"
placeholder="johnDoe@gmail.com"
/>
<VBtn type="submit">
Submit
</VBtn>
</VForm>
<h6 class="text-h6 mb-4 mt-7">
Share the referral link
</h6>
<p class="mb-1 text-sm">
You can also copy and send it or share it on your social media. 🚀
</p>
<VForm
class="d-flex align-center flex-wrap gap-3"
@submit.prevent="() => {}"
>
<AppTextField
density="compact"
placeholder="http://referral.link"
class="refer-link-input me-1"
>
<template #append-inner>
<VBtn variant="text">
COPY LINK
</VBtn>
</template>
</AppTextField>
<div class="d-flex gap-3">
<VBtn
icon
class="rounded"
color="#3B5998"
size="38"
>
<VIcon
color="white"
icon="tabler-brand-facebook"
size="22"
/>
</VBtn>
<VBtn
icon
class="rounded"
color="#55ACEE"
size="38"
>
<VIcon
color="white"
icon="tabler-brand-twitter"
size="22"
/>
</VBtn>
<VBtn
icon
class="rounded"
color="#007BB6"
size="38"
>
<VIcon
color="white"
icon="tabler-brand-linkedin"
size="22"
/>
</VBtn>
</div>
</VForm>
</VCardText>
</VCard>
</VDialog>
</template>
<style lang="scss">
.refer-link-input {
.v-field--appended {
padding-inline-end: 0;
}
.v-field__append-inner {
padding-block-start: 0.125rem;
}
}
</style>

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