Merge pull request 'KAN-19 Responsive app navigation' (#113) from feature/KAN-19-responsive-app-navigation into main
Reviewed-on: #113
This commit was merged in pull request #113.
This commit is contained in:
@@ -4,6 +4,7 @@ namespace App\Http\Middleware;
|
|||||||
|
|
||||||
use App\Actions\Permissions\PermissionService;
|
use App\Actions\Permissions\PermissionService;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
|
use App\Models\Project;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Middleware;
|
use Inertia\Middleware;
|
||||||
|
|
||||||
@@ -60,6 +61,36 @@ class HandleInertiaRequests extends Middleware
|
|||||||
'user' => $user,
|
'user' => $user,
|
||||||
],
|
],
|
||||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||||
|
'navigation' => fn (): array => [
|
||||||
|
'project' => $this->projectNavigation($request),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{id: int, name: string, key: string, canManage: bool, canCreateTickets: bool}|null
|
||||||
|
*/
|
||||||
|
private function projectNavigation(Request $request): ?array
|
||||||
|
{
|
||||||
|
$project = $request->route('project');
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (! $project instanceof Project || $user === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$permissions = $this->permissionService->effectivePermissions($user, $project);
|
||||||
|
|
||||||
|
if (! in_array(Permission::ProjectRead, $permissions, true)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $project->id,
|
||||||
|
'name' => $project->name,
|
||||||
|
'key' => $project->key,
|
||||||
|
'canManage' => in_array(Permission::ProjectAdmin, $permissions, true),
|
||||||
|
'canCreateTickets' => in_array(Permission::ProjectWrite, $permissions, true),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AppLogoIcon from '@/components/AppLogoIcon.vue';
|
import { Columns3 } from 'lucide-vue-next';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<span class="flex min-w-0 items-center gap-3">
|
||||||
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md"
|
<span
|
||||||
>
|
class="bg-brand text-brand-foreground flex size-8 shrink-0 items-center justify-center rounded-md"
|
||||||
<AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
|
|
||||||
</div>
|
|
||||||
<div class="ml-1 grid flex-1 text-left text-sm">
|
|
||||||
<span class="mb-0.5 truncate font-semibold leading-tight"
|
|
||||||
>Laravel Starter Kit</span
|
|
||||||
>
|
>
|
||||||
</div>
|
<Columns3 class="size-5" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="text-title truncate font-semibold group-data-[collapsible=icon]:hidden"
|
||||||
|
>KanbanGit</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,19 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link, usePage } from '@inertiajs/vue3';
|
import { Link, router } from '@inertiajs/vue3';
|
||||||
import {
|
import { Settings2, X } from 'lucide-vue-next';
|
||||||
BookOpen,
|
import { computed, onMounted, onUnmounted } from 'vue';
|
||||||
Folder,
|
|
||||||
FolderGit2,
|
|
||||||
LayoutGrid,
|
|
||||||
Layers3,
|
|
||||||
Shield,
|
|
||||||
Users,
|
|
||||||
} from 'lucide-vue-next';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import AppLogo from '@/components/AppLogo.vue';
|
import AppLogo from '@/components/AppLogo.vue';
|
||||||
import NavFooter from '@/components/NavFooter.vue';
|
|
||||||
import NavMain from '@/components/NavMain.vue';
|
import NavMain from '@/components/NavMain.vue';
|
||||||
import NavUser from '@/components/NavUser.vue';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -22,93 +13,98 @@ import {
|
|||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
|
import { useAppNavigation } from '@/composables/useAppNavigation';
|
||||||
|
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { dashboard } from '@/routes';
|
import { dashboard } from '@/routes';
|
||||||
import { index as adminUsers } from '@/routes/admin/users';
|
import { edit as profile } from '@/routes/profile';
|
||||||
import { index as projectTemplatesIndex } from '@/routes/project-templates';
|
import { show } from '@/routes/projects';
|
||||||
import { index as projectsIndex } from '@/routes/projects';
|
|
||||||
import { index as teamsIndex } from '@/routes/teams';
|
|
||||||
import type { NavItem } from '@/types';
|
|
||||||
|
|
||||||
const page = usePage();
|
const { t } = useLocalization();
|
||||||
|
const { currentUrl } = useCurrentUrl();
|
||||||
const mainNavItems = computed<NavItem[]>(() => {
|
const { project, workspaceItems, projectItems, projectSettingsItems } =
|
||||||
const items: NavItem[] = [
|
useAppNavigation();
|
||||||
{
|
const { isMobile, setOpenMobile } = useSidebar();
|
||||||
title: 'Dashboard',
|
const accountItems = computed(() => [
|
||||||
href: dashboard(),
|
{
|
||||||
icon: LayoutGrid,
|
title: t('navigation.settings'),
|
||||||
},
|
href: profile(),
|
||||||
{
|
icon: Settings2,
|
||||||
title: 'Projects',
|
isActive: currentUrl.value.startsWith('/settings/'),
|
||||||
href: projectsIndex(),
|
},
|
||||||
icon: Folder,
|
]);
|
||||||
},
|
let removeNavigationListener: (() => void) | undefined;
|
||||||
];
|
onMounted(() => {
|
||||||
|
removeNavigationListener = router.on('navigate', () =>
|
||||||
if (page.props.auth.permissions.projectCreate) {
|
setOpenMobile(false),
|
||||||
items.push({
|
);
|
||||||
title: 'Project templates',
|
|
||||||
href: projectTemplatesIndex(),
|
|
||||||
icon: Layers3,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (page.props.auth.permissions.globalAdmin) {
|
|
||||||
items.push({
|
|
||||||
title: 'Users',
|
|
||||||
href: adminUsers(),
|
|
||||||
icon: Shield,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (page.props.auth.permissions.canManageTeams) {
|
|
||||||
items.push({
|
|
||||||
title: 'Teams',
|
|
||||||
href: teamsIndex(),
|
|
||||||
icon: Users,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
});
|
});
|
||||||
|
onUnmounted(() => removeNavigationListener?.());
|
||||||
const footerNavItems: NavItem[] = [
|
|
||||||
{
|
|
||||||
title: 'Repository',
|
|
||||||
href: 'https://github.com/laravel/vue-starter-kit',
|
|
||||||
icon: FolderGit2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Documentation',
|
|
||||||
href: 'https://laravel.com/docs/starter-kits#vue',
|
|
||||||
icon: BookOpen,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Sidebar collapsible="icon" variant="inset">
|
<Sidebar id="app-navigation" collapsible="icon" variant="sidebar">
|
||||||
<SidebarHeader>
|
<SidebarHeader class="border-sidebar-border gap-4 border-b p-3">
|
||||||
<SidebarMenu>
|
<div class="flex items-center gap-2">
|
||||||
<SidebarMenuItem>
|
<SidebarMenu class="min-w-0">
|
||||||
<SidebarMenuButton size="lg" as-child>
|
<SidebarMenuItem>
|
||||||
<Link :href="dashboard()">
|
<SidebarMenuButton
|
||||||
<AppLogo />
|
size="lg"
|
||||||
</Link>
|
as-child
|
||||||
</SidebarMenuButton>
|
tooltip="KanbanGit"
|
||||||
</SidebarMenuItem>
|
>
|
||||||
</SidebarMenu>
|
<Link :href="dashboard()" aria-label="KanbanGit"
|
||||||
|
><AppLogo
|
||||||
|
/></Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
<Button
|
||||||
|
v-if="isMobile"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:aria-label="t('navigation.close')"
|
||||||
|
@click="setOpenMobile(false)"
|
||||||
|
>
|
||||||
|
<X aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
v-if="project"
|
||||||
|
:href="show(project.id)"
|
||||||
|
class="bg-sidebar-accent flex min-w-0 items-center gap-3 rounded-md p-3 group-data-[collapsible=icon]:hidden"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-label bg-brand/15 text-primary shrink-0 rounded-md px-2 py-1"
|
||||||
|
>{{ project.key }}</span
|
||||||
|
>
|
||||||
|
<span class="truncate font-semibold" :title="project.name">{{
|
||||||
|
project.name
|
||||||
|
}}</span>
|
||||||
|
</Link>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<NavMain :items="mainNavItems" />
|
<nav :aria-label="t('navigation.main')">
|
||||||
|
<NavMain
|
||||||
|
:items="workspaceItems"
|
||||||
|
:label="t('navigation.workspace')"
|
||||||
|
/>
|
||||||
|
<NavMain
|
||||||
|
v-if="projectItems.length"
|
||||||
|
:items="projectItems"
|
||||||
|
:label="t('navigation.project')"
|
||||||
|
/>
|
||||||
|
<NavMain
|
||||||
|
v-if="projectSettingsItems.length"
|
||||||
|
:items="projectSettingsItems"
|
||||||
|
:label="t('navigation.projectSettings')"
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
<SidebarFooter class="border-sidebar-border border-t p-0">
|
||||||
<SidebarFooter>
|
<NavMain :items="accountItems" :label="t('navigation.account')" />
|
||||||
<NavFooter :items="footerNavItems" />
|
|
||||||
<NavUser />
|
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
<slot />
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,27 +1,145 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Link, usePage } from '@inertiajs/vue3';
|
||||||
|
import { Monitor, Moon, Plus, Sun } from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
import Breadcrumbs from '@/components/Breadcrumbs.vue';
|
import Breadcrumbs from '@/components/Breadcrumbs.vue';
|
||||||
|
import UserMenuContent from '@/components/UserMenuContent.vue';
|
||||||
|
import { UserAvatar } from '@/components/ui/avatar';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||||
|
import { useAppNavigation } from '@/composables/useAppNavigation';
|
||||||
|
import {
|
||||||
|
normalizeAppearance,
|
||||||
|
useAppearance,
|
||||||
|
} from '@/composables/useAppearance';
|
||||||
|
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { create as createProject } from '@/routes/projects';
|
||||||
|
import { index as board } from '@/routes/projects/board';
|
||||||
|
import { create as createTicket } from '@/routes/projects/tickets';
|
||||||
import type { BreadcrumbItem } from '@/types';
|
import type { BreadcrumbItem } from '@/types';
|
||||||
|
|
||||||
withDefaults(
|
withDefaults(defineProps<{ breadcrumbs?: BreadcrumbItem[] }>(), {
|
||||||
defineProps<{
|
breadcrumbs: () => [],
|
||||||
breadcrumbs?: BreadcrumbItem[];
|
});
|
||||||
}>(),
|
const page = usePage();
|
||||||
{
|
const { project } = useAppNavigation();
|
||||||
breadcrumbs: () => [],
|
const { isCurrentUrl } = useCurrentUrl();
|
||||||
},
|
const { t } = useLocalization();
|
||||||
|
const { appearance, updateAppearance } = useAppearance();
|
||||||
|
const themeIcon = computed(
|
||||||
|
() => ({ light: Sun, dark: Moon, system: Monitor })[appearance.value],
|
||||||
);
|
);
|
||||||
|
const createAction = computed(() => {
|
||||||
|
if (project.value?.canCreateTickets) {
|
||||||
|
return {
|
||||||
|
title: t('navigation.createTicket'),
|
||||||
|
href: createTicket(project.value.id, {
|
||||||
|
query: {
|
||||||
|
source: isCurrentUrl(board(project.value.id))
|
||||||
|
? 'board'
|
||||||
|
: 'backlog',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!project.value && page.props.auth.permissions.projectCreate) {
|
||||||
|
return { title: t('navigation.createProject'), href: createProject() };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header
|
<header
|
||||||
class="border-sidebar-border/70 group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 flex h-16 shrink-0 items-center gap-2 border-b px-6 transition-[width,height] ease-linear md:px-4"
|
class="bg-background md:px-page sticky top-0 z-20 flex min-h-16 min-w-0 shrink-0 items-center justify-between gap-2 border-b px-3 md:gap-4"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||||
<SidebarTrigger class="-ml-1" />
|
<SidebarTrigger class="shrink-0" />
|
||||||
<template v-if="breadcrumbs && breadcrumbs.length > 0">
|
<Breadcrumbs v-if="breadcrumbs.length" :breadcrumbs="breadcrumbs" />
|
||||||
<Breadcrumbs :breadcrumbs="breadcrumbs" />
|
<span v-else class="truncate font-semibold">KanbanGit</span>
|
||||||
</template>
|
</div>
|
||||||
|
<div class="flex shrink-0 items-center gap-1 sm:gap-2">
|
||||||
|
<Button
|
||||||
|
v-if="createAction"
|
||||||
|
as-child
|
||||||
|
:aria-label="createAction.title"
|
||||||
|
class="px-3"
|
||||||
|
>
|
||||||
|
<Link :href="createAction.href"
|
||||||
|
><Plus aria-hidden="true" /><span
|
||||||
|
class="hidden sm:inline"
|
||||||
|
>{{ createAction.title }}</span
|
||||||
|
></Link
|
||||||
|
>
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:aria-label="t('appearance.label')"
|
||||||
|
><component :is="themeIcon" aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>{{
|
||||||
|
t('appearance.label')
|
||||||
|
}}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
:model-value="appearance"
|
||||||
|
@update:model-value="
|
||||||
|
(value) =>
|
||||||
|
updateAppearance(normalizeAppearance(value))
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<DropdownMenuRadioItem value="light"
|
||||||
|
><Sun aria-hidden="true" />{{
|
||||||
|
t('appearance.light')
|
||||||
|
}}</DropdownMenuRadioItem
|
||||||
|
>
|
||||||
|
<DropdownMenuRadioItem value="dark"
|
||||||
|
><Moon aria-hidden="true" />{{
|
||||||
|
t('appearance.dark')
|
||||||
|
}}</DropdownMenuRadioItem
|
||||||
|
>
|
||||||
|
<DropdownMenuRadioItem value="system"
|
||||||
|
><Monitor aria-hidden="true" />{{
|
||||||
|
t('appearance.system')
|
||||||
|
}}</DropdownMenuRadioItem
|
||||||
|
>
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:aria-label="
|
||||||
|
t('navigation.userMenu', {
|
||||||
|
name: page.props.auth.user.name,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
data-test="sidebar-menu-button"
|
||||||
|
>
|
||||||
|
<UserAvatar :user="page.props.auth.user" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" class="min-w-60">
|
||||||
|
<UserMenuContent :user="page.props.auth.user" />
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -9,29 +9,48 @@ import {
|
|||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from '@/components/ui/breadcrumb';
|
} from '@/components/ui/breadcrumb';
|
||||||
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
breadcrumbs: BreadcrumbItemType[];
|
breadcrumbs: BreadcrumbItemType[];
|
||||||
};
|
};
|
||||||
|
|
||||||
defineProps<Props>();
|
defineProps<Props>();
|
||||||
|
const { t } = useLocalization();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Breadcrumb>
|
<Breadcrumb class="min-w-0" :aria-label="t('navigation.breadcrumbs')">
|
||||||
<BreadcrumbList>
|
<BreadcrumbList class="flex-nowrap">
|
||||||
<template v-for="(item, index) in breadcrumbs" :key="index">
|
<template v-for="(item, index) in breadcrumbs" :key="index">
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem
|
||||||
|
:class="[
|
||||||
|
'min-w-0',
|
||||||
|
index !== breadcrumbs.length - 1
|
||||||
|
? 'hidden md:inline-flex'
|
||||||
|
: '',
|
||||||
|
]"
|
||||||
|
>
|
||||||
<template v-if="index === breadcrumbs.length - 1">
|
<template v-if="index === breadcrumbs.length - 1">
|
||||||
<BreadcrumbPage>{{ item.title }}</BreadcrumbPage>
|
<BreadcrumbPage class="truncate" :title="item.title">{{
|
||||||
|
item.title
|
||||||
|
}}</BreadcrumbPage>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<BreadcrumbLink as-child>
|
<BreadcrumbLink as-child>
|
||||||
<Link :href="item.href">{{ item.title }}</Link>
|
<Link
|
||||||
|
:href="item.href"
|
||||||
|
class="max-w-40 truncate"
|
||||||
|
:title="item.title"
|
||||||
|
>{{ item.title }}</Link
|
||||||
|
>
|
||||||
</BreadcrumbLink>
|
</BreadcrumbLink>
|
||||||
</template>
|
</template>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
<BreadcrumbSeparator v-if="index !== breadcrumbs.length - 1" />
|
<BreadcrumbSeparator
|
||||||
|
v-if="index !== breadcrumbs.length - 1"
|
||||||
|
class="hidden md:block"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
|
|||||||
@@ -12,23 +12,46 @@ import type { NavItem } from '@/types';
|
|||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
items: NavItem[];
|
items: NavItem[];
|
||||||
|
label: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { isCurrentUrl } = useCurrentUrl();
|
const { isCurrentUrl } = useCurrentUrl();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SidebarGroup class="px-2 py-0">
|
<SidebarGroup class="px-3 py-2">
|
||||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
<SidebarGroupLabel class="text-label text-muted-foreground uppercase">{{
|
||||||
<SidebarMenu>
|
label
|
||||||
|
}}</SidebarGroupLabel>
|
||||||
|
<SidebarMenu role="list" :aria-label="label">
|
||||||
<SidebarMenuItem v-for="item in items" :key="item.title">
|
<SidebarMenuItem v-for="item in items" :key="item.title">
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
as-child
|
as-child
|
||||||
:is-active="isCurrentUrl(item.href)"
|
:is-active="
|
||||||
|
item.isActive ??
|
||||||
|
isCurrentUrl(
|
||||||
|
item.href,
|
||||||
|
undefined,
|
||||||
|
item.match === 'section',
|
||||||
|
)
|
||||||
|
"
|
||||||
:tooltip="item.title"
|
:tooltip="item.title"
|
||||||
|
class="min-h-control data-[active=true]:border-brand border-l-2 border-transparent px-3 data-[active=true]:font-semibold group-data-[collapsible=icon]:min-h-8"
|
||||||
>
|
>
|
||||||
<Link :href="item.href">
|
<Link
|
||||||
<component :is="item.icon" />
|
:href="item.href"
|
||||||
|
:aria-current="
|
||||||
|
(item.isActive ??
|
||||||
|
isCurrentUrl(
|
||||||
|
item.href,
|
||||||
|
undefined,
|
||||||
|
item.match === 'section',
|
||||||
|
))
|
||||||
|
? 'page'
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<component :is="item.icon" aria-hidden="true" />
|
||||||
<span>{{ item.title }}</span>
|
<span>{{ item.title }}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Link } from '@inertiajs/vue3';
|
||||||
|
import { ChevronDown, Settings2 } from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { useAppNavigation } from '@/composables/useAppNavigation';
|
||||||
|
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
|
||||||
|
const { projectItems, projectSettingsItems } = useAppNavigation();
|
||||||
|
const { isCurrentUrl } = useCurrentUrl();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const settingsActive = computed(() =>
|
||||||
|
projectSettingsItems.value.some((item) => isCurrentUrl(item.href)),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="projectItems.length"
|
||||||
|
class="bg-card md:px-page flex min-w-0 items-center gap-2 border-b px-4"
|
||||||
|
>
|
||||||
|
<nav
|
||||||
|
:aria-label="t('navigation.project')"
|
||||||
|
class="flex min-w-0 flex-1 gap-4 overflow-x-auto"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
v-for="item in projectItems"
|
||||||
|
:key="item.title"
|
||||||
|
:href="item.href"
|
||||||
|
:aria-current="isCurrentUrl(item.href) ? 'page' : undefined"
|
||||||
|
:class="[
|
||||||
|
'min-h-control text-body flex shrink-0 items-center gap-2 border-b-2 px-1 py-3 font-medium outline-offset-[-3px]',
|
||||||
|
isCurrentUrl(item.href)
|
||||||
|
? 'border-brand text-primary'
|
||||||
|
: 'text-muted-foreground hover:border-border hover:text-foreground border-transparent',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="item.icon"
|
||||||
|
class="size-4"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>{{ item.title }}
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
<DropdownMenu v-if="projectSettingsItems.length">
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:aria-label="t('navigation.projectSettings')"
|
||||||
|
:class="{
|
||||||
|
'bg-accent text-accent-foreground': settingsActive,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Settings2 aria-hidden="true" /><span
|
||||||
|
class="hidden sm:inline"
|
||||||
|
>{{ t('navigation.projectSettings') }}</span
|
||||||
|
><ChevronDown class="hidden sm:block" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
v-for="item in projectSettingsItems"
|
||||||
|
:key="item.title"
|
||||||
|
as-child
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
:href="item.href"
|
||||||
|
:aria-current="
|
||||||
|
isCurrentUrl(item.href) ? 'page' : undefined
|
||||||
|
"
|
||||||
|
:class="{ 'bg-accent': isCurrentUrl(item.href) }"
|
||||||
|
>
|
||||||
|
<component :is="item.icon" aria-hidden="true" />{{
|
||||||
|
item.title
|
||||||
|
}}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,33 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useInitials } from '@/composables/useInitials';
|
|
||||||
import type { User } from '@/types';
|
|
||||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||||
|
import type { User } from '@/types';
|
||||||
|
|
||||||
type Props = {
|
withDefaults(defineProps<{ user: User; showEmail?: boolean }>(), {
|
||||||
user: User;
|
|
||||||
showEmail?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
showEmail: false,
|
showEmail: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { getInitials } = useInitials();
|
|
||||||
|
|
||||||
// Compute whether we should show the avatar image
|
|
||||||
const showAvatar = computed(
|
|
||||||
() => props.user.avatar && props.user.avatar !== '',
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UserAvatar />
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
<UserAvatar :user="user" />
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
<div class="grid min-w-0 flex-1 text-left text-sm leading-tight">
|
||||||
<span class="truncate font-medium">{{ user.name }}</span>
|
<span class="truncate font-medium">{{ user.name }}</span>
|
||||||
<span v-if="showEmail" class="text-muted-foreground truncate text-xs">{{
|
<span
|
||||||
user.email
|
v-if="showEmail"
|
||||||
}}</span>
|
class="text-muted-foreground truncate text-xs"
|
||||||
|
>{{ user.email }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import UserInfo from '@/components/UserInfo.vue';
|
|||||||
import { logout } from '@/routes/index';
|
import { logout } from '@/routes/index';
|
||||||
import { edit } from '@/routes/profile/index';
|
import { edit } from '@/routes/profile/index';
|
||||||
import type { User } from '@/types';
|
import type { User } from '@/types';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -21,6 +22,7 @@ const handleLogout = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
defineProps<Props>();
|
defineProps<Props>();
|
||||||
|
const { t } = useLocalization();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -34,7 +36,7 @@ defineProps<Props>();
|
|||||||
<DropdownMenuItem :as-child="true">
|
<DropdownMenuItem :as-child="true">
|
||||||
<Link class="block w-full cursor-pointer" :href="edit()" prefetch>
|
<Link class="block w-full cursor-pointer" :href="edit()" prefetch>
|
||||||
<Settings class="mr-2 h-4 w-4" />
|
<Settings class="mr-2 h-4 w-4" />
|
||||||
Settings
|
{{ t('navigation.settings') }}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
@@ -48,7 +50,7 @@ defineProps<Props>();
|
|||||||
data-test="logout-button"
|
data-test="logout-button"
|
||||||
>
|
>
|
||||||
<LogOut class="mr-2 h-4 w-4" />
|
<LogOut class="mr-2 h-4 w-4" />
|
||||||
Log out
|
{{ t('navigation.logout') }}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,96 +1,118 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SidebarProps } from "."
|
import type { SidebarProps } from ".";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||||
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue'
|
import SheetDescription from "@/components/ui/sheet/SheetDescription.vue";
|
||||||
import SheetHeader from '@/components/ui/sheet/SheetHeader.vue'
|
import SheetHeader from "@/components/ui/sheet/SheetHeader.vue";
|
||||||
import SheetTitle from '@/components/ui/sheet/SheetTitle.vue'
|
import SheetTitle from "@/components/ui/sheet/SheetTitle.vue";
|
||||||
import { SIDEBAR_WIDTH_MOBILE, useSidebar } from "./utils"
|
import { SIDEBAR_WIDTH_MOBILE, useSidebar } from "./utils";
|
||||||
|
import { useLocalization } from "@/composables/useLocalization";
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
const props = withDefaults(defineProps<SidebarProps>(), {
|
const props = withDefaults(defineProps<SidebarProps>(), {
|
||||||
side: "left",
|
side: "left",
|
||||||
variant: "sidebar",
|
variant: "sidebar",
|
||||||
collapsible: "offcanvas",
|
collapsible: "offcanvas",
|
||||||
})
|
});
|
||||||
|
|
||||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
|
||||||
|
function restoreTriggerFocus(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
document
|
||||||
|
.querySelector<HTMLButtonElement>('[data-sidebar="trigger"]')
|
||||||
|
?.focus();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
|
||||||
v-if="collapsible === 'none'"
|
|
||||||
data-slot="sidebar"
|
|
||||||
:class="cn('bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col', props.class)"
|
|
||||||
v-bind="$attrs"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Sheet v-else-if="isMobile" :open="openMobile" v-bind="$attrs" @update:open="setOpenMobile">
|
|
||||||
<SheetContent
|
|
||||||
data-sidebar="sidebar"
|
|
||||||
data-slot="sidebar"
|
|
||||||
data-mobile="true"
|
|
||||||
:side="side"
|
|
||||||
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
|
||||||
:style="{
|
|
||||||
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<SheetHeader class="sr-only">
|
|
||||||
<SheetTitle>Sidebar</SheetTitle>
|
|
||||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
|
||||||
</SheetHeader>
|
|
||||||
<div class="flex h-full w-full flex-col">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="group peer text-sidebar-foreground hidden md:block"
|
|
||||||
data-slot="sidebar"
|
|
||||||
:data-state="state"
|
|
||||||
:data-collapsible="state === 'collapsed' ? collapsible : ''"
|
|
||||||
:data-variant="variant"
|
|
||||||
:data-side="side"
|
|
||||||
>
|
|
||||||
<!-- This is what handles the sidebar gap on desktop -->
|
|
||||||
<div
|
<div
|
||||||
:class="cn(
|
v-if="collapsible === 'none'"
|
||||||
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
|
data-slot="sidebar"
|
||||||
'group-data-[collapsible=offcanvas]:w-0',
|
:class="
|
||||||
'group-data-[side=right]:rotate-180',
|
cn(
|
||||||
variant === 'floating' || variant === 'inset'
|
'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',
|
||||||
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
|
props.class,
|
||||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
|
)
|
||||||
)"
|
"
|
||||||
/>
|
v-bind="$attrs"
|
||||||
<div
|
|
||||||
:class="cn(
|
|
||||||
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
|
|
||||||
side === 'left'
|
|
||||||
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
|
||||||
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
|
|
||||||
// Adjust the padding for floating and inset variants.
|
|
||||||
variant === 'floating' || variant === 'inset'
|
|
||||||
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
|
|
||||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
|
|
||||||
props.class,
|
|
||||||
)"
|
|
||||||
v-bind="$attrs"
|
|
||||||
>
|
>
|
||||||
<div
|
|
||||||
data-sidebar="sidebar"
|
|
||||||
class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
|
||||||
>
|
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<Sheet v-else-if="isMobile" :open="openMobile" @update:open="setOpenMobile">
|
||||||
|
<SheetContent
|
||||||
|
v-bind="$attrs"
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-slot="sidebar"
|
||||||
|
data-mobile="true"
|
||||||
|
:side="side"
|
||||||
|
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) max-w-[calc(100vw-2rem)] gap-0 p-0 [&>button]:hidden"
|
||||||
|
@close-auto-focus="restoreTriggerFocus"
|
||||||
|
:style="{
|
||||||
|
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<SheetHeader class="sr-only">
|
||||||
|
<SheetTitle>{{ t("navigation.main") }}</SheetTitle>
|
||||||
|
<SheetDescription>{{
|
||||||
|
t("navigation.mobileDescription")
|
||||||
|
}}</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div class="flex min-h-0 h-full w-full flex-col">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="group peer text-sidebar-foreground hidden md:block"
|
||||||
|
data-slot="sidebar"
|
||||||
|
:data-state="state"
|
||||||
|
:data-collapsible="state === 'collapsed' ? collapsible : ''"
|
||||||
|
:data-variant="variant"
|
||||||
|
:data-side="side"
|
||||||
|
>
|
||||||
|
<!-- This is what handles the sidebar gap on desktop -->
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
|
||||||
|
'group-data-[collapsible=offcanvas]:w-0',
|
||||||
|
'group-data-[side=right]:rotate-180',
|
||||||
|
variant === 'floating' || variant === 'inset'
|
||||||
|
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
|
||||||
|
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
|
||||||
|
side === 'left'
|
||||||
|
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
||||||
|
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
|
||||||
|
// Adjust the padding for floating and inset variants.
|
||||||
|
variant === 'floating' || variant === 'inset'
|
||||||
|
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
|
||||||
|
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
v-bind="$attrs"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
class?: HTMLAttributes["class"]
|
class?: HTMLAttributes["class"];
|
||||||
}>()
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-content"
|
data-slot="sidebar-content"
|
||||||
data-sidebar="content"
|
data-sidebar="content"
|
||||||
:class="cn('flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden', props.class)"
|
:class="
|
||||||
>
|
cn(
|
||||||
<slot />
|
'flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto',
|
||||||
</div>
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
class?: HTMLAttributes["class"]
|
class?: HTMLAttributes["class"];
|
||||||
}>()
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main
|
<main
|
||||||
data-slot="sidebar-inset"
|
data-slot="sidebar-inset"
|
||||||
:class="cn(
|
:class="
|
||||||
'bg-background relative flex w-full flex-1 flex-col',
|
cn(
|
||||||
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
|
'bg-background relative flex min-w-0 w-full flex-1 flex-col',
|
||||||
props.class,
|
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
|
||||||
)"
|
props.class,
|
||||||
>
|
)
|
||||||
<slot />
|
"
|
||||||
</main>
|
>
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,48 +1,60 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Component } from "vue"
|
import type { Component } from "vue";
|
||||||
import type { SidebarMenuButtonProps } from "./SidebarMenuButtonChild.vue"
|
import type { SidebarMenuButtonProps } from "./SidebarMenuButtonChild.vue";
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import {
|
||||||
import SidebarMenuButtonChild from "./SidebarMenuButtonChild.vue"
|
Tooltip,
|
||||||
import { useSidebar } from "./utils"
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import SidebarMenuButtonChild from "./SidebarMenuButtonChild.vue";
|
||||||
|
import { useSidebar } from "./utils";
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
const props = withDefaults(defineProps<SidebarMenuButtonProps & {
|
const props = withDefaults(
|
||||||
tooltip?: string | Component
|
defineProps<
|
||||||
}>(), {
|
SidebarMenuButtonProps & {
|
||||||
as: "button",
|
tooltip?: string | Component;
|
||||||
variant: "default",
|
}
|
||||||
size: "default",
|
>(),
|
||||||
})
|
{
|
||||||
|
as: "button",
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const { isMobile, state } = useSidebar()
|
const { isMobile, state } = useSidebar();
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "tooltip")
|
const delegatedProps = reactiveOmit(props, "tooltip");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SidebarMenuButtonChild v-if="!tooltip" v-bind="{ ...delegatedProps, ...$attrs }">
|
<SidebarMenuButtonChild
|
||||||
<slot />
|
v-if="!tooltip || isMobile || state !== 'collapsed'"
|
||||||
</SidebarMenuButtonChild>
|
v-bind="{ ...delegatedProps, ...$attrs }"
|
||||||
|
|
||||||
<Tooltip v-else>
|
|
||||||
<TooltipTrigger as-child>
|
|
||||||
<SidebarMenuButtonChild v-bind="{ ...delegatedProps, ...$attrs }">
|
|
||||||
<slot />
|
|
||||||
</SidebarMenuButtonChild>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent
|
|
||||||
side="right"
|
|
||||||
align="center"
|
|
||||||
:hidden="state !== 'collapsed' || isMobile"
|
|
||||||
>
|
>
|
||||||
<template v-if="typeof tooltip === 'string'">
|
<slot />
|
||||||
{{ tooltip }}
|
</SidebarMenuButtonChild>
|
||||||
</template>
|
|
||||||
<component :is="tooltip" v-else />
|
<Tooltip v-else>
|
||||||
</TooltipContent>
|
<TooltipTrigger as-child>
|
||||||
</Tooltip>
|
<SidebarMenuButtonChild v-bind="{ ...delegatedProps, ...$attrs }">
|
||||||
|
<slot />
|
||||||
|
</SidebarMenuButtonChild>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
align="center"
|
||||||
|
:hidden="state !== 'collapsed' || isMobile"
|
||||||
|
>
|
||||||
|
<template v-if="typeof tooltip === 'string'">
|
||||||
|
{{ tooltip }}
|
||||||
|
</template>
|
||||||
|
<component :is="tooltip" v-else />
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,82 +1,112 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HTMLAttributes, Ref } from "vue"
|
import type { HTMLAttributes, Ref } from "vue";
|
||||||
import { defaultDocument, useEventListener, useMediaQuery, useVModel } from "@vueuse/core"
|
import {
|
||||||
import { TooltipProvider } from "reka-ui"
|
defaultDocument,
|
||||||
import { computed, ref } from "vue"
|
useEventListener,
|
||||||
import { cn } from "@/lib/utils"
|
useMediaQuery,
|
||||||
import { provideSidebarContext, SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON } from "./utils"
|
useVModel,
|
||||||
|
} from "@vueuse/core";
|
||||||
|
import { TooltipProvider } from "reka-ui";
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
provideSidebarContext,
|
||||||
|
SIDEBAR_COOKIE_MAX_AGE,
|
||||||
|
SIDEBAR_COOKIE_NAME,
|
||||||
|
SIDEBAR_KEYBOARD_SHORTCUT,
|
||||||
|
SIDEBAR_WIDTH,
|
||||||
|
SIDEBAR_WIDTH_ICON,
|
||||||
|
} from "./utils";
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(
|
||||||
defaultOpen?: boolean
|
defineProps<{
|
||||||
open?: boolean
|
defaultOpen?: boolean;
|
||||||
class?: HTMLAttributes["class"]
|
open?: boolean;
|
||||||
}>(), {
|
class?: HTMLAttributes["class"];
|
||||||
defaultOpen: !defaultDocument?.cookie.includes(`${SIDEBAR_COOKIE_NAME}=false`),
|
}>(),
|
||||||
open: undefined,
|
{
|
||||||
})
|
defaultOpen: !defaultDocument?.cookie.includes(
|
||||||
|
`${SIDEBAR_COOKIE_NAME}=false`,
|
||||||
|
),
|
||||||
|
open: undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const emits = defineEmits<{
|
const emits = defineEmits<{
|
||||||
"update:open": [open: boolean]
|
"update:open": [open: boolean];
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
const isMobile = useMediaQuery("(max-width: 768px)")
|
const isMobile = useMediaQuery("(width < 768px)");
|
||||||
const openMobile = ref(false)
|
const openMobile = ref(false);
|
||||||
|
watch(isMobile, () => {
|
||||||
|
openMobile.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
const open = useVModel(props, "open", emits, {
|
const open = useVModel(props, "open", emits, {
|
||||||
defaultValue: props.defaultOpen ?? false,
|
defaultValue: props.defaultOpen ?? false,
|
||||||
passive: (props.open === undefined) as false,
|
passive: (props.open === undefined) as false,
|
||||||
}) as Ref<boolean>
|
}) as Ref<boolean>;
|
||||||
|
|
||||||
function setOpen(value: boolean) {
|
function setOpen(value: boolean) {
|
||||||
open.value = value // emits('update:open', value)
|
open.value = value; // emits('update:open', value)
|
||||||
|
|
||||||
// This sets the cookie to keep the sidebar state.
|
// This sets the cookie to keep the sidebar state.
|
||||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setOpenMobile(value: boolean) {
|
function setOpenMobile(value: boolean) {
|
||||||
openMobile.value = value
|
openMobile.value = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to toggle the sidebar.
|
// Helper to toggle the sidebar.
|
||||||
function toggleSidebar() {
|
function toggleSidebar() {
|
||||||
return isMobile.value ? setOpenMobile(!openMobile.value) : setOpen(!open.value)
|
return isMobile.value
|
||||||
|
? setOpenMobile(!openMobile.value)
|
||||||
|
: setOpen(!open.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEventListener("keydown", (event: KeyboardEvent) => {
|
useEventListener("keydown", (event: KeyboardEvent) => {
|
||||||
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
|
if (
|
||||||
event.preventDefault()
|
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||||
toggleSidebar()
|
(event.metaKey || event.ctrlKey)
|
||||||
}
|
) {
|
||||||
})
|
event.preventDefault();
|
||||||
|
toggleSidebar();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||||
// This makes it easier to style the sidebar with Tailwind classes.
|
// This makes it easier to style the sidebar with Tailwind classes.
|
||||||
const state = computed(() => open.value ? "expanded" : "collapsed")
|
const state = computed(() => (open.value ? "expanded" : "collapsed"));
|
||||||
|
|
||||||
provideSidebarContext({
|
provideSidebarContext({
|
||||||
state,
|
state,
|
||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
isMobile,
|
isMobile,
|
||||||
openMobile,
|
openMobile,
|
||||||
setOpenMobile,
|
setOpenMobile,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TooltipProvider :delay-duration="0">
|
<TooltipProvider :delay-duration="0">
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-wrapper"
|
data-slot="sidebar-wrapper"
|
||||||
:style="{
|
:style="{
|
||||||
'--sidebar-width': SIDEBAR_WIDTH,
|
'--sidebar-width': SIDEBAR_WIDTH,
|
||||||
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
|
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
|
||||||
}"
|
}"
|
||||||
:class="cn('group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', props.class)"
|
:class="
|
||||||
v-bind="$attrs"
|
cn(
|
||||||
>
|
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
|
||||||
<slot />
|
props.class,
|
||||||
</div>
|
)
|
||||||
</TooltipProvider>
|
"
|
||||||
|
v-bind="$attrs"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,28 +1,44 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { PanelLeftClose, PanelLeftOpen } from "lucide-vue-next"
|
import { PanelLeftClose, PanelLeftOpen } from "lucide-vue-next";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { useSidebar } from "./utils"
|
import { useSidebar } from "./utils";
|
||||||
|
import { useLocalization } from "@/composables/useLocalization";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
class?: HTMLAttributes["class"]
|
class?: HTMLAttributes["class"];
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
const { isMobile, state, toggleSidebar } = useSidebar()
|
const { isMobile, state, openMobile, toggleSidebar } = useSidebar();
|
||||||
|
const { t } = useLocalization();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Button
|
<Button
|
||||||
data-sidebar="trigger"
|
data-sidebar="trigger"
|
||||||
data-slot="sidebar-trigger"
|
data-slot="sidebar-trigger"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
:class="cn('h-7 w-7', props.class)"
|
type="button"
|
||||||
@click="toggleSidebar"
|
aria-controls="app-navigation"
|
||||||
>
|
:aria-expanded="isMobile ? openMobile : state === 'expanded'"
|
||||||
<PanelLeftOpen v-if="isMobile || state === 'collapsed'" />
|
:aria-label="
|
||||||
<PanelLeftClose v-else />
|
isMobile
|
||||||
<span class="sr-only">Toggle sidebar</span>
|
? t(openMobile ? 'navigation.close' : 'navigation.open')
|
||||||
</Button>
|
: t(
|
||||||
|
state === 'collapsed'
|
||||||
|
? 'navigation.expand'
|
||||||
|
: 'navigation.collapse',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:class="cn('size-control', props.class)"
|
||||||
|
@click="toggleSidebar"
|
||||||
|
>
|
||||||
|
<PanelLeftOpen
|
||||||
|
v-if="isMobile || state === 'collapsed'"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<PanelLeftClose v-else aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createContext } from "reka-ui"
|
import { createContext } from "reka-ui";
|
||||||
import type { ComputedRef, Ref } from 'vue';
|
import type { ComputedRef, Ref } from "vue";
|
||||||
|
|
||||||
export type SidebarContext = {
|
export type SidebarContext = {
|
||||||
state: ComputedRef<'expanded' | 'collapsed'>;
|
state: ComputedRef<"expanded" | "collapsed">;
|
||||||
open: Ref<boolean>;
|
open: Ref<boolean>;
|
||||||
setOpen: (value: boolean) => void;
|
setOpen: (value: boolean) => void;
|
||||||
isMobile: Ref<boolean>;
|
isMobile: Ref<boolean>;
|
||||||
@@ -11,11 +11,12 @@ export type SidebarContext = {
|
|||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
export const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||||
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||||
export const SIDEBAR_WIDTH = "16rem"
|
export const SIDEBAR_WIDTH = "16rem";
|
||||||
export const SIDEBAR_WIDTH_MOBILE = "18rem"
|
export const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||||
export const SIDEBAR_WIDTH_ICON = "3rem"
|
export const SIDEBAR_WIDTH_ICON = "4rem";
|
||||||
export const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
export const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||||
|
|
||||||
export const [useSidebar, provideSidebarContext] = createContext<SidebarContext>("Sidebar")
|
export const [useSidebar, provideSidebarContext] =
|
||||||
|
createContext<SidebarContext>("Sidebar");
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { usePage } from '@inertiajs/vue3';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import {
|
||||||
|
projectNavigation,
|
||||||
|
projectSettingsNavigation,
|
||||||
|
workspaceNavigation,
|
||||||
|
} from '@/lib/navigation';
|
||||||
|
|
||||||
|
export function useAppNavigation() {
|
||||||
|
const page = usePage();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const project = computed(() => page.props.navigation?.project ?? null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
project,
|
||||||
|
workspaceItems: computed(() =>
|
||||||
|
workspaceNavigation(page.props.auth.permissions, t),
|
||||||
|
),
|
||||||
|
projectItems: computed(() => projectNavigation(project.value, t)),
|
||||||
|
projectSettingsItems: computed(() =>
|
||||||
|
projectSettingsNavigation(project.value, t),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { usePage } from '@inertiajs/vue3';
|
|||||||
import type { ComputedRef, DeepReadonly } from 'vue';
|
import type { ComputedRef, DeepReadonly } from 'vue';
|
||||||
import { computed, readonly } from 'vue';
|
import { computed, readonly } from 'vue';
|
||||||
import { toUrl } from '@/lib/utils';
|
import { toUrl } from '@/lib/utils';
|
||||||
|
import { matchesNavigationUrl } from '@/lib/navigation';
|
||||||
|
|
||||||
export type UseCurrentUrlReturn = {
|
export type UseCurrentUrlReturn = {
|
||||||
currentUrl: DeepReadonly<ComputedRef<string>>;
|
currentUrl: DeepReadonly<ComputedRef<string>>;
|
||||||
@@ -22,40 +23,21 @@ export type UseCurrentUrlReturn = {
|
|||||||
) => T | F;
|
) => T | F;
|
||||||
};
|
};
|
||||||
|
|
||||||
const page = usePage();
|
|
||||||
const currentUrlReactive = computed(
|
|
||||||
() =>
|
|
||||||
new URL(
|
|
||||||
page.url,
|
|
||||||
typeof window !== 'undefined'
|
|
||||||
? window.location.origin
|
|
||||||
: 'http://localhost',
|
|
||||||
).pathname,
|
|
||||||
);
|
|
||||||
|
|
||||||
export function useCurrentUrl(): UseCurrentUrlReturn {
|
export function useCurrentUrl(): UseCurrentUrlReturn {
|
||||||
|
const page = usePage();
|
||||||
|
const currentUrlReactive = computed(
|
||||||
|
() => new URL(page.url, 'http://localhost').pathname,
|
||||||
|
);
|
||||||
function isCurrentUrl(
|
function isCurrentUrl(
|
||||||
urlToCheck: NonNullable<InertiaLinkProps['href']>,
|
urlToCheck: NonNullable<InertiaLinkProps['href']>,
|
||||||
currentUrl?: string,
|
currentUrl?: string,
|
||||||
startsWith: boolean = false,
|
startsWith: boolean = false,
|
||||||
) {
|
) {
|
||||||
const urlToCompare = currentUrl ?? currentUrlReactive.value;
|
return matchesNavigationUrl(
|
||||||
const urlString = toUrl(urlToCheck);
|
toUrl(urlToCheck),
|
||||||
|
currentUrl ?? currentUrlReactive.value,
|
||||||
const comparePath = (path: string): boolean =>
|
startsWith,
|
||||||
startsWith ? urlToCompare.startsWith(path) : path === urlToCompare;
|
);
|
||||||
|
|
||||||
if (!urlString.startsWith('http')) {
|
|
||||||
return comparePath(urlString);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const absoluteUrl = new URL(urlString);
|
|
||||||
|
|
||||||
return comparePath(absoluteUrl.pathname);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCurrentOrParentUrl(
|
function isCurrentOrParentUrl(
|
||||||
@@ -65,11 +47,11 @@ export function useCurrentUrl(): UseCurrentUrlReturn {
|
|||||||
return isCurrentUrl(urlToCheck, currentUrl, true);
|
return isCurrentUrl(urlToCheck, currentUrl, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function whenCurrentUrl(
|
function whenCurrentUrl<T, F = null>(
|
||||||
urlToCheck: NonNullable<InertiaLinkProps['href']>,
|
urlToCheck: NonNullable<InertiaLinkProps['href']>,
|
||||||
ifTrue: any,
|
ifTrue: T,
|
||||||
ifFalse: any = null,
|
ifFalse: F = null as F,
|
||||||
) {
|
): T | F {
|
||||||
return isCurrentUrl(urlToCheck) ? ifTrue : ifFalse;
|
return isCurrentUrl(urlToCheck) ? ifTrue : ifFalse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import AppContent from '@/components/AppContent.vue';
|
|||||||
import AppShell from '@/components/AppShell.vue';
|
import AppShell from '@/components/AppShell.vue';
|
||||||
import AppSidebar from '@/components/AppSidebar.vue';
|
import AppSidebar from '@/components/AppSidebar.vue';
|
||||||
import AppSidebarHeader from '@/components/AppSidebarHeader.vue';
|
import AppSidebarHeader from '@/components/AppSidebarHeader.vue';
|
||||||
|
import ProjectNavigation from '@/components/ProjectNavigation.vue';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import type { BreadcrumbItem } from '@/types';
|
import type { BreadcrumbItem } from '@/types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -12,14 +14,27 @@ type Props = {
|
|||||||
withDefaults(defineProps<Props>(), {
|
withDefaults(defineProps<Props>(), {
|
||||||
breadcrumbs: () => [],
|
breadcrumbs: () => [],
|
||||||
});
|
});
|
||||||
|
const { t } = useLocalization();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppShell variant="sidebar">
|
<AppShell variant="sidebar">
|
||||||
|
<a
|
||||||
|
href="#main-content"
|
||||||
|
class="bg-primary text-primary-foreground sr-only fixed left-4 top-4 z-50 rounded-md p-3 focus:not-sr-only"
|
||||||
|
>{{ t('navigation.skipToContent') }}</a
|
||||||
|
>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<AppContent variant="sidebar" class="overflow-x-hidden">
|
<AppContent variant="sidebar" class="min-w-0">
|
||||||
<AppSidebarHeader :breadcrumbs="breadcrumbs" />
|
<AppSidebarHeader :breadcrumbs="breadcrumbs" />
|
||||||
<slot />
|
<ProjectNavigation />
|
||||||
|
<div
|
||||||
|
id="main-content"
|
||||||
|
tabindex="-1"
|
||||||
|
class="flex min-w-0 flex-1 flex-col"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
</AppContent>
|
</AppContent>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link } from '@inertiajs/vue3';
|
import { Link } from '@inertiajs/vue3';
|
||||||
|
import { computed } from 'vue';
|
||||||
import Heading from '@/components/Heading.vue';
|
import Heading from '@/components/Heading.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { toUrl } from '@/lib/utils';
|
import { toUrl } from '@/lib/utils';
|
||||||
import { edit as editAppearance } from '@/routes/appearance/index';
|
import { edit as editAppearance } from '@/routes/appearance/index';
|
||||||
import { edit as editApplications } from '@/routes/applications/index';
|
import { edit as editApplications } from '@/routes/applications/index';
|
||||||
@@ -12,28 +14,29 @@ import { edit as editProfile } from '@/routes/profile/index';
|
|||||||
import { edit as editSecurity } from '@/routes/security/index';
|
import { edit as editSecurity } from '@/routes/security/index';
|
||||||
import type { NavItem } from '@/types';
|
import type { NavItem } from '@/types';
|
||||||
|
|
||||||
const sidebarNavItems: NavItem[] = [
|
const { t } = useLocalization();
|
||||||
|
const sidebarNavItems = computed<NavItem[]>(() => [
|
||||||
{
|
{
|
||||||
title: 'Profile',
|
title: t('navigation.profile'),
|
||||||
href: editProfile(),
|
href: editProfile(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Security',
|
title: t('navigation.security'),
|
||||||
href: editSecurity(),
|
href: editSecurity(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Git connections',
|
title: t('navigation.gitConnections'),
|
||||||
href: editGitConnections(),
|
href: editGitConnections(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Applications',
|
title: t('navigation.applications'),
|
||||||
href: editApplications(),
|
href: editApplications(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Appearance',
|
title: t('navigation.appearance'),
|
||||||
href: editAppearance(),
|
href: editAppearance(),
|
||||||
},
|
},
|
||||||
];
|
]);
|
||||||
|
|
||||||
const { isCurrentOrParentUrl } = useCurrentUrl();
|
const { isCurrentOrParentUrl } = useCurrentUrl();
|
||||||
</script>
|
</script>
|
||||||
@@ -41,27 +44,34 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
|
|||||||
<template>
|
<template>
|
||||||
<div class="px-4 py-6">
|
<div class="px-4 py-6">
|
||||||
<Heading
|
<Heading
|
||||||
title="Settings"
|
:title="t('navigation.settings')"
|
||||||
description="Manage your profile, security, and Git integration settings"
|
:description="t('navigation.settingsDescription')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col lg:flex-row lg:space-x-12">
|
<div class="flex min-w-0 flex-col gap-6 lg:flex-row lg:gap-12">
|
||||||
<aside class="w-full max-w-xl lg:w-48">
|
<aside class="w-full min-w-0 lg:w-52 lg:shrink-0">
|
||||||
<nav
|
<nav
|
||||||
class="flex flex-col space-x-0 space-y-1"
|
class="flex gap-1 overflow-x-auto lg:flex-col"
|
||||||
aria-label="Settings"
|
:aria-label="t('navigation.settings')"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
v-for="item in sidebarNavItems"
|
v-for="item in sidebarNavItems"
|
||||||
:key="toUrl(item.href)"
|
:key="toUrl(item.href)"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
:class="[
|
:class="[
|
||||||
'w-full justify-start',
|
'shrink-0 justify-start lg:w-full',
|
||||||
{ 'bg-muted': isCurrentOrParentUrl(item.href) },
|
{ 'bg-muted': isCurrentOrParentUrl(item.href) },
|
||||||
]"
|
]"
|
||||||
as-child
|
as-child
|
||||||
>
|
>
|
||||||
<Link :href="item.href">
|
<Link
|
||||||
|
:href="item.href"
|
||||||
|
:aria-current="
|
||||||
|
isCurrentOrParentUrl(item.href)
|
||||||
|
? 'page'
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
>
|
||||||
<component :is="item.icon" class="h-4 w-4" />
|
<component :is="item.icon" class="h-4 w-4" />
|
||||||
{{ item.title }}
|
{{ item.title }}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -69,9 +79,9 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
|
|||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<Separator class="my-6 lg:hidden" />
|
<Separator class="lg:hidden" />
|
||||||
|
|
||||||
<div class="flex-1 md:max-w-2xl">
|
<div class="min-w-0 flex-1 md:max-w-2xl">
|
||||||
<section class="max-w-xl space-y-12">
|
<section class="max-w-xl space-y-12">
|
||||||
<slot />
|
<slot />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
Columns3,
|
||||||
|
Folder,
|
||||||
|
FolderGit2,
|
||||||
|
House,
|
||||||
|
KeyRound,
|
||||||
|
Layers3,
|
||||||
|
LayoutGrid,
|
||||||
|
ListTodo,
|
||||||
|
Settings2,
|
||||||
|
Shield,
|
||||||
|
Tags,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
import { dashboard } from '@/routes';
|
||||||
|
import { index as adminUsers } from '@/routes/admin/users';
|
||||||
|
import { index as templates } from '@/routes/project-templates';
|
||||||
|
import { access, index as projects, show } from '@/routes/projects';
|
||||||
|
import { index as backlog } from '@/routes/projects/backlog';
|
||||||
|
import { index as board } from '@/routes/projects/board';
|
||||||
|
import {
|
||||||
|
board as boardSettings,
|
||||||
|
general,
|
||||||
|
metadata,
|
||||||
|
notifications,
|
||||||
|
repositories,
|
||||||
|
} from '@/routes/projects/settings';
|
||||||
|
import { index as teams } from '@/routes/teams';
|
||||||
|
import type { Localization } from '@/lib/localization';
|
||||||
|
import type { AuthPermissions, NavItem, NavigationProject } from '@/types';
|
||||||
|
|
||||||
|
export function workspaceNavigation(
|
||||||
|
permissions: AuthPermissions,
|
||||||
|
t: Localization['t'],
|
||||||
|
): NavItem[] {
|
||||||
|
const items: NavItem[] = [
|
||||||
|
{
|
||||||
|
title: t('navigation.dashboard'),
|
||||||
|
href: dashboard(),
|
||||||
|
icon: LayoutGrid,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('navigation.projects'),
|
||||||
|
href: projects(),
|
||||||
|
icon: Folder,
|
||||||
|
match: 'section',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (permissions.projectCreate) {
|
||||||
|
items.push({
|
||||||
|
title: t('navigation.templates'),
|
||||||
|
href: templates(),
|
||||||
|
icon: Layers3,
|
||||||
|
match: 'section',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (permissions.canManageTeams) {
|
||||||
|
items.push({
|
||||||
|
title: t('navigation.teams'),
|
||||||
|
href: teams(),
|
||||||
|
icon: Users,
|
||||||
|
match: 'section',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (permissions.globalAdmin) {
|
||||||
|
items.push({
|
||||||
|
title: t('navigation.users'),
|
||||||
|
href: adminUsers(),
|
||||||
|
icon: Shield,
|
||||||
|
match: 'section',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectNavigation(
|
||||||
|
project: NavigationProject | null,
|
||||||
|
t: Localization['t'],
|
||||||
|
): NavItem[] {
|
||||||
|
if (!project) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: t('navigation.overview'),
|
||||||
|
href: show(project.id),
|
||||||
|
icon: House,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('navigation.board'),
|
||||||
|
href: board(project.id),
|
||||||
|
icon: Columns3,
|
||||||
|
match: 'section',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('navigation.backlog'),
|
||||||
|
href: backlog(project.id),
|
||||||
|
icon: ListTodo,
|
||||||
|
match: 'section',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectSettingsNavigation(
|
||||||
|
project: NavigationProject | null,
|
||||||
|
t: Localization['t'],
|
||||||
|
): NavItem[] {
|
||||||
|
if (!project?.canManage) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: t('navigation.general'),
|
||||||
|
href: general(project.id),
|
||||||
|
icon: Settings2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('navigation.boardSettings'),
|
||||||
|
href: boardSettings(project.id),
|
||||||
|
icon: Columns3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('navigation.ticketSettings'),
|
||||||
|
href: metadata(project.id),
|
||||||
|
icon: Tags,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('navigation.repositories'),
|
||||||
|
href: repositories(project.id),
|
||||||
|
icon: FolderGit2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('navigation.notifications'),
|
||||||
|
href: notifications(project.id),
|
||||||
|
icon: Bell,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('navigation.access'),
|
||||||
|
href: access(project.id),
|
||||||
|
icon: KeyRound,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesNavigationUrl(
|
||||||
|
target: string,
|
||||||
|
current: string,
|
||||||
|
section = false,
|
||||||
|
): boolean {
|
||||||
|
try {
|
||||||
|
const base = 'http://localhost';
|
||||||
|
const targetPath =
|
||||||
|
new URL(target, base).pathname.replace(/\/+$/, '') || '/';
|
||||||
|
const currentPath =
|
||||||
|
new URL(current, base).pathname.replace(/\/+$/, '') || '/';
|
||||||
|
return (
|
||||||
|
currentPath === targetPath ||
|
||||||
|
(section &&
|
||||||
|
targetPath !== '/' &&
|
||||||
|
currentPath.startsWith(`${targetPath}/`))
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,43 @@
|
|||||||
export const en = {
|
export const en = {
|
||||||
|
'navigation.main': 'Main navigation',
|
||||||
|
'navigation.workspace': 'Workspace',
|
||||||
|
'navigation.project': 'Project navigation',
|
||||||
|
'navigation.projectSettings': 'Project settings',
|
||||||
|
'navigation.account': 'Account',
|
||||||
|
'navigation.dashboard': 'Dashboard',
|
||||||
|
'navigation.projects': 'Projects',
|
||||||
|
'navigation.templates': 'Project templates',
|
||||||
|
'navigation.users': 'Users',
|
||||||
|
'navigation.teams': 'Teams',
|
||||||
|
'navigation.overview': 'Overview',
|
||||||
|
'navigation.board': 'Board',
|
||||||
|
'navigation.backlog': 'Backlog',
|
||||||
|
'navigation.general': 'General',
|
||||||
|
'navigation.boardSettings': 'Board settings',
|
||||||
|
'navigation.ticketSettings': 'Ticket settings',
|
||||||
|
'navigation.repositories': 'Repositories',
|
||||||
|
'navigation.notifications': 'Notifications',
|
||||||
|
'navigation.access': 'Access',
|
||||||
|
'navigation.settings': 'Settings',
|
||||||
|
'navigation.profile': 'Profile',
|
||||||
|
'navigation.security': 'Security',
|
||||||
|
'navigation.gitConnections': 'Git connections',
|
||||||
|
'navigation.applications': 'Applications',
|
||||||
|
'navigation.appearance': 'Appearance',
|
||||||
|
'navigation.settingsDescription':
|
||||||
|
'Manage your profile, security, and Git integration settings.',
|
||||||
|
'navigation.createTicket': 'New ticket',
|
||||||
|
'navigation.createProject': 'New project',
|
||||||
|
'navigation.open': 'Open navigation',
|
||||||
|
'navigation.close': 'Close navigation',
|
||||||
|
'navigation.expand': 'Expand sidebar',
|
||||||
|
'navigation.collapse': 'Collapse sidebar',
|
||||||
|
'navigation.mobileDescription':
|
||||||
|
'Navigate your workspace and current project.',
|
||||||
|
'navigation.skipToContent': 'Skip to content',
|
||||||
|
'navigation.userMenu': 'Account menu for {name}',
|
||||||
|
'navigation.logout': 'Log out',
|
||||||
|
'navigation.breadcrumbs': 'Breadcrumbs',
|
||||||
'appearance.label': 'Color theme',
|
'appearance.label': 'Color theme',
|
||||||
'appearance.light': 'Light',
|
'appearance.light': 'Light',
|
||||||
'appearance.dark': 'Dark',
|
'appearance.dark': 'Dark',
|
||||||
|
|||||||
Vendored
+2
@@ -1,4 +1,5 @@
|
|||||||
import type { Auth } from '@/types/auth';
|
import type { Auth } from '@/types/auth';
|
||||||
|
import type { AppNavigation } from '@/types/navigation';
|
||||||
import type { LocalizationSettings } from '@/types/localization';
|
import type { LocalizationSettings } from '@/types/localization';
|
||||||
import type { Appearance } from '@/types/ui';
|
import type { Appearance } from '@/types/ui';
|
||||||
import type { Page, Router } from '@inertiajs/core';
|
import type { Page, Router } from '@inertiajs/core';
|
||||||
@@ -25,6 +26,7 @@ declare module '@inertiajs/core' {
|
|||||||
appearance: Appearance;
|
appearance: Appearance;
|
||||||
auth: Auth;
|
auth: Auth;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
|
navigation: AppNavigation;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,17 @@ export type NavItem = {
|
|||||||
href: NonNullable<InertiaLinkProps['href']>;
|
href: NonNullable<InertiaLinkProps['href']>;
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
match?: 'exact' | 'section';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NavigationProject = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
canManage: boolean;
|
||||||
|
canCreateTickets: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppNavigation = {
|
||||||
|
project: NavigationProject | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class NavigationTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
#[DataProvider('projectRoles')]
|
||||||
|
public function test_navigation_uses_effective_project_permissions(string $role, bool $teamAccess, bool $canManage, bool $canCreateTickets): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$assignee = $user;
|
||||||
|
if ($teamAccess) {
|
||||||
|
$assignee = Team::factory()->create();
|
||||||
|
$assignee->users()->attach($user);
|
||||||
|
}
|
||||||
|
$this->syncProjectPermissions($project, $assignee, [$role]);
|
||||||
|
|
||||||
|
$this->actingAs($user)->get(route('projects.board.index', $project))
|
||||||
|
->assertOk()->assertInertia(fn (Assert $page) => $page
|
||||||
|
->where('navigation.project', [
|
||||||
|
'id' => $project->id,
|
||||||
|
'name' => $project->name,
|
||||||
|
'key' => $project->key,
|
||||||
|
'canManage' => $canManage,
|
||||||
|
'canCreateTickets' => $canCreateTickets,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{string, bool, bool, bool}> */
|
||||||
|
public static function projectRoles(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'reader' => ['read', false, false, false],
|
||||||
|
'writer' => ['write', false, false, true],
|
||||||
|
'project admin' => ['admin', false, true, true],
|
||||||
|
'team reader' => ['read', true, false, false],
|
||||||
|
'team writer' => ['write', true, false, true],
|
||||||
|
'team admin' => ['admin', true, true, true],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_global_admin_has_project_actions_without_direct_membership(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->admin()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$this->actingAs($user)->get(route('projects.show', $project))
|
||||||
|
->assertOk()->assertInertia(fn (Assert $page) => $page
|
||||||
|
->where('navigation.project.canManage', true)
|
||||||
|
->where('navigation.project.canCreateTickets', true)
|
||||||
|
->where('auth.permissions.globalAdmin', true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_every_project_admin_navigation_destination_is_authorized(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$this->syncProjectPermissions($project, $user, ['admin']);
|
||||||
|
foreach (['projects.show', 'projects.board.index', 'projects.backlog.index', 'projects.settings.general', 'projects.settings.board', 'projects.settings.metadata', 'projects.settings.repositories', 'projects.settings.notifications', 'projects.access'] as $route) {
|
||||||
|
$this->actingAs($user)->get(route($route, $project))
|
||||||
|
->assertOk()->assertInertia(fn (Assert $page) => $page
|
||||||
|
->where('navigation.project.id', $project->id)
|
||||||
|
->where('navigation.project.canManage', true)
|
||||||
|
->where('auth.permissions.globalAdmin', false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_project_context_is_cleared_when_leaving_a_project(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->admin()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$this->actingAs($user)->get(route('projects.show', $project))
|
||||||
|
->assertInertia(fn (Assert $page) => $page->where('navigation.project.id', $project->id));
|
||||||
|
$this->get(route('dashboard'))->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page->where('navigation.project', null));
|
||||||
|
$this->get(route('projects.create'))->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page->where('navigation.project', null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unrelated_projects_remain_inaccessible(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create(['name' => 'Private project']);
|
||||||
|
$this->actingAs($user)->get(route('projects.show', $project))
|
||||||
|
->assertForbidden()->assertDontSee('Private project');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_navigation_rendering_and_url_matching(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/navigation.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Navigation behavior checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { createApp, h } from 'vue';
|
||||||
|
import { createInertiaApp, Head, usePage } from '@inertiajs/vue3';
|
||||||
|
import '../../resources/css/app.css';
|
||||||
|
import AppLayout from '../../resources/js/layouts/AppLayout.vue';
|
||||||
|
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||||
|
import { createLocalization } from '../../resources/js/lib/localization';
|
||||||
|
|
||||||
|
const Preview = {
|
||||||
|
setup() {
|
||||||
|
const page = usePage();
|
||||||
|
return () => {
|
||||||
|
const title = page.url
|
||||||
|
.split('?')[0]
|
||||||
|
.split('/')
|
||||||
|
.filter(Boolean)
|
||||||
|
.at(-1);
|
||||||
|
return h(
|
||||||
|
AppLayout,
|
||||||
|
{
|
||||||
|
breadcrumbs: [
|
||||||
|
{ title: 'Projects', href: '/projects' },
|
||||||
|
...(page.props.navigation.project
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: page.props.navigation.project.name,
|
||||||
|
href: '/projects/7',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{ title, href: page.url },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
() => [
|
||||||
|
h(Head, { title }),
|
||||||
|
h('div', { class: 'grid gap-column p-page' }, [
|
||||||
|
h(
|
||||||
|
'h1',
|
||||||
|
{
|
||||||
|
class: 'text-headline',
|
||||||
|
'data-test': 'page-title',
|
||||||
|
},
|
||||||
|
title,
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'p',
|
||||||
|
{ class: 'text-muted-foreground' },
|
||||||
|
'Navigation preview with isolated test data.',
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
class: 'grid gap-column sm:grid-cols-2 xl:grid-cols-3',
|
||||||
|
},
|
||||||
|
['To do', 'In progress', 'Done'].map((label) =>
|
||||||
|
h(
|
||||||
|
'section',
|
||||||
|
{
|
||||||
|
class: 'grid gap-stack rounded-md border bg-card p-card',
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h('h2', { class: 'text-title' }, label),
|
||||||
|
h(
|
||||||
|
'p',
|
||||||
|
{},
|
||||||
|
'Plan and track the next iteration.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
createInertiaApp({
|
||||||
|
resolve: () => Preview,
|
||||||
|
setup: ({ el, App, props, plugin }) =>
|
||||||
|
createApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(createLocalization())
|
||||||
|
.use(createAppearance())
|
||||||
|
.mount(el),
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import tailwind from '@tailwindcss/vite';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
plugins: [vue(), tailwind()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: { host: '127.0.0.1', port: 4175, strictPort: true, hmr: false },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
|
||||||
|
server.middlewares.use(async (request, response, next) => {
|
||||||
|
const url = new URL(request.url, 'http://localhost');
|
||||||
|
if (
|
||||||
|
!/^\/(dashboard|projects|project-templates|teams|admin|settings)(\/|$)/.test(
|
||||||
|
url.pathname,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
const reader =
|
||||||
|
request.headers.cookie?.includes('navigation_test_role=reader') ??
|
||||||
|
false;
|
||||||
|
const project = url.pathname.startsWith('/projects/7')
|
||||||
|
? {
|
||||||
|
id: 7,
|
||||||
|
name: 'Engineering · Platform and developer experience',
|
||||||
|
key: 'ENG',
|
||||||
|
canManage: !reader,
|
||||||
|
canCreateTickets: !reader,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
const page = {
|
||||||
|
component: 'NavigationPreview',
|
||||||
|
url: request.url,
|
||||||
|
version: null,
|
||||||
|
clearHistory: false,
|
||||||
|
encryptHistory: false,
|
||||||
|
props: {
|
||||||
|
name: 'KanbanGit',
|
||||||
|
sidebarOpen: !request.headers.cookie?.includes(
|
||||||
|
'sidebar_state=false',
|
||||||
|
),
|
||||||
|
auth: {
|
||||||
|
permissions: {
|
||||||
|
globalAdmin: !reader,
|
||||||
|
projectCreate: !reader,
|
||||||
|
canManageTeams: !reader,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Alex Morgan',
|
||||||
|
email: 'alex@example.test',
|
||||||
|
avatar: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
navigation: { project },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
response.setHeader('Vary', 'X-Inertia');
|
||||||
|
if (request.headers['x-inertia']) {
|
||||||
|
response.setHeader('X-Inertia', 'true');
|
||||||
|
response.setHeader('Content-Type', 'application/json');
|
||||||
|
response.end(JSON.stringify(page));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const escapedPage = JSON.stringify(page)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll('<', '<');
|
||||||
|
const html =
|
||||||
|
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||||
|
escapedPage +
|
||||||
|
'"></div><script type="module" src="/tests/Fixtures/navigation-preview.mjs"></script></body></html>';
|
||||||
|
response.setHeader('Content-Type', 'text/html');
|
||||||
|
response.end(await server.transformIndexHtml(request.url, html));
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.listen();
|
||||||
|
console.log(
|
||||||
|
'Navigation fixture ready at http://127.0.0.1:4175/projects/7/board',
|
||||||
|
);
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
matchesNavigationUrl,
|
||||||
|
projectNavigation,
|
||||||
|
projectSettingsNavigation,
|
||||||
|
workspaceNavigation,
|
||||||
|
} = await server.ssrLoadModule('/resources/js/lib/navigation.ts');
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const { createAppearance } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useAppearance.ts',
|
||||||
|
);
|
||||||
|
const { en } = await server.ssrLoadModule('/resources/js/locales/en.ts');
|
||||||
|
const { default: AppLayout } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/layouts/AppLayout.vue',
|
||||||
|
);
|
||||||
|
const locale = createLocalization();
|
||||||
|
const permissions = {
|
||||||
|
globalAdmin: false,
|
||||||
|
projectCreate: false,
|
||||||
|
canManageTeams: false,
|
||||||
|
};
|
||||||
|
const project = {
|
||||||
|
id: 7,
|
||||||
|
name: 'Engineering',
|
||||||
|
key: 'ENG',
|
||||||
|
canManage: false,
|
||||||
|
canCreateTickets: false,
|
||||||
|
};
|
||||||
|
const targets = (items) => items.map((item) => item.href.url);
|
||||||
|
assert.deepEqual(targets(workspaceNavigation(permissions, locale.t)), [
|
||||||
|
'/dashboard',
|
||||||
|
'/projects',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
targets(
|
||||||
|
workspaceNavigation(
|
||||||
|
{ ...permissions, canManageTeams: true },
|
||||||
|
locale.t,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
['/dashboard', '/projects', '/teams'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
targets(
|
||||||
|
workspaceNavigation(
|
||||||
|
{ ...permissions, projectCreate: true },
|
||||||
|
locale.t,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
['/dashboard', '/projects', '/project-templates'],
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
targets(
|
||||||
|
workspaceNavigation(
|
||||||
|
{ ...permissions, globalAdmin: true },
|
||||||
|
locale.t,
|
||||||
|
),
|
||||||
|
).includes('/admin/users'),
|
||||||
|
);
|
||||||
|
assert.equal(projectNavigation(null, locale.t).length, 0);
|
||||||
|
assert.deepEqual(targets(projectNavigation(project, locale.t)), [
|
||||||
|
'/projects/7',
|
||||||
|
'/projects/7/board',
|
||||||
|
'/projects/7/backlog',
|
||||||
|
]);
|
||||||
|
assert.equal(projectSettingsNavigation(project, locale.t).length, 0);
|
||||||
|
assert.deepEqual(
|
||||||
|
targets(
|
||||||
|
projectSettingsNavigation(
|
||||||
|
{ ...project, canManage: true },
|
||||||
|
locale.t,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
[
|
||||||
|
'/projects/7/settings/general',
|
||||||
|
'/projects/7/settings/board',
|
||||||
|
'/projects/7/settings/metadata',
|
||||||
|
'/projects/7/settings/repositories',
|
||||||
|
'/projects/7/settings/notifications',
|
||||||
|
'/projects/7/access',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
for (const [target, current, section, expected] of [
|
||||||
|
['/projects', '/projects/7/board', true, true],
|
||||||
|
['/projects', '/project-templates', true, false],
|
||||||
|
['/projects/7', '/projects/70', true, false],
|
||||||
|
['/projects/7', '/projects/7/board', false, false],
|
||||||
|
[
|
||||||
|
'/projects/7/board',
|
||||||
|
'/projects/7/board?filter=mine#lane',
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'/projects/7/board/',
|
||||||
|
'https://kanbangit.test/projects/7/board',
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
],
|
||||||
|
['/', '/projects', true, false],
|
||||||
|
]) {
|
||||||
|
assert.equal(matchesNavigationUrl(target, current, section), expected);
|
||||||
|
}
|
||||||
|
const render = async (
|
||||||
|
url,
|
||||||
|
projectContext,
|
||||||
|
authPermissions = permissions,
|
||||||
|
localization = locale,
|
||||||
|
) => {
|
||||||
|
const response = await createInertiaApp({
|
||||||
|
page: {
|
||||||
|
component: 'Preview',
|
||||||
|
url,
|
||||||
|
version: null,
|
||||||
|
clearHistory: false,
|
||||||
|
encryptHistory: false,
|
||||||
|
props: {
|
||||||
|
name: 'KanbanGit',
|
||||||
|
sidebarOpen: true,
|
||||||
|
appearance: 'light',
|
||||||
|
auth: {
|
||||||
|
permissions: authPermissions,
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Test User',
|
||||||
|
email: 'test@example.test',
|
||||||
|
avatar: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
navigation: { project: projectContext },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: () => ({
|
||||||
|
render: () =>
|
||||||
|
h(
|
||||||
|
AppLayout,
|
||||||
|
{
|
||||||
|
breadcrumbs: [
|
||||||
|
{ title: 'Board', href: '/projects/7/board' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
() => h('h1', {}, 'Content'),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
render: renderToString,
|
||||||
|
setup: ({ App, props, plugin }) =>
|
||||||
|
createSSRApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(localization)
|
||||||
|
.use(createAppearance('light')),
|
||||||
|
});
|
||||||
|
return response.body;
|
||||||
|
};
|
||||||
|
const reader = await render('/projects/7/board?filter=mine', project);
|
||||||
|
assert.match(
|
||||||
|
reader,
|
||||||
|
/<a(?=[^>]*href="\/projects\/7\/board")(?=[^>]*aria-current="page")[^>]*>/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(reader, /href="\/projects\/7\/settings/);
|
||||||
|
assert.doesNotMatch(reader, /href="\/admin\/users"/);
|
||||||
|
assert.doesNotMatch(reader, /href="\/projects\/7\/tickets\/create/);
|
||||||
|
assert.doesNotMatch(reader, /Laravel Starter Kit|vue-starter-kit/);
|
||||||
|
assert.match(reader, /aria-controls="app-navigation"/);
|
||||||
|
const writer = await render('/projects/7/backlog', {
|
||||||
|
...project,
|
||||||
|
canCreateTickets: true,
|
||||||
|
});
|
||||||
|
assert.match(
|
||||||
|
writer,
|
||||||
|
/href="\/projects\/7\/tickets\/create\?source=backlog"/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(writer, /href="\/projects\/7\/settings/);
|
||||||
|
const admin = await render('/projects/7/settings/general', {
|
||||||
|
...project,
|
||||||
|
canManage: true,
|
||||||
|
canCreateTickets: true,
|
||||||
|
});
|
||||||
|
assert.match(
|
||||||
|
admin,
|
||||||
|
/<a(?=[^>]*href="\/projects\/7\/settings\/general")(?=[^>]*aria-current="page")[^>]*>/,
|
||||||
|
);
|
||||||
|
// Render another page in the same process to catch stale module-level Inertia state.
|
||||||
|
const dashboard = await render('/dashboard', null);
|
||||||
|
assert.match(
|
||||||
|
dashboard,
|
||||||
|
/<a(?=[^>]*href="\/dashboard")(?=[^>]*aria-current="page")[^>]*>/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(dashboard, /href="\/projects\/7/);
|
||||||
|
const translated = await render(
|
||||||
|
'/dashboard',
|
||||||
|
null,
|
||||||
|
permissions,
|
||||||
|
createLocalization(
|
||||||
|
{ locale: 'de' },
|
||||||
|
{
|
||||||
|
de: {
|
||||||
|
...en,
|
||||||
|
'navigation.dashboard': 'Übersicht',
|
||||||
|
'navigation.expand': 'Navigation öffnen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.match(translated, /Übersicht/);
|
||||||
|
console.log('Navigation behavior checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user