first commit

This commit is contained in:
Inshal
2024-05-29 22:34:28 +05:00
commit e63fc41a20
1470 changed files with 174828 additions and 0 deletions

View File

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

View File

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

View File

@@ -0,0 +1,390 @@
<script setup>
import stepperCheck from '@images/svg/stepper-check.svg'
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,
},
align: {
type: String,
required: false,
default: 'default',
},
})
const emit = defineEmits(['update:currentStep'])
const currentStep = ref(props.currentStep || 0)
const activeOrCompletedStepsClasses = computed(() => index => index < currentStep.value ? 'stepper-steps-completed' : index === currentStep.value ? 'stepper-steps-active' : '')
const isHorizontalAndNotLastStep = computed(() => index => props.direction === 'horizontal' && props.items.length - 1 !== index)
// check if validation is enabled
const isValidationEnabled = computed(() => {
return props.isActiveStepValid !== undefined
})
watchEffect(() => {
if (props.currentStep !== undefined && props.currentStep < props.items.length && props.currentStep >= 0)
currentStep.value = props.currentStep
emit('update:currentStep', currentStep.value)
})
</script>
<template>
<VSlideGroup
v-model="currentStep"
class="app-stepper"
show-arrows
:direction="props.direction"
:class="`app-stepper-${props.align} ${props.items[0].icon ? 'app-stepper-icons' : ''}`"
>
<VSlideGroupItem
v-for="(item, index) in props.items"
:key="item.title"
:value="index"
>
<div
class="cursor-pointer app-stepper-step 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-3 step-wrapper"
:class="[props.direction === 'horizontal' && 'flex-column']"
>
<div class="stepper-icon">
<template v-if="typeof item.icon === 'object'">
<Component :is="item.icon" />
</template>
<VIcon
v-else
:icon="item.icon"
:size="item.size || props.iconSize"
/>
</div>
<div>
<p class="stepper-title font-weight-medium text-base mb-1">
{{ item.title }}
</p>
<p
v-if="item.subtitle"
class="stepper-subtitle text-sm mb-0"
>
{{ item.subtitle }}
</p>
</div>
</div>
<!-- 👉 append chevron -->
<VIcon
v-if="isHorizontalAndNotLastStep(index)"
class="flip-in-rtl stepper-chevron-indicator mx-6"
size="18"
icon="ri-arrow-right-s-line"
/>
</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="ri-error-warning-line"
size="24"
color="error"
/>
</template>
<!-- 👉 step completed icon -->
<component
:is="stepperCheck"
v-else
class="stepper-step-icon"
/>
</div>
<!-- 👉 Step Number -->
<h4 :class="`${!item.subtitle ? 'text-h6' : 'text-h4'} step-number`">
{{ (index + 1).toString().padStart(2, '0') }}
</h4>
</div>
<!-- 👉 title and subtitle -->
<div
class="app-stepper-title-wrapper"
style="line-height: 0;"
>
<h6 class="text-base font-weight-medium step-title">
{{ item.title }}
</h6>
<p
v-if="item.subtitle"
class="text-sm step-subtitle mb-0"
>
{{ item.subtitle }}
</p>
</div>
<!-- 👉 stepper step line -->
<div
v-if="isHorizontalAndNotLastStep(index)"
class="stepper-step-line"
/>
</div>
<div
v-if="props.direction === 'vertical' && props.items.length - 1 !== index"
class="stepper-step-line vertical"
/>
</template>
<!-- !SECTION -->
</div>
</VSlideGroupItem>
</VSlideGroup>
</template>
<style lang="scss">
@use "@core-scss/base/mixins.scss";
/* stylelint-disable no-descending-specificity */
.app-stepper {
&.app-stepper-default:not(.app-stepper-icons) .app-stepper-step:not(:last-child) {
inline-size: 100%;
}
// 👉 stepper step with icon and default
.v-slide-group__content {
.stepper-step-indicator {
border: .1875rem 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;
opacity: var(--v-activated-opacity);
}
.stepper-step-line:not(.vertical) {
inline-size: 100%;
min-inline-size: 3rem;
}
.stepper-step-line.vertical {
border-radius: 1.25rem;
block-size: 1.25rem;
inline-size: 0.1875rem;
margin-inline-start: 0.625rem;
}
.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 {
border-width: 0.3125rem;
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;
}
}
.app-stepper-step:not(.stepper-steps-active,.stepper-steps-completed) {
.step-number{
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
}
}
}
.app-stepper-title-wrapper{
text-wrap: nowrap;
}
// 👉 stepper step with bg color
&.stepper-icon-step-bg {
.v-slide-group__content{
row-gap: 1.5rem;
}
.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-hover-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;
line-height: 1.375rem;
}
.stepper-subtitle {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.8125rem;
font-weight: 400;
line-height: 1.25rem;
}
}
.stepper-steps-active {
.stepper-icon-step {
.stepper-icon {
background-color: rgb(var(--v-theme-primary));
color: rgba(var(--v-theme-on-primary));
@include mixins.elevation(2);
}
}
}
.stepper-steps-completed {
.stepper-icon-step {
.stepper-icon {
background: rgba(var(--v-theme-primary), 0.16);
color: rgba(var(--v-theme-primary));
}
}
}
}
// 👉 stepper alignment
&.app-stepper-default {
.v-slide-group__content {
justify-content: space-between;
}
}
&.app-stepper-center {
.v-slide-group__content {
justify-content: center;
}
}
&.app-stepper- {
.v-slide-group__content {
justify-content: start;
}
}
&.app-stepper-end {
.v-slide-group__content {
justify-content: end;
}
}
&.app-stepper-icons {
.app-stepper-step:not(.stepper-steps-active,.stepper-steps-completed) {
.stepper-title {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
}
&:not(.stepper-icon-step-bg) {
.step-wrapper {
padding-block: 1.25rem;
padding-inline: 1.875rem;
}
}
&.v-slide-group--vertical{
.step-wrapper {
padding-inline: 0;
}
}
}
}
</style>

View File

