init: чистый старт Laravel + Vuexy

This commit is contained in:
2026-02-20 13:30:03 +03:00
commit af53445c26
474 changed files with 58860 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
const props = defineProps<{
title: string
}>()
defineEmits<{
(e: 'cancel', el: MouseEvent): void
}>()
</script>
<template>
<div class="pa-6 d-flex align-center">
<h5 class="text-h5">
{{ props.title }}
</h5>
<VSpacer />
<slot name="beforeClose" />
<IconBtn
size="small"
@click="$emit('cancel', $event)"
>
<VIcon
size="24"
icon="tabler-x"
/>
</IconBtn>
</div>
</template>

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
interface Props {
title: string
color?: string
icon: string
stats: string
}
const props = withDefaults(defineProps<Props>(), {
color: 'primary',
})
</script>
<template>
<VCard>
<VCardText class="d-flex flex-column align-center justify-center">
<VAvatar
v-if="props.icon"
size="40"
variant="tonal"
rounded
:color="props.color"
>
<VIcon :icon="props.icon" />
</VAvatar>
<h5 class="text-h5 pt-2 mb-1">
{{ props.stats }}
</h5>
<div class="text-body-1">
{{ props.title }}
</div>
</VCardText>
</VCard>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
interface Props {
title: string
divider?: boolean
}
const props = withDefaults(defineProps<Props>(), {
divider: true,
})
</script>
<template>
<VDivider v-if="props.divider" />
<div class="customizer-section">
<div>
<VChip
size="small"
color="primary"
>
<span class="font-weight-medium">{{ props.title }}</span>
</VChip>
</div>
<slot />
</div>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
interface Props {
icon?: string
iconSize?: string
}
const props = withDefaults(defineProps<Props>(), {
icon: 'tabler-x',
iconSize: '20',
})
</script>
<template>
<IconBtn
variant="elevated"
size="30"
:ripple="false"
class="v-dialog-close-btn"
>
<VIcon
:icon="props.icon"
:size="props.iconSize"
/>
</IconBtn>
</template>

View File

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

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import type { I18nLanguage } from '@layouts/types'
interface Props {
languages: I18nLanguage[]
location?: any
}
const props = withDefaults(defineProps<Props>(), {
location: 'bottom end',
})
const { locale } = useI18n({ useScope: 'global' })
</script>
<template>
<IconBtn>
<VIcon icon="tabler-language" />
<!-- Menu -->
<VMenu
activator="parent"
:location="props.location"
offset="12px"
width="175"
>
<!-- List -->
<VList
:selected="[locale]"
color="primary"
>
<!-- List item -->
<VListItem
v-for="lang in props.languages"
:key="lang.i18nLang"
:value="lang.i18nLang"
@click="locale = lang.i18nLang"
>
<!-- Language label -->
<VListItemTitle>
{{ lang.label }}
</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>

View File

