Files
panel/resources/js/@core/components/TheCustomizer.vue
2025-09-24 12:12:33 +03:30

578 lines
18 KiB
Vue

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