Rename resources/scripts-v2 to resources/scripts and drop @v2 alias

Now that the legacy v1 frontend (commit 064bdf53) is gone, the v2 directory is the only frontend and the v2 suffix is just noise. Renames resources/scripts-v2 to resources/scripts via git mv (so git records the move as renames, preserving blame and log --follow), then bulk-rewrites the 152 files that imported via @v2/... to use @/scripts/... instead. The existing @ alias (resources/) covers the new path with no extra config needed.

Drops the now-unused @v2 alias from vite.config.js and points the laravel-vite-plugin entry at resources/scripts/main.ts. Updates the only blade reference (resources/views/app.blade.php) to match. The package.json test script (eslint ./resources/scripts) automatically targets the right place after the rename without any edit.

Verified: npm run build exits clean and the Vite warning lines now reference resources/scripts/plugins/i18n.ts, confirming every import resolved through the new path. git log --follow on any moved file walks back through its scripts-v2 history.
This commit is contained in:
Darko Gjorgjijoski
2026-04-07 12:50:16 +02:00
parent 064bdf5395
commit 71388ec6a5
448 changed files with 381 additions and 382 deletions

View File

@@ -0,0 +1,277 @@
<template>
<div ref="companySwitchBar" class="relative rounded">
<div
class="
flex items-center justify-center px-3 h-8 md:h-9 ml-2 text-sm text-white
bg-white/20 rounded-lg cursor-pointer hover:bg-white/30 transition-colors
"
@click="isShow = !isShow"
>
<span
v-if="companyStore.isAdminMode"
class="w-16 text-sm font-medium truncate sm:w-auto"
>
{{ $t('navigation.administration') }}
</span>
<span
v-else-if="companyStore.selectedCompany"
class="w-16 text-sm font-medium truncate sm:w-auto"
>
{{ companyStore.selectedCompany.name }}
</span>
<BaseIcon name="ChevronDownIcon" class="h-5 ml-1 text-white" />
</div>
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-1 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-1 opacity-0"
>
<div
v-if="isShow"
class="absolute right-0 mt-2 bg-surface rounded-md shadow-lg"
>
<div
class="
overflow-y-auto scrollbar-thin scrollbar-thumb-rounded-full
w-[300px] max-h-[350px]
scrollbar-thumb-surface-muted scrollbar-track-surface-secondary pb-4
"
>
<!-- Administration Mode -->
<div v-if="userStore.currentUser?.is_super_admin">
<div
class="p-2 px-3 rounded-md cursor-pointer hover:bg-hover-strong hover:text-primary-500"
:class="{
'bg-surface-tertiary text-primary-500': companyStore.isAdminMode,
}"
@click="enterAdminMode"
>
<div class="flex items-center">
<span
class="flex items-center justify-center mr-3 overflow-hidden text-base font-semibold bg-primary-100 rounded-md w-9 h-9 shrink-0 text-primary-500"
>
<BaseIcon name="ShieldCheckIcon" class="w-5 h-5" />
</span>
<div class="flex flex-col">
<span class="text-sm font-medium">{{ $t('navigation.administration') }}</span>
</div>
</div>
</div>
<div class="border-t border-line-light my-1" />
</div>
<label
class="px-3 py-2 text-xs font-semibold text-subtle mb-0.5 block uppercase"
>
{{ $t('company_switcher.label') }}
</label>
<div
v-if="companyStore.companies.length < 1"
class="flex flex-col items-center justify-center p-2 px-3 mt-4 text-base text-subtle"
>
<BaseIcon name="ExclamationCircleIcon" class="h-5 text-subtle" />
{{ $t('company_switcher.no_results_found') }}
</div>
<div v-else>
<div v-if="companyStore.companies.length > 0">
<div
v-for="(company, index) in companyStore.companies"
:key="index"
class="p-2 px-3 rounded-md cursor-pointer hover:bg-hover-strong hover:text-primary-500"
:class="{
'bg-surface-tertiary text-primary-500':
companyStore.selectedCompany && companyStore.selectedCompany.id === company.id,
}"
@click="changeCompany(company)"
>
<div class="flex items-center">
<span
class="
flex items-center justify-center mr-3 overflow-hidden text-base font-semibold
bg-surface-muted rounded-md w-9 h-9 text-primary-500
"
>
<span v-if="!company.logo">
{{ initGenerator(company.name) }}
</span>
<img
v-else
:src="company.logo"
alt="Company logo"
class="w-full h-full object-contain"
/>
</span>
<div class="flex flex-col">
<span class="text-sm">{{ company.name }}</span>
<span v-if="company.user_role" class="text-xs text-subtle">
{{ company.user_role }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Pending Invitations -->
<div
v-if="pendingInvitations.length > 0"
class="border-t border-line-light p-2"
>
<label
class="block px-1 pt-1 pb-2 text-xs font-semibold leading-tight text-subtle uppercase"
>
{{ $t('members.pending_invitations') }}
</label>
<div
v-for="invitation in pendingInvitations"
:key="invitation.id"
class="p-2 px-3 rounded-md"
>
<div class="flex items-center mb-2">
<span
class="
flex items-center justify-center mr-3 overflow-hidden text-xs font-semibold
bg-surface-muted rounded-md w-9 h-9 shrink-0 text-subtle
"
>
{{ initGenerator(invitation.company?.name ?? '?') }}
</span>
<div class="flex flex-col min-w-0">
<span class="text-sm text-body truncate">{{ invitation.company?.name }}</span>
<span class="text-xs text-subtle">{{ invitation.role?.title }}</span>
</div>
</div>
<div class="flex space-x-1 pl-12">
<button
class="text-xs px-2 py-1 rounded bg-primary-500 text-white hover:bg-primary-600"
@click.stop="acceptInvitation(invitation.token)"
>
{{ $t('general.accept') }}
</button>
<button
class="text-xs px-2 py-1 rounded bg-surface-muted text-body hover:bg-hover-strong"
@click.stop="declineInvitation(invitation.token)"
>
{{ $t('general.decline') }}
</button>
</div>
</div>
</div>
<div
v-if="userStore.currentUser?.is_owner"
class="
flex items-center justify-center p-4 pl-3 border-t-2 border-line-light
cursor-pointer text-primary-400 hover:text-primary-500
"
@click="addNewCompany"
>
<BaseIcon name="PlusIcon" class="h-5 mr-2" />
<span class="font-medium">
{{ $t('company_switcher.add_new_company') }}
</span>
</div>
</div>
</transition>
<CompanyModal />
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { onClickOutside } from '@vueuse/core'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useCompanyStore } from '@/scripts/stores/company.store'
import { useGlobalStore } from '@/scripts/stores/global.store'
import { useUserStore } from '@/scripts/stores/user.store'
import { useModalStore } from '@/scripts/stores/modal.store'
import CompanyModal from '@/scripts/features/company/settings/components/CompanyModal.vue'
import type { Company, CompanyInvitation } from '@/scripts/types/domain/company'
import type { Role } from '@/scripts/types/domain/role'
interface PendingInvitation {
id: number
token: string
company?: { name: string }
role?: { title: string }
}
const companyStore = useCompanyStore()
const modalStore = useModalStore()
const route = useRoute()
const router = useRouter()
const globalStore = useGlobalStore()
const { t } = useI18n()
const userStore = useUserStore()
const isShow = ref<boolean>(false)
const companySwitchBar = ref<HTMLElement | null>(null)
// TODO: Wire up pending invitations from a dedicated invitation store or bootstrap data
const pendingInvitations = ref<PendingInvitation[]>([])
watch(route, () => {
isShow.value = false
})
onClickOutside(companySwitchBar, () => {
isShow.value = false
})
function initGenerator(name: string): string {
if (name) {
const nameSplit = name.split(' ')
return nameSplit[0].charAt(0).toUpperCase()
}
return ''
}
function addNewCompany(): void {
modalStore.openModal({
title: t('company_switcher.new_company'),
componentName: 'CompanyModal',
size: 'sm',
})
}
async function enterAdminMode(): Promise<void> {
companyStore.setAdminMode(true)
isShow.value = false
router.push('/admin/administration/dashboard')
globalStore.setIsAppLoaded(false)
await globalStore.bootstrap()
}
async function changeCompany(company: Company): Promise<void> {
companyStore.setAdminMode(false)
companyStore.setSelectedCompany(company)
router.push('/admin/dashboard')
globalStore.setIsAppLoaded(false)
await globalStore.bootstrap()
}
async function acceptInvitation(token: string): Promise<void> {
// TODO: call invitation accept API, then re-bootstrap
pendingInvitations.value = pendingInvitations.value.filter(
(inv) => inv.token !== token
)
await globalStore.bootstrap()
}
async function declineInvitation(token: string): Promise<void> {
// TODO: call invitation decline API
pendingInvitations.value = pendingInvitations.value.filter(
(inv) => inv.token !== token
)
}
</script>

