Add tab/project quick-switch dropdowns to the project breadcrumb

The project header breadcrumb now always shows the active tab
(previously Overview was hidden), and both the project-name and
tab-level crumbs become dropdowns: project name lists all projects
(with a checkmark on the active one, plus "All Projects"), and the
tab crumb lists Overview/Shots/Assets/Tasks/Schedule/Settings with a
checkmark on the current tab. The trailing ">" separator is skipped
after any crumb that renders as a dropdown.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 23:22:35 +08:00
parent 74be250912
commit 04c85be0f7
2 changed files with 95 additions and 10 deletions
+81 -4
View File
@@ -8,13 +8,53 @@
<Breadcrumb class="flex-1">
<BreadcrumbList>
<BreadcrumbItem v-for="(crumb, index) in breadcrumbs" :key="index">
<BreadcrumbLink v-if="crumb.href" :href="crumb.href">
<DropdownMenu v-if="crumb.isProjectCrumb && projectsForSwitcher.length > 0">
<DropdownMenuTrigger
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
:class="{ 'font-semibold text-foreground': crumb.isActive }"
>
{{ crumb.label }}
<ChevronDown class="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem v-for="project in projectsForSwitcher" :key="project.id" as-child>
<router-link :to="`/projects/${project.id}`" class="flex items-center justify-between gap-4 w-full">
{{ project.name }}
<Check v-if="String(project.id) === projectIdParam" class="h-4 w-4 flex-shrink-0" />
</router-link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem as-child>
<router-link to="/projects">All Projects</router-link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu v-else-if="crumb.isTabCrumb && projectIdParam">
<DropdownMenuTrigger
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
:class="{ 'font-semibold text-foreground': crumb.isActive }"
>
{{ crumb.label }}
<ChevronDown class="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem v-for="tabItem in projectTabItems" :key="tabItem.tab" as-child>
<router-link :to="`/projects/${projectIdParam}${tabItem.path}`" class="flex items-center justify-between gap-4 w-full">
{{ tabItem.label }}
<Check v-if="currentTab === tabItem.tab" class="h-4 w-4 flex-shrink-0" />
</router-link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<BreadcrumbLink v-else-if="crumb.href" :href="crumb.href">
{{ crumb.label }}
</BreadcrumbLink>
<BreadcrumbPage v-else :class="{ 'font-semibold': crumb.isActive }">
{{ crumb.label }}
</BreadcrumbPage>
<BreadcrumbSeparator v-if="index < breadcrumbs.length - 1" />
<BreadcrumbSeparator v-if="index < breadcrumbs.length - 1 && !isDropdownCrumb(crumb)" />
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
@@ -76,7 +116,7 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { SidebarTrigger } from '@/components/ui/sidebar'
import { Separator } from '@/components/ui/separator'
@@ -97,10 +137,11 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import { User, Settings, LogOut } from 'lucide-vue-next'
import { User, Settings, LogOut, ChevronDown, Check } from 'lucide-vue-next'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { useAuthStore } from '@/stores/auth'
import { useProjectsStore } from '@/stores/projects'
import { BreadcrumbService, type BreadcrumbItem as BreadcrumbData } from '@/services/breadcrumb'
import ThemeToggle from '@/components/ui/theme/ThemeToggle.vue'
import NotificationCenter from './NotificationCenter.vue'
@@ -123,6 +164,42 @@ const { getAvatarUrl } = useAvatarUrl()
// Generate breadcrumbs based on current route with enhanced context
const breadcrumbs = ref<BreadcrumbData[]>([])
// The current tab's breadcrumb (Overview/Shots/Assets/...) becomes a quick-nav
// dropdown instead of a plain link/label - see BreadcrumbItem.isTabCrumb.
const projectIdParam = computed(() => {
const id = route.params.projectId
return typeof id === 'string' ? id : Array.isArray(id) ? id[0] : null
})
const currentTab = computed(() => route.meta?.tab as string | undefined)
const projectTabItems = [
{ tab: 'overview', label: 'Overview', path: '' },
{ tab: 'shots', label: 'Shots', path: '/shots' },
{ tab: 'assets', label: 'Assets', path: '/assets' },
{ tab: 'tasks', label: 'Tasks', path: '/tasks' },
{ tab: 'schedule', label: 'Schedule', path: '/schedule' },
{ tab: 'settings', label: 'Settings', path: '/settings' }
]
// Project-name breadcrumb becomes a project-switcher dropdown - see BreadcrumbItem.isProjectCrumb.
const projectsStore = useProjectsStore()
const projectsForSwitcher = computed(() => projectsStore.projects)
onMounted(() => {
if (projectsStore.projects.length === 0 && !projectsStore.isLoading) {
projectsStore.fetchProjects()
}
})
// Mirrors the v-if conditions that actually render a crumb as a dropdown,
// so the trailing ">" separator can be skipped for it.
function isDropdownCrumb(crumb: BreadcrumbData): boolean {
if (crumb.isProjectCrumb) return projectsForSwitcher.value.length > 0
if (crumb.isTabCrumb) return !!projectIdParam.value
return false
}
const updateBreadcrumbs = async () => {
try {
breadcrumbs.value = await BreadcrumbService.generateBreadcrumbs(route)
+14 -6
View File
@@ -7,6 +7,10 @@ export interface BreadcrumbItem {
label: string
href?: string
isActive?: boolean
/** True for the crumb representing the current project tab (Overview/Shots/Assets/...), so the header can render a tab-switcher dropdown on it. */
isTabCrumb?: boolean
/** True for the crumb representing the current project name, so the header can render a project-switcher dropdown on it. */
isProjectCrumb?: boolean
}
export class BreadcrumbService {
@@ -33,7 +37,8 @@ export class BreadcrumbService {
// Add project breadcrumb
crumbs.push({
label: project ? project.name : `Project ${projectId}`,
href: `/projects/${projectId}`
href: `/projects/${projectId}`,
isProjectCrumb: true
})
// Handle tab-based navigation
@@ -45,7 +50,8 @@ export class BreadcrumbService {
if (tab === 'shots' && route.params.episodeId) {
crumbs.push({
label: tabLabel,
href: `/projects/${projectId}/shots`
href: `/projects/${projectId}/shots`,
isTabCrumb: true
})
// Add episode context
@@ -66,11 +72,12 @@ export class BreadcrumbService {
})
}
}
} else if (pathSegments[2] || tab !== 'overview') {
// Regular tab navigation (don't show Overview in breadcrumbs unless explicitly navigated to)
} else {
// Regular tab navigation (Overview included, so the trail always reads Home > Project > Tab)
crumbs.push({
label: tabLabel,
isActive: true
isActive: true,
isTabCrumb: true
})
}
}
@@ -85,7 +92,8 @@ export class BreadcrumbService {
if (crumbs.length > 1) {
crumbs[crumbs.length - 1] = {
label: 'Shots',
href: `/projects/${projectId}/shots`
href: `/projects/${projectId}/shots`,
isTabCrumb: true
}
}
crumbs.push({