first commit

pull/10/head
Frédérik Benoist 2023-12-02 12:49:31 +01:00
commit 4840b31c96
435 changed files with 29686 additions and 0 deletions

38
.editorconfig Executable file
View File

@ -0,0 +1,38 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
# Matches multiple files with brace expansion notation
# Set default charset
[*.{js,py}]
charset = utf-8
# 4 space indentation
[*.py]
indent_style = space
indent_size = 4
# 2 space indentation
[*.{vue,scss,ts}]
indent_style = space
indent_size = 2
# Tab indentation (no size specified)
[Makefile]
indent_style = tab
# Indentation override for all JS under lib directory
[lib/**.js]
indent_style = space
indent_size = 2
# Matches the exact files either package.json or .travis.yml
[{package.json,.travis.yml}]
indent_style = space
indent_size = 2

1
.env.example Executable file
View File

@ -0,0 +1 @@
VITE_API_BASE_URL=

245
.eslintrc.cjs Executable file
View File

@ -0,0 +1,245 @@
module.exports = {
env: {
browser: true,
es2021: true,
},
extends: [
'@antfu/eslint-config-vue',
'plugin:vue/vue3-recommended',
'plugin:import/recommended',
'plugin:import/typescript',
'plugin:promise/recommended',
'plugin:sonarjs/recommended',
'plugin:@typescript-eslint/recommended',
'plugin:case-police/recommended',
// 'plugin:unicorn/recommended',
],
parser: 'vue-eslint-parser',
parserOptions: {
ecmaVersion: 13,
parser: '@typescript-eslint/parser',
sourceType: 'module',
},
plugins: [
'vue',
'@typescript-eslint',
'regex',
],
ignorePatterns: ['src/plugins/iconify/*.js', 'node_modules', 'dist', '*.d.ts', 'vendor'],
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
// indentation (Already present in TypeScript)
'comma-spacing': ['error', { before: false, after: true }],
'key-spacing': ['error', { afterColon: true }],
'n/prefer-global/process': ['off'],
'sonarjs/cognitive-complexity': ['off'],
'vue/first-attribute-linebreak': ['error', {
singleline: 'beside',
multiline: 'below',
}],
'antfu/top-level-function': 'off',
'@typescript-eslint/no-explicit-any': 'off',
// indentation (Already present in TypeScript)
'indent': ['error', 2],
// Enforce trailing comma (Already present in TypeScript)
'comma-dangle': ['error', 'always-multiline'],
// Enforce consistent spacing inside braces of object (Already present in TypeScript)
'object-curly-spacing': ['error', 'always'],
// Enforce camelCase naming convention
'camelcase': 'error',
// Disable max-len
'max-len': 'off',
// we don't want it
'semi': ['error', 'never'],
// add parens ony when required in arrow function
'arrow-parens': ['error', 'as-needed'],
// add new line above comment
'newline-before-return': 'error',
// add new line above comment
'lines-around-comment': [
'error',
{
beforeBlockComment: true,
beforeLineComment: true,
allowBlockStart: true,
allowClassStart: true,
allowObjectStart: true,
allowArrayStart: true,
// We don't want to add extra space above closing SECTION
ignorePattern: '!SECTION',
},
],
// Ignore _ as unused variable
'@typescript-eslint/no-unused-vars': ['error', { varsIgnorePattern: '^_+$', argsIgnorePattern: '^_+$' }],
'array-element-newline': ['error', 'consistent'],
'array-bracket-newline': ['error', 'consistent'],
'vue/multi-word-component-names': 'off',
'padding-line-between-statements': [
'error',
{ blankLine: 'always', prev: 'expression', next: 'const' },
{ blankLine: 'always', prev: 'const', next: 'expression' },
{ blankLine: 'always', prev: 'multiline-const', next: '*' },
{ blankLine: 'always', prev: '*', next: 'multiline-const' },
],
// Plugin: eslint-plugin-import
'import/prefer-default-export': 'off',
'import/newline-after-import': ['error', { count: 1 }],
'no-restricted-imports': ['error', 'vuetify/components', {
name: 'vue3-apexcharts',
message: 'apexcharts are autoimported',
}],
// For omitting extension for ts files
'import/extensions': [
'error',
'ignorePackages',
{
js: 'never',
jsx: 'never',
ts: 'never',
tsx: 'never',
},
],
// ignore virtual files
'import/no-unresolved': [2, {
ignore: [
'~pages$',
'virtual:generated-layouts',
// Ignore vite's ?raw imports
'.*\?raw',
],
}],
// Thanks: https://stackoverflow.com/a/63961972/10796681
'no-shadow': 'off',
'@typescript-eslint/no-shadow': ['error'],
'@typescript-eslint/consistent-type-imports': 'error',
// Plugin: eslint-plugin-promise
'promise/always-return': 'off',
'promise/catch-or-return': 'off',
// ESLint plugin vue
'vue/block-tag-newline': 'error',
'vue/component-api-style': 'error',
'vue/component-name-in-template-casing': ['error', 'PascalCase', { registeredComponentsOnly: false, ignores: ['/^swiper-/'] }],
'vue/custom-event-name-casing': ['error', 'camelCase', {
ignores: [
'/^(click):[a-z]+((\d)|([A-Z0-9][a-z0-9]+))*([A-Z])?/',
],
}],
'vue/define-macros-order': 'error',
'vue/html-comment-content-newline': 'error',
'vue/html-comment-content-spacing': 'error',
'vue/html-comment-indent': 'error',
'vue/match-component-file-name': 'error',
'vue/no-child-content': 'error',
'vue/require-default-prop': 'off',
'vue/no-duplicate-attr-inheritance': 'error',
'vue/no-empty-component-block': 'error',
'vue/no-multiple-objects-in-class': 'error',
'vue/no-reserved-component-names': 'error',
'vue/no-template-target-blank': 'error',
'vue/no-useless-mustaches': 'error',
'vue/no-useless-v-bind': 'error',
'vue/padding-line-between-blocks': 'error',
'vue/prefer-separate-static-class': 'error',
'vue/prefer-true-attribute-shorthand': 'error',
'vue/v-on-function-call': 'error',
'vue/no-restricted-class': ['error', '/^(p|m)(l|r)-/'],
'vue/valid-v-slot': ['error', {
allowModifiers: true,
}],
// -- Extension Rules
'vue/no-irregular-whitespace': 'error',
'vue/template-curly-spacing': 'error',
// -- Sonarlint
'sonarjs/no-duplicate-string': 'off',
'sonarjs/no-nested-template-literals': 'off',
// -- Unicorn
// 'unicorn/filename-case': 'off',
// 'unicorn/prevent-abbreviations': ['error', {
// replacements: {
// props: false,
// },
// }],
// Internal Rules
'valid-appcardcode-code-prop': 'error',
'valid-appcardcode-demo-sfc': 'error',
// https://github.com/gmullerb/eslint-plugin-regex
'regex/invalid': [
'error',
[
{
regex: '@/assets/images',
replacement: '@images',
message: 'Use \'@images\' path alias for image imports',
},
{
regex: '@/assets/styles',
replacement: '@styles',
message: 'Use \'@styles\' path alias for importing styles from \'src/assets/styles\'',
},
{
id: 'Disallow icon of icon library',
regex: 'mdi-\\w',
message: 'Only \'tabler\' icons are allowed',
},
{
regex: '@core/\\w',
message: 'You can\'t use @core when you are in @layouts module',
files: {
inspect: '@layouts/.*',
},
},
{
regex: 'useLayouts\\(',
message: '`useLayouts` composable is only allowed in @layouts & @core directory. Please use `useThemeConfig` composable instead.',
files: {
inspect: '^(?!.*(@core|@layouts)).*',
},
},
],
// Ignore files
'\.eslintrc\.cjs',
],
},
settings: {
'import/resolver': {
node: true,
typescript: {},
},
},
}

43
.gitignore vendored Executable file
View File

@ -0,0 +1,43 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
*.local
/cypress/videos/
/cypress/screenshots/
# 👉 Custom Git ignores
# Editor directories and files
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
!.vscode/*.code-snippets
!.vscode/tours
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.yarn
# iconify dist files
src/plugins/iconify/icons.css
# Ignore MSW script
public/mockServiceWorker.js
# Env files
.env*
!.env.example

2
.npmrc Executable file
View File

@ -0,0 +1,2 @@
auto-install-peers=true
shamefully-hoist=true

1
.nvmrc Executable file
View File

@ -0,0 +1 @@
lts/*

45
.stylelintrc.json Executable file
View File

@ -0,0 +1,45 @@
{
"extends": [
"stylelint-config-standard-scss",
"stylelint-config-idiomatic-order"
],
"plugins": [
"stylelint-use-logical-spec",
"stylelint-codeguide"
],
"overrides": [
{
"files": [
"**/*.scss"
],
"customSyntax": "postcss-scss"
},
{
"files": [
"**/*.vue"
],
"customSyntax": "postcss-html"
}
],
"rules": {
"codeguide/max-line-length": [
120,
{
"ignore": "comments"
}
],
"codeguide/indentation": 2,
"liberty/use-logical-spec": true,
"selector-class-pattern": null,
"color-function-notation": null,
"annotation-no-unknown": [
true,
{
"ignoreAnnotations": [
"default"
]
}
],
"media-feature-range-notation": null
}
}

23
.vscode/anchor-comments.code-snippets vendored Executable file
View File

@ -0,0 +1,23 @@
{
"Add hand emoji": {
"prefix": "cm-hand-emoji",
"body": [
"👉"
],
"description": "Add hand emoji"
},
"Add info emoji": {
"prefix": "cm-info-emoji",
"body": [
""
],
"description": "Add info emoji"
},
"Add warning emoji": {
"prefix": "cm-warning-emoji",
"body": [
"❗"
],
"description": "Add warning emoji"
}
}

15
.vscode/extensions.json vendored Executable file
View File

@ -0,0 +1,15 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"editorconfig.editorconfig",
"xabikos.javascriptsnippets",
"stylelint.vscode-stylelint",
"fabiospampinato.vscode-highlight",
"github.vscode-pull-request-github",
"vue.volar",
"antfu.iconify",
"cipchk.cssrem",
"matijao.vue-nuxt-snippets",
"dongido.sync-env"
]
}

113
.vscode/settings.json vendored Executable file
View File

@ -0,0 +1,113 @@
{
"editor.formatOnSave": true,
"files.insertFinalNewline": true,
"javascript.updateImportsOnFileMove.enabled": "always",
"[javascript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
"[typescript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"editor.autoClosingBrackets": "always"
},
"[markdown]": {
"editor.defaultFormatter": "DavidAnson.vscode-markdownlint"
},
// SCSS
"[scss]": {
"editor.defaultFormatter": "stylelint.vscode-stylelint"
},
// JSON
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
// Vue
"[vue]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
},
// Extension: Volar
"volar.preview.port": 3000,
"volar.completion.preferredTagNameCase": "pascal",
// Extension: ESLint
"eslint.options": {
"rulePaths": [
"eslint-internal-rules"
]
},
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true,
"source.fixAll.stylelint": true,
"source.organizeImports": true,
},
"eslint.alwaysShowStatus": true,
"eslint.format.enable": true,
// Extension: Stylelint
"stylelint.packageManager": "pnpm",
"stylelint.validate": [
"css",
"scss",
"vue"
],
// Extension: Spell Checker
"cSpell.words": [
"Composables",
"Customizer",
"flagpack",
"Iconify",
"psudo",
"stylelint",
"touchless",
"triggerer",
"vuetify"
],
// Extension: Comment Anchors
"commentAnchors.tags.list": [
{
"tag": "",
"scope": "hidden",
// This color is taken from "Better Comments" Extension (?)
"highlightColor": "#3498DB",
"styleComment": true,
"isItalic": false,
},
{
"tag": "👉",
"scope": "file",
// This color is taken from "Better Comments" Extension (*)
"highlightColor": "#98C379",
"styleComment": true,
"isItalic": false
},
{
"tag": "❗",
"scope": "hidden",
// This color is taken from "Better Comments" Extension (*)
"highlightColor": "#FF2D00",
"styleComment": true,
"isItalic": false,
},
],
// Extension: fabiospampinato.vscode-highlight
"highlight.regexFlags": "gi",
"highlight.regexes": {
// We flaged this for enforcing logical CSS properties
"(100vh|translate|margin:|padding:|margin-left|margin-right|rotate|text-align|border-top|border-right|border-bottom|border-left|float|background-position|transform|width|height|top|left|bottom|right|float|clear|(p|m)(l|r)-|border-(start|end)-(start|end)-radius)": [
{
// "rangeBehavior": 1,
"borderWidth": "1px",
"borderColor": "tomato",
"borderStyle": "solid"
}
],
"(overflow-x:|overflow-y:)": [
{
// "rangeBehavior": 1,
"borderWidth": "1px",
"borderColor": "green",
"borderStyle": "solid"
}
]
}
}

18
.vscode/vue-ts.code-snippets vendored Executable file
View File

@ -0,0 +1,18 @@
{
"Vue TS - DefineProps": {
"prefix": "dprops",
"body": [
"defineProps<${1:Props}>()"
],
"description": "DefineProps in script setup"
},
"Vue TS - Props interface": {
"prefix": "iprops",
"body": [
"interface Props {",
" ${1}",
"}"
],
"description": "Create props interface in script setup"
}
}

63
.vscode/vue.code-snippets vendored Executable file
View File

@ -0,0 +1,63 @@
{
"script": {
"prefix": "vue-sfc-ts",
"body": [
"<script lang=\"ts\" setup>",
"",
"</script>",
"",
"<template>",
" ",
"</template>",
"",
"<style lang=\"scss\">",
"",
"</style>",
""
],
"description": "Vue SFC Typescript"
},
"template": {
"scope": "vue",
"prefix": "template",
"body": [
"<template>",
" $1",
"</template>"
],
"description": "Create <template> block"
},
"Script setup + TS": {
"prefix": "script-setup-ts",
"body": [
"<script setup lang=\"ts\">",
"${1}",
"</script>"
],
"description": "Script setup + TS"
},
"style": {
"scope": "vue",
"prefix": "style",
"body": [
"<style lang=\"scss\">",
"$1",
"</style>"
],
"description": "Create <style> block"
},
"use composable": {
"prefix": "use-composable",
"body": [
"const { $2 } = ${1:useComposable}()"
],
"description": "We frequently uses composable in our components and writing const {} = useModule() is tedious. This snippet helps you to write it quickly."
},
"template interpolation": {
"prefix": "cc",
"body": [
"{{ ${1} }}"
],
"description": "We are just making writing template interpolation easier."
}
}

52
.vscode/vuetify.code-snippets vendored Executable file
View File

@ -0,0 +1,52 @@
{
"Vuetify Menu -- Parent Activator": {
"prefix": "v-menu",
"body": [
"<v-btn color=\"primary\">",
" Activator",
" <v-menu activator=\"parent\">",
" <v-list>",
" <v-list-item",
" v-for=\"(item, index) in ['apple', 'banana', 'cherry']\"",
" :key=\"index\"",
" :value=\"index\"",
" >",
" <v-list-item-title>{{ item }}</v-list-item-title>",
" </v-list-item>",
" </v-list>",
" </v-menu>",
"</v-btn>"
],
"description": "We use menu component with parent activator mostly because it is compact and easy to understand."
},
"Vuetify CSS variable": {
"prefix": "v-css-var",
"body": [
"rgb(var(--v-${1:theme}))"
],
"description": "Vuetify CSS variable"
},
"Icon only button": {
"prefix": "IconBtn",
"body": [
"<IconBtn>",
" <VIcon icon=\"tabler-${1}\" />",
"</IconBtn>"
],
"description": "Icon only button"
},
"Radio Group": {
"prefix": "v-radio-grp",
"body": [
"<v-radio-group v-model=\"${1:modelValue}\">",
" <v-radio",
" v-for=\"item in ['apple', 'banana', 'cherry']\"",
" :key=\"item\"",
" :label=\"item\"",
" :value=\"item\"",
" />",
"</v-radio-group>"
],
"description": "Radio Group"
}
}

35
README.md Executable file
View File