@@ -0,0 +1,85 @@
<script setup>
const buyNowUrl = typeof window !== 'undefined' && 'isMarketplace' in window && window.isMarketplace ? 'https://store.vuetifyjs.com/products/materio-vuetify-vuejs-admin-template' : 'https://themeselection.com/item/materio-vuetify-vuejs-laravel-admin-template/'
</script>
<template>
<a
className="buy-now-button"
role="button"
rel="noopener noreferrer"
:href="buyNowUrl"
target="_blank"
>
Buy Now
<span className="button-inner" />
</a>
</template>
<style lang="scss" scoped>
.buy-now-button,
.button-inner {
display: inline-flex;
box-sizing: border-box;
align-items: center;
justify-content: center;
border: 0;
border-radius: 6px;
margin: 0;
animation: anime 12s linear infinite;
appearance: none;
background: linear-gradient(-45deg, #ffa63d, #ff3d77, #338aff, #3cf0c5);
background-size: 600%;
color: rgba(255, 255, 255, 90%);
cursor: pointer;
font-size: 0.9375rem;
font-weight: 500;
letter-spacing: 0.43px;
line-height: 1.2;
min-inline-size: 50px;
outline: 0;
padding-block: 0.625rem;
padding-inline: 1.25rem;
text-decoration: none;
text-transform: none;
vertical-align: middle;
}
.buy-now-button {
position: fixed;
z-index: 999;
inset-block-end: 5%;
inset-inline-end: 87px;
&:hover {
color: white;
text-decoration: none;
}
.button-inner {
position: absolute;
z-index: -1;
filter: blur(12px);
inset: 0;
opacity: 0;
transition: opacity 200ms ease-in-out;
}
&:not(:hover) .button-inner {
opacity: 0.8;
}
}
@keyframes anime {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
</style>

View File

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

View File

@@ -0,0 +1,23 @@
<script setup>
const props = defineProps({
icon: {
type: String,
required: false,
default: 'ri-close-line',
},
iconSize: {
type: String,
required: false,
default: '24',
},
})
</script>
<template>
<IconBtn class="v-dialog-close-btn">
<VIcon
:icon="props.icon"
:size="props.iconSize"
/>
</IconBtn>
</template>

View File

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

View File

@@ -0,0 +1,28 @@
<script setup>
const props = defineProps({
menuList: {
type: Array,
required: false,
},
itemProps: {
type: Boolean,
required: false,
},
})
</script>
<template>
<IconBtn>
<VIcon icon="ri-more-2-line" />
<VMenu
v-if="props.menuList"
activator="parent"
>
<VList
:items="props.menuList"
:item-props="props.itemProps"
/>
</VMenu>
</IconBtn>
</template>

View File

@@ -0,0 +1,220 @@
<script setup>
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
const props = defineProps({
notifications: {
type: Array,
required: true,
},
badgeProps: {
type: Object,
required: false,
default: undefined,
},
location: {
type: null,
required: false,
default: 'bottom end',
},
})
const emit = defineEmits([
'read',
'unread',
'remove',
'click:notification',
])
const isAllMarkRead = computed(() => 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 totalUnreadNotifications = computed(() => props.notifications.filter(item => !item.isSeen).length)
</script>
<template>
<IconBtn id="notification-btn">
<VBadge
dot
v-bind="props.badgeProps"
:model-value="props.notifications.some(n => !n.isSeen)"
color="error"
bordered
offset-x="1"
offset-y="1"
>
<VIcon icon="ri-notification-2-line" />
</VBadge>
<VMenu
activator="parent"
width="380"
:location="props.location"
offset="15px"
:close-on-content-click="false"
>
<VCard class="d-flex flex-column">
<!-- 👉 Header -->
<VCardItem class="notification-section">
<h5 class="text-h5 text-truncate">
Notifications
</h5>
<template #append>
<VChip
v-show="!!isAllMarkRead"
size="small"
class="me-3"
variant="tonal"
color="primary"
>
{{ totalUnreadNotifications }} new
</VChip>
<IconBtn
v-show="props.notifications.length"
@click="markAllReadOrUnread"
>
<VIcon
color="high-emphasis"
:icon="!isAllMarkRead ? 'ri-mail-line' : 'ri-mail-open-line' "
/>
<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: 27rem"
>
<VList class="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 py-3"
@click="$emit('click:notification', notification)"
>
<!-- Slot: Prepend -->
<!-- Handles Avatar: Image, Icon, Text -->
<div class="d-flex align-start gap-3">
<VAvatar
size="40"
:color="notification.color && !notification.img ? 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>
<div>
<h6 class="text-h6 mb-1">
{{ notification.title }}
</h6>
<p
class="text-body-2 mb-2"
style="letter-spacing: 0.4px !important; line-height: 18px;"
>
{{ notification.subtitle }}
</p>
<p
class="text-sm text-disabled mb-0"
style="letter-spacing: 0.4px !important; line-height: 18px;"
>
{{ notification.time }}
</p>
</div>
<VSpacer />
<div class="d-flex flex-column align-end gap-2">
<VIcon
:color="!notification.isSeen ? 'primary' : '#a8aaae'"
:class="`${notification.isSeen ? 'visible-in-hover' : ''} ms-1`"
size="10"
icon="ri-circle-fill"
@click.stop="$emit(notification.isSeen ? 'unread' : 'read', [notification.id])"
/>
<div style="block-size: 20px; inline-size: 20px;">
<VIcon
size="20"
icon="ri-close-line"
color="secondary"
class="visible-in-hover"
@click="$emit('remove', notification.id)"
/>
</div>
</div>
</div>
</VListItem>
</template>
<VListItem
v-show="!props.notifications.length"
class="text-center text-medium-emphasis"
style="block-size: 56px;"
>
<VListItemTitle>No Notification Found!</VListItemTitle>
</VListItem>
</VList>
</PerfectScrollbar>
<VDivider />
<!-- 👉 Footer -->
<VCardText
v-show="props.notifications.length"
class="pa-4"
>
<VBtn
block
size="small"
>
View All Notifications
</VBtn>
</VCardText>
</VCard>
</VMenu>
</IconBtn>
</template>
<style lang="scss">
.notification-section {
padding: 14px !important;
}
.list-item-hover-class {
.visible-in-hover {
display: none;
}
&:hover {
.visible-in-hover {
display: block;
}
}
}
</style>

View File

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

View File

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

View File

@@ -0,0 +1,621 @@
<script setup>
import { useStorage } from '@vueuse/core'
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
import { useTheme } from 'vuetify'
import {
staticPrimaryColor,
staticPrimaryDarkenColor,
} from '@/plugins/vuetify/theme'
import {
Direction,
Layout,
Skins,
Theme,
} from '@core/enums'
import { useConfigStore } from '@core/stores/config'
import {
AppContentLayoutNav,
ContentWidth,
} from '@layouts/enums'
import {
cookieRef,
namespaceConfig,
} from '@layouts/stores/config'
import { themeConfig } from '@themeConfig'
import borderSkinDark from '@images/customizer-icons/border-dark.svg'
import borderSkinLight from '@images/customizer-icons/border-light.svg'
import collapsedDark from '@images/customizer-icons/collapsed-dark.svg'
import collapsedLight from '@images/customizer-icons/collapsed-light.svg'
import compactDark from '@images/customizer-icons/compact-dark.svg'
import compactLight from '@images/customizer-icons/compact-light.svg'
import defaultSkinDark from '@images/customizer-icons/default-dark.svg'
import defaultSkinLight from '@images/customizer-icons/default-light.svg'
import horizontalDark from '@images/customizer-icons/horizontal-dark.svg'
import horizontalLight from '@images/customizer-icons/horizontal-light.svg'
import ltrDark from '@images/customizer-icons/ltr-dark.svg'
import ltrLight from '@images/customizer-icons/ltr-light.svg'
import rtlDark from '@images/customizer-icons/rtl-dark.svg'
import rtlLight from '@images/customizer-icons/rtl-light.svg'
import wideDark from '@images/customizer-icons/wide-dark.svg'
import wideLight from '@images/customizer-icons/wide-light.svg'
const isNavDrawerOpen = ref(false)
const configStore = useConfigStore()
const vuetifyTheme = useTheme()
const colors = [
{
main: staticPrimaryColor,
darken: '#7E4EE6',
},
{
main: '#0D9394',
darken: '#0C8485',
},
{
main: '#FFB400',
darken: '#E6A200',
},
{
main: '#FF4C51',
darken: '#E64449',
},
{
main: '#16B1FF',
darken: '#149FE6',
},
]
const customPrimaryColor = ref('#ffffff')
watch(() => configStore.theme, () => {
const cookiePrimaryColor = cookieRef(`${ vuetifyTheme.name.value }ThemePrimaryColor`, null).value
if (cookiePrimaryColor && !colors.some(color => color.main === cookiePrimaryColor))
customPrimaryColor.value = cookiePrimaryColor
}, { immediate: true })
const setPrimaryColor = useDebounceFn(color => {
vuetifyTheme.themes.value[vuetifyTheme.name.value].colors.primary = color.main
vuetifyTheme.themes.value[vuetifyTheme.name.value].colors['primary-darken-1'] = color.darken
cookieRef(`${ vuetifyTheme.name.value }ThemePrimaryColor`, null).value = color.main
cookieRef(`${ vuetifyTheme.name.value }ThemePrimaryDarkenColor`, null).value = color.darken
useStorage(namespaceConfig('initial-loader-color'), null).value = color.main
}, 100)
const defaultSkin = useGenerateImageVariant(defaultSkinLight, defaultSkinDark)
const borderSkin = useGenerateImageVariant(borderSkinLight, borderSkinDark)
const collapsed = useGenerateImageVariant(collapsedLight, collapsedDark)
const compactContent = useGenerateImageVariant(compactLight, compactDark)
const wideContent = useGenerateImageVariant(wideLight, wideDark)
const ltrImg = useGenerateImageVariant(ltrLight, ltrDark)
const rtlImg = useGenerateImageVariant(rtlLight, rtlDark)
const horizontalImg = useGenerateImageVariant(horizontalLight, horizontalDark)
const themeMode = computed(() => {
return [
{
bgImage: 'ri-sun-line',
value: Theme.Light,
label: 'Light',
},
{
bgImage: 'ri-moon-clear-line',
value: Theme.Dark,
label: 'Dark',
},
{
bgImage: 'ri-computer-line',
value: Theme.System,
label: 'System',
},
]
})
const themeSkin = computed(() => {
return [
{
bgImage: defaultSkin.value,
value: Skins.Default,
label: 'Default',
},
{
bgImage: borderSkin.value,
value: Skins.Bordered,
label: 'Bordered',
},
]
})
const currentLayout = ref(configStore.isVerticalNavCollapsed ? 'collapsed' : configStore.appContentLayoutNav)
const layouts = computed(() => {
return [
{
bgImage: defaultSkin.value,
value: Layout.Vertical,
label: 'Vertical',
},
{
bgImage: collapsed.value,
value: Layout.Collapsed,
label: 'Collapsed',
},
{
bgImage: horizontalImg.value,
value: Layout.Horizontal,
label: 'Horizontal',
},
]
})
watch(currentLayout, () => {
if (currentLayout.value === 'collapsed') {
configStore.isVerticalNavCollapsed = true
configStore.appContentLayoutNav = AppContentLayoutNav.Vertical
} else {
configStore.isVerticalNavCollapsed = false
configStore.appContentLayoutNav = currentLayout.value
}
})
watch(() => configStore.isVerticalNavCollapsed, () => {
currentLayout.value = configStore.isVerticalNavCollapsed ? 'collapsed' : configStore.appContentLayoutNav
})
const contentWidth = computed(() => {
return [
{
bgImage: compactContent.value,
value: ContentWidth.Boxed,
label: 'Compact',
},
{
bgImage: wideContent.value,
value: ContentWidth.Fluid,
label: 'Wide',
},
]
})
const currentDir = ref(configStore.isAppRTL ? 'rtl' : 'ltr')
const direction = computed(() => {
return [
{
bgImage: ltrImg.value,
value: Direction.Ltr,
label: 'Left to right',
},
{
bgImage: rtlImg.value,
value: Direction.Rtl,
label: 'Right to left',
},
]
})
watch(currentDir, () => {
if (currentDir.value === 'rtl')
configStore.isAppRTL = true
else
configStore.isAppRTL = false
})
const isCookieHasAnyValue = ref(false)
const { locale } = useI18n({ useScope: 'global' })
const isActiveLangRTL = computed(() => {
const lang = themeConfig.app.i18n.langConfig.find(l => l.i18nLang === locale.value)
return lang?.isRTL ?? false
})
watch([
() => vuetifyTheme.current.value.colors.primary,
configStore.$state,
locale,
], () => {
const initialConfigValue = [
staticPrimaryColor,
staticPrimaryColor,
themeConfig.app.theme,
themeConfig.app.skin,
themeConfig.verticalNav.isVerticalNavSemiDark,
themeConfig.verticalNav.isVerticalNavCollapsed,
themeConfig.app.contentWidth,
isActiveLangRTL.value,
themeConfig.app.contentLayoutNav,
]
const themeConfigValue = [
vuetifyTheme.themes.value.light.colors.primary,
vuetifyTheme.themes.value.dark.colors.primary,
configStore.theme,
configStore.skin,
configStore.isVerticalNavSemiDark,
configStore.isVerticalNavCollapsed,
configStore.appContentWidth,
configStore.isAppRTL,
configStore.appContentLayoutNav,
]
currentDir.value = configStore.isAppRTL ? 'rtl' : 'ltr'
isCookieHasAnyValue.value = JSON.stringify(themeConfigValue) !== JSON.stringify(initialConfigValue)
}, {
deep: true,
immediate: true,
})
const resetCustomizer = async () => {
if (isCookieHasAnyValue.value) {
vuetifyTheme.themes.value.light.colors.primary = staticPrimaryColor
vuetifyTheme.themes.value.dark.colors.primary = staticPrimaryColor
vuetifyTheme.themes.value.light.colors['primary-darken-1'] = staticPrimaryDarkenColor
vuetifyTheme.themes.value.dark.colors['primary-darken-1'] = staticPrimaryDarkenColor
configStore.theme = themeConfig.app.theme
configStore.skin = themeConfig.app.skin
configStore.isVerticalNavSemiDark = themeConfig.verticalNav.isVerticalNavSemiDark
configStore.appContentLayoutNav = themeConfig.app.contentLayoutNav
configStore.appContentWidth = themeConfig.app.contentWidth
configStore.isAppRTL = isActiveLangRTL.value
configStore.isVerticalNavCollapsed = themeConfig.verticalNav.isVerticalNavCollapsed
useStorage(namespaceConfig('initial-loader-color'), null).value = staticPrimaryColor
currentLayout.value = themeConfig.app.contentLayoutNav
configStore.theme = themeConfig.app.theme
configStore.skin = themeConfig.app.skin
configStore.isVerticalNavSemiDark = themeConfig.verticalNav.isVerticalNavSemiDark
configStore.appContentLayoutNav = themeConfig.app.contentLayoutNav
configStore.appContentWidth = themeConfig.app.contentWidth
configStore.isAppRTL = isActiveLangRTL.value
configStore.isVerticalNavCollapsed = themeConfig.verticalNav.isVerticalNavCollapsed
useStorage(namespaceConfig('initial-loader-color'), null).value = staticPrimaryColor
currentLayout.value = themeConfig.app.contentLayoutNav
cookieRef('lightThemePrimaryColor', null).value = null
cookieRef('darkThemePrimaryColor', null).value = null
cookieRef('lightThemePrimaryDarkenColor', null).value = null
cookieRef('darkThemePrimaryDarkenColor', null).value = null
await nextTick()
await nextTick()
isCookieHasAnyValue.value = false
customPrimaryColor.value = '#ffffff'
}
}
</script>
<template>
<div class="d-lg-block d-none">
<VBtn
icon
class="app-customizer-toggler rounded-s-xl rounded-0"
style="z-index: 1001;"
@click="isNavDrawerOpen = true"
>
<VIcon icon="ri-settings-3-line" />
</VBtn>
<VNavigationDrawer
v-model="isNavDrawerOpen"
temporary
touchless
border="none"
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>
<p class="text-body-2 mb-0">
Customize & Preview in Real Time
</p>
</div>
<div class="d-flex align-center gap-1">
<VBtn
icon
variant="text"
size="small"
color="medium-emphasis"
@click="resetCustomizer"
>
<VBadge
v-show="isCookieHasAnyValue"
dot
color="error"
offset-x="-29"
offset-y="-14"
/>
<VIcon
size="22"
icon="ri-refresh-line"
/>
</VBtn>
<VBtn
icon
variant="text"
color="medium-emphasis"
size="small"
@click="isNavDrawerOpen = false"
>
<VIcon
icon="ri-close-line"
size="22"
/>
</VBtn>
</div>
</div>
<VDivider />
<PerfectScrollbar
tag="ul"
:options="{ wheelPropagation: false }"
>
<!-- SECTION Theming -->
<CustomizerSection
title="Theming"
:divider="false"
>
<!-- 👉 Primary Color -->
<div class="d-flex flex-column gap-2">
<h6 class="text-h6">
Primary Color
</h6>
<div
class="d-flex app-customizer-primary-colors"
style="column-gap: 0.7rem; margin-block-start: 2px;"
>
<div
v-for="color in colors"
:key="color.main"
style="
border-radius: 0.375rem;
outline: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
padding-block: 0.5rem;
padding-inline: 0.625rem;"
class="primary-color-wrapper cursor-pointer"
:class="vuetifyTheme.current.value.colors.primary === color.main ? 'active' : ''"
:style="vuetifyTheme.current.value.colors.primary === color.main ? `outline-color: ${color.main}; outline-width:2px;` : `--v-color:${color.main}`"
@click="setPrimaryColor(color)"
>
<div
style="border-radius: 0.375rem;block-size: 2.125rem; inline-size: 1.9375rem;"
:style="{ backgroundColor: color.main }"
/>
</div>
<div
class="primary-color-wrapper cursor-pointer"
style="
border-radius: 0.375rem;
outline: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
padding-block: 0.5rem;
padding-inline: 0.625rem;"
:class="vuetifyTheme.current.value.colors.primary === customPrimaryColor ? 'active' : ''"
:style="vuetifyTheme.current.value.colors.primary === customPrimaryColor ? `outline-color: ${customPrimaryColor}; outline-width:2px;` : ''"
>
<VBtn
icon
size="small"
:color="vuetifyTheme.current.value.colors.primary === customPrimaryColor ? customPrimaryColor : $vuetify.theme.current.dark ? '#8692d029' : '#4b465c29'"
variant="flat"
style="border-radius: 0.375rem;"
>
<VIcon
size="20"
icon="ri-palette-line"
/>
</VBtn>
<VMenu
activator="parent"
:close-on-content-click="false"
>
<VList>
<VListItem>
<VColorPicker
v-model="customPrimaryColor"
mode="hex"
:modes="['hex']"
@update:model-value="setPrimaryColor({ main: customPrimaryColor, darken: customPrimaryColor })"
/>
</VListItem>
</VList>
</VMenu>
</div>
</div>
</div>
<!-- 👉 Theme -->
<div class="d-flex flex-column gap-3">
<h6 class="text-h6">
Theme
</h6>
<CustomRadiosWithImage
:key="configStore.theme"
v-model:selected-radio="configStore.theme"
:radio-content="themeMode"
:grid-column="{ cols: '4' }"
class="customizer-skins"
>
<template #label="item">
<span class="text-sm text-medium-emphasis mt-1">{{ item?.label }}</span>
</template>
<template #content="{ item }">
<div
class="customizer-skins-icon-wrapper d-flex align-center justify-center py-3 w-100"
style="min-inline-size: 100%;"
>
<VIcon
size="30"
:icon="item.bgImage"
color="high-emphasis"
/>
</div>
</template>
</CustomRadiosWithImage>
</div>
<!-- 👉 Skin -->
<div class="d-flex flex-column gap-3">
<h6 class="text-h6">
Skins
</h6>
<CustomRadiosWithImage
:key="configStore.skin"
v-model:selected-radio="configStore.skin"
:radio-content="themeSkin"
:grid-column="{ cols: '4' }"
>
<template #label="item">
<span class="text-sm text-medium-emphasis">{{ item?.label }}</span>
</template>
</CustomRadiosWithImage>
</div>
<!-- 👉 Semi Dark -->
<div
class="align-center justify-space-between"
:class="vuetifyTheme.global.name.value === 'light' && configStore.appContentLayoutNav === AppContentLayoutNav.Vertical ? 'd-flex' : 'd-none'"
>
<VLabel
for="customizer-semi-dark"
class="text-h6 text-high-emphasis"
>
Semi Dark Menu
</VLabel>
<div>
<VSwitch
id="customizer-semi-dark"
v-model="configStore.isVerticalNavSemiDark"
class="ms-2"
/>
</div>
</div>
</CustomizerSection>
<!-- !SECTION -->
<!-- SECTION LAYOUT -->
<CustomizerSection title="Layout">
<!-- 👉 Layouts -->
<div class="d-flex flex-column gap-3">
<h6 class="text-base font-weight-medium">
Layout
</h6>
<CustomRadiosWithImage
:key="currentLayout"
v-model:selected-radio="currentLayout"
:radio-content="layouts"
:grid-column="{ cols: '4' }"
>
<template #label="item">
<span class="text-sm text-medium-emphasis">{{ item.label }}</span>
</template>
</CustomRadiosWithImage>
</div>
<!-- 👉 Content Width -->
<div class="d-flex flex-column gap-3">
<h6 class="text-base font-weight-medium">
Content
</h6>
<CustomRadiosWithImage
:key="configStore.appContentWidth"
v-model:selected-radio="configStore.appContentWidth"
:radio-content="contentWidth"
:grid-column="{ cols: '4' }"
>
<template #label="item">
<span class="text-sm text-medium-emphasis">{{ item.label }}</span>
</template>
</CustomRadiosWithImage>
</div>
<!-- 👉 Direction -->
<div class="d-flex flex-column gap-3">
<h6 class="text-base font-weight-medium">
Direction
</h6>
<CustomRadiosWithImage
:key="currentDir"
v-model:selected-radio="currentDir"
:radio-content="direction"
:grid-column="{ cols: '4' }"
>
<template #label="item">
<span class="text-sm text-medium-emphasis">{{ item?.label }}</span>
</template>
</CustomRadiosWithImage>
</div>
</CustomizerSection>
<!-- !SECTION -->
</PerfectScrollbar>
</VNavigationDrawer>
</div>
</template>
<style lang="scss">
.app-customizer {
.customizer-section {
display: flex;
flex-direction: column;
padding: 1.25rem;
gap: 1.5rem;
}
.customizer-heading {
padding-block: 1rem;
padding-inline: 1.5rem;
}
.v-navigation-drawer__content {
display: flex;
flex-direction: column;
}
.v-label.custom-input.active {
border-color: transparent;
outline: 2px solid rgb(var(--v-theme-primary));
}
.v-label.custom-input:not(.active):hover{
border-color: rgba(var(--v-border-color), 0.22);
}
.customizer-skins{
.custom-input.active{
.customizer-skins-icon-wrapper {
background-color: rgba(var(--v-global-theme-primary), var(--v-selected-opacity));
}
}
}
.app-customizer-primary-colors {
.primary-color-wrapper:not(.active) {
&:hover{
outline-color: rgba(var(--v-border-color), 0.22) !important;
}
}
}
}
.app-customizer-toggler {
position: fixed !important;
inset-block-start: 20%;
inset-inline-end: 0;
transform: translateY(-50%);
}
</style>

View File

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

View File

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

View File

@@ -0,0 +1,472 @@
<script setup>
import FlatPickr from 'vue-flatpickr-component'
import { useTheme } from 'vuetify'
import {
VField,
filterFieldProps,
makeVFieldProps,
} from 'vuetify/lib/components/VField/VField'
import {
VInput,
makeVInputProps,
} from 'vuetify/lib/components/VInput/VInput'
import { filterInputAttrs } from 'vuetify/lib/util/helpers'
import { useConfigStore } from '@core/stores/config'
const props = defineProps({
autofocus: Boolean,
counter: [
Boolean,
Number,
String,
],
counterValue: Function,
prefix: String,
placeholder: String,
persistentPlaceholder: Boolean,
persistentCounter: Boolean,
suffix: String,
type: {
type: String,
default: 'text',
},
modelModifiers: Object,
...makeVInputProps({
density: 'comfortable',
hideDetails: 'auto',
}),
...makeVFieldProps({
variant: 'outlined',
color: 'primary',
}),
})
const emit = defineEmits([
'click:control',
'mousedown:control',
'update:focused',
'update:modelValue',
'click:clear',
])
defineOptions({
inheritAttrs: false,
})
const configStore = useConfigStore()
const attrs = useAttrs()
const [rootAttrs, compAttrs] = filterInputAttrs(attrs)
const {
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 vuetifyTheme = useTheme()
const vuetifyThemesName = Object.keys(vuetifyTheme.themes.value)
// Themes class added to flat-picker component for light and dark support
const updateThemeClassInCalendar = () => {
// Flatpickr don't render it's instance in mobile and device simulator
if (!refFlatPicker.value.fp.calendarContainer)
return
vuetifyThemesName.forEach(t => {
refFlatPicker.value.fp.calendarContainer.classList.remove(`v-theme--${ t }`)
})
refFlatPicker.value.fp.calendarContainer.classList.add(`v-theme--${ vuetifyTheme.global.name.value }`)
}
watch(() => configStore.theme, updateThemeClassInCalendar)
onMounted(() => {
updateThemeClassInCalendar()
})
const emitModelValue = val => {
emit('update:modelValue', val)
}
</script>
<template>
<div class="app-picker-field">
<VInput
v-bind="{ ...inputProps, ...rootAttrs }"
:model-value="modelValue"
:hide-details="props.hideDetails"
:class="[{
'v-text-field--prefixed': props.prefix,
'v-text-field--suffixed': props.suffix,
'v-text-field--flush-details': ['plain', 'underlined'].includes(props.variant),
}, props.class]"
class="position-relative v-text-field"
:style="props.style"
>
<template #default="{ id, isDirty, isValid, isDisabled, isReadonly }">
<!-- v-field -->
<VField
v-bind="{ ...fieldProps }"
:id="id.value"
role="textbox"
:active="focused || isDirty.value || isCalendarOpen"
:focused="focused || isCalendarOpen"
:dirty="isDirty.value || props.dirty"
:error="isValid.value === false"
:disabled="isDisabled.value"
@click:clear="onClear"
>
<template #default="{ props: vFieldProps }">
<div v-bind="vFieldProps">
<!-- flat-picker -->
<FlatPickr
v-if="!isInlinePicker"
v-bind="compAttrs"
ref="refFlatPicker"
:model-value="modelValue"
:placeholder="props.placeholder"
:readonly="isReadonly.value"
class="flat-picker-custom-style"
: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"
:readonly="isReadonly.value"
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";
@use "@styles/variables/_vuetify.scss" as vuetify;
.flat-picker-custom-style {
position: absolute;
color: inherit;
inline-size: 100%;
inset: 0;
outline: none;
padding-block: 0;
padding-inline: var(--v-field-padding-start);
}
$heading-color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));
$body-color: rgba(var(--v-theme-on-background), var(--v-medium-emphasis-opacity));
// hide the input when your picker is inline
input[altinputclass="inlinePicker"] {
display: none;
}
.flatpickr-calendar {
border-radius: vuetify.$border-radius-root;
background-color: rgb(var(--v-theme-surface));
inline-size: 16.875rem;
@include mixins.elevation(6);
.flatpickr-rContainer {
inline-size: 16.875rem;
.flatpickr-weekdays {
padding-inline: 0.5rem;
}
.flatpickr-days {
font-size: .9375rem;
min-inline-size: 16.875rem;
.dayContainer {
justify-content: center !important;
inline-size: 16.875rem !important;
min-inline-size: 16.875rem !important;
padding-block: 0 0.5rem;
.flatpickr-day {
block-size: 36px;
line-height: 36px;
margin-block-start: 0 !important;
max-inline-size: 36px;
}
}
}
}
.flatpickr-day {
color: $heading-color;
&.today {
border-color: transparent;
background-color: rgb(var(--v-theme-primary), var(--v-border-opacity));
color: rgba(var(--v-theme-primary));
&:hover {
border-color: transparent;
background-color: rgb(var(--v-theme-primary), var(--v-border-opacity));
color: rgba(var(--v-theme-primary));
}
}
&.selected,
&.selected:hover {
border-color: rgb(var(--v-theme-primary));
background: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
@include mixins.elevation(2);
}
&.inRange,
&.inRange:hover {
border: none;
background: rgba(var(--v-theme-primary), 0.1) !important;
box-shadow: none !important;
color: rgb(var(--v-theme-primary));
}
&.inRange.today {
background: rgba(var(--v-theme-primary), 0.24) !important;
}
&.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) {
color: rgba(var(--v-theme-on-background), var(--v-disabled-opacity));
}
&:hover {
border-color: transparent;
background: rgba(var(--v-theme-on-surface), var(--v-hover-opacity));
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
}
}
.flatpickr-weekday {
color: $heading-color;
font-size: 13px;
font-weight: 500;
}
&::after,
&::before {
display: none;
}
.flatpickr-months {
padding-block-start: 0.5rem;
.flatpickr-prev-month,
.flatpickr-next-month {
fill: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
svg {
block-size: 13px;
inline-size: 13px;
stroke: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
}
&:hover i,
&:hover svg {
fill: $body-color;
}
}
}
.flatpickr-current-month {
padding-block: 4px 0;
padding-inline: 0;
span.cur-month {
font-weight: 400;
}
}
&.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;
}
}
&.hasTime .flatpickr-time:first-child {
border-color: transparent;
}
}
// 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 {
input.flatpickr-hour {
font-weight: 400;
}
.flatpickr-am-pm,
.flatpickr-time-separator,
input {
color: $heading-color;
}
.numInputWrapper {
span {
&.arrowUp {
&::after {
border-block-end-color: rgb(var(--v-border-color));
}
}
&.arrowDown {
&::after {
border-block-start-color: rgb(var(--v-border-color));
}
}
}
}
}
// Added bg color for flatpickr input only as it has default readonly attribute
.flatpickr-input[readonly],
.flatpickr-input ~ .form-control[readonly],
.flatpickr-human-friendly[readonly] {
background-color: inherit;
}
// week sections
.flatpickr-weekdays {
margin-block: 8px;
}
// Month and year section
.flatpickr-current-month {
.flatpickr-monthDropdown-months {
appearance: none;
block-size: 24px;
}
.flatpickr-monthDropdown-months,
.numInputWrapper {
padding: 2px;
border-radius: 4px;
color: $heading-color;
font-size: 0.9375rem;
font-weight: 400;
transition: all 0.15s ease-out;
span {
display: none;
}
input.cur-year {
font-weight: 400 !important;
}
.flatpickr-monthDropdown-month {
background-color: rgb(var(--v-theme-surface));
}
}
}
.flatpickr-day.flatpickr-disabled,
.flatpickr-day.flatpickr-disabled:hover {
color: $body-color;
}
.flatpickr-months {
padding-block: 0.3rem;
padding-inline: 0;
.flatpickr-prev-month,
.flatpickr-next-month {
inset-block-start: .2rem !important;
}
.flatpickr-next-month {
inset-inline-end: 0.375rem !important;
}
.flatpickr-prev-month {
inset-inline-start: 0.25rem !important;
}
}
</style>

