fix: Switch to pure Vuetify components for unified sidebar

- Remove Nuxt UI dependency that was causing conflicts
- Use v-navigation-drawer with rail prop for collapsible functionality
- Implement proper Vuetify list components for navigation
- Add responsive behavior with drawer toggle on mobile
- Fix layout structure to work with existing Vuetify setup
This commit is contained in:
Matt 2025-07-11 17:04:13 -04:00
parent 7244349fe7
commit 9b045c7b97
2 changed files with 144 additions and 145 deletions

View File

@ -1,146 +1,132 @@
<template> <template>
<div class="min-h-screen bg-gray-50 flex"> <v-app>
<!-- Sidebar --> <v-navigation-drawer
<div v-model="drawer"
:class="[ :rail="rail"
'bg-white border-r border-gray-200 transition-all duration-300', permanent
sidebarOpen ? 'w-64' : 'w-0 overflow-hidden', color="white"
'lg:w-64' class="elevation-2"
]"
> >
<div class="h-full flex flex-col"> <!-- Logo and Title -->
<!-- Header --> <v-list>
<div class="px-4 py-4 border-b border-gray-100"> <v-list-item
<div class="flex items-center gap-3"> class="px-2"
<img prepend-avatar="/Port Nimara New Logo-Circular Frame.png"
src="/Port Nimara New Logo-Circular Frame.png" :title="rail ? '' : 'Port Nimara'"
class="h-8 w-8" :subtitle="rail ? '' : 'Client Portal'"
alt="Port Nimara" >
<template v-slot:append>
<v-btn
icon
variant="text"
size="small"
@click.stop="rail = !rail"
> >
<div class="flex flex-col"> <v-icon>{{ rail ? 'mdi-menu' : 'mdi-chevron-left' }}</v-icon>
<span class="text-sm font-semibold text-gray-900">Port Nimara</span> </v-btn>
<span class="text-xs text-gray-500">Client Portal</span> </template>
</div> </v-list-item>
</div> </v-list>
</div>
<!-- Navigation --> <v-divider></v-divider>
<nav class="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
<NuxtLink
v-for="item in navigationItems"
:key="item.to"
:to="item.to"
class="group flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors duration-200"
:class="[
route.path === item.to
? 'bg-blue-50 text-blue-700'
: 'text-gray-700 hover:bg-gray-100'
]"
>
<Icon
:name="item.icon"
class="w-5 h-5 flex-shrink-0"
:class="[
route.path === item.to
? 'text-blue-700'
: 'text-gray-400 group-hover:text-gray-600'
]"
/>
<span class="truncate">{{ item.label }}</span>
</NuxtLink>
</nav>
<!-- Footer --> <!-- Navigation Items -->
<div class="px-4 py-4 border-t border-gray-100 space-y-2"> <v-list density="compact" nav>
<!-- User Info --> <v-list-item
<div v-if="authState?.user" class="flex items-center gap-3 p-2"> v-for="item in navigationItems"
<div class="flex-shrink-0"> :key="item.to"
<div class="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-sm font-medium"> :prepend-icon="item.icon"
{{ (authState.user.name || authState.user.email || '?')[0].toUpperCase() }} :title="item.label"
</div> :value="item.to"
</div> :to="item.to"
<div class="flex-1 min-w-0"> color="primary"
<p class="text-sm font-medium text-gray-900 truncate"> rounded="xl"
{{ authState.user.name || authState.user.email }} class="mx-1"
</p> ></v-list-item>
<p class="text-xs text-gray-500 truncate"> </v-list>
{{ authState.user.email }}
</p>
</div>
</div>
<!-- Role Badge --> <template v-slot:append>
<div v-if="authState?.groups?.length" class="px-2"> <v-divider></v-divider>
<div
v-if="authState.groups.includes('admin')" <!-- User Info Section -->
class="inline-flex items-center justify-center w-full px-2 py-1 text-xs font-medium text-orange-700 bg-orange-100 rounded-md" <div class="pa-2">
<v-list v-if="authState?.user">
<v-list-item
:prepend-avatar="`https://ui-avatars.com/api/?name=${encodeURIComponent(authState.user.name || authState.user.email)}&background=387bca&color=fff`"
:title="authState.user.name || authState.user.email"
:subtitle="authState.user.email"
class="px-2"
> >
Admin <template v-slot:append v-if="!rail && authState?.groups?.length">
</div> <div>
<div <v-chip
v-else-if="authState.groups.includes('sales')" v-if="authState.groups.includes('admin')"
class="inline-flex items-center justify-center w-full px-2 py-1 text-xs font-medium text-green-700 bg-green-100 rounded-md" size="small"
> color="orange"
Sales variant="tonal"
</div> >
</div> Admin
</v-chip>
<!-- Logout Button --> <v-chip
<button v-else-if="authState.groups.includes('sales')"
@click="handleLogout" size="small"
class="w-full flex items-center justify-start gap-3 px-3 py-2 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors duration-200" color="green"
> variant="tonal"
<Icon name="i-heroicons-arrow-left-on-rectangle" class="w-5 h-5 text-gray-400" /> >
<span>Logout</span> Sales
</button> </v-chip>
</div> </div>
</div> </template>
</div> </v-list-item>
<!-- Main Content -->
<div class="flex-1 flex flex-col">
<!-- Top Bar -->
<header class="bg-white border-b border-gray-200 px-4 py-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<!-- Mobile Menu Toggle -->
<button
@click="sidebarOpen = !sidebarOpen"
class="lg:hidden p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100"
>
<Icon name="i-heroicons-bars-3" class="w-6 h-6" />
</button>
<!-- Page Title --> <v-list-item
<h1 class="text-xl font-semibold text-gray-900">{{ pageTitle }}</h1> @click="handleLogout"
</div> prepend-icon="mdi-logout"
title="Logout"
class="px-2 mt-1"
base-color="error"
rounded="xl"
></v-list-item>
</v-list>
</div> </div>
</header> </template>
</v-navigation-drawer>
<!-- Page Content --> <v-app-bar
<main class="flex-1 p-6 overflow-y-auto"> flat
<slot /> color="white"
</main> class="border-b"
</div> >
</div> <v-app-bar-nav-icon
@click="drawer = !drawer"
class="d-lg-none"
></v-app-bar-nav-icon>
<v-toolbar-title class="text-h6">
{{ pageTitle }}
</v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-main class="bg-grey-lighten-4">
<slot />
</v-main>
</v-app>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { useDisplay } from 'vuetify';
// Define NavigationMenuItem type locally
interface NavigationMenuItem {
label: string;
icon: string;
to: string;
}
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const nuxtApp = useNuxtApp(); const nuxtApp = useNuxtApp();
const { mdAndDown } = useDisplay();
// Sidebar state // Sidebar state
const sidebarOpen = ref(false); const drawer = ref(true);
const rail = ref(false);
// Get auth state - with fallback to prevent errors // Get auth state - with fallback to prevent errors
const authState = computed(() => { const authState = computed(() => {
@ -157,6 +143,7 @@ const pageTitle = computed(() => {
const routeName = route.name as string; const routeName = route.name as string;
const pageTitles: Record<string, string> = { const pageTitles: Record<string, string> = {
'dashboard': 'Dashboard', 'dashboard': 'Dashboard',
'dashboard-index': 'Dashboard',
'dashboard-expenses': 'Expense Tracking', 'dashboard-expenses': 'Expense Tracking',
'dashboard-interest-list': 'Interest List', 'dashboard-interest-list': 'Interest List',
'dashboard-berth-list': 'Berth List', 'dashboard-berth-list': 'Berth List',
@ -167,52 +154,52 @@ const pageTitle = computed(() => {
'dashboard-interest-analytics': 'Analytics', 'dashboard-interest-analytics': 'Analytics',
'dashboard-file-browser': 'File Browser', 'dashboard-file-browser': 'File Browser',
'dashboard-admin': 'Admin Console', 'dashboard-admin': 'Admin Console',
'dashboard-admin-index': 'Admin Console',
'dashboard-admin-audit-logs': 'Audit Logs', 'dashboard-admin-audit-logs': 'Audit Logs',
'dashboard-admin-system-logs': 'System Logs', 'dashboard-admin-system-logs': 'System Logs',
'dashboard-admin-duplicates': 'Duplicate Management', 'dashboard-admin-duplicates': 'Duplicate Management',
'dashboard-sidebar-demo': 'Sidebar Demo',
}; };
return pageTitles[routeName] || routeName?.split('-').map(word => return pageTitles[routeName] || 'Dashboard';
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ') || 'Dashboard';
}); });
// Navigation items based on user role // Navigation items based on user role
const navigationItems = computed((): NavigationMenuItem[] => { const navigationItems = computed(() => {
const items: NavigationMenuItem[] = [ const items = [
{ {
label: 'Dashboard', label: 'Dashboard',
icon: 'i-heroicons-home', icon: 'mdi-home',
to: '/dashboard', to: '/dashboard',
}, },
{ {
label: 'Analytics', label: 'Analytics',
icon: 'i-heroicons-chart-bar', icon: 'mdi-chart-bar',
to: '/dashboard/interest-analytics', to: '/dashboard/interest-analytics',
}, },
{ {
label: 'Berth List', label: 'Berth List',
icon: 'i-heroicons-table-cells', icon: 'mdi-table',
to: '/dashboard/interest-berth-list', to: '/dashboard/interest-berth-list',
}, },
{ {
label: 'Berth Status', label: 'Berth Status',
icon: 'i-heroicons-map', icon: 'mdi-map',
to: '/dashboard/interest-berth-status', to: '/dashboard/interest-berth-status',
}, },
{ {
label: 'Interest List', label: 'Interest List',
icon: 'i-heroicons-users', icon: 'mdi-account-multiple',
to: '/dashboard/interest-list', to: '/dashboard/interest-list',
}, },
{ {
label: 'Interest Status', label: 'Interest Status',
icon: 'i-heroicons-clipboard-document-check', icon: 'mdi-clipboard-check',
to: '/dashboard/interest-status', to: '/dashboard/interest-status',
}, },
{ {
label: 'File Browser', label: 'File Browser',
icon: 'i-heroicons-folder-open', icon: 'mdi-folder-open',
to: '/dashboard/file-browser', to: '/dashboard/file-browser',
}, },
]; ];
@ -222,12 +209,12 @@ const navigationItems = computed((): NavigationMenuItem[] => {
items.push( items.push(
{ {
label: 'Expenses', label: 'Expenses',
icon: 'i-heroicons-receipt-percent', icon: 'mdi-receipt',
to: '/dashboard/expenses', to: '/dashboard/expenses',
}, },
{ {
label: 'Interest Emails', label: 'Interest Emails',
icon: 'i-heroicons-envelope', icon: 'mdi-email',
to: '/dashboard/interest-emails', to: '/dashboard/interest-emails',
} }
); );
@ -237,7 +224,7 @@ const navigationItems = computed((): NavigationMenuItem[] => {
if (authState.value?.groups?.includes('admin')) { if (authState.value?.groups?.includes('admin')) {
items.push({ items.push({
label: 'Admin Console', label: 'Admin Console',
icon: 'i-heroicons-shield-check', icon: 'mdi-shield-crown',
to: '/dashboard/admin', to: '/dashboard/admin',
}); });
} }
@ -256,13 +243,25 @@ const handleLogout = async () => {
await router.push('/login'); await router.push('/login');
} }
}; };
// Initialize drawer state on mobile
onMounted(() => {
if (mdAndDown.value) {
drawer.value = false;
}
});
</script> </script>
<style scoped> <style scoped>
/* Ensure proper responsive behavior */ .border-b {
@media (min-width: 1024px) { border-bottom: 1px solid rgba(0, 0, 0, 0.12) !important;
.lg\:w-64 { }
width: 16rem !important;
} .v-navigation-drawer--rail .v-list-item__append {
display: none;
}
.v-navigation-drawer--rail.v-navigation-drawer--is-hovering .v-list-item__append {
display: flex;
} }
</style> </style>

View File

@ -2,7 +2,7 @@ export default defineNuxtConfig({
ssr: false, ssr: false,
compatibilityDate: "2024-11-01", compatibilityDate: "2024-11-01",
devtools: { enabled: true }, devtools: { enabled: true },
modules: ["nuxt-directus", "vuetify-nuxt-module", "@vite-pwa/nuxt", "@nuxt/ui"], modules: ["nuxt-directus", "vuetify-nuxt-module", "@vite-pwa/nuxt"],
app: { app: {
head: { head: {
titleTemplate: "%s • Port Nimara Portal", titleTemplate: "%s • Port Nimara Portal",