@ -0,0 +1,35 @@
# vue
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) (and disable Vetur).
## Type Support for `.vue` Imports in TS
Since TypeScript cannot handle type information for `.vue` imports, they are shimmed to be a generic Vue component type by default. In most cases this is fine if you don't really care about component prop types outside of templates.
However, if you wish to get actual prop types in `.vue` imports (for example to get props validation when using manual `h(...)` calls), you can run `Volar: Switch TS Plugin on/off` from VS Code command palette.
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```

1049
auto-imports.d.ts vendored Executable file

File diff suppressed because it is too large Load Diff

64
components.d.ts vendored Executable file
View File

@ -0,0 +1,64 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
AddAuthenticatorAppDialog: typeof import('./src/components/dialogs/AddAuthenticatorAppDialog.vue')['default']
AddEditAddressDialog: typeof import('./src/components/dialogs/AddEditAddressDialog.vue')['default']
AddEditPermissionDialog: typeof import('./src/components/dialogs/AddEditPermissionDialog.vue')['default']
AddEditRoleDialog: typeof import('./src/components/dialogs/AddEditRoleDialog.vue')['default']
AddPaymentMethodDialog: typeof import('./src/components/dialogs/AddPaymentMethodDialog.vue')['default']
AppAutocomplete: typeof import('./src/@core/components/app-form-elements/AppAutocomplete.vue')['default']
AppBarSearch: typeof import('./src/@core/components/AppBarSearch.vue')['default']
AppCardActions: typeof import('./src/@core/components/cards/AppCardActions.vue')['default']
AppCardCode: typeof import('./src/@core/components/cards/AppCardCode.vue')['default']
AppCombobox: typeof import('./src/@core/components/app-form-elements/AppCombobox.vue')['default']
AppDateTimePicker: typeof import('./src/@core/components/app-form-elements/AppDateTimePicker.vue')['default']
AppDrawerHeaderSection: typeof import('./src/@core/components/AppDrawerHeaderSection.vue')['default']
AppLoadingIndicator: typeof import('./src/components/AppLoadingIndicator.vue')['default']
AppPricing: typeof import('./src/components/AppPricing.vue')['default']
AppSearchHeader: typeof import('./src/components/AppSearchHeader.vue')['default']
AppSelect: typeof import('./src/@core/components/app-form-elements/AppSelect.vue')['default']
AppStepper: typeof import('./src/@core/components/AppStepper.vue')['default']
AppTextarea: typeof import('./src/@core/components/app-form-elements/AppTextarea.vue')['default']
AppTextField: typeof import('./src/@core/components/app-form-elements/AppTextField.vue')['default']
BuyNow: typeof import('./src/@core/components/BuyNow.vue')['default']
CardAddEditDialog: typeof import('./src/components/dialogs/CardAddEditDialog.vue')['default']
CardStatisticsHorizontal: typeof import('./src/@core/components/cards/CardStatisticsHorizontal.vue')['default']
CardStatisticsVertical: typeof import('./src/@core/components/cards/CardStatisticsVertical.vue')['default']
CardStatisticsVerticalSimple: typeof import('./src/@core/components/CardStatisticsVerticalSimple.vue')['default']
ConfirmDialog: typeof import('./src/components/dialogs/ConfirmDialog.vue')['default']
CreateAppDialog: typeof import('./src/components/dialogs/CreateAppDialog.vue')['default']
CustomCheckboxes: typeof import('./src/@core/components/app-form-elements/CustomCheckboxes.vue')['default']
CustomCheckboxesWithIcon: typeof import('./src/@core/components/app-form-elements/CustomCheckboxesWithIcon.vue')['default']
CustomCheckboxesWithImage: typeof import('./src/@core/components/app-form-elements/CustomCheckboxesWithImage.vue')['default']
CustomizerSection: typeof import('./src/@core/components/CustomizerSection.vue')['default']
CustomRadios: typeof import('./src/@core/components/app-form-elements/CustomRadios.vue')['default']
CustomRadiosWithIcon: typeof import('./src/@core/components/app-form-elements/CustomRadiosWithIcon.vue')['default']
CustomRadiosWithImage: typeof import('./src/@core/components/app-form-elements/CustomRadiosWithImage.vue')['default']
DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default']
EnableOneTimePasswordDialog: typeof import('./src/components/dialogs/EnableOneTimePasswordDialog.vue')['default']
ErrorHeader: typeof import('./src/components/ErrorHeader.vue')['default']
I18n: typeof import('./src/@core/components/I18n.vue')['default']
MoreBtn: typeof import('./src/@core/components/MoreBtn.vue')['default']
Notifications: typeof import('./src/@core/components/Notifications.vue')['default']
PaymentProvidersDialog: typeof import('./src/components/dialogs/PaymentProvidersDialog.vue')['default']
PricingPlanDialog: typeof import('./src/components/dialogs/PricingPlanDialog.vue')['default']
ReferAndEarnDialog: typeof import('./src/components/dialogs/ReferAndEarnDialog.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
ScrollToTop: typeof import('./src/@core/components/ScrollToTop.vue')['default']
ShareProjectDialog: typeof import('./src/components/dialogs/ShareProjectDialog.vue')['default']
Shortcuts: typeof import('./src/@core/components/Shortcuts.vue')['default']
TheCustomizer: typeof import('./src/@core/components/TheCustomizer.vue')['default']
ThemeSwitcher: typeof import('./src/@core/components/ThemeSwitcher.vue')['default']
TiptapEditor: typeof import('./src/@core/components/TiptapEditor.vue')['default']
TwoFactorAuthDialog: typeof import('./src/components/dialogs/TwoFactorAuthDialog.vue')['default']
UserInfoEditDialog: typeof import('./src/components/dialogs/UserInfoEditDialog.vue')['default']
UserUpgradePlanDialog: typeof import('./src/components/dialogs/UserUpgradePlanDialog.vue')['default']
}
}

12
env.d.ts vendored Executable file
View File

@ -0,0 +1,12 @@
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
action?: string
subject?: string
layoutWrapperClasses?: string
navActiveLink?: RouteLocationRaw
layout?: 'blank' | 'default'
unauthenticatedOnly?: boolean
public?: boolean
}
}

View File

@ -0,0 +1,14 @@
module.exports = {
rules: {
'valid-appcardcode-code-prop': 'error',
'valid-appcardcode-demo-sfc': 'error',
},
overrides: [
{
files: ['*.json'],
rules: {
'no-invalid-meta': 'off',
},
},
],
}

View File

@ -0,0 +1,53 @@
'use strict'
// ------------------------------------------------------------------------------
// Requirements
// ------------------------------------------------------------------------------
// eslint-disable-next-line @typescript-eslint/no-var-requires
const utils = require('eslint-plugin-vue/lib/utils')
function toCamelCase(str) {
return str.toLowerCase().replace(/[^a-zA-Z0-9]+(.)/g, (m, chr) => chr.toUpperCase())
}
// ------------------------------------------------------------------------------
// Rule Definition
// ------------------------------------------------------------------------------
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'require valid prop for "AppCardCode" component',
categories: ['base'],
url: 'https://eslint.vuejs.org/rules/require-component-is.html',
},
fixable: null,
schema: [],
},
/** @param {RuleContext} context */
create(context) {
return utils.defineTemplateBodyVisitor(context, {
/** @param {VElement} node */
'VElement[name=\'appcardcode\']': function (node) {
const propTitle = utils.getAttribute(node, 'title')
const propCode = utils.getDirective(node, 'bind', 'code')
const titleValue = propTitle.value.value
const demoCodeProperty = propCode.value.expression.property.name
const camelCasedTitle = toCamelCase(titleValue)
if (camelCasedTitle !== demoCodeProperty) {
context.report({
node,
loc: propCode.value.expression.property.loc,
message: `Expected 'code' prop value to match the camelcase value of title prop value. Expected: '${camelCasedTitle}', Actual: '${demoCodeProperty}'`,
})
}
},
})
},
}

View File

@ -0,0 +1,82 @@
'use strict'
// ------------------------------------------------------------------------------
// Requirements
// ------------------------------------------------------------------------------
// eslint-disable-next-line @typescript-eslint/no-var-requires
const utils = require('eslint-plugin-vue/lib/utils')
function toPascalCase(str) {
const words = str.match(/[a-z]+/gi)
if (!words)
return ''
return words
.map(word => {
return word.charAt(0).toUpperCase() + word.substr(1).toLowerCase()
})
.join('')
}
function findDemoElement(node) {
let el = null
node.children.forEach(child => {
if (child.children && child.children.length) {
const r = findDemoElement(child)
if (r)
el = r
}
else {
const r = child.type === 'VElement' && child.name.startsWith('demo')
if (r)
el = child
}
})
return el
}
// ------------------------------------------------------------------------------
// Rule Definition
// ------------------------------------------------------------------------------
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'require valid demo SFC for "AppCardCode" component',
categories: ['base'],
url: 'https://eslint.vuejs.org/rules/require-component-is.html',
},
fixable: null,
schema: [],
},
/** @param {RuleContext} context */
create(context) {
return utils.defineTemplateBodyVisitor(context, {
/** @param {VElement} node */
'VElement[name=\'appcardcode\']': function (node) {
const propTitle = utils.getAttribute(node, 'title')
const titleValue = propTitle.value.value
const pascalCaseTitle = toPascalCase(titleValue)
const demoNode = findDemoElement(node)
const demoNodeSfcName = demoNode.rawName
const pattern = new RegExp(`Demo[a-zA-z]+${pascalCaseTitle}`)
const demoSfcMatch = demoNodeSfcName.search(pattern)
if (demoSfcMatch !== 0) {
context.report({
node,
loc: demoNode.loc,
message: `Expected Demo SFC to match the pascal case value of title prop value. Expected: 'Demo[a-zA-Z]+${pascalCaseTitle}', Actual: '${demoNodeSfcName}'`,
})
}
},
})
},
}

49
index.html Executable file
View File

@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vuexy - Vuejs Admin Dashboard Template</title>
<link rel="stylesheet" type="text/css" href="/loader.css" />
</head>
<body>
<div id="app">
<div id="loading-bg">
<div class="loading-logo">
<!-- SVG Logo -->
<svg width="86" height="48" viewBox="0 0 34 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M0.00183571 0.3125V7.59485C0.00183571 7.59485 -0.141502 9.88783 2.10473 11.8288L14.5469 23.6837L21.0172 23.6005L19.9794 10.8126L17.5261 7.93369L9.81536 0.3125H0.00183571Z"
fill="var(--initial-loader-color)" />
<path opacity="0.06" fill-rule="evenodd" clip-rule="evenodd"
d="M8.17969 17.7762L13.3027 3.75173L17.589 8.02192L8.17969 17.7762Z" fill="#161616" />
<path opacity="0.06" fill-rule="evenodd" clip-rule="evenodd"
d="M8.58203 17.2248L14.8129 5.24231L17.6211 8.05247L8.58203 17.2248Z" fill="#161616" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M8.25781 17.6914L25.1339 0.3125H33.9991V7.62657C33.9991 7.62657 33.8144 10.0645 32.5743 11.3686L21.0179 23.6875H14.5487L8.25781 17.6914Z"
fill="var(--initial-loader-color)" />
</svg>
</div>
<div class=" loading">
<div class="effect-1 effects"></div>
<div class="effect-2 effects"></div>
<div class="effect-3 effects"></div>
</div>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
<script>
const loaderColor = localStorage.getItem('vuexy-initial-loader-bg') || '#FFFFFF'
const primaryColor = localStorage.getItem('vuexy-initial-loader-color') || '#7367F0'
if (loaderColor)
document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
if (primaryColor)
document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
</script>
</body>
</html>

136
package.json Executable file
View File

@ -0,0 +1,136 @@
{
"name": "vuexy-vuejs-admin-template",
"version": "9.0.1",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --port 5050",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint . -c .eslintrc.cjs --fix --rulesdir eslint-internal-rules/ --ext .ts,.js,.cjs,.vue,.tsx,.jsx",
"build:icons": "tsx src/plugins/iconify/build-icons.ts",
"msw:init": "msw init public/ --save",
"postinstall": "npm run build:icons && npm run msw:init"
},
"dependencies": {
"@casl/ability": "^6.5.0",
"@casl/vue": "^2.2.1",
"@floating-ui/dom": "1.5.3",
"@iconify-json/fa": "^1.1.5",
"@iconify-json/tabler": "^1.1.96",
"@sindresorhus/is": "^6.0.1",
"@tiptap/extension-highlight": "^2.1.12",
"@tiptap/extension-image": "^2.1.12",
"@tiptap/extension-link": "^2.1.12",
"@tiptap/extension-text-align": "^2.1.12",
"@tiptap/pm": "^2.1.12",
"@tiptap/starter-kit": "^2.1.12",
"@tiptap/vue-3": "^2.1.12",
"@vueuse/core": "^10.5.0",
"@vueuse/math": "^10.5.0",
"apexcharts-clevision": "^3.28.5",
"chart.js": "^4.4.0",
"cookie-es": "^1.0.0",
"jwt-decode": "^3.1.2",
"mapbox-gl": "2.15.0",
"ofetch": "^1.3.3",
"pinia": "^2.1.7",
"prismjs": "^1.29.0",
"roboto-fontface": "^0.10.0",
"shepherd.js": "^11.2.0",
"swiper": "^10.3.1",
"ufo": "^1.3.1",
"unplugin-vue-define-options": "^1.3.18",
"vue": "^3.3.4",
"vue-chartjs": "^5.2.0",
"vue-flatpickr-component": "11.0.3",
"vue-i18n": "^9.5.0",
"vue-prism-component": "^2.0.0",
"vue-router": "^4.2.5",
"vue3-apexcharts": "^1.4.4",
"vue3-perfect-scrollbar": "^1.6.1",
"vuetify": "3.3.22",
"webfontloader": "^1.6.28"
},
"devDependencies": {
"@antfu/eslint-config-vue": "^0.43.1",
"@antfu/utils": "^0.7.6",
"@fullcalendar/core": "^6.1.9",
"@fullcalendar/daygrid": "^6.1.9",
"@fullcalendar/interaction": "^6.1.9",
"@fullcalendar/list": "^6.1.9",
"@fullcalendar/timegrid": "^6.1.9",
"@fullcalendar/vue3": "^6.1.9",
"@iconify-json/mdi": "^1.1.54",
"@iconify/tools": "^3.0.5",
"@iconify/utils": "^2.1.11",
"@iconify/vue": "4.1.1",
"@intlify/unplugin-vue-i18n": "^1.4.0",
"@tabler/icons": "^2.39.0",
"@tiptap/extension-character-count": "^2.1.12",
"@tiptap/extension-placeholder": "^2.1.12",
"@tiptap/extension-subscript": "^2.1.12",
"@tiptap/extension-superscript": "^2.1.12",
"@tiptap/extension-underline": "^2.1.12",
"@types/mapbox-gl": "^2.7.16",
"@types/node": "^20.8.6",
"@types/webfontloader": "^1.6.35",
"@typescript-eslint/eslint-plugin": "^6.8.0",
"@typescript-eslint/parser": "^6.8.0",
"@videojs-player/vue": "^1.0.0",
"@vitejs/plugin-vue": "^4.4.0",
"@vitejs/plugin-vue-jsx": "^3.0.2",
"eslint": "^8.51.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-case-police": "^0.6.1",
"eslint-plugin-import": "^2.28.1",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-regex": "^1.10.0",
"eslint-plugin-sonarjs": "^0.21.0",
"eslint-plugin-unicorn": "^48.0.1",
"eslint-plugin-vue": "^9.17.0",
"msw": "^1.3.2",
"postcss-html": "^1.5.0",
"postcss-scss": "^4.0.9",
"sass": "^1.69.4",
"stylelint": "15.11.0",
"stylelint-codeguide": "0.3.2",
"stylelint-config-idiomatic-order": "9.0.0",
"stylelint-config-standard-scss": "11.0.0",
"stylelint-use-logical-spec": "5.0.0",
"tsx": "^3.14.0",
"type-fest": "^4.4.0",
"typescript": "^5.2.2",
"unplugin-auto-import": "^0.16.6",
"unplugin-vue-components": "^0.25.2",
"unplugin-vue-router": "^0.7.0",
"video.js": "^8.6.0",
"vite": "^4.4.11",
"vite-plugin-vue-devtools": "1.0.0-rc.5",
"vite-plugin-vue-layouts": "^0.8.0",
"vite-plugin-vuetify": "1.0.2",
"vue-shepherd": "^3.0.0",
"vue-tsc": "^1.8.19"
},
"resolutions": {
"postcss": "^8",
"@tiptap/core": "^2",
"@types/video.js": "^7",
"pug": "^3",
"apexcharts": "^3",
"stylelint-order": "6.0.3"
},
"overrides": {
"postcss": "^8",
"@tiptap/core": "^2",
"@types/video.js": "^7",
"pug": "^3",
"apexcharts": "^3",
"stylelint-order": "6.0.3"
},
"packageManager": "pnpm@8.6.2",
"msw": {
"workerDirectory": "public"
}
}

8251
pnpm-lock.yaml Executable file

File diff suppressed because it is too large Load Diff

BIN
public/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

79
public/loader.css Executable file
View File

@ -0,0 +1,79 @@
body {
margin: 0;
}
html {
overflow-x: hidden;
overflow-y: scroll;
}
#loading-bg {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--initial-loader-bg, #fff);
block-size: 100%;
gap: 1rem 0;
inline-size: 100%;
}
.loading {
position: relative;
box-sizing: border-box;
border: 3px solid transparent;
border-radius: 50%;
block-size: 55px;
inline-size: 55px;
}
.loading .effect-1,
.loading .effect-2,
.loading .effect-3 {
position: absolute;
box-sizing: border-box;
border: 3px solid transparent;
border-radius: 50%;
block-size: 100%;
border-inline-start: 3px solid var(--initial-loader-color, #eee);
inline-size: 100%;
}
.loading .effect-1 {
animation: rotate 1s ease infinite;
}
.loading .effect-2 {
animation: rotate-opacity 1s ease infinite 0.1s;
}
.loading .effect-3 {
animation: rotate-opacity 1s ease infinite 0.2s;
}
.loading .effects {
transition: all 0.3s ease;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(1turn);
}
}
@keyframes rotate-opacity {
0% {
opacity: 0.1;
transform: rotate(0deg);
}
100% {
opacity: 1;
transform: rotate(1turn);
}
}

15
shims.d.ts vendored Executable file
View File

@ -0,0 +1,15 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module 'vue-prism-component' {
import { ComponentOptions } from 'vue'
const component: ComponentOptions
export default component
}
declare module 'vue-shepherd';
declare module '@videojs-player/vue';

View File

@ -0,0 +1,266 @@
<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[]
}
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 ? '550' : '100%'"
:fullscreen="$vuetify.display.width < 600"
class="app-bar-search-dialog"
@update:model-value="dialogModelValueUpdate"
@keyup.esc="clearSearchAndCloseDialog"
>
<VCard
height="100%"
width="100%"
class="position-relative"
>
<VCardText class="pt-2">
<!-- 👉 Search Input -->
<VTextField
ref="refSearchInput"
v-model="searchQueryLocal"
autofocus
density="comfortable"
variant="plain"
@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="22"
icon="tabler-search"
style="opacity: 1;"
/>
</div>
</template>
<!-- 👉 Append Inner -->
<template #append-inner>
<div class="d-flex align-start">
<div
class="text-base text-disabled cursor-pointer me-1"
@click="clearSearchAndCloseDialog"
>
[esc]
</div>
<IconBtn
size="22"
@click="clearSearchAndCloseDialog"
>
<VIcon
icon="tabler-x"
size="20"
/>
</IconBtn>
</div>
</template>
</VTextField>
</VCardText>
<!-- 👉 Divider -->
<VDivider />
<!-- 👉 Perfect Scrollbar -->
<PerfectScrollbar
:options="{ wheelPropagation: false, suppressScrollX: true }"
class="h-100"
>
<!-- 👉 Search List -->
<VList
v-show="searchQueryLocal.length && !!props.searchResults.length"
ref="refSearchList"
density="compact"
class="app-bar-search-list"
>
<!-- 👉 list Item /List Sub header -->
<template
v-for="item in props.searchResults"
:key="item"
>
<slot
name="searchResult"
:item="item"
>
<VListItem>
{{ item }}
</VListItem>
</slot>
</template>
</VList>
<!-- 👉 Suggestions -->
<div
v-show="!!props.searchResults && !searchQueryLocal && $slots.suggestions"
class="h-100"
>
<slot name="suggestions" />
</div>
<!-- 👉 No Data found -->
<div
v-show="!props.searchResults.length && searchQueryLocal.length"
class="h-100"
>
<slot name="noData">
<VCardText class="h-100">
<div class="app-bar-search-suggestions d-flex flex-column align-center justify-center text-high-emphasis h-100">
<VIcon
size="75"
icon="tabler-file-x"
/>
<div class="d-flex align-center flex-wrap justify-center gap-2 text-h6 my-3">
<span>No Result For </span>
<span>"{{ searchQueryLocal }}"</span>
</div>
<slot name="noDataSuggestion" />
</div>
</VCardText>
</slot>
</div>
</PerfectScrollbar>
</VCard>
</VDialog>
</template>
<style lang="scss">
.app-bar-search-suggestions {
.app-bar-search-suggestion {
&:hover {
color: rgb(var(--v-theme-primary));
}
}
}
.app-bar-search-dialog {
.v-overlay__scrim {
backdrop-filter: blur(4px);
}
.v-list-item-title {
font-size: 0.875rem !important;
}
.v-input{
.v-field{
.v-field__field{
input{
padding-block-start: 1rem !important;
}
}
}
}
.app-bar-search-list {
.v-list-item,
.v-list-subheader {
font-size: 0.75rem;
padding-inline: 1.5rem !important;
}
.v-list-item {
.v-list-item__append {
.enter-icon {
visibility: hidden;
}
}
&:hover,
&:active,
&:focus {
.v-list-item__append {
.enter-icon {
visibility: visible;
}
}
}
}
.v-list-subheader {
line-height: 1;
min-block-size: auto;
padding-block: 0.6875rem 0.3125rem;
text-transform: uppercase;
}
}
}
</style>
<style lang="scss" scoped>
.card-list {
--v-card-list-gap: 16px;
}
</style>

View File

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

View File

@ -0,0 +1,333 @@
<script setup lang="ts">
interface Item {
title: string
icon?: string
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'
}
interface Emit {
(e: 'update:currentStep', value: number): void
}
const props = withDefaults(defineProps<Props>(), {
currentStep: 0,
direction: 'horizontal',
iconSize: 52,
isActiveStepValid: undefined,
align: 'center',
})
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}`"
>
<VSlideGroupItem
v-for="(item, index) in props.items"
:key="item.title"
:value="index"
>
<div
class="cursor-pointer mx-1"
:class="[
(!props.isActiveStepValid && (isValidationEnabled)) && 'stepper-steps-invalid',
activeOrCompletedStepsClasses(index),
]"
@click="!isValidationEnabled && emit('update:currentStep', index)"
>
<!-- SECTION stepper step with icon -->
<template v-if="item.icon">
<div class="stepper-icon-step text-high-emphasis d-flex align-center gap-2">
<!-- 👉 icon and title -->
<div
class="d-flex align-center gap-4 step-wrapper"
:class="[props.direction === 'horizontal' && 'flex-column']"
>
<div class="stepper-icon">
<VIcon
:icon="item.icon"
:size="item.size || props.iconSize"
/>
</div>
<div>
<p class="stepper-title font-weight-medium mb-0">
{{ item.title }}
</p>
<span
v-if="item.subtitle"
class="stepper-subtitle"
>
<span class="text-sm">{{ item.subtitle }}</span>
</span>
</div>
</div>
<!-- 👉 append chevron -->
<VIcon
v-if="isHorizontalAndNotLastStep(index)"
class="flip-in-rtl stepper-chevron-indicator mx-6"
size="24"
icon="tabler-chevron-right"
/>
</div>
</template>
<!-- !SECTION -->
<!-- SECTION stepper step without icon -->
<template v-else>
<div class="d-flex align-center gap-x-4">
<div class="d-flex align-center gap-2">
<div class="d-flex align-center justify-center">
<!-- 👉 custom circle icon -->
<template v-if="index >= currentStep">
<VBtn
v-if="(!isValidationEnabled || props.isActiveStepValid || index !== currentStep)"
size="40"
:variant="index === currentStep ? 'elevated' : 'tonal'"
:color="index === currentStep ? 'primary' : 'default'"
>
<h5
class="text-h5"
:style="index === currentStep ? { color: '#fff' } : ''"
>
{{ index + 1 }}
</h5>
</VBtn>
<VIcon
v-else
icon="tabler-alert-circle"
size="24"
color="error"
/>
</template>
<!-- 👉 step completed icon -->
<VBtn
v-else
class="stepper-icon"
variant="tonal"
color="primary"
size="40"
>
<h5
class="text-h5"
style="color: rgb(var(--v-theme-primary))"
>
{{ index + 1 }}
</h5>
</VBtn>
</div>
</div>
<!-- 👉 title and subtitle -->
<div class="d-flex flex-column justify-center">
<div class="step-title font-weight-medium">
{{ item.title }}
</div>
<div
v-if="item.subtitle"
class="step-subtitle text-sm text-disabled"
>
{{ item.subtitle }}
</div>
</div>
<!-- 👉 stepper step icon -->
<div
v-if="isHorizontalAndNotLastStep(index)"
class="stepper-step-line"
>
<VIcon
icon="tabler-chevron-right"
size="24"
/>
</div>
</div>
</template>
<!-- !SECTION -->
</div>
</VSlideGroupItem>
</VSlideGroup>
</template>
<style lang="scss">
.app-stepper {
// 👉 stepper step with bg color
&.stepper-icon-step-bg {
.stepper-icon-step {
.step-wrapper {
flex-direction: row !important;
}
.stepper-icon {
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.3125rem;
background-color: rgba(var(--v-theme-on-surface), var(--v-selected-opacity));
block-size: 2.5rem;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
inline-size: 2.5rem;
margin-inline-end: 0.3rem;
}
.stepper-title,
.stepper-subtitle {
line-height: normal;
}
.stepper-title {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 0.9375rem;
font-weight: 500 !important;
}
.stepper-subtitle {
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
font-size: 0.875rem;
}
}
.stepper-steps-active {
.stepper-icon-step {
.stepper-icon {
background-color: rgb(var(--v-theme-primary));
color: rgba(var(--v-theme-on-primary));
}
}
}
.stepper-steps-completed {
.stepper-icon-step {
.stepper-icon {
background: rgba(var(--v-theme-primary), 0.08);
color: rgba(var(--v-theme-primary));
}
}
}
}
// 👉 stepper step with icon and default
.v-slide-group__content {
row-gap: 1.5rem;
.stepper-step-indicator {
block-size: 3rem;
opacity: var(--v-activated-opacity);
}
.stepper-step-line {
opacity: var(--v-activated-opacity);
}
.stepper-chevron-indicator {
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
}
.stepper-steps-completed,
.stepper-steps-active {
.stepper-icon-step,
.stepper-step-icon {
color: rgb(var(--v-theme-primary)) !important;
}
.stepper-step-indicator {
opacity: 1;
}
}
.stepper-steps-completed {
.stepper-step-line {
opacity: 1;
}
.stepper-chevron-indicator {
color: rgb(var(--v-theme-primary));
}
}
.stepper-steps-invalid.stepper-steps-active {
.stepper-icon-step,
.step-number,
.step-title,
.step-subtitle {
color: rgb(var(--v-theme-error)) !important;
}
}
}
// 👉 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>

42
src/@core/components/BuyNow.vue Executable file
View File

@ -0,0 +1,42 @@
<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>
<VBtn
id="buy-now-btn"
color="error"
class="product-buy-now"
:href="buyNowUrl"
target="_blank"
rel="noopener noreferrer"
>
Buy Now
</VBtn>
</template>
<style lang="scss" scoped>
.product-buy-now {
position: fixed;
// To keep buy now button on top of v-layout. E.g. Email app
z-index: 999;
inset-block-end: 5%;
inset-inline-end: 79px;
body &.v-btn.v-btn--elevated {
box-shadow: 0 1px 20px 1px rgb(var(--v-theme-error)) !important;
&:hover {
box-shadow: none !important;
}
}
}
</style>

View File

@ -0,0 +1,32 @@
<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="42"
variant="tonal"
:color="props.color"
>
<VIcon :icon="props.icon" />
</VAvatar>
<h5 class="text-h5 my-2">
{{ props.stats }}
</h5>
<span class="text-body-2">{{ props.title }}</span>
</VCardText>
</VCard>
</template>

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="x-small"
color="primary"
>
<span class="text-xs font-weight-medium">{{ props.title }}</span>
</VChip>
</div>
<slot />
</div>
</template>

View File

@ -0,0 +1,23 @@
<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"
class="v-dialog-close-btn"
>
<VIcon
:icon="props.icon"
:size="props.iconSize"
/>
</IconBtn>
</template>

48
src/@core/components/I18n.vue Executable file
View File

@ -0,0 +1,48 @@
<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
size="26"
icon="tabler-language"
/>
<!-- Menu -->
<VMenu
activator="parent"
:location="props.location"
offset="14px"
>
<!-- List -->
<VList
:selected="[locale]"
color="primary"
min-width="175px"
>
<!-- 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,27 @@
<script lang="ts" setup>
interface Props {
menuList?: unknown[]
itemProps?: boolean
}
const props = defineProps<Props>()
</script>
<template>
<IconBtn
density="compact"
color="disabled"
>
<VIcon icon="tabler-dots-vertical" />
<VMenu
v-if="props.menuList"
activator="parent"
>
<VList
:items="props.menuList"
:item-props="props.itemProps"
/>
</VMenu>
</IconBtn>
</template>

View File

@ -0,0 +1,230 @@
<script lang="ts" setup>
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
import type { Notification } from '@layouts/types'
interface Props {
notifications: Notification[]
badgeProps?: unknown
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(() => props.notifications.some(item => item.isSeen === false),
)
const markAllReadOrUnread = () => {
const allNotificationsIds = props.notifications.map(item => item.id)
if (!isAllMarkRead.value)
emit('unread', allNotificationsIds)
else
emit('read', allNotificationsIds)
}
const totalUnseenNotifications = computed(() => {
return props.notifications.filter(item => item.isSeen === false).length
})
</script>
<template>
<IconBtn id="notification-btn">
<VBadge
v-bind="props.badgeProps"
:model-value="props.notifications.some(n => !n.isSeen)"
color="error"
:content="totalUnseenNotifications"
class="notification-badge"
>
<VIcon
size="26"
icon="tabler-bell"
/>
</VBadge>
<VMenu
activator="parent"
width="380px"
:location="props.location"
offset="14px"
:close-on-content-click="false"
>
<VCard class="d-flex flex-column">
<!-- 👉 Header -->
<VCardItem class="notification-section">
<VCardTitle class="text-lg">
Notifications
</VCardTitle>
<template #append>
<IconBtn
v-show="props.notifications.length"
@click="markAllReadOrUnread"
>
<VIcon :icon="!isAllMarkRead ? 'tabler-mail' : 'tabler-mail-opened' " />
<VTooltip
activator="parent"
location="start"
>
{{ !isAllMarkRead ? 'Mark all as unread' : 'Mark all as read' }}
</VTooltip>
</IconBtn>
</template>
</VCardItem>
<VDivider />
<!-- 👉 Notifications list -->
<PerfectScrollbar
:options="{ wheelPropagation: false }"
style="max-block-size: 23.75rem;"
>
<VList class="notification-list rounded-0 py-0">
<template
v-for="(notification, index) in props.notifications"
:key="notification.title"
>
<VDivider v-if="index > 0" />
<VListItem
link
lines="one"
min-height="66px"
class="list-item-hover-class"
@click="$emit('click:notification', notification)"
>
<!-- Slot: Prepend -->
<!-- Handles Avatar: Image, Icon, Text -->
<template #prepend>
<VListItemAction start>
<VAvatar
size="40"
:color="notification.color && notification.icon ? notification.color : undefined"
:image="notification.img || undefined"
:icon="notification.icon || undefined"
:variant="notification.img ? undefined : 'tonal' "
>
<span v-if="notification.text">{{ avatarText(notification.text) }}</span>
</VAvatar>
</VListItemAction>
</template>
<VListItemTitle class="font-weight-medium">
{{ notification.title }}
</VListItemTitle>
<VListItemSubtitle>{{ notification.subtitle }}</VListItemSubtitle>
<span class="text-xs text-disabled">{{ notification.time }}</span>
<!-- Slot: Append -->
<template #append>
<div class="d-flex flex-column align-center gap-4">
<VBadge
dot
:color="!notification.isSeen ? 'primary' : '#a8aaae'"
:class="`${notification.isSeen ? 'visible-in-hover' : ''} ms-1`"
@click.stop="$emit(notification.isSeen ? 'unread' : 'read', [notification.id])"
/>
<div style="block-size: 28px; inline-size: 28px;">
<IconBtn
size="small"
class="visible-in-hover"
@click="$emit('remove', notification.id)"
>
<VIcon
size="20"
icon="tabler-x"
/>
</IconBtn>
</div>
</div>
</template>
</VListItem>
</template>
<VListItem
v-show="!props.notifications.length"
class="text-center text-medium-emphasis"
style="block-size: 56px;"
>
<VListItemTitle>No Notification Found!</VListItemTitle>
</VListItem>
</VList>
</PerfectScrollbar>
<VDivider />
<!-- 👉 Footer -->
<VCardActions
v-show="props.notifications.length"
class="notification-footer"
>
<VBtn block>
View All Notifications
</VBtn>
</VCardActions>
</VCard>
</VMenu>
</IconBtn>
</template>
<style lang="scss">
.notification-section {
padding: 14px !important;
}
.notification-footer {
padding: 6px !important;
}
.list-item-hover-class {
.visible-in-hover {
display: none;
}
&:hover {
.visible-in-hover {
display: block;
}
}
}
.notification-list.v-list {
.v-list-item {
border-radius: 0 !important;
margin: 0 !important;
&[tabindex="-2"]:not(.v-list-item--active) {
&:hover,
&:focus-visible {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
.v-list-item-subtitle {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
}
}
}
}
}
// 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,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,85 @@
<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
size="26"
:icon="props.togglerIcon"
/>
<VMenu
activator="parent"
offset="14px"
location="bottom end"
>
<VCard
width="340"
max-height="560"
class="d-flex flex-column"
>
<VCardItem class="py-4">
<VCardTitle>Shortcuts</VCardTitle>
<template #append>
<IconBtn>
<VIcon icon="tabler-layout-grid-add" />
</IconBtn>
</template>
</VCardItem>
<VDivider />
<PerfectScrollbar :options="{ wheelPropagation: false }">
<VRow class="ma-0 mt-n1">
<VCol
v-for="(shortcut, index) in props.shortcuts"
:key="shortcut.title"
cols="6"
class="text-center border-t cursor-pointer pa-4 shortcut-icon"
:class="(index + 1) % 2 ? 'border-e' : ''"
@click="router.push(shortcut.to)"
>
<VAvatar
variant="tonal"
size="48"
>
<VIcon :icon="shortcut.icon" />
</VAvatar>
<h6 class="text-base font-weight-medium mt-2 mb-0">
{{ shortcut.title }}
</h6>
<span class="text-sm">{{ shortcut.subtitle }}</span>
</VCol>
</VRow>
</PerfectScrollbar>
</VCard>
</VMenu>
</IconBtn>
</template>
<style lang="scss">
.shortcut-icon:hover {
background-color: rgba(var(--v-theme-on-surface), var(--v-hover-opacity));
}
</style>

View File

@ -0,0 +1,563 @@
<script setup lang="tsx">
import { useStorage } from '@vueuse/core'
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
import { useTheme } from 'vuetify'
import { staticPrimaryColor } from '@/plugins/vuetify/theme'
import { Direction, Layout, Skins, Theme } from '@core/enums'
import { useConfigStore } from '@core/stores/config'
import { AppContentLayoutNav, ContentWidth } from '@layouts/enums'
import { cookieRef, namespaceConfig } from '@layouts/stores/config'
import { themeConfig } from '@themeConfig'
import borderSkinDark from '@images/customizer-icons/border-dark.svg'
import borderSkinLight from '@images/customizer-icons/border-light.svg'
import collapsedDark from '@images/customizer-icons/collapsed-dark.svg'
import collapsedLight from '@images/customizer-icons/collapsed-light.svg'
import compactDark from '@images/customizer-icons/compact-dark.svg'
import compactLight from '@images/customizer-icons/compact-light.svg'
import darkThemeDark from '@images/customizer-icons/dark-theme-dark.svg'
import darkThemeLight from '@images/customizer-icons/dark-theme-light.svg'
import defaultSkinDark from '@images/customizer-icons/default-dark.svg'
import defaultSkinLight from '@images/customizer-icons/default-light.svg'
import lightThemeDark from '@images/customizer-icons/light-theme-dark.svg'
import lightThemeLight from '@images/customizer-icons/light-theme-light.svg'
import ltrDark from '@images/customizer-icons/ltr-dark.svg'
import ltrLight from '@images/customizer-icons/ltr-light.svg'
import rtlDark from '@images/customizer-icons/rtl-dark.svg'
import rtlLight from '@images/customizer-icons/rtl-light.svg'
import systemThemeDark from '@images/customizer-icons/system-theme-dark.svg'
import systemThemeLight from '@images/customizer-icons/system-theme-light.svg'
import wideDark from '@images/customizer-icons/wide-dark.svg'
import wideLight from '@images/customizer-icons/wide-light.svg'
const isNavDrawerOpen = ref(false)
const configStore = useConfigStore()
// 👉 Primary Color
const vuetifyTheme = useTheme()
const colors = [staticPrimaryColor, '#0D9394', '#FFAB1D', '#EB3D63', '#2092EC']
const customPrimaryColor = ref('#ffffff')
watch(
() => configStore.theme,
() => {
const cookiePrimaryColor = cookieRef(`${vuetifyTheme.name.value}ThemePrimaryColor`, null).value
if (cookiePrimaryColor && !colors.includes(cookiePrimaryColor))
customPrimaryColor.value = cookiePrimaryColor
},
{ immediate: true },
)
// It will set primary color for current theme only
const setPrimaryColor = useDebounceFn((color: string) => {
vuetifyTheme.themes.value[vuetifyTheme.name.value].colors.primary = color
// 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
// Update initial loader color
useStorage<string | null>(namespaceConfig('initial-loader-color'), null).value = color
}, 100)
const lightTheme = useGenerateImageVariant(lightThemeLight, lightThemeDark)
const darkTheme = useGenerateImageVariant(darkThemeLight, darkThemeDark)
const systemTheme = useGenerateImageVariant(systemThemeLight, systemThemeDark)
const defaultSkin = useGenerateImageVariant(defaultSkinLight, defaultSkinDark)
const borderSkin = useGenerateImageVariant(borderSkinLight, borderSkinDark)
const collapsed = useGenerateImageVariant(collapsedLight, collapsedDark)
const compact = useGenerateImageVariant(compactLight, compactDark)
const compactContent = useGenerateImageVariant(compactLight, compactDark)
const wideContent = useGenerateImageVariant(wideLight, wideDark)
const ltrImg = useGenerateImageVariant(ltrLight, ltrDark)
const rtlImg = useGenerateImageVariant(rtlLight, rtlDark)
// 👉 Mode
const themeMode = computed(() => {
return [
{
bgImage: lightTheme.value,
value: Theme.Light,
},
{
bgImage: darkTheme.value,
value: Theme.Dark,
},
{
bgImage: systemTheme.value,
value: Theme.System,
},
]
})
// 👉 Skin
const themeSkin = computed(() => {
return [
{
bgImage: defaultSkin.value,
value: Skins.Default,
label: 'Default',
},
{
bgImage: borderSkin.value,
value: Skins.Bordered,
label: 'Bordered',
},
]
})
// 👉 Layout
const currentLayout = ref<'vertical' | 'collapsed' | 'horizontal'>(configStore.isVerticalNavCollapsed ? 'collapsed' : configStore.appContentLayoutNav)
const layouts = computed(() => {
return [
{
bgImage: defaultSkin.value,
value: Layout.Vertical,
label: 'Vertical',
},
{
bgImage: collapsed.value,
value: Layout.Collapsed,
label: 'Collapsed',
},
{
bgImage: compact.value,
value: Layout.Horizontal,
label: 'Horizontal',
},
]
})
watch(currentLayout, () => {
if (currentLayout.value === 'collapsed') {
configStore.isVerticalNavCollapsed = true
configStore.appContentLayoutNav = AppContentLayoutNav.Vertical
}
else {
configStore.isVerticalNavCollapsed = false
configStore.appContentLayoutNav = currentLayout.value
}
})
// watch vertical sidebar collapse state
watch(
() => configStore.isVerticalNavCollapsed,
() => {
currentLayout.value = configStore.isVerticalNavCollapsed
? 'collapsed'
: configStore.appContentLayoutNav
},
)
// 👉 Content Width
const contentWidth = computed(() => {
return [
{
bgImage: compactContent.value,
value: ContentWidth.Boxed,
label: 'Compact',
},
{
bgImage: wideContent.value,
value: ContentWidth.Fluid,
label: 'Wide',
},
]
})
// 👉 Direction
const currentDir = ref(configStore.isAppRTL ? 'rtl' : 'ltr')
const direction = computed(() => {
return [
{
bgImage: ltrImg.value,
value: Direction.Ltr,
label: 'Left to right',
},
{
bgImage: rtlImg.value,
value: Direction.Rtl,
label: 'Right to left',
},
]
})
watch(currentDir, () => {
if (currentDir.value === 'rtl')
configStore.isAppRTL = true
else
configStore.isAppRTL = false
})
// 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 () => {
// reset themeConfig values
vuetifyTheme.themes.value.light.colors.primary = staticPrimaryColor
vuetifyTheme.themes.value.dark.colors.primary = staticPrimaryColor
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 = 'vertical'
cookieRef('lightThemePrimaryColor', null).value = null
cookieRef('darkThemePrimaryColor', null).value = null
await nextTick()
isCookieHasAnyValue.value = false
customPrimaryColor.value = '#ffffff'
}
</script>
<template>
<div class="d-lg-block d-none">
<VBtn
icon
size="small"
class="app-customizer-toggler rounded-s-lg rounded-0"
style="z-index: 1001;"
@click="isNavDrawerOpen = true"
>
<VIcon
size="22"
icon="tabler-settings"
/>
</VBtn>
<VNavigationDrawer
v-model="isNavDrawerOpen"
temporary
touchless
border="0"
location="end"
width="400"
:scrim="false"
class="app-customizer"
>
<!-- 👉 Header -->
<div class="customizer-heading d-flex align-center justify-space-between">
<div>
<h6 class="text-h6">
Theme Customizer
</h6>
<span class="text-body-1">Customize & Preview in Real Time</span>
</div>
<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="-30"
offset-y="-15"
/>
<VIcon
size="22"
icon="tabler-refresh"
/>
</VBtn>
<VBtn
icon
variant="text"
color="medium-emphasis"
size="small"
@click="isNavDrawerOpen = false"
>
<VIcon
icon="tabler-x"
size="22"
/>
</VBtn>
</div>
</div>
<VDivider />
<PerfectScrollbar
tag="ul"
:options="{ wheelPropagation: false }"
>
<!-- SECTION Theming -->
<CustomizerSection
title="Theming"
:divider="false"
>
<!-- 👉 Primary Color -->
<div class="d-flex flex-column gap-3">
<h6 class="text-base font-weight-medium">
Primary Color
</h6>
<div
class="d-flex align-center gap-x-3"
style="column-gap: 0.7rem;"
>
<div
v-for="color in colors"
:key="color"
style="
border-radius: 0.375rem;
outline: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
padding-block: 0.625rem;
padding-inline: 0.625rem;"
class="cursor-pointer"
:style="vuetifyTheme.current.value.colors.primary === color ? `outline-color: ${color}; outline-width:2px;` : ''"
@click="setPrimaryColor(color)"
>
<div
style="border-radius: 0.375rem;block-size: 1.875rem; inline-size: 1.875rem;"
:style="{ backgroundColor: color }"
/>
</div>
<div
class="cursor-pointer"
style="
border-radius: 0.375rem;
outline: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
padding-block: 0.625rem;
padding-inline: 0.625rem;"
: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="22"
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"
/>
</VListItem>
</VList>
</VMenu>
</div>
</div>
</div>
<!-- 👉 Theme -->
<div class="d-flex flex-column gap-3">
<h6 class="text-base font-weight-medium">
Theme
</h6>
<CustomRadiosWithImage
:key="configStore.theme"
v-model:selected-radio="configStore.theme"
:radio-content="themeMode"
:grid-column="{ cols: '4' }"
/>
</div>
<!-- 👉 Skin -->
<div class="d-flex flex-column gap-3">
<h6 class="text-base font-weight-medium">
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="mt-4 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-high-emphasis"
>
Semi Dark Menu
</VLabel>
<div>
<VSwitch
id="customizer-semi-dark"
v-model="configStore.isVerticalNavSemiDark"
class="ms-2"
/>
</div>
</div>
</CustomizerSection>
<!-- !SECTION -->
<!-- SECTION LAYOUT -->
<CustomizerSection title="Layout">
<!-- 👉 Layouts -->
<div class="d-flex flex-column gap-3">
<h6 class="text-base font-weight-medium">
Layout
</h6>
<CustomRadiosWithImage
:key="currentLayout"
v-model:selected-radio="currentLayout"
:radio-content="layouts"
:grid-column="{ cols: '4' }"
>
<template #label="item">
<span class="text-sm text-medium-emphasis">{{ item.label }}</span>
</template>
</CustomRadiosWithImage>
</div>
<!-- 👉 Content Width -->
<div class="d-flex flex-column gap-3">
<h6 class="text-base font-weight-medium">
Content
</h6>
<CustomRadiosWithImage
:key="configStore.appContentWidth"
v-model:selected-radio="configStore.appContentWidth"
:radio-content="contentWidth"
:grid-column="{ cols: '4' }"
>
<template #label="item">
<span class="text-sm text-medium-emphasis">{{ item.label }}</span>
</template>
</CustomRadiosWithImage>
</div>
<!-- 👉 Direction -->
<div class="d-flex flex-column gap-3">
<h6 class="text-base font-weight-medium">
Direction
</h6>
<CustomRadiosWithImage
:key="currentDir"
v-model:selected-radio="currentDir"
:radio-content="direction"
:grid-column="{ cols: '4' }"
>
<template #label="item">
<span class="text-sm text-medium-emphasis">{{ item?.label }}</span>
</template>
</CustomRadiosWithImage>
</div>
</CustomizerSection>
<!-- !SECTION -->
</PerfectScrollbar>
</VNavigationDrawer>
</div>
</template>
<style lang="scss">
.app-customizer {
.customizer-section {
display: flex;
flex-direction: column;
padding: 1.25rem;
gap: 1.5rem;
}
.customizer-heading {
padding-block: 1.125rem;
padding-inline: 1.25rem;
}
.v-navigation-drawer__content {
display: flex;
flex-direction: column;
}
}
.app-customizer-toggler {
position: fixed !important;
inset-block-start: 50%;
inset-inline-end: 0;
}
</style>

View File

@ -0,0 +1,57 @@
<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"
size="26"
/>
<VTooltip
activator="parent"
open-delay="1000"
scroll-strategy="close"
>
<span class="text-capitalize">{{ configStore.theme }}</span>
</VTooltip>
<VMenu
activator="parent"
offset="14px"
>
<VList v-model:selected="selectedItem">
<VListItem
v-for="{ name, icon } in props.themes"
:key="name"
:value="name"
:prepend-icon="icon"
color="primary"
class="text-capitalize"
@click="() => { configStore.theme = name }"
>
{{ name }}
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>

View File

@ -0,0 +1,139 @@
<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-3 pa-2 flex-wrap align-center"
>
<VIcon
class="font-weight-medium"
icon="tabler-bold"
:color="editor.isActive('bold') ? 'primary' : 'default'"
size="20"
@click="editor.chain().focus().toggleBold().run()"
/>
<VIcon
:color="editor.isActive('underline') ? 'primary' : 'default'"
icon="tabler-underline"
size="20"
@click="editor.commands.toggleUnderline()"
/>
<VIcon
:color="editor.isActive('italic') ? 'primary' : 'default'"
icon="tabler-italic"
size="20"
@click="editor.chain().focus().toggleItalic().run()"
/>
<VIcon
icon="tabler-strikethrough"
size="20"
:color="editor.isActive('strike') ? 'primary' : 'default'"
@click="editor.chain().focus().toggleStrike().run()"
/>
<VIcon
:color="editor.isActive({ textAlign: 'left' }) ? 'primary' : 'default'"
icon="tabler-align-left"
size="20"
@click="editor.chain().focus().setTextAlign('left').run()"
/>
<VIcon
icon="tabler-align-center"
size="20"
:color="editor.isActive({ textAlign: 'center' }) ? 'primary' : 'default'"
@click="editor.chain().focus().setTextAlign('center').run()"
/>
<VIcon
:color="editor.isActive({ textAlign: 'right' }) ? 'primary' : 'default'"
icon="tabler-align-right"
size="20"
@click="editor.chain().focus().setTextAlign('right').run()"
/>
<VIcon
:color="editor.isActive({ textAlign: 'justify' }) ? 'primary' : 'default'"
icon="tabler-align-justified"
size="20"
@click="editor.chain().focus().setTextAlign('justify').run()"
/>
</div>
<VDivider />
<EditorContent
ref="editorRef"
:editor="editor"
/>
</div>
</template>
<style lang="scss">
.ProseMirror {
padding: 0.5rem;
min-block-size: 15vh;
p {
margin-block-end: 0;
}
p.is-editor-empty:first-child::before {
block-size: 0;
color: #adb5bd;
content: attr(data-placeholder);
float: inline-start;
pointer-events: none;
}
}
</style>

View File

@ -0,0 +1,57 @@
<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 || attrs.label
return _elementIdToken ? `app-autocomplete-${_elementIdToken}-${Math.random().toString(36).slice(2, 7)}` : undefined
})
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-high-emphasis"
:text="label"
/>
<VAutocomplete
v-bind="{
...$attrs,
class: null,
label: undefined,
id: elementId,
variant: 'outlined',
menuProps: {
contentClass: [
'app-inner-list',
'app-autocomplete__content',
'v-autocomplete__content',
],
},
}"
>
<template
v-for="(_, name) in $slots"
#[name]="slotProps"
>
<slot
:name="name"
v-bind="slotProps || {}"
/>
</template>
</VAutocomplete>
</div>
</template>