View File

@@ -0,0 +1,179 @@
<template>
<div ref="searchBar" class="hidden rounded md:block relative">
<div>
<BaseInput
v-model="searchQuery"
:placeholder="$t('global_search.search')"
container-class="!rounded-lg !shadow-none"
class="h-8 md:h-9 !rounded-lg !bg-white/20 !border-white/10 !text-white !placeholder-white/60"
@input="onSearchInput"
>
<template #left>
<BaseIcon name="MagnifyingGlassIcon" class="!text-white/70" />
</template>
<template #right>
<span v-if="isSearching" class="h-5 w-5 animate-spin text-primary-500" />
</template>
</BaseInput>
</div>
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-1 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-1 opacity-0"
>
<div
v-if="isShow"
class="
scrollbar-thin scrollbar-thumb-rounded-full scrollbar-thumb-surface-muted
scrollbar-track-surface-secondary overflow-y-auto bg-surface rounded-md
mt-2 shadow-lg p-3 absolute w-[300px] h-[200px] right-0
"
>
<div
v-if="customerList.length < 1 && userList.length < 1"
class="flex items-center justify-center text-subtle text-base flex-col mt-4"
>
<BaseIcon name="ExclamationCircleIcon" class="text-subtle" />
{{ $t('global_search.no_results_found') }}
</div>
<div v-else>
<div v-if="customerList.length > 0">
<label class="text-sm text-subtle mb-0.5 block px-2 uppercase">
{{ $t('global_search.customers') }}
</label>
<div
v-for="(customer, index) in customerList"
:key="index"
class="p-2 hover:bg-hover-strong cursor-pointer rounded-md"
>
<router-link
:to="{ path: `/admin/customers/${customer.id}/view` }"
class="flex items-center"
>
<span
class="
flex items-center justify-center w-9 h-9 mr-3 text-base font-semibold
bg-surface-muted rounded-full text-primary-500
"
>
{{ initGenerator(customer.name) }}
</span>
<div class="flex flex-col">
<span class="text-sm">{{ customer.name }}</span>
<span v-if="customer.contact_name" class="text-xs text-subtle">
{{ customer.contact_name }}
</span>
<span v-else class="text-xs text-subtle">{{ customer.email }}</span>
</div>
</router-link>
</div>
</div>
<div v-if="userList.length > 0" class="mt-2">
<label class="text-sm text-subtle mb-0.5 block px-2 uppercase">
{{ $t('global_search.users') }}
</label>
<div
v-for="(user, index) in userList"
:key="index"
class="p-2 hover:bg-hover-strong cursor-pointer rounded-md"
>
<router-link
:to="{ path: `/admin/members/${user.id}/edit` }"
class="flex items-center"
>
<span
class="
flex items-center justify-center w-9 h-9 mr-3 text-base font-semibold
bg-surface-muted rounded-full text-primary-500
"
>
{{ initGenerator(user.name) }}
</span>
<div class="flex flex-col">
<span class="text-sm">{{ user.name }}</span>
<span class="text-xs text-subtle">{{ user.email }}</span>
</div>
</router-link>
</div>
</div>
</div>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { onClickOutside, useDebounceFn } from '@vueuse/core'
import { useRoute } from 'vue-router'
import { client } from '@/scripts/api/client'
import { API } from '@/scripts/api/endpoints'
interface SearchResult {
id: number
name: string
email?: string
contact_name?: string
}
const isShow = ref<boolean>(false)
const searchQuery = ref<string>('')
const searchBar = ref<HTMLElement | null>(null)
const isSearching = ref<boolean>(false)
const customerList = ref<SearchResult[]>([])
const userList = ref<SearchResult[]>([])
const route = useRoute()
watch(route, () => {
isShow.value = false
searchQuery.value = ''
})
onClickOutside(searchBar, () => {
isShow.value = false
searchQuery.value = ''
})
const debouncedSearch = useDebounceFn(async () => {
if (!searchQuery.value) {
isShow.value = false
return
}
isSearching.value = true
try {
const { data } = await client.get(API.SEARCH, {
params: { search: searchQuery.value },
})
customerList.value = data.customers ?? []
userList.value = data.users ?? []
isShow.value = true
} finally {
isSearching.value = false
}
}, 500)
function onSearchInput(): void {
if (searchQuery.value === '') {
isShow.value = false
return
}
debouncedSearch()
}
function initGenerator(name: string): string {
if (name) {
const nameSplit = name.split(' ')
return nameSplit[0].charAt(0).toUpperCase()
}
return ''
}
</script>

