Phase 1: Rebuild layout infrastructure with new shared components
Delete old Vuexy VerticalNav layout system (5 components + 4 SCSS files) and replace with new modular components: AppSidebar, AppTopNavbar, CommandPalette, NotificationPanel, ToastStack, SkeletonLoader, EmptyState, Breadcrumbs. Rebuild all 4 layouts (Admin, Account, Marketing, Auth) using new components. Add Pinia toast store for flash message integration. Update navigation with cleaner groupings. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,100 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import { usePage } from '@inertiajs/vue3'
|
|
||||||
import { Link } from '@inertiajs/vue3'
|
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
import type { NavGroup, NavLink, NavSectionTitle, VerticalNavItems } from '@layouts/types'
|
|
||||||
import VerticalNavLink from './VerticalNavLink.vue'
|
|
||||||
import VerticalNavGroup from './VerticalNavGroup.vue'
|
|
||||||
import VerticalNavSectionTitle from './VerticalNavSectionTitle.vue'
|
|
||||||
import logoWhite from '@images/ezscale_logo_white.png'
|
|
||||||
import { useTheme } from 'vuetify'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
navItems: VerticalNavItems
|
|
||||||
isOverlayNavActive: boolean
|
|
||||||
isCollapsed: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:isOverlayNavActive': [value: boolean]
|
|
||||||
'update:isCollapsed': [value: boolean]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const theme = useTheme()
|
|
||||||
const isDark = computed(() => theme.global.current.value.dark)
|
|
||||||
|
|
||||||
const navRef = ref<HTMLElement | null>(null)
|
|
||||||
const isHovered = ref<boolean>(false)
|
|
||||||
|
|
||||||
const page = usePage()
|
|
||||||
|
|
||||||
// Mini mode: collapsed AND not hovered AND not mobile overlay
|
|
||||||
const isMini = computed<boolean>(() => props.isCollapsed && !isHovered.value && !props.isOverlayNavActive)
|
|
||||||
|
|
||||||
// Auto-close overlay on route change
|
|
||||||
watch(() => page.url, () => {
|
|
||||||
if (props.isOverlayNavActive) {
|
|
||||||
emit('update:isOverlayNavActive', false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function resolveNavItemComponent(item: NavLink | NavGroup | NavSectionTitle): typeof VerticalNavLink | typeof VerticalNavGroup | typeof VerticalNavSectionTitle {
|
|
||||||
if ('heading' in item) return VerticalNavSectionTitle
|
|
||||||
if ('children' in item) return VerticalNavGroup
|
|
||||||
return VerticalNavLink
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleCollapse(): void {
|
|
||||||
emit('update:isCollapsed', !props.isCollapsed)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<aside
|
|
||||||
ref="navRef"
|
|
||||||
class="layout-vertical-nav"
|
|
||||||
:class="{
|
|
||||||
'overlay-nav': isOverlayNavActive,
|
|
||||||
visible: isOverlayNavActive,
|
|
||||||
hovered: isHovered,
|
|
||||||
}"
|
|
||||||
@mouseenter="isHovered = true"
|
|
||||||
@mouseleave="isHovered = false"
|
|
||||||
>
|
|
||||||
<!-- Nav Header (Logo + Toggle) -->
|
|
||||||
<div class="nav-header">
|
|
||||||
<Link href="/dashboard" class="nav-header-logo">
|
|
||||||
<img
|
|
||||||
:src="logoWhite"
|
|
||||||
alt="EZSCALE"
|
|
||||||
:class="{ 'logo-light': !isDark }"
|
|
||||||
class="nav-logo-img"
|
|
||||||
/>
|
|
||||||
<span v-show="!isMini" class="nav-logo-title">EZSCALE</span>
|
|
||||||
</Link>
|
|
||||||
<VBtn
|
|
||||||
v-show="!isMini"
|
|
||||||
icon
|
|
||||||
variant="text"
|
|
||||||
size="x-small"
|
|
||||||
class="nav-collapse-btn"
|
|
||||||
@click="toggleCollapse"
|
|
||||||
>
|
|
||||||
<VIcon :icon="isCollapsed ? 'tabler-layout-sidebar-right-expand' : 'tabler-layout-sidebar-left-collapse'" size="20" />
|
|
||||||
</VBtn>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Nav Items -->
|
|
||||||
<ul class="nav-items">
|
|
||||||
<Component
|
|
||||||
:is="resolveNavItemComponent(item)"
|
|
||||||
v-for="(item, index) in navItems"
|
|
||||||
:key="index"
|
|
||||||
:item="item"
|
|
||||||
:is-mini="isMini"
|
|
||||||
/>
|
|
||||||
</ul>
|
|
||||||
</aside>
|
|
||||||
</template>
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import { usePage } from '@inertiajs/vue3'
|
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
import type { NavGroup, NavLink } from '@layouts/types'
|
|
||||||
import VerticalNavLink from './VerticalNavLink.vue'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
item: NavGroup
|
|
||||||
isMini?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
isMini: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const page = usePage()
|
|
||||||
|
|
||||||
function isChildActive(children: (NavLink | NavGroup)[]): boolean {
|
|
||||||
return children.some((child) => {
|
|
||||||
if ('children' in child) {
|
|
||||||
return isChildActive(child.children)
|
|
||||||
}
|
|
||||||
const url = page.url
|
|
||||||
if (child.to === '/dashboard') {
|
|
||||||
return url === '/dashboard' || url === '/dashboard/'
|
|
||||||
}
|
|
||||||
return url.startsWith(child.to)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const isGroupActive = computed<boolean>(() => isChildActive(props.item.children))
|
|
||||||
const isOpen = ref<boolean>(isGroupActive.value)
|
|
||||||
|
|
||||||
// Auto-open/close when route changes
|
|
||||||
watch(() => page.url, () => {
|
|
||||||
if (isGroupActive.value) {
|
|
||||||
isOpen.value = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Close when entering mini mode
|
|
||||||
watch(() => props.isMini, (mini) => {
|
|
||||||
if (mini) {
|
|
||||||
isOpen.value = false
|
|
||||||
}
|
|
||||||
else if (isGroupActive.value) {
|
|
||||||
isOpen.value = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function toggleGroup(): void {
|
|
||||||
if (!props.isMini) {
|
|
||||||
isOpen.value = !isOpen.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveComponent(child: NavLink | NavGroup): typeof VerticalNavLink | typeof VerticalNavGroup {
|
|
||||||
if ('children' in child) {
|
|
||||||
return VerticalNavGroup
|
|
||||||
}
|
|
||||||
return VerticalNavLink
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<li class="nav-group" :class="{ active: isGroupActive, open: isOpen, disabled: item.disable }">
|
|
||||||
<div class="nav-group-label" @click="toggleGroup">
|
|
||||||
<VIcon
|
|
||||||
v-if="item.icon"
|
|
||||||
:icon="item.icon"
|
|
||||||
class="nav-item-icon"
|
|
||||||
size="22"
|
|
||||||
/>
|
|
||||||
<VIcon
|
|
||||||
v-else
|
|
||||||
icon="tabler-circle"
|
|
||||||
class="nav-item-icon"
|
|
||||||
size="12"
|
|
||||||
/>
|
|
||||||
<span v-show="!isMini" class="nav-item-title">{{ item.title }}</span>
|
|
||||||
<span
|
|
||||||
v-if="item.badgeContent"
|
|
||||||
v-show="!isMini"
|
|
||||||
class="nav-item-badge"
|
|
||||||
:class="item.badgeClass"
|
|
||||||
>
|
|
||||||
{{ item.badgeContent }}
|
|
||||||
</span>
|
|
||||||
<VIcon
|
|
||||||
v-show="!isMini"
|
|
||||||
icon="tabler-chevron-right"
|
|
||||||
class="nav-group-arrow"
|
|
||||||
size="18"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Transition name="vertical-nav-group">
|
|
||||||
<ul v-show="isOpen && !isMini" class="nav-group-children">
|
|
||||||
<Component
|
|
||||||
:is="resolveComponent(child)"
|
|
||||||
v-for="(child, index) in item.children"
|
|
||||||
:key="index"
|
|
||||||
:item="child"
|
|
||||||
:is-mini="isMini"
|
|
||||||
/>
|
|
||||||
</ul>
|
|
||||||
</Transition>
|
|
||||||
</li>
|
|
||||||
</template>
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
|
||||||
import type { VerticalNavItems } from '@layouts/types'
|
|
||||||
import VerticalNav from './VerticalNav.vue'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
navItems: VerticalNavItems
|
|
||||||
}
|
|
||||||
|
|
||||||
defineProps<Props>()
|
|
||||||
|
|
||||||
const isOverlayNavActive = ref<boolean>(false)
|
|
||||||
const isNavCollapsed = ref<boolean>(false)
|
|
||||||
|
|
||||||
// Persist collapsed state in localStorage
|
|
||||||
onMounted(() => {
|
|
||||||
const saved = localStorage.getItem('ezscale-nav-collapsed')
|
|
||||||
if (saved !== null) {
|
|
||||||
isNavCollapsed.value = saved === 'true'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(isNavCollapsed, (val) => {
|
|
||||||
localStorage.setItem('ezscale-nav-collapsed', String(val))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Close overlay when window resizes to desktop
|
|
||||||
const BREAKPOINT = 1280
|
|
||||||
|
|
||||||
function handleResize(): void {
|
|
||||||
if (window.innerWidth >= BREAKPOINT && isOverlayNavActive.value) {
|
|
||||||
isOverlayNavActive.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
window.addEventListener('resize', handleResize)
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
window.removeEventListener('resize', handleResize)
|
|
||||||
})
|
|
||||||
|
|
||||||
function toggleOverlayNav(value: boolean): void {
|
|
||||||
isOverlayNavActive.value = value
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="layout-wrapper layout-nav-type-vertical"
|
|
||||||
:class="{
|
|
||||||
'layout-vertical-nav-collapsed': isNavCollapsed,
|
|
||||||
'layout-overlay-nav': false,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<!-- Vertical Nav -->
|
|
||||||
<VerticalNav
|
|
||||||
:nav-items="navItems"
|
|
||||||
v-model:is-overlay-nav-active="isOverlayNavActive"
|
|
||||||
:is-collapsed="isNavCollapsed"
|
|
||||||
@update:is-collapsed="isNavCollapsed = $event"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Content Wrapper -->
|
|
||||||
<div class="layout-content-wrapper">
|
|
||||||
<!-- Navbar -->
|
|
||||||
<header class="layout-navbar">
|
|
||||||
<div class="navbar-content d-flex align-center h-100 px-4">
|
|
||||||
<!-- Mobile hamburger (shown below 1280px via CSS) -->
|
|
||||||
<VBtn
|
|
||||||
icon
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
class="d-lg-none me-2"
|
|
||||||
@click="toggleOverlayNav(true)"
|
|
||||||
>
|
|
||||||
<VIcon icon="tabler-menu-2" size="22" />
|
|
||||||
</VBtn>
|
|
||||||
|
|
||||||
<slot name="navbar" />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Main Content -->
|
|
||||||
<main class="layout-page-content">
|
|
||||||
<slot />
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<footer class="layout-footer">
|
|
||||||
<slot name="footer">
|
|
||||||
<span class="text-body-2 text-medium-emphasis">
|
|
||||||
© {{ new Date().getFullYear() }} EZSCALE. All rights reserved.
|
|
||||||
</span>
|
|
||||||
</slot>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Overlay Backdrop -->
|
|
||||||
<div
|
|
||||||
class="layout-overlay"
|
|
||||||
:class="{ visible: isOverlayNavActive }"
|
|
||||||
@click="toggleOverlayNav(false)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import { Link, usePage } from '@inertiajs/vue3'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import type { NavLink } from '@layouts/types'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
item: NavLink
|
|
||||||
isMini?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
isMini: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const page = usePage()
|
|
||||||
const isActive = computed<boolean>(() => {
|
|
||||||
const url = page.url
|
|
||||||
// Exact match for dashboard, prefix match for everything else
|
|
||||||
if (props.item.to === '/dashboard') {
|
|
||||||
return url === '/dashboard' || url === '/dashboard/'
|
|
||||||
}
|
|
||||||
return url.startsWith(props.item.to)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<li class="nav-link" :class="{ active: isActive, disabled: item.disable }">
|
|
||||||
<Link :href="item.to" class="nav-link-btn">
|
|
||||||
<VIcon
|
|
||||||
v-if="item.icon"
|
|
||||||
:icon="item.icon"
|
|
||||||
class="nav-item-icon"
|
|
||||||
size="22"
|
|
||||||
/>
|
|
||||||
<VIcon
|
|
||||||
v-else
|
|
||||||
icon="tabler-circle"
|
|
||||||
class="nav-item-icon"
|
|
||||||
size="12"
|
|
||||||
/>
|
|
||||||
<span v-show="!isMini" class="nav-item-title">{{ item.title }}</span>
|
|
||||||
<span
|
|
||||||
v-if="item.badgeContent"
|
|
||||||
v-show="!isMini"
|
|
||||||
class="nav-item-badge"
|
|
||||||
:class="item.badgeClass"
|
|
||||||
>
|
|
||||||
{{ item.badgeContent }}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
</template>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import type { NavSectionTitle } from '@layouts/types'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
item: NavSectionTitle
|
|
||||||
isMini?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
|
||||||
isMini: false,
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<li class="nav-section-title">
|
|
||||||
<div v-show="!isMini" class="nav-section-title-text">
|
|
||||||
{{ item.heading }}
|
|
||||||
</div>
|
|
||||||
<VIcon v-show="isMini" icon="tabler-dots" size="16" class="nav-section-title-icon" />
|
|
||||||
</li>
|
|
||||||
</template>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
@use "placeholders";
|
|
||||||
@use "@configured-variables" as variables;
|
|
||||||
|
|
||||||
@mixin rtl {
|
|
||||||
@if variables.$enable-rtl-styles {
|
|
||||||
[dir="rtl"] & {
|
|
||||||
@content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin boxed-content($nest-selector: false) {
|
|
||||||
& {
|
|
||||||
@extend %boxed-content-spacing;
|
|
||||||
|
|
||||||
@at-root {
|
|
||||||
@if $nest-selector == false {
|
|
||||||
.layout-content-width-boxed#{&} {
|
|
||||||
@extend %boxed-content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// stylelint-disable-next-line @stylistic/indentation
|
|
||||||
@else {
|
|
||||||
.layout-content-width-boxed & {
|
|
||||||
@extend %boxed-content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
// Stub — placeholders not used in our Inertia setup
|
|
||||||
%boxed-content {
|
|
||||||
max-inline-size: 1440px;
|
|
||||||
margin-inline: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
%boxed-content-spacing {
|
|
||||||
padding-inline: 1.5rem;
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
// @use "@styles/style.scss";
|
|
||||||
|
|
||||||
// 👉 Vertical nav
|
|
||||||
$layout-vertical-nav-z-index: 12 !default;
|
|
||||||
$layout-vertical-nav-width: 260px !default;
|
|
||||||
$layout-vertical-nav-collapsed-width: 80px !default;
|
|
||||||
$selector-vertical-nav-mini: ".layout-vertical-nav-collapsed .layout-vertical-nav:not(:hover)";
|
|
||||||
|
|
||||||
// 👉 Horizontal nav
|
|
||||||
$layout-horizontal-nav-z-index: 11 !default;
|
|
||||||
$layout-horizontal-nav-navbar-height: 64px !default;
|
|
||||||
|
|
||||||
// 👉 Navbar
|
|
||||||
$layout-vertical-nav-navbar-height: 64px !default;
|
|
||||||
$layout-vertical-nav-navbar-is-contained: true !default;
|
|
||||||
$layout-vertical-nav-layout-navbar-z-index: 11 !default;
|
|
||||||
$layout-horizontal-nav-layout-navbar-z-index: 11 !default;
|
|
||||||
|
|
||||||
// 👉 Main content
|
|
||||||
$layout-boxed-content-width: 1440px !default;
|
|
||||||
|
|
||||||
// 👉Footer
|
|
||||||
$layout-vertical-nav-footer-height: 56px !default;
|
|
||||||
|
|
||||||
// 👉 Layout overlay
|
|
||||||
$layout-overlay-z-index: 11 !default;
|
|
||||||
|
|
||||||
// 👉 RTL
|
|
||||||
$enable-rtl-styles: true !default;
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
@use "@configured-variables" as variables;
|
|
||||||
@use "variables" as layout-variables;
|
|
||||||
|
|
||||||
// ━━━ Layout Wrapper ━━━
|
|
||||||
.layout-wrapper.layout-nav-type-vertical {
|
|
||||||
display: flex;
|
|
||||||
min-block-size: 100vh;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ━━━ Vertical Nav (Sidebar) ━━━
|
|
||||||
.layout-vertical-nav {
|
|
||||||
position: fixed;
|
|
||||||
inset-block: 0;
|
|
||||||
inset-inline-start: 0;
|
|
||||||
z-index: layout-variables.$layout-vertical-nav-z-index;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
inline-size: layout-variables.$layout-vertical-nav-width;
|
|
||||||
background-color: rgb(var(--v-theme-surface));
|
|
||||||
border-inline-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
|
||||||
transition: inline-size 0.25s ease-in-out, transform 0.25s ease-in-out, box-shadow 0.25s ease-in-out;
|
|
||||||
|
|
||||||
// ━━━ Nav Header ━━━
|
|
||||||
.nav-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 16px 16px 8px;
|
|
||||||
min-block-size: layout-variables.$layout-vertical-nav-navbar-height;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-header-logo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
text-decoration: none;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-logo-img {
|
|
||||||
block-size: 28px;
|
|
||||||
inline-size: auto;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-logo-title {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
color: rgb(var(--v-theme-on-surface));
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-collapse-btn {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ━━━ Nav Items List ━━━
|
|
||||||
.nav-items {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
flex-grow: 1;
|
|
||||||
padding-block: 4px;
|
|
||||||
padding-inline: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ━━━ Nav Link ━━━
|
|
||||||
.nav-link {
|
|
||||||
margin-block: 2px;
|
|
||||||
|
|
||||||
.nav-link-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
text-decoration: none;
|
|
||||||
color: rgba(var(--v-theme-on-surface), 0.78);
|
|
||||||
transition: background-color 0.15s ease, color 0.15s ease;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item-title {
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
flex-grow: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item-badge {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: rgb(var(--v-theme-error));
|
|
||||||
color: rgb(var(--v-theme-on-error));
|
|
||||||
line-height: 1;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(.disabled) .nav-link-btn:hover {
|
|
||||||
background-color: rgba(var(--v-theme-on-surface), 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active .nav-link-btn {
|
|
||||||
background: linear-gradient(270deg, rgb(var(--v-theme-primary)), rgba(var(--v-theme-primary), 0.7));
|
|
||||||
color: rgb(var(--v-theme-on-primary));
|
|
||||||
box-shadow: 0 2px 6px 0 rgba(var(--v-theme-primary), 0.4);
|
|
||||||
|
|
||||||
.nav-item-icon {
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.disabled .nav-link-btn {
|
|
||||||
opacity: 0.4;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ━━━ Nav Group ━━━
|
|
||||||
.nav-group {
|
|
||||||
margin-block: 2px;
|
|
||||||
|
|
||||||
.nav-group-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: rgba(var(--v-theme-on-surface), 0.78);
|
|
||||||
transition: background-color 0.15s ease;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-group-label:hover {
|
|
||||||
background-color: rgba(var(--v-theme-on-surface), 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active > .nav-group-label {
|
|
||||||
color: rgb(var(--v-theme-primary));
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-group-arrow {
|
|
||||||
margin-inline-start: auto;
|
|
||||||
transition: transform 0.25s ease-in-out;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.open > .nav-group-label .nav-group-arrow {
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-group-children {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
padding-inline-start: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.disabled > .nav-group-label {
|
|
||||||
opacity: 0.4;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ━━━ Section Title ━━━
|
|
||||||
.nav-section-title {
|
|
||||||
margin-block: 12px 4px;
|
|
||||||
padding-inline: 12px;
|
|
||||||
|
|
||||||
.nav-section-title-text {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: rgba(var(--v-theme-on-surface), 0.38);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-section-title-icon {
|
|
||||||
color: rgba(var(--v-theme-on-surface), 0.38);
|
|
||||||
margin-inline: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:first-child {
|
|
||||||
margin-block-start: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ━━━ Collapsed / Mini Mode ━━━
|
|
||||||
.layout-vertical-nav-collapsed .layout-vertical-nav:not(:hover) {
|
|
||||||
inline-size: layout-variables.$layout-vertical-nav-collapsed-width;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-vertical-nav-collapsed .layout-vertical-nav:hover {
|
|
||||||
box-shadow: 0 4px 25px 0 rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ━━━ Content Wrapper ━━━
|
|
||||||
.layout-content-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-grow: 1;
|
|
||||||
min-block-size: 100vh;
|
|
||||||
padding-inline-start: layout-variables.$layout-vertical-nav-width;
|
|
||||||
transition: padding-inline-start 0.25s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-vertical-nav-collapsed .layout-content-wrapper {
|
|
||||||
padding-inline-start: layout-variables.$layout-vertical-nav-collapsed-width;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ━━━ Navbar ━━━
|
|
||||||
.layout-navbar {
|
|
||||||
position: sticky;
|
|
||||||
inset-block-start: 0;
|
|
||||||
z-index: layout-variables.$layout-vertical-nav-layout-navbar-z-index;
|
|
||||||
background-color: rgb(var(--v-theme-surface));
|
|
||||||
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
|
||||||
min-block-size: layout-variables.$layout-vertical-nav-navbar-height;
|
|
||||||
|
|
||||||
.navbar-content {
|
|
||||||
block-size: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ━━━ Page Content ━━━
|
|
||||||
.layout-page-content {
|
|
||||||
flex-grow: 1;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ━━━ Footer ━━━
|
|
||||||
.layout-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-block-size: layout-variables.$layout-vertical-nav-footer-height;
|
|
||||||
padding-inline: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ━━━ Overlay ━━━
|
|
||||||
.layout-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: layout-variables.$layout-overlay-z-index;
|
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 0.25s ease-in-out;
|
|
||||||
|
|
||||||
&.visible {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ━━━ Responsive (Mobile Overlay) ━━━
|
|
||||||
@media (max-width: 1279.98px) {
|
|
||||||
.layout-vertical-nav {
|
|
||||||
position: fixed;
|
|
||||||
transform: translateX(-100%);
|
|
||||||
|
|
||||||
&.visible {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-content-wrapper {
|
|
||||||
padding-inline-start: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-vertical-nav-collapsed .layout-content-wrapper {
|
|
||||||
padding-inline-start: 0 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ━━━ Transition for nav group expand/collapse ━━━
|
|
||||||
.vertical-nav-group-enter-active,
|
|
||||||
.vertical-nav-group-leave-active {
|
|
||||||
overflow: hidden;
|
|
||||||
transition: max-height 0.3s ease-in-out, opacity 0.25s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vertical-nav-group-enter-from,
|
|
||||||
.vertical-nav-group-leave-to {
|
|
||||||
max-height: 0;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vertical-nav-group-enter-to,
|
|
||||||
.vertical-nav-group-leave-from {
|
|
||||||
max-height: 500px;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
150
website/resources/ts/Components/AppSidebar.vue
Normal file
150
website/resources/ts/Components/AppSidebar.vue
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { Link, usePage } from '@inertiajs/vue3'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import type { NavGroup, NavLink, VerticalNavItems } from '@layouts/types'
|
||||||
|
import logoWhite from '@images/ezscale_logo_white.png'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
navItems: VerticalNavItems
|
||||||
|
collapsed?: boolean
|
||||||
|
mobileOpen?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
collapsed: false,
|
||||||
|
mobileOpen: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:collapsed': [value: boolean]
|
||||||
|
'update:mobileOpen': [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const page = usePage()
|
||||||
|
|
||||||
|
function isActive(to: string): boolean {
|
||||||
|
const url = page.url
|
||||||
|
if (to === '/dashboard') return url === '/dashboard' || url === '/dashboard/'
|
||||||
|
return url.startsWith(to)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGroupActive(children: (NavLink | NavGroup)[]): boolean {
|
||||||
|
return children.some(child => {
|
||||||
|
if ('children' in child) return isGroupActive(child.children)
|
||||||
|
return isActive(child.to)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const openGroups = ref<Set<number>>(new Set())
|
||||||
|
|
||||||
|
function toggleGroup(index: number): void {
|
||||||
|
if (openGroups.value.has(index)) {
|
||||||
|
openGroups.value.delete(index)
|
||||||
|
} else {
|
||||||
|
openGroups.value.add(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-open groups containing active links
|
||||||
|
watch(() => page.url, () => {
|
||||||
|
props.navItems.forEach((item, index) => {
|
||||||
|
if ('children' in item && isGroupActive(item.children)) {
|
||||||
|
openGroups.value.add(index)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// Close mobile nav on route change
|
||||||
|
if (props.mobileOpen) emit('update:mobileOpen', false)
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
function toggleCollapse(): void {
|
||||||
|
emit('update:collapsed', !props.collapsed)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside
|
||||||
|
class="app-sidebar"
|
||||||
|
:class="{
|
||||||
|
'app-sidebar--collapsed': collapsed && !mobileOpen,
|
||||||
|
'app-sidebar--mobile-open': mobileOpen,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<Link href="/dashboard" class="d-flex align-center text-decoration-none">
|
||||||
|
<img :src="logoWhite" alt="EZSCALE" style="height: 28px; width: auto;" />
|
||||||
|
<span class="sidebar-logo-text text-white">EZSCALE</span>
|
||||||
|
</Link>
|
||||||
|
<VBtn
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
size="x-small"
|
||||||
|
color="white"
|
||||||
|
class="ms-auto sidebar-logo-text"
|
||||||
|
@click="toggleCollapse"
|
||||||
|
>
|
||||||
|
<VIcon :icon="collapsed ? 'tabler-layout-sidebar-right-expand' : 'tabler-layout-sidebar-left-collapse'" size="20" />
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<template v-for="(item, index) in navItems" :key="index">
|
||||||
|
<!-- Section Title -->
|
||||||
|
<div v-if="'heading' in item" class="nav-section-title">
|
||||||
|
{{ item.heading }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nav Group -->
|
||||||
|
<template v-else-if="'children' in item">
|
||||||
|
<div
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ 'nav-item--active': isGroupActive(item.children) }"
|
||||||
|
@click="toggleGroup(index)"
|
||||||
|
>
|
||||||
|
<VIcon v-if="item.icon" :icon="item.icon" class="nav-item-icon" size="22" />
|
||||||
|
<span class="nav-item-title">{{ item.title }}</span>
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-chevron-right"
|
||||||
|
size="16"
|
||||||
|
class="nav-item-title ms-auto"
|
||||||
|
:style="{ transform: openGroups.has(index) ? 'rotate(90deg)' : 'none', transition: 'transform 0.2s ease' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-show="openGroups.has(index) && !(collapsed && !mobileOpen)" style="padding-left: 12px;">
|
||||||
|
<template v-for="(child, ci) in item.children" :key="ci">
|
||||||
|
<Link
|
||||||
|
v-if="!('children' in child)"
|
||||||
|
:href="child.to"
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ 'nav-item--active': isActive(child.to) }"
|
||||||
|
>
|
||||||
|
<VIcon icon="tabler-circle" size="10" class="nav-item-icon" />
|
||||||
|
<span class="nav-item-title">{{ child.title }}</span>
|
||||||
|
</Link>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Nav Link -->
|
||||||
|
<Link
|
||||||
|
v-else
|
||||||
|
:href="item.to"
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ 'nav-item--active': isActive(item.to) }"
|
||||||
|
>
|
||||||
|
<VIcon v-if="item.icon" :icon="item.icon" class="nav-item-icon" size="22" />
|
||||||
|
<span class="nav-item-title">{{ item.title }}</span>
|
||||||
|
<VChip
|
||||||
|
v-if="item.badgeContent"
|
||||||
|
size="x-small"
|
||||||
|
:color="item.badgeClass || 'primary'"
|
||||||
|
class="nav-item-badge nav-item-title"
|
||||||
|
>
|
||||||
|
{{ item.badgeContent }}
|
||||||
|
</VChip>
|
||||||
|
</Link>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
54
website/resources/ts/Components/AppTopNavbar.vue
Normal file
54
website/resources/ts/Components/AppTopNavbar.vue
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import ThemeSwitcher from '@/Components/ThemeSwitcher.vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
showHamburger?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
showHamburger: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
toggleSidebar: []
|
||||||
|
openCommandPalette: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="app-navbar">
|
||||||
|
<!-- Mobile hamburger -->
|
||||||
|
<VBtn
|
||||||
|
v-if="showHamburger"
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
class="d-lg-none me-2"
|
||||||
|
@click="emit('toggleSidebar')"
|
||||||
|
>
|
||||||
|
<VIcon icon="tabler-menu-2" size="22" />
|
||||||
|
</VBtn>
|
||||||
|
|
||||||
|
<!-- Search trigger -->
|
||||||
|
<VBtn
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
color="default"
|
||||||
|
class="text-medium-emphasis"
|
||||||
|
@click="emit('openCommandPalette')"
|
||||||
|
>
|
||||||
|
<VIcon icon="tabler-search" size="18" start />
|
||||||
|
<span class="d-none d-sm-inline">Search...</span>
|
||||||
|
<VChip size="x-small" variant="outlined" class="ms-2 d-none d-sm-inline-flex">
|
||||||
|
⌘K
|
||||||
|
</VChip>
|
||||||
|
</VBtn>
|
||||||
|
|
||||||
|
<VSpacer />
|
||||||
|
|
||||||
|
<!-- Right side slot for layout-specific content -->
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<ThemeSwitcher />
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
21
website/resources/ts/Components/Breadcrumbs.vue
Normal file
21
website/resources/ts/Components/Breadcrumbs.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
interface BreadcrumbItem {
|
||||||
|
title: string
|
||||||
|
href?: string
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: BreadcrumbItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VBreadcrumbs :items="items" class="pa-0 mb-6">
|
||||||
|
<template #divider>
|
||||||
|
<VIcon icon="tabler-chevron-right" size="14" />
|
||||||
|
</template>
|
||||||
|
</VBreadcrumbs>
|
||||||
|
</template>
|
||||||
137
website/resources/ts/Components/CommandPalette.vue
Normal file
137
website/resources/ts/Components/CommandPalette.vue
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { router } from '@inertiajs/vue3'
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
interface PaletteItem {
|
||||||
|
title: string
|
||||||
|
icon: string
|
||||||
|
href: string
|
||||||
|
section: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: PaletteItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const isOpen = ref<boolean>(false)
|
||||||
|
const searchQuery = ref<string>('')
|
||||||
|
const selectedIndex = ref<number>(0)
|
||||||
|
|
||||||
|
const filteredItems = computed<PaletteItem[]>(() => {
|
||||||
|
if (!searchQuery.value) return props.items
|
||||||
|
const q = searchQuery.value.toLowerCase()
|
||||||
|
return props.items.filter(item =>
|
||||||
|
item.title.toLowerCase().includes(q) || item.section.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(searchQuery, () => { selectedIndex.value = 0 })
|
||||||
|
|
||||||
|
function open(): void {
|
||||||
|
isOpen.value = true
|
||||||
|
searchQuery.value = ''
|
||||||
|
selectedIndex.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(): void {
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigate(item: PaletteItem): void {
|
||||||
|
close()
|
||||||
|
router.visit(item.href)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent): void {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||||
|
e.preventDefault()
|
||||||
|
isOpen.value ? close() : open()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isOpen.value) return
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
selectedIndex.value = Math.min(selectedIndex.value + 1, filteredItems.value.length - 1)
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
selectedIndex.value = Math.max(selectedIndex.value - 1, 0)
|
||||||
|
} else if (e.key === 'Enter' && filteredItems.value[selectedIndex.value]) {
|
||||||
|
e.preventDefault()
|
||||||
|
navigate(filteredItems.value[selectedIndex.value])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => document.addEventListener('keydown', handleKeydown))
|
||||||
|
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
|
||||||
|
|
||||||
|
defineExpose({ open })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VDialog
|
||||||
|
v-model="isOpen"
|
||||||
|
max-width="560"
|
||||||
|
content-class="command-palette-dialog"
|
||||||
|
transition="dialog-top-transition"
|
||||||
|
>
|
||||||
|
<VCard>
|
||||||
|
<div class="d-flex align-center pa-4 pb-0">
|
||||||
|
<VIcon icon="tabler-search" size="20" class="me-3 text-medium-emphasis" />
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="Search pages..."
|
||||||
|
class="command-palette-input flex-grow-1"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<VChip size="x-small" variant="outlined" @click="close">
|
||||||
|
ESC
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VDivider class="mt-4" />
|
||||||
|
|
||||||
|
<VList v-if="filteredItems.length > 0" class="pa-2" density="compact" max-height="400" style="overflow-y: auto;">
|
||||||
|
<VListItem
|
||||||
|
v-for="(item, i) in filteredItems"
|
||||||
|
:key="item.href"
|
||||||
|
:active="i === selectedIndex"
|
||||||
|
color="primary"
|
||||||
|
rounded
|
||||||
|
@click="navigate(item)"
|
||||||
|
@mouseenter="selectedIndex = i"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon :icon="item.icon" size="18" />
|
||||||
|
</template>
|
||||||
|
<VListItemTitle class="text-body-2">{{ item.title }}</VListItemTitle>
|
||||||
|
<template #append>
|
||||||
|
<VChip size="x-small" variant="text" class="text-caption text-medium-emphasis">
|
||||||
|
{{ item.section }}
|
||||||
|
</VChip>
|
||||||
|
</template>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
|
||||||
|
<div v-else class="pa-8 text-center">
|
||||||
|
<div class="text-body-2 text-medium-emphasis">No results found</div>
|
||||||
|
</div>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.command-palette-input {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: inherit;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
38
website/resources/ts/Components/EmptyState.vue
Normal file
38
website/resources/ts/Components/EmptyState.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
interface Props {
|
||||||
|
icon?: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
actionLabel?: string
|
||||||
|
actionTo?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
icon: 'tabler-inbox',
|
||||||
|
description: undefined,
|
||||||
|
actionLabel: undefined,
|
||||||
|
actionTo: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{ action: [] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="d-flex flex-column align-center justify-center pa-12 text-center">
|
||||||
|
<VAvatar size="80" color="default" variant="tonal" class="mb-6">
|
||||||
|
<VIcon :icon="icon" size="40" />
|
||||||
|
</VAvatar>
|
||||||
|
<h3 class="text-h6 mb-2">{{ title }}</h3>
|
||||||
|
<p v-if="description" class="text-body-2 text-medium-emphasis mb-6" style="max-width: 360px;">
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
<VBtn
|
||||||
|
v-if="actionLabel"
|
||||||
|
:to="actionTo"
|
||||||
|
color="primary"
|
||||||
|
@click="emit('action')"
|
||||||
|
>
|
||||||
|
{{ actionLabel }}
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
141
website/resources/ts/Components/NotificationPanel.vue
Normal file
141
website/resources/ts/Components/NotificationPanel.vue
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
interface NotificationItem {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
message: string
|
||||||
|
data: Record<string, unknown>
|
||||||
|
read: boolean
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: boolean }>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||||
|
|
||||||
|
const notifications = ref<NotificationItem[]>([])
|
||||||
|
const unreadCount = ref<number>(0)
|
||||||
|
const loading = ref<boolean>(false)
|
||||||
|
|
||||||
|
function resolveIcon(type: string): string {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
payment_succeeded: 'tabler-credit-card',
|
||||||
|
payment_failed: 'tabler-credit-card-off',
|
||||||
|
subscription_created: 'tabler-rosette-discount-check',
|
||||||
|
subscription_cancelled: 'tabler-circle-x',
|
||||||
|
service_provisioned: 'tabler-server',
|
||||||
|
invoice_generated: 'tabler-file-invoice',
|
||||||
|
}
|
||||||
|
return icons[type] ?? 'tabler-bell'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveColor(type: string): string {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
payment_succeeded: 'success',
|
||||||
|
payment_failed: 'error',
|
||||||
|
subscription_created: 'primary',
|
||||||
|
subscription_cancelled: 'warning',
|
||||||
|
service_provisioned: 'info',
|
||||||
|
invoice_generated: 'secondary',
|
||||||
|
}
|
||||||
|
return colors[type] ?? 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchNotifications(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/notifications')
|
||||||
|
notifications.value = response.data.notifications
|
||||||
|
unreadCount.value = response.data.unread_count
|
||||||
|
} catch {
|
||||||
|
// Silently fail
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAsRead(id: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await axios.post(`/notifications/${id}/read`)
|
||||||
|
const notification = notifications.value.find(n => n.id === id)
|
||||||
|
if (notification) {
|
||||||
|
notification.read = true
|
||||||
|
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAllAsRead(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await axios.post('/notifications/read-all')
|
||||||
|
notifications.value.forEach(n => { n.read = true })
|
||||||
|
unreadCount.value = 0
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => fetchNotifications())
|
||||||
|
|
||||||
|
defineExpose({ unreadCount, fetchNotifications })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VNavigationDrawer
|
||||||
|
:model-value="modelValue"
|
||||||
|
temporary
|
||||||
|
location="end"
|
||||||
|
width="380"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<div class="d-flex align-center justify-space-between pa-4">
|
||||||
|
<h3 class="text-h6">Notifications</h3>
|
||||||
|
<div class="d-flex align-center gap-2">
|
||||||
|
<VBtn
|
||||||
|
v-if="unreadCount > 0"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
@click="markAllAsRead"
|
||||||
|
>
|
||||||
|
Mark all read
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
@click="emit('update:modelValue', false)"
|
||||||
|
>
|
||||||
|
<VIcon icon="tabler-x" />
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VDivider />
|
||||||
|
|
||||||
|
<VList v-if="notifications.length > 0" lines="two">
|
||||||
|
<VListItem
|
||||||
|
v-for="notification in notifications"
|
||||||
|
:key="notification.id"
|
||||||
|
:class="{ 'bg-surface-variant': !notification.read }"
|
||||||
|
@click="markAsRead(notification.id)"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<VAvatar size="40" :color="resolveColor(notification.type)" variant="tonal">
|
||||||
|
<VIcon :icon="resolveIcon(notification.type)" size="20" />
|
||||||
|
</VAvatar>
|
||||||
|
</template>
|
||||||
|
<VListItemTitle class="text-body-2 font-weight-medium">
|
||||||
|
{{ notification.message }}
|
||||||
|
</VListItemTitle>
|
||||||
|
<VListItemSubtitle class="text-caption">
|
||||||
|
{{ notification.created_at }}
|
||||||
|
</VListItemSubtitle>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
|
||||||
|
<div v-else class="d-flex flex-column align-center justify-center pa-12" style="min-height: 300px;">
|
||||||
|
<VIcon icon="tabler-bell-off" size="48" color="disabled" class="mb-4" />
|
||||||
|
<div class="text-body-1 text-medium-emphasis">No notifications</div>
|
||||||
|
</div>
|
||||||
|
</VNavigationDrawer>
|
||||||
|
</template>
|
||||||
39
website/resources/ts/Components/SkeletonLoader.vue
Normal file
39
website/resources/ts/Components/SkeletonLoader.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
interface Props {
|
||||||
|
type?: 'card' | 'table' | 'list' | 'chart' | 'text'
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
type: 'card',
|
||||||
|
count: 1,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<template v-if="type === 'card'">
|
||||||
|
<VRow>
|
||||||
|
<VCol v-for="i in count" :key="i" cols="12" md="4">
|
||||||
|
<VSkeletonLoader type="card" />
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="type === 'table'">
|
||||||
|
<VSkeletonLoader type="table-heading, table-tbody" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="type === 'list'">
|
||||||
|
<VSkeletonLoader v-for="i in count" :key="i" type="list-item-avatar-two-line" class="mb-1" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="type === 'chart'">
|
||||||
|
<VSkeletonLoader type="image" height="300" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<VSkeletonLoader v-for="i in count" :key="i" type="text" class="mb-2" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
76
website/resources/ts/Components/ToastStack.vue
Normal file
76
website/resources/ts/Components/ToastStack.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
|
||||||
|
const toastStore = useToastStore()
|
||||||
|
|
||||||
|
function resolveColor(type: string): string {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
success: 'success',
|
||||||
|
error: 'error',
|
||||||
|
warning: 'warning',
|
||||||
|
info: 'info',
|
||||||
|
}
|
||||||
|
return colors[type] ?? 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveIcon(type: string): string {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
success: 'tabler-circle-check',
|
||||||
|
error: 'tabler-alert-circle',
|
||||||
|
warning: 'tabler-alert-triangle',
|
||||||
|
info: 'tabler-info-circle',
|
||||||
|
}
|
||||||
|
return icons[type] ?? 'tabler-info-circle'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div class="toast-stack">
|
||||||
|
<TransitionGroup name="toast">
|
||||||
|
<VAlert
|
||||||
|
v-for="toast in toastStore.toasts"
|
||||||
|
:key="toast.id"
|
||||||
|
:type="toast.type"
|
||||||
|
:color="resolveColor(toast.type)"
|
||||||
|
variant="flat"
|
||||||
|
closable
|
||||||
|
class="toast-item mb-2"
|
||||||
|
elevation="8"
|
||||||
|
@click:close="toastStore.dismiss(toast.id)"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon :icon="resolveIcon(toast.type)" />
|
||||||
|
</template>
|
||||||
|
{{ toast.message }}
|
||||||
|
</VAlert>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.toast-stack {
|
||||||
|
position: fixed;
|
||||||
|
top: 80px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 400;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-enter-active {
|
||||||
|
animation: slide-in-right 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-leave-active {
|
||||||
|
animation: slide-out-right 0.3s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Link, usePage } from '@inertiajs/vue3'
|
import { Link, usePage } from '@inertiajs/vue3'
|
||||||
import { computed } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { accountNavItems } from '@/navigation/account'
|
import { accountNavItems } from '@/navigation/account'
|
||||||
import FlashMessages from '@/Components/FlashMessages.vue'
|
import AppSidebar from '@/Components/AppSidebar.vue'
|
||||||
import NotificationBell from '@/Components/NotificationBell.vue'
|
import AppTopNavbar from '@/Components/AppTopNavbar.vue'
|
||||||
import ThemeSwitcher from '@/Components/ThemeSwitcher.vue'
|
import CommandPalette from '@/Components/CommandPalette.vue'
|
||||||
import VerticalNavLayout from '@layouts/components/VerticalNavLayout.vue'
|
import NotificationPanel from '@/Components/NotificationPanel.vue'
|
||||||
|
import ToastStack from '@/Components/ToastStack.vue'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
|
||||||
interface AuthUser {
|
interface AuthUser {
|
||||||
name: string
|
name: string
|
||||||
@@ -16,6 +18,7 @@ interface PageProps {
|
|||||||
auth: { user: AuthUser | null }
|
auth: { user: AuthUser | null }
|
||||||
domains: { marketing: string; account: string; admin: string }
|
domains: { marketing: string; account: string; admin: string }
|
||||||
impersonating: boolean
|
impersonating: boolean
|
||||||
|
flash?: { success?: string; error?: string; info?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = usePage()
|
const page = usePage()
|
||||||
@@ -23,67 +26,147 @@ const props = computed(() => page.props as unknown as PageProps)
|
|||||||
const user = computed(() => props.value.auth?.user)
|
const user = computed(() => props.value.auth?.user)
|
||||||
const isImpersonating = computed(() => props.value.impersonating)
|
const isImpersonating = computed(() => props.value.impersonating)
|
||||||
const adminUrl = computed(() => `https://${props.value.domains?.admin}`)
|
const adminUrl = computed(() => `https://${props.value.domains?.admin}`)
|
||||||
|
|
||||||
|
const sidebarCollapsed = ref<boolean>(false)
|
||||||
|
const mobileOpen = ref<boolean>(false)
|
||||||
|
const notificationPanelOpen = ref<boolean>(false)
|
||||||
|
const notificationPanel = ref<InstanceType<typeof NotificationPanel> | null>(null)
|
||||||
|
const commandPalette = ref<InstanceType<typeof CommandPalette> | null>(null)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const saved = localStorage.getItem('ezscale-account-nav-collapsed')
|
||||||
|
if (saved !== null) sidebarCollapsed.value = saved === 'true'
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(sidebarCollapsed, (val) => {
|
||||||
|
localStorage.setItem('ezscale-account-nav-collapsed', String(val))
|
||||||
|
})
|
||||||
|
|
||||||
|
const toastStore = useToastStore()
|
||||||
|
watch(() => page.props, () => {
|
||||||
|
const flash = (page.props as unknown as PageProps).flash
|
||||||
|
if (flash?.success) toastStore.success(flash.success)
|
||||||
|
if (flash?.error) toastStore.error(flash.error)
|
||||||
|
if (flash?.info) toastStore.info(flash.info)
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
const paletteItems = computed(() => {
|
||||||
|
const items: Array<{ title: string; icon: string; href: string; section: string }> = []
|
||||||
|
for (const item of accountNavItems) {
|
||||||
|
if ('heading' in item) continue
|
||||||
|
if ('to' in item) {
|
||||||
|
items.push({ title: item.title, icon: item.icon || 'tabler-circle', href: item.to, section: 'Navigation' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VApp>
|
<VApp>
|
||||||
<VerticalNavLayout :nav-items="accountNavItems">
|
<div class="app-layout">
|
||||||
<template #navbar>
|
<AppSidebar
|
||||||
<VSpacer />
|
:nav-items="accountNavItems"
|
||||||
<NotificationBell />
|
v-model:collapsed="sidebarCollapsed"
|
||||||
<ThemeSwitcher />
|
v-model:mobile-open="mobileOpen"
|
||||||
|
/>
|
||||||
|
|
||||||
<span v-if="user" class="text-body-2 ms-3">
|
<div
|
||||||
{{ user.name }}
|
class="app-content-wrapper"
|
||||||
</span>
|
:style="{ marginLeft: sidebarCollapsed ? '72px' : '260px' }"
|
||||||
|
|
||||||
<Link
|
|
||||||
v-if="user"
|
|
||||||
href="/logout"
|
|
||||||
method="post"
|
|
||||||
as="button"
|
|
||||||
class="text-decoration-none ms-2"
|
|
||||||
>
|
|
||||||
<VBtn variant="text" size="small" color="error">
|
|
||||||
<VIcon icon="tabler-logout" start />
|
|
||||||
Log out
|
|
||||||
</VBtn>
|
|
||||||
</Link>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Impersonation Alert (Sticky) -->
|
|
||||||
<VAlert
|
|
||||||
v-if="isImpersonating"
|
|
||||||
type="warning"
|
|
||||||
variant="tonal"
|
|
||||||
prominent
|
|
||||||
class="mb-6"
|
|
||||||
style="position: sticky; top: 0; z-index: 1000;"
|
|
||||||
>
|
>
|
||||||
<VAlertTitle class="d-flex align-center justify-space-between flex-wrap gap-4">
|
<AppTopNavbar
|
||||||
<div class="d-flex align-center gap-2">
|
@toggle-sidebar="mobileOpen = !mobileOpen"
|
||||||
<VIcon icon="tabler-user-shield" />
|
@open-command-palette="commandPalette?.open()"
|
||||||
<span class="font-weight-bold">Impersonation Active</span>
|
>
|
||||||
</div>
|
<VBadge
|
||||||
|
:content="notificationPanel?.unreadCount"
|
||||||
|
:model-value="(notificationPanel?.unreadCount ?? 0) > 0"
|
||||||
|
color="error"
|
||||||
|
overlap
|
||||||
|
>
|
||||||
|
<VBtn
|
||||||
|
icon="tabler-bell"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
@click="notificationPanelOpen = true"
|
||||||
|
/>
|
||||||
|
</VBadge>
|
||||||
|
|
||||||
|
<span v-if="user" class="text-body-2 ms-3">
|
||||||
|
{{ user.name }}
|
||||||
|
</span>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
:href="adminUrl + '/impersonate/stop'"
|
v-if="user"
|
||||||
|
href="/logout"
|
||||||
method="post"
|
method="post"
|
||||||
as="button"
|
as="button"
|
||||||
class="text-decoration-none"
|
class="text-decoration-none ms-2"
|
||||||
>
|
>
|
||||||
<VBtn color="warning" variant="flat" size="small">
|
<VBtn variant="text" size="small" color="error">
|
||||||
<VIcon icon="tabler-logout" start />
|
<VIcon icon="tabler-logout" start />
|
||||||
Stop Impersonating
|
Log out
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</Link>
|
</Link>
|
||||||
</VAlertTitle>
|
</AppTopNavbar>
|
||||||
<div class="mt-2">
|
|
||||||
You are viewing the account as <strong>{{ user?.name }}</strong>. All actions will be attributed to this user.
|
|
||||||
</div>
|
|
||||||
</VAlert>
|
|
||||||
|
|
||||||
<FlashMessages />
|
<main class="app-page-content">
|
||||||
<slot />
|
<!-- Impersonation Alert -->
|
||||||
</VerticalNavLayout>
|
<VAlert
|
||||||
|
v-if="isImpersonating"
|
||||||
|
type="warning"
|
||||||
|
variant="tonal"
|
||||||
|
prominent
|
||||||
|
class="mb-6"
|
||||||
|
>
|
||||||
|
<VAlertTitle class="d-flex align-center justify-space-between flex-wrap gap-4">
|
||||||
|
<div class="d-flex align-center gap-2">
|
||||||
|
<VIcon icon="tabler-user-shield" />
|
||||||
|
<span class="font-weight-bold">Impersonation Active</span>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
:href="adminUrl + '/impersonate/stop'"
|
||||||
|
method="post"
|
||||||
|
as="button"
|
||||||
|
class="text-decoration-none"
|
||||||
|
>
|
||||||
|
<VBtn color="warning" variant="flat" size="small">
|
||||||
|
<VIcon icon="tabler-logout" start />
|
||||||
|
Stop Impersonating
|
||||||
|
</VBtn>
|
||||||
|
</Link>
|
||||||
|
</VAlertTitle>
|
||||||
|
<div class="mt-2">
|
||||||
|
You are viewing the account as <strong>{{ user?.name }}</strong>. All actions will be attributed to this user.
|
||||||
|
</div>
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="app-footer">
|
||||||
|
© {{ new Date().getFullYear() }} EZSCALE. All rights reserved.
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="app-overlay"
|
||||||
|
:class="{ 'app-overlay--visible': mobileOpen }"
|
||||||
|
@click="mobileOpen = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NotificationPanel
|
||||||
|
ref="notificationPanel"
|
||||||
|
v-model="notificationPanelOpen"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CommandPalette
|
||||||
|
ref="commandPalette"
|
||||||
|
:items="paletteItems"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ToastStack />
|
||||||
</VApp>
|
</VApp>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Link, usePage } from '@inertiajs/vue3'
|
import { Link, usePage } from '@inertiajs/vue3'
|
||||||
import { computed } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { adminNavItems } from '@/navigation/admin'
|
import { adminNavItems } from '@/navigation/admin'
|
||||||
import FlashMessages from '@/Components/FlashMessages.vue'
|
import AppSidebar from '@/Components/AppSidebar.vue'
|
||||||
import NotificationBell from '@/Components/NotificationBell.vue'
|
import AppTopNavbar from '@/Components/AppTopNavbar.vue'
|
||||||
import ThemeSwitcher from '@/Components/ThemeSwitcher.vue'
|
import CommandPalette from '@/Components/CommandPalette.vue'
|
||||||
import VerticalNavLayout from '@layouts/components/VerticalNavLayout.vue'
|
import NotificationPanel from '@/Components/NotificationPanel.vue'
|
||||||
|
import ToastStack from '@/Components/ToastStack.vue'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
|
||||||
interface AuthUser {
|
interface AuthUser {
|
||||||
name: string
|
name: string
|
||||||
@@ -15,58 +17,144 @@ interface AuthUser {
|
|||||||
interface PageProps {
|
interface PageProps {
|
||||||
auth: { user: AuthUser | null }
|
auth: { user: AuthUser | null }
|
||||||
domains: { marketing: string; account: string; admin: string }
|
domains: { marketing: string; account: string; admin: string }
|
||||||
|
flash?: { success?: string; error?: string; info?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = usePage()
|
const page = usePage()
|
||||||
const props = computed(() => page.props as unknown as PageProps)
|
const props = computed(() => page.props as unknown as PageProps)
|
||||||
const user = computed(() => props.value.auth?.user)
|
const user = computed(() => props.value.auth?.user)
|
||||||
const accountUrl = computed(() => `https://${props.value.domains?.account}`)
|
const accountUrl = computed(() => `https://${props.value.domains?.account}`)
|
||||||
|
|
||||||
|
const sidebarCollapsed = ref<boolean>(false)
|
||||||
|
const mobileOpen = ref<boolean>(false)
|
||||||
|
const notificationPanelOpen = ref<boolean>(false)
|
||||||
|
const notificationPanel = ref<InstanceType<typeof NotificationPanel> | null>(null)
|
||||||
|
const commandPalette = ref<InstanceType<typeof CommandPalette> | null>(null)
|
||||||
|
|
||||||
|
// Persist sidebar collapse state
|
||||||
|
onMounted(() => {
|
||||||
|
const saved = localStorage.getItem('ezscale-nav-collapsed')
|
||||||
|
if (saved !== null) sidebarCollapsed.value = saved === 'true'
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(sidebarCollapsed, (val) => {
|
||||||
|
localStorage.setItem('ezscale-nav-collapsed', String(val))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Flash messages to toasts
|
||||||
|
const toastStore = useToastStore()
|
||||||
|
watch(() => page.props, () => {
|
||||||
|
const flash = (page.props as unknown as PageProps).flash
|
||||||
|
if (flash?.success) toastStore.success(flash.success)
|
||||||
|
if (flash?.error) toastStore.error(flash.error)
|
||||||
|
if (flash?.info) toastStore.info(flash.info)
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Command palette items
|
||||||
|
const paletteItems = computed(() => {
|
||||||
|
const items: Array<{ title: string; icon: string; href: string; section: string }> = []
|
||||||
|
for (const item of adminNavItems) {
|
||||||
|
if ('heading' in item) continue
|
||||||
|
if ('to' in item) {
|
||||||
|
items.push({ title: item.title, icon: item.icon || 'tabler-circle', href: item.to, section: 'Navigation' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VApp>
|
<VApp>
|
||||||
<VerticalNavLayout :nav-items="adminNavItems">
|
<div class="app-layout">
|
||||||
<template #navbar>
|
<AppSidebar
|
||||||
<VChip size="small" color="error" variant="flat" class="me-2">
|
:nav-items="adminNavItems"
|
||||||
Admin
|
v-model:collapsed="sidebarCollapsed"
|
||||||
</VChip>
|
v-model:mobile-open="mobileOpen"
|
||||||
|
/>
|
||||||
|
|
||||||
<VSpacer />
|
<div
|
||||||
|
class="app-content-wrapper"
|
||||||
<a
|
:style="{ marginLeft: sidebarCollapsed ? '72px' : '260px' }"
|
||||||
v-if="user"
|
>
|
||||||
:href="accountUrl + '/dashboard'"
|
<AppTopNavbar
|
||||||
class="text-decoration-none"
|
@toggle-sidebar="mobileOpen = !mobileOpen"
|
||||||
|
@open-command-palette="commandPalette?.open()"
|
||||||
>
|
>
|
||||||
<VBtn variant="text" size="small">
|
<VChip size="small" color="error" variant="flat" class="me-2">
|
||||||
<VIcon icon="tabler-external-link" start />
|
Admin
|
||||||
Customer View
|
</VChip>
|
||||||
</VBtn>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<NotificationBell />
|
<a
|
||||||
<ThemeSwitcher />
|
v-if="user"
|
||||||
|
:href="accountUrl + '/dashboard'"
|
||||||
|
class="text-decoration-none"
|
||||||
|
>
|
||||||
|
<VBtn variant="text" size="small">
|
||||||
|
<VIcon icon="tabler-external-link" start />
|
||||||
|
Customer View
|
||||||
|
</VBtn>
|
||||||
|
</a>
|
||||||
|
|
||||||
<span v-if="user" class="text-body-2 ms-3">
|
<!-- Notification bell -->
|
||||||
{{ user.name }}
|
<VBadge
|
||||||
</span>
|
:content="notificationPanel?.unreadCount"
|
||||||
|
:model-value="(notificationPanel?.unreadCount ?? 0) > 0"
|
||||||
|
color="error"
|
||||||
|
overlap
|
||||||
|
>
|
||||||
|
<VBtn
|
||||||
|
icon="tabler-bell"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
@click="notificationPanelOpen = true"
|
||||||
|
/>
|
||||||
|
</VBadge>
|
||||||
|
|
||||||
<Link
|
<span v-if="user" class="text-body-2 ms-3">
|
||||||
v-if="user"
|
{{ user.name }}
|
||||||
href="/logout"
|
</span>
|
||||||
method="post"
|
|
||||||
as="button"
|
|
||||||
class="text-decoration-none ms-2"
|
|
||||||
>
|
|
||||||
<VBtn variant="text" size="small" color="error">
|
|
||||||
<VIcon icon="tabler-logout" start />
|
|
||||||
Log out
|
|
||||||
</VBtn>
|
|
||||||
</Link>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<FlashMessages />
|
<Link
|
||||||
<slot />
|
v-if="user"
|
||||||
</VerticalNavLayout>
|
href="/logout"
|
||||||
|
method="post"
|
||||||
|
as="button"
|
||||||
|
class="text-decoration-none ms-2"
|
||||||
|
>
|
||||||
|
<VBtn variant="text" size="small" color="error">
|
||||||
|
<VIcon icon="tabler-logout" start />
|
||||||
|
Log out
|
||||||
|
</VBtn>
|
||||||
|
</Link>
|
||||||
|
</AppTopNavbar>
|
||||||
|
|
||||||
|
<main class="app-page-content">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="app-footer">
|
||||||
|
© {{ new Date().getFullYear() }} EZSCALE. All rights reserved.
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile overlay -->
|
||||||
|
<div
|
||||||
|
class="app-overlay"
|
||||||
|
:class="{ 'app-overlay--visible': mobileOpen }"
|
||||||
|
@click="mobileOpen = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NotificationPanel
|
||||||
|
ref="notificationPanel"
|
||||||
|
v-model="notificationPanelOpen"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CommandPalette
|
||||||
|
ref="commandPalette"
|
||||||
|
:items="paletteItems"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ToastStack />
|
||||||
</VApp>
|
</VApp>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { usePage } from '@inertiajs/vue3'
|
import { usePage } from '@inertiajs/vue3'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useTheme } from 'vuetify'
|
|
||||||
import logoWhite from '@images/ezscale_logo_white.png'
|
import logoWhite from '@images/ezscale_logo_white.png'
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
@@ -11,62 +10,50 @@ interface PageProps {
|
|||||||
const page = usePage()
|
const page = usePage()
|
||||||
const props = computed(() => page.props as unknown as PageProps)
|
const props = computed(() => page.props as unknown as PageProps)
|
||||||
const marketingUrl = computed(() => `https://${props.value.domains?.marketing}`)
|
const marketingUrl = computed(() => `https://${props.value.domains?.marketing}`)
|
||||||
const theme = useTheme()
|
|
||||||
const isDark = computed(() => theme.global.current.value.dark)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VApp>
|
<VApp>
|
||||||
<!-- Logo (absolute top-left, above everything) -->
|
<!-- Logo -->
|
||||||
<a :href="marketingUrl" class="auth-logo d-flex align-center gap-x-3">
|
<a :href="marketingUrl" class="auth-logo d-flex align-center gap-x-3">
|
||||||
<img
|
<img :src="logoWhite" alt="EZSCALE" style="height: 38px; width: auto;" />
|
||||||
:src="logoWhite"
|
|
||||||
alt="EZSCALE"
|
|
||||||
:class="{ 'logo-light': !isDark }"
|
|
||||||
style="height: 38px; width: auto;"
|
|
||||||
>
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<VRow
|
<VRow no-gutters class="auth-wrapper" style="min-height: 100vh;">
|
||||||
no-gutters
|
<!-- Left: Gradient Art -->
|
||||||
class="auth-wrapper bg-surface"
|
<VCol md="7" class="d-none d-md-flex">
|
||||||
>
|
<div class="auth-left-panel w-100 d-flex align-center justify-center" style="position: relative; overflow: hidden;">
|
||||||
<!-- Left: Illustration -->
|
<!-- Abstract gradient background -->
|
||||||
<VCol
|
<div class="auth-gradient-bg" />
|
||||||
md="8"
|
|
||||||
class="d-none d-md-flex"
|
|
||||||
>
|
|
||||||
<div class="position-relative bg-background w-100 me-0">
|
|
||||||
<div
|
|
||||||
class="d-flex align-center justify-center w-100 h-100"
|
|
||||||
style="padding-inline: 6.25rem;"
|
|
||||||
>
|
|
||||||
<div class="d-flex flex-column align-center justify-center" style="max-width: 500px;">
|
|
||||||
<img
|
|
||||||
:src="logoWhite"
|
|
||||||
alt="EZSCALE"
|
|
||||||
class="auth-illustration mb-8"
|
|
||||||
:class="{ 'logo-light': !isDark }"
|
|
||||||
style="max-width: 360px; height: auto;"
|
|
||||||
>
|
|
||||||
<p class="text-body-1 text-center mb-0" style="max-width: 400px;">
|
|
||||||
Deploy VPS, Dedicated Servers, Web Hosting, and Game Servers in minutes.
|
|
||||||
Enterprise-grade infrastructure made simple.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="d-flex ga-8 mt-8">
|
<!-- Grid pattern overlay -->
|
||||||
<div class="text-center">
|
<div class="auth-grid-pattern" />
|
||||||
<div class="text-h5 font-weight-bold text-primary">99.99%</div>
|
|
||||||
<div class="text-caption">Uptime</div>
|
<!-- Content -->
|
||||||
</div>
|
<div class="d-flex flex-column align-center justify-center position-relative" style="z-index: 1; max-width: 480px; padding: 48px;">
|
||||||
<div class="text-center">
|
<img
|
||||||
<div class="text-h5 font-weight-bold text-primary">50+</div>
|
:src="logoWhite"
|
||||||
<div class="text-caption">Locations</div>
|
alt="EZSCALE"
|
||||||
</div>
|
class="mb-8"
|
||||||
<div class="text-center">
|
style="max-width: 200px; height: auto;"
|
||||||
<div class="text-h5 font-weight-bold text-primary">24/7</div>
|
>
|
||||||
<div class="text-caption">Support</div>
|
<p class="text-body-1 text-center mb-0" style="color: rgba(255,255,255,0.7); max-width: 400px;">
|
||||||
</div>
|
Deploy VPS, Dedicated Servers, Web Hosting, and Game Servers in minutes.
|
||||||
|
Enterprise-grade infrastructure made simple.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="d-flex ga-8 mt-8">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-h5 font-weight-bold" style="color: #3b82f6;">99.99%</div>
|
||||||
|
<div class="text-caption" style="color: rgba(255,255,255,0.5);">Uptime</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-h5 font-weight-bold" style="color: #3b82f6;">50+</div>
|
||||||
|
<div class="text-caption" style="color: rgba(255,255,255,0.5);">Locations</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-h5 font-weight-bold" style="color: #3b82f6;">24/7</div>
|
||||||
|
<div class="text-caption" style="color: rgba(255,255,255,0.5);">Support</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,16 +61,8 @@ const isDark = computed(() => theme.global.current.value.dark)
|
|||||||
</VCol>
|
</VCol>
|
||||||
|
|
||||||
<!-- Right: Auth Form -->
|
<!-- Right: Auth Form -->
|
||||||
<VCol
|
<VCol cols="12" md="5" class="d-flex align-center justify-center">
|
||||||
cols="12"
|
<VCard flat :max-width="440" class="pa-6 w-100" style="max-width: 440px;">
|
||||||
md="4"
|
|
||||||
class="auth-card-v2 d-flex align-center justify-center"
|
|
||||||
>
|
|
||||||
<VCard
|
|
||||||
flat
|
|
||||||
:max-width="500"
|
|
||||||
class="mt-12 mt-sm-0 pa-6"
|
|
||||||
>
|
|
||||||
<slot />
|
<slot />
|
||||||
</VCard>
|
</VCard>
|
||||||
</VCol>
|
</VCol>
|
||||||
@@ -91,8 +70,35 @@ const isDark = computed(() => theme.global.current.value.dark)
|
|||||||
</VApp>
|
</VApp>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss" scoped>
|
||||||
.logo-light {
|
.auth-logo {
|
||||||
filter: brightness(0) saturate(100%);
|
position: absolute;
|
||||||
|
top: 24px;
|
||||||
|
left: 32px;
|
||||||
|
z-index: 10;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-left-panel {
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-gradient-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 30% 20%, rgba(29, 78, 216, 0.3) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse at 70% 80%, rgba(6, 182, 212, 0.15) 0%, transparent 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-grid-pattern {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
||||||
|
background-size: 60px 60px;
|
||||||
|
mask-image: radial-gradient(ellipse 80% 80% at 50% 50%, black 40%, transparent 100%);
|
||||||
|
-webkit-mask-image: radial-gradient(ellipse 80% 80% at 50% 50%, black 40%, transparent 100%);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import logoWhite from '@images/ezscale_logo_white.png'
|
|||||||
|
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const isDark = computed(() => theme.global.current.value.dark)
|
const isDark = computed(() => theme.global.current.value.dark)
|
||||||
|
|
||||||
const isScrolled = ref(false)
|
const isScrolled = ref(false)
|
||||||
|
|
||||||
function handleScroll(): void {
|
function handleScroll(): void {
|
||||||
@@ -25,20 +24,14 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const currentPath = computed<string>(() => {
|
const currentPath = computed<string>(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') return window.location.pathname
|
||||||
return window.location.pathname
|
|
||||||
}
|
|
||||||
return '/'
|
return '/'
|
||||||
})
|
})
|
||||||
|
|
||||||
const newsletterForm = useForm({
|
const newsletterForm = useForm({ email: '' })
|
||||||
email: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const newsletterSuccess = ref(false)
|
const newsletterSuccess = ref(false)
|
||||||
|
|
||||||
function subscribeNewsletter(): void {
|
function subscribeNewsletter(): void {
|
||||||
// For now, just show success state (no backend endpoint yet)
|
|
||||||
if (newsletterForm.email) {
|
if (newsletterForm.email) {
|
||||||
newsletterSuccess.value = true
|
newsletterSuccess.value = true
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -67,7 +60,6 @@ const footerLinks = {
|
|||||||
{ title: 'About', href: '/about' },
|
{ title: 'About', href: '/about' },
|
||||||
{ title: 'Pricing', href: '/pricing' },
|
{ title: 'Pricing', href: '/pricing' },
|
||||||
{ title: 'Contact', href: '/contact' },
|
{ title: 'Contact', href: '/contact' },
|
||||||
{ title: 'Blog', href: '/blog' },
|
|
||||||
],
|
],
|
||||||
support: [
|
support: [
|
||||||
{ title: 'Help Center', href: 'https://ezscale.support' },
|
{ title: 'Help Center', href: 'https://ezscale.support' },
|
||||||
@@ -84,32 +76,36 @@ const footerLinks = {
|
|||||||
|
|
||||||
const socialLinks = [
|
const socialLinks = [
|
||||||
{ title: 'twitter', icon: 'tabler-brand-twitter-filled', href: '#' },
|
{ title: 'twitter', icon: 'tabler-brand-twitter-filled', href: '#' },
|
||||||
{ title: 'facebook', icon: 'tabler-brand-facebook-filled', href: '#' },
|
|
||||||
{ title: 'github', icon: 'tabler-brand-github-filled', href: '#' },
|
|
||||||
{ title: 'discord', icon: 'tabler-brand-discord-filled', href: '#' },
|
{ title: 'discord', icon: 'tabler-brand-discord-filled', href: '#' },
|
||||||
|
{ title: 'github', icon: 'tabler-brand-github-filled', href: '#' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const mobileMenuOpen = ref(false)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VApp>
|
<VApp>
|
||||||
|
<!-- Navbar -->
|
||||||
<VAppBar
|
<VAppBar
|
||||||
:elevation="isScrolled ? 4 : 0"
|
:elevation="0"
|
||||||
:class="isScrolled ? 'navbar-scrolled' : 'navbar-transparent'"
|
:class="isScrolled ? 'navbar-scrolled' : 'navbar-transparent'"
|
||||||
class="marketing-navbar"
|
class="marketing-navbar"
|
||||||
|
flat
|
||||||
>
|
>
|
||||||
<VContainer class="d-flex align-center">
|
<VContainer class="d-flex align-center">
|
||||||
<a href="/" class="d-inline-flex align-center">
|
<a href="/" class="d-inline-flex align-center">
|
||||||
<img
|
<img
|
||||||
:src="logoWhite"
|
:src="logoWhite"
|
||||||
alt="EZSCALE"
|
alt="EZSCALE"
|
||||||
:class="{ 'logo-light': !isDark }"
|
:class="{ 'logo-light': !isDark && isScrolled }"
|
||||||
style="height: 32px; width: auto;"
|
style="height: 32px; width: auto;"
|
||||||
>
|
>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
|
|
||||||
<div class="d-flex align-center ga-1">
|
<!-- Desktop nav -->
|
||||||
|
<div class="d-none d-md-flex align-center ga-1">
|
||||||
<template v-for="item in marketingNavItems" :key="item.title">
|
<template v-for="item in marketingNavItems" :key="item.title">
|
||||||
<VMenu v-if="item.children" open-on-hover>
|
<VMenu v-if="item.children" open-on-hover>
|
||||||
<template #activator="{ props: menuProps }">
|
<template #activator="{ props: menuProps }">
|
||||||
@@ -118,7 +114,6 @@ const socialLinks = [
|
|||||||
<VIcon icon="tabler-chevron-down" end size="small" />
|
<VIcon icon="tabler-chevron-down" end size="small" />
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<VList>
|
<VList>
|
||||||
<VListItem
|
<VListItem
|
||||||
v-for="child in item.children"
|
v-for="child in item.children"
|
||||||
@@ -153,194 +148,153 @@ const socialLinks = [
|
|||||||
<div class="d-flex align-center ga-2">
|
<div class="d-flex align-center ga-2">
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
|
|
||||||
<a :href="accountUrl + '/login'" class="text-decoration-none">
|
<a :href="accountUrl + '/login'" class="text-decoration-none d-none d-sm-inline">
|
||||||
<VBtn variant="text" size="small">
|
<VBtn variant="text" size="small">Login</VBtn>
|
||||||
Login
|
|
||||||
</VBtn>
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a :href="accountUrl + '/register'" class="text-decoration-none">
|
<a :href="accountUrl + '/register'" class="text-decoration-none d-none d-sm-inline">
|
||||||
<VBtn variant="flat" size="small" color="primary">
|
<VBtn variant="flat" size="small" color="primary">Sign Up</VBtn>
|
||||||
Sign Up
|
|
||||||
</VBtn>
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- Mobile hamburger -->
|
||||||
|
<VBtn
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
class="d-md-none"
|
||||||
|
@click="mobileMenuOpen = true"
|
||||||
|
>
|
||||||
|
<VIcon icon="tabler-menu-2" />
|
||||||
|
</VBtn>
|
||||||
</div>
|
</div>
|
||||||
</VContainer>
|
</VContainer>
|
||||||
</VAppBar>
|
</VAppBar>
|
||||||
|
|
||||||
|
<!-- Mobile slide-in menu -->
|
||||||
|
<VNavigationDrawer
|
||||||
|
v-model="mobileMenuOpen"
|
||||||
|
temporary
|
||||||
|
location="end"
|
||||||
|
width="280"
|
||||||
|
>
|
||||||
|
<VList>
|
||||||
|
<template v-for="item in marketingNavItems" :key="item.title">
|
||||||
|
<template v-if="item.children">
|
||||||
|
<VListItem
|
||||||
|
v-for="child in item.children"
|
||||||
|
:key="child.href"
|
||||||
|
:href="child.href"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon v-if="child.icon" :icon="child.icon" />
|
||||||
|
</template>
|
||||||
|
<VListItemTitle>{{ child.title }}</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
</template>
|
||||||
|
<VListItem v-else :href="item.href">
|
||||||
|
<VListItemTitle>{{ item.title }}</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
</template>
|
||||||
|
<VDivider class="my-2" />
|
||||||
|
<VListItem :href="accountUrl + '/login'">
|
||||||
|
<VListItemTitle>Login</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
<VListItem :href="accountUrl + '/register'">
|
||||||
|
<VListItemTitle class="text-primary font-weight-bold">Sign Up</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
</VNavigationDrawer>
|
||||||
|
|
||||||
<VMain>
|
<VMain>
|
||||||
<slot />
|
<slot />
|
||||||
</VMain>
|
</VMain>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Mega Footer -->
|
||||||
<div class="footer">
|
<footer class="mega-footer">
|
||||||
<div class="footer-top pt-11">
|
<VContainer>
|
||||||
<VContainer>
|
<VRow>
|
||||||
<VRow>
|
<VCol cols="12" md="4">
|
||||||
<!-- Logo + Description + Newsletter -->
|
<div class="mb-4" :class="$vuetify.display.smAndDown ? 'w-100' : 'w-75'">
|
||||||
<VCol cols="12" md="4">
|
<div class="d-flex align-center mb-6">
|
||||||
<div
|
<img :src="logoWhite" alt="EZSCALE" style="height: 32px; width: auto;" />
|
||||||
class="mb-4"
|
|
||||||
:class="$vuetify.display.smAndDown ? 'w-100' : 'w-75'"
|
|
||||||
>
|
|
||||||
<div class="d-flex align-center mb-6">
|
|
||||||
<img
|
|
||||||
:src="logoWhite"
|
|
||||||
alt="EZSCALE"
|
|
||||||
style="height: 32px; width: auto;"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-white-variant mb-6">
|
|
||||||
High-performance VPS, dedicated servers, and hosting solutions with 24/7 support and enterprise-grade infrastructure.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<VForm class="subscribe-form d-flex align-center" @submit.prevent="subscribeNewsletter">
|
|
||||||
<VTextField
|
|
||||||
v-model="newsletterForm.email"
|
|
||||||
:label="newsletterSuccess ? 'Subscribed!' : 'Subscribe to newsletter'"
|
|
||||||
placeholder="john@email.com"
|
|
||||||
variant="outlined"
|
|
||||||
density="comfortable"
|
|
||||||
hide-details
|
|
||||||
type="email"
|
|
||||||
:color="newsletterSuccess ? 'success' : undefined"
|
|
||||||
/>
|
|
||||||
<VBtn
|
|
||||||
type="submit"
|
|
||||||
class="align-self-end rounded-s-0"
|
|
||||||
:color="newsletterSuccess ? 'success' : undefined"
|
|
||||||
>
|
|
||||||
{{ newsletterSuccess ? 'Done!' : 'Subscribe' }}
|
|
||||||
</VBtn>
|
|
||||||
</VForm>
|
|
||||||
</div>
|
</div>
|
||||||
</VCol>
|
<div style="color: rgba(255,255,255,0.5);" class="mb-6 text-body-2">
|
||||||
|
High-performance VPS, dedicated servers, and hosting solutions with 24/7 support and enterprise-grade infrastructure.
|
||||||
<!-- Products -->
|
|
||||||
<VCol md="2" sm="4" xs="6">
|
|
||||||
<div class="footer-links">
|
|
||||||
<h6 class="footer-title text-h6 mb-6">
|
|
||||||
Products
|
|
||||||
</h6>
|
|
||||||
<ul style="list-style: none; padding: 0;">
|
|
||||||
<li
|
|
||||||
v-for="link in footerLinks.products"
|
|
||||||
:key="link.href"
|
|
||||||
class="mb-4"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
:href="link.href"
|
|
||||||
class="text-white-variant"
|
|
||||||
>
|
|
||||||
{{ link.title }}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</VCol>
|
<VForm class="d-flex" @submit.prevent="subscribeNewsletter">
|
||||||
|
<VTextField
|
||||||
<!-- Company -->
|
v-model="newsletterForm.email"
|
||||||
<VCol md="2" sm="4" xs="6">
|
:label="newsletterSuccess ? 'Subscribed!' : 'Subscribe to newsletter'"
|
||||||
<div class="footer-links">
|
placeholder="john@email.com"
|
||||||
<h6 class="footer-title text-h6 mb-6">
|
variant="outlined"
|
||||||
Company
|
density="comfortable"
|
||||||
</h6>
|
hide-details
|
||||||
<ul style="list-style: none; padding: 0;">
|
type="email"
|
||||||
<li
|
:color="newsletterSuccess ? 'success' : undefined"
|
||||||
v-for="link in footerLinks.company"
|
class="flex-grow-1"
|
||||||
:key="link.href"
|
style="border-top-right-radius: 0; border-bottom-right-radius: 0;"
|
||||||
class="mb-4"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
:href="link.href"
|
|
||||||
class="text-white-variant"
|
|
||||||
>
|
|
||||||
{{ link.title }}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</VCol>
|
|
||||||
|
|
||||||
<!-- Support -->
|
|
||||||
<VCol md="2" sm="4" xs="6">
|
|
||||||
<div class="footer-links">
|
|
||||||
<h6 class="footer-title text-h6 mb-6">
|
|
||||||
Support
|
|
||||||
</h6>
|
|
||||||
<ul style="list-style: none; padding: 0;">
|
|
||||||
<li
|
|
||||||
v-for="link in footerLinks.support"
|
|
||||||
:key="link.href"
|
|
||||||
class="mb-4"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
:href="link.href"
|
|
||||||
class="text-white-variant"
|
|
||||||
>
|
|
||||||
{{ link.title }}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</VCol>
|
|
||||||
|
|
||||||
<!-- Legal -->
|
|
||||||
<VCol md="2" sm="4" xs="6">
|
|
||||||
<div class="footer-links">
|
|
||||||
<h6 class="footer-title text-h6 mb-6">
|
|
||||||
Legal
|
|
||||||
</h6>
|
|
||||||
<ul style="list-style: none; padding: 0;">
|
|
||||||
<li
|
|
||||||
v-for="link in footerLinks.legal"
|
|
||||||
:key="link.href"
|
|
||||||
class="mb-4"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
:href="link.href"
|
|
||||||
class="text-white-variant"
|
|
||||||
>
|
|
||||||
{{ link.title }}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</VCol>
|
|
||||||
</VRow>
|
|
||||||
</VContainer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer Line -->
|
|
||||||
<div class="footer-line w-100">
|
|
||||||
<VContainer>
|
|
||||||
<div class="d-flex justify-space-between flex-wrap gap-y-5 align-center">
|
|
||||||
<div class="text-body-1 text-white-variant text-wrap me-4">
|
|
||||||
© {{ new Date().getFullYear() }}
|
|
||||||
<span class="font-weight-bold ms-1 text-white">EZSCALE</span>,
|
|
||||||
All rights reserved.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-flex gap-x-6">
|
|
||||||
<a
|
|
||||||
v-for="item in socialLinks"
|
|
||||||
:key="item.title"
|
|
||||||
:href="item.href"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<VIcon
|
|
||||||
:icon="item.icon"
|
|
||||||
size="16"
|
|
||||||
color="white"
|
|
||||||
/>
|
/>
|
||||||
</a>
|
<VBtn
|
||||||
|
type="submit"
|
||||||
|
class="rounded-s-0"
|
||||||
|
style="height: 40px;"
|
||||||
|
:color="newsletterSuccess ? 'success' : 'primary'"
|
||||||
|
>
|
||||||
|
{{ newsletterSuccess ? 'Done!' : 'Subscribe' }}
|
||||||
|
</VBtn>
|
||||||
|
</VForm>
|
||||||
</div>
|
</div>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol md="2" sm="4" xs="6">
|
||||||
|
<div class="footer-title">Products</div>
|
||||||
|
<a v-for="link in footerLinks.products" :key="link.href" :href="link.href" class="footer-link">
|
||||||
|
{{ link.title }}
|
||||||
|
</a>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol md="2" sm="4" xs="6">
|
||||||
|
<div class="footer-title">Company</div>
|
||||||
|
<a v-for="link in footerLinks.company" :key="link.href" :href="link.href" class="footer-link">
|
||||||
|
{{ link.title }}
|
||||||
|
</a>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol md="2" sm="4" xs="6">
|
||||||
|
<div class="footer-title">Support</div>
|
||||||
|
<a v-for="link in footerLinks.support" :key="link.href" :href="link.href" class="footer-link">
|
||||||
|
{{ link.title }}
|
||||||
|
</a>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol md="2" sm="4" xs="6">
|
||||||
|
<div class="footer-title">Legal</div>
|
||||||
|
<a v-for="link in footerLinks.legal" :key="link.href" :href="link.href" class="footer-link">
|
||||||
|
{{ link.title }}
|
||||||
|
</a>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
|
||||||
|
<div class="footer-bottom d-flex justify-space-between flex-wrap gap-y-4 align-center">
|
||||||
|
<div class="text-body-2" style="color: rgba(255,255,255,0.4);">
|
||||||
|
© {{ new Date().getFullYear() }}
|
||||||
|
<span class="font-weight-bold ms-1" style="color: rgba(255,255,255,0.7);">EZSCALE</span>,
|
||||||
|
All rights reserved.
|
||||||
</div>
|
</div>
|
||||||
</VContainer>
|
<div class="d-flex gap-x-4">
|
||||||
</div>
|
<a
|
||||||
</div>
|
v-for="item in socialLinks"
|
||||||
|
:key="item.title"
|
||||||
|
:href="item.href"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<VIcon :icon="item.icon" size="16" color="white" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VContainer>
|
||||||
|
</footer>
|
||||||
</VApp>
|
</VApp>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -349,67 +303,14 @@ const socialLinks = [
|
|||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navbar-transparent {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
.navbar-scrolled {
|
.navbar-scrolled {
|
||||||
backdrop-filter: blur(10px);
|
background: rgba(var(--v-theme-surface), 0.85) !important;
|
||||||
}
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
.footer-title {
|
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.06);
|
||||||
color: rgba(255, 255, 255, 92%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-top {
|
|
||||||
border-radius: 60px 60px 0 0;
|
|
||||||
background-color: #2f3349;
|
|
||||||
background-size: cover;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-links {
|
|
||||||
a {
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #fff !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-line {
|
|
||||||
background: #282c3e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-white-variant {
|
|
||||||
color: rgba(255, 255, 255, 70%);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.subscribe-form {
|
|
||||||
.v-label {
|
|
||||||
color: rgba(225, 222, 245, 90%) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-field {
|
|
||||||
border-end-end-radius: 0;
|
|
||||||
border-end-start-radius: 10px;
|
|
||||||
border-start-end-radius: 0;
|
|
||||||
border-start-start-radius: 10px;
|
|
||||||
|
|
||||||
input.v-field__input::placeholder {
|
|
||||||
color: rgba(225, 222, 245, 40%) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
input.v-field__input {
|
|
||||||
color: rgba(255, 255, 255, 78%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
@media (min-width: 600px) and (max-width: 960px) {
|
|
||||||
.v-container {
|
|
||||||
padding-inline: 2rem !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import type { VerticalNavItems } from '@layouts/types'
|
import type { VerticalNavItems } from '@layouts/types'
|
||||||
|
|
||||||
export const adminNavItems: VerticalNavItems = [
|
export const adminNavItems: VerticalNavItems = [
|
||||||
{ heading: 'Dashboard' },
|
{ heading: 'Overview' },
|
||||||
{ title: 'Analytics', to: '/dashboard', icon: 'tabler-smart-home' },
|
{ title: 'Dashboard', to: '/dashboard', icon: 'tabler-smart-home' },
|
||||||
|
|
||||||
{ heading: 'Management' },
|
{ heading: 'Customers' },
|
||||||
{ title: 'Customers', to: '/customers', icon: 'tabler-users' },
|
{ title: 'Customers', to: '/customers', icon: 'tabler-users' },
|
||||||
|
|
||||||
|
{ heading: 'Infrastructure' },
|
||||||
{ title: 'Plans', to: '/plans', icon: 'tabler-package' },
|
{ title: 'Plans', to: '/plans', icon: 'tabler-package' },
|
||||||
{ title: 'Services', to: '/services', icon: 'tabler-server' },
|
{ title: 'Services', to: '/services', icon: 'tabler-server' },
|
||||||
{ title: 'Orders', to: '/orders', icon: 'tabler-shopping-cart' },
|
{ title: 'Orders', to: '/orders', icon: 'tabler-shopping-cart' },
|
||||||
@@ -13,11 +15,13 @@ export const adminNavItems: VerticalNavItems = [
|
|||||||
{ heading: 'Billing' },
|
{ heading: 'Billing' },
|
||||||
{ title: 'Invoices', to: '/invoices', icon: 'tabler-file-invoice' },
|
{ title: 'Invoices', to: '/invoices', icon: 'tabler-file-invoice' },
|
||||||
{ title: 'Coupons', to: '/coupons', icon: 'tabler-discount-2' },
|
{ title: 'Coupons', to: '/coupons', icon: 'tabler-discount-2' },
|
||||||
|
{ title: 'Tax Rates', to: '/tax-rates', icon: 'tabler-receipt-tax' },
|
||||||
|
|
||||||
{ heading: 'Support' },
|
{ heading: 'Support' },
|
||||||
{ title: 'Tickets', to: '/tickets', icon: 'tabler-message-circle' },
|
{ title: 'Tickets', to: '/tickets', icon: 'tabler-message-circle' },
|
||||||
|
|
||||||
{ heading: 'System' },
|
{ heading: 'System' },
|
||||||
{ title: 'Audit Logs', to: '/audit-logs', icon: 'tabler-clipboard-list' },
|
|
||||||
{ title: 'Settings', to: '/settings', icon: 'tabler-settings' },
|
{ title: 'Settings', to: '/settings', icon: 'tabler-settings' },
|
||||||
|
{ title: 'Email Templates', to: '/email-templates', icon: 'tabler-mail-cog' },
|
||||||
|
{ title: 'Audit Logs', to: '/audit-logs', icon: 'tabler-clipboard-list' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ export const marketingNavItems: MarketingNavItem[] = [
|
|||||||
{ title: 'Dedicated Servers', href: '/dedicated-servers', description: 'Bare metal power', icon: 'tabler-server-2' },
|
{ title: 'Dedicated Servers', href: '/dedicated-servers', description: 'Bare metal power', icon: 'tabler-server-2' },
|
||||||
{ title: 'Web Hosting', href: '/web-hosting', description: 'Managed web hosting', icon: 'tabler-world' },
|
{ title: 'Web Hosting', href: '/web-hosting', description: 'Managed web hosting', icon: 'tabler-world' },
|
||||||
{ title: 'Game Servers', href: '/game-servers', description: 'Low-latency game hosting', icon: 'tabler-device-gamepad-2' },
|
{ title: 'Game Servers', href: '/game-servers', description: 'Low-latency game hosting', icon: 'tabler-device-gamepad-2' },
|
||||||
{ title: 'Battlefield ACP', href: '/battlefield-acp', description: 'BF server admin panel', icon: 'tabler-military-rank' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ title: 'Pricing', href: '/pricing' },
|
{ title: 'Pricing', href: '/pricing' },
|
||||||
|
{ title: 'Developers', href: '/api-docs' },
|
||||||
{ title: 'About', href: '/about' },
|
{ title: 'About', href: '/about' },
|
||||||
{ title: 'Contact', href: '/contact' },
|
{ title: 'Contact', href: '/contact' },
|
||||||
]
|
]
|
||||||
|
|||||||
36
website/resources/ts/stores/toast.ts
Normal file
36
website/resources/ts/stores/toast.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: number
|
||||||
|
message: string
|
||||||
|
type: 'success' | 'error' | 'warning' | 'info'
|
||||||
|
timeout?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 0
|
||||||
|
|
||||||
|
export const useToastStore = defineStore('toast', () => {
|
||||||
|
const toasts = ref<Toast[]>([])
|
||||||
|
|
||||||
|
function show(message: string, type: Toast['type'] = 'info', timeout = 5000): void {
|
||||||
|
const id = nextId++
|
||||||
|
toasts.value.push({ id, message, type, timeout })
|
||||||
|
|
||||||
|
if (timeout > 0) {
|
||||||
|
setTimeout(() => dismiss(id), timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss(id: number): void {
|
||||||
|
const index = toasts.value.findIndex(t => t.id === id)
|
||||||
|
if (index > -1) toasts.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function success(message: string): void { show(message, 'success') }
|
||||||
|
function error(message: string): void { show(message, 'error') }
|
||||||
|
function warning(message: string): void { show(message, 'warning') }
|
||||||
|
function info(message: string): void { show(message, 'info') }
|
||||||
|
|
||||||
|
return { toasts, show, dismiss, success, error, warning, info }
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user