View File

@ -0,0 +1,57 @@
<script lang="ts" setup>
defineOptions({
name: 'AppCombobox',
inheritAttrs: false,
})
const elementId = computed(() => {
const attrs = useAttrs()
const _elementIdToken = attrs.id || attrs.label
return _elementIdToken ? `app-combobox-${_elementIdToken}-${Math.random().toString(36).slice(2, 7)}` : undefined
})
const label = computed(() => useAttrs().label 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-high-emphasis"
:text="label"
/>
<VCombobox
v-bind="{
...$attrs,
class: null,
label: undefined,
variant: 'outlined',
id: elementId,
menuProps: {
contentClass: [
'app-inner-list',
'app-combobox__content',
'v-combobox__content',
$attrs.multiple !== undefined ? 'v-list-select-multiple' : '',
],
},
}"
>
<template
v-for="(_, name) in $slots"
#[name]="slotProps"
>
<slot
:name="name"
v-bind="slotProps || {}"
/>
</template>
</VCombobox>
</div>
</template>

View File

@ -0,0 +1,512 @@
<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, filterFieldProps, 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: 'compact',
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 [{ modelValue: _, ...inputProps }] = VInput.filterProps(props)
const [fieldProps] = filterFieldProps(props)
const refFlatPicker = ref()
const { focused } = useFocus(refFlatPicker)
const isCalendarOpen = ref(false)
const isInlinePicker = ref(false)
// flat picker prop manipulation
if (compAttrs.config && compAttrs.config.inline) {
isInlinePicker.value = compAttrs.config.inline
Object.assign(compAttrs, { altInputClass: 'inlinePicker' })
}
// 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)
}
const elementId = computed(() => {
const _elementIdToken = fieldProps.id || fieldProps.label
return _elementIdToken ? `app-picker-field-${_elementIdToken}-${Math.random().toString(36).slice(2, 7)}` : undefined
})
</script>
<template>
<div class="app-picker-field">
<!-- v-input -->
<VLabel
v-if="fieldProps.label"
class="mb-1 text-body-2 text-high-emphasis"
:for="elementId"
:text="fieldProps.label"
/>
<VInput
v-bind="{ ...inputProps, ...rootAttrs }"
:model-value="modelValue"
:hide-details="props.hideDetails"
:class="[{
'v-text-field--prefixed': props.prefix,
'v-text-field--suffixed': props.suffix,
'v-text-field--flush-details': ['plain', 'underlined'].includes(props.variant),
}, props.class]"
class="position-relative v-text-field"
:style="props.style"
>
<template #default="{ id, isDirty, isValid, isDisabled, isReadonly }">
<!-- 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"
:disabled="isReadonly.value"
@on-open="isCalendarOpen = true"
@on-close="isCalendarOpen = false"
@update:model-value="emitModelValue"
/>
<!-- simple input for inline prop -->
<input
v-if="isInlinePicker"
:value="modelValue"
:placeholder="props.placeholder"
:readonly="isReadonly.value"
class="flat-picker-custom-style"
type="text"
>
</div>
</template>
</VField>
</template>
</VInput>
<!-- flat picker for inline props -->
<FlatPickr
v-if="isInlinePicker"
v-bind="compAttrs"
ref="refFlatPicker"
:model-value="modelValue"
@update:model-value="emitModelValue"
@on-open="isCalendarOpen = true"
@on-close="isCalendarOpen = false"
/>
</div>
</template>
<style lang="scss">
/* stylelint-disable no-descending-specificity */
@use "flatpickr/dist/flatpickr.css";
@use "@core/scss/base/mixins";
.flat-picker-custom-style {
position: absolute;
color: inherit;
inline-size: 100%;
inset: 0;
outline: none;
padding-block: 0;
padding-inline: var(--v-field-padding-start);
}
$heading-color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));
$body-color: rgba(var(--v-theme-on-background), var(--v-medium-emphasis-opacity));
$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.625rem;
margin-block-start: 0.1875rem;
.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: 2.125rem;
padding-inline: 0.875rem;
}
.flatpickr-days {
min-inline-size: 16.625rem;
.dayContainer {
justify-content: center !important;
inline-size: 16.625rem;
min-inline-size: 16.625rem;
padding-block-end: 0.75rem;
padding-block-start: 0;
.flatpickr-day {
block-size: 2.125rem;
font-size: 0.9375rem;
line-height: 2.125rem;
margin-block-start: 0 !important;
max-inline-size: 2.125rem;
}
}
}
}
.flatpickr-day {
color: $body-color;
&.today {
border-color: rgb(var(--v-theme-primary));
&:hover {
border-color: rgb(var(--v-theme-primary));
background: transparent;
color: $body-color;
}
}
&.selected,
&.selected:hover {
border-color: rgb(var(--v-theme-primary));
background: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
@include mixins.elevation(2);
}
&.inRange,
&.inRange:hover {
border: none;
background: rgba(var(--v-theme-primary), var(--v-activated-opacity)) !important;
box-shadow: none !important;
color: rgb(var(--v-theme-primary));
}
&.startRange {
@include mixins.elevation(2);
}
&.endRange {
@include mixins.elevation(2);
}
&.startRange,
&.endRange,
&.startRange:hover,
&.endRange:hover {
border-color: rgb(var(--v-theme-primary));
background: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
}
&.selected.startRange + .endRange:not(:nth-child(7n + 1)),
&.startRange.startRange + .endRange:not(:nth-child(7n + 1)),
&.endRange.startRange + .endRange:not(:nth-child(7n + 1)) {
box-shadow: -10px 0 0 rgb(var(--v-theme-primary));
}
&.flatpickr-disabled,
&.prevMonthDay:not(.startRange,.inRange),
&.nextMonthDay:not(.endRange,.inRange) {
opacity: var(--v-disabled-opacity);
}
&:hover {
border-color: transparent;
background: rgba(var(--v-theme-on-surface), 0.08);
}
}
.flatpickr-weekday {
color: $heading-color;
font-size: 0.8125rem;
font-weight: 500;
}
.flatpickr-days {
inline-size: 16.625rem;
}
&::after,
&::before {
display: none;
}
.flatpickr-months {
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
.flatpickr-prev-month,
.flatpickr-next-month {
fill: $body-color;
&:hover i,
&:hover svg {
fill: $body-color;
}
}
}
.flatpickr-current-month span.cur-month {
font-weight: 300;
}
&.open {
// Open calendar above overlay
z-index: 2401;
}
&.hasTime.open {
.flatpickr-time {
border-color: rgba(var(--v-border-color), var(--v-border-opacity));
block-size: auto;
}
.flatpickr-hour,
.flatpickr-minute,
.flatpickr-am-pm {
font-size: 0.9375rem;
}
}
}
.v-theme--dark .flatpickr-calendar {
box-shadow: 0 3px 14px 0 rgb(15 20 34 / 38%);
}
// Time picker hover & focus bg color
.flatpickr-time input:hover,
.flatpickr-time .flatpickr-am-pm:hover,
.flatpickr-time input:focus,
.flatpickr-time .flatpickr-am-pm:focus {
background: transparent;
}
// Time picker
.flatpickr-time {
.flatpickr-am-pm,
.flatpickr-time-separator,
input {
color: $body-color;
}
.numInputWrapper {
span {
&.arrowUp {
&::after {
border-block-end-color: rgb(var(--v-border-color));
}
}
&.arrowDown {
&::after {
border-block-start-color: rgb(var(--v-border-color));
}
}
}
}
}
// Added bg color for flatpickr input only as it has default readonly attribute
.flatpickr-input[readonly],
.flatpickr-input ~ .form-control[readonly],
.flatpickr-human-friendly[readonly] {
background-color: inherit;
}
// week sections
.flatpickr-weekdays {
margin-block: 12px;
}
// Month and year section
.flatpickr-current-month {
.flatpickr-monthDropdown-months {
appearance: none;
}
.flatpickr-monthDropdown-months,
.numInputWrapper {
padding: 2px;
border-radius: 4px;
color: $heading-color;
font-size: 0.9375rem;
font-weight: 500;
transition: all 0.15s ease-out;
span {
display: none;
}
.flatpickr-monthDropdown-month {
background-color: rgb(var(--v-theme-surface));
}
.numInput.cur-year {
font-weight: 500;
}
}
}
.flatpickr-day.flatpickr-disabled,
.flatpickr-day.flatpickr-disabled:hover {
color: $body-color;
}
.flatpickr-months {
padding-block: 0.75rem;
padding-inline: 1rem;
.flatpickr-prev-month,
.flatpickr-next-month {
display: flex;
align-items: center;
border-radius: 5rem;
background: rgba(var(--v-theme-surface-variant), var(--v-selected-opacity));
block-size: 1.75rem;
inline-size: 1.75rem;
inset-block-start: 0.75rem !important;
margin-block: 0.1875rem;
padding-block: 0.25rem;
padding-inline: 0.4375rem;
}
.flatpickr-next-month {
inset-inline-end: 1.05rem !important;
}
.flatpickr-prev-month {
/* stylelint-disable-next-line liberty/use-logical-spec */
right: 3.8rem;
left: unset !important;
}
.flatpickr-month {
display: flex;
align-items: center;
block-size: 2.125rem;
.flatpickr-current-month {
display: flex;
align-items: center;
padding: 0;
block-size: 1.75rem;
inset-inline-start: 0;
text-align: start;
}
}
}
// Update hour font-weight
.flatpickr-time input.flatpickr-hour {
font-weight: 400;
}
</style>

