298 lines
16 KiB
Vue
298 lines
16 KiB
Vue
<template>
|
||
<div class="min-h-screen bg-gray-50">
|
||
<!-- Sidebar -->
|
||
<aside
|
||
class="fixed inset-y-0 left-0 bg-white border-r border-gray-200 transition-all duration-300 z-20"
|
||
:class="sidebarCollapsed ? 'w-16' : 'w-64'"
|
||
>
|
||
<div class="flex flex-col h-full">
|
||
<!-- Logo / Toggle Button -->
|
||
<div class="flex items-center h-16 px-4 border-b border-gray-200" :class="sidebarCollapsed ? 'justify-center' : 'justify-between'">
|
||
<div v-if="!sidebarCollapsed" class="flex items-center space-x-2">
|
||
<div class="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-700 rounded-lg flex items-center justify-center">
|
||
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||
</svg>
|
||
</div>
|
||
<span class="text-xl font-bold text-gray-900">{{ settings.siteName }}</span>
|
||
</div>
|
||
<button
|
||
@click="toggleSidebar"
|
||
class="p-2 rounded-lg text-gray-500 hover:bg-gray-100 transition-colors"
|
||
:class="sidebarCollapsed ? 'mx-auto' : ''"
|
||
>
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path v-if="!sidebarCollapsed" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Navigation -->
|
||
<nav class="flex-1 px-2 py-6 space-y-1 overflow-y-auto">
|
||
<router-link
|
||
to="/dashboard"
|
||
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
|
||
:class="[
|
||
route.path === '/dashboard' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
|
||
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
|
||
]"
|
||
:title="sidebarCollapsed ? t('app.dashboard') : ''"
|
||
>
|
||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||
</svg>
|
||
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.dashboard') }}</span>
|
||
</router-link>
|
||
|
||
<router-link
|
||
v-if="userStore.role === 'admin'"
|
||
to="/users"
|
||
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
|
||
:class="[
|
||
route.path === '/users' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
|
||
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
|
||
]"
|
||
:title="sidebarCollapsed ? t('app.users') : ''"
|
||
>
|
||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||
</svg>
|
||
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.users') }}</span>
|
||
</router-link>
|
||
|
||
<router-link
|
||
to="/restaurants"
|
||
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
|
||
:class="[
|
||
route.path === '/restaurants' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
|
||
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
|
||
]"
|
||
:title="sidebarCollapsed ? t('app.restaurants') : ''"
|
||
>
|
||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||
</svg>
|
||
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.restaurants') }}</span>
|
||
</router-link>
|
||
|
||
<router-link
|
||
v-if="userStore.role === 'admin'"
|
||
to="/olap-columns"
|
||
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
|
||
:class="[
|
||
route.path === '/olap-columns' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
|
||
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
|
||
]"
|
||
:title="sidebarCollapsed ? t('app.olapColumns') : ''"
|
||
>
|
||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V8a2 2 0 012-2z" />
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 6v12M16 6v12" />
|
||
</svg>
|
||
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.olapColumns') }}</span>
|
||
</router-link>
|
||
|
||
<router-link
|
||
v-if="userStore.role === 'admin'"
|
||
to="/database-connections"
|
||
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
|
||
:class="[
|
||
route.path === '/database-connections' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
|
||
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
|
||
]"
|
||
:title="sidebarCollapsed ? t('dbConnections.pageName') : ''"
|
||
>
|
||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||
</svg>
|
||
<span v-if="!sidebarCollapsed" class="truncate">{{ t('dbConnections.pageName') }}</span>
|
||
</router-link>
|
||
|
||
<router-link
|
||
v-if="userStore.role === 'admin'"
|
||
to="/settings"
|
||
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
|
||
:class="[
|
||
route.path === '/settings' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
|
||
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
|
||
]"
|
||
:title="sidebarCollapsed ? t('app.settings') : ''"
|
||
>
|
||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||
</svg>
|
||
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.settings') }}</span>
|
||
</router-link>
|
||
</nav>
|
||
|
||
<!-- User Info (collapsed aware) -->
|
||
<div v-if="!sidebarCollapsed" class="p-4 border-t border-gray-200">
|
||
<div class="flex items-center space-x-3">
|
||
<div class="w-10 h-10 bg-gradient-to-br from-primary-500 to-primary-700 rounded-full flex items-center justify-center text-white font-semibold">
|
||
{{ userInitials }}
|
||
</div>
|
||
<div class="flex-1 min-w-0">
|
||
<p class="text-sm font-medium text-gray-900 truncate">{{ userName }}</p>
|
||
<p class="text-xs text-gray-500 truncate">{{ userStore.role === 'admin' ? t('app.administrator') : t('app.user') }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="p-2 border-t border-gray-200 flex justify-center">
|
||
<div class="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-700 rounded-full flex items-center justify-center text-white font-semibold text-sm">
|
||
{{ userInitials }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- Main Content -->
|
||
<main class="transition-all duration-300" :class="sidebarCollapsed ? 'ml-16' : 'ml-64'">
|
||
<!-- Header (без заголовка) -->
|
||
<header class="bg-white border-b border-gray-200 sticky top-0 z-10">
|
||
<div class="flex items-center justify-end h-16 px-8">
|
||
<div class="flex items-center space-x-4">
|
||
<!-- Search -->
|
||
<div class="relative">
|
||
<input
|
||
type="text"
|
||
:placeholder="t('app.search')"
|
||
class="w-64 px-4 py-2 pl-10 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||
/>
|
||
<svg class="absolute left-3 top-2.5 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||
</svg>
|
||
</div>
|
||
|
||
<!-- Notifications -->
|
||
<button class="relative p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors" :title="t('app.notifications')">
|
||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||
</svg>
|
||
<span class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
|
||
</button>
|
||
|
||
<!-- User actions -->
|
||
<div class="flex items-center space-x-1 border-l pl-4 ml-2">
|
||
<button @click="toggleLanguage" class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors" :title="t('app.language')">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
|
||
</svg>
|
||
</button>
|
||
<router-link to="/profile" class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors" :title="t('app.profile')">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||
</svg>
|
||
</router-link>
|
||
<a
|
||
href="/phpmyadmin/"
|
||
v-if="userStore.role === 'admin'"
|
||
target="_self"
|
||
class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors"
|
||
:title="t('app.database')"
|
||
>
|
||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||
</svg>
|
||
</a>
|
||
<button @click="logout" class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors" :title="t('app.logout')">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Page Content -->
|
||
<div class="p-8">
|
||
<slot />
|
||
</div>
|
||
</main>
|
||
<!-- Notification Toast -->
|
||
<Transition name="slide">
|
||
<div v-if="notification.show" class="fixed bottom-4 right-4 z-50 flex items-center space-x-2 px-4 py-3 rounded-lg shadow-lg" :class="notification.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'">
|
||
<svg v-if="notification.type === 'success'" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
<svg v-else class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
<span>{{ notification.message }}</span>
|
||
</div>
|
||
</Transition>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, ref, onMounted } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { useSettingsStore } from '@/stores/settings'
|
||
import { useUserStore } from '@/stores/user'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { useNotification } from '@/composables/useNotification'
|
||
|
||
const { notification, showNotification } = useNotification()
|
||
const settings = useSettingsStore()
|
||
const userStore = useUserStore()
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const { t, locale } = useI18n()
|
||
|
||
const userName = computed(() => userStore.login || 'User')
|
||
const userInitials = computed(() => (userName.value[0] || 'U').toUpperCase())
|
||
|
||
const SIDEBAR_STORAGE_KEY = 'admin_sidebar_collapsed'
|
||
const sidebarCollapsed = ref(false)
|
||
|
||
onMounted(() => {
|
||
const saved = localStorage.getItem(SIDEBAR_STORAGE_KEY)
|
||
if (saved !== null) sidebarCollapsed.value = saved === 'true'
|
||
})
|
||
|
||
function toggleSidebar() {
|
||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||
localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarCollapsed.value))
|
||
}
|
||
|
||
async function logout() {
|
||
await fetch('/api/logout', { method: 'POST' })
|
||
userStore.clear()
|
||
router.push('/login')
|
||
}
|
||
|
||
async function toggleLanguage() {
|
||
const newLang = locale.value === 'en' ? 'ru' : 'en'
|
||
if (userStore.id) {
|
||
const ok = await userStore.updateProfile({ language: newLang })
|
||
if (ok) {
|
||
locale.value = newLang
|
||
localStorage.setItem('locale', newLang)
|
||
} else {
|
||
showNotification('profile.updateError', 'error');
|
||
// В случае ошибки всё равно меняем локаль, но не сохраняем в БД
|
||
locale.value = newLang
|
||
localStorage.setItem('locale', newLang)
|
||
}
|
||
} else {
|
||
// Для неавторизованных просто сохраняем в localStorage
|
||
locale.value = newLang
|
||
localStorage.setItem('locale', newLang)
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.slide-enter-active,
|
||
.slide-leave-active {
|
||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||
}
|
||
.slide-enter-from,
|
||
.slide-leave-to {
|
||
transform: translateX(100%);
|
||
opacity: 0;
|
||
}
|
||
</style>
|