View File

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

View File

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

View File

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

View File

@@ -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 updateSelectedOption = value => {
if (value !== null)
emit('update:selectedRadio', value)
}
</script>
<template>
<VRadioGroup
v-if="props.radioContent"
:model-value="props.selectedRadio"
@update:model-value="updateSelectedOption"
>
<VRow>
<VCol
v-for="item in props.radioContent"
:key="item.title"
v-bind="gridColumn"
>
<VLabel
class="custom-input custom-radio rounded cursor-pointer"
:class="props.selectedRadio === item.value ? 'active' : ''"
>
<div>
<VRadio :value="item.value" />
</div>
<slot :item="item">
<div class="flex-grow-1">
<div class="d-flex align-center mb-2">
<h6 class="cr-title text-base">
{{ item.title }}
</h6>
<VSpacer />
<span
v-if="item.subtitle"
class="text-disabled text-sm"
>{{ item.subtitle }}</span>
</div>
<p class="text-sm text-medium-emphasis mb-0">
{{ item.desc }}
</p>
</div>
</slot>
</VLabel>
</VCol>
</VRow>
</VRadioGroup>
</template>
<style lang="scss" scoped>
.custom-radio {
display: flex;
align-items: flex-start;
gap: 0.25rem;
.v-radio {
margin-block-start: -0.45rem;
}
.cr-title {
font-weight: 500;
}
}
</style>