View File

@ -0,0 +1,49 @@
<script lang="ts" setup>
defineOptions({
name: 'AppSelect',
inheritAttrs: false,
})
const elementId = computed(() => {
const attrs = useAttrs()
const _elementIdToken = attrs.id || attrs.label
return _elementIdToken ? `app-select-${_elementIdToken}-${Math.random().toString(36).slice(2, 7)}` : undefined
})
const label = computed(() => useAttrs().label 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 text-high-emphasis"
:text="label"
/>
<VSelect
v-bind="{
...$attrs,
class: null,
label: undefined,
variant: 'outlined',
id: elementId,
menuProps: { contentClass: ['app-inner-list', 'app-select__content', 'v-select__content', $attrs.multiple !== undefined ? 'v-list-select-multiple' : ''] },
}"
>
<template
v-for="(_, name) in $slots"
#[name]="slotProps"
>
<slot
:name="name"
v-bind="slotProps || {}"
/>
</template>
</VSelect>
</div>
</template>

View File

@ -0,0 +1,48 @@
<script lang="ts" setup>
defineOptions({
name: 'AppTextField',
inheritAttrs: false,
})
const elementId = computed(() => {
const attrs = useAttrs()
const _elementIdToken = attrs.id || attrs.label
return _elementIdToken ? `app-text-field-${_elementIdToken}-${Math.random().toString(36).slice(2, 7)}` : undefined
})
const label = computed(() => useAttrs().label 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-high-emphasis"
:text="label"
/>
<VTextField
v-bind="{
...$attrs,
class: null,
label: undefined,
variant: 'outlined',
id: elementId,
}"
>
<template
v-for="(_, name) in $slots"
#[name]="slotProps"
>
<slot
:name="name"
v-bind="slotProps || {}"
/>
</template>
</VTextField>
</div>
</template>