@@ -0,0 +1,31 @@
<script lang="ts" setup>
interface Props {
menuList?: unknown[]
itemProps?: boolean
iconSize?: string
class?: string
}
const props = withDefaults(defineProps<Props>(), {
class: 'text-disabled',
})
</script>
<template>
<IconBtn :class="props.class">
<VIcon
:size="iconSize"
icon="tabler-dots-vertical"
/>
<VMenu
v-if="props.menuList"
activator="parent"
>
<VList
:items="props.menuList"
:item-props="props.itemProps"
/>
</VMenu>
</IconBtn>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,629 @@
<script setup lang="tsx">
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 horizontalLight from '@images/customizer-icons/horizontal-light.svg'
import { AppContentLayoutNav, ContentWidth } from '@layouts/enums'
import { cookieRef, namespaceConfig } from '@layouts/stores/config'
import { themeConfig } from '@themeConfig'
import borderSkin from '@images/customizer-icons/border-light.svg'
import collapsed from '@images/customizer-icons/collapsed-light.svg'
import compact from '@images/customizer-icons/compact-light.svg'
import defaultSkin from '@images/customizer-icons/default-light.svg'
import ltrSvg from '@images/customizer-icons/ltr-light.svg'
import rtlSvg from '@images/customizer-icons/rtl-light.svg'
import wideSvg from '@images/customizer-icons/wide-light.svg'
const isNavDrawerOpen = ref(false)
const configStore = useConfigStore()
// 👉 Primary Color
const vuetifyTheme = useTheme()
const colors: { main: string; darken: string }[] = [
{ main: staticPrimaryColor, darken: staticPrimaryDarkenColor },
{ main: '#0D9394', darken: '#0C8485' },
{ main: '#FFB400', darken: '#E6A200' },
{ main: '#FF4C51', darken: '#E64449' },
{ main: '#16B1FF', darken: '#149FE6' },
]
const customPrimaryColor = ref('#663131')
watch(
() => configStore.theme,
() => {
const cookiePrimaryColor = cookieRef(`${vuetifyTheme.name.value}ThemePrimaryColor`, null).value
if (cookiePrimaryColor && !colors.some(color => color.main === cookiePrimaryColor))
customPrimaryColor.value = cookiePrimaryColor
},
{ immediate: true },
)
// It will set primary color for current theme only
const setPrimaryColor = useDebounceFn((color: { main: string; darken: string }) => {
vuetifyTheme.themes.value[vuetifyTheme.name.value].colors.primary = color.main
vuetifyTheme.themes.value[vuetifyTheme.name.value].colors['primary-darken-1'] = color.darken
// We need to store this color value in cookie so vuetify plugin can pick on next reload
cookieRef<string | null>(`${vuetifyTheme.name.value}ThemePrimaryColor`, null).value = color.main
cookieRef<string | null>(`${vuetifyTheme.name.value}ThemePrimaryDarkenColor`, null).value = color.darken
// Update initial loader color
useStorage<string | null>(namespaceConfig('initial-loader-color'), null).value = color.main
}, 100)
// 👉 Mode
const themeMode = computed(() => {
return [
{
bgImage: 'tabler-sun',
value: Theme.Light,
label: 'Light',
},
{
bgImage: 'tabler-moon-stars',
value: Theme.Dark,
label: 'Dark',
},
{
bgImage: 'tabler-device-desktop-analytics',
value: Theme.System,
label: 'System',
},
]
})
// 👉 Skin
const themeSkin = computed(() => {
return [
{
bgImage: defaultSkin,
value: Skins.Default,
label: 'Default',
},
{
bgImage: borderSkin,
value: Skins.Bordered,
label: 'Bordered',
},
]
})
// 👉 Layout
const currentLayout = ref<'vertical' | 'collapsed' | 'horizontal'>(configStore.isVerticalNavCollapsed ? 'collapsed' : configStore.appContentLayoutNav)
const layouts = computed(() => {
return [
{
bgImage: defaultSkin,
value: Layout.Vertical,
label: 'Vertical',
},
{
bgImage: collapsed,
value: Layout.Collapsed,
label: 'Collapsed',
},
{
bgImage: horizontalLight,
value: Layout.Horizontal,
label: 'Horizontal',
},
]
})
watch(currentLayout, () => {
if (currentLayout.value === 'collapsed') {
configStore.isVerticalNavCollapsed = true
configStore.appContentLayoutNav = AppContentLayoutNav.Vertical
}
else {
configStore.isVerticalNavCollapsed = false
configStore.appContentLayoutNav = currentLayout.value
}
})
// watch vertical sidebar collapse state
watch(
() => configStore.isVerticalNavCollapsed,
() => {
currentLayout.value = configStore.isVerticalNavCollapsed
? 'collapsed'
: configStore.appContentLayoutNav
},
)
// 👉 Content Width
const contentWidth = computed(() => {
return [
{
bgImage: compact,
value: ContentWidth.Boxed,
label: 'Compact',
},
{
bgImage: wideSvg,
value: ContentWidth.Fluid,
label: 'Wide',
},
]
})
// 👉 Direction
const currentDir = ref(configStore.isAppRTL ? 'rtl' : 'ltr')
const direction = computed(() => {
return [
{
bgImage: ltrSvg,
value: Direction.Ltr,
label: 'Left to right',
},
{
bgImage: rtlSvg,
value: Direction.Rtl,
label: 'Right to left',
},
]
})
watch(currentDir, () => {
if (currentDir.value === 'rtl')
configStore.isAppRTL = true
else
configStore.isAppRTL = false
})
// check if any value set in cookie
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 })
// remove all theme related values from localStorage
const resetCustomizer = async () => {
if (isCookieHasAnyValue.value) {
// reset themeConfig values
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<string | null>(namespaceConfig('initial-loader-color'), null).value = staticPrimaryColor
currentLayout.value = themeConfig.app.contentLayoutNav
cookieRef('lightThemePrimaryColor', null).value = null
cookieRef('darkThemePrimaryColor', null).value = null
cookieRef('lightThemePrimaryDarkenColor', null).value = null
cookieRef('darkThemePrimaryDarkenColor', null).value = null
await nextTick()
isCookieHasAnyValue.value = false
customPrimaryColor.value = '#ffffff'
}
}
</script>
<template>
<div class="d-lg-block d-none">
<VBtn
icon
class="app-customizer-toggler rounded-s-lg rounded-0"
style="z-index: 1001;"
@click="isNavDrawerOpen = true"
>
<VIcon
size="22"
icon="tabler-settings"
/>
</VBtn>
<VNavigationDrawer
v-model="isNavDrawerOpen"
data-allow-mismatch
temporary
touchless
border="none"
location="end"
width="400"
elevation="10"
: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="24"
color="high-emphasis"
icon="tabler-refresh"
/>
</VBtn>
<VBtn
icon
variant="text"
color="medium-emphasis"
size="small"
@click="isNavDrawerOpen = false"
>
<VIcon
icon="tabler-x"
color="high-emphasis"
size="24"
/>
</VBtn>
</div>
</div>
<VDivider />
<PerfectScrollbar
tag="ul"
:options="{ wheelPropagation: false }"
>
<!-- 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.75rem; margin-block-start: 2px;"
>
<div
v-for="color in colors"
:key="color.main"
style="
border-radius: 0.375rem;
outline: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
padding-block: 0.5rem;
padding-inline: 0.625rem;"
class="primary-color-wrapper cursor-pointer"
:class="vuetifyTheme.current.value.colors.primary === color.main ? 'active' : ''"
:style="vuetifyTheme.current.value.colors.primary === color.main ? `outline-color: ${color.main}; outline-width:2px;` : `--v-color:${color.main}`"
@click="setPrimaryColor(color)"
>
<div
style="border-radius: 0.375rem;block-size: 2.125rem; inline-size: 1.8938rem;"
:style="{ backgroundColor: color.main }"
/>
</div>
<div
class="primary-color-wrapper cursor-pointer d-flex align-center"
style="
border-radius: 0.375rem;
outline: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
padding-block: 0.5rem;
padding-inline: 0.625rem;"
:class="vuetifyTheme.current.value.colors.primary === customPrimaryColor ? 'active' : ''"
:style="vuetifyTheme.current.value.colors.primary === customPrimaryColor ? `outline-color: ${customPrimaryColor}; outline-width:2px;` : ''"
>
<VBtn
icon
size="30"
:color="vuetifyTheme.current.value.colors.primary === customPrimaryColor ? customPrimaryColor : $vuetify.theme.current.dark ? '#8692d029' : '#4b465c29'"
variant="flat"
style="border-radius: 0.375rem;"
>
<VIcon
size="20"
icon="tabler-color-picker"
:color="vuetifyTheme.current.value.colors.primary === customPrimaryColor ? 'rgb(var(--v-theme-on-primary))' : ''"
/>
</VBtn>
<VMenu
activator="parent"
:close-on-content-click="false"
>
<VList>
<VListItem>
<VColorPicker
v-model="customPrimaryColor"
mode="hex"
:modes="['hex']"
@update:model-value="setPrimaryColor({ main: customPrimaryColor, darken: customPrimaryColor })"
/>
</VListItem>
</VList>
</VMenu>
</div>
</div>
</div>
<!-- 👉 Theme -->
<div class="d-flex flex-column gap-2">
<h6 class="text-h6">
Theme
</h6>
<CustomRadiosWithImage
:key="configStore.theme"
v-model:selected-radio="configStore.theme"
:radio-content="themeMode"
:grid-column="{ cols: '4' }"
class="customizer-skins"
>
<template #label="item">
<span class="text-sm text-medium-emphasis mt-1">{{ item?.label }}</span>
</template>
<template #content="{ item }">
<div
class="customizer-skins-icon-wrapper d-flex align-center justify-center py-3 w-100"
style="min-inline-size: 100%;"
>
<VIcon
size="30"
:icon="item.bgImage"
color="high-emphasis"
/>
</div>
</template>
</CustomRadiosWithImage>
</div>
<!-- 👉 Skin -->
<div class="d-flex flex-column gap-2">
<h6 class="text-h6">
Skins
</h6>
<CustomRadiosWithImage
:key="configStore.skin"
v-model:selected-radio="configStore.skin"
:radio-content="themeSkin"
:grid-column="{ cols: '4' }"
>
<template #label="item">
<span class="text-sm text-medium-emphasis">{{ item?.label }}</span>
</template>
</CustomRadiosWithImage>
</div>
<!-- 👉 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-2">
<h6 class="text-base font-weight-medium">
Layout
</h6>
<CustomRadiosWithImage
:key="currentLayout"
v-model:selected-radio="currentLayout"
:radio-content="layouts"
:grid-column="{ cols: '4' }"
>
<template #label="item">
<span class="text-sm text-medium-emphasis">{{ item.label }}</span>
</template>
</CustomRadiosWithImage>
</div>
<!-- 👉 Content Width -->
<div class="d-flex flex-column gap-2">
<h6 class="text-base font-weight-medium">
Content
</h6>
<CustomRadiosWithImage
:key="configStore.appContentWidth"
v-model:selected-radio="configStore.appContentWidth"
:radio-content="contentWidth"
:grid-column="{ cols: '4' }"
>
<template #label="item">
<span class="text-sm text-medium-emphasis">{{ item.label }}</span>
</template>
</CustomRadiosWithImage>
</div>
<!-- 👉 Direction -->
<div class="d-flex flex-column gap-2">
<h6 class="text-base font-weight-medium">
Direction
</h6>
<CustomRadiosWithImage
:key="currentDir"
v-model:selected-radio="currentDir"
:radio-content="direction"
:grid-column="{ cols: '4' }"
>
<template #label="item">
<span class="text-sm text-medium-emphasis">{{ item?.label }}</span>
</template>
</CustomRadiosWithImage>
</div>
</CustomizerSection>
<!-- !SECTION -->
</PerfectScrollbar>
</VNavigationDrawer>
</div>
</template>
<style lang="scss">
@use "@layouts/styles/mixins" as layoutMixins;
.app-customizer {
&.v-navigation-drawer--temporary:not(.v-navigation-drawer--active) {
transform: translateX(110%) !important;
@include layoutMixins.rtl {
transform: translateX(-110%) !important;
}
}
.customizer-section {
display: flex;
flex-direction: column;
padding: 1.5rem;
gap: 1.5rem;
}
.customizer-heading {
padding-block: 1rem;
padding-inline: 1.5rem;
}
.custom-input-wrapper {
.v-col {
padding-inline: 10px;
}
.v-label.custom-input {
border: none;
color: rgb(var(--v-theme-on-surface));
outline: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
}
.v-navigation-drawer__content {
display: flex;
flex-direction: column;
}
.v-label.custom-input.active {
border-color: transparent;
outline: 2px solid rgb(var(--v-theme-primary));
}
.v-label.custom-input:not(.active):hover {
border-color: rgba(var(--v-border-color), 0.22);
}
.customizer-skins {
.custom-input.active {
.customizer-skins-icon-wrapper {
background-color: rgba(var(--v-global-theme-primary), var(--v-selected-opacity));
}
}
}
.app-customizer-primary-colors {
.primary-color-wrapper:not(.active) {
&:hover {
outline-color: rgba(var(--v-border-color), 0.22) !important;
}
}
}
}
.app-customizer-toggler {
position: fixed !important;
inset-block-start: 20%;
inset-inline-end: 0;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
interface Props {
title: string
color?: string
icon: string
stats: string
}
const props = withDefaults(defineProps<Props>(), {
color: 'primary',
})
</script>
<template>
<VCard>
<VCardText class="d-flex align-center justify-space-between">
<div>
<div class="d-flex align-center flex-wrap">
<h5 class="text-h5">
{{ props.stats }}
</h5>
</div>
<div class="text-subtitle-1">
{{ props.title }}
</div>
</div>
<VAvatar
:color="props.color"
:size="42"
rounded
variant="tonal"
>
<VIcon
:icon="props.icon"
size="26"
/>
</VAvatar>
</VCardText>
</VCard>
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
interface Props {
title: string
color?: string
icon: string
stats: string
height: number
series: unknown[]
chartOptions: unknown
}
const props = withDefaults(defineProps<Props>(), {
color: 'primary',
})
</script>
<template>
<VCard>
<VCardText class="d-flex flex-column pb-0">
<VAvatar
v-if="props.icon"
size="42"
variant="tonal"
:color="props.color"
rounded
class="mb-2"
>
<VIcon
:icon="props.icon"
size="26"
/>
</VAvatar>
<h5 class="text-h5">
{{ props.stats }}
</h5>
<div class="text-sm">
{{ props.title }}
</div>
</VCardText>
<VueApexCharts
:series="props.series"
:options="props.chartOptions"
:height="props.height"
/>
</VCard>
</template>

View File

@@ -0,0 +1,20 @@
import { stringifyQuery } from 'ufo'
import type { MaybeRefOrGetter } from 'vue'
interface Options {
query: MaybeRefOrGetter<Record<string, any>>
}
export const createUrl = (url: MaybeRefOrGetter<string>, options?: 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,43 @@
// Ported from [Nuxt](https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/composables/cookie.ts)
import type { CookieParseOptions, CookieSerializeOptions } from 'cookie-es'
import { parse, serialize } from 'cookie-es'
import { destr } from 'destr'
type _CookieOptions = Omit<CookieSerializeOptions & CookieParseOptions, 'decode' | 'encode'>
export interface CookieOptions<T = any> extends _CookieOptions {
decode?(value: string): T
encode?(value: T): string
default?: () => T | Ref<T>
watch?: boolean | 'shallow'
}
export type CookieRef<T> = Ref<T>
const CookieDefaults: CookieOptions<any> = {
path: '/',
watch: true,
decode: val => destr(decodeURIComponent(val)),
encode: val => encodeURIComponent(typeof val === 'string' ? val : JSON.stringify(val)),
}
export const useCookie = <T = string | null | undefined>(name: string, _opts?: CookieOptions<T>): CookieRef<T> => {
const opts = { ...CookieDefaults, ..._opts || {} }
const cookies = parse(document.cookie, opts)
const cookie = ref<T | undefined>(cookies[name] as any ?? opts.default?.())
watch(cookie, () => {
document.cookie = serializeCookie(name, cookie.value, opts)
})
return cookie as CookieRef<T>
}
function serializeCookie(name: string, value: any, opts: CookieSerializeOptions = {}) {
if (value === null || value === undefined)
return serialize(name, value, { ...opts, maxAge: -1 })
return serialize(name, value, { ...opts, maxAge: 60 * 60 * 24 * 30 })
}

View File

@@ -0,0 +1,28 @@
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: string, imgDark: string, imgLightBordered?: string, imgDarkBordered?: string, bordered = false) => {
const configStore = useConfigStore()
const { global } = useTheme()
return computed(() => {
if (global.name.value === 'light') {
if (configStore.skin === 'bordered' && bordered)
return imgLightBordered
else
return imgLight
}
if (global.name.value === 'dark') {
if (configStore.skin === 'bordered' && bordered)
return imgDarkBordered
else
return imgDark
}
// Add a default return statement
return imgLight
})
}

View File

@@ -0,0 +1,29 @@
import type { Ref } from 'vue'
import { useDisplay } from 'vuetify'
export const useResponsiveLeftSidebar = (mobileBreakpoint: Ref<boolean> | undefined = 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,42 @@
import { VThemeProvider } from 'vuetify/components/VThemeProvider'
import { useConfigStore } from '@core/stores/config'
import { AppContentLayoutNav } from '@layouts/enums'
// TODO: Use `VThemeProvider` from dist instead of lib (Using this component from dist causes navbar to loose sticky positioning)
export const useSkins = () => {
const configStore = useConfigStore()
const layoutAttrs = computed(() => ({
verticalNavAttrs: {
wrapper: h(VThemeProvider, { tag: 'div' }),
wrapperProps: {
withBackground: true,
theme: (configStore.isVerticalNavSemiDark && configStore.appContentLayoutNav === AppContentLayoutNav.Vertical)
? 'dark'
: undefined,
},
},
}))
const injectSkinClasses = () => {
if (typeof document !== 'undefined') {
const bodyClasses = document.body.classList
const genSkinClass = (_skin?: string) => `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,21 @@
export const Skins = {
Default: 'default',
Bordered: 'bordered',
} as const
export const Theme = {
Light: 'light',
Dark: 'dark',
System: 'system',
} as const
export const Layout = {
Vertical: 'vertical',
Horizontal: 'horizontal',
Collapsed: 'collapsed',
} as const
export const Direction = {
Ltr: 'ltr',
Rtl: 'rtl',
} as const

View File

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

View File

@@ -0,0 +1,372 @@
import type { ThemeInstance } from 'vuetify'
import { hexToRgb } from '@core/utils/colorConverter'
// 👉 Colors variables
const colorVariables = (themeColors: ThemeInstance['themes']['value']['colors']) => {
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: ThemeInstance['themes']['value']['colors']) => {
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: ThemeInstance['themes']['value']['colors']) => {
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: ThemeInstance['themes']['value']['colors']) => {
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: ThemeInstance['themes']['value']['colors']) => {
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: ThemeInstance['themes']['value']['colors']) => {
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: ThemeInstance['themes']['value']['colors']) => {
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: ThemeInstance['themes']['value']['colors']) => {
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: ThemeInstance['themes']['value']['colors']) => {
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,58 @@
import type { PluginOptionsByType } from 'chart.js'
import { BarElement, CategoryScale, Chart as ChartJS, Legend, LinearScale, Title, Tooltip } from 'chart.js'
import type { PropType } from 'vue'
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 as PropType<Partial<CSSStyleDeclaration>>,
default: () => ({}),
},
plugins: {
type: Array as PropType<PluginOptionsByType<'bar'>[]>,
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,58 @@
import type { PluginOptionsByType } from 'chart.js'
import { Chart as ChartJS, Legend, LinearScale, PointElement, Title, Tooltip } from 'chart.js'
import type { PropType } from 'vue'
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 as PropType<Partial<CSSStyleDeclaration>>,
default: () => ({}),
},
plugins: {
type: Array as PropType<PluginOptionsByType<'bubble'>[]>,
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,58 @@
import type { PluginOptionsByType } from 'chart.js'
import { ArcElement, CategoryScale, Chart as ChartJS, Legend, Title, Tooltip } from 'chart.js'
import type { PropType } from 'vue'
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 as PropType<Partial<CSSStyleDeclaration>>,
default: () => ({}),
},
plugins: {
type: Array as PropType<PluginOptionsByType<'doughnut'>[]>,
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,58 @@
import type { PluginOptionsByType } from 'chart.js'
import { CategoryScale, Chart as ChartJS, Legend, LineElement, LinearScale, PointElement, Title, Tooltip } from 'chart.js'
import type { PropType } from 'vue'
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 as PropType<Partial<CSSStyleDeclaration>>,
default: () => ({}),
},
plugins: {
type: Array as PropType<PluginOptionsByType<'line'>[]>,
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,58 @@
import type { PluginOptionsByType } from 'chart.js'
import { ArcElement, Chart as ChartJS, Legend, RadialLinearScale, Title, Tooltip } from 'chart.js'
import type { PropType } from 'vue'
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 as PropType<Partial<CSSStyleDeclaration>>,
default: () => ({}),
},
plugins: {
type: Array as PropType<PluginOptionsByType<'polarArea'>[]>,
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,58 @@
import type { PluginOptionsByType } from 'chart.js'
import { Chart as ChartJS, Filler, Legend, LineElement, PointElement, RadialLinearScale, Title, Tooltip } from 'chart.js'
import type { PropType } from 'vue'
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 as PropType<Partial<CSSStyleDeclaration>>,
default: () => ({}),
},
plugins: {
type: Array as PropType<PluginOptionsByType<'radar'>[]>,
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,58 @@
import type { PluginOptionsByType } from 'chart.js'
import { CategoryScale, Chart as ChartJS, Legend, LineElement, LinearScale, PointElement, Title, Tooltip } from 'chart.js'
import type { PropType } from 'vue'
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 as PropType<Partial<CSSStyleDeclaration>>,
default: () => ({}),
},
plugins: {
type: Array as PropType<PluginOptionsByType<'scatter'>[]>,
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,80 @@
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<'light' | 'dark'>('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,49 @@
import type { LiteralUnion, ValueOf } from 'type-fest'
import type { Skins } from './enums'
import type { I18nLanguage, LayoutConfig } from '@layouts/types'
interface ExplicitThemeConfig {
app: {
i18n: {
defaultLocale: string
langConfig: I18nLanguage[]
}
theme: LiteralUnion<'light' | 'dark' | 'system', string>
skin: ValueOf<typeof Skins>
}
verticalNav: {
isVerticalNavSemiDark: boolean
}
}
export type UserThemeConfig = LayoutConfig & ExplicitThemeConfig
// SECTION Custom Input
export interface CustomInputContent {
title: string
desc?: string
value: string
subtitle?: string
icon?: string | object
images?: string
}
export interface GridColumn {
cols?: string
sm?: string
md?: string
lg?: string
xl?: string
xxl?: string
}
// Data table
export interface SortItem { key: string; order?: boolean | 'asc' | 'desc' }
export interface Options {
page: number
itemsPerPage: number
sortBy: readonly SortItem[]
groupBy: readonly SortItem[]
search: string | undefined
}

View File

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

View File

@@ -0,0 +1,48 @@
import { isToday } from './helpers'
export const avatarText = (value: string) => {
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: number) => {
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: string, formatting: Intl.DateTimeFormatOptions = { 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: string, toTimeForCurrentDay = true) => {
const date = new Date(value)
let formatting: Record<string, string> = { 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: number) => value > 0 ? `+${value}` : value

View File

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

View File

@@ -0,0 +1,53 @@
import type { App } from 'vue'
/**
* This is helper function to register plugins like a nuxt
* To register a plugin just export a const function `defineVuePlugin` that takes `app` as argument and call `app.use`
* For Scanning plugins it will include all files in `src/plugins` and `src/plugins/**\/index.ts`
*
*
* @param {App} app Vue app instance
*
* @example
* ```ts
* // File: src/plugins/vuetify/index.ts
*
* import type { App } from 'vue'
* import { createVuetify } from 'vuetify'
*
* const vuetify = createVuetify({ ... })
*
* export default function (app: App) {
* app.use(vuetify)
* }
* ```
*
* All you have to do is use this helper function in `main.ts` file like below:
* ```ts
* // File: src/main.ts
* import { registerPlugins } from '@core/utils/plugins'
* import { createApp } from 'vue'
* import App from '@/App.vue'
*
* // Create vue app
* const app = createApp(App)
*
* // Register plugins
* registerPlugins(app) // [!code focus]
*
* // Mount vue app
* app.mount('#app')
* ```
*/
export const registerPlugins = (app: App) => {
const imports = import.meta.glob<{ default: (app: App) => void }>(['../../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,105 @@
import { isEmpty, isEmptyArray, isNullOrUndefined } from './helpers'
// 👉 Required Validator
export const requiredValidator = (value: unknown) => {
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: unknown) => {
if (isEmpty(value))
return true
const re = /^(?:[^<>()[\]\\.,;:\s@"]+(?:\.[^<>()[\]\\.,;:\s@"]+)*|".+")@(?:\[\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\]|(?:[a-z\-\d]+\.)+[a-z]{2,})$/i
if (Array.isArray(value))
return value.every(val => re.test(String(val))) || 'The Email field must be a valid email'
return re.test(String(value)) || 'The Email field must be a valid email'
}
// 👉 Password Validator
export const passwordValidator = (password: string) => {
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: string, target: string) =>
value === target || 'The Confirm Password field confirmation does not match'
// 👉 Between Validator
export const betweenValidator = (value: unknown, min: number, max: number) => {
const valueAsNumber = Number(value)
return (Number(min) <= valueAsNumber && Number(max) >= valueAsNumber) || `Enter number between ${min} and ${max}`
}
// 👉 Integer Validator
export const integerValidator = (value: unknown) => {
if (isEmpty(value))
return true
if (Array.isArray(value))
return value.every(val => /^-?\d+$/.test(String(val))) || 'This field must be an integer'
return /^-?\d+$/.test(String(value)) || 'This field must be an integer'
}
// 👉 Regex Validator
export const regexValidator = (value: unknown, regex: RegExp | string): string | boolean => {
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: unknown) => {
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: unknown) => {
if (isEmpty(value))
return true
const re = /^https?:\/\/[^\s$.?#].\S*$/
return re.test(String(value)) || 'URL is invalid'
}
// 👉 Length Validator
export const lengthValidator = (value: unknown, length: number) => {
if (isEmpty(value))
return true
return String(value).length === length || `"The length of the Characters field must be ${length} characters."`
}
// 👉 Alpha-dash Validator
export const alphaDashValidator = (value: unknown) => {
if (isEmpty(value))
return true
const valueAsString = String(value)
return /^[\w-]*$/.test(valueAsString) || 'All Character are not valid'
}

View File

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