View File

@@ -0,0 +1,93 @@
<script setup>
const props = defineProps({
selectedRadio: {
type: String,
required: true,
},
radioContent: {
type: Array,
required: true,
},
gridColumn: {
type: null,
required: false,
},
})
const emit = defineEmits(['update:selectedRadio'])
const updateSelectedOption = value => {
if (value !== null)
emit('update:selectedRadio', value)
}
</script>
<template>
<VRadioGroup
v-if="props.radioContent"
:model-value="props.selectedRadio"
@update:model-value="updateSelectedOption"
>
<VRow>
<VCol
v-for="item in props.radioContent"
:key="item.title"
v-bind="gridColumn"
>
<VLabel
class="custom-input custom-radio-icon rounded cursor-pointer"
:class="props.selectedRadio === item.value ? 'active' : ''"
>
<slot :item="item">
<div class="d-flex flex-column align-center text-center gap-2">
<VIcon
size="28"
:icon="item.icon"
class="text-high-emphasis"
/>
<h6 class="cr-title text-base">
{{ item.title }}
</h6>
<p class="text-sm text-medium-emphasis 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.5rem;
.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>

View File

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

View File

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

View File

@@ -0,0 +1,116 @@
<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 = useCookie('preferredCodeLanguage', {
default: () => 'ts',
maxAge: COOKIE_MAX_AGE_1_YEAR,
})
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
:color="isCodeShown ? 'primary' : 'default'"
:class="isCodeShown ? '' : 'text-disabled'"
@click="isCodeShown = !isCodeShown"
>
<VIcon
size="20"
icon="ri-code-s-line"
/>
</IconBtn>
</template>
</VCardItem>
<slot v-if="noPadding" />
<VCardText v-else>
<slot />
</VCardText>
<VExpandTransition>
<div v-show="isCodeShown">
<VDivider />
<VCardText class="d-flex gap-y-3 flex-column">
<div class="d-flex justify-end">
<VBtnToggle
v-model="preferredCodeLanguage"
mandatory
density="compact"
>
<VBtn value="ts">
<!-- eslint-disable-next-line regex/invalid -->
<VIcon icon="mdi-language-typescript" />
</VBtn>
<VBtn value="js">
<!-- eslint-disable-next-line regex/invalid -->
<VIcon icon="mdi-language-javascript" />
</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 ? 'ri-check-line' : 'ri-file-copy-line'"
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>