View File

@ -0,0 +1,50 @@
<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 || attrs.label
return _elementIdToken ? `app-textarea-${_elementIdToken}-${Math.random().toString(36).slice(2, 7)}` : undefined
})
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-high-emphasis"
:text="label"
/>
<VTextarea
v-bind="{
...$attrs,
class: null,
label: undefined,
variant: 'outlined',
id: elementId,
}"
>
<template
v-for="(_, name) in $slots"
#[name]="slotProps"
>
<slot
:name="name"
v-bind="slotProps || {}"
/>
</template>
</VTextarea>
</div>
</template>

View File

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

View File

@ -0,0 +1,91 @@
<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[] | boolean) => {
if (typeof value !== 'boolean')
emit('update:selectedCheckbox', value)
}
</script>
<template>
<VRow v-if="props.checkboxContent && props.selectedCheckbox">
<VCol
v-for="item in props.checkboxContent"
:key="item.title"
v-bind="gridColumn"
>
<VLabel
class="custom-input custom-checkbox-icon rounded cursor-pointer"
:class="props.selectedCheckbox.includes(item.value) ? 'active' : ''"
>
<slot :item="item">
<div class="d-flex flex-column align-center text-center gap-2">
<VIcon
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.375rem;
.v-checkbox {
margin-block-end: -0.375rem;
.v-selection-control__wrapper {
margin-inline-start: 0;
}
}
.cr-title {
font-weight: 500;
}
}
</style>
<style lang="scss">
.custom-checkbox-icon {
.v-checkbox {
margin-block-end: -0.375rem;
.v-selection-control__wrapper {
margin-inline-start: 0;
}
}
}
</style>

View File

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

View File

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

View File

@ -0,0 +1,90 @@
<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) => {
emit('update:selectedRadio', value)
}
</script>
<template>
<VRadioGroup
v-if="props.radioContent"
:model-value="props.selectedRadio"
@update:model-value="updateSelectedOption"
>
<VRow>
<VCol
v-for="item in props.radioContent"
:key="item.title"
v-bind="gridColumn"
>
<VLabel
class="custom-input custom-radio-icon rounded cursor-pointer"
:class="props.selectedRadio === item.value ? 'active' : ''"
>
<slot :item="item">
<div class="d-flex flex-column align-center text-center gap-2">
<VIcon
v-bind="item.icon"
class="text-high-emphasis"
/>
<h6 class="cr-title text-base">
{{ item.title }}
</h6>
<p class="text-sm mb-0 clamp-text">
{{ item.desc }}
</p>
</div>
</slot>
<div>
<VRadio :value="item.value" />
</div>
</VLabel>
</VCol>
</VRow>
</VRadioGroup>
</template>
<style lang="scss" scoped>
.custom-radio-icon {
display: flex;
flex-direction: column;
gap: 0.375rem;
.v-radio {
margin-block-end: -0.25rem;
}
.cr-title {
font-weight: 500;
}
}
</style>
<style lang="scss">
.custom-radio-icon {
.v-radio {
margin-block-end: -0.25rem;
.v-selection-control__wrapper {
margin-inline-start: 0;
}
}
}
</style>

View File

@ -0,0 +1,91 @@
<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) => {
emit('update:selectedRadio', value)
}
</script>
<template>
<VRadioGroup
v-if="props.radioContent"
:model-value="props.selectedRadio"
@update:model-value="updateSelectedOption"
>
<VRow>
<VCol
v-for="item in props.radioContent"
:key="item.bgImage"
v-bind="gridColumn"
>
<VLabel
class="custom-input custom-radio rounded cursor-pointer w-100"
:class="props.selectedRadio === item.value ? 'active' : ''"
>
<slot
name="content"
:item="item"
>
<img
v-if="item.bgImage"
:src="item.bgImage"
alt="bg-img"
class="custom-radio-image"
>
</slot>
<VRadio
:id="`custom-radio-with-img-${item.value}`"
:value="item.value"
/>
</VLabel>
<VLabel
v-if="item.label || $slots.label"
:for="`custom-radio-with-img-${item.value}`"
class="cursor-pointer"
>
<slot
name="label"
:label="item.label"
>
{{ item.label }}
</slot>
</VLabel>
</VCol>
</VRow>
</VRadioGroup>
</template>
<style lang="scss" scoped>
.custom-radio {
padding: 0;
&.active {
border-width: 2px;
}
.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,125 @@
<script lang="ts" setup>
import 'prismjs'
import 'prismjs/themes/prism-tomorrow.css'
import Prism from 'vue-prism-component'
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]) })
</script>
<template>
<VCard>
<VCardItem>
<VCardTitle>{{ props.title }}</VCardTitle>
<template #append>
<IconBtn
size="small"
:color="isCodeShown ? 'primary' : 'default'"
:class="isCodeShown ? '' : 'text-disabled'"
@click="isCodeShown = !isCodeShown"
>
<VIcon
size="20"
icon="tabler-code"
/>
</IconBtn>
</template>
</VCardItem>
<slot v-if="noPadding" />
<VCardText v-else>
<slot />
</VCardText>
<VExpandTransition>
<div v-show="isCodeShown">
<VDivider />
<VCardText class="d-flex gap-y-3 flex-column">
<div class="d-flex justify-end">
<VBtnToggle
v-model="preferredCodeLanguage"
mandatory
variant="outlined"
density="compact"
>
<VBtn
value="ts"
:color="preferredCodeLanguage === 'ts' ? 'primary' : 'default'"
>
<VIcon
icon="custom-typescript"
:color="preferredCodeLanguage === 'ts' ? 'primary' : 'secondary'"
/>
</VBtn>
<VBtn
value="js"
:color="preferredCodeLanguage === 'js' ? 'primary' : 'default'"
>
<VIcon
icon="custom-javascript"
:color="preferredCodeLanguage === 'js' ? 'primary' : 'secondary'"
/>
</VBtn>
</VBtnToggle>
</div>
<div class="position-relative">
<Prism
:key="props.code[preferredCodeLanguage]"
:language="props.codeLanguage"
:style="$vuetify.locale.isRtl ? 'text-align: right' : 'text-align: left'"
>
{{ props.code[preferredCodeLanguage] }}
</Prism>
<IconBtn
class="position-absolute app-card-code-copy-icon"
color="white"
@click="() => { copy() }"
>
<VIcon
:icon="copied ? 'tabler-check' : 'tabler-copy'"
size="20"
/>
</IconBtn>
</div>
</VCardText>
</div>
</VExpandTransition>
</VCard>
</template>
<style lang="scss">
@use "@styles/variables/_vuetify.scss";
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
border-radius: vuetify.$card-border-radius;
}
.app-card-code-copy-icon {
inset-block-start: 1.2em;
inset-inline-end: 0.8em;
}
</style>

View File

@ -0,0 +1,32 @@
<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">
<span class="text-h5">{{ props.stats }}</span>
</div>
<span class="text-body-2">{{ props.title }}</span>
</div>
<VAvatar
:icon="props.icon"
:color="props.color"
:size="42"
variant="tonal"
/>
</VCardText>
</VCard>
</template>

View File

@ -0,0 +1,41 @@
<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"
:icon="props.icon"
class="mb-3"
/>
<h6 class="text-lg font-weight-medium">
{{ props.stats }}
</h6>
<span class="text-sm">{{ props.title }}</span>
</VCardText>
<VueApexCharts
:series="props.series"
:options="props.chartOptions"
:height="props.height"
/>
</VCard>
</template>

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)
}

View File

@ -0,0 +1,25 @@
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
}
})
}

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: 'aside' }),
wrapperProps: {
withBackground: true,
theme: (configStore.isVerticalNavSemiDark && configStore.appContentLayoutNav === AppContentLayoutNav.Vertical)
? 'dark'
: undefined,
},
},
}))
const injectSkinClasses = () => {
if (typeof document !== 'undefined') {
const bodyClasses = document.body.classList
const genSkinClass = (_skin?: string) => `skin--${_skin}`
watch(
() => configStore.skin,
(val, oldVal) => {
bodyClasses.remove(genSkinClass(oldVal))
bodyClasses.add(genSkinClass(val))
},
{ immediate: true },
)
}
}
return {
injectSkinClasses,
layoutAttrs,
}
}

21
src/@core/enums.ts Executable file
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

42
src/@core/index.ts Executable file
View File

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