View File

@@ -0,0 +1,44 @@
<template>
<div
v-if="isImpersonating"
class="fixed top-0 left-0 right-0 z-50 flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-orange-600"
>
<BaseIcon name="ExclamationTriangleIcon" class="w-4 h-4 mr-2" />
<span>{{ $t('administration.users.impersonating_banner') }}</span>
<button
class="ml-4 px-3 py-1 text-xs font-semibold text-orange-600 bg-white rounded hover:bg-orange-50"
:disabled="isStopping"
@click="stopImpersonating"
>
{{ $t('administration.users.stop_impersonating') }}
</button>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import * as ls from '@/scripts/utils/local-storage'
import { client } from '@/scripts/api/client'
import { API } from '@/scripts/api/endpoints'
const isStopping = ref<boolean>(false)
const isImpersonating = computed<boolean>(() => {
return ls.get<string>('admin.impersonating') === 'true'
})
async function stopImpersonating(): Promise<void> {
isStopping.value = true
try {
await client.post(API.SUPER_ADMIN_STOP_IMPERSONATING)
} catch {
// Token already cleaned up in store action
}
ls.remove('admin.impersonating')
ls.remove('auth.token')
window.location.href = '/admin/administration/users'
}
</script>

View File

@@ -0,0 +1,229 @@
<template>
<header
class="
fixed top-0 left-0 z-20 flex items-center justify-between w-full
px-4 py-3 md:h-16 md:px-8 bg-linear-to-r from-header-from to-header-to
"
>
<div class="flex items-center">
<router-link
:to="companyStore.isAdminMode ? '/admin/administration/dashboard' : '/admin/dashboard'"
class="
text-lg not-italic font-black tracking-wider text-white
brand-main font-base hidden md:block
"
>
<img v-if="adminLogo" :src="adminLogo" class="h-6" />
<MainLogo v-else class="h-6" light-color="white" dark-color="white" />
</router-link>
</div>
<!-- Mobile toggle button -->
<div
:class="{ 'is-active': globalStore.isSidebarOpen }"
class="
flex float-left p-1 overflow-visible text-sm ease-linear bg-surface
border-0 rounded cursor-pointer md:hidden md:ml-0 hover:bg-hover-strong
"
@click.prevent="onToggle"
>
<BaseIcon name="Bars3Icon" class="!w-6 !h-6 text-muted" />
</div>
<ul class="flex float-right h-8 m-0 list-none md:h-9">
<!-- Create dropdown -->
<li
v-if="hasCreateAbilities && !companyStore.isAdminMode"
class="relative hidden float-left m-0 md:block"
>
<BaseDropdown width-class="w-48">
<template #activator>
<div
class="
flex items-center justify-center w-8 h-8 ml-2 text-sm text-white
bg-white/20 rounded-lg hover:bg-white/30 md:h-9 md:w-9
"
>
<BaseIcon name="PlusIcon" class="w-5 h-5 text-white" />
</div>
</template>
<router-link to="/admin/invoices/create">
<BaseDropdownItem
v-if="userStore.hasAbilities(ABILITIES.CREATE_INVOICE)"
>
<BaseIcon
name="DocumentTextIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
aria-hidden="true"
/>
{{ $t('invoices.new_invoice') }}
</BaseDropdownItem>
</router-link>
<router-link to="/admin/estimates/create">
<BaseDropdownItem
v-if="userStore.hasAbilities(ABILITIES.CREATE_ESTIMATE)"
>
<BaseIcon
name="DocumentIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
aria-hidden="true"
/>
{{ $t('estimates.new_estimate') }}
</BaseDropdownItem>
</router-link>
<router-link to="/admin/customers/create">
<BaseDropdownItem
v-if="userStore.hasAbilities(ABILITIES.CREATE_CUSTOMER)"
>
<BaseIcon
name="UserIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
aria-hidden="true"
/>
{{ $t('customers.new_customer') }}
</BaseDropdownItem>
</router-link>
</BaseDropdown>
</li>
<!-- Global search -->
<li v-if="!companyStore.isAdminMode" class="ml-2">
<GlobalSearchBar
v-if="
userStore.currentUser?.is_owner ||
userStore.hasAbilities(ABILITIES.VIEW_CUSTOMER)
"
/>
</li>
<!-- Company switcher -->
<li>
<CompanySwitcher />
</li>
<!-- User dropdown -->
<li class="relative block float-left ml-2">
<BaseDropdown width-class="w-48">
<template #activator>
<img
:src="previewAvatar"
class="block w-8 h-8 rounded-full ring-2 ring-white/30 md:h-9 md:w-9 object-cover"
/>
</template>
<!-- Theme Toggle -->
<div class="px-3 py-2">
<div class="flex items-center justify-between rounded-lg bg-surface-secondary p-1">
<button
v-for="opt in themeOptions"
:key="opt.value"
:class="[
'flex items-center justify-center rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors',
currentTheme === opt.value
? 'bg-surface text-heading shadow-sm'
: 'text-muted hover:text-body',
]"
@click.stop="setTheme(opt.value)"
>
<BaseIcon :name="opt.icon" class="w-3.5 h-3.5" />
</button>
</div>
</div>
<router-link to="/admin/settings/account-settings">
<BaseDropdownItem>
<BaseIcon
name="CogIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
aria-hidden="true"
/>
{{ $t('navigation.settings') }}
</BaseDropdownItem>
</router-link>
<BaseDropdownItem @click="logout">
<BaseIcon
name="ArrowRightOnRectangleIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
aria-hidden="true"
/>
{{ $t('navigation.logout') }}
</BaseDropdownItem>
</BaseDropdown>
</li>
</ul>
</header>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/scripts/stores/auth.store'
import { useUserStore } from '@/scripts/stores/user.store'
import { useGlobalStore } from '@/scripts/stores/global.store'
import { useCompanyStore } from '@/scripts/stores/company.store'
import { useTheme } from '@/scripts/composables/use-theme'
import { ABILITIES } from '@/scripts/config/abilities'
import { THEME } from '@/scripts/config/constants'
import type { Theme } from '@/scripts/config/constants'
import CompanySwitcher from './CompanySwitcher.vue'
import GlobalSearchBar from './GlobalSearchBar.vue'
import MainLogo from '@/scripts/components/icons/MainLogo.vue'
interface ThemeOption {
value: Theme
icon: string
}
const authStore = useAuthStore()
const userStore = useUserStore()
const globalStore = useGlobalStore()
const companyStore = useCompanyStore()
const router = useRouter()
const { currentTheme, setTheme } = useTheme()
const previewAvatar = computed<string>(() => {
if (userStore.currentUser && userStore.currentUser.avatar !== 0) {
return userStore.currentUser.avatar as string
}
return getDefaultAvatar()
})
const adminLogo = computed<string | false>(() => {
if (globalStore.globalSettings?.admin_portal_logo) {
return '/storage/' + globalStore.globalSettings.admin_portal_logo
}
return false
})
const hasCreateAbilities = computed<boolean>(() => {
return userStore.hasAbilities([
ABILITIES.CREATE_INVOICE,
ABILITIES.CREATE_ESTIMATE,
ABILITIES.CREATE_CUSTOMER,
])
})
function getDefaultAvatar(): string {
const imgUrl = new URL('$images/default-avatar.jpg', import.meta.url)
return imgUrl.href
}
async function logout(): Promise<void> {
await authStore.logout()
router.push('/login')
}
function onToggle(): void {
globalStore.setSidebarVisibility(true)
}
const themeOptions: ThemeOption[] = [
{ value: THEME.LIGHT, icon: 'SunIcon' },
{ value: THEME.DARK, icon: 'MoonIcon' },
{ value: THEME.SYSTEM, icon: 'ComputerDesktopIcon' },
]
</script>