View File

@@ -0,0 +1,84 @@
<script setup>
import { kFormatter } from '@core/utils/formatters'
const props = defineProps({
title: {
type: String,
required: true,
},
color: {
type: String,
required: false,
default: 'primary',
},
icon: {
type: String,
required: true,
},
stats: {
type: Number,
required: true,
},
change: {
type: Number,
required: true,
},
})
const isPositive = computed(() => Math.sign(props.change) === 1)
</script>
<template>
<VCard
variant="text"
border
>
<VCardText class="d-flex align-center">
<VAvatar
size="40"
rounded
class="elevation-2 me-4"
style="background-color: rgb(var(--v-theme-surface));"
>
<VIcon
:color="props.color"
:icon="props.icon"
:size="24"
/>
</VAvatar>
<div>
<div class="text-body-1">
{{ props.title }}
</div>
<div class="d-flex align-center flex-wrap">
<h5 class="text-h5">
{{ kFormatter(props.stats) }}
</h5>
<div
v-if="props.change"
:class="`${isPositive ? 'text-success' : 'text-error'} mt-1`"
>
<VIcon
:icon="isPositive ? 'ri-arrow-up-s-line' : 'ri-arrow-down-s-line'"
size="24"
/>
<span class="text-base">
{{ Math.abs(props.change) }}%
</span>
</div>
</div>
</div>
</VCardText>
</VCard>
</template>
<style lang="scss">
.skin--bordered {
.v-avatar {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)) !important;
box-shadow: none !important;
}
}
</style>