93
src/@core/initCore.ts Executable file
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,702 @@
import type { ThemeInstance } from 'vuetify'
import { hexToRgb } from '@layouts/utils'
// 👉 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,
startingShape: 'rounded',
},
},
grid: {
borderColor: themeBorderColor,
xaxis: {
lines: { show: false },
},
padding: {
top: -10,
},
},
yaxis: {
labels: {
style: { colors: themeDisabledTextColor, fontSize: '0.8125rem' },
},
},
xaxis: {
axisBorder: { show: false },
axisTicks: { color: themeBorderColor },
categories: ['MON, 11', 'THU, 14', 'FRI, 15', 'MON, 18', 'WED, 20', 'FRI, 21', 'MON, 23'],
labels: {
style: { colors: themeDisabledTextColor, fontSize: '0.8125rem' },
},
},
}
}
export const getCandlestickChartConfig = (themeColors: 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.colors['grey-100'],
},
dataLabels: {
name: {
fontSize: '2rem',
},
value: {
fontSize: '0.9375rem',
color: themeSecondaryTextColor,
},
total: {
show: true,
fontWeight: 400,
label: 'Comments',
fontSize: '1.125rem',
color: themePrimaryTextColor,
formatter(w: { 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 '@layouts/utils'
// 👉 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,172 @@
@use "@core/scss/base/mixins";
@use "@layouts/styles/placeholders";
@use "@layouts/styles/mixins" as layoutMixins;
@use "@configured-variables" as variables;
// 👉 Avatar group
.v-avatar-group {
display: flex;
align-items: center;
> * {
&:not(:first-child) {
margin-inline-start: -0.8rem;
}
transition: transform 0.25s ease, box-shadow 0.15s ease;
&:hover {
z-index: 2;
transform: translateY(-5px) scale(1.05);
@include mixins.elevation(3);
}
}
> .v-avatar {
border: 2px solid rgb(var(--v-theme-surface));
transition: transform 0.15s ease;
}
}
// 👉 Button outline with default color border color
.v-alert--variant-outlined,
.v-avatar--variant-outlined,
.v-btn.v-btn--variant-outlined,
.v-card--variant-outlined,
.v-chip--variant-outlined,
.v-list-item--variant-outlined {
&:not([class*="text-"]) {
border-color: rgba(var(--v-border-color), var(--v-border-opacity));
}
&.text-default {
border-color: rgba(var(--v-border-color), var(--v-border-opacity));
}
}
// 👉 Custom Input
.v-label.custom-input {
padding: 1rem;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
opacity: 1;
white-space: normal;
&:hover {
border-color: rgba(var(--v-border-color), 0.25);
}
&.active {
border-color: rgb(var(--v-theme-primary));
.v-icon {
color: rgb(var(--v-theme-primary)) !important;
}
}
}
// 👉 Datatable
.v-data-table-footer__pagination {
@include layoutMixins.rtl {
.v-btn {
.v-icon {
transform: rotate(180deg);
}
}
}
}
// Dialog responsive width
.v-dialog {
// dialog custom close btn
.v-dialog-close-btn {
position: absolute;
z-index: 1;
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity)) !important;
inset-block-start: 0.5rem;
inset-inline-end: 0.5rem;
.v-btn__overlay {
display: none;
}
}
.v-card {
@extend %style-scroll-bar;
}
}
@media (min-width: 600px) {
.v-dialog {
&.v-dialog-sm,
&.v-dialog-lg,
&.v-dialog-xl {
.v-overlay__content {
inline-size: 565px !important;
}
}
}
}
@media (min-width: 960px) {
.v-dialog {
&.v-dialog-lg,
&.v-dialog-xl {
.v-overlay__content {
inline-size: 865px !important;
}
}
}
}
@media (min-width: 1264px) {
.v-dialog.v-dialog-xl {
.v-overlay__content {
inline-size: 1165px !important;
}
}
}
// v-tab with pill support
.v-tabs.v-tabs-pill {
.v-tab.v-btn {
border-radius: 6px !important;
transition: none;
.v-tab__slider {
visibility: hidden;
}
}
}
// loop for all colors bg
@each $color-name in variables.$theme-colors-name {
.v-tabs.v-tabs-pill {
.v-slide-group-item--active.v-tab--selected.text-#{$color-name} {
background-color: rgb(var(--v-theme-#{$color-name}));
color: rgb(var(--v-theme-on-#{$color-name})) !important;
}
}
}
// We are make even width of all v-timeline body
.v-timeline--vertical.v-timeline {
.v-timeline-item {
.v-timeline-item__body {
justify-self: stretch !important;
}
}
}
// 👉 Switch
.v-switch .v-selection-control:not(.v-selection-control--dirty) .v-switch__thumb {
color: #fff !important;
}
// 👉 Textarea
.v-textarea .v-field__input {
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-mask-image: none !important;
mask-image: none !important;
}

16
src/@core/scss/base/_dark.scss Executable file
View File

@ -0,0 +1,16 @@
@use "@configured-variables" as variables;
//
// * Perfect Scrollbar
//
body.v-theme--dark {
.ps__rail-y,
.ps__rail-x {
background-color: transparent !important;
}
.ps__thumb-y {
background-color: variables.$plugin-ps-thumb-y-dark;
}
}

View File

@ -0,0 +1,45 @@
@use "@core/scss/base/placeholders" as *;
@use "@core/scss/template/placeholders" as *;
@use "@core/scss/base/mixins";
.layout-wrapper.layout-nav-type-horizontal {
.layout-navbar-and-nav-container {
@extend %default-layout-horizontal-nav-navbar-and-nav-container;
}
// 👉 Navbar
.layout-navbar {
@extend %default-layout-horizontal-nav-navbar;
}
// 👉 Layout content container
.navbar-content-container {
display: flex;
align-items: center;
block-size: 100%;
}
.layout-horizontal-nav {
@extend %default-layout-horizontal-nav-nav;
.nav-items {
@extend %default-layout-horizontal-nav-nav-items-list;
}
}
// 👉 App footer
.layout-footer {
@at-root {
.layout-footer-sticky#{&} {
background-color: rgb(var(--v-theme-surface));
@include mixins.elevation(3);
}
}
}
// TODO: Use Vuetify grid sass variable here
.layout-page-content {
padding-block: 1.5rem;
}
}

View File

@ -0,0 +1,103 @@
@use "@configured-variables" as variables;
@use "@core/scss/base/placeholders" as *;
@use "@core/scss/template/placeholders" as *;
@use "misc";
@use "@core/scss/base/mixins";
$header: ".layout-navbar";
@if variables.$layout-vertical-nav-navbar-is-contained {
$header: ".layout-navbar .navbar-content-container";
}
.layout-wrapper.layout-nav-type-vertical {
// SECTION Layout Navbar
// 👉 Elevated navbar
@if variables.$vertical-nav-navbar-style == "elevated" {
// Add transition
#{$header} {
transition: padding 0.2s ease, background-color 0.18s ease;
}
// If navbar is contained => Add border radius to header
@if variables.$layout-vertical-nav-navbar-is-contained {
#{$header} {
border-radius: 0 0 variables.$default-layout-with-vertical-nav-navbar-footer-roundness variables.$default-layout-with-vertical-nav-navbar-footer-roundness;
}
}
// Scrolled styles for sticky navbar
@at-root {
/* This html selector with not selector is required when:
dialog is opened and window don't have any scroll. This removes window-scrolled class from layout and our style broke
*/
html.v-overlay-scroll-blocked:not([style*="--v-body-scroll-y: 0px;"]) .layout-navbar-sticky,
&.window-scrolled.layout-navbar-sticky {
#{$header} {
@extend %default-layout-vertical-nav-scrolled-sticky-elevated-nav;
@extend %default-layout-vertical-nav-floating-navbar-and-sticky-elevated-navbar-scrolled;
}
.navbar-blur#{$header} {
@extend %blurry-bg;
}
}
}
}
// 👉 Floating navbar
@else if variables.$vertical-nav-navbar-style == "floating" {
// Regardless of navbar is contained or not => Apply overlay to .layout-navbar
.layout-navbar {
&.navbar-blur {
@extend %default-layout-vertical-nav-floating-navbar-overlay;
}
}
&:not(.layout-navbar-sticky) {
#{$header} {
margin-block-start: variables.$vertical-nav-floating-navbar-top;
}
}
#{$header} {
@if variables.$layout-vertical-nav-navbar-is-contained {
border-radius: variables.$default-layout-with-vertical-nav-navbar-footer-roundness;
}
background-color: rgb(var(--v-theme-surface));
@extend %default-layout-vertical-nav-floating-navbar-and-sticky-elevated-navbar-scrolled;
}
.navbar-blur#{$header} {
@extend %blurry-bg;
}
}
// !SECTION
// 👉 Layout footer
.layout-footer {
$ele-layout-footer: &;
.footer-content-container {
border-radius: variables.$default-layout-with-vertical-nav-navbar-footer-roundness variables.$default-layout-with-vertical-nav-navbar-footer-roundness 0 0;
// Sticky footer
@at-root {
// .layout-footer-sticky#{$ele-layout-footer} => .layout-footer-sticky.layout-wrapper.layout-nav-type-vertical .layout-footer
.layout-footer-sticky#{$ele-layout-footer} {
.footer-content-container {
background-color: rgb(var(--v-theme-surface));
padding-block: 0;
padding-inline: 1.2rem;
@include mixins.elevation(3);
}
}
}
}
}
}

View File

@ -0,0 +1,16 @@
@use "@core/scss/base/placeholders";
@use "@core/scss/base/variables";
.layout-vertical-nav,
.layout-horizontal-nav {
ol,
ul {
list-style: none;
}
}
.layout-navbar {
@if variables.$navbar-high-emphasis-text {
@extend %layout-navbar;
}
}

View File

@ -0,0 +1,194 @@
@use "@core/scss/base/placeholders" as *;
@use "@core/scss/template/placeholders" as *;
@use "@configured-variables" as variables;
@use "@layouts/styles/mixins" as layoutsMixins;
@use "@core/scss/base/mixins";
@use "vuetify/lib/styles/tools/states" as vuetifyStates;
.layout-horizontal-nav {
@extend %nav;
// 👉 Icon styles
.nav-item-icon {
@extend %horizontal-nav-item-icon;
}
// 👉 Common styles for nav group & nav link
.nav-link,
.nav-group {
// 👉 Disabled nav items
&.disabled {
@extend %horizontal-nav-disabled;
}
// Set width of inner nav group and link
&.sub-item {
@extend %horizontal-nav-subitem;
}
}
// SECTION Nav Link
.nav-link {
@extend %nav-link;
a {
@extend %horizontal-nav-item;
// Adds before psudo element to style hover state
@include mixins.before-pseudo;
// Adds vuetify states
@include vuetifyStates.states($active: false);
}
// 👉 Top level nav link
&:not(.sub-item) {
a {
@extend %horizontal-nav-top-level-item;
&.router-link-active {
@extend %nav-link-active;
}
}
}
// 👉 Sub link
&.sub-item {
a {
&.router-link-active {
// We will not use active styles from material here because we want to use primary color for active link
@extend %horizontal-nav-sub-nav-link-active;
}
}
}
}
// !SECTION
// SECTION Nav Group
.nav-group {
.popper-triggerer {
.nav-group-label {
@extend %horizontal-nav-item;
}
}
> .popper-triggerer > .nav-group-label {
// Adds before psudo element to style hover state
@include mixins.before-pseudo;
// Adds vuetify states
@include vuetifyStates.states($active: false);
}
.popper-content {
@extend %horizontal-nav-popper-content-hidden;
@extend %horizontal-nav-popper-content;
background-color: rgb(var(--v-theme-surface));
// Set max-height for the popper content
> div {
max-block-size: variables.$horizontal-nav-popper-content-max-height;
}
}
// 👉 Top level group
&:not(.sub-item) {
> .popper-triggerer {
position: relative;
/*
The Bridge
This after pseudo will work as bridge when we have space between popper triggerer and popper content
Initially it will have pointer events none for normal behavior and once the content is shown it will
work as bridge by setting pointer events to `auto`
*/
&::after {
position: absolute;
block-size: variables.$horizontal-nav-popper-content-top;
content: "";
inline-size: 100%;
inset-block-start: 100%;
inset-inline-start: 0;
pointer-events: none;
}
}
// Enable the pseudo bridge when content is shown by setting pointer events to `auto`
&.show-content > .popper-triggerer::after {
/*
We have added `z-index: 2` because when there is horizontal nav item below the popper trigger (group)
without this style nav item below popper trigger (group) gets focus hence closes the popper content
*/
z-index: 2;
pointer-events: auto;
}
> .popper-triggerer > .nav-group-label {
@extend %horizontal-nav-top-level-item;
}
&.active {
> .popper-triggerer > .nav-group-label {
@extend %nav-link-active;
}
}
> .popper-content {
// Add space between popper wrapper & content
margin-block-start: variables.$horizontal-nav-popper-content-top !important;
}
}
// 👉 Sub group
&.sub-item {
&.active {
@include mixins.selected-states("> .popper-triggerer > .nav-group-label::before");
}
// Reduce the icon's size of nested group's nav links (Top level group > Sub group > [Nav links])
.sub-item {
.nav-item-icon {
@extend %third-level-nav-item-icon;
}
}
}
.nav-group-arrow {
font-size: variables.$horizontal-nav-group-arrow-icon-size;
/*
ml-auto won't matter in top level group (because we haven't specified fixed width for top level groups)
but we wrote generally because we don't want to become so specific
*/
margin-inline-start: auto;
}
&.popper-inline-end {
.nav-group-arrow {
transform: rotateZ(270deg);
@include layoutsMixins.rtl {
transform: rotateZ(90deg);
}
}
}
.nav-item-title {
@extend %horizontal-nav-item-title;
}
&.show-content {
> .popper-content {
@extend %horizontal-nav-popper-content-visible;
}
&:not(.active) {
@include mixins.selected-states("> .popper-triggerer > .nav-group-label::before");
}
}
}
// !SECTION
}

48
src/@core/scss/base/_index.scss Executable file
View File

@ -0,0 +1,48 @@
@use "sass:map";
// Layout
@use "vertical-nav";
@use "horizontal-nav";
@use "default-layout";
@use "default-layout-w-vertical-nav";
@use "default-layout-w-horizontal-nav";
// Layouts package
@use "layouts";
// Skins
@use "skins";
// Components
@use "components";
// Utilities
@use "utilities";
// Route Transitions
@use "route-transitions";
// Misc
@use "misc";
// Dark
@use "dark";
// libs
@use "libs/perfect-scrollbar";
a {
color: rgb(var(--v-theme-primary));
text-decoration: none;
}
// Vuetify 3 don't provide margin bottom style like vuetify 2
p {
margin-block-end: 1rem;
}
// Iconify icon size
svg.iconify {
block-size: 1em;
inline-size: 1em;
}

View File

@ -0,0 +1,63 @@
@use "@configured-variables" as variables;
/* This styles extends the existing layout package's styles for handling cases that aren't related to layouts package */
/*
When we use v-layout as immediate first child of `.page-content-container`, it adds display:flex and page doesn't get contained height
*/
// .layout-wrapper.layout-nav-type-vertical {
// &.layout-content-height-fixed {
// .page-content-container {
// > .v-layout:first-child > :not(.v-navigation-drawer):first-child {
// flex-grow: 1;
// block-size: 100%;
// }
// }
// }
// }
.layout-wrapper.layout-nav-type-vertical {
&.layout-content-height-fixed {
.page-content-container {
> .v-layout:first-child {
overflow: hidden;
min-block-size: 100%;
> .v-main {
// overflow-y: auto;
.v-main__wrap > :first-child {
block-size: 100%;
overflow-y: auto;
}
}
}
}
}
}
// Let div/v-layout take full height. E.g. Email App
.layout-wrapper.layout-nav-type-horizontal {
&.layout-content-height-fixed {
> .layout-page-content {
display: flex;
}
}
}
// 👉 Floating navbar styles
@if variables.$vertical-nav-navbar-style == "floating" {
// Add spacing above navbar if navbar is floating (was in %layout-navbar-sticky placeholder)
body .layout-wrapper.layout-nav-type-vertical.layout-navbar-sticky {
.layout-navbar {
inset-block-start: variables.$vertical-nav-floating-navbar-top;
}
/*
If it's floating navbar
Add `vertical-nav-floating-navbar-top` as margin top to .layout-page-content
*/
.layout-page-content {
margin-block-start: variables.$vertical-nav-floating-navbar-top;
}
}
}

20
src/@core/scss/base/_misc.scss Executable file
View File

@ -0,0 +1,20 @@
// scrollable-content allows creating fixed header and scrollable content for VNavigationDrawer (Used when perfect scrollbar is used)
.scrollable-content {
&.v-navigation-drawer {
.v-navigation-drawer__content {
display: flex;
overflow: hidden;
flex-direction: column;
}
}
}
// adding styling for code tag
code {
border-radius: 3px;
color: rgb(var(--v-code-color));
font-size: 90%;
font-weight: 400;
padding-block: 0.2em;
padding-inline: 0.4em;
}

View File