View File

@@ -0,0 +1,217 @@
<template>
<!-- MOBILE MENU -->
<TransitionRoot as="template" :show="globalStore.isSidebarOpen">
<Dialog
as="div"
class="fixed inset-0 z-40 flex md:hidden"
@close="globalStore.setSidebarVisibility(false)"
>
<TransitionChild
as="template"
enter="transition-opacity ease-linear duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="transition-opacity ease-linear duration-300"
leave-from="opacity-100"
leave-to="opacity-0"
>
<DialogOverlay class="fixed inset-0 bg-gray-600/75" />
</TransitionChild>
<TransitionChild
as="template"
enter="transition ease-in-out duration-300"
enter-from="-translate-x-full"
enter-to="translate-x-0"
leave="transition ease-in-out duration-300"
leave-from="translate-x-0"
leave-to="-translate-x-full"
>
<div class="relative flex flex-col flex-1 w-full max-w-xs bg-surface">
<TransitionChild
as="template"
enter="ease-in-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in-out duration-300"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="absolute top-0 right-0 pt-2 -mr-12">
<button
class="
flex items-center justify-center w-10 h-10 ml-1 rounded-full
focus:outline-hidden focus:ring-2 focus:ring-inset focus:ring-white
"
@click="globalStore.setSidebarVisibility(false)"
>
<span class="sr-only">Close sidebar</span>
<BaseIcon
name="XMarkIcon"
class="w-6 h-6 text-white"
aria-hidden="true"
/>
</button>
</div>
</TransitionChild>
<div class="flex-1 h-0 pt-5 pb-4 overflow-y-auto">
<div class="flex items-center shrink-0 px-4 mb-10">
<MainLogo
class="block h-auto max-w-full w-36 text-primary-400"
alt="InvoiceShelf Logo"
/>
</div>
<nav
v-for="(menu, index) in globalStore.menuGroups"
:key="index"
class="mt-5 space-y-1"
>
<div
v-if="menu[0] && menu[0].group_label"
class="px-4 mt-6 mb-2 text-xs font-semibold text-subtle uppercase tracking-wider"
>
{{ $t(menu[0].group_label) }}
</div>
<router-link
v-for="item in menu"
:key="item.name"
:to="item.link"
:class="[
hasActiveUrl(item.link)
? 'text-primary-600 bg-primary-50 font-semibold'
: 'text-body hover:bg-hover',
'cursor-pointer mx-3 px-3 py-2.5 flex items-center rounded-lg text-sm not-italic font-medium transition-colors',
]"
@click="globalStore.setSidebarVisibility(false)"
>
<BaseIcon
:name="item.icon"
:class="[
hasActiveUrl(item.link)
? 'text-primary-500'
: 'text-subtle',
'mr-3 shrink-0 h-5 w-5',
]"
@click="globalStore.setSidebarVisibility(false)"
/>
{{ $t(item.title) }}
</router-link>
</nav>
</div>
</div>
</TransitionChild>
<div class="shrink-0 w-14">
<!-- Force sidebar to shrink to fit close icon -->
</div>
</Dialog>
</TransitionRoot>
<!-- DESKTOP MENU -->
<div
:class="[
globalStore.isSidebarCollapsed ? 'w-16' : 'w-56 xl:w-64',
]"
class="
hidden h-screen pb-0 overflow-y-auto overflow-x-hidden
bg-surface/80 backdrop-blur-xl border-r border-white/10
md:fixed md:flex md:flex-col md:inset-y-0 pt-16
transition-all duration-300
"
>
<div
v-for="(menu, index) in globalStore.menuGroups"
:key="index"
class="p-0 m-0 mt-4 list-none"
>
<div
v-if="menu[0] && menu[0].group_label && !globalStore.isSidebarCollapsed"
class="px-6 mt-6 mb-2 text-xs font-semibold text-subtle uppercase tracking-wider whitespace-nowrap"
>
{{ $t(menu[0].group_label) }}
</div>
<div
v-else-if="menu[0] && menu[0].group_label && globalStore.isSidebarCollapsed"
class="mx-3 my-2 border-t border-line-light"
/>
<router-link
v-for="item in menu"
:key="item.name"
:to="item.link"
v-tooltip="globalStore.isSidebarCollapsed ? { content: $t(item.title), placement: 'right' } : null"
:class="[
hasActiveUrl(item.link)
? 'text-primary-600 bg-primary-50 font-semibold'
: 'text-body hover:bg-hover',
globalStore.isSidebarCollapsed
? 'cursor-pointer mx-2 px-0 py-2.5 group flex items-center justify-center rounded-lg text-sm font-medium transition-colors'
: 'cursor-pointer mx-3 px-3 py-2.5 group flex items-center rounded-lg text-sm not-italic font-medium transition-colors',
]"
>
<BaseIcon
:name="item.icon"
:class="[
hasActiveUrl(item.link)
? 'text-primary-500'
: 'text-subtle group-hover:text-body',
globalStore.isSidebarCollapsed
? 'shrink-0 h-6 w-6'
: 'mr-3 shrink-0 h-5 w-5',
]"
/>
<span v-if="!globalStore.isSidebarCollapsed" class="whitespace-nowrap">
{{ $t(item.title) }}
</span>
</router-link>
</div>
<!-- Bottom toolbar -->
<div class="mt-auto sticky bottom-0 border-t border-white/10 bg-surface/80 backdrop-blur-xl p-2 flex flex-col items-center gap-1">
<button
v-tooltip="globalStore.isSidebarCollapsed ? { content: $t('general.collapse'), placement: 'right' } : null"
:class="[
globalStore.isSidebarCollapsed
? 'w-10 h-10 justify-center'
: 'w-full px-3 h-10 justify-end',
]"
class="flex items-center rounded-lg text-subtle hover:text-body hover:bg-hover transition-colors"
@click="globalStore.toggleSidebarCollapse()"
>
<BaseIcon
:name="globalStore.isSidebarCollapsed ? 'ChevronDoubleRightIcon' : 'ChevronDoubleLeftIcon'"
class="w-4 h-4 shrink-0"
/>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import {
Dialog,
DialogOverlay,
TransitionChild,
TransitionRoot,
} from '@headlessui/vue'
import { useRoute } from 'vue-router'
import { useGlobalStore } from '@/scripts/stores/global.store'
import MainLogo from '@/scripts/components/icons/MainLogo.vue'
interface MenuItemData {
name: string
title: string
icon: string
link: string
group_label?: string
}
const route = useRoute()
const globalStore = useGlobalStore()
function hasActiveUrl(url: string): boolean {
return route.path.indexOf(url) > -1
}
</script>