View File

@@ -0,0 +1,77 @@
<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,
},
change: {
type: Number,
required: true,
},
subtitle: {
type: String,
required: true,
},
})
const isPositive = computed(() => Math.sign(props.change) === 1)
</script>
<template>
<VCard>
<VCardText class="d-flex align-center">
<VAvatar
v-if="props.icon"
size="40"
:color="props.color"
class="elevation-2"
>
<VIcon
:icon="props.icon"
size="24"
/>
</VAvatar>
<VSpacer />
<MoreBtn class="me-n3 mt-n1" />
</VCardText>
<VCardText>
<h6 class="text-h6 mb-1">
{{ props.title }}
</h6>
<div
v-if="props.change"
class="d-flex align-center mb-1 flex-wrap"
>
<h4 class="text-h4 me-2">
{{ props.stats }}
</h4>
<div
:class="isPositive ? 'text-success' : 'text-error'"
class="text-body-1"
>
{{ isPositive ? `+${props.change}` : props.change }}%
</div>
</div>
<div class="text-body-2">
{{ props.subtitle }}
</div>
</VCardText>
</VCard>
</template>

View File

@@ -0,0 +1,65 @@
<script setup>
const props = defineProps({
title: {
type: String,
required: true,
},
value: {
type: String,
required: true,
},
change: {
type: Number,
required: true,
},
desc: {
type: String,
required: true,
},
icon: {
type: String,
required: true,
},
iconColor: {
type: String,
required: true,
},
})
</script>
<template>
<VCard>
<VCardText>
<div class="d-flex justify-space-between">
<div class="d-flex flex-column gap-y-1">
<div class="text-body-1 text-high-emphasis">
{{ title }}
</div>
<div>
<h5 class="text-h5">
{{ value }}
<span
class="text-base"
:class="change > 0 ? 'text-success' : 'text-error'"
>({{ prefixWithPlus(change) }}%)</span>
</h5>
</div>
<div class="text-body-2">
{{ desc }}
</div>
</div>
<VAvatar
:color="iconColor"
variant="tonal"
rounded
size="42"
>
<VIcon
:icon="icon"
size="26"
/>
</VAvatar>
</div>
</VCardText>
</VCard>
</template>