@ -0,0 +1,63 @@
@use "sass:map";
@use "@styles/variables/_vuetify.scss";
@mixin elevation($z, $important: false) {
box-shadow: map.get(vuetify.$shadow-key-umbra, $z), map.get(vuetify.$shadow-key-penumbra, $z), map.get(vuetify.$shadow-key-ambient, $z) if($important, !important, null);
}
// #region before-pseudo
// This mixin is inspired from vuetify for adding hover styles via before pseudo element
@mixin before-pseudo() {
position: relative;
&::before {
position: absolute;
border-radius: inherit;
background: currentcolor;
block-size: 100%;
content: "";
inline-size: 100%;
inset: 0;
opacity: 0;
pointer-events: none;
}
}
// #endregion before-pseudo
@mixin bordered-skin($component, $border-property: "border", $important: false) {
#{$component} {
// background-color: rgb(var(--v-theme-background));
box-shadow: none !important;
#{$border-property}: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)) if($important, !important, null);
}
}
// #region selected-states
// Inspired from vuetify's active-states mixin
// focus => 0.12 & selected => 0.08
@mixin selected-states($selector) {
#{$selector} {
opacity: calc(var(--v-selected-opacity) * var(--v-theme-overlay-multiplier));
}
&:hover
#{$selector} {
opacity: calc(var(--v-selected-opacity) + var(--v-hover-opacity) * var(--v-theme-overlay-multiplier));
}
&:focus-visible
#{$selector} {
opacity: calc(var(--v-selected-opacity) + var(--v-focus-opacity) * var(--v-theme-overlay-multiplier));
}
@supports not selector(:focus-visible) {
&:focus {
#{$selector} {
opacity: calc(var(--v-selected-opacity) + var(--v-focus-opacity) * var(--v-theme-overlay-multiplier));
}
}
}
}
// #endregion selected-states

View File

@ -0,0 +1,70 @@
// 👉 Zoom fade
.app-transition-zoom-fade-enter-active,
.app-transition-zoom-fade-leave-active {
transition: transform 0.35s, opacity 0.28s ease-in-out;
}
.app-transition-zoom-fade-enter-from {
opacity: 0;
transform: scale(0.98);
}
.app-transition-zoom-fade-leave-to {
opacity: 0;
transform: scale(1.02);
}
// 👉 Fade
.app-transition-fade-enter-active,
.app-transition-fade-leave-active {
transition: opacity 0.25s ease-in-out;
}
.app-transition-fade-enter-from,
.app-transition-fade-leave-to {
opacity: 0;
}
// 👉 Fade bottom
.app-transition-fade-bottom-enter-active,
.app-transition-fade-bottom-leave-active {
transition: opacity 0.3s, transform 0.35s;
}
.app-transition-fade-bottom-enter-from {
opacity: 0;
transform: translateY(-0.6rem);
}
.app-transition-fade-bottom-leave-to {
opacity: 0;
transform: translateY(0.6rem);
}
// 👉 Slide fade
.app-transition-slide-fade-enter-active,
.app-transition-slide-fade-leave-active {
transition: opacity 0.3s, transform 0.35s;
}
.app-transition-slide-fade-enter-from {
opacity: 0;
transform: translateX(-0.6rem);
}
.app-transition-slide-fade-leave-to {
opacity: 0;
transform: translateX(0.6rem);
}
// 👉 Zoom out
.app-transition-zoom-out-enter-active,
.app-transition-zoom-out-leave-active {
transition: opacity 0.26s ease-in-out, transform 0.3s ease-out;
}
.app-transition-zoom-out-enter-from,
.app-transition-zoom-out-leave-to {
opacity: 0;
transform: scale(0.98);
}

View File

@ -0,0 +1,175 @@
@use "@configured-variables" as variables;
@use "@layouts/styles/mixins" as layoutsMixins;
// 👉 Demo spacers
// TODO: Use vuetify SCSS variable here
$card-spacer-content: 16px;
.demo-space-x {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-block-start: -$card-spacer-content;
& > * {
margin-block-start: $card-spacer-content;
margin-inline-end: $card-spacer-content;
}
}
.demo-space-y {
& > * {
margin-block-end: $card-spacer-content;
&:last-child {
margin-block-end: 0;
}
}
}
// 👉 Card match height
.match-height.v-row {
.v-card {
block-size: 100%;
}
}
// 👉 Whitespace
.whitespace-no-wrap {
white-space: nowrap;
}
// 👉 Colors
/*
Vuetify is applying `.text-white` class to badge icon but don't provide its styles
Moreover, we also use this class in some places
In vuetify 2 with `$color-pack: false` SCSS var config this class was getting generated but this is not the case in v3
We also need !important to get correct color in badge icon
*/
.text-white {
color: #fff !important;
}
.text-white-variant {
color: rgba(211, 212, 220);
}
.text-link{
&:not(:hover){
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity))
}
}
.bg-var-theme-background {
background-color: rgba(var(--v-theme-on-background), 0.08) !important;
}
.bg-global-primary {
background-color: rgb(var(--v-global-theme-primary)) !important;
color: rgb(var(--v-theme-on-primary)) !important;
}
// [/^bg-light-(\w+)$/, ([, w]) => ({ backgroundColor: `rgba(var(--v-theme-${w}), var(--v-activated-opacity))` })],
@each $color-name in variables.$theme-colors-name {
.bg-light-#{$color-name} {
background-color: rgba(var(--v-theme-#{$color-name}), var(--v-activated-opacity)) !important;
}
}
// 👉 clamp text
.clamp-text {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
text-overflow: ellipsis;
}
.custom-badge {
.v-badge__badge {
border-radius: 6px !important;
block-size: 12px !important;
inline-size: 12px !important;
}
}
.leading-normal {
line-height: normal !important;
}
// 👉 for rtl only
.flip-in-rtl {
@include layoutsMixins.rtl {
transform: scaleX(-1);
}
}
// 👉 Carousel
.carousel-delimiter-top-end {
.v-carousel__controls {
justify-content: end;
block-size: 40px;
inset-block-start: 0;
padding-inline: 1rem;
.v-btn--icon.v-btn--density-default {
block-size: calc(var(--v-btn-height) + -10px);
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
inline-size: calc(var(--v-btn-height) + -8px);
&.v-btn--active {
color: #fff;
}
.v-btn__overlay {
opacity: 0;
}
.v-ripple__container {
display: none;
}
.v-btn__content {
.v-icon {
block-size: 8px !important;
inline-size: 8px !important;
}
}
}
}
@each $color-name in variables.$theme-colors-name {
&.dots-active-#{$color-name} {
.v-carousel__controls {
.v-btn--active {
color: rgb(var(--v-theme-#{$color-name})) !important;
}
}
}
}
}
.v-timeline-item {
.app-timeline-title {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 16px;
font-weight: 500;
line-height: 1.3125rem;
}
.app-timeline-meta {
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
font-size: 12px;
line-height: 0.875rem;
}
.app-timeline-text {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 14px;
line-height: 1.25rem;
}
}

90
src/@core/scss/base/_utils.scss Executable file
View File

@ -0,0 +1,90 @@
@use "sass:map";
@use "sass:list";
@use "@configured-variables" as variables;
// Thanks: https://css-tricks.com/snippets/sass/deep-getset-maps/
@function map-deep-get($map, $keys...) {
@each $key in $keys {
$map: map.get($map, $key);
}
@return $map;
}
@function map-deep-set($map, $keys, $value) {
$maps: ($map,);
$result: null;
// If the last key is a map already
// Warn the user we will be overriding it with $value
@if type-of(nth($keys, -1)) == "map" {
@warn "The last key you specified is a map; it will be overrided with `#{$value}`.";
}
// If $keys is a single key
// Just merge and return
@if length($keys) == 1 {
@return map-merge($map, ($keys: $value));
}
// Loop from the first to the second to last key from $keys
// Store the associated map to this key in the $maps list
// If the key doesn't exist, throw an error
@for $i from 1 through length($keys) - 1 {
$current-key: list.nth($keys, $i);
$current-map: list.nth($maps, -1);
$current-get: map.get($current-map, $current-key);
@if not $current-get {
@error "Key `#{$key}` doesn't exist at current level in map.";
}
$maps: list.append($maps, $current-get);
}
// Loop from the last map to the first one
// Merge it with the previous one
@for $i from length($maps) through 1 {
$current-map: list.nth($maps, $i);
$current-key: list.nth($keys, $i);
$current-val: if($i == list.length($maps), $value, $result);
$result: map.map-merge($current-map, ($current-key: $current-val));
}
// Return result
@return $result;
}
// font size utility classes
@each $name, $size in variables.$font-sizes {
.text-#{$name} {
font-size: $size;
line-height: map.get(variables.$font-line-height, $name);
}
}
// truncate utility class
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// gap utility class
@each $name, $size in variables.$gap {
.gap-#{$name} {
gap: $size;
}
.gap-x-#{$name} {
column-gap: $size;
}
.gap-y-#{$name} {
row-gap: $size;
}
}
.list-none {
list-style-type: none;
}

View File

