use simple repo

This commit is contained in:
liangping
2023-04-03 17:08:02 +08:00
parent 52de377644
commit 671050f5f2
1157 changed files with 33878 additions and 159167 deletions
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { useTheme } from 'vuetify'
import { useThemeConfig } from '@/plugins/vuetify/@core/composable/useThemeConfig'
import { hexToRgb } from '@/plugins/vuetify/@layouts/utils'
const { syncInitialLoaderTheme, syncVuetifyThemeWithTheme: syncConfigThemeWithVuetifyTheme, isAppRtl } = useThemeConfig()
const { global } = useTheme()
// ️ Sync current theme with initial loader theme
syncInitialLoaderTheme()
syncConfigThemeWithVuetifyTheme()
</script>
<template>
<VLocaleProvider :rtl="isAppRtl">
<!-- This is required to set the background color of active nav link based on currently active global theme's primary -->
<VApp :style="`--v-global-theme-primary: ${hexToRgb(global.current.value.colors.primary)}`">
<RouterView />
</VApp>
</VLocaleProvider>
</template>
+74
View File
@@ -0,0 +1,74 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
position: relative;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition: color 0.5s, background-color 0.5s;
line-height: 1.6;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+82
View File
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="150px" height="132px" viewBox="0 0 150 132" enable-background="new 0 0 150 132" xml:space="preserve"> <image id="image0" width="150" height="132" x="0" y="0"
href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJYAAACECAMAAABFwSJAAAAABGdBTUEAALGPC/xhBQAAACBjSFJN
AAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAACuFBMVEVmbP////9mbP9mbP9m
bP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9m
bP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9m
bP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9m
bP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9m
bP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9m
bP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9m
bP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9m
bP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP9mbP+Znf+Xm/+Wmv+UmP+T
l/+Rlv+QlP+Ok/+Mkf+LkP+Jjv+Ijf/a2//////S1P+ztv+mqf+coP/l5v/d3v+7vv+ytf+hpP/2
9v/v8P+qrf+prP+nqv+kp/+bn//g4f/u7//Hyf+2uf/9/f++wf+Gi//y8//c3f+sr/+eov/R0/+w
s//U1f/Jy//p6v/6+v/BxP/P0f/Ex/+fo//8/P+5vP/3+P/s7f/m5//09f/o6f+Fiv/f4P/j5P/X
2P+1uP/Dxf/5+f+vsv/i4//Mzv/GyP+tsP+8v//KzP/O0P+ipv/x8v+/wv/r7P+4u//Z2v/V1/+N
hj37AAAAl3RSTlMAAIXd6u7y4Qgo+nu08xhGnQTU/TFrvxDrUY7bByZzsu8VRZUD0/sraLhJjNYl
+Wyw7EPRZg5Ci9Ak+GSu50GGz/cgquj+PInLASFerOQKP4ACzvUcY6I1xFbfPXnM8WGbDOb8L4S9
H0+p2gVxE1+TKYK2HvZHpzpqyV2BQKQ4Ysdb4n8bOcioDa+N1Ui3KpRgiCOKRLGzfY2kuQAAAAFi
S0dEAf8CLd4AAAAHdElNRQfnAgkKBBi6ykX0AAACs3pUWHRSYXcgcHJvZmlsZSB0eXBlIHhtcAAA
OI2VVVGy2yAM/OcUPQKWhATHcYL560w/e/zuQpKXxHnt1MzYIIS0khY5/f75K/3gU0OTXnVEjeyb
q1+8hEl28eLhzQ/tIse4XC5DBPLmRkkJLdY1W49sCt3qLVmNPXCwaOx2FHN8YVAVh0R06CFZr1F1
j+o46J3OfJPMtV/9COVeogegMR/EofvaeKhPJF9mILvwhD1OSC7VeslJCG7EFGmRQ1068GyqWiEJ
bZBtWrRpVVGTK6SiGTKXgW/De1NL0qdwp2i+AfptyC08AQrXvYiZ+VtokuYmw6thGFl3hDNiPnIE
tOSYiGN6bhwTieAtePflAIhCA/VhRqIiLHjg/isKQECpUAjxNjPVkCFo3Pd9S0jYCCSWqFZin2vB
BFtHqU+Yp8Pjq0wJCyTbO8KpwJQZANKLLwz2e9WejGtYIc/eEpI+Wf/eOJkYCA46g+ZCZzzimkDF
aZ95+RTYv4NabtMnv3eTt/zsxqq1dWlg4Go0oVFIW5bJmtlk9joEyzZv047ysMwZw3UD2g4yNvBe
UdWNtMT16QiiTN5xNycoGpZQxKECNYwTog8I3gGkiYD0B81VrJginxvWBWsYhcQMcsyrrasi2iDF
wJcaTsfpxTOJqaSmM4Hl5LndQn91bHSc/tfzrZa7keVIBdyiFMUSoh9+PbWGjGY1ppLOWWaXAIQs
tazqcQ9XUdq6TolN6yWtZd4nhzcsMCcx8xOfd/+YgHTOgDv7EWY8tqG5dXyN3YjFR9GxM2mALkpC
KBsimA1KQxTITZAzaF3yhuCb4r/01ZkjXUjilJeJbtPsBEmu+WyD1A7MDIjQ4zFruiW8KCQViYj4
nGGxkkg729fsTDL+3pvSu9pqpA/p6e+xds6/sMS4198qZP1+0h/aE5hgx863IwAACeZJREFUeNq9
nPlDHEUWxyuR1RjXKF5oPDYqiUeMN1mNiYqKSjQoicb7QI237rq48Vo16nrt4a3FMMydRiAkEA5J
YJJIEiKQAzKBBBPMCeu/sV2vhqFher5dPdOd7w/AzNSr+kxN1atXr2pgTEUTj0mhrD8o6Fhu0HGT
cFMTSEpUx0/mzukEx7D+6CAVP3GKQ1gnOUnF+cnOYGWf4izWqac5gnW6s1Scn+EEVs6ZTmPxsxzA
muo4FT/7nIyxznXSOYzovIyx/uQCFZ92foZYF7hBxfmFmWHlTncHa8ZFGWFd7A4V55dkgpWT5RYW
vzQDrJmuUfHLstPGmnW5e1j8irSxrnSRil+VkybW1W5ScT41Pazca9zFmjwrLaxr3aXi/Mp0sPLc
cw4jmp0G1p9dp+LX5drGun6O+1j8BttYc48CFZ+XZxPrxqNBxflN9rBybz46WPm32MK6AdVVVubx
eMp1eXVV6PL5fH6hgK6grpBQmBSJRCDXrXaw8m5DVMs1O6qEXAW328C6A9X0oy0qTauC3XVnoTLW
fOQcqmtsYq0IQ667lLHuRtXU2qTStJUQa0GRItY9qJZVdXpD9StXkxoaGhqlqqoaR//U1dRU1fxT
HKvFB7nuVcMqXgjq8KwRDa3lCqpuHemutigqt+g+Jaz7UR2NNFrKFKiCcgyuEz9w+cUqWHnzUGet
F61sUOmstUS1/Gfxsx07iQcUsG5CNWwUjWwqV6DythBWdXSz+LUKln2w0BJrfj7qrA69iS24jbh+
IarOKC8Xc6QGji7+kCXWrci8TTTVpULVvVUU3Srm4DZrJ/HwJAusR5B19Xa9gR0qH2G4kjprm/g7
1KP/1RuC5R/FWMWPAdvyTdbvO64Gotop/TvN3s0xVP7xKRDrCcu2dnkVqAJ9xtUw1i8eNEOLJxFW
yVPAsmyXqL1JpbNWElX/yGJYJh6th6P+6WcA1hJkuZs+iwoFqoo66RxGHsf2iIeroc2zqbGeQ86h
WjiHgWpurUgXUXWNdk+wV3/cE4RWz6fEegGZkSOqVaCSn5lWZxyEvybmZUq9+FIKrNsLgFWTcA49
KothpF9LggjvFU4CW79sjlV4J7Ap3yea+lWls6qkcxjrqFaJ5wahk5j2iinWq8imQXkxjOwgrN/G
PbvPemn8ixlWyQJgUSZCuq1KzkFGr/3j3YFni4gnoK+f8VcTrNeQhXQOKlS+AVG0NXnG7rd2En9L
xip9HZRvFsFc/c8KVNED1FmDJrzCwdT7ofXfk7CWgtLeLuv5HRdFfdr2gMlL5CT2Q+tTssdhvYGc
Q7eosE8pcpDO4Uez10Ji29uKa3lzLFbhW6iz+pUj5Q1E1WMeI5OTaIdLY1bOGKy3Ldvqx7sqKb/c
VqTwA7GD4sVDsIaZRqwi5By8NeZTy0Q0YVPHCl4xS/tgd8253oD1DipJM3uPChU1iyKrbdabzLmj
WKWLQLnmw2JiqwR/Udraau2pl5iwcBLrsJP4RwLrXVTsgHKk3Cz30GiuNVq75YXFcaz3kHOoEluY
FSrBX1TmvY6g9TgqJnUr9svvS6zCZaCM3FbgFJWxJ7TeACxUTUs+XBrnlRDWB6gMbaMH8WiQCreY
RQ5J3XXE2gcuEVhFH4ISHuEcevGmJd4eTVjtp6hFuQoRP9dAJ5hfqmN9hErQjNY6hhLqMNXOnTt7
NAvnMCLKcQ7DIksnsH8i59Ddq5TuG1UXjD9JfhE/18H4ueBjthh1+CabVAN4vEtRUF0JE6rL2Cfg
VZ9NKqswTyqwQpSEWIthROPbZxerA2eVSU2i4P9QQZG2/BS8XkbbhT3DtdukhuNaO6rdca3cS1zW
sWJAJBS3wJX/owkWW2lKJgyrjBjuJaztlosnBaltqMSHRcKdTkRtUa60WwUrJvN/my0cV0W9XmgI
LmYfkJf/DOVwaUXpxNmDuILbiQsHZmHyunDlX1Yol2qU8fbTqG9UweK/EVY/HPWUn1iORkXBe/EI
ohgdHlJiskZpdEVksg29B5m8hCv/u4l4Cx610pDZrdRdhwhrCHzk5BwqUQCxqHQ0aP4clPN3iMVC
JeDi0YMWTiIs+nPAg+p4xxDLwzs+FNusUfCTenxKae/WlHEQbUGOoBoWFBk3ZP8CJWnbqSntfDhF
U9qBFAu2T+QMhmAM+PaYfSK8XEqb6vVK3eXbAZxEmM5ZYAj4VuHYzT66ihuyri4h+sS1vaY+lfIT
61FnFbwxLgeR/W9QmjLHPSqRMw/Ls02z0Dn4u3gFbi+WJmVs/oOKU0SplM6ViQatxaRPVtPahDrr
9dLktNt/QfkgHduo5HN5TIaOyWtxhQgxBmAdr5lkA7+YAQzIUR5QGvVeOgtOTuAPi6fhRn9BiVlK
90tgEWpXjiTiTqJz3Kj3COfQA8OeV00zza9MAybNIiPbrxRJhOuJq2Hs+xpMfm6c5EWN5OOCl5FR
rbqTkLm3XWMGNzmHSvS24tdakrFeehFYeYf0encoOYmIdBLGs4WAWCm2wrT8CynPfJ5HZhTs1lpt
mkm0LGiHDcs7deAgssl/LiUWexbYBSl+VslzjWTB2xIzNyDWpHoYOSxhqbGeeRoYkqM8aL1t1uWh
SEJLcOy3dA5PlQAs9iTqg05RudJtg3juojPeXXSwghevrxjCmvI4MK0QtfcpdVeFjCRkVjnUaekc
HiuGWOxRZEyOcqNSd8lIooa6i5IO2Oc9wjDWpIeBcVTEz+uUthsydqTxFKSjd7gYGm4Iprg18hAy
p1NFpVsjcjOh9frjkfIvqLPy51tiFT4I7GN0bKMUP4fldmMN94q16DAc78bbp6luJD2A8s60+/wd
39kcKSoPFstrLQfkvM8UsGAiLma94ibUJke9iBx2wc66n6lg3YfSln6RtuxTiiT8LYm0FzxNXlis
hMXuRZVstLvJ1rUPTt57mBoWPC8LiY1x3YbuVaRDo2oyip6heyYiUoXbiruZIha7C1VTpZStNAoe
BM+Zr4wFL2pE221SDcFz4K+ZMha+1lI9YA8L5thuy7OBhW8I7rFFhZ1D0tceIBa8TxkjRUcViUte
kQ/TdflQkBQIwFl4c64tLPY1Pyq6kdnDgnd1HdNcZhMLf+3BIc35xjZW7nXuY33LbGOx2a5TZeWl
geXutxOFrmXpYM1y44veBl2TmxaWG1+LN2o2Sw8r5yo3qT5naWKxK1ykunxW2ljZl7mHNZOljcUu
dY0qKycDLHaJW1gXs0ywLpqROYGZpudmhMUudAfrO5YZ1vnTMmdI1vcsQyx2ngtUk8/NGOucs53H
msoyxmJnOU51Zo4DWOwMp7HeZE5gnXaqs1TTsx3BYic7i3UScwZryolOUv3AHMJiJzhINfl4x7Am
HWes+FiVf5yWlerfrk1kKlj/B8aoqde073OmAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDIzLTAyLTA5
VDEwOjA0OjIzKzAwOjAwewKfcQAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyMy0wMi0wOVQxMDowNDoy
MyswMDowMApfJ80AAAAodEVYdGRhdGU6dGltZXN0YW1wADIwMjMtMDItMDlUMTA6MDQ6MjQrMDA6
MDCY7TicAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAABJRU5ErkJggg==" />
</svg>

After

Width:  |  Height:  |  Size: 6.3 KiB

+35
View File
@@ -0,0 +1,35 @@
@import './base.css';
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
}
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { controlledComputed } from '@vueuse/shared';
interface Props {
title: string
color?: string
icon: string
stats: number
change?: number
}
const props = withDefaults(defineProps<Props>(), {
color: 'primary',
})
const isPositive = controlledComputed(() => props.change, () => Math.sign(props.change||0) === 1)
</script>
<template>
<VCard>
<VCardText class="d-flex align-center">
<VAvatar
size="40"
rounded
:color="props.color"
variant="tonal"
class="me-4"
>
<VIcon
:icon="props.icon"
size="24"
/>
</VAvatar>
<div class="d-flex flex-column">
<div class="d-flex align-center flex-wrap">
<h6 class="text-h6">
{{ (props.stats) }}
</h6>
<div
v-if="props.change"
:class="`${isPositive ? 'text-success' : 'text-error'}`"
>
<VIcon
size="24"
:icon="isPositive ? 'mdi-chevron-up' : 'mdi-chevron-down'"
/>
<span class="text-caption">{{ Math.abs(props.change) }}%</span>
</div>
</div>
<span class="text-caption">{{ props.title }}</span>
</div>
</VCardText>
</VCard>
</template>
+64
View File
@@ -0,0 +1,64 @@
<script setup lang="ts">
interface Props {
title: string
color?: string
icon: string
stats: string
change?: number
subtitle?: string
}
const props = withDefaults(defineProps<Props>(), {
color: 'primary',
})
const isPositive = controlledComputed(() => props.change, () => Math.sign(props.change||0) === 1)
</script>
<template>
<VCard>
<VCardText class="d-flex align-center justify-center">
<VAvatar
v-if="props.icon"
rounded
size="38"
variant="tonal"
:color="props.color"
>
<VIcon
:icon="props.icon"
size="24"
/>
</VAvatar>
<VSpacer />
<div
v-if="props.change"
:class="isPositive ? 'text-success' : 'text-error'"
class="d-flex align-center text-sm font-weight-medium mt-n4"
>
<span>{{ isPositive ? `+${props.change}` : props.change }}%</span>
<VIcon :icon="isPositive ? 'mdi-chevron-up' : 'mdi-chevron-down'" />
</div>
</VCardText>
<VCardText>
<h6 class="text-h6 me-2 mt-2 mb-1">
{{ props.stats }}
</h6>
<p class="text-sm">
{{ props.title }}
</p>
<VChip
v-if="props.subtitle"
size="x-small"
class="font-weight-medium"
>
<span class="text-truncate">{{ props.subtitle }}</span>
</VChip>
</VCardText>
</VCard>
</template>
@@ -0,0 +1,65 @@
<script setup lang="ts">
interface Props {
title: string
subtitle: string
stats: string
change: number
image: string
imgWidth: number
color?: string
}
const props = withDefaults(defineProps<Props>(), {
color: 'primary',
})
const isPositive = controlledComputed(() => props.change, () => Math.sign(props.change) === 1)
</script>
<template>
<VCard>
<VCardText>
<h6 class="text-base font-weight-semibold mb-2">
{{ props.title }}
</h6>
<VChip
v-if="props.subtitle"
size="x-small"
:color="props.color"
class="mb-5"
>
{{ props.subtitle }}
</VChip>
<div class="d-flex align-center flex-wrap">
<h5 class="text-h5 me-2">
{{ props.stats }}
</h5>
<span
class="text-caption"
:class="isPositive ? 'text-success' : 'text-error'"
>
{{ isPositive ? `+${props.change}` : props.change }}%
</span>
</div>
</VCardText>
<VSpacer />
<div class="illustrator-img">
<VImg
v-if="props.image"
:src="props.image"
:width="props.imgWidth"
/>
</div>
</VCard>
</template>
<style lang="scss">
.illustrator-img {
position: absolute;
inset-block-end: 0;
inset-inline-end: 5%;
}
</style>
+54
View File
@@ -0,0 +1,54 @@
<script lang="ts" setup>
import { getLogo, useDashboard, } from '@/stores/useDashboard';
import { computed } from 'vue';
const props = defineProps({
name: {
type: String,
required: true,
},
});
const dashboardStore = useDashboard()
const conf = computed(()=> dashboardStore.chains[props.name] || {})
</script>
<template>
<VCard outlined class="p-1">
<VList class="card-list">
<VListItem :to="`/${name}`">
<template #prepend>
<VAvatar rounded size="45" variant="tonal" class="me-3">
<VImg :src="conf.logo" height="22"/>
</VAvatar>
</template>
<VListItemTitle class="font-weight-semibold text-sm mb-1">
{{ conf?.prettyName || props.name }}
</VListItemTitle>
<VListItemSubtitle class="text-xs">
{{conf?.chainId || ''}}
</VListItemSubtitle>
<template #append>
<VListItemAction @click="(e:Event)=>{e.stopPropagation()}">
<VCheckbox
v-model="dashboardStore.favorite"
true-icon="mdi-star"
false-icon="mdi-star"
color="warning"
:value="props.name"
/>
<VTooltip
activator="parent"
location="top"
>
{{ $t('index.add_to_favorite') }}
</VTooltip>
</VListItemAction>
</template>
</VListItem>
</VList>
</VCard>
</template>
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts" setup>
import { useFormatter } from '@/stores';
import { computed } from '@vue/reactivity';
import { ref, type PropType } from 'vue';
const props = defineProps({
tally: { type: Object as PropType<{
yes: string,
no: string,
noWithVeto: string,
abstain: string
}>},
pool: {
type: Object as PropType<{
notBondedTokens: string;
bondedTokens: string;
}>,
},
})
const format = useFormatter()
const yes = computed(() => (format.calculatePercent(props.tally?.yes, props.pool?.bondedTokens)))
const no = computed(() => ref(format.calculatePercent(props.tally?.no, props.pool?.bondedTokens)))
const abstain = computed(() => (format.calculatePercent(props.tally?.abstain, props.pool?.bondedTokens)))
const veto = computed(() => (format.calculatePercent(props.tally?.noWithVeto, props.pool?.bondedTokens)))
console.log(yes.value, no.value, abstain.value, veto.value)
</script>
<template>
<div class="progress">
<div class="progress-bar bg-success" :style="`width: ${yes}`"></div>
<div class="progress-bar bg-error" :style="`width: ${no}`"></div>
<div class="progress-bar " :style="`width: ${veto}; background-color: #B71C1C;`"></div>
<div class="progress-bar bg-secondary" :style="`width: ${abstain}`"></div>
</div>
</template>
<style scoped>
.progress {
height: 0.8rem;
overflow: hidden;
background-color: rgba(128, 128, 128, 0.178);
}
.progress-bar {
display: inline-block;
height: 100%;
}
.progress :first-child {
border-radius: 0px !important;
border-top-right-radius: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
border-top-left-radius: 0;
}
.progress :last-child {
border-radius: 0px !important;
}
</style>
+155
View File
@@ -0,0 +1,155 @@
<script setup lang="ts">
import VueApexCharts from 'vue3-apexcharts'
import { useTheme } from 'vuetify'
import { hexToRgb } from '@/plugins/vuetify/@layouts/utils'
import type { PropType } from 'vue';
import { useFormatter } from '@/stores';
const props = defineProps({
commission: { type: Object as PropType<{
commissionRates: {
rate: string,
maxRate: string,
maxChangeRate: string,
},
updateTime: string,
}>},
})
console.log('commission:', props)
const zeros = Math.pow(10, 16)
let rate = Number(props.commission?.commissionRates.rate || 0)
let change = Number(props.commission?.commissionRates.maxChangeRate || 10)
let max = Number(props.commission?.commissionRates.maxRate || 100)
if(rate > 100) {
rate = rate / zeros
}
if(change > 100) {
change = change / zeros
}
if(max > 100) {
max = max / zeros
}
// const rate = 15 // props.commision?.commissionRates.rate
// const change = 15
// const max = 20
const left = rate
const right = max - rate
const s1 = left > change ? left - change : 0
const s2 = left > change ? change: left
const s3 = 2
const s4 = right > change? change: right
const s5 = right > change? right - change: 0
const series = [s1, s2, s3, s4, s5]
const vuetifyTheme = useTheme()
const format = useFormatter()
const chartConfig = computed(() => {
const themeColors = vuetifyTheme.current.value.colors
const variableTheme = vuetifyTheme.current.value.variables
const secondaryText = `rgba(${hexToRgb(String(themeColors['on-background']))},${variableTheme['medium-emphasis-opacity']})`
const primaryText = `rgba(${hexToRgb(String(themeColors['on-background']))},${variableTheme['high-emphasis-opacity']})`
return {
chart: {
sparkline: { enabled: false },
},
colors: [
`rgba(${hexToRgb(String(themeColors.secondary))},0.2)`,
`rgba(${hexToRgb(String(themeColors.success))},0.2)`,
`rgba(${hexToRgb(String(themeColors.success))},1)`,
`rgba(${hexToRgb(String(themeColors.success))},0.2)`,
`rgba(${hexToRgb(String(themeColors.secondary))},0.2)`,
],
legend: { show: false },
tooltip: { enabled: false },
dataLabels: { enabled: false },
stroke: { width: 3, lineCap: 'round', colors: ['rgba(var(--v-theme-surface), 1)'] },
labels: ['Available', 'Daily Change', 'Commission Rate', 'Daily Change', 'Available'],
states: {
hover: {
filter: { type: 'none' },
},
active: {
filter: { type: 'none' },
},
},
plotOptions: {
pie: {
endAngle: 130,
startAngle: -130,
customScale: 0.9,
donut: {
size: '83%',
labels: {
show: true,
name: {
offsetY: 25,
fontSize: '1rem',
color: secondaryText,
},
value: {
offsetY: -15,
fontWeight: 500,
fontSize: '2.125rem',
formatter: (value: unknown) => `${rate}%`,
color: primaryText,
},
total: {
show: true,
label: 'Commission Rate',
fontSize: '1rem',
color: secondaryText,
formatter: ( ) => `${rate}%`,
},
},
},
},
},
responsive: [
{
breakpoint: 1709,
options: {
chart: { height: 237 },
},
},
],
}
})
</script>
<template>
<VCard title="Commission Rate" :subtitle="`Updated at ${format.toDay(props.commision?.updateTime, 'short')}`">
<VCardText>
<VueApexCharts
type="donut"
height="257"
:options="chartConfig"
:series="series"
/>
<div class="d-flex align-center justify-center flex-wrap mx-2 gap-x-6">
<div class="d-flex align-center gap-2">
<VBadge dot color="success"/>
<span class="mt-1 text-caption">Rate:{{ rate }}%</span>
</div>
<div class="d-flex align-center gap-2">
<VBadge dot color="success" style="opacity:0.2"/>
<span class="mt-1 text-caption">24h: ±{{ change }}%</span>
</div>
<div class="d-flex align-center gap-2">
<VBadge dot color="secondary"/>
<span class="mt-1 text-caption">Max:{{ max }}%</span>
</div>
</div>
</VCardText>
</VCard>
</template>
@@ -0,0 +1,30 @@
<script lang="ts" setup>
import VueApexCharts from 'vue3-apexcharts'
import { useTheme } from 'vuetify'
import { getAreaChartSplineConfig, getMarketPriceChartConfig } from './apexCharConfig'
import { useIndexModule } from '@/modules/[chain]/indexStore';
import { computed, ref } from '@vue/reactivity';
const store = useIndexModule()
const vuetifyTheme = useTheme()
const chartConfig = computed(() => {
const labels = store.marketData.prices.map(x => x[0])
return getMarketPriceChartConfig(vuetifyTheme.current.value, labels)
})
const kind = ref('price')
const series = computed(() => {
return [{
name: 'Price',
data: kind.value ==='price'?store.marketData.prices.map(x => x[1]) : store.marketData.total_volumes.map(x => x[1])}]
})
</script>
<template>
<VTabs v-model="kind" align-tabs="end"><VTab value="price">Price</VTab><VTab value="volume">Volume</VTab></VTabs>
<VueApexCharts
type="area"
height="261"
:options="chartConfig"
:series="series"
/>
</template>
+779
View File
@@ -0,0 +1,779 @@
import type { ThemeInstance } from 'vuetify'
import { hexToRgb } from '@/plugins/vuetify/@layouts/utils'
import numeral from 'numeral'
// 👉 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 }
}
/// Price Chart config
export const getMarketPriceChartConfig = (themeColors: ThemeInstance['themes']['value']['colors'], categories: string[]) => {
const { themeSecondaryTextColor, themeBorderColor, themeDisabledTextColor } = colorVariables(themeColors)
return {
chart: {
redrawOnParentResize: true,
width: '100%',
parentHeightOffset: 0,
toolbar: { show: false },
},
tooltip: {
theme: 'dark',
shared: false
},
dataLabels: { enabled: false },
stroke: {
// show: false,
curve: 'smooth',
width: 1.5,
},
legend: {
position: 'top',
horizontalAlign: 'left',
labels: { colors: themeSecondaryTextColor },
markers: {
offsetY: 1,
offsetX: -3,
},
itemMargin: {
vertical: 3,
horizontal: 10,
},
},
colors: [themeColors.colors.primary],
fill: {
opacity: 0.5,
type: 'gradient',
},
grid: {
show: true,
borderColor: themeBorderColor,
xaxis: {
lines: { show: true },
},
},
yaxis: {
labels: {
style: { colors: themeDisabledTextColor },
formatter: function (value: string) {
const pattern = (Number(value) > 0.01 ? '0.0[0]a': '0.00[000]')
return numeral(value).format(pattern);
}
},
},
xaxis: {
type: 'datetime',
axisBorder: { show: false },
axisTicks: { color: themeBorderColor },
crosshairs: {
stroke: { color: themeBorderColor },
},
labels: {
style: { colors: themeDisabledTextColor },
},
categories,
},
}
}
/// default config
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 },
labels: { colors: themeSecondaryTextColor },
itemMargin: {
vertical: 3,
horizontal: 10,
},
},
colors: [scatterColors.series1, scatterColors.series2, scatterColors.series3],
grid: {
borderColor: themeBorderColor,
xaxis: {
lines: { show: true },
},
},
yaxis: {
labels: {
style: { colors: themeDisabledTextColor },
},
},
xaxis: {
tickAmount: 10,
axisBorder: { show: false },
axisTicks: { color: themeBorderColor },
crosshairs: {
stroke: { color: themeBorderColor },
},
labels: {
style: { colors: themeDisabledTextColor },
formatter: (val: string) => 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: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
custom(data: any) {
return `<div class='bar-chart pa-2'>
<span>${data.series[data.seriesIndex][data.dataPointIndex]}%</span>
</div>`
},
},
yaxis: {
labels: {
style: { colors: themeDisabledTextColor },
},
},
xaxis: {
axisBorder: { show: false },
axisTicks: { color: themeBorderColor },
crosshairs: {
stroke: { color: themeBorderColor },
},
labels: {
style: { colors: themeDisabledTextColor },
},
categories: [
'7/12',
'8/12',
'9/12',
'10/12',
'11/12',
'12/12',
'13/12',
'14/12',
'15/12',
'16/12',
'17/12',
'18/12',
'19/12',
'20/12',
'21/12',
],
},
}
}
export const getBarChartConfig = (themeColors: 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 },
},
},
xaxis: {
axisBorder: { show: false },
axisTicks: { color: themeBorderColor },
categories: ['MON, 11', 'THU, 14', 'FRI, 15', 'MON, 18', 'WED, 20', 'FRI, 21', 'MON, 23'],
labels: {
style: { colors: themeDisabledTextColor },
},
},
}
}
export const getCandlestickChartConfig = (themeColors: 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 },
},
},
xaxis: {
type: 'datetime',
axisBorder: { show: false },
axisTicks: { color: themeBorderColor },
crosshairs: {
stroke: { color: themeBorderColor },
},
labels: {
style: { colors: themeDisabledTextColor },
},
},
}
}
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,
position: 'bottom',
labels: {
colors: themeSecondaryTextColor,
},
markers: {
offsetX: -3,
},
itemMargin: {
vertical: 3,
horizontal: 10,
},
},
colors: [radialBarColors.series1, radialBarColors.series2, radialBarColors.series4],
plotOptions: {
radialBar: {
hollow: { size: '30%' },
track: {
margin: 15,
background: themeColors.colors['grey-100'],
},
dataLabels: {
name: {
fontSize: '2rem',
},
value: {
fontSize: '1rem',
color: themeSecondaryTextColor,
},
total: {
show: true,
fontWeight: 400,
label: 'Comments',
fontSize: '1.125rem',
color: themePrimaryTextColor,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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: -35,
bottom: -30,
},
},
}
}
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) => `${parseInt(val, 10)}%`,
},
legend: {
position: 'bottom',
markers: { offsetX: -3 },
labels: { colors: themeSecondaryTextColor },
itemMargin: {
vertical: 3,
horizontal: 10,
},
},
plotOptions: {
pie: {
donut: {
labels: {
show: true,
name: {
fontSize: '1.5rem',
},
value: {
fontSize: '1.5rem',
color: themeSecondaryTextColor,
formatter: (val: string) => `${parseInt(val, 10)}`,
},
total: {
show: true,
fontSize: '1.5rem',
label: 'Operational',
formatter: () => '31%',
color: themePrimaryTextColor,
},
},
},
},
},
responsive: [
{
breakpoint: 992,
options: {
chart: {
height: 380,
},
legend: {
position: 'bottom',
},
},
},
{
breakpoint: 576,
options: {
chart: {
height: 320,
},
plotOptions: {
pie: {
donut: {
labels: {
show: true,
name: {
fontSize: '1rem',
},
value: {
fontSize: '1rem',
},
total: {
fontSize: '1rem',
},
},
},
},
},
},
},
],
}
}
export const getAreaChartSplineConfig = (themeColors: 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',
labels: { colors: themeSecondaryTextColor },
markers: {
offsetY: 1,
offsetX: -3,
},
itemMargin: {
vertical: 3,
horizontal: 10,
},
},
colors: [areaColors.series3, areaColors.series2, areaColors.series1],
fill: {
opacity: 1,
type: 'solid',
},
grid: {
show: true,
borderColor: themeBorderColor,
xaxis: {
lines: { show: true },
},
},
yaxis: {
labels: {
style: { colors: themeDisabledTextColor },
},
},
xaxis: {
axisBorder: { show: false },
axisTicks: { color: themeBorderColor },
crosshairs: {
stroke: { color: themeBorderColor },
},
labels: {
style: { colors: themeDisabledTextColor },
},
categories: [
'7/12',
'8/12',
'9/12',
'10/12',
'11/12',
'12/12',
'13/12',
'14/12',
'15/12',
'16/12',
'17/12',
'18/12',
'19/12',
],
},
}
}
export const getColumnChartConfig = (themeColors: 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',
labels: { colors: themeSecondaryTextColor },
markers: {
offsetY: 1,
offsetX: -3,
},
itemMargin: {
vertical: 3,
horizontal: 10,
},
},
stroke: {
show: true,
colors: ['transparent'],
},
plotOptions: {
bar: {
columnWidth: '15%',
colors: {
backgroundBarRadius: 10,
backgroundBarColors: [columnColors.bg, columnColors.bg, columnColors.bg, columnColors.bg, columnColors.bg],
},
},
},
grid: {
borderColor: themeBorderColor,
xaxis: {
lines: { show: true },
},
},
yaxis: {
labels: {
style: { colors: themeDisabledTextColor },
},
},
xaxis: {
axisBorder: { show: false },
axisTicks: { color: themeBorderColor },
categories: ['7/12', '8/12', '9/12', '10/12', '11/12', '12/12', '13/12', '14/12', '15/12'],
crosshairs: {
stroke: { color: themeBorderColor },
},
labels: {
style: { colors: themeDisabledTextColor },
},
},
responsive: [
{
breakpoint: 600,
options: {
plotOptions: {
bar: {
columnWidth: '35%',
},
},
},
},
],
}
}
export const getHeatMapChartConfig = (themeColors: 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',
labels: {
colors: themeSecondaryTextColor,
},
markers: {
offsetY: 0,
offsetX: -3,
},
itemMargin: {
vertical: 3,
horizontal: 10,
},
},
plotOptions: {
heatmap: {
enableShades: false,
colorScale: {
ranges: [
{ to: 10, from: 0, name: '0-10', color: '#b9b3f8' },
{ to: 20, from: 11, name: '10-20', color: '#aba4f6' },
{ to: 30, from: 21, name: '20-30', color: '#9d95f5' },
{ to: 40, from: 31, name: '30-40', color: '#8f85f3' },
{ to: 50, from: 41, name: '40-50', color: '#8176f2' },
{ to: 60, from: 51, name: '50-60', color: '#7367f0' },
],
},
},
},
grid: {
padding: { top: -20 },
},
yaxis: {
labels: {
style: {
colors: themeDisabledTextColor,
},
},
},
xaxis: {
labels: { show: false },
axisTicks: { show: false },
axisBorder: { show: false },
},
}
}
export const getRadarChartConfig = (themeColors: 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: {
labels: {
colors: themeSecondaryTextColor,
},
markers: {
offsetX: -3,
},
itemMargin: {
vertical: 3,
horizontal: 10,
},
},
plotOptions: {
radar: {
polygons: {
strokeColors: themeBorderColor,
connectorColors: themeBorderColor,
},
},
},
grid: {
show: false,
padding: {
top: -20,
bottom: -20,
},
},
yaxis: { show: false },
xaxis: {
categories: ['Battery', 'Brand', 'Camera', 'Memory', 'Storage', 'Display', 'OS', 'Price'],
labels: {
style: {
colors: [
themeDisabledTextColor,
themeDisabledTextColor,
themeDisabledTextColor,
themeDisabledTextColor,
themeDisabledTextColor,
themeDisabledTextColor,
themeDisabledTextColor,
themeDisabledTextColor,
],
},
},
},
}
}
@@ -0,0 +1,15 @@
<script lang="ts" setup>
import { fromBase64, toBase64 } from '@cosmjs/encoding';
const props = defineProps({
value: { type: Array<Uint8Array>},
})
</script>
<template>
<div>
<div v-for="v in props.value">
{{ toBase64(v) }}
</div>
</div>
</template>
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts" setup>
import { toBase64 } from '@cosmjs/encoding';
import type { PropType } from 'vue';
import { computed } from '@vue/reactivity';
import DynamicComponentVue from './DynamicComponent.vue';
import {select} from './index'
import ArrayBytesElement from './ArrayBytesElement.vue';
import ArrayObjectElement from './ArrayObjectElement.vue';
import TextElement from './TextElement.vue';
const props = defineProps({
value: { type: Array<Object>},
})
function selectByElement() {
if(props.value && props.value.length > 0) {
const [first] = props.value
switch(true) {
case first instanceof Uint8Array:
return ArrayBytesElement
default:
return ArrayObjectElement
}
}
return TextElement
}
</script>
<template>
<Component :is="selectByElement()" :value="props.value"></Component>
</template>
@@ -0,0 +1,30 @@
<script lang="ts" setup>
import { computed } from '@vue/reactivity';
import DynamicComponent from './DynamicComponent.vue';
const props = defineProps({
value: { type: Array<Object>},
})
const header = computed(() => {
if(props.value && props.value.length > 0) {
return Object.keys(props.value[0])
}
return []
})
</script>
<template>
<VTable v-if="header.length > 0" density="compact" height="300px" fixed-header>
<thead>
<tr>
<th v-for="k in header" class="text-left text-capitalize">{{ k }}</th>
</tr>
</thead>
<tbody>
<tr v-for="v in props.value">
<td v-for="k in header"> <DynamicComponent :value="v[k]" /></td>
</tr>
</tbody>
</VTable>
</template>
@@ -0,0 +1,11 @@
<script lang="ts" setup>
import TextElement from './TextElement.vue'
import ObjectElement from './ObjectElement.vue'
import { select } from './index'
const props = defineProps(["value"]);
</script>
<template>
<Component :is="select(value)" :value="value"></Component>
</template>
+6
View File
@@ -0,0 +1,6 @@
<script lang="ts" setup>
const props = defineProps(["value"]);
</script>
<template>
<span>{{ Number(props.value) }}</span>
</template>
+19
View File
@@ -0,0 +1,19 @@
<script lang="ts" setup>
import DynamicComponent from './DynamicComponent.vue';
import {select} from './index'
const props = defineProps(["value"]);
</script>
<template>
<VTable>
<tbody>
<tr v-for="(v, k) of value">
<td class="text-capitalize">{{ k }}</td>
<td><div class="overflow-hidden w-auto" style="max-width: 1000px;">
<Component v-if="v" :is="select(v, k)" :value="v"></Component></div>
</td>
</tr>
</tbody>
</VTable>
</template>
+6
View File
@@ -0,0 +1,6 @@
<script lang="ts" setup>
const props = defineProps(["value"]);
</script>
<template>
<span>{{ props.value }}</span>
</template>
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts" setup>
import { fromBase64, toBase64 } from '@cosmjs/encoding';
import { decodeTxRaw } from '@cosmjs/proto-signing'
import { computed } from '@vue/reactivity';
import { hashTx } from '@/libs'
import { useBlockchain, useFormatter } from '@/stores';
const props = defineProps({
value: { type: Array<Uint8Array>},
});
const txs = computed(() => {
return props.value?.map(x => ({ hash: hashTx(x) , tx: decodeTxRaw(x) })) || []
})
const format = useFormatter()
const chain = useBlockchain()
</script>
<template>
<VTable density="compact" v-if="txs.length > 0">
<thead>
<tr>
<th>Hash</th><th>Msgs</th><th>Memo</th>
</tr>
</thead>
<tbody>
<tr v-for="item in txs">
<td><RouterLink :to="`/${chain.chainName}/tx/${item.hash}`">{{ item.hash }}</RouterLink></td>
<td>{{ format.messages(item.tx.body.messages) }}</td>
<td>{{ item.tx.body.memo }}</td>
</tr>
</tbody>
</VTable>
<div v-else>[]</div>
</template>
+16
View File
@@ -0,0 +1,16 @@
<script lang="ts" setup>
import { toBase64, toHex } from '@cosmjs/encoding';
import { computed } from '@vue/reactivity';
const props = defineProps(["value"]);
const format = ref('base64')
const text = computed(()=> {
return format.value === 'hex'? toHex(props.value) : toBase64(props.value)
})
function change() {
format.value = format.value === 'hex'? 'base64': 'hex'
}
</script>
<template>
<span>{{ text }} <VIcon size="12" icon="mdi-cached" @click="change()"/></span>
</template>
+37
View File
@@ -0,0 +1,37 @@
import ObjectElement from './ObjectElement.vue'
import TextElement from './TextElement.vue'
import ArrayElement from './ArrayElement.vue'
import UInt8Array from './UInt8Array.vue'
import NumberElement from './NumberElement.vue'
import TxsElement from './TxsElement.vue'
import Long from 'long'
export function select(v: any, k?: any) {
if(k === 'txs' && v) {
console.log("=======txs=======", k, v)
return TxsElement
} else {
const type = typeof v
switch(type) {
case 'object':
return selectObject(v)
case 'number':
return NumberElement
default:
return TextElement
}
}
}
function selectObject(v: Object) {
switch(true) {
case v instanceof Long:
return NumberElement
case v instanceof Uint8Array:
return UInt8Array
case Array.isArray(v):
return ArrayElement
default:
return ObjectElement
}
}
+7
View File
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>
+7
View File
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>
+7
View File
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>
+19
View File
@@ -0,0 +1,19 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import { useSkins } from '@core/composable/useSkins'
export default defineComponent({
setup() {
const routerView = resolveComponent('router-view')
const { injectSkinClasses } = useSkins()
// ️ This will inject classes in body tag for accurate styling
injectSkinClasses()
return () => h('div', { class: 'layout-wrapper layout-blank' }, h(routerView))
},
})
</script>
<style>
.layout-wrapper.layout-blank {
flex-direction: column;
}
</style>
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { useBlockchain } from '@/stores/useBlockchain';
import { computed } from '@vue/reactivity';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
const chain = useBlockchain()
const route = useRoute()
const i18n = useI18n()
/// To display human readable module name, we have to set the prefix("module.") + route name to the key in i18n.
/// such as `module.chain` = 'Dashboard'
const moduleName = computed(() => i18n.t(`module.${route.name?.toString()||''}`))
const items = computed(() => [{title: String(chain.name).toUpperCase(), href: `/${chain.name}`}, moduleName.value])
</script>
<template>
<div class="d-flex flex-rows align-center">
<span class="text-h5 mr-3">{{ moduleName }}</span>
<v-icon icon="mdi-dots-vertical" />
<VBreadcrumbs :items="items">
<template v-slot:divider>
<v-icon icon="mdi-chevron-right"></v-icon>
</template>
</VBreadcrumbs>
</div>
</template>
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { useBlockchain, useBaseStore } from '@/stores';
const chainStore = useBlockchain()
const baseStore = useBaseStore()
chainStore.initial()
</script>
<template>
<VListItem class="m-0 p-0">
<template #prepend>
<VBadge
dot
location="bottom right"
offset-x="3"
offset-y="3"
bordered
color="success"
class="mr-2"
>
<VAvatar
class="cursor-pointer"
color="primary"
variant="tonal"
>
<VImg :src="chainStore.logo" />
<!-- SECTION Menu -->
<VMenu
activator="parent"
location="bottom start"
offset="14px"
>
<VList>
<!-- 👉 Rest -->
<VListSubheader v-if="chainStore.current?.endpoints?.rpc" title="Rest Endpoint" />
<VListItem v-for="i in chainStore.current?.endpoints?.rpc" link @click="chainStore.setRestEndpoint(i)">
<VListItemTitle>{{ i.provider }} <VIcon v-if="i.address === chainStore.endpoint?.address" icon="mdi-check" color="success" /></VListItemTitle>
<VListItemSubtitle>{{ i.address }}</VListItemSubtitle>
</VListItem>
<VListSubheader v-if="chainStore.current?.endpoints?.grpc" title="gRPC Endpoint" />
<VListItem v-for="i in chainStore.current?.endpoints?.grpc" link>
<VListItemTitle>{{ i.provider }}</VListItemTitle>
<VListItemSubtitle>{{ i.address }}</VListItemSubtitle>
</VListItem>
</VList>
</VMenu>
<!-- !SECTION -->
</VAvatar>
</VBadge>
</template>
<VListItemTitle>{{ baseStore.latest.block?.header?.chain_id || chainStore.chainName || '' }}</VListItemTitle>
<VListItemSubtitle> {{ chainStore.connErr|| chainStore.endpoint.address }}</VListItemSubtitle>
</VListItem>
</template>
+110
View File
@@ -0,0 +1,110 @@
<script lang="ts" setup>
import { useThemeConfig } from '@/plugins/vuetify/@core/composable/useThemeConfig'
// Components
import Footer from '@/layouts/components/Footer.vue'
import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue'
import UserProfile from '@/layouts/components/ChainProfile.vue'
import { useDashboard } from '@/stores/useDashboard'
// @layouts plugin
import { VerticalNavLayout } from '@layouts'
import NavBarI18n from './NavBarI18n.vue'
import NavSearchBar from './NavSearchBar.vue'
import NavBarNotifications from './NavBarNotifications.vue'
import TheCustomizer from '@/plugins/vuetify/@core/components/TheCustomizer.vue'
import Breadcrumbs from './Breadcrumbs.vue'
import { useBlockchain } from '@/stores'
const { appRouteTransition, isLessThanOverlayNavBreakpoint, isVerticalNavCollapsed } = useThemeConfig()
const { width: windowWidth } = useWindowSize()
// ️ Provide animation name for vertical nav collapse icon.
const verticalNavHeaderActionAnimationName = ref<null | 'rotate-180' | 'rotate-back-180'>(null)
watch(isVerticalNavCollapsed, val => {
verticalNavHeaderActionAnimationName.value = val ? 'rotate-180' : 'rotate-back-180'
})
const dashboard = useDashboard()
dashboard.initial()
const blockchain = useBlockchain()
// blockchain.initial()
blockchain.$subscribe((m, s) => {
if(!Array.isArray(m.events) && m.events.key === 'chainName') {
blockchain.initial()
}
})
</script>
<template>
<VerticalNavLayout :nav-items="blockchain.computedChainMenu">
<!-- 👉 navbar -->
<template #navbar="{ toggleVerticalOverlayNavActive }">
<div class="d-flex h-100 align-center">
<IconBtn v-if="isLessThanOverlayNavBreakpoint(windowWidth)" class="ms-n3"
@click="toggleVerticalOverlayNavActive(true)">
<VIcon icon="mdi-menu" />
</IconBtn>
<UserProfile />
<VSpacer />
<!-- <NavSearchBar />-->
<NavBarNotifications />
<NavBarI18n />
<NavbarThemeSwitcher />
</div>
</template>
<!-- 👉 Pages -->
<RouterView v-slot="{ Component }">
<Transition :name="appRouteTransition" mode="out-in">
<Component :is="Component" />
</Transition>
</RouterView>
<!-- 👉 Footer -->
<template #footer>
<Footer />
</template>
<!-- 👉 Customizer -->
<!-- <TheCustomizer /> -->
</VerticalNavLayout>
</template>
<style lang="scss">
@keyframes rotate-180 {
from {
transform: rotate(0deg);
}
to {
transform: rotate(180deg);
}
}
@keyframes rotate-back-180 {
from {
transform: rotate(180deg);
}
to {
transform: rotate(0deg);
}
}
.layout-vertical-nav {
.nav-header {
.header-action {
animation-duration: 0s;
animation-duration: 0.35s;
animation-fill-mode: forwards;
animation-name: v-bind(verticalNavHeaderActionAnimationName);
transform: rotate(0deg);
}
}
}
</style>
+33
View File
@@ -0,0 +1,33 @@
<template>
<div class="h-100 d-flex align-center justify-space-between">
<!-- 👉 Footer: left content -->
<span class="d-flex align-center">
&copy;
{{ new Date().getFullYear() }}
Made With
<VIcon
icon="mdi-heart-outline"
color="error"
size="1.25rem"
class="mx-1"
/>
By <a
href="https://ping.pub"
target="_blank"
rel="noopener noreferrer"
class="text-primary ms-1"
>Ping.pub</a>
</span>
<!-- 👉 Footer: right content -->
<span class="d-md-flex gap-x-4 text-primary d-none">
<a
href="https://github.com/ping-pub/explorer/blob/master/LICENSE"
target="noopener noreferrer"
>License</a>
<a
href="https://github.com/ping-pub/explorer"
target="noopener noreferrer"
>Github</a>
</span>
</div>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script lang="ts" setup>
import NavBarI18n from '@core/components/I18n.vue'
import { useThemeConfig } from '@core/composable/useThemeConfig'
import type { I18nLanguage } from '@layouts/types'
const { isAppRtl } = useThemeConfig()
const i18nCompLanguages: I18nLanguage[] = [
{
label: 'English',
i18nLang: 'en',
},
{
label: '中文',
i18nLang: 'cn',
},
{
label: 'Arabic',
i18nLang: 'ar',
},
]
const handleLangChange = (lang: string) => {
isAppRtl.value = lang === 'ar'
localStorage.setItem('lang', lang)
}
</script>
<template>
<NavBarI18n
:languages="i18nCompLanguages"
@change="handleLangChange"
/>
</template>
@@ -0,0 +1,88 @@
<script lang="ts" setup>
import Notifications from '@core/components/Notifications.vue'
import type { Notification } from '@layouts/types'
// Images
import avatar3 from '@images/avatars/avatar-3.png'
import avatar4 from '@images/avatars/avatar-4.png'
import avatar5 from '@images/avatars/avatar-5.png'
import paypal from '@images/svg/paypal.svg'
const notifications = ref<Notification[]>([
{
id: 1,
img: avatar4,
title: 'Congratulation Flora! 🎉',
subtitle: 'Won the monthly best seller badge',
time: 'Today',
isRead: false,
},
{
id: 2,
text: 'Tom Holland',
title: 'New user registered.',
subtitle: '5 hours ago',
time: 'Yesterday',
isRead: true,
},
{
id: 3,
img: avatar5,
title: 'New message received 👋🏻',
subtitle: 'You have 10 unread messages',
time: '11 Aug',
isRead: false,
},
{
id: 4,
img: paypal,
title: 'Paypal',
subtitle: 'Received Payment',
time: '25 May',
isRead: true,
color: 'error',
},
{
id: 5,
img: avatar3,
title: 'Received Order 📦',
subtitle: 'New order received from john',
time: '19 Mar',
isRead: false,
},
])
const removeNotification = (notificationId: number) => {
notifications.value.forEach((item, index) => {
if (notificationId === item.id)
notifications.value.splice(index, 1)
})
}
const markRead = (notificationId: number[]) => {
notifications.value.forEach(item => {
notificationId.forEach(id => {
if (id === item.id)
item.isRead = true
})
})
}
const markUnRead = (notificationId: number[]) => {
notifications.value.forEach(item => {
notificationId.forEach(id => {
if (id === item.id)
item.isRead = false
})
})
}
</script>
<template>
<Notifications
:notifications="notifications"
@remove="removeNotification"
@read="markRead"
@unread="markUnRead"
/>
</template>
+170
View File
@@ -0,0 +1,170 @@
<script setup lang="ts">
import type { SearchHeader, SearchItem } from '@/@fake-db/types'
import axios from 'axios'
import { useThemeConfig } from '@core/composable/useThemeConfig'
interface Suggestion {
icon: string
title: string
url: object
}
const { appContentLayoutNav } = useThemeConfig()
interface SuggestionGroup {
title: string
content: Suggestion[]
}
defineOptions({
inheritAttrs: false,
})
// 👉 Is App Search Bar Visible
const isAppSearchBarVisible = ref(false)
// 👉 Default suggestions
const suggestionGroups: SuggestionGroup[] = [
{
title: 'Popular Searches',
content: [
{ icon: 'mdi-chart-donut', title: 'Analytics', url: { name: 'dashboards-analytics' } },
{ icon: 'mdi-chart-bubble', title: 'CRM', url: { name: 'dashboards-crm' } },
{ icon: 'mdi-file-outline', title: 'Invoice List', url: { name: 'apps-invoice-list' } },
{ icon: 'mdi-account-group-outline', title: 'User List', url: { name: 'apps-user-list' } },
],
},
{
title: 'Apps & Pages',
content: [
{ icon: 'mdi-calendar', title: 'Calendar', url: { name: 'apps-calendar' } },
{ icon: 'mdi-file-plus-outline', title: 'Invoice Add', url: { name: 'apps-invoice-add' } },
{ icon: 'mdi-currency-usd', title: 'Pricing', url: { name: 'pages-pricing' } },
{ icon: 'mdi-account-cog-outline', title: 'Account Settings', url: { name: 'pages-account-settings-tab', params: { tab: 'account' } } },
],
},
{
title: 'User Interface',
content: [
{ icon: 'mdi-alpha-a-box-outline', title: 'Typography', url: { name: 'pages-typography' } },
{ icon: 'mdi-tab', title: 'Tabs', url: { name: 'components-tabs' } },
{ icon: 'mdi-gesture-tap-button', title: 'Buttons', url: { name: 'components-button' } },
{ icon: 'mdi-keyboard-settings-outline', title: 'Statistics', url: { name: 'pages-cards-card-statistics' } },
],
},
{
title: 'Popular Searches',
content: [
{ icon: 'mdi-format-list-checkbox', title: 'Select', url: { name: 'forms-select' } },
{ icon: 'mdi-lastpass', title: 'Combobox', url: { name: 'forms-combobox' } },
{ icon: 'mdi-calendar-range-outline', title: 'Date & Time Picker', url: { name: 'forms-date-time-picker' } },
{ icon: 'mdi-hexagram-outline', title: 'Rating', url: { name: 'forms-rating' } },
],
},
]
// 👉 No Data suggestion
const noDataSuggestions: Suggestion[] = [
{
title: 'Analytics Dashboard',
icon: 'mdi-cart-outline',
url: { name: 'dashboards-analytics' },
},
{
title: 'Account Settings',
icon: 'mdi-account-outline',
url: { name: 'pages-account-settings-tab', params: { tab: 'account' } },
},
{
title: 'Pricing Page',
icon: 'mdi-cash',
url: { name: 'pages-pricing' },
},
]
const searchQuery = ref('')
const searchResult = ref<(SearchItem | SearchHeader)[]>([])
const router = useRouter()
// 👉 fetch search result API
watchEffect(() => {
axios.get('/app-bar/search', {
params: {
q: searchQuery.value,
},
}).then(response => {
searchResult.value = response.data
})
})
// 👉 redirect the selected page
const redirectToSuggestedOrSearchedPage = (selected: Suggestion) => {
router.push(selected.url)
isAppSearchBarVisible.value = false
searchQuery.value = ''
}
const LazyAppBarSearch = defineAsyncComponent(() => import('@core/components/AppBarSearch.vue'))
</script>
<template>
<div
class="d-flex align-center cursor-pointer"
v-bind="$attrs"
@click="isAppSearchBarVisible = !isAppSearchBarVisible"
>
<!-- 👉 Search Trigger button -->
<IconBtn>
<VIcon icon="mdi-magnify" />
</IconBtn>
<span
v-if="appContentLayoutNav === 'vertical'"
class="d-none d-md-flex align-center text-disabled"
>
<span class="me-3">Search</span>
<span class="meta-key">&#8984;K</span>
</span>
</div>
<!-- 👉 App Bar Search -->
<LazyAppBarSearch
v-model:isDialogVisible="isAppSearchBarVisible"
v-model:search-query="searchQuery"
:search-results="searchResult"
:suggestions="suggestionGroups"
:no-data-suggestion="noDataSuggestions"
@item-selected="redirectToSuggestedOrSearchedPage"
>
<!--
<template #suggestions>
use this slot if you want to override default suggestions
</template>
-->
<!--
<template #noData>
use this slot to change the view of no data section
</template>
-->
<!--
<template #searchResult="{ item }">
use this slot to change the search item
</template>
-->
</LazyAppBarSearch>
</template>
<style lang="scss" scoped>
@use "@styles/variables/_vuetify.scss";
.meta-key {
border: thin solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 6px;
block-size: 1.5625rem;
line-height: 1.3125rem;
padding-block: 0.125rem;
padding-inline: 0.25rem;
}
</style>
@@ -0,0 +1,44 @@
<script setup lang="ts">
const shortcuts = [
{
icon: 'mdi-calendar',
title: 'Calendar',
subtitle: 'Appointments',
to: { name: 'apps-calendar' },
},
{
icon: 'mdi-file-document-outline',
title: 'Invoice App',
subtitle: 'Manage Accounts',
to: { name: 'apps-invoice-list' },
},
{
icon: 'mdi-account-outline',
title: 'Users',
subtitle: 'Manage Users',
to: { name: 'apps-user-list' },
},
{
icon: 'mdi-view-dashboard-outline',
title: 'Dashboard',
subtitle: 'Dashboard Analytics',
to: { name: 'dashboards-analytics' },
},
{
icon: 'mdi-cog-outline',
title: 'Settings',
subtitle: 'Account Settings',
to: { name: 'pages-account-settings-tab', params: { tab: 'account' } },
},
{
icon: 'mdi-help-circle-outline',
title: 'Help Center',
subtitle: 'FAQs & Articles',
to: { name: 'pages-help-center' },
},
]
</script>
<template>
<Shortcuts :shortcuts="shortcuts" />
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { ThemeSwitcherTheme } from '@layouts/types'
const themes: ThemeSwitcherTheme[] = [
{
name: 'system',
icon: 'mdi-laptop',
},
{
name: 'light',
icon: 'mdi-weather-sunny',
},
{
name: 'dark',
icon: 'mdi-weather-night',
},
]
</script>
<template>
<ThemeSwitcher :themes="themes" />
</template>
+19
View File
@@ -0,0 +1,19 @@
<script lang="ts" setup>
import { useSkins } from '@core/composable/useSkins'
import { useThemeConfig } from '@core/composable/useThemeConfig'
const DefaultLayout = defineAsyncComponent(() => import('./components/DefaultLayout.vue'))
const { layoutAttrs, injectSkinClasses } = useSkins()
injectSkinClasses()
</script>
<template>
<DefaultLayout v-bind="layoutAttrs" />
</template>
<style lang="scss">
// As we are using `layouts` plugin we need its styles to be imported
@use "@layouts/styles/default-layout";
</style>
+73
View File
@@ -0,0 +1,73 @@
import {fromBase64, fromBech32, fromHex, toBase64, toBech32, toHex} from '@cosmjs/encoding'
import { Ripemd160, sha256 } from '@cosmjs/crypto'
import { cosmos } from '@ping-pub/codegen'
import type { PubKey } from '@ping-pub/codegen/src/cosmos/crypto/ed25519/keys'
export function decodeAddress(address: string) {
return fromBech32(address)
}
export function valoperToPrefix(valoper?: string) {
if(!valoper) return ''
const prefixIndex = valoper.indexOf('valoper')
if (prefixIndex === -1) return null
return valoper.slice(0, prefixIndex)
}
export function operatorAddressToAccount(operAddress?: string) {
if(!operAddress) return ''
const { prefix, data } = fromBech32(operAddress)
if (prefix === 'iva') { // handle special cases
return toBech32('iaa', data)
}
if (prefix === 'crocncl') { // handle special cases
return toBech32('cro', data)
}
return toBech32(prefix.replace('valoper', ''), data)
}
export function decodeKey(consensusPubkey: {typeUrl: string, value: Uint8Array}) {
let raw = null
if (consensusPubkey.typeUrl === '/cosmos.crypto.ed25519.PubKey') {
const pubkey = cosmos.crypto.ed25519.PubKey.decode(consensusPubkey.value)
return pubkey
}
if (consensusPubkey.typeUrl === '/cosmos.crypto.secp256k1.PubKey') {
const pubkey = cosmos.crypto.secp256k1.PubKey.decode(consensusPubkey.value)
return pubkey
}
return raw
}
export function consensusPubkeyToHexAddress(consensusPubkey?: {typeUrl: string, value: Uint8Array}) {
if(!consensusPubkey) return ""
let raw = ""
if (consensusPubkey.typeUrl === '/cosmos.crypto.ed25519.PubKey') {
const pubkey = decodeKey(consensusPubkey)
if(pubkey) return toHex(sha256(pubkey.key)).slice(0, 40).toUpperCase()
}
if (consensusPubkey.typeUrl === '/cosmos.crypto.secp256k1.PubKey') {
const pubkey = decodeKey(consensusPubkey)
if(pubkey) return toHex(new Ripemd160().update(sha256(pubkey.key)).digest())
}
return raw
}
export function pubKeyToValcons(consensusPubkey: {typeUrl: string, value: Uint8Array}, prefix: string) {
const pubkey = decodeKey(consensusPubkey)
if(pubkey) {
const addressData = sha256(pubkey.key).slice(0, 20)
return toBech32(`${prefix}valcons`, addressData)
}
}
export function toETHAddress(cosmosAddress: string) {
return `0x${toHex(fromBech32(cosmosAddress).data)}`
}
export function addressEnCode(prefix: string, pubkey: Uint8Array) {
return toBech32(prefix, pubkey)
}
+16
View File
@@ -0,0 +1,16 @@
import { type RequestRegistry, type Registry, convert, withCustomAdapter } from "./registry";
const DEFAULT: RequestRegistry = {
users: { url: "https://jsonplaceholder.typicode.com/users/1", adapter: convert<User> },
posts: { url: "https://jsonplaceholder.typicode.com/posts/1", adapter: convert<Post> },
};
const VERSION_REGISTRY: Registry = {
"0.46.1": DEFAULT
}
const NAME_REGISTRY: Registry = {
"evmos": withCustomAdapter(DEFAULT, {users: {url: "another url", adapter: (): User => {}}})
}
+38
View File
@@ -0,0 +1,38 @@
import type { LCDClient } from "@osmonauts/lcd";
import type { QueryAnnualProvisionsRequest, QueryAnnualProvisionsResponseSDKType, QueryInflationRequest, QueryInflationResponseSDKType, QueryParamsRequest, QueryParamsResponseSDKType } from '@ping-pub/codegen/src/cosmos/mint/v1beta1/query'
export class OsmosisMintClient {
req: LCDClient;
constructor({
requestClient
}: {
requestClient: LCDClient;
}) {
this.req = requestClient;
this.params = this.params.bind(this);
this.inflation = this.inflation.bind(this);
this.annualProvisions = this.annualProvisions.bind(this);
}
/* Params returns the total set of minting parameters. */
async params(_params: QueryParamsRequest = {}): Promise<QueryParamsResponseSDKType> {
const endpoint = `cosmos/mint/v1beta1/params`;
return await this.req.get<QueryParamsResponseSDKType>(endpoint);
}
/* Inflation returns the current minting inflation value. */
async inflation(_params: QueryInflationRequest = {}): Promise<QueryInflationResponseSDKType> {
const endpoint = `cosmos/mint/v1beta1/inflation`;
return await this.req.get<QueryInflationResponseSDKType>(endpoint);
}
/* AnnualProvisions current minting annual provisions value. */
async annualProvisions(_params: QueryAnnualProvisionsRequest = {}): Promise<QueryAnnualProvisionsResponseSDKType> {
const endpoint = `cosmos/mint/v1beta1/annual_provisions`;
return await this.req.get<QueryAnnualProvisionsResponseSDKType>(endpoint);
}
}
+38
View File
@@ -0,0 +1,38 @@
import fetch from 'cross-fetch'
export interface ApiResponse<T> {
data: T;
}
// Usage:
// const usersResponse = await fetchData<User[]>("https://somewhere/")
export async function fetchData<T>(url: string): Promise<ApiResponse<T>> {
const response = await fetch(url);
const data = await response.json();
const result : ApiResponse<T> = {
data
}
return result
}
export async function get(url: string) {
return (await fetch(url)).json()
}
export async function post(url: string, data: any) {
const response = await fetch(url, {
method: 'POST', // *GET, POST, PUT, DELETE, etc.
// mode: 'cors', // no-cors, *cors, same-origin
// credentials: 'same-origin', // redirect: 'follow', // manual, *follow, error
// referrerPolicy: 'origin', // no-referrer, *no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url
headers: {
'Content-Type': 'text/plain',
Accept: '*/*',
'Accept-Encoding': 'gzip, deflate, br',
},
body: JSON.stringify(data), // body data type must match "Content-Type" header
})
// const response = axios.post((config ? config.api : this.config.api) + url, data)
return response.json() // parses JSON response into native JavaScript objects
}
+4
View File
@@ -0,0 +1,4 @@
export * from './address'
export * from './client'
export * from './http'
export * from './misc'
+19
View File
@@ -0,0 +1,19 @@
import { sha256 } from "@cosmjs/crypto";
import { toHex } from "@cosmjs/encoding";
import { PageRequest } from "@ping-pub/codegen/src/cosmos/base/query/v1beta1/pagination";
export function newPageRequest(param: {
key?: Uint8Array,
limit?: number,
offset?: number,
countTotal?: boolean,
reverse?: boolean
}) {
return PageRequest.fromPartial(
param
)
}
export function hashTx(raw: Uint8Array) {
return toHex(sha256(raw)).toUpperCase()
}
+46
View File
@@ -0,0 +1,46 @@
import semver from "semver";
interface Registry {
[key: string]: string;
}
const registry: Registry = {
v1: "https://example.com/api/v1/endpoint",
v2: "https://example.com/api/v2/endpoint",
v3: "https://example.com/api/v3/endpoint",
alpha: "https://example.com/api/alpha/endpoint",
beta: "https://example.com/api/beta/endpoint",
production: "https://example.com/api/production/endpoint",
};
export function findConfigByName(name: string): string {
const base = "https://example.com/api/";
const url = registry[name];
if (!url) {
throw new Error(`Unsupported version or name: ${name}`);
}
return url;
}
export function findConfigByVersion(version: string, registry: Registry): string {
let closestVersion: string | null = null;
for (const key in registry) {
if (semver.satisfies(key, version)) {
if (!closestVersion || semver.gt(key, closestVersion)) {
closestVersion = key;
}
}
}
if (!closestVersion) {
throw new Error(`Unsupported version: ${version}`);
}
console.log(`Closest version to ${version}: ${closestVersion}`);
return registry[closestVersion];
}
+33
View File
@@ -0,0 +1,33 @@
/* eslint-disable import/order */
import "@/plugins/vuetify/@iconify/icons-bundle";
import App from "@/App.vue";
import layoutsPlugin from "@/plugins/vuetify/layouts";
import vuetify from "@/plugins/vuetify";
import i18n from "@/plugins/i18n";
import { loadFonts } from "@/plugins/vuetify/webfontloader";
import "@/plugins/vuetify/@core/scss/template/index.scss";
import "@/plugins/vuetify/styles/styles.scss";
import { createApp } from "vue";
import { createPinia } from "pinia";
// import router from "@/plugins/vuetify/router";
import router from "./router";
import { useBaseStore } from "./stores/useBaseStore";
loadFonts();
// Create vue app
const app = createApp(App);
// Use plugins
app.use(i18n)
app.use(vuetify);
app.use(createPinia());
app.use(layoutsPlugin);
app.use(router);
// Mount vue app
app.mount("#app");
// fetch latest block every 6s
// const blockStore = useBaseStore()
// setInterval(() => {blockStore.fetchLatest()}, 6000)
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts" setup>
import TxsElement from '@/components/dynamic/TxsElement.vue';
import { useBlockModule } from './block'
import DynamicComponent from '@/components/dynamic/DynamicComponent.vue';
import { computed } from '@vue/reactivity';
import { onBeforeRouteUpdate } from 'vue-router';
const props = defineProps(["height", "chain"]);
const store = useBlockModule()
store.fetchBlock(props.height)
const tab = ref('summary')
const height = computed(() => {
return Number(store.current.block?.header?.height || props.height || 0)
})
onBeforeRouteUpdate(async (to, from, next) => {
if (from.path !== to.path) {
store.fetchBlock(Number(to.params.height))
next()
}
})
</script>
<template>
<div>
<VCard>
<VCardTitle class="d-flex justify-space-between">
<span class="mt-2">#{{ store.current.block?.header?.height }}</span>
<span v-if="props.height" class="mt-2">
<VBtn size="32" :to="`/${store.blockchain.chainName}/block/${height - 1}`" class="mr-2"><VIcon icon="mdi-arrow-left"/></VBtn>
<VBtn size="32" :to="`/${store.blockchain.chainName}/block/${height + 1}`"><VIcon icon="mdi-arrow-right"/></VBtn>
</span>
</VCardTitle>
<VCardItem class="pt-0">
<DynamicComponent :value="store.current.blockId"/>
</VCardItem>
</VCard>
<VCard title="Block Header" class="my-5">
<VCardItem class="pt-0">
<DynamicComponent :value="store.current.block?.header"/>
</VCardItem>
</VCard>
<VCard title="Transactions">
<VCardItem class="pt-0">
<TxsElement :value="store.current.block?.data?.txs"/>
</VCardItem>
</VCard>
<VCard title="Last Commit" class="mt-5">
<VCardItem class="pt-0">
<DynamicComponent :value="store.current.block?.lastCommit"/>
</VCardItem>
</VCard>
</div>
</template>
+62
View File
@@ -0,0 +1,62 @@
import { defineStore } from "pinia";
import { decodeTxRaw, type DecodedTxRaw } from '@cosmjs/proto-signing'
import { createBaseClientForChain } from "@/libs/client";
import { useBlockchain } from "@/stores";
import type { GetLatestBlockResponse, GetLatestBlockResponseSDKType } from "@ping-pub/codegen/src/cosmos/base/tendermint/v1beta1/query";
import { hashTx } from "@/libs";
export const useBlockModule = defineStore('blockModule', {
state: () => {
return {
latest: {} as GetLatestBlockResponse,
current: {} as GetLatestBlockResponse,
recents: [] as GetLatestBlockResponse[]
}
},
getters: {
blockchain() {
return useBlockchain()
},
blocktime() {
if(this.recents.length<2) return 6000
return 6000 // todo later
},
txsInRecents() {
const txs = [] as {hash:string, tx: DecodedTxRaw}[]
this.recents.forEach((x:GetLatestBlockResponse) => x.block?.data?.txs.forEach((tx:Uint8Array) => txs.push({
hash: hashTx(tx),
tx :decodeTxRaw(tx)
})))
return txs
}
},
actions: {
initial() {
this.clearRecentBlocks()
this.autoFetch()
},
async clearRecentBlocks() {
this.recents = []
},
autoFetch() {
this.fetchLatest().then(x => {
const timer = this.autoFetch
this.latest = x;
// if(this.recents.length >= 50) this.recents.pop()
// this.recents.push(x)
// setTimeout(timer, 6000)
})
},
async fetchLatest() {
this.latest = await this.blockchain.rpc.block()
if(this.recents.length >= 50) this.recents.shift()
this.recents.push(this.latest)
return this.latest
},
async fetchBlock(height?: number) {
this.current = await this.blockchain.rpc.block(height)
return this.current
},
}
})
+74
View File
@@ -0,0 +1,74 @@
<script lang="ts" setup>
import TxsElement from '@/components/dynamic/TxsElement.vue';
import { useBlockModule } from './block'
import DynamicComponent from '@/components/dynamic/DynamicComponent.vue';
import { computed } from '@vue/reactivity';
import { toBase64, toHex } from '@cosmjs/encoding';
import { useFormatter } from '@/stores';
const props = defineProps(["height", "chain"]);
const store = useBlockModule()
store.fetchBlock(props.height)
const tab = ref('blocks')
const format = useFormatter()
</script>
<template>
<VCard>
<VCardTitle class="d-flex justify-space-between">
<VTabs v-model="tab">
<VTab value="blocks">Blocks</VTab>
<VTab value="transactions">Transactions</VTab>
</VTabs>
</VCardTitle>
<VWindow v-model="tab">
<VWindowItem value="blocks">
<VTable>
<thead>
<tr>
<th>Height</th><th>Hash</th><th>Proposor</th><th>Txs</th><th>Time</th>
</tr>
</thead>
<tbody>
<tr v-for="item in store.recents">
<td><RouterLink :to="`/${props.chain}/block/${item.block?.header?.height}`">{{ item.block?.header?.height }}</RouterLink></td>
<td>{{ toBase64(item.blockId?.hash) }}</td>
<td>{{ format.validator(item.block?.header?.proposerAddress) }}</td>
<td>{{ item.block?.data?.txs.length }}</td>
<td>{{ format.toDay(item.block?.header?.time, 'from') }}</td>
</tr>
</tbody>
</VTable>
</VWindowItem>
<VWindowItem value="transactions">
<VTable>
<thead>
<tr>
<th>Hash</th><th>Messages</th><th>Fees</th>
</tr>
</thead>
<tbody>
<tr v-for="item in store.txsInRecents">
<td><RouterLink :to="`/${props.chain}/tx/${item.hash}`">{{ item.hash }}</RouterLink></td>
<td>{{ format.messages(item.tx.body.messages) }}</td>
<td>{{ format.formatTokens(item.tx.authInfo.fee?.amount) }}</td>
</tr>
</tbody>
</VTable>
<VCardItem>
<v-alert
type="info"
text="Only show txs in recent blocks"
variant="tonal"
></v-alert>
</VCardItem>
</VWindowItem>
</VWindow>
<VCardActions>
</VCardActions>
</VCard>
</template>
+191
View File
@@ -0,0 +1,191 @@
<script lang="ts" setup>
import MdEditor from 'md-editor-v3';
import PriceMarketChart from '@/components/charts/PriceMarketChart.vue'
import { useBlockchain, useFormatter } from '@/stores';
import { onMounted, ref } from 'vue';
import { useIndexModule } from './indexStore';
import { computed } from '@vue/reactivity';
import CardStatisticsVertical from '@/components/CardStatisticsVertical.vue';
import ProposalProcess from '@/components/ProposalProcess.vue';
const blockchain = useBlockchain()
const store = useIndexModule()
const coinInfo = computed(() => {
return store.coinInfo
})
onMounted(() => {
store.loadDashboard()
})
const format = useFormatter()
const ticker = computed(() => store.coinInfo.tickers[store.tickerIndex])
blockchain.$subscribe((m, s) => {
console.log('index:', m)
if(!Array.isArray(m.events) && ['chainName', 'endpoint'].includes(m.events.key)) {
console.log(m.events.key)
store.loadDashboard()
}
})
function shortName(name: string, id: string) {
return name.toLowerCase().startsWith('ibc/') || name.toLowerCase().startsWith('0x') ? id: name
}
</script>
<template>
<div>
<VCard v-if="coinInfo && coinInfo.name" class="mb-5">
<VRow>
<VCol md="5">
<VCardItem>
<VCardTitle>
{{ coinInfo.name }} (<span class="text-uppercase">{{ coinInfo.symbol }}</span>)
</VCardTitle>
<VCardSubtitle>
Rank: <VChip color="error" size="x-small">#{{ coinInfo.market_cap_rank }}</VChip>
</VCardSubtitle>
</VCardItem>
<VDivider/>
<VCardItem>
<VBtn variant="text" size="small" :href="store.homepage" prependIcon="mdi-web">Website</VBtn>
<VBtn variant="text" size="small" :href="store.twitter" prependIcon="mdi-twitter">Twitter</VBtn>
<VBtn variant="text" size="small" :href="store.telegram" prependIcon="mdi-telegram">Telegram</VBtn>
<VBtn variant="text" size="small" :href="store.github" prependIcon="mdi-github">Github</VBtn>
</VCardItem>
<VCardItem>
<!-- SECTION upgrade plan banner -->
<div class="plan-upgrade-banner d-flex bg-light-secondary rounded align-center pa-3">
<h3
class="plan-details me-3"
:class="store.priceColor"
>
{{ store.priceChange }}<small>%</small>
</h3>
<VMenu open-on-hover>
<template #activator="{ props }">
<div class="d-flex flex-column align-start" v-bind="props">
<h3 class="text-base font-weight-semibold">
{{ ticker?.market?.name || ''}}
</h3>
<span class="text-primary text-xs">{{ shortName(ticker?.base, ticker.coin_id) }}/{{ shortName(ticker?.target, ticker.target_coin_id) }}</span>
</div>
</template>
<VList style="max-height: 300px;">
<VListItem
v-for="(item, i) in store.coinInfo.tickers"
:key="i"
rounded
@click="store.selectTicker(i)"
>
<template #prepend>
</template>
<!-- eslint-disable-next-line vue/no-v-text-v-html-on-component -->
<VListItemTitle v-text="item.market.name" />
<VListItemSubtitle>{{ shortName(item?.base, item.coin_id) }}/{{ shortName(item?.target, item.target_coin_id) }}</VListItemSubtitle>
<template #append>
<span class="ml-3" :class="`text-${store.tickerColor(item.trust_score)}`">{{ item.converted_last.usd }}</span>
</template>
</VListItem>
</VList>
</VMenu>
<VSpacer />
<div class="d-flex align-center">
<sub>
<h6 class="text-xs font-weight-regular">$</h6>
</sub>
<span class="text-h5">{{ ticker.converted_last.usd }}</span>
</div>
</div>
<!-- !SECTION -->
<VSpacer />
<VBtn
block
:color="store.trustColor"
class="mt-3"
:href="ticker.trade_url"
>
Buy {{ coinInfo.symbol || '' }}
</VBtn>
</VCardItem>
</VCol>
<VCol md="7">
<VCardItem>
<PriceMarketChart />
</VCardItem>
</VCol>
</VRow>
<VDivider />
<VCardText style="max-height: 250px; overflow:scroll;"><MdEditor :model-value="coinInfo.description?.en" previewOnly></MdEditor></VCardText>
<VCardItem>
<VChip v-for="tag in coinInfo.categories" size="x-small" class="mr-2">{{ tag }}</VChip>
</VCardItem>
</VCard>
<VRow>
<VCol v-for="item in store.stats" cols="12" sm="6" md="2">
<VCard>
<CardStatisticsVertical v-bind="item" />
</VCard>
</VCol>
</VRow>
<VCard class="my-5">
<VCardItem class="pb-0">
<VCardTitle>Active Proposals</VCardTitle>
</VCardItem>
<VCardItem>
<VExpansionPanels variant="accordion">
<VExpansionPanel v-for="(x, i) in store.proposals">
<VExpansionPanelTitle disable-icon-rotate>
<VChip label color="primary" class="mr-2">{{x.proposalId}}</VChip>
<div class="w-100">{{ x.content?.title }}
<div class="d-flex mt-1">
<small class="text-secondary me-auto"> {{ format.toDay(x.votingEndTime, 'from') }}</small>
<ProposalProcess style="width:300px;" :pool="store.pool" :tally="store.tally[Number(x.proposalId)]"></ProposalProcess>
<span></span>
</div>
</div>
<template #actions>
<VIcon
icon="mdi-check"
color="success"
class="ml-2"
/>
</template>
</VExpansionPanelTitle>
<VExpansionPanelText>
<VCard class="card-box">
<VCardText>
<MdEditor :model-value="x.content?.description" previewOnly></MdEditor>
</VCardText>
<div class="text-center w-100 my-2">
<VBtn color="primary" variant="flat">Vote</VBtn>
</div>
</VCard>
</VExpansionPanelText>
</VExpansionPanel>
</VExpansionPanels>
</VCardItem>
<VCardText v-if="store.proposals.length === 0">No active proposals</VCardText>
</VCard>
<VBtn block color='secondary' variant="outlined" class="mt-5">
Connect Wallet
</VBtn>
</div>
</template>
<style lang="scss" scoped>
.card-box {
border: 1px solid rgb(var(--v-theme-primary));
}
</style>
+224
View File
@@ -0,0 +1,224 @@
import { useBlockchain, useCoingecko, useBaseStore, useBankStore, useFormatter, useGovStore } from "@/stores";
import { useDistributionStore } from "@/stores/useDistributionStore";
import { useMintStore } from "@/stores/useMintStore";
import { useStakingStore } from "@/stores/useStakingStore";
import { ProposalStatus, type ProposalSDKType, Proposal } from "@ping-pub/codegen/src/cosmos/gov/v1beta1/gov";
import numeral from "numeral";
import { defineStore } from "pinia";
function colorMap(color: string) {
switch (color) {
case 'yellow':
return 'warning'
case 'green':
return 'success'
default:
return 'secondary'
}
}
export const useIndexModule = defineStore('module-index', {
state: () => {
return {
days: 14,
tickerIndex: 0,
coinInfo: {
name: '',
symbol: '',
description: {
en: ''
},
categories: [] as string[],
market_cap_rank: 0,
links: {
twitter_screen_name: '',
homepage: [] as string[],
repos_url: {
github: []
},
telegram_channel_identifier: ''
},
market_data: {
price_change_percentage_24h: 0
},
tickers: [] as {
market: {
name: string,
identifier: string,
},
coin_id: string,
target_coin_id: string,
trust_score: string,
trade_url: string,
converted_last: {
btc: number,
eth: number,
usd: number,
},
base: string,
target: string,
}[]
},
marketData: {
market_caps: [],
prices: [] as number[],
total_volumes: [] as number[],
},
communityPool: [] as {amount: string, denom: string}[],
proposals: [] as Proposal[],
tally: {} as Record<number, {
yes: string;
abstain: string;
no: string;
noWithVeto: string;
}>
}
},
getters: {
blockchain() {
const chain = useBlockchain()
return chain.current
},
coingecko() {
return useCoingecko()
},
bankStore() {
return useBankStore()
},
twitter() : string {
return `https://twitter.com/${this.coinInfo.links.twitter_screen_name}`
},
homepage(): string {
const [page1, page2, page3] = this.coinInfo.links?.homepage
return page1 || page2 || page3
},
github(): string {
const [page1, page2, page3] = this.coinInfo.links?.repos_url?.github
return page1 || page2 || page3
},
telegram() : string {
return `https://t.me/${this.coinInfo.links.telegram_channel_identifier}`
},
priceChange(): string {
const change = this.coinInfo.market_data?.price_change_percentage_24h || 0
return numeral(change).format('+0.[00]')
},
priceColor() : string {
const change = this.coinInfo.market_data?.price_change_percentage_24h || 0
switch (true) {
case change > 0:
return 'text-success'
case change < 0:
return 'text-error'
default:
return ''
}
},
trustColor() : string {
const change = this.coinInfo.tickers[this.tickerIndex]?.trust_score
return colorMap(change)
},
pool() {
const staking = useStakingStore()
return staking.pool
},
stats () {
const base = useBaseStore()
const bank = useBankStore()
const staking = useStakingStore()
const mintStore = useMintStore()
const formatter = useFormatter()
return [
{
title: 'Height',
color: 'primary',
icon: 'mdi-pound',
stats: String(base.latest.block?.header?.height || 0),
change: 0,
},
{
title: 'Validators',
color: 'error',
icon: 'mdi-human-queue',
stats: String(base.latest.block?.lastCommit?.signatures.length || 0),
change: 0,
},
{
title: 'Supply',
color: 'success',
icon: 'mdi-currency-usd',
stats: formatter.formatTokenAmount(bank.supply),
change: 0,
},
{
title: 'Bonded Tokens',
color: 'warning',
icon: 'mdi-lock',
stats: formatter.formatTokenAmount({amount: this.pool.bondedTokens, denom: staking.params.bondDenom }),
change: 0,
},
{
title: 'Inflation',
color: 'success',
icon: 'mdi-chart-multiple',
stats: formatter.formatDecimalToPercent(mintStore.inflation),
change: 0,
},
{
title: 'Community Pool',
color: 'primary',
icon: 'mdi-bank',
stats: formatter.formatTokens(this.communityPool),
change: 0,
},
]
},
},
actions: {
async loadDashboard() {
console.log('initial dashboard')
this.$reset()
this.initCoingecko()
useMintStore().fetchInflation()
useDistributionStore().fetchCommunityPool().then(x => {
this.communityPool = x.pool.filter(t=> t.denom.length < 10).map(t => ({
amount: String(parseInt(t.amount)),
denom: t.denom
}))
})
const gov = useGovStore()
gov.fetchProposals(ProposalStatus.PROPOSAL_STATUS_VOTING_PERIOD).then(x => {
this.proposals = x.proposals
x.proposals.forEach(x1 => {
gov.fetchTally(Number(x1.proposalId)).then(t => {
console.log("log: ", t)
if(t.tally) this.tally[Number(x1.proposalId)] = t.tally
})
})
})
},
tickerColor(color: string) {
return colorMap(color)
},
initCoingecko() {
this.tickerIndex = 0
const [firstAsset] = this.blockchain?.assets || []
if (firstAsset && firstAsset.coingecko_id) {
this.coingecko.getCoinInfo(firstAsset.coingecko_id).then(x => {
this.coinInfo = x
})
this.coingecko.getMarketChart(this.days, firstAsset.coingecko_id).then(x => {
this.marketData = x
})
}
},
selectTicker(i: number) {
this.tickerIndex = i
}
}
})
+254
View File
@@ -0,0 +1,254 @@
<script setup lang="ts">
import { useBlockchain, useFormatter, useMintStore, useStakingStore } from '@/stores';
import type { QueryValidatorResponseSDKType } from '@ping-pub/codegen/src/cosmos/staking/v1beta1/query';
import { onMounted } from 'vue';
import { useRoute } from 'vue-router';
import ValidatorCommissionRate from '@/components/ValidatorCommissionRate.vue'
import type { Coin, DecCoin } from '@ping-pub/codegen/src/cosmos/base/v1beta1/coin';
import { consensusPubkeyToHexAddress, operatorAddressToAccount, pubKeyToValcons, valoperToPrefix } from '@/libs';
import { computed } from '@vue/reactivity';
import type { GetTxsEventResponse } from '@ping-pub/codegen/src/cosmos/tx/v1beta1/service';
const props = defineProps(['validator', 'chain'])
const staking = useStakingStore()
const blockchain = useBlockchain()
const format = useFormatter()
const validator: string = props.validator
const v = ref({} as QueryValidatorResponseSDKType)
const cache = JSON.parse(localStorage.getItem('avatars')||'{}')
const avatars = ref( cache || {} )
const identity = ref("")
const rewards = ref([] as DecCoin[])
const addresses = ref({} as {
account: string
operAddress: string
hex: string
valCons: string,
})
const selfBonded = ref({} as Coin)
addresses.value.account = operatorAddressToAccount(validator)
// load self bond
staking.fetchValidatorDelegation(validator, addresses.value.account).then(x => {
if(x) {
selfBonded.value = x
}
})
const txs = ref({} as GetTxsEventResponse)
blockchain.rpc.txs([`message.sender='${addresses.value.account}'`]).then(x => {
console.log("txs", x)
txs.value = x
})
const apr = computed(()=> {
const rate = v.value.commission?.commissionRates.rate || 0
const inflation = useMintStore().inflation
if(Number(inflation)) {
return format.percent((1 - rate) * Number(inflation))
}
return "-"
})
const selfRate = computed(()=> {
console.log("self rate", selfBonded.value.balance?.amount, v.value.tokens)
if(selfBonded.value.balance?.amount) {
return format.calculatePercent(selfBonded.value.balance.amount, v.value.tokens)
}
return "-"
})
onMounted(()=> {
if(validator) {
staking.fetchValidator(validator.toString()).then(res => {
v.value = res.validator
identity.value = res.validator?.description?.identity
if(identity.value && !avatars.value[identity.value]) {
console.log(identity.value, avatars)
staking.keybase(identity.value).then(d => {
if (Array.isArray(d.them) && d.them.length > 0) {
const uri = String(d.them[0]?.pictures?.primary?.url).replace("https://s3.amazonaws.com/keybase_processed_uploads/", "")
if(uri) {
avatars.value[identity.value] = uri
localStorage.setItem('avatars', JSON.stringify(avatars.value))
}
}
})
}
const prefix = valoperToPrefix(v.value.operatorAddress) || '<Invalid>'
addresses.value.hex = consensusPubkeyToHexAddress(v.value.consensusPubkey)
addresses.value.valCons = pubKeyToValcons(v.value.consensusPubkey, prefix)
})
blockchain.rpc.validatorOutstandingRewards(validator).then(res => {
console.log(res)
rewards.value = res.rewards?.rewards
})
}
})
</script>
<template>
<div>
<VCard class="card-box">
<VCardItem>
<VRow>
<VCol cols="12" md="6">
<div class="d-flex pl-2">
<VAvatar icon="mdi-help-circle-outline" :image="avatars[identity]" size="90" rounded variant="outlined" color="secondary"/>
<div class="mx-2">
<h4>{{ v.description?.moniker }}</h4>
<div class="text-sm mb-2">{{ v.description?.identity || '-'}}</div>
<VBtn>Delegate</VBtn>
</div>
</div>
<VSpacer/>
<VCardText>
<p class="text-xs">
About Us
</p>
<VList class="card-list">
<VListItem prepend-icon="mdi-web">
<span>Website: </span><span> {{ v.description?.website || '-' }}</span>
</VListItem>
<VListItem prepend-icon="mdi-phone">
<span>Contact: </span><span> {{ v.description?.securityContact }}</span>
</VListItem>
</VList>
<p class="text-xs mt-3">
Validator Status
</p>
<VList class="card-list">
<VListItem prepend-icon="mdi-check">
<span>Status: </span><span> {{ v.status }}</span>
</VListItem>
<VListItem prepend-icon="mdi-lock">
<span>Jailed: </span><span> {{ v.jailed }}</span>
</VListItem>
</VList>
</VCardText>
</VCol>
<VCol cols="12" md="6">
<div class="d-flex flex-column py-3 justify-space-between">
<div class="d-flex">
<VAvatar color="secondary" rounded variant="outlined" icon="mdi-coin"></VAvatar>
<div class="ml-3 d-flex flex-column justify-center">
<h4>{{ format.formatToken2({amount: v.tokens, denom: staking.params.bondDenom}) }}</h4>
<span class="text-sm">Bonded Tokens</span>
</div>
</div>
<div class="d-flex">
<VAvatar color="secondary" rounded variant="outlined" icon="mdi-percent"></VAvatar>
<div class="ml-3 d-flex flex-column justify-center">
<h4>{{ format.formatToken(selfBonded.balance) }} ({{ selfRate }})</h4>
<span class="text-sm">Self Bonded</span>
</div>
</div>
<div class="d-flex">
<VAvatar color="secondary" rounded variant="outlined" icon="mdi-flag"></VAvatar>
<div class="ml-3 d-flex flex-column justify-center">
<h4>{{ v.minSelfDelegation }} {{ staking.params.bondDenom }}</h4>
<span class="text-sm">Min Self Delegation:</span>
</div>
</div>
<div class="d-flex">
<VAvatar color="secondary" rounded variant="outlined" icon="mdi-coin"></VAvatar>
<div class="ml-3 d-flex flex-column justify-center">
<h4>{{ apr }}</h4>
<span class="text-sm">Annual Profit</span>
</div>
</div>
<div class="d-flex">
<VAvatar color="secondary" rounded variant="outlined" icon="mdi-pound"></VAvatar>
<div class="ml-3 d-flex flex-column justify-center">
<h4>{{ v.unbondingHeight }}</h4>
<span class="text-sm">Unbonding Height</span>
</div>
</div>
<div class="d-flex">
<VAvatar color="secondary" rounded variant="outlined" icon="mdi-clock"></VAvatar>
<div class="ml-3 d-flex flex-column justify-center">
<h4>{{ format.toDay(v.unbondingTime, 'from') }}</h4>
<span class="text-sm">Unbonding Time</span>
</div>
</div>
</div>
</VCol>
</VRow>
<VDivider />
<VCardText>{{ v.description?.details }}</VCardText>
</VCardItem>
</VCard>
<VRow class="mt-3">
<VCol md="4" sm="12">
<ValidatorCommissionRate :commission="v.commission"></ValidatorCommissionRate>
</VCol>
<VCol md="4" sm="12">
<VCard title="Outstanding Rewards" class="h-100">
<VList>
<VListItem v-for="(i, k) in rewards" :key="`reward-${k}`">
<VAlertTitle>{{ format.formatToken2(i) }}</VAlertTitle>
</VListItem>
</VList>
</VCard>
</VCol>
<VCol md="4" sm="12">
<VCard title="Addresses" class="h-100">
<VList class="pt-0">
<VListItem>
<VListItemTitle>Account</VListItemTitle>
<VListItemSubtitle class="text-caption">{{ addresses.account }}</VListItemSubtitle>
</VListItem>
<VListItem>
<VListItemTitle>Operator Address</VListItemTitle>
<VListItemSubtitle class="text-caption">{{ v.operatorAddress }}</VListItemSubtitle>
</VListItem>
<VListItem>
<VListItemTitle>Hex Address</VListItemTitle>
<VListItemSubtitle class="text-caption">{{ addresses.hex }}</VListItemSubtitle>
</VListItem>
<VListItem>
<VListItemTitle>Signer Address</VListItemTitle>
<VListItemSubtitle class="text-caption">{{ addresses.valCons }}</VListItemSubtitle>
</VListItem>
</VList>
</VCard>
</VCol>
</VRow>
<VCard title="Transactions" class="mt-5">
<VCardItem class="pt-0">
<VTable>
<thead>
<th class="text-left pl-4">Height</th>
<th class="text-left pl-4">Hash</th>
<th class="text-left pl-4" width="40%">Messages</th>
<th class="text-left pl-4">Time</th>
</thead>
<tbody>
<tr v-for="(item, i) in txs.txResponses">
<td>{{ item.height }}</td>
<td class="text-truncate" style="max-width: 200px;">{{ item.txhash }}</td>
<td>{{ format.messages(txs.txs[i]?.body.messages) }}</td>
<td width="150">{{ format.toDay(item.timestamp,'from') }}</td>
</tr>
</tbody>
</VTable>
</VCardItem>
</VCard>
</div>
</template>
<style>
.card-list {
--v-card-list-gap: 10px;
}
</style>
+232
View File
@@ -0,0 +1,232 @@
<script lang=ts setup>
import { useBaseStore, useFormatter, useStakingStore } from '@/stores';
import { toBase64, toHex } from '@cosmjs/encoding';
import { pubkeyToAddress } from '@cosmjs/tendermint-rpc';
import type { ValidatorSDKType } from '@ping-pub/codegen/src/cosmos/staking/v1beta1/staking';
import { computed } from '@vue/reactivity';
import { onMounted, ref, type DebuggerEvent } from 'vue';
import { consensusPubkeyToHexAddress } from '@/libs'
const staking = useStakingStore()
const format = useFormatter()
const cache = JSON.parse(localStorage.getItem('avatars')||'{}')
const avatars = ref( cache || {} )
const latest = ref({} as Record<string, number>)
const yesterday = ref({} as Record<string, number>)
const tab = ref('active')
const unbondList = ref([] as ValidatorSDKType[])
const base = useBaseStore()
onMounted(()=> {
fetchChange(0)
staking.fetchInacitveValdiators().then(x => {
unbondList.value = x
})
})
function fetchChange(offset: number) {
const base = useBaseStore()
const diff = 86400000 / base.blocktime
base.fetchAbciInfo().then(h => {
// console.log('block:', h)
base.fetchValidatorByHeight(h.lastBlockHeight, offset).then(x => {
x.validators.forEach(v => {
if(v.pubkey) latest.value[pubkeyToAddress(v.pubkey.algorithm, v.pubkey.data)] = Number(v.votingPower)
})
})
const height = Number(h.lastBlockHeight) - diff
base.fetchValidatorByHeight(height > 0 ? height : 1, offset).then(old => {
old.validators.forEach(v => {
if(v.pubkey) yesterday.value[pubkeyToAddress(v.pubkey.algorithm, v.pubkey.data)] = Number(v.votingPower)
})
// console.log(Object.keys(yesterday.value).map(x => x.toUpperCase()))
})
})
}
const change24 = (key: {typeUrl: string, value: Uint8Array}) => {
// console.log('hex key:', consensusPubkeyToHexAddress(key))
const txt = toBase64(key.value)
const n : number = latest.value[txt];
const o : number = yesterday.value[txt]
// console.log( txt, n, o)
return n >0 && o > 0 ? n - o : 0
}
const change24Text = (key?: {typeUrl: string, value: Uint8Array}) => {
if(!key) return ''
const v = change24(key)
return v!==0 ? format.numberAndSign(v) : ''
}
const change24Color = (key?: {typeUrl: string, value: Uint8Array}) => {
if(!key) return ''
const v = change24(key)
if(v > 0) return 'text-success'
if(v < 0) return 'text-error'
}
const update = (m: DebuggerEvent) => {
if(m.key === 'validators') {
loadAvatars()
}
}
const list = computed(() => {
// return tab.value === 'active' ? staking.validators: unbondList.value
return staking.validators
})
const loadAvatars = () => {
// fetch avatar from keybase
let promise = Promise.resolve()
staking.validators.forEach(item => {
promise = promise.then(() => new Promise(resolve => {
const identity = item.description?.identity
if(identity && !avatars.value[identity]){
staking.keybase(identity).then(d => {
if (Array.isArray(d.them) && d.them.length > 0) {
const uri = String(d.them[0]?.pictures?.primary?.url).replace("https://s3.amazonaws.com/keybase_processed_uploads/", "")
if(uri) {
avatars.value[identity] = uri
localStorage.setItem('avatars', JSON.stringify(avatars.value))
}
}
resolve()
})
}else{
resolve()
}
}))
})
}
staking.$subscribe((m, s)=> {
if (Array.isArray(m.events)) {
m.events.forEach(x => {
update(x)
})
} else {
update(m.events)
}
})
const logo = (identity?: string) => {
if(!identity) return ''
const url = avatars.value[identity] || ''
return url.startsWith('http')? url: `https://s3.amazonaws.com/keybase_processed_uploads/${url}`
}
const rank = function(position: number) {
let sum = 0
for(let i = 0;i < position; i++) {
sum += Number(staking.validators[i]?.delegatorShares)
}
const percent = (sum / staking.totalPower)
switch (true) {
case tab.value ==='active' && percent < 0.33: return 'error'
case tab.value ==='active' && percent < 0.67: return 'warning'
default: return 'primary'
}
}
</script>
<template>
<div>
<VCard>
<VCardTitle class="d-flex justify-space-between">
<VBtnToggle v-model="tab" size="small" color="primary">
<VBtn value="active" variant="outlined" >Active</VBtn>
<VBtn value="inactive" variant="outlined">Inactive</VBtn>
</VBtnToggle>
<span class="mt-2">{{ list.length }}/{{ staking.params.maxValidators }}</span>
</VCardTitle>
<VTable class="text-no-wrap table-header-bg rounded-0">
<thead>
<tr>
<th
scope="col"
style="width: 3rem;"
>#</th>
<th scope="col">
VALIDATOR
</th>
<th scope="col" class="text-right">
VOTING POWER
</th>
<th scope="col" class="text-right">
24h CHANGES
</th>
<th scope="col" class="text-right">
COMMISSION
</th>
<th scope="col">
ACTIONS
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(v, i) in list"
:key="v.operatorAddress"
>
<!-- 👉 rank -->
<td>
<VChip label :color="rank(i)">
{{ i + 1 }}
</VChip>
</td>
<!-- 👉 Validator -->
<td>
<div class="d-flex align-center overflow-hidden" style="max-width: 400px;">
<VAvatar
variant="tonal"
class="me-3"
size="34"
icon="mdi-help-circle-outline"
:image="logo(v.description?.identity)"
/>
<div class="d-flex flex-column">
<h6 class="text-sm">
<RouterLink
:to="{name: 'chain-staking-validator', params: {validator: v.operatorAddress}}"
class="font-weight-medium user-list-name"
>
{{ v.description?.moniker }}
</RouterLink>
</h6>
<span class="text-xs">{{ v.description?.website || v.description?.identity || '-' }}</span>
</div>
</div>
</td>
<!-- 👉 Voting Power -->
<td class="text-right">
<div class="d-flex flex-column">
<h6 class="text-sm font-weight-medium">
{{ format.formatToken( {amount: parseInt(v.tokens).toString(), denom: staking.params.bondDenom }, true, "0,0") }}
</h6>
<span class="text-xs">{{ format.calculatePercent(v.delegatorShares, staking.totalPower) }}</span>
</div>
</td>
<!-- 👉 24h Changes -->
<td class="text-right text-xs" :class="change24Color(v.consensusPubkey)">
{{ change24Text(v.consensusPubkey) }} <VChip label v-if="v.jailed" color="error">Jailed</VChip>
</td>
<!-- 👉 commission -->
<td class="text-right">
{{ format.formatCommissionRate(v.commission?.commissionRates?.rate) }}
</td>
<!-- 👉 Action -->
<td>
{{ 2 }}
</td>
</tr>
</tbody>
</VTable>
<VDivider/>
<VCardActions class="py-2">
<VChip label color="error">Top 33%</VChip> <VChip label color="warning" class="mx-2">Top 67%</VChip>
</VCardActions>
</VCard>
</div>
</template>
+3
View File
@@ -0,0 +1,3 @@
<template>
<div>Hello module</div>
</template>
+66
View File
@@ -0,0 +1,66 @@
<script lang="ts" setup>
import { useBlockchain, useFormatter } from '@/stores';
import type { GetTxResponse } from 'cosmjs-types/cosmos/tx/v1beta1/service';
import DynamicComponent from '@/components/dynamic/DynamicComponent.vue';
import { computed } from '@vue/reactivity';
import { fromBase64, toBase64 } from '@cosmjs/encoding';
const props = defineProps(['hash', 'chain'])
const blockchain = useBlockchain()
const format = useFormatter()
const tx = ref({} as GetTxResponse)
if(props.hash) {
blockchain.rpc.tx(props.hash).then(x => tx.value = x)
}
const messages = computed(() => {
return tx.value.tx?.body?.messages||[]
})
</script>
<template>
<div>
<VCard v-if="tx.txResponse" title="Summary">
<VCardItem class="pt-0">
<VTable>
<tbody>
<tr><td>Tx Hash</td><td>{{ tx.txResponse.txhash }}</td></tr>
<tr><td>Height</td><td><RouterLink :to="`/${props.chain}/block/${tx.txResponse.height}`">{{ tx.txResponse.height }}</RouterLink></td></tr>
<tr><td>Status</td><td>
<VChip v-if="tx.txResponse.code === 0" color="success">Success</VChip>
<span v-else><VChip color="error">Failded</VChip></span>
</td></tr>
<tr><td>Time</td><td>{{ tx.txResponse.timestamp }} ({{ format.toDay(tx.txResponse.timestamp, "from") }})</td></tr>
<tr><td>Gas</td><td>{{ tx.txResponse.gasUsed }} / {{ tx.txResponse.gasWanted }}</td></tr>
<tr><td>Fee</td><td>{{ format.formatTokens(tx.tx?.authInfo?.fee?.amount, true, '0,0.[00]') }}</td></tr>
<tr><td>Memo</td><td>{{ tx.tx.body.memo }}</td></tr>
</tbody>
</VTable>
</VCardItem>
</VCard>
<VCard title="Messages" class="my-5">
<VCardItem>
<div v-for="(msg, i) in messages">
<div><VChip label color="primary">#{{ i+1 }}</VChip>{{ msg.typeUrl }}</div>
<div>{{ toBase64(msg.value) }}</div>
</div>
</VCardItem>
</VCard>
<VCard title="Detail">
<VExpansionPanels>
<VExpansionPanel title="Transaction">
<v-expansion-panel-text>
<DynamicComponent :value="tx.tx" />
</v-expansion-panel-text>
</VExpansionPanel>
<VExpansionPanel title="Transaction Response">
<v-expansion-panel-text>
<DynamicComponent :value="tx.txResponse" />
</v-expansion-panel-text>
</VExpansionPanel>
</VExpansionPanels>
</VCard>
</div>
</template>
+3
View File
@@ -0,0 +1,3 @@
<template>
<div>Hello wallet</div>
</template>
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
import misc404 from '@images/pages/404.png'
import miscObj from '@images/pages/misc-404-object.png'
import miscMaskDark from '@images/pages/misc-mask-dark.png'
import miscMaskLight from '@images/pages/misc-mask-light.png'
import { useGenerateImageVariant } from '@/plugins/vuetify/@core/composable/useGenerateImageVariant'
const miscThemeMask = useGenerateImageVariant(miscMaskLight, miscMaskDark)
</script>
<template>
<div class="misc-wrapper">
<ErrorHeader error-code="404" error-title="Page Not Found "
error-description="We couldn't find the page you are looking for." />
<!-- 👉 Image -->
<div class="misc-avatar w-100 text-center">
<VImg :src="misc404" alt="Coming Soon" :height="$vuetify.display.xs ? 400 : 500" class="my-sm-4" />
<VBtn to="/" class="mt-10">
Back to Home
</VBtn>
<VImg :src="miscThemeMask" class="d-none d-md-block footer-coming-soon" cover />
<VImg :src="miscObj" class="d-none d-md-block footer-coming-soon-obj" :max-width="174" height="158" />
</div>
</div>
</template>
<style lang="scss">
@use "@core/scss/template/pages/misc.scss";
</style>
<route lang="yaml">
meta:
layout: blank
action: read
subject: Auth
</route>
+56
View File
@@ -0,0 +1,56 @@
<script lang="ts" setup>
import { useDashboard, LoadingStatus, type ChainConfig } from '@/stores/useDashboard';
import ChainSummary from '@/components/ChainSummary.vue';
import { computed, ref } from 'vue';
import { useBlockchain } from '@/stores';
import { Tendermint34Client } from "@cosmjs/tendermint-rpc";
import { QueryClient, setupBankExtension, setupDistributionExtension, setupGovExtension, setupMintExtension, setupStakingExtension } from "@cosmjs/stargate";
const dashboard = useDashboard()
dashboard.$subscribe((mutation, state) => {
localStorage.setItem('favorite', JSON.stringify(state.favorite))
})
const keywords = ref('')
const chains = computed(()=> {
if(keywords.value) {
return Object.values(dashboard.chains).filter( (x: ChainConfig)=>x.chainName.indexOf(keywords.value) > -1)
}else{
return Object.values(dashboard.chains)
}
})
const chain = useBlockchain()
</script>
<template>
<div class="d-flex flex-column justify-center">
<div class="d-flex justify-center align-center align-self-center p-1 b1">
<VImg src="/logo.svg" width="85" height="85"/>
<h1 class="text-primary text-h3 font-weight-bold d-none d-md-block ml-1">
Ping Dashboard<VChip>Beta</VChip>
</h1>
</div>
<div class="d-flex flex-column align-center">
<p class="mb-1">
{{ $t('index.slogan') }}
</p>
<h2 class="mb-9">
Cosmos Ecosystem Blockchains 🚀
</h2>
</div>
<VProgressLinear v-if="dashboard.status !== LoadingStatus.Loaded " indeterminate color="primary darken-2"/>
<VTextField v-model="keywords" variant="underlined" :placeholder="$t('index.search_placeholder')" style="max-width: 300px;" app>
<template #append-inner>
{{ chains.length }}/{{ dashboard.length }}
</template>
</VTextField>
<VRow class="my-auto">
<VCol v-for="k in chains" md="3">
<VLazy min-height="40" min-width="200" transition="fade-transition">
<ChainSummary :name="k.chainName" />
</VLazy>
</VCol>
</VRow>
</div>
</template>
+18
View File
@@ -0,0 +1,18 @@
<template>
<div>
<VCard title="Create Awesome 🙌">
<VCardText>This is your second page.</VCardText>
<VCardText>
Chocolate sesame snaps pie carrot cake pastry pie lollipop muffin.
Carrot cake dragée chupa chups jujubes. Macaroon liquorice cookie
wafer tart marzipan bonbon. Gingerbread jelly-o dragée
chocolate.
</VCardText>
</VCard>
</div>
</template>
<route lang="yaml">
meta:
layout: blank
bgColor: yellow
</route>
+15
View File
@@ -0,0 +1,15 @@
import { createI18n } from 'vue-i18n'
const messages = Object.fromEntries(
Object.entries(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
import.meta.glob<{ default: any }>('./locales/*.json', { eager: true }))
.map(([key, value]) => [key.slice(10, -5), value.default]),
)
export default createI18n({
legacy: false,
locale: localStorage.getItem('lang') || 'en',
fallbackLocale: 'en',
messages,
})
+14
View File
@@ -0,0 +1,14 @@
{
"module": {
"chain": "面板首页",
"chain-test": "测试功能",
},
"index": {
"slogan": "Ping Dashboard 是一个区块链浏览器,也是一个网页钱包,还有更多 ... 🛠",
"search_placeholder": "搜索区块链",
"add_to_favorite": "加入收藏夹"
},
"Ecosystem": "生态系统",
"All Blockchains": "全部区块链",
"Favorite": "收藏夹"
}
+15
View File
@@ -0,0 +1,15 @@
{
"module": {
"chain": "Dashboard",
"chain-test": "test",
},
"index": {
"slogan": "Ping Dashboard is not just an explorer but also a wallet and more ... 🛠",
"search_placeholder": "Search Chain",
"add_to_favorite": "Add to favorite"
},
"Ecosystem": "Ecosystem",
"All Blockchains": "All Blockchain222",
"Favorite": "Favorite"
}
+11
View File
@@ -0,0 +1,11 @@
import type { RPCClient } from '@/libs/client.rpc'
import 'pinia'
import type { Ref } from 'vue'
declare module 'pinia' {
export interface PiniaCustomProperties {
// by using a setter we can allow both strings and refs
set rpc(value: RPCClient | Ref<RPCClient>)
get rpc(): RPCClient
}
}
+10
View File
@@ -0,0 +1,10 @@
import type { PiniaPluginContext } from "pinia"
export function DashboardPlugin(context: PiniaPluginContext) {
context.pinia // the pinia created with `createPinia()`
context.app // the current app created with `createApp()` (Vue 3 only)
context.store // the store the plugin is augmenting
context.options // the options object defining the store passed to `defineStore()`
// ...
context.store.$subscribe((m, s) => console.log(m, s))
}
@@ -0,0 +1,100 @@
<script setup lang="ts">
import pixinventQr from '@images/pages/pixinvent-qr.png'
interface Emit {
(e: 'update:isDialogVisible', value: boolean): void
(e: 'submit', value: string): void
}
interface Props {
authCode?: string
isDialogVisible: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<Emit>()
const authCode = ref(structuredClone(toRaw(props.authCode)))
const formSubmit = () => {
if (authCode.value) {
emit('submit', authCode.value)
emit('update:isDialogVisible', false)
}
}
const resetAuthCode = () => {
authCode.value = structuredClone(toRaw(props.authCode))
emit('update:isDialogVisible', false)
}
</script>
<template>
<VDialog
max-width="600"
:model-value="props.isDialogVisible"
@update:model-value="(val) => $emit('update:isDialogVisible', val)"
>
<VCard class="pa-5 pa-sm-8">
<!-- 👉 dialog close btn -->
<DialogCloseBtn
variant="text"
size="small"
@click="resetAuthCode"
/>
<VCardItem>
<VCardTitle class="text-h5 font-weight-medium text-center">
Add Authenticator App
</VCardTitle>
</VCardItem>
<VCardText class="pt-6">
<h6 class="text-h6 font-weight-medium mb-3">
Authenticator Apps
</h6>
<p class="mb-6">
Using an authenticator app like Google Authenticator, Microsoft Authenticator, Authy, or 1Password, scan the QR code. It will generate a 6 digit code for you to enter below.
</p>
<div class="my-6">
<VImg
width="122"
:src="pixinventQr"
class="mx-auto"
/>
</div>
<VForm @submit.prevent="() => {}">
<VTextField
v-model="authCode"
name="auth-code"
label="Enter Authentication Code"
class="mb-4"
/>
<div class="d-flex justify-end flex-wrap gap-3">
<VBtn
color="secondary"
variant="tonal"
@click="resetAuthCode"
>
Cancel
</VBtn>
<VBtn
type="submit"
@click="formSubmit"
>
Continue
<VIcon
end
icon="mdi-chevron-right"
class="flip-in-rtl"
/>
</VBtn>
</div>
</VForm>
</VCardText>
</VCard>
</VDialog>
</template>
@@ -0,0 +1,261 @@
<script setup lang="ts">
interface BillingAddress {
companyName: string
billingEmail: string
taxID: string
vatNumber: string
address: string
contact: string
country: string
state: string
zipCode: string
isSaveDefaultAddress: boolean
}
interface Props {
billingAddress?: BillingAddress
isDialogVisible: boolean
}
interface Emit {
(e: 'update:isDialogVisible', value: boolean): void
(e: 'submit', value: BillingAddress): void
}
const props = withDefaults(defineProps<Props>(), {
billingAddress: () => ({
companyName: '',
billingEmail: '',
taxID: '',
vatNumber: '',
address: '',
contact: '',
country: '',
state: '',
zipCode: '',
isSaveDefaultAddress: true,
}),
})
const emit = defineEmits<Emit>()
const billingAddress = ref<BillingAddress>(structuredClone(toRaw(props.billingAddress)))
const resetForm = () => {
emit('update:isDialogVisible', false)
billingAddress.value = structuredClone(toRaw(props.billingAddress))
}
const onFormSubmit = () => {
emit('update:isDialogVisible', false)
emit('submit', billingAddress.value)
}
const selectedAddress = ref('Home')
const addressTypes = [
{
icon: 'mdi-home-outline',
title: 'Home',
time: 'Delivery Time (7am - 9pm)',
},
{
icon: 'mdi-briefcase-outline',
title: 'Office',
time: 'Delivery Time (10am - 6pm)',
},
]
</script>
<template>
<VDialog
:width="$vuetify.display.smAndDown ? 'auto' : 800"
:model-value="props.isDialogVisible"
@update:model-value="val => $emit('update:isDialogVisible', val)"
>
<VCard
v-if="props.billingAddress"
class="pa-sm-8 pa-5"
>
<!-- 👉 dialog close btn -->
<DialogCloseBtn
variant="text"
size="small"
@click="resetForm"
/>
<!-- 👉 Title -->
<VCardItem>
<VCardTitle class="text-h5 text-center">
{{ props.billingAddress.address ? 'Edit' : 'Add New' }} Address
</VCardTitle>
</VCardItem>
<VCardText class="pt-3">
<!-- 👉 Subtitle -->
<VCardSubtitle class="text-center mb-8">
Edit Address for future billing
</VCardSubtitle>
<VRow>
<VCol
v-for="type in addressTypes"
:key="type.title"
cols="12"
sm="6"
>
<div
class="custom-address-input border rounded cursor-pointer pa-4"
:class="selectedAddress === type.title ? 'bg-light-primary text-primary border-primary' : 'bg-light-secondary border-secondary'"
style="/* stylelint-disable-next-line max-empty-lines */
--v-border-opacity: 1;"
@click="selectedAddress = type.title"
>
<div class="d-flex align-center font-weight-medium gap-2 text-xl mb-1">
<VIcon
size="24"
:icon="type.icon"
/>
<span>{{ type.title }}</span>
</div>
<span>{{ type.time }}</span>
</div>
</VCol>
</VRow>
<!-- 👉 Form -->
<VForm
class="mt-4"
@submit.prevent="onFormSubmit"
>
<VRow>
<!-- 👉 Company Name -->
<VCol
cols="12"
md="6"
>
<VTextField
v-model="billingAddress.companyName"
label="Company Name"
/>
</VCol>
<!-- 👉 Email -->
<VCol
cols="12"
md="6"
>
<VTextField
v-model="billingAddress.billingEmail"
label="Email"
/>
</VCol>
<!-- 👉 Tax ID -->
<VCol
cols="12"
md="6"
>
<VTextField
v-model="billingAddress.taxID"
label="Tax ID"
/>
</VCol>
<!-- 👉 VAT Number -->
<VCol
cols="12"
md="6"
>
<VTextField
v-model="billingAddress.vatNumber"
label="VAT Number"
/>
</VCol>
<!-- 👉 Billing Address -->
<VCol cols="12">
<VTextarea
v-model="billingAddress.address"
rows="2"
label="Billing Address"
/>
</VCol>
<!-- 👉 Contact -->
<VCol
cols="12"
md="6"
>
<VTextField
v-model="billingAddress.contact"
label="Contact"
/>
</VCol>
<!-- 👉 Country -->
<VCol
cols="12"
md="6"
>
<VSelect
v-model="billingAddress.country"
label="Country"
:items="['USA', 'Uk', 'France', 'Germany', 'Japan']"
/>
</VCol>
<!-- 👉 State -->
<VCol
cols="12"
md="6"
>
<VTextField
v-model="billingAddress.state"
label="State"
/>
</VCol>
<!-- 👉 Zip Code -->
<VCol
cols="12"
md="6"
>
<VTextField
v-model="billingAddress.zipCode"
label="Zip Code"
/>
</VCol>
<!-- 👉 default address -->
<VCol cols="12">
<VSwitch
v-model="billingAddress.isSaveDefaultAddress"
label="Make this default shipping address"
/>
</VCol>
<!-- 👉 Submit and Cancel button -->
<VCol
cols="12"
class="text-center"
>
<VBtn
type="submit"
class="me-3"
>
submit
</VBtn>
<VBtn
variant="tonal"
color="secondary"
@click="resetForm"
>
Cancel
</VBtn>
</VCol>
</VRow>
</VForm>
</VCardText>
</VCard>
</VDialog>
</template>
@@ -0,0 +1,378 @@
<script setup lang="ts">
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
import { VList, VListItem, VListSubheader } from 'vuetify/components'
interface Emit {
(e: 'update:isDialogVisible', value: boolean): void
(e: 'update:searchQuery', value: string): void
(e: 'itemSelected', value: any): void
}
interface Suggestion {
icon: string
title: string
url: object
}
interface Suggestions {
title: string
content: Suggestion[]
}
interface Props {
isDialogVisible: boolean
searchQuery: string
searchResults: any[]
suggestions?: Suggestions[]
noDataSuggestion?: Suggestion[]
}
const props = defineProps<Props>()
const emit = defineEmits<Emit>()
// 👉 Hotkey
const { ctrl_k, meta_k } = useMagicKeys()
const refSearchList = ref<VList>()
const searchQuery = ref(structuredClone(toRaw(props.searchQuery)))
const refSearchInput = ref<HTMLInputElement>()
const isLocalDialogVisible = ref(structuredClone(toRaw(props.isDialogVisible)))
const searchResults = ref(structuredClone(toRaw(props.searchResults)))
// 👉 Watching props change
watch(props, () => {
isLocalDialogVisible.value = structuredClone(toRaw(props.isDialogVisible))
searchResults.value = structuredClone(toRaw(props.searchResults))
searchQuery.value = structuredClone(toRaw(props.searchQuery))
})
// 👉 watching control + / to open dialog
watch([ctrl_k, meta_k], () => {
isLocalDialogVisible.value = true
emit('update:isDialogVisible', true)
})
// 👉 clear search result and close the dialog
const clearSearchAndCloseDialog = () => {
emit('update:isDialogVisible', false)
emit('update:searchQuery', '')
}
watchEffect(() => {
if (!searchQuery.value.length)
searchResults.value = []
})
// 👉 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) => {
emit('update:isDialogVisible', val)
emit('update:searchQuery', '')
}
// 👉 resolve categories name
const resolveCategories = (val: string) => {
if (val === 'dashboards')
return 'Dashboards'
if (val === 'appsPages')
return 'Apps & Pages'
if (val === 'userInterface')
return 'User Interface'
if (val === 'formsTables')
return 'Forms Tables'
if (val === 'chartsMisc')
return 'Charts Misc'
return 'Misc'
}
</script>
<template>
<VDialog
max-width="600"
:model-value="isLocalDialogVisible"
:height="$vuetify.display.smAndUp ? '550' : '100%'"
:fullscreen="$vuetify.display.width < 600"
class="app-bar-search-dialog"
@update:model-value="dialogModelValueUpdate"
@keyup.esc="clearSearchAndCloseDialog"
>
<VCard
height="100%"
width="100%"
class="position-relative"
>
<VCardText
class="pt-1"
style="max-height: 65px;"
>
<!-- 👉 Search Input -->
<VTextField
ref="refSearchInput"
v-model="searchQuery"
autofocus
variant="plain"
class="app-bar-autocomplete-box"
@keyup.esc="clearSearchAndCloseDialog"
@keydown="getFocusOnSearchList"
@update:model-value="$emit('update:searchQuery', searchQuery)"
>
<!-- 👉 Prepend Inner -->
<template #prepend-inner>
<VIcon
icon="mdi-magnify"
class="text-high-emphasis"
/>
</template>
<!-- 👉 Append Inner -->
<template #append-inner>
<div class="d-flex align-center mt-n1">
<div
class="text-base text-disabled cursor-pointer me-2"
@click="clearSearchAndCloseDialog"
>
[esc]
</div>
<IconBtn
size="x-small"
@click="clearSearchAndCloseDialog"
>
<VIcon icon="mdi-close" />
</IconBtn>
</div>
</template>
</VTextField>
</VCardText>
<!-- 👉 Divider -->
<VDivider />
<!-- 👉 Perfect Scrollbar -->
<PerfectScrollbar
:options="{ wheelPropagation: false, suppressScrollX: true }"
class="h-100"
>
<!-- 👉 Search List -->
<VList
v-show="searchQuery.length && !!searchResults.length"
ref="refSearchList"
density="compact"
class="app-bar-search-list"
>
<!-- 👉 list Item /List Sub header -->
<template
v-for="item in searchResults"
:key="item.title"
>
<VListSubheader
v-if="'header' in item"
class="text-disabled"
>
{{ resolveCategories(item.title) }}
</VListSubheader>
<template v-else>
<slot
name="searchResult"
:item="item"
>
<VListItem
link
@click="$emit('itemSelected', item)"
>
<template #prepend>
<VIcon
size="20"
:icon="item.icon"
class="me-3"
/>
</template>
<template #append>
<VIcon
size="20"
icon="mdi-subdirectory-arrow-left"
class="enter-icon text-disabled"
/>
</template>
<VListItemTitle>
{{ item.title }}
</VListItemTitle>
</VListItem>
</slot>
</template>
</template>
</VList>
<!-- 👉 Suggestions -->
<div
v-show="!!searchResults && !searchQuery"
class="h-100"
>
<slot name="suggestions">
<VCardText class="app-bar-search-suggestions h-100 pa-10">
<VRow
v-if="props.suggestions"
class="gap-y-4"
>
<VCol
v-for="suggestion in props.suggestions"
:key="suggestion.title"
cols="12"
sm="6"
class="ps-6"
>
<p class="text-xs text-disabled text-uppercase">
{{ suggestion.title }}
</p>
<VList class="card-list">
<VListItem
v-for="item in suggestion.content"
:key="item.title"
link
:title="item.title"
class="app-bar-search-suggestion"
@click="$emit('itemSelected', item)"
>
<template #prepend>
<VIcon
:icon="item.icon"
size="20"
class="me-2"
/>
</template>
</VListItem>
</VList>
</VCol>
</VRow>
</VCardText>
</slot>
</div>
<!-- 👉 No Data found -->
<div
v-show="!searchResults.length && searchQuery.length"
class="h-100"
>
<slot name="noData">
<VCardText class="h-100">
<div class="app-bar-search-suggestions d-flex flex-column align-center justify-center text-high-emphasis h-100">
<VIcon
size="75"
icon="mdi-file-remove-outline"
/>
<div class="d-flex align-center flex-wrap justify-center gap-2 text-h6 my-3">
<span>No Result For </span>
<span>"{{ searchQuery }}"</span>
</div>
<div
v-if="props.noDataSuggestion"
class="mt-8"
>
<span class="d-flex justify-center text-disabled">Try searching for</span>
<h6
v-for="suggestion in props.noDataSuggestion"
:key="suggestion.title"
class="app-bar-search-suggestion text-sm font-weight-regular cursor-pointer mt-3"
@click="$emit('itemSelected', suggestion)"
>
<VIcon
size="20"
:icon="suggestion.icon"
class="me-3"
/>
<span class="text-sm">{{ suggestion.title }}</span>
</h6>
</div>
</div>
</VCardText>
</slot>
</div>
</PerfectScrollbar>
</VCard>
</VDialog>
</template>
<style lang="scss">
.app-bar-search-suggestions {
.app-bar-search-suggestion {
&:hover {
color: rgb(var(--v-theme-primary));
}
}
}
.app-bar-autocomplete-box {
.v-field__input {
padding-block-end: 0.425rem;
padding-block-start: 0.9375rem;
}
.v-field__field input {
text-align: start !important;
}
}
.app-bar-search-dialog {
.v-list-item-title {
font-size: 0.875rem !important;
}
.app-bar-search-list {
.v-list-item,
.v-list-subheader {
font-size: 0.75rem;
padding-inline: 1.5rem !important;
}
.v-list-item {
.v-list-item__append {
.enter-icon {
visibility: hidden;
}
}
&:hover,
&:active,
&:focus {
.v-list-item__append {
.enter-icon {
visibility: visible;
}
}
}
}
.v-list-subheader {
line-height: 1;
min-block-size: auto;
padding-block: 0.6875rem 0.3125rem;
text-transform: uppercase;
}
}
}
</style>
<style lang="scss" scoped>
.card-list {
--v-card-list-gap: 16px;
}
</style>
@@ -0,0 +1,152 @@
<script setup lang="ts">
interface Props {
collapsed?: boolean
noActions?: boolean
actionCollapsed?: boolean
actionRefresh?: boolean
actionRemove?: boolean
title?: string
}
interface Emit {
(e: 'collapsed', isContentCollapsed: boolean): void
(e: 'refresh', hideOverlay: () => void): void
(e: 'trash'): void
}
const props = withDefaults(defineProps<Props>(), {
collapsed: false,
noActions: false,
actionCollapsed: false,
actionRefresh: false,
actionRemove: false,
title: undefined,
})
const emit = defineEmits<Emit>()
// inherit Attribute make false
defineOptions({
inheritAttrs: false,
})
const isContentCollapsed = ref(props.collapsed)
const isCardRemoved = ref(false)
const isOverlayVisible = ref(false)
// hiding overlay
const hideOverlay = () => {
isOverlayVisible.value = false
}
// trigger collapse
const triggerCollapse = () => {
isContentCollapsed.value = !isContentCollapsed.value
emit('collapsed', isContentCollapsed.value)
}
// trigger refresh
const triggerRefresh = () => {
isOverlayVisible.value = true
emit('refresh', hideOverlay)
}
// trigger removal
const triggeredRemove = () => {
isCardRemoved.value = true
emit('trash')
}
</script>
<template>
<VExpandTransition>
<!-- TODO remove div when transition work with v-card components: https://github.com/vuetifyjs/vuetify/issues/15111 -->
<div v-if="!isCardRemoved">
<VCard v-bind="$attrs">
<VCardItem>
<VCardTitle v-if="props.title || $slots.title">
<!-- 👉 Title slot and prop -->
<slot name="title">
{{ props.title }}
</slot>
</VCardTitle>
<template #append>
<!-- 👉 Before actions slot -->
<div>
<slot name="before-actions" />
<!-- SECTION Actions buttons -->
<!-- 👉 Collapse button -->
<IconBtn
v-if="(!(actionRemove || actionRefresh) || actionCollapsed) && !noActions"
@click="triggerCollapse"
>
<VIcon
size="20"
icon="mdi-chevron-up"
:style="{ transform: isContentCollapsed ? 'rotate(-180deg)' : null }"
style="transition-duration: 0.28s;"
/>
</IconBtn>
<!-- 👉 Overlay button -->
<IconBtn
v-if="(!(actionRemove || actionCollapsed) || actionRefresh) && !noActions"
@click="triggerRefresh"
>
<VIcon
size="20"
icon="mdi-refresh"
/>
</IconBtn>
<!-- 👉 Close button -->
<IconBtn
v-if="(!(actionRefresh || actionCollapsed) || actionRemove) && !noActions"
@click="triggeredRemove"
>
<VIcon
size="20"
icon="mdi-close"
/>
</IconBtn>
</div>
<!-- !SECTION -->
</template>
</VCardItem>
<!-- 👉 card content -->
<VExpandTransition>
<div
v-show="!isContentCollapsed"
class="v-card-content"
>
<slot />
</div>
</VExpandTransition>
<!-- 👉 Overlay -->
<VOverlay
v-model="isOverlayVisible"
contained
persistent
class="align-center justify-center"
>
<VProgressCircular indeterminate />
</VOverlay>
</VCard>
</div>
</VExpandTransition>
</template>
<style lang="scss">
.v-card-item {
+.v-card-content {
.v-card-text:first-child {
padding-block-start: 0;
}
}
}
</style>
@@ -0,0 +1,125 @@
<script lang="ts" setup>
import 'prismjs'
import 'prismjs/themes/prism-tomorrow.css'
import type { Ref } from 'vue'
import Prism from 'vue-prism-component'
interface Props {
title: string
code: CodeProp
codeLanguage?: string
noPadding?: boolean
}
interface CodeProp {
ts: string
js: string
}
const props = withDefaults(defineProps<Props>(), {
codeLanguage: 'markup',
noPadding: false,
})
const preferredCodeLanguage = useStorage('preferredCodeLanguage', 'ts') as unknown as Ref<keyof CodeProp>
const isCodeShown = ref(false)
const { copy, copied } = useClipboard({ source: computed(() => props.code[preferredCodeLanguage.value]) })
</script>
<template>
<VCard>
<VCardItem>
<VCardTitle>{{ props.title }}</VCardTitle>
<template #append>
<IconBtn
:color="isCodeShown ? 'primary' : 'default'"
:class="isCodeShown ? '' : 'text-disabled'"
@click="isCodeShown = !isCodeShown"
>
<VIcon
size="20"
icon="mdi-code-tags"
/>
</IconBtn>
</template>
</VCardItem>
<slot v-if="noPadding" />
<VCardText v-else>
<slot />
</VCardText>
<VExpandTransition>
<div v-show="isCodeShown">
<VDivider />
<VCardText class="d-flex gap-y-3 flex-column">
<div class="d-flex justify-end">
<VBtnToggle
v-model="preferredCodeLanguage"
mandatory
variant="outlined"
density="compact"
>
<VBtn
size="x-small"
value="ts"
:color="preferredCodeLanguage === 'ts' ? 'primary' : 'default'"
>
<VIcon
size="x-large"
icon="mdi-language-typescript"
:color="preferredCodeLanguage === 'ts' ? 'primary' : 'secondary'"
/>
</VBtn>
<VBtn
size="x-small"
value="js"
:color="preferredCodeLanguage === 'js' ? 'primary' : 'default'"
>
<VIcon
size="x-large"
icon="mdi-language-javascript"
:color="preferredCodeLanguage === 'js' ? 'primary' : 'secondary'"
/>
</VBtn>
</VBtnToggle>
</div>
<div class="position-relative">
<Prism
:key="props.code[preferredCodeLanguage]"
:language="props.codeLanguage"
>
{{ props.code[preferredCodeLanguage] }}
</Prism>
<IconBtn
class="position-absolute app-card-code-copy-icon"
color="white"
@click="() => { copy() }"
>
<VIcon
:icon="copied ? 'mdi-check' : 'mdi-content-copy'"
size="20"
/>
</IconBtn>
</div>
</VCardText>
</div>
</VExpandTransition>
</VCard>
</template>
<style lang="scss">
@use "@styles/variables/_vuetify.scss";
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
border-radius: vuetify.$card-border-radius;
}
.app-card-code-copy-icon {
inset-block-start: 1.2em;
inset-inline-end: 0.8em;
}
</style>
@@ -0,0 +1,416 @@
<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 { filterFieldProps, makeVFieldProps } from 'vuetify/lib/components/VField/VField'
// @ts-expect-error There won't be declaration file for it
import { filterInputProps, 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 { useThemeConfig } from '@core/composable/useThemeConfig'
const props = defineProps({
...makeVInputProps({
hideDetails: 'auto',
}),
...makeVFieldProps({
variant: 'outlined',
color: 'primary',
}),
})
const emit = defineEmits<Emit>()
interface Emit {
(e: 'update:modelValue', val: string): void
(e: 'click:clear', el: MouseEvent): void
}
// inherit Attribute make false
defineOptions({
inheritAttrs: false,
})
const attrs = useAttrs()
const [rootAttrs, compAttrs] = filterInputAttrs(attrs)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [{ modelValue: _, ...inputProps }] = filterInputProps(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 { theme } = useThemeConfig()
const vuetifyTheme = useTheme()
const vuetifyThemesName = Object.keys(vuetifyTheme.themes.value)
// Themes class added to flat-picker component for light and dark support
const updateThemeClassInCalendar = () => {
// ️ Flatpickr don't render it's instance in mobile and device simulator
if (!refFlatPicker.value.fp.calendarContainer)
return
vuetifyThemesName.forEach(t => {
refFlatPicker.value.fp.calendarContainer.classList.remove(`v-theme--${t}`)
})
refFlatPicker.value.fp.calendarContainer.classList.add(`v-theme--${vuetifyTheme.global.name.value}`)
}
watch(theme, updateThemeClassInCalendar)
onMounted(() => {
updateThemeClassInCalendar()
})
const emitModelValue = (val: string) => {
emit('update:modelValue', val)
}
</script>
<template>
<!-- v-input -->
<VInput
v-bind="{ ...inputProps, ...rootAttrs }"
:model-value="modelValue"
:hide-details="props.hideDetails"
class="position-relative"
>
<template #default="{ isDirty, isValid, isReadonly }">
<!-- v-field -->
<VField
v-bind="fieldProps"
:active="focused || isDirty.value || isCalendarOpen"
:focused="focused || isCalendarOpen"
role="textbox"
:dirty="isDirty.value || props.dirty"
:error="isValid.value === false"
@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"
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"
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"
/>
</template>
<style lang="scss">
/* stylelint-disable no-descending-specificity */
@use "flatpickr/dist/flatpickr.css";
@use "vuetify/lib/styles/tools/_elevation" as mixins_elevation;
.flat-picker-custom-style {
position: absolute;
color: inherit;
inline-size: 100%;
inset: 0;
outline: none;
padding-block: 0;
padding-inline: var(--v-field-padding-start);
}
$heading-color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));
$body-color: rgba(var(--v-theme-on-background), var(--v-medium-emphasis-opacity));
// hide the input when your picker is inline
input[altinputclass="inlinePicker"] {
display: none;
}
.flatpickr-calendar {
border-radius: 10px;
background-color: rgb(var(--v-theme-surface));
margin-block-start: 0.1875rem;
@include mixins_elevation.elevation(6);
&.hasTime .flatpickr-time {
border-block-start: none;
}
.numInputWrapper:hover {
background: transparent;
}
.flatpickr-rContainer {
.flatpickr-weekdays {
padding-inline: 0.625rem;
}
.flatpickr-days {
.dayContainer {
justify-content: center !important;
padding-block-end: 0.625rem;
padding-block-start: 0;
.flatpickr-day {
block-size: 2.625rem;
line-height: 2.625rem;
margin-block-start: 0 !important;
max-inline-size: 2.625rem;
}
}
}
}
.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));
}
&.inRange,
&.inRange:hover {
border: none;
background: rgba(var(--v-theme-primary), 0.1) !important;
box-shadow: none !important;
color: rgb(var(--v-theme-primary));
}
&.startRange {
box-shadow: none;
}
&.endRange {
box-shadow: none;
}
&.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: rgba(var(--v-theme-surface-variant), var(--v-hover-opacity));
background: rgba(var(--v-theme-surface-variant), var(--v-hover-opacity));
}
}
.flatpickr-weekday {
color: $heading-color;
font-size: 14px;
font-weight: 500;
}
&::after,
&::before {
display: none;
}
.flatpickr-months {
padding-block-start: 0.625rem;
.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;
}
}
}
// Time picker hover & focus bg color
.flatpickr-time input:hover,
.flatpickr-time .flatpickr-am-pm:hover,
.flatpickr-time input:focus,
.flatpickr-time .flatpickr-am-pm:focus {
background: transparent;
}
// Time picker
.flatpickr-time {
.flatpickr-am-pm,
.flatpickr-time-separator,
input {
color: $body-color;
}
.numInputWrapper {
span {
&.arrowUp {
&::after {
border-block-end-color: rgb(var(--v-border-color));
}
}
&.arrowDown {
&::after {
border-block-start-color: rgb(var(--v-border-color));
}
}
}
}
}
// Added bg color for flatpickr input only as it has default readonly attribute
.flatpickr-input[readonly],
.flatpickr-input ~ .form-control[readonly],
.flatpickr-human-friendly[readonly] {
background-color: inherit;
opacity: 1 !important;
}
// week sections
.flatpickr-weekdays {
margin-block-start: 8px;
}
// 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: 1rem;
font-weight: 400;
transition: all 0.15s ease-out;
span {
display: none;
}
.cur-year {
font-weight: 400;
}
.flatpickr-monthDropdown-month {
background-color: rgb(var(--v-theme-surface));
}
}
}
.flatpickr-day.flatpickr-disabled,
.flatpickr-day.flatpickr-disabled:hover {
color: $body-color;
}
.flatpickr-months {
padding-block: 0.3rem;
padding-inline: 0;
.flatpickr-prev-month,
.flatpickr-next-month {
inset-block-start: 0.625rem !important;
}
.flatpickr-next-month {
inset-inline-end: 0.375rem !important;
}
.flatpickr-prev-month {
inset-inline-start: 0.25rem !important;
}
}
</style>
@@ -0,0 +1,29 @@
<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="px-5 py-3 d-flex align-center bg-var-theme-background">
<h3 class="font-weight-medium text-xl">
{{ props.title }}
</h3>
<VSpacer />
<slot name="beforeClose" />
<IconBtn @click="$emit('cancel')">
<VIcon
size="18"
icon="mdi-close"
/>
</IconBtn>
</div>
</template>
@@ -0,0 +1,88 @@
<script setup lang="ts">
interface Props {
totalInput?: number
default?: string
}
interface Emit {
(e: 'updateOtp', val: string): void
}
const props = withDefaults(defineProps<Props>(), {
totalInput: 6,
default: '',
})
const emit = defineEmits<Emit>()
const digits = ref<string[]>([])
const refOtpComp = ref<HTMLInputElement | null>(null)
digits.value = props.default.split('')
const defaultStyle = {
style: 'max-width: 54px; text-align: center;',
}
// eslint-disable-next-line sonarjs/cognitive-complexity
const handleKeyDown = (event: KeyboardEvent, index: number) => {
if (event.code !== 'Tab' && event.code !== 'ArrowRight' && event.code !== 'ArrowLeft')
event.preventDefault()
if (event.code === 'Backspace') {
digits.value[index - 1] = ''
if (refOtpComp.value !== null && index > 1) {
const inputEl = refOtpComp.value.children[index - 2].querySelector('input')
if (inputEl)
inputEl.focus()
}
}
const numberRegExp = /^([0-9])$/
if (numberRegExp.test(event.key)) {
digits.value[index - 1] = event.key
if (refOtpComp.value !== null && index !== 0 && index < refOtpComp.value.children.length) {
const inputEl = refOtpComp.value.children[index].querySelector('input')
if (inputEl)
inputEl.focus()
}
}
emit('updateOtp', digits.value.join(''))
}
</script>
<template>
<div>
<h6 class="text-base font-weight-bold mb-3">
Type your 6 digit security code
</h6>
<div
ref="refOtpComp"
class="d-flex align-center gap-4"
>
<VTextField
v-for="i in props.totalInput"
:key="i"
:model-value="digits[i - 1]"
v-bind="defaultStyle"
maxlength="1"
@keydown="handleKeyDown($event, i)"
/>
</div>
</div>
</template>
<style lang="scss">
.v-field__field {
input {
padding: 0.5rem;
font-size: 1.25rem;
text-align: center;
}
}
</style>
@@ -0,0 +1,230 @@
<script setup lang="ts">
import pricingIllustration1 from '@images/misc/pricing-illustration-1.png'
import pricingIllustration2 from '@images/misc/pricing-illustration-2.png'
import pricingIllustration3 from '@images/misc/pricing-illustration-3.png'
interface Pricing {
title?: string
xs?: number | string
sm?: number | string
md?: string | number
lg?: string | number
xl?: string | number
}
const props = defineProps<Pricing>()
const annualMonthlyPlanPriceToggler = ref(true)
const pricingPlans = [
{
name: 'Basic',
tagLine: 'A simple start for everyone',
logo: pricingIllustration1,
monthlyPrice: 0,
yearlyPrice: 0,
isPopular: false,
current: true,
features: [
'100 responses a month',
'Unlimited forms and surveys',
'Unlimited fields',
'Basic form creation tools',
'Up to 2 subdomains',
],
},
{
name: 'Standard',
tagLine: 'For small to medium businesses',
logo: pricingIllustration2,
monthlyPrice: 49,
yearlyPrice: 480,
isPopular: true,
current: false,
features: [
'Unlimited responses',
'Unlimited forms and surveys',
'Instagram profile page',
'Google Docs integration',
'Custom “Thank you” page',
],
},
{
name: 'Enterprise',
tagLine: 'Solution for big organizations',
logo: pricingIllustration3,
monthlyPrice: 99,
yearlyPrice: 960,
isPopular: false,
current: false,
features: [
'PayPal payments',
'Logic Jumps',
'File upload with 5GB storage',
'Custom domain support',
'Stripe integration',
],
},
]
</script>
<template>
<!-- 👉 Title and subtitle -->
<div class="text-center">
<h4 class="pricing-title text-h4 mb-4">
{{ props.title ? props.title : 'Pricing Plans' }}
</h4>
<p class="text-sm mb-0">
All plans include 40+ advanced tools and features to boost your product.
</p>
<p class="text-sm mb-0">
Choose the best plan to fit your needs.
</p>
</div>
<!-- 👉 Annual and monthly price toggler -->
<div class="d-flex align-center justify-center mx-auto py-10">
<VLabel
for="pricing-plan-toggle"
class="me-2"
>
Monthly
</VLabel>
<div class="position-relative">
<div class="position-absolute price-chip d-none d-sm-flex">
<VIcon
icon="mdi-arrow-down-left"
size="24"
class="text-disabled flip-in-rtl mt-1 me-1"
/>
<VChip
text="Save up to 10%"
size="x-small"
color="primary"
/>
</div>
<VSwitch
id="pricing-plan-toggle"
v-model="annualMonthlyPlanPriceToggler"
label="Annual"
/>
</div>
</div>
<!-- SECTION pricing plans -->
<VRow>
<VCol
v-for="plan in pricingPlans"
:key="plan.logo"
v-bind="props"
cols="12"
>
<!-- 👉 Card -->
<VCard
flat
border
:class="plan.isPopular ? 'border-primary border-opacity-100' : ''"
>
<VCardText
style="height: 4.125rem;"
class="text-end"
>
<!-- 👉 Popular -->
<VChip
v-show="plan.isPopular"
color="primary"
size="small"
class="font-weight-semibold"
>
Popular
</VChip>
</VCardText>
<!-- 👉 Plan logo -->
<VCardText class="text-center">
<VImg
:height="100"
:src="plan.logo"
class="mx-auto mb-5"
/>
<!-- 👉 Plan name -->
<h5 class="text-h5 mb-2">
{{ plan.name }}
</h5>
<p class="mb-0">
{{ plan.tagLine }}
</p>
</VCardText>
<!-- 👉 Plan price -->
<VCardText class="position-relative text-center">
<div class="d-flex justify-center align-center">
<sup class="text-sm font-weight-medium me-1">$</sup>
<h1 class="text-5xl font-weight-medium text-primary">
{{ annualMonthlyPlanPriceToggler ? Math.floor(Number(plan.yearlyPrice) / 12) : plan.monthlyPrice }}
</h1>
<sub class="text-sm font-weight-medium ms-1 mt-4">/month</sub>
</div>
<!-- 👉 Annual Price -->
<span
v-show="annualMonthlyPlanPriceToggler"
class="position-absolute text-caption font-weight-medium"
style="inset-inline: 0;"
>
{{ plan.yearlyPrice === 0 ? 'free' : `USD ${plan.yearlyPrice}/Year` }}
</span>
</VCardText>
<!-- 👉 Plan features -->
<VCardText class="mt-2">
<VList class="card-list mb-5">
<VListItem
v-for="feature in plan.features"
:key="feature"
>
<template #prepend>
<VIcon
:size="14"
icon="mdi-circle-outline"
class="me-3"
/>
</template>
<VListItemTitle class="text-body-2">
{{ feature }}
</VListItemTitle>
</VListItem>
</VList>
<VBtn
block
:color="plan.current ? 'success' : 'primary'"
:variant="plan.isPopular ? 'elevated' : 'tonal'"
>
{{ plan.yearlyPrice === 0 ? 'Your Current Plan' : 'Upgrade' }}
</VBtn>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- !SECTION -->
</template>
<style lang="scss" scoped>
.price-chip {
display: flex;
inset-block-start: -1.5rem;
inset-inline-end: -6.5rem;
// inset-inline-start: 2rem;
}
.card-list {
--v-card-list-gap: 0.75rem;
}
</style>
@@ -0,0 +1,77 @@
<script setup lang="ts">
import AppSearchHeaderBgDark from '@images/pages/app-search-header-bg-dark.png'
import AppSearchHeaderBgLight from '@images/pages/app-search-header-bg-light.png'
import { useGenerateImageVariant } from '@core/composable/useGenerateImageVariant'
interface Props {
title?: string
subtitle?: string
customClass?: string
}
const props = defineProps<Props>()
defineOptions({
inheritAttrs: false,
})
const themeBackgroundImg = useGenerateImageVariant(AppSearchHeaderBgLight, AppSearchHeaderBgDark)
</script>
<template>
<!-- 👉 Search Banner -->
<VCard
flat
class="text-center search-header"
:class="props.customClass"
:style="`background: url(${themeBackgroundImg});`"
>
<VCardText>
<h5 class="text-h5 font-weight-semibold text-primary mb-2">
{{ props.title }}
</h5>
<p>{{ props.subtitle }}</p>
<!-- 👉 Search Input -->
<VTextField
v-bind="$attrs"
placeholder="Ask a question.."
class="search-header-input mx-auto my-6"
>
<template #prepend-inner>
<VIcon
icon="mdi-magnify"
size="23"
/>
</template>
</VTextField>
</VCardText>
</VCard>
</template>
<style lang="scss">
.search-header {
padding: 4rem !important;
background-size: cover !important;
}
// search input
.search-header-input {
border-radius: 0.5rem !important;
background-color: rgb(var(--v-theme-surface));
max-inline-size: 32.125rem;
.v-field__prepend-inner {
i {
inset-block-start: 3px !important;
}
}
}
@media (max-width: 37.5rem) {
.search-header {
padding: 1.5rem !important;
}
}
</style>
@@ -0,0 +1,40 @@
<script lang="ts" setup>
const vm = getCurrentInstance()
const buyNowUrl = ref(vm?.appContext.config.globalProperties.buyNowUrl || 'https://1.envato.market/materialize_admin')
watch(buyNowUrl, val => {
if (vm)
vm.appContext.config.globalProperties.buyNowUrl = val
})
</script>
<template>
<VBtn
color="error"
class="product-buy-now"
:href="buyNowUrl"
target="_blank"
rel="noopener noreferrer"
>
Buy Now
</VBtn>
</template>
<style lang="scss" scoped>
.product-buy-now {
position: fixed;
// To keep buy now button on top of v-layout. E.g. Email app
z-index: 999;
inset-block-end: 5%;
inset-inline-end: 79px;
.v-application &.v-btn.v-btn--elevated {
box-shadow: 0 1px 20px 1px rgb(var(--v-theme-error)) !important;
&:hover {
box-shadow: none !important;
}
}
}
</style>
@@ -0,0 +1,147 @@
<script setup lang="ts">
interface Details {
number: string
name: string
expiry: string
cvv: string
isPrimary: boolean
type: string
}
interface Emit {
(e: 'submit', value: Details): void
(e: 'update:isDialogVisible', value: boolean): void
}
interface Props {
cardDetails?: Details
isDialogVisible: boolean
}
const props = withDefaults(defineProps<Props>(), {
cardDetails: () => ({
number: '',
name: '',
expiry: '',
cvv: '',
isPrimary: false,
type: '',
}),
})
const emit = defineEmits<Emit>()
const cardDetails = ref<Details>(structuredClone(toRaw(props.cardDetails)))
watch(props, () => {
cardDetails.value = structuredClone(toRaw(props.cardDetails))
})
const formSubmit = () => {
emit('submit', cardDetails.value)
}
</script>
<template>
<VDialog
:width="$vuetify.display.smAndDown ? 'auto' : 650 "
:model-value="props.isDialogVisible"
@update:model-value="val => $emit('update:isDialogVisible', val)"
>
<VCard class="pa-5 pa-sm-8">
<!-- 👉 dialog close btn -->
<DialogCloseBtn
variant="text"
size="small"
@click="$emit('update:isDialogVisible', false)"
/>
<!-- 👉 Title -->
<VCardItem class="text-center">
<VCardTitle class="text-2xl mb-3">
{{ props.cardDetails.name ? 'Edit Card' : 'Add New New Card' }}
</VCardTitle>
<VCardSubtitle>
{{ props.cardDetails.name ? 'Edit your saved card details' : 'Add your saved card details' }}
</VCardSubtitle>
</VCardItem>
<VCardText class="mt-6">
<VForm @submit.prevent="() => {}">
<VRow>
<!-- 👉 Card Number -->
<VCol cols="12">
<VTextField
v-model="cardDetails.number"
label="Card Number"
type="number"
/>
</VCol>
<!-- 👉 Card Name -->
<VCol
cols="12"
md="6"
>
<VTextField
v-model="cardDetails.name"
label="Name"
/>
</VCol>
<!-- 👉 Card Expiry -->
<VCol
cols="6"
md="3"
>
<VTextField
v-model="cardDetails.expiry"
label="Expiry Date"
/>
</VCol>
<!-- 👉 Card CVV -->
<VCol
cols="6"
md="3"
>
<VTextField
v-model="cardDetails.cvv"
type="password"
label="CVV Code"
/>
</VCol>
<!-- 👉 Card Primary Set -->
<VCol cols="12">
<VSwitch
v-model="cardDetails.isPrimary"
label="Save Card for future billing?"
/>
</VCol>
<!-- 👉 Card actions -->
<VCol
cols="12"
class="text-center"
>
<VBtn
class="me-4"
type="submit"
@click="formSubmit"
>
Submit
</VBtn>
<VBtn
color="secondary"
variant="tonal"
@click="$emit('update:isDialogVisible', false)"
>
Cancel
</VBtn>
</VCol>
</VRow>
</VForm>
</VCardText>
</VCard>
</VDialog>
</template>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { kFormatter } from '@core/utils/formatters'
interface Props {
title: string
color?: string
icon: string
stats: number
change: number
}
const props = withDefaults(defineProps<Props>(), {
color: 'primary',
})
const isPositive = controlledComputed(() => props.change, () => Math.sign(props.change) === 1)
</script>
<template>
<VCard>
<VCardText class="d-flex align-center">
<VAvatar
size="40"
rounded
:color="props.color"
variant="tonal"
class="me-4"
>
<VIcon
:icon="props.icon"
size="24"
/>
</VAvatar>
<div class="d-flex flex-column">
<div class="d-flex align-center flex-wrap">
<h6 class="text-h6">
{{ kFormatter(props.stats) }}
</h6>
<div
v-if="props.change"
:class="`${isPositive ? 'text-success' : 'text-error'}`"
>
<VIcon
size="24"
:icon="isPositive ? 'mdi-chevron-up' : 'mdi-chevron-down'"
/>
<span class="text-caption">{{ Math.abs(props.change) }}%</span>
</div>
</div>
<span class="text-caption">{{ props.title }}</span>
</div>
</VCardText>
</VCard>
</template>
@@ -0,0 +1,63 @@
<script setup lang="ts">
interface Props {
title: string
color?: string
icon: string
stats: string
change: number
subtitle: string
}
const props = withDefaults(defineProps<Props>(), {
color: 'primary',
})
const isPositive = controlledComputed(() => props.change, () => Math.sign(props.change) === 1)
</script>
<template>
<VCard>
<VCardText class="d-flex align-center">
<VAvatar
v-if="props.icon"
rounded
size="38"
variant="tonal"
:color="props.color"
>
<VIcon
:icon="props.icon"
size="24"
/>
</VAvatar>
<VSpacer />
<div
v-if="props.change"
:class="isPositive ? 'text-success' : 'text-error'"
class="d-flex align-center text-sm font-weight-medium mt-n4"
>
<span>{{ isPositive ? `+${props.change}` : props.change }}%</span>
<VIcon :icon="isPositive ? 'mdi-chevron-up' : 'mdi-chevron-down'" />
</div>
</VCardText>
<VCardText>
<h6 class="text-h6 me-2 mt-2 mb-1">
{{ props.stats }}
</h6>
<p class="text-sm">
{{ props.title }}
</p>
<VChip
size="x-small"
class="font-weight-medium"
>
<span class="text-truncate">{{ props.subtitle }}</span>
</VChip>
</VCardText>
</VCard>
</template>
@@ -0,0 +1,65 @@
<script setup lang="ts">
interface Props {
title: string
subtitle: string
stats: string
change: number
image: string
imgWidth: number
color?: string
}
const props = withDefaults(defineProps<Props>(), {
color: 'primary',
})
const isPositive = controlledComputed(() => props.change, () => Math.sign(props.change) === 1)
</script>
<template>
<VCard>
<VCardText>
<h6 class="text-base font-weight-semibold mb-2">
{{ props.title }}
</h6>
<VChip
v-if="props.subtitle"
size="x-small"
:color="props.color"
class="mb-5"
>
{{ props.subtitle }}
</VChip>
<div class="d-flex align-center flex-wrap">
<h5 class="text-h5 me-2">
{{ props.stats }}
</h5>
<span
class="text-caption"
:class="isPositive ? 'text-success' : 'text-error'"
>
{{ isPositive ? `+${props.change}` : props.change }}%
</span>
</div>
</VCardText>
<VSpacer />
<div class="illustrator-img">
<VImg
v-if="props.image"
:src="props.image"
:width="props.imgWidth"
/>
</div>
</VCard>
</template>
<style lang="scss">
.illustrator-img {
position: absolute;
inset-block-end: 0;
inset-inline-end: 5%;
}
</style>
@@ -0,0 +1,73 @@
<script setup lang="ts">
interface Props {
confirmationMsg: string
isDialogVisible: boolean
}
interface Emit {
(e: 'update:isDialogVisible', value: boolean): void
(e: 'confirm', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emit>()
const updateModelValue = (val: boolean) => {
emit('update:isDialogVisible', val)
}
const onConfirmation = () => {
emit('confirm', true)
updateModelValue(false)
}
const onCancel = () => {
emit('confirm', false)
emit('update:isDialogVisible', false)
}
</script>
<template>
<!-- 👉 Confirm Dialog -->
<VDialog
max-width="500"
:model-value="props.isDialogVisible"
@update:model-value="updateModelValue"
>
<VCard class="text-center px-10 py-6">
<VCardText>
<VBtn
icon
variant="outlined"
color="warning"
class="mb-4"
style="width: 88px; height: 88px; pointer-events: none;"
>
<span class="text-5xl">!</span>
</VBtn>
<h6 class="text-lg font-weight-medium">
{{ props.confirmationMsg }}
</h6>
</VCardText>
<VCardActions class="align-center justify-center gap-2">
<VBtn
variant="elevated"
@click="onConfirmation"
>
Confirm
</VBtn>
<VBtn
color="secondary"
variant="tonal"
@click="onCancel"
>
Cancel
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
@@ -0,0 +1,75 @@
<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 selectedOption = ref(structuredClone(toRaw(props.selectedCheckbox)))
watch(selectedOption, () => {
emit('update:selectedCheckbox', selectedOption.value)
})
</script>
<template>
<VRow
v-if="props.checkboxContent && selectedOption"
v-model="selectedOption"
>
<VCol
v-for="item in props.checkboxContent"
:key="item.title"
v-bind="gridColumn"
>
<VLabel
class="custom-input custom-checkbox rounded cursor-pointer"
:class="selectedOption.includes(item.value) ? 'active' : ''"
>
<div>
<VCheckbox
v-model="selectedOption"
:value="item.value"
/>
</div>
<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">{{ item.subtitle }}</span>
</div>
<p class="text-sm mb-0">
{{ item.desc }}
</p>
</div>
</VLabel>
</VCol>
</VRow>
</template>
<style lang="scss" scoped>
.custom-checkbox {
display: flex;
align-items: flex-start;
gap: 0.375rem;
.v-checkbox {
margin-block-start: -0.375rem;
}
.cr-title {
font-weight: 500;
}
}
</style>
@@ -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 selectedOption = ref(structuredClone(toRaw(props.selectedCheckbox)))
watch(selectedOption, () => {
emit('update:selectedCheckbox', selectedOption.value)
})
</script>
<template>
<VRow
v-if="props.checkboxContent && selectedOption"
v-model="selectedOption"
>
<VCol
v-for="item in props.checkboxContent"
:key="item.title"
v-bind="gridColumn"
>
<VLabel
class="custom-input custom-checkbox rounded cursor-pointer"
:class="selectedOption.includes(item.value) ? 'active' : ''"
>
<div class="d-flex flex-column align-center text-center gap-2">
<VIcon
size="32"
:icon="item.icon"
class="text-high-emphasis"
/>
<h6 class="cr-title text-base">
{{ item.title }}
</h6>
<p class="text-sm mb-0">
{{ item.desc }}
</p>
</div>
<div>
<VCheckbox
v-model="selectedOption"
:value="item.value"
/>
</div>
</VLabel>
</VCol>
</VRow>
</template>
<style lang="scss" scoped>
.custom-checkbox {
display: flex;
flex-direction: column;
gap: 0.375rem;
.v-checkbox {
margin-block-end: -0.375rem;
}
.cr-title {
font-weight: 500;
}
}
</style>
@@ -0,0 +1,80 @@
<script lang="ts" setup>
import type { GridColumn } from '@core/types'
interface Props {
selectedCheckbox: string[]
checkboxContent: { bgImage: string; value: string }[]
gridColumn?: GridColumn
}
interface Emit {
(e: 'update:selectedCheckbox', value: string[]): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emit>()
const selectedOption = ref(structuredClone(toRaw(props.selectedCheckbox)))
watch(selectedOption, () => {
emit('update:selectedCheckbox', selectedOption.value)
})
</script>
<template>
<VRow
v-if="props.checkboxContent && selectedOption"
v-model="selectedOption"
>
<VCol
v-for="item in props.checkboxContent"
:key="item.value"
v-bind="gridColumn"
>
<VLabel
class="custom-input custom-checkbox rounded cursor-pointer"
:class="selectedOption.includes(item.value) ? 'active' : ''"
>
<div>
<VCheckbox
v-model="selectedOption"
:value="item.value"
/>
</div>
<img
:src="item.bgImage"
alt="bg-img"
class="custom-checkbox-image"
>
</VLabel>
</VCol>
</VRow>
</template>
<style lang="scss" scoped>
.custom-checkbox {
position: relative;
padding: 0;
border-width: 2px;
transition: all 0.5s;
.custom-checkbox-image {
block-size: 100%;
inline-size: 100%;
}
.v-checkbox {
position: absolute;
inset-block-start: 0;
inset-inline-end: 0;
visibility: hidden;
}
&:hover,
&.active {
.v-checkbox {
visibility: visible;
}
}
}
</style>
@@ -0,0 +1,74 @@
<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 selectedOption = ref(structuredClone(toRaw(props.selectedRadio)))
watch(selectedOption, () => {
emit('update:selectedRadio', selectedOption.value)
})
</script>
<template>
<VRadioGroup
v-if="props.radioContent"
v-model="selectedOption"
>
<VRow>
<VCol
v-for="item in props.radioContent"
:key="item.title"
v-bind="gridColumn"
>
<VLabel
class="custom-input custom-radio rounded cursor-pointer"
:class="selectedOption === item.value ? 'active' : ''"
>
<div>
<VRadio :value="item.value" />
</div>
<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">{{ item.subtitle }}</span>
</div>
<p class="text-sm mb-0">
{{ item.desc }}
</p>
</div>
</VLabel>
</VCol>
</VRow>
</VRadioGroup>
</template>
<style lang="scss" scoped>
.custom-radio {
display: flex;
align-items: flex-start;
gap: 0.375rem;
.v-radio {
margin-block-start: -0.25rem;
}
.cr-title {
font-weight: 500;
}
}
</style>
@@ -0,0 +1,77 @@
<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 selectedOption = ref(structuredClone(toRaw(props.selectedRadio)))
watch(selectedOption, () => {
emit('update:selectedRadio', selectedOption.value)
})
</script>
<template>
<VRadioGroup
v-if="props.radioContent"
v-model="selectedOption"
>
<VRow>
<VCol
v-for="item in props.radioContent"
:key="item.title"
v-bind="gridColumn"
>
<VLabel
class="custom-input custom-radio rounded cursor-pointer"
:class="selectedOption === item.value ? 'active' : ''"
>
<div class="d-flex flex-column align-center text-center gap-2">
<VIcon
size="32"
:icon="item.icon"
class="text-high-emphasis"
/>
<h6 class="cr-title text-base">
{{ item.title }}
</h6>
<p class="text-sm mb-0">
{{ item.desc }}
</p>
</div>
<div>
<VRadio :value="item.value" />
</div>
</VLabel>
</VCol>
</VRow>
</VRadioGroup>
</template>
<style lang="scss" scoped>
.custom-radio {
display: flex;
flex-direction: column;
gap: 0.375rem;
.v-radio {
margin-block-end: -0.25rem;
}
.cr-title {
font-weight: 500;
}
}
</style>
@@ -0,0 +1,65 @@
<script lang="ts" setup>
import type { GridColumn } from '@core/types'
interface Props {
selectedRadio: string
radioContent: { bgImage: string; value: string }[]
gridColumn?: GridColumn
}
interface Emit {
(e: 'update:selectedRadio', value: string): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emit>()
const selectedOption = ref(structuredClone(toRaw(props.selectedRadio)))
watch(selectedOption, () => {
emit('update:selectedRadio', selectedOption.value)
})
</script>
<template>
<VRadioGroup
v-if="props.radioContent"
v-model="selectedOption"
>
<VRow>
<VCol
v-for="item in props.radioContent"
:key="item.bgImage"
v-bind="gridColumn"
>
<VLabel
class="custom-input custom-radio rounded cursor-pointer"
:class="selectedOption === item.value ? 'active' : ''"
>
<img
:src="item.bgImage"
alt="bg-img"
class="custom-radio-image"
>
<VRadio :value="item.value" />
</VLabel>
</VCol>
</VRow>
</VRadioGroup>
</template>
<style lang="scss" scoped>
.custom-radio {
padding: 0;
border-width: 2px;
.custom-radio-image {
block-size: 100%;
inline-size: 100%;
}
.v-radio {
visibility: hidden;
}
}
</style>
@@ -0,0 +1,22 @@
<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">
<p class="text-caption">
{{ props.title }}
</p>
<slot />
</div>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
interface Props {
icon?: string
iconSize?: string
}
const props = withDefaults(defineProps<Props>(), {
icon: 'mdi-close',
iconSize: '22',
})
</script>
<template>
<IconBtn class="v-dialog-close-btn">
<VIcon
:icon="props.icon"
:size="props.iconSize"
/>
</IconBtn>
</template>
@@ -0,0 +1,79 @@
<script setup lang="ts">
interface Emit {
(e: 'update:isDialogVisible', value: boolean): void
(e: 'submit', value: string): void
}
interface Props {
mobileNumber?: string
isDialogVisible: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<Emit>()
const phoneNumber = ref(structuredClone(toRaw(props.mobileNumber)))
const formSubmit = () => {
if (phoneNumber.value) {
emit('submit', phoneNumber.value)
emit('update:isDialogVisible', false)
}
}
const resetPhoneNumber = () => {
phoneNumber.value = structuredClone(toRaw(props.mobileNumber))
emit('update:isDialogVisible', false)
}
</script>
<template>
<VDialog
max-width="600"
:model-value="props.isDialogVisible"
@update:model-value="(val) => $emit('update:isDialogVisible', val)"
>
<VCard
title="Verify Your Mobile Number for SMS"
subtitle="Enter your mobile phone number with country code and we will send you a verification code."
class="pa-5 pa-sm-8"
>
<!-- 👉 dialog close btn -->
<DialogCloseBtn
variant="text"
size="small"
@click="resetPhoneNumber"
/>
<VCardText>
<VForm @submit.prevent="() => {}">
<VTextField
v-model="phoneNumber"
name="mobile"
label="Phone Number"
class="mb-4"
/>
<div class="d-flex flex-wrap justify-end gap-3">
<VBtn
color="secondary"
variant="tonal"
@click="resetPhoneNumber"
>
Cancel
</VBtn>
<VBtn
type="submit"
@click="formSubmit"
>
continue
<VIcon
end
icon="mdi-chevron-right"
class="flip-in-rtl"
/>
</VBtn>
</div>
</VForm>
</VCardText>
</VCard>
</VDialog>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
interface Props {
errorCode: string
errorTitle: string
errorDescription: string
}
const props = defineProps<Props>()
</script>
<template>
<div class="text-center mb-4">
<!-- 👉 Title and subtitle -->
<h1 class="text-h1">
{{ props.errorCode }}
</h1>
<h5 class="text-h5 font-weight-semibold mb-3">
{{ props.errorTitle }}
</h5>
<p>{{ props.errorDescription }}</p>
</div>
</template>
@@ -0,0 +1,56 @@
<script setup lang="ts">
import type { Anchor } from 'vuetify/lib/components'
import type { I18nLanguage } from '@layouts/types'
const props = withDefaults(defineProps<Props>(), {
location: 'bottom end',
})
defineEmits<{
(e: 'change', id: string): void
}>()
interface Props {
languages: I18nLanguage[]
location?: Anchor
}
const { locale } = useI18n({ useScope: 'global' })
watch(locale, val => {
document.documentElement.setAttribute('lang', val as string)
})
const currentLang = ref([localStorage.getItem('lang')|| 'en'])
</script>
<template>
<IconBtn>
<VIcon icon="mdi-translate" />
<!-- Menu -->
<VMenu
activator="parent"
:location="props.location"
offset="14px"
>
<!-- List -->
<VList
v-model:selected="currentLang"
active-color="primary"
min-width="175px"
>
<!-- List item -->
<VListItem
v-for="lang in props.languages"
:key="lang.i18nLang"
:value="lang.i18nLang"
@click="locale = lang.i18nLang; $emit('change', lang.i18nLang)"
>
<!-- Language label -->
<VListItemTitle>{{ lang.label }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
@@ -0,0 +1,27 @@
<script lang="ts" setup>
import type { ItemProps } from 'vuetify/composables/item'
interface Props {
menuList?: ItemProps[]
itemProps?: boolean
}
const props = defineProps<Props>()
</script>
<template>
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu
v-if="props.menuList"
activator="parent"
>
<VList
density="compact"
:items="props.menuList"
:item-props="props.itemProps"
/>
</VMenu>
</IconBtn>
</template>
@@ -0,0 +1,196 @@
<script lang="ts" setup>
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
import type { Anchor } from 'vuetify/lib/components'
import { avatarText } from '@core/utils/formatters'
import type { Notification } from '@layouts/types'
interface Props {
notifications: Notification[]
badgeProps?: unknown
location?: Anchor
}
interface Emit {
(e: 'read', value: number[]): void
(e: 'unread', value: number[]): void
(e: 'remove', value: number): void
(e: 'click:notification', value: Notification): void
}
const props = withDefaults(defineProps<Props>(), {
location: 'bottom end',
badgeProps: undefined,
})
const emit = defineEmits<Emit>()
const isAllMarkRead = computed(() => {
return props.notifications.some(item => item.isRead === true)
})
const markAllReadOrUnread = () => {
const allNotificationsIds = props.notifications.map(item => item.id)
if (isAllMarkRead.value)
emit('unread', allNotificationsIds)
else
emit('read', allNotificationsIds)
}
</script>
<template>
<VBadge
:model-value="!!props.badgeProps"
v-bind="props.badgeProps"
>
<IconBtn>
<VBadge
dot
:model-value="!!props.notifications.length"
color="error"
bordered
offset-x="1"
offset-y="1"
>
<VIcon icon="mdi-bell-outline" />
</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 ? 'mdi-email-open-outline' : 'mdi-email-outline' " />
<VTooltip
activator="parent"
location="start"
>
{{ isAllMarkRead ? 'Mark all as read' : 'Mark all as unread' }}
</VTooltip>
</IconBtn>
</template>
</VCardItem>
<VDivider />
<!-- 👉 Notifications list -->
<PerfectScrollbar :options="{ wheelPropagation: false }">
<VList class="py-0">
<template
v-for="notification in props.notifications"
:key="notification.title"
>
<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
:color="notification.color || 'primary'"
:image="notification.img || undefined"
:icon="notification.icon || undefined"
size="40"
variant="tonal"
>
<span v-if="notification.text">{{ avatarText(notification.text) }}</span>
</VAvatar>
</VListItemAction>
</template>
<VListItemTitle>{{ notification.title }}</VListItemTitle>
<VListItemSubtitle>{{ notification.subtitle }}</VListItemSubtitle>
<span class="text-xs text-disabled">{{ notification.time }}</span>
<!-- Slot: Append -->
<template #append>
<div class="d-flex flex-column align-center gap-4">
<VBadge
dot
:color="notification.isRead ? 'primary' : '#a8aaae'"
:class="`${!notification.isRead ? 'visible-in-hover' : ''} ms-1`"
@click.stop="$emit(notification.isRead ? 'unread' : 'read', [notification.id])"
/>
<div style=" width: 28px;height: 28px;">
<IconBtn
size="x-small"
class="visible-in-hover"
@click="$emit('remove', notification.id)"
>
<VIcon
size="20"
icon="mdi-close"
/>
</IconBtn>
</div>
</div>
</template>
</VListItem>
<VDivider />
</template>
<VListItem
v-show="!props.notifications.length"
class="text-center text-medium-emphasis"
>
<VListItemTitle>No Notification Found!</VListItemTitle>
</VListItem>
</VList>
</PerfectScrollbar>
<!-- 👉 Footer -->
<VCardActions
v-show="props.notifications.length"
class="notification-footer"
>
<VBtn block>
VIEW ALL NOTIFICATIONS
</VBtn>
</VCardActions>
</VCard>
</VMenu>
</IconBtn>
</VBadge>
</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;
}
}
}
</style>
@@ -0,0 +1,55 @@
<script setup lang="ts">
interface Props {
isDialogVisible: boolean
}
interface Emit {
(e: 'update:isDialogVisible', val: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emit>()
const dialogVisibleUpdate = (val: boolean) => {
emit('update:isDialogVisible', val)
}
</script>
<template>
<VDialog
:model-value="props.isDialogVisible"
class="v-dialog-xl"
@update:model-value="dialogVisibleUpdate"
>
<VCard class="pricing-dialog pa-5 pa-sm-8">
<!-- 👉 dialog close btn -->
<DialogCloseBtn
variant="text"
size="small"
@click="emit('update:isDialogVisible', false)"
/>
<VCardText>
<AppPricing
title="Subscription Plan"
lg="4"
/>
</VCardText>
<VCardText class="text-center">
<p class="text-sm">
Still Not Convinced? Start with a 14-day FREE trial!
</p>
<VBtn>Start Your Trial</VBtn>
</VCardText>
</VCard>
</VDialog>
</template>
<style lang="scss">
.pricing-dialog {
.pricing-title {
font-size: 1.5rem;
}
}
</style>
@@ -0,0 +1,185 @@
<script setup lang="ts">
interface Props {
isDialogVisible: boolean
}
interface Emit {
(e: 'update:isDialogVisible', val: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emit>()
const dialogVisibleUpdate = (val: boolean) => {
emit('update:isDialogVisible', val)
}
const referAndEarnSteps = [
{
icon: 'mdi-message-outline',
title: 'Send Invitation 👍🏻',
subtitle: 'Send your referral link to your friend',
},
{
icon: 'mdi-clipboard-outline',
title: 'Registration 😎',
subtitle: 'Let them register to our services',
},
{
icon: 'mdi-medal-outline',
title: 'Free Trial 🎉',
subtitle: 'Your friend will get 30 days free trial',
},
]
</script>
<template>
<VDialog
:model-value="props.isDialogVisible"
max-width="900"
@update:model-value="dialogVisibleUpdate"
>
<VCard class="refer-and-earn-dialog">
<!-- 👉 dialog close btn -->
<DialogCloseBtn
variant="text"
size="small"
@click="emit('update:isDialogVisible', false)"
/>
<VCardText class="pa-5 pa-sm-10">
<h5 class="text-h5 text-center mb-3">
Refer & Earn
</h5>
<p class="text-sm-body-1 text-center">
Invite your friend to vuexy, if they sign up, you and your friend will get 30 days free trial
</p>
<VRow class="text-center mt-6">
<VCol
v-for="step in referAndEarnSteps"
:key="step.title"
cols="12"
sm="4"
>
<VAvatar
variant="tonal"
size="100"
color="primary"
>
<VIcon
size="40"
:icon="step.icon"
/>
</VAvatar>
<h6 class="text-base mt-2 mb-3">
{{ step.title }}
</h6>
<span class="text-sm">{{ step.subtitle }}</span>
</VCol>
</VRow>
</VCardText>
<VDivider />
<VCardText class="pa-5 pa-sm-10">
<h6 class="text-h6 mb-4">
Invite your friends
</h6>
<p class="mb-3 text-sm">
Enter your friend's email address and invite them to join Materio 😍
</p>
<VForm
class="d-flex align-center gap-4"
@submit.prevent="() => {}"
>
<VTextField
density="compact"
placeholder="johnDoe@gmail.com"
/>
<VBtn type="submit">
Submit
</VBtn>
</VForm>
<h6 class="text-h6 mb-4 mt-8">
Share the referral link
</h6>
<p class="mb-2 text-sm">
You can also copy and send it or share it on your social media. 🚀
</p>
<VForm
class="d-flex align-center flex-wrap gap-3"
@submit.prevent="() => {}"
>
<VTextField
density="compact"
placeholder="http://referral.link"
class="refer-link-input me-1"
>
<template #append-inner>
<VBtn variant="text">
COPY LINK
</VBtn>
</template>
</VTextField>
<div class="d-flex gap-3">
<VBtn
icon
class="rounded"
color="#3B5998"
size="40"
>
<VIcon
color="white"
icon="mdi-facebook"
/>
</VBtn>
<VBtn
icon
class="rounded"
color="#55ACEE"
size="40"
>
<VIcon
color="white"
icon="mdi-twitter"
/>
</VBtn>
<VBtn
icon
class="rounded"
color="#007BB6"
size="40"
>
<VIcon
color="white"
icon="mdi-linkedin"
/>
</VBtn>
</div>
</VForm>
</VCardText>
</VCard>
</VDialog>
</template>
<style lang="scss">
.refer-link-input {
.v-field--appended {
padding-inline-end: 0;
}
.v-field__append-inner {
padding-block-start: 0.125rem;
}
}
</style>
@@ -0,0 +1,203 @@
<script setup lang="ts">
import avatar1 from '@images/avatars/avatar-1.png'
import avatar2 from '@images/avatars/avatar-2.png'
import avatar3 from '@images/avatars/avatar-3.png'
import avatar4 from '@images/avatars/avatar-4.png'
import avatar5 from '@images/avatars/avatar-5.png'
import avatar6 from '@images/avatars/avatar-6.png'
import avatar7 from '@images/avatars/avatar-7.png'
import avatar8 from '@images/avatars/avatar-8.png'
interface Props {
isDialogVisible: boolean
}
interface Emit {
(e: 'update:isDialogVisible', val: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emit>()
const dialogVisibleUpdate = (val: boolean) => {
emit('update:isDialogVisible', val)
}
type Permission = 'Owner' | 'Can Edit' | 'Can Comment' | 'Can View'
interface Member {
avatar: string
name: string
email: string
permission: Permission
}
const membersList: Member[] = [
{
avatar: avatar1,
name: 'Lester Palmer',
email: 'jerrod98@gmail.com',
permission: 'Can Edit',
},
{
avatar: avatar2,
name: 'Mattie Blair',
email: 'prudence.boehm@yahoo.com',
permission: 'Owner',
},
{
avatar: avatar3,
name: 'Marvin Wheeler',
email: 'rumet@jujpejah.net',
permission: 'Can Comment',
},
{
avatar: avatar4,
name: 'Nannie Ford',
email: 'negza@nuv.io',
permission: 'Can View',
},
{
avatar: avatar5,
name: 'Julian Murphy',
email: 'lunebame@umdomgu.net',
permission: 'Can Edit',
},
{
avatar: avatar6,
name: 'Sophie Gilbert',
email: 'ha@sugit.gov',
permission: 'Can View',
},
{
avatar: avatar7,
name: 'Chris Watkins',
email: 'zokap@mak.org',
permission: 'Can Comment',
},
{
avatar: avatar8,
name: 'Adelaide Nichols',
email: 'ujinomu@jigo.com',
permission: 'Can Edit',
},
]
</script>
<template>
<VDialog
:model-value="props.isDialogVisible"
max-width="900"
@update:model-value="dialogVisibleUpdate"
>
<VCard class="share-project-dialog pa-5 pa-sm-8">
<!-- 👉 dialog close btn -->
<DialogCloseBtn
size="small"
@click="emit('update:isDialogVisible', false)"
/>
<VCardText>
<h5 class="text-h5 text-center mb-3">
Share Project
</h5>
<p class="text-sm text-center">
Share project with a team members
</p>
<p class="text-xl font-weight-medium mb-2">
Add Members
</p>
<VAutocomplete
:items="membersList"
item-title="name"
item-value="name"
placeholder="Add project members..."
density="compact"
>
<template #item="{ props: listItemProp, item }">
<VListItem v-bind="listItemProp">
<template #prepend>
<VAvatar
:image="item.raw.avatar"
size="30"
/>
</template>
</VListItem>
</template>
</VAutocomplete>
<h6 class="text-h6 mb-4 mt-8">
8 Members
</h6>
<VList class="card-list">
<VListItem
v-for="member in membersList"
:key="member.name"
>
<template #prepend>
<VAvatar :image="member.avatar" />
</template>
<VListItemTitle class="text-sm">
{{ member.name }}
</VListItemTitle>
<VListItemSubtitle>
{{ member.email }}
</VListItemSubtitle>
<template #append>
<VBtn
variant="text"
color="default"
:icon="$vuetify.display.xs"
>
<span class="d-none d-sm-block">{{ member.permission }}</span>
<VIcon icon="mdi-chevron-down" />
<VMenu activator="parent">
<VList :selected="[member.permission]">
<VListItem
v-for="(item, index) in ['Owner', 'Can Edit', 'Can Comment', 'Can View']"
:key="index"
:value="item"
>
<VListItemTitle>{{ item }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</VBtn>
</template>
</VListItem>
</VList>
<div class="d-flex justify-space-between flex-wrap gap-3 mt-6">
<h6 class="text-sm font-weight-semibold d-flex align-start">
<VIcon
icon="mdi-account-group-outline"
class="me-2"
/>
<span>Public to Master - Pixinvent</span>
</h6>
<VBtn
variant="text"
prepend-icon="mdi-link"
>
Copy Project Link
</VBtn>
</div>
</VCardText>
</VCard>
</VDialog>
</template>
<style lang="scss">
.share-project-dialog {
.card-list {
--v-card-list-gap: 1rem;
}
}
</style>
@@ -0,0 +1,76 @@
<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: 'mdi-view-grid-plus-outline',
})
const router = useRouter()
</script>
<template>
<IconBtn>
<VIcon :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="mdi-view-grid-plus-outline" />
</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"
: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-semibold mt-2 mb-0">
{{ shortcut.title }}
</h6>
<span class="text-sm">{{ shortcut.subtitle }}</span>
</VCol>
</VRow>
</PerfectScrollbar>
</VCard>
</VMenu>
</IconBtn>
</template>
@@ -0,0 +1,376 @@
<script setup lang="tsx">
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
import { useTheme } from 'vuetify'
import { staticPrimaryColor } from '@/plugins/vuetify/theme'
import { useThemeConfig } from '@core/composable/useThemeConfig'
import { RouteTransitions, Skins } from '@core/enums'
import { AppContentLayoutNav, ContentWidth, FooterType, NavbarType } from '@layouts/enums'
import { themeConfig } from '@themeConfig'
// import { useTheme } from 'vuetify'
const isNavDrawerOpen = ref(false)
const {
theme,
skin,
appRouteTransition,
navbarType,
footerType,
isVerticalNavCollapsed,
isVerticalNavSemiDark,
appContentWidth,
appContentLayoutNav,
isAppRtl,
isNavbarBlurEnabled,
isLessThanOverlayNavBreakpoint,
} = useThemeConfig()
// 👉 Primary Color
const vuetifyTheme = useTheme()
// const vuetifyThemesName = Object.keys(vuetifyTheme.themes.value)
const initialThemeColors = JSON.parse(JSON.stringify(vuetifyTheme.current.value.colors))
const colors = ['primary', 'secondary', 'success', 'info', 'warning', 'error']
// ️ It will set primary color for current theme only
const setPrimaryColor = (color: string) => {
const currentThemeName = vuetifyTheme.name.value
vuetifyTheme.themes.value[currentThemeName].colors.primary = color
// ️ We need to store this color value in localStorage so vuetify plugin can pick on next reload
localStorage.setItem(`${themeConfig.app.title}-${currentThemeName}ThemePrimaryColor`, color)
// ️ Update initial loader color
localStorage.setItem(`${themeConfig.app.title}-initial-loader-color`, color)
}
/*
️ This will return static color for first indexed color
If we don't make first (primary) color as static then when another color is selected then we will have two theme colors with same hex codes and it will show two check marks
*/
const getBoxColor = (color: string, index: number) => index ? color : staticPrimaryColor
const { width: windowWidth } = useWindowSize()
const headerValues = computed(() => {
const entries = Object.entries(NavbarType)
if (appContentLayoutNav.value === AppContentLayoutNav.Horizontal)
return entries.filter(([_, val]) => val !== NavbarType.Hidden)
return entries
})
</script>
<template>
<template v-if="!isLessThanOverlayNavBreakpoint(windowWidth)">
<VBtn
icon
size="small"
class="app-customizer-toggler rounded-s rounded-0"
style="z-index: 1001;"
@click="isNavDrawerOpen = true"
>
<VIcon icon="mdi-cog" />
</VBtn>
<VNavigationDrawer
v-model="isNavDrawerOpen"
temporary
location="end"
width="400"
:scrim="false"
class="app-customizer"
>
<!-- 👉 Header -->
<div class="customizer-heading d-flex align-center justify-space-between">
<div>
<h6 class="text-h6">
THEME CUSTOMIZER
</h6>
<span class="text-body-1">Customize & Preview in Real Time</span>
</div>
<IconBtn @click="isNavDrawerOpen = false">
<VIcon
icon="mdi-close"
size="20"
/>
</IconBtn>
</div>
<VDivider />
<PerfectScrollbar
tag="ul"
:options="{ wheelPropagation: false }"
>
<!-- SECTION Theming -->
<CustomizerSection
title="THEMING"
:divider="false"
>
<!-- 👉 Skin -->
<h6 class="text-base font-weight-regular">
Skins
</h6>
<VRadioGroup
v-model="skin"
inline
>
<VRadio
v-for="[key, val] in Object.entries(Skins)"
:key="key"
:label="key"
:value="val"
/>
</VRadioGroup>
<!-- 👉 Theme -->
<h6 class="mt-3 text-base font-weight-regular">
Theme
</h6>
<VRadioGroup
v-model="theme"
inline
>
<VRadio
v-for="themeOption in ['system', 'light', 'dark']"
:key="themeOption"
:label="themeOption"
:value="themeOption"
class="text-capitalize"
/>
</VRadioGroup>
<!-- 👉 Primary color -->
<h6 class="mt-3 text-base font-weight-regular">
Primary Color
</h6>
<div class="d-flex gap-x-4 mt-2">
<div
v-for="(color, index) in colors"
:key="color"
style="width: 2.5rem; height: 2.5rem; border-radius: 0.5rem; transition: all 0.25s ease;"
:style="{ backgroundColor: getBoxColor(initialThemeColors[color], index) }"
class="cursor-pointer d-flex align-center justify-center"
:class="{ 'elevation-4': vuetifyTheme.current.value.colors.primary === getBoxColor(initialThemeColors[color], index) }"
@click="setPrimaryColor(getBoxColor(initialThemeColors[color], index))"
>
<VFadeTransition>
<VIcon
v-show="vuetifyTheme.current.value.colors.primary === (getBoxColor(initialThemeColors[color], index))"
icon="mdi-check"
color="white"
/>
</VFadeTransition>
</div>
</div>
</CustomizerSection>
<!-- !SECTION -->
<!-- SECTION LAYOUT -->
<CustomizerSection title="LAYOUT">
<!-- 👉 Content Width -->
<h6 class="text-base font-weight-regular">
Content width
</h6>
<VRadioGroup
v-model="appContentWidth"
inline
>
<VRadio
v-for="[key, val] in Object.entries(ContentWidth)"
:key="key"
:label="key"
:value="val"
/>
</VRadioGroup>
<!-- 👉 Navbar Type -->
<h6 class="mt-3 text-base font-weight-regular">
{{ appContentLayoutNav === AppContentLayoutNav.Vertical ? 'Navbar' : 'Header' }} Type
</h6>
<VRadioGroup
v-model="navbarType"
inline
>
<VRadio
v-for="[key, val] in headerValues"
:key="key"
:label="key"
:value="val"
/>
</VRadioGroup>
<!-- 👉 Footer Type -->
<h6 class="mt-3 text-base font-weight-regular">
Footer Type
</h6>
<VRadioGroup
v-model="footerType"
inline
>
<VRadio
v-for="[key, val] in Object.entries(FooterType)"
:key="key"
:label="key"
:value="val"
/>
</VRadioGroup>
<!-- 👉 Navbar blur -->
<div class="d-flex align-center justify-space-between">
<VLabel
for="customizer-navbar-blur"
class="text-high-emphasis"
>
Navbar Blur
</VLabel>
<div>
<VSwitch
id="customizer-navbar-blur"
v-model="isNavbarBlurEnabled"
class="ms-2"
/>
</div>
</div>
</CustomizerSection>
<!-- !SECTION -->
<!-- SECTION Menu -->
<CustomizerSection title="MENU">
<!-- 👉 Menu Type -->
<h6 class="text-base font-weight-regular">
Menu Type
</h6>
<VRadioGroup
v-model="appContentLayoutNav"
inline
>
<VRadio
v-for="[key, val] in Object.entries(AppContentLayoutNav)"
:key="key"
:label="key"
:value="val"
/>
</VRadioGroup>
<!-- 👉 Collapsed Menu -->
<div
v-if="appContentLayoutNav === AppContentLayoutNav.Vertical"
class="d-flex align-center justify-space-between"
>
<VLabel
for="customizer-menu-collapsed"
class="text-high-emphasis"
>
Collapsed Menu
</VLabel>
<div>
<VSwitch
id="customizer-menu-collapsed"
v-model="isVerticalNavCollapsed"
class="ms-2"
/>
</div>
</div>
<!-- 👉 Semi Dark Menu -->
<div
class="align-center justify-space-between"
:class="vuetifyTheme.global.name.value === 'light' && appContentLayoutNav === AppContentLayoutNav.Vertical ? 'd-flex' : 'd-none'"
>
<VLabel
for="customizer-menu-semi-dark"
class="text-high-emphasis"
>
Semi Dark Menu
</VLabel>
<div>
<VSwitch
id="customizer-menu-semi-dark"
v-model="isVerticalNavSemiDark"
class="ms-2"
/>
</div>
</div>
</CustomizerSection>
<!-- !SECTION -->
<!-- SECTION MISC -->
<CustomizerSection title="MISC">
<!-- 👉 RTL -->
<div class="d-flex align-center justify-space-between">
<VLabel
for="customizer-rtl"
class="text-high-emphasis"
>
RTL
</VLabel>
<div>
<VSwitch
id="customizer-rtl"
v-model="isAppRtl"
class="ms-2"
/>
</div>
</div>
<!-- 👉 Route Transition -->
<div class="mt-6">
<VRow>
<VCol
cols="5"
class="d-flex align-center"
>
<VLabel
for="route-transition"
class="text-high-emphasis"
>
Router Transition
</VLabel>
</VCol>
<VCol cols="7">
<VSelect
id="route-transition"
v-model="appRouteTransition"
:items="Object.entries(RouteTransitions).map(([key, value]) => ({ key, value }))"
item-title="key"
item-value="value"
single-line
/>
</VCol>
</VRow>
</div>
</CustomizerSection>
<!-- !SECTION -->
</PerfectScrollbar>
</VNavigationDrawer>
</template>
</template>
<style lang="scss">
.app-customizer {
.customizer-section {
padding: 1.25rem;
}
.customizer-heading {
padding-block: 0.875rem;
padding-inline: 1.25rem;
}
.v-navigation-drawer__content {
display: flex;
flex-direction: column;
}
}
.app-customizer-toggler {
position: fixed !important;
inset-block-start: 50%;
inset-inline-end: 0;
transform: translateY(-50%);
}
</style>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { useThemeConfig } from '@core/composable/useThemeConfig'
import type { ThemeSwitcherTheme } from '@layouts/types'
const props = defineProps<{
themes: ThemeSwitcherTheme[]
}>()
const { theme } = useThemeConfig()
const { state: currentThemeName, next: getNextThemeName, index: currentThemeIndex } = useCycleList(props.themes.map(t => t.name), { initialValue: theme.value })
const changeTheme = () => {
theme.value = getNextThemeName()
}
// Update icon if theme is changed from other sources
watch(theme, val => {
currentThemeName.value = val
})
</script>
<template>
<IconBtn @click="changeTheme">
<VIcon :icon="props.themes[currentThemeIndex].icon" />
<VTooltip
activator="parent"
open-delay="1000"
>
<span class="text-capitalize">{{ currentThemeName }}</span>
</VTooltip>
</IconBtn>
</template>
@@ -0,0 +1,158 @@
<script setup lang="ts">
import AddAuthenticatorAppDialog from '@core/components/AddAuthenticatorAppDialog.vue'
import EnableOneTimePasswordDialog from '@core/components/EnableOneTimePasswordDialog.vue'
interface Emit {
(e: 'update:isDialogVisible', value: boolean): void
}
interface Props {
isDialogVisible: boolean
smsCode?: string
authAppCode?: string
}
const props = withDefaults(defineProps<Props>(), {
isDialogVisible: false,
smsCode: '',
authAppCode: '',
})
const emit = defineEmits<Emit>()
const authMethods = [
{
icon: 'mdi-cog-outline',
title: 'Authenticator Apps',
subtitle: 'Get code from an app like Google Authenticator or Microsoft Authenticator.',
method: 'authApp',
},
{
icon: 'mdi-message-outline',
title: 'SMS',
subtitle: 'We will send a code via SMS if you need to use your backup login method.',
method: 'sms',
},
]
const selectedMethod = ref(['authApp'])
const isAuthAppDialogVisible = ref(false)
const isSmsDialogVisible = ref(false)
const openSelectedMethodDialog = () => {
if (selectedMethod.value[0] === 'authApp') {
isAuthAppDialogVisible.value = true
isSmsDialogVisible.value = false
emit('update:isDialogVisible', false)
}
if (selectedMethod.value[0] === 'sms') {
isAuthAppDialogVisible.value = false
isSmsDialogVisible.value = true
emit('update:isDialogVisible', false)
}
}
</script>
<template>
<VDialog
max-width="900"
:model-value="props.isDialogVisible"
@update:model-value="(val) => $emit('update:isDialogVisible', val)"
>
<VCard class="pa-5 pa-sm-8">
<!-- 👉 dialog close btn -->
<DialogCloseBtn
variant="text"
size="small"
@click="$emit('update:isDialogVisible', false)"
/>
<VCardItem class="text-center">
<VCardTitle class="text-h5 mb-3">
Select Authentication Method
</VCardTitle>
<VCardSubtitle>
You also need to select a method by which the proxy authenticates to the directory serve.
</VCardSubtitle>
</VCardItem>
<VCardText>
<VList
v-model:selected="selectedMethod"
mandatory
class="card-list auth-method-card"
:class="$vuetify.display.xs ? 'responsive-card' : ''"
>
<VListItem
v-for="item of authMethods"
:key="item.title"
rounded
border
:value="item.method"
class="py-5 px-6 my-6"
:class="selectedMethod[0] === item.method ? 'bg-light-primary border-primary' : 'bg-light-secondary border-secondary'"
style="/* stylelint-disable-next-line max-empty-lines */
--v-border-opacity: 1;"
>
<template #prepend>
<VIcon
:icon="item.icon"
size="38"
/>
</template>
<VListItemTitle class="text-xl font-weight-medium">
{{ item.title }}
</VListItemTitle>
<p class="text-base mb-0">
{{ item.subtitle }}
</p>
</VListItem>
</VList>
<div class="text-end">
<VBtn @click="openSelectedMethodDialog">
continue
<VIcon
end
icon="mdi-chevron-right"
class="flip-in-rtl"
/>
</VBtn>
</div>
</VCardText>
</VCard>
</VDialog>
<AddAuthenticatorAppDialog
v-model:isDialogVisible="isAuthAppDialogVisible"
:auth-code="props.authAppCode"
/>
<EnableOneTimePasswordDialog
v-model:isDialogVisible="isSmsDialogVisible"
:mobile-number="props.smsCode"
/>
</template>
<style lang="scss">
.auth-method-card {
&.card-list .v-list-item {
padding-block: 20px !important;
padding-inline: 30px !important;
}
&.responsive-card {
.v-list-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
text-align: center;
.v-list-item__prepend {
svg {
margin: 0;
}
}
}
}
}
</style>

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