View File

@@ -0,0 +1,89 @@
<script setup>
const props = defineProps({
title: {
type: String,
required: true,
},
subtitle: {
type: String,
required: true,
},
stats: {
type: String,
required: true,
},
change: {
type: Number,
required: true,
},
image: {
type: String,
required: true,
},
color: {
type: String,
required: false,
default: 'primary',
},
})
const isPositive = computed(() => Math.sign(props.change) === 1)
</script>
<template>
<VCard class="overflow-visible position-relative">
<div class="d-flex">
<VCardText>
<h6 class="text-h6 mb-5">
{{ props.title }}
</h6>
<div class="d-flex align-center flex-wrap mb-3">
<h4 class="text-h4 me-2">
{{ props.stats }}
</h4>
<div
class="text-body-1"
:class="isPositive ? 'text-success' : 'text-error'"
>
{{ isPositive ? `+${props.change}` : props.change }}%
</div>
</div>
<VChip
v-if="props.subtitle"
:color="props.color"
size="small"
>
{{ props.subtitle }}
</VChip>
</VCardText>
<VSpacer />
<div class="illustrator-img">
<VImg
v-if="props.image"
:src="props.image"
:width="110"
/>
</div>
</div>
</VCard>
</template>
<style lang="scss">
.illustrator-img {
position: absolute;
inset-block-end: 0;
inset-inline-end: 5%;
}
@media (max-width: 1200px) and (min-width: 960px) {
.illustrator-img {
inset-block-end: 0;
inset-inline-end: 0;
}
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,680 @@
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 },
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: { colors: themeDisabledTextColor },
},
},
xaxis: {
tickAmount: 10,
axisBorder: { show: false },
axisTicks: { color: themeBorderColor },
crosshairs: {
stroke: { color: themeBorderColor },
},
labels: {
style: { colors: themeDisabledTextColor },
formatter: val => Number.parseFloat(val).toFixed(1),
},
},
}
}
export const getLineChartSimpleConfig = themeColors => {
const { themeBorderColor, themeDisabledTextColor } = colorVariables(themeColors)
return {
chart: {
parentHeightOffset: 0,
zoom: { enabled: false },
toolbar: { show: false },
},
colors: ['#ff9f43'],
stroke: { curve: 'straight' },
dataLabels: { enabled: false },
markers: {
strokeWidth: 7,
strokeOpacity: 1,
colors: ['#ff9f43'],
strokeColors: ['#fff'],
},
grid: {
padding: { top: -10 },
borderColor: themeBorderColor,
xaxis: {
lines: { show: true },
},
},
tooltip: {
custom(data) {
return `<div class='bar-chart pa-2'>
<span>${data.series[data.seriesIndex][data.dataPointIndex]}%</span>
</div>`
},
},
yaxis: {
labels: {
style: { colors: themeDisabledTextColor },
},
},
xaxis: {
axisBorder: { show: false },
axisTicks: { color: themeBorderColor },
crosshairs: {
stroke: { color: themeBorderColor },
},
labels: {
style: { colors: themeDisabledTextColor },
},
categories: [
'7/12',
'8/12',
'9/12',
'10/12',
'11/12',
'12/12',
'13/12',
'14/12',
'15/12',
'16/12',
'17/12',
'18/12',
'19/12',
'20/12',
'21/12',
],
},
}
}
export const getBarChartConfig = themeColors => {
const { themeBorderColor, themeDisabledTextColor } = colorVariables(themeColors)
return {
chart: {
parentHeightOffset: 0,
toolbar: { show: false },
},
colors: ['#00cfe8'],
dataLabels: { enabled: false },
plotOptions: {
bar: {
borderRadius: 8,
barHeight: '30%',
horizontal: true,
},
},
grid: {
borderColor: themeBorderColor,
xaxis: {
lines: { show: false },
},
padding: {
top: -10,
},
},
yaxis: {
labels: {
style: { colors: themeDisabledTextColor },
},
},
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 },
},
},
}
}
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 },
},
},
xaxis: {
type: 'datetime',
axisBorder: { show: false },
axisTicks: { color: themeBorderColor },
crosshairs: {
stroke: { color: themeBorderColor },
},
labels: {
style: { colors: themeDisabledTextColor },
},
},
}
}
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,
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: '1rem',
color: themeSecondaryTextColor,
},
total: {
show: true,
fontWeight: 400,
label: 'Comments',
fontSize: '1.125rem',
color: themePrimaryTextColor,
formatter(w) {
const totalValue = w.globals.seriesTotals.reduce((a, b) => {
return a + b
}, 0) / w.globals.series.length
if (totalValue % 1 === 0)
return `${totalValue}%`
else
return `${totalValue.toFixed(2)}%`
},
},
},
},
},
grid: {
padding: {
top: -30,
bottom: -25,
},
},
}
}
export const getDonutChartConfig = themeColors => {
const donutColors = {
series1: '#fdd835',
series2: '#00d4bd',
series3: '#826bf8',
series4: '#32baff',
series5: '#ffa1a1',
}
const { themeSecondaryTextColor, themePrimaryTextColor } = colorVariables(themeColors)
return {
stroke: { width: 0 },
labels: ['Operational', 'Networking', 'Hiring', 'R&D'],
colors: [donutColors.series1, donutColors.series5, donutColors.series3, donutColors.series2],
dataLabels: {
enabled: true,
formatter: val => `${Number.parseInt(val, 10)}%`,
},
legend: {
position: 'bottom',
markers: { offsetX: -3 },
labels: { colors: themeSecondaryTextColor },
itemMargin: {
vertical: 3,
horizontal: 10,
},
},
plotOptions: {
pie: {
donut: {
labels: {
show: true,
name: {
fontSize: '1.5rem',
},
value: {
fontSize: '1.5rem',
color: themeSecondaryTextColor,
formatter: val => `${Number.parseInt(val, 10)}`,
},
total: {
show: true,
fontSize: '1.5rem',
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: '1rem',
},
value: {
fontSize: '1rem',
},
total: {
fontSize: '1rem',
},
},
},
},
},
},
},
],
}
}
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',
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 },
},
},
xaxis: {
axisBorder: { show: false },
axisTicks: { color: themeBorderColor },
crosshairs: {
stroke: { color: themeBorderColor },
},
labels: {
style: { colors: themeDisabledTextColor },
},
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',
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 },
},
},
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 },
},
},
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',
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,
},
},
},
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: {
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: {
colors: [
themeDisabledTextColor,
themeDisabledTextColor,
themeDisabledTextColor,
themeDisabledTextColor,
themeDisabledTextColor,
themeDisabledTextColor,
themeDisabledTextColor,
themeDisabledTextColor,
],
},
},
},
}
}

View File

@@ -0,0 +1,372 @@
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,95 @@
import { isEmpty, isEmptyArray, isNullOrUndefined } from './helpers'
// 👉 Required Validator
export const requiredValidator = value => {
if (isNullOrUndefined(value) || isEmptyArray(value) || value === false)
return 'This field is required'
return !!String(value).trim().length || 'This field is required'
}
// 👉 Email Validator
export const emailValidator = value => {
if (isEmpty(value))
return true
const re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[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 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 = /^(https?):\/\/[^\s$.?#].[^\s]*$/
return re.test(String(value)) || 'URL is invalid'
}
// 👉 Length Validator
export const lengthValidator = (value, length) => {
if (isEmpty(value))
return true
return String(value).length === length || `The 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'
}

View File

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