@ -0,0 +1,198 @@
@use "vuetify/lib/styles/tools/functions" as *;
/*
TODO: Add docs on when to use placeholder vs when to use SASS variable
Placeholder
- When we want to keep customization to our self between templates use it
Variables
- When we want to allow customization from both user and our side
- You can also use variable for consistency (e.g. mx 1 rem should be applied to both vertical nav items and vertical nav header)
*/
@forward "@layouts/styles/variables" with (
// Adjust z-index so vertical nav & overlay stays on top of v-layout in v-main. E.g. Email app
$layout-vertical-nav-z-index: 1003,
$layout-overlay-z-index: 1002,
);
@use "@layouts/styles/variables" as *;
// 👉 Default layout
$navbar-high-emphasis-text: true !default;
// @forward "@layouts/styles/variables" with (
// $layout-vertical-nav-width: 350px !default,
// );
$theme-colors-name: (
"primary",
"secondary",
"error",
"info",
"success",
"warning"
) !default;
// 👉 Default layout with vertical nav
$default-layout-with-vertical-nav-navbar-footer-roundness: 10px !default;
// 👉 Vertical nav
$vertical-nav-background-color-rgb: var(--v-theme-background) !default;
$vertical-nav-background-color: rgb(#{$vertical-nav-background-color-rgb}) !default;
// This is used to keep consistency between nav items and nav header left & right margin
// This is used by nav items & nav header
$vertical-nav-horizontal-spacing: 1rem !default;
$vertical-nav-horizontal-padding: 0.75rem !default;
// Vertical nav header height. Mostly we will align it with navbar height;
$vertical-nav-header-height: $layout-vertical-nav-navbar-height !default;
$vertical-nav-navbar-elevation: 3 !default;
$vertical-nav-navbar-style: "elevated" !default; // options: elevated, floating
$vertical-nav-floating-navbar-top: 1rem !default;
// Vertical nav header padding
$vertical-nav-header-padding: 1rem $vertical-nav-horizontal-padding !default;
$vertical-nav-header-inline-spacing: $vertical-nav-horizontal-spacing !default;
// Move logo when vertical nav is mini (collapsed but not hovered)
$vertical-nav-header-logo-translate-x-when-vertical-nav-mini: -4px !default;
// Space between logo and title
$vertical-nav-header-logo-title-spacing: 0.9rem !default;
// Section title margin top (when its not first child)
$vertical-nav-section-title-mt: 1.5rem !default;
// Section title margin bottom
$vertical-nav-section-title-mb: 0.5rem !default;
// Vertical nav icons
$vertical-nav-items-icon-size: 1.5rem !default;
$vertical-nav-items-nested-icon-size: 0.9rem !default;
$vertical-nav-items-icon-margin-inline-end: 0.5rem !default;
// Transition duration for nav group arrow
$vertical-nav-nav-group-arrow-transition-duration: 0.15s !default;
// Timing function for nav group arrow
$vertical-nav-nav-group-arrow-transition-timing-function: ease-in-out !default;
// 👉 Horizontal nav
/*
Heads up
==================
Here we assume we will always use shorthand property which will apply same padding on four side
This is because this have been used as value of top property by `.popper-content`
*/
$horizontal-nav-padding: 0.6875rem !default;
// Gap between top level horizontal nav items
$horizontal-nav-top-level-items-gap: 4px !default;
// Horizontal nav icons
$horizontal-nav-items-icon-size: 1.5rem !default;
$horizontal-nav-third-level-icon-size: 0.9rem !default;
$horizontal-nav-items-icon-margin-inline-end: 0.625rem !default;
$horizontal-nav-group-arrow-icon-size: 1.375rem !default;
// We used SCSS variable because we want to allow users to update max height of popper content
// 120px is combined height of navbar & horizontal nav
$horizontal-nav-popper-content-max-height: calc(100dvh - 120px - 4rem) !default;
// This variable is used for horizontal nav popper content's `margin-top` and "The bridge"'s height. We need to sync both values.
$horizontal-nav-popper-content-top: calc($horizontal-nav-padding + 0.375rem) !default;
// 👉 Plugins
$plugin-ps-thumb-y-dark: rgba(var(--v-theme-surface-variant), 0.35) !default;
// 👉 Vuetify
// Used in src/@core/scss/base/libs/vuetify/_overrides.scss
$vuetify-reduce-default-compact-button-icon-size: true !default;
// 👉 Custom variables
// for utility classes
$font-sizes: () !default;
$font-sizes: map-deep-merge(
(
"xs": 0.75rem,
"sm": 0.875rem,
"base": 1rem,
"lg": 1.125rem,
"xl": 1.25rem,
"2xl": 1.5rem,
"3xl": 1.875rem,
"4xl": 2.25rem,
"5xl": 3rem,
"6xl": 3.75rem,
"7xl": 4.5rem,
"8xl": 6rem,
"9xl": 8rem
),
$font-sizes
);
// line height
$font-line-height: () !default;
$font-line-height: map-deep-merge(
(
"xs": 1rem,
"sm": 1.25rem,
"base": 1.5rem,
"lg": 1.75rem,
"xl": 1.75rem,
"2xl": 2rem,
"3xl": 2.25rem,
"4xl": 2.5rem,
"5xl": 1,
"6xl": 1,
"7xl": 1,
"8xl": 1,
"9xl": 1
),
$font-line-height
);
// gap utility class
$gap: () !default;
$gap: map-deep-merge(
(
"0": 0,
"1": 0.25rem,
"2": 0.5rem,
"3": 0.75rem,
"4": 1rem,
"5": 1.25rem,
"6":1.5rem,
"7": 1.75rem,
"8": 2rem,
"9": 2.25rem,
"10": 2.5rem,
"11": 2.75rem,
"12": 3rem,
"14": 3.5rem,
"16": 4rem,
"20": 5rem,
"24": 6rem,
"28": 7rem,
"32": 8rem,
"36": 9rem,
"40": 10rem,
"44": 11rem,
"48": 12rem,
"52": 13rem,
"56": 14rem,
"60": 15rem,
"64": 16rem,
"72": 18rem,
"80": 20rem,
"96": 24rem
),
$gap
);

View File

@ -0,0 +1,251 @@
@use "@core/scss/base/placeholders" as *;
@use "@core/scss/template/placeholders" as *;
@use "@layouts/styles/mixins" as layoutsMixins;
@use "@configured-variables" as variables;
@use "@core/scss/base/mixins" as mixins;
@use "vuetify/lib/styles/tools/states" as vuetifyStates;
.layout-nav-type-vertical {
// 👉 Layout Vertical nav
.layout-vertical-nav {
$sl-layout-nav-type-vertical: &;
@extend %nav;
@at-root {
// Add styles for collapsed vertical nav
.layout-vertical-nav-collapsed#{$sl-layout-nav-type-vertical}.hovered {
@include mixins.elevation(6);
}
}
background-color: variables.$vertical-nav-background-color;
// 👉 Nav header
.nav-header {
overflow: hidden;
padding: variables.$vertical-nav-header-padding;
margin-inline: variables.$vertical-nav-header-inline-spacing;
min-block-size: variables.$vertical-nav-header-height;
// TEMPLATE: Check if we need to move this to master
.app-logo {
flex-shrink: 0;
transition: transform 0.25s ease-in-out;
@at-root {
// Move logo a bit to align center with the icons in vertical nav mini variant
.layout-vertical-nav-collapsed#{$sl-layout-nav-type-vertical}:not(.hovered) .nav-header .app-logo {
transform: translateX(variables.$vertical-nav-header-logo-translate-x-when-vertical-nav-mini);
@include layoutsMixins.rtl {
transform: translateX(-(variables.$vertical-nav-header-logo-translate-x-when-vertical-nav-mini));
}
}
}
}
.header-action {
@extend %nav-header-action;
}
}
// 👉 Nav items shadow
.vertical-nav-items-shadow {
position: absolute;
z-index: 1;
background:
linear-gradient(
rgb(#{variables.$vertical-nav-background-color-rgb}) 5%,
rgba(#{variables.$vertical-nav-background-color-rgb}, 75%) 45%,
rgba(#{variables.$vertical-nav-background-color-rgb}, 20%) 80%,
transparent
);
block-size: 55px;
inline-size: 100%;
inset-block-start: calc(#{variables.$vertical-nav-header-height} - 2px);
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease-in-out;
will-change: opacity;
@include layoutsMixins.rtl {
transform: translateX(8px);
}
}
&.scrolled {
.vertical-nav-items-shadow {
opacity: 1;
}
}
.ps__rail-y {
// Setting z-index: 1 will make perfect scrollbar thumb appear on top of vertical nav items shadow
z-index: 1;
}
// 👉 Nav section title
.nav-section-title {
@extend %vertical-nav-item;
@extend %vertical-nav-section-title;
margin-block-end: variables.$vertical-nav-section-title-mb;
&:not(:first-child) {
margin-block-start: variables.$vertical-nav-section-title-mt;
}
.placeholder-icon {
margin-inline: auto;
}
}
// Nav item badge
.nav-item-badge {
@extend %vertical-nav-item-badge;
}
// 👉 Nav group & Link
.nav-link,
.nav-group {
overflow: hidden;
> :first-child {
@extend %vertical-nav-item;
@extend %vertical-nav-item-interactive;
}
.nav-item-icon {
@extend %vertical-nav-items-icon;
}
&.disabled {
opacity: var(--v-disabled-opacity);
pointer-events: none;
}
}
// 👉 Vertical nav link
.nav-link {
@extend %nav-link;
> .router-link-exact-active {
@extend %nav-link-active;
}
> a {
// Adds before psudo element to style hover state
@include mixins.before-pseudo;
// Adds vuetify states
&:not(.router-link-active, .router-link-exact-active){
@include vuetifyStates.states($active: false);
}
}
}
// 👉 Vertical nav group
.nav-group {
// Reduce the size of icon if link/group is inside group
.nav-group,
.nav-link {
.nav-item-icon {
@extend %vertical-nav-items-nested-icon;
}
}
// Hide icons after 2nd level
& .nav-group {
.nav-link,
.nav-group {
.nav-item-icon {
@extend %vertical-nav-items-icon-after-2nd-level;
}
}
}
.nav-group-arrow {
flex-shrink: 0;
transform-origin: center;
transition: transform variables.$vertical-nav-nav-group-arrow-transition-duration variables.$vertical-nav-nav-group-arrow-transition-timing-function;
will-change: transform;
}
// Rotate arrow icon if group is opened
&.open {
> .nav-group-label .nav-group-arrow {
transform: rotateZ(90deg);
}
}
// Nav group label
> :first-child {
// Adds before psudo element to style hover state
@include mixins.before-pseudo;
}
&:not(.active,.open) > :first-child {
// Adds vuetify states
@include vuetifyStates.states($active: false);
}
// Active & open states for nav group label
&.active,
&.open {
> :first-child {
@extend %vertical-nav-group-open-active;
}
}
}
}
}
// SECTION: Transitions
.vertical-nav-section-title-enter-active,
.vertical-nav-section-title-leave-active {
transition: opacity 0.1s ease-in-out, transform 0.1s ease-in-out;
}
.vertical-nav-section-title-enter-from,
.vertical-nav-section-title-leave-to {
opacity: 0;
transform: translateX(15px);
@include layoutsMixins.rtl {
transform: translateX(-15px);
}
}
.transition-slide-x-enter-active,
.transition-slide-x-leave-active {
transition: opacity 0.1s ease-in-out, transform 0.12s ease-in-out;
}
.transition-slide-x-enter-from,
.transition-slide-x-leave-to {
opacity: 0;
transform: translateX(-15px);
@include layoutsMixins.rtl {
transform: translateX(15px);
}
}
.vertical-nav-app-title-enter-active,
.vertical-nav-app-title-leave-active {
transition: opacity 0.1s ease-in-out, transform 0.12s ease-in-out;
}
.vertical-nav-app-title-enter-from,
.vertical-nav-app-title-leave-to {
opacity: 0;
transform: translateX(-15px);
@include layoutsMixins.rtl {
transform: translateX(15px);
}
}
// !SECTION

View File

@ -0,0 +1,35 @@
$ps-size: 0.25rem;
$ps-hover-size: 0.375rem;
$ps-track-size: 0.5rem;
.ps__thumb-y {
inline-size: $ps-size !important;
inset-inline-end: 0.0625rem;
}
.ps__thumb-x {
block-size: $ps-size !important;
}
.ps__rail-x {
background: transparent !important;
block-size: $ps-track-size;
}
.ps__rail-y {
background: transparent !important;
inline-size: $ps-track-size !important;
inset-inline-end: 0.125rem !important;
inset-inline-start: unset !important;
}
.ps__rail-y.ps--clicking .ps__thumb-y,
.ps__rail-y:focus > .ps__thumb-y,
.ps__rail-y:hover > .ps__thumb-y {
inline-size: $ps-hover-size !important;
}
.ps__thumb-x,
.ps__thumb-y {
background-color: rgb(var(--v-theme-perfect-scrollbar-thumb)) !important;
}

View File

@ -0,0 +1 @@
@use "overrides";

View File

@ -0,0 +1,295 @@
@use "@core/scss/base/utils";
@use "@configured-variables" as variables;
// 👉 Application
// We need accurate vh in mobile devices as well
.v-application__wrap {
/* stylelint-disable-next-line liberty/use-logical-spec */
min-height: 100dvh;
}
// 👉 Typography
h1,
h2,
h3,
h4,
h5,
h6,
.text-h1,
.text-h2,
.text-h3,
.text-h4,
.text-h5,
.text-h6,
.text-button,
.text-overline,
.v-card-title {
color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));
}
body,
.text-body-1,
.text-body-2,
.text-subtitle-1,
.text-subtitle-2 {
color: rgba(var(--v-theme-on-background), var(--v-medium-emphasis-opacity));
}
// 👉 Grid
// Remove margin-bottom of v-input_details inside grid (validation error message)
.v-row {
.v-col,
[class^="v-col-*"] {
.v-input__details {
margin-block-end: 0;
}
}
}
// 👉 Button
// Update tonal variant disabled opacity
.v-btn--disabled {
opacity: 0.65;
}
@if variables.$vuetify-reduce-default-compact-button-icon-size {
.v-btn--density-compact.v-btn--size-default {
.v-btn__content > svg {
block-size: 22px;
font-size: 22px;
inline-size: 22px;
}
}
}
// 👉 Card
.v-card-subtitle {
color: rgba(var(--v-theme-on-background), var(--v-disabled-opacity));
}
// Removes padding-top for immediately placed v-card-text after itself
.v-card-text {
& + & {
padding-block-start: 0 !important;
}
}
/*
👉 Checkbox & Radio Ripple
TODO Checkbox and switch component. Remove it when vuetify resolve the extra spacing: https://github.com/vuetifyjs/vuetify/issues/15519
We need this because form elements likes checkbox and switches are by default set to height of textfield height which is way big than we want
Tested with checkbox & switches
*/
.v-checkbox.v-input,
.v-switch.v-input {
--v-input-control-height: auto;
flex: unset;
}
.v-selection-control--density-comfortable {
&.v-checkbox-btn,
&.v-radio,
&.v-radio-btn {
.v-selection-control__wrapper {
margin-inline-start: -0.5625rem;
}
}
}
.v-selection-control--density-compact {
&.v-radio,
&.v-radio-btn,
&.v-checkbox-btn {
.v-selection-control__wrapper {
margin-inline-start: -0.3125rem;
}
}
}
.v-selection-control--density-default {
&.v-checkbox-btn,
&.v-radio,
&.v-radio-btn {
.v-selection-control__wrapper {
margin-inline-start: -0.6875rem;
}
}
}
.v-radio-group {
.v-selection-control-group {
.v-radio:not(:last-child) {
margin-inline-end: 0.9rem;
}
}
}
/*
👉 Tabs
Disable tab transition
This is for tabs where we don't have card wrapper to tabs and have multiple cards as tab content.
This class will disable transition and adds `overflow: unset` on `VWindow` to allow spreading shadow
*/
.disable-tab-transition {
overflow: unset !important;
.v-window__container {
block-size: auto !important;
}
.v-window-item:not(.v-window-item--active) {
display: none !important;
}
.v-window__container .v-window-item {
transform: none !important;
}
}
// 👉 List
.v-list {
// Set icons opacity to .87
.v-list-item__prepend > .v-icon,
.v-list-item__append > .v-icon {
opacity: 1;
}
}
// 👉 Card list
/*
Custom class
Remove list spacing inside card
This is because card title gets padding of 20px and list item have padding of 16px. Moreover, list container have padding-bottom as well.
*/
.card-list {
--v-card-list-gap: 20px;
&.v-list {
padding-block: 0;
}
.v-list-item {
min-block-size: unset;
min-block-size: auto !important;
padding-block: 0 !important;
padding-inline: 0 !important;
> .v-ripple__container {
opacity: 0;
}
&:not(:last-child) {
padding-block-end: var(--v-card-list-gap) !important;
}
}
.v-list-item:hover,
.v-list-item:focus,
.v-list-item:active,
.v-list-item.active {
> .v-list-item__overlay {
opacity: 0 !important;
}
}
}
// 👉 Divider
.v-divider {
color: rgb(var(--v-border-color));
}
// 👉 DataTable
.v-data-table {
/* stylelint-disable-next-line no-descending-specificity */
.v-checkbox-btn .v-selection-control__wrapper {
margin-inline-start: 0 !important;
}
.v-selection-control {
display: flex !important;
}
.v-pagination {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
}
}
.v-data-table-footer {
margin-block-start: 1rem;
}
// 👉 v-field
.v-field:hover .v-field__outline {
--v-field-border-opacity: var(--v-medium-emphasis-opacity);
}
// 👉 VLabel
.v-label {
opacity: 1 !important;
&:not(.v-field-label--floating) {
color: rgba(var(--v-theme-on-background), var(--v-medium-emphasis-opacity));
}
}
// 👉 Overlay
.v-overlay__scrim,
.v-navigation-drawer__scrim {
background: rgba(var(--v-overlay-scrim-background), var(--v-overlay-scrim-opacity)) !important;
opacity: 1 !important;
}
// 👉 VMessages
.v-messages {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
opacity: 1;
}
// 👉 Alert close btn
.v-alert__close {
.v-btn--icon .v-icon {
--v-icon-size-multiplier: 1.5;
}
}
// 👉 Badge icon alignment
.v-badge__badge {
display: flex;
align-items: center;
}
// 👉 Btn focus outline style removed
.v-btn:focus-visible::after {
opacity: 0 !important;
}
// .v-select chip spacing for slot
.v-input:not(.v-select--chips) .v-select__selection {
.v-chip {
margin-block: 2px var(--select-chips-margin-bottom);
}
}
// 👉 VCard and VList subtitle color
.v-list-item-subtitle {
color: rgba(var(--v-theme-on-background), var(--v-medium-emphasis-opacity));
}
// 👉 placeholders
.v-field__input {
@at-root {
& input::placeholder,
input#{&}::placeholder,
textarea#{&}::placeholder {
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity)) !important;
opacity: 1 !important;
}
}
}

View File

@ -0,0 +1,57 @@
@use "sass:map";
// 👉 Shadow opacities
$shadow-key-umbra-opacity-custom: var(--v-shadow-key-umbra-opacity);
$shadow-key-penumbra-opacity-custom: var(--v-shadow-key-penumbra-opacity);
$shadow-key-ambient-opacity-custom: var(--v-shadow-key-ambient-opacity);
// 👉 Card transition properties
$card-transition-property-custom: box-shadow, opacity;
@forward "vuetify/settings" with (
// 👉 General settings
$color-pack: false !default,
// 👉 Shadow opacity
$shadow-key-umbra-opacity: $shadow-key-umbra-opacity-custom !default,
$shadow-key-penumbra-opacity: $shadow-key-penumbra-opacity-custom !default,
$shadow-key-ambient-opacity: $shadow-key-ambient-opacity-custom !default,
// 👉 Card
$card-color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) !default,
$card-elevation: 6 !default,
$card-title-line-height: 1.6 !default,
$card-actions-min-height: unset !default,
$card-text-padding: 1.25rem !default,
$card-item-padding: 1.25rem !default,
$card-actions-padding: 0 12px 12px !default,
$card-transition-property: $card-transition-property-custom !default,
$card-subtitle-opacity: 1 !default,
// 👉 Expansion Panel
$expansion-panel-active-title-min-height: 48px !default,
// 👉 List
$list-item-icon-margin-end: 16px !default,
$list-item-icon-margin-start: 16px !default,
$list-item-subtitle-opacity: 1 !default,
// 👉 Tooltip
$tooltip-background-color: rgba(59, 55, 68, 0.9) !default,
$tooltip-text-color: rgb(var(--v-theme-on-primary)) !default,
$tooltip-font-size: 0.75rem !default,
// 👉 VTimeline
$timeline-dot-size: 34px !default,
// 👉 VOverlay
$overlay-opacity: 1 !default,
// 👉 VContainer
$container-max-widths: (
"xl": 1440px,
"xxl": 1440px
) !default,
);

View File

@ -0,0 +1,27 @@
@use "@configured-variables" as variables;
@use "misc";
@use "@core/scss/base/mixins";
%default-layout-horizontal-nav-navbar-and-nav-container {
@include mixins.elevation(3);
// 1000 is v-window z-index
z-index: 1001;
background-color: rgb(var(--v-theme-surface));
&.header-blur {
@extend %blurry-bg;
}
}
%default-layout-horizontal-nav-navbar {
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
%default-layout-horizontal-nav-nav {
padding-block: variables.$horizontal-nav-padding;
}
%default-layout-horizontal-nav-nav-items-list {
gap: variables.$horizontal-nav-top-level-items-gap;
}

View File

@ -0,0 +1,46 @@
@use "@configured-variables" as variables;
@use "misc";
@use "@core/scss/base/mixins";
%default-layout-vertical-nav-scrolled-sticky-elevated-nav {
background-color: rgb(var(--v-theme-surface));
}
%default-layout-vertical-nav-floating-navbar-and-sticky-elevated-navbar-scrolled {
@include mixins.elevation(variables.$vertical-nav-navbar-elevation);
// If navbar is contained => Squeeze navbar content on scroll
@if variables.$layout-vertical-nav-navbar-is-contained {
padding-inline: 1.2rem;
}
}
%default-layout-vertical-nav-floating-navbar-overlay {
isolation: isolate;
&::after {
position: absolute;
z-index: -1;
/* stylelint-disable property-no-vendor-prefix */
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
/* stylelint-enable */
background:
linear-gradient(
180deg,
rgba(var(--v-theme-background), 70%) 44%,
rgba(var(--v-theme-background), 43%) 73%,
rgba(var(--v-theme-background), 0%)
);
background-repeat: repeat;
block-size: calc(variables.$layout-vertical-nav-navbar-height + variables.$vertical-nav-floating-navbar-top + 0.5rem);
content: "";
inset-block-start: -(variables.$vertical-nav-floating-navbar-top);
inset-inline-end: 0;
inset-inline-start: 0;
/* stylelint-disable property-no-vendor-prefix */
-webkit-mask: linear-gradient(black, black 18%, transparent 100%);
mask: linear-gradient(black, black 18%, transparent 100%);
/* stylelint-enable */
}
}

View File

@ -0,0 +1,3 @@
%layout-navbar {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}

View File

@ -0,0 +1,99 @@
@use "@layouts/styles/mixins" as layoutsMixins;
@use "@core/scss/base/variables";
@use "@layouts/styles/placeholders";
@use "@core/scss/base/mixins";
// Horizontal nav item styles (including nested)
%horizontal-nav-item {
padding-block: 0.6rem;
padding-inline: 1rem;
}
// Top level horizontal nav item styles (`a` tag & group label)
%horizontal-nav-top-level-item {
border-radius: 0.4rem;
}
%horizontal-nav-disabled {
opacity: var(--v-disabled-opacity);
pointer-events: none;
}
// Active styles for sub nav link
%horizontal-nav-sub-nav-link-active {
background: rgba(var(--v-theme-primary), 0.1);
color: rgb(var(--v-theme-primary));
}
/*
This style is required when you don't provide any transition to horizontal nav items via themeConfig `themeConfig.horizontalNav.transition`
Also, you have to disable it if you are using transition
*/
// Popper content styles when it's hidden
%horizontal-nav-popper-content-hidden {
// display: none;
// opacity: 0;
// pointer-events: none;
// transform: translateY(7px);
// transition: transform 0.25s ease-in-out, opacity 0.15s ease-in-out;
}
/*
This style is required when you don't provide any transition to horizontal nav items via themeConfig `themeConfig.horizontalNav.transition`
Also, you have to disable it if you are using transition
*/
// Popper content styles when it's shown
%horizontal-nav-popper-content-visible {
// display: block;
// opacity: 1;
// pointer-events: auto;
// pointer-events: auto;
// transform: translateY(0);
}
// Horizontal nav item icon (Including sub nav items)
%horizontal-nav-item-icon {
font-size: variables.$horizontal-nav-items-icon-size;
margin-inline-end: variables.$horizontal-nav-items-icon-margin-inline-end;
}
// Horizontal nav subitem
%horizontal-nav-subitem {
min-inline-size: 12rem;
.nav-item-title {
margin-inline-end: 1rem;
}
}
// Styles for third level item icon/ (e.g. Reduce the icon's size of nested group's nav links (Top level group > Sub group > [Nav links]))
%third-level-nav-item-icon {
font-size: variables.$horizontal-nav-third-level-icon-size;
margin-inline-end: 0.75rem;
/*
`margin-inline` will be (normal icon font-size - small icon font-size) / 2
(1.5rem - 0.9rem) / 2 => 0.6rem / 2 => 0.3rem
*/
margin-inline-start: calc((variables.$horizontal-nav-items-icon-size - variables.$horizontal-nav-third-level-icon-size) / 2);
}
// Horizontal nav item title
%horizontal-nav-item-title {
margin-inline-end: 0.3rem;
white-space: nowrap;
}
// Popper content styles
%horizontal-nav-popper-content {
@include mixins.elevation(4);
border-radius: 6px;
padding-block: 0.3rem;
> div {
@extend %style-scroll-bar;
}
}

View File

@ -0,0 +1,7 @@
@forward "horizontal-nav";
@forward "vertical-nav";
@forward "nav";
@forward "default-layout";
@forward "default-layout-vertical-nav";
@forward "default-layout-horizontal-nav";
@forward "misc";

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