feat: show active product name in navbar, links to product page
Sub-layout sets product in Zustand store; NavBar reads it. getAccessibleProduct wrapped with React cache to avoid double DB call. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bc91e3c169
commit
29ed4f2773
5 changed files with 69 additions and 2 deletions
25
app/(app)/products/[id]/layout.tsx
Normal file
25
app/(app)/products/[id]/layout.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { redirect, notFound } from 'next/navigation'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
import { getAccessibleProduct } from '@/lib/product-access'
|
||||||
|
import { SetCurrentProduct } from '@/components/shared/set-current-product'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: React.ReactNode
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProductLayout({ children, params }: Props) {
|
||||||
|
const { id } = await params
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session.userId) redirect('/login')
|
||||||
|
|
||||||
|
const product = await getAccessibleProduct(id, session.userId)
|
||||||
|
if (!product) notFound()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetCurrentProduct id={id} name={product.name} />
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { AppIcon } from '@/components/shared/app-icon'
|
import { AppIcon } from '@/components/shared/app-icon'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useProductStore } from '@/stores/product-store'
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
PRODUCT_OWNER: 'PO',
|
PRODUCT_OWNER: 'PO',
|
||||||
|
|
@ -21,6 +22,7 @@ interface NavBarProps {
|
||||||
|
|
||||||
export function NavBar({ isDemo, roles }: NavBarProps) {
|
export function NavBar({ isDemo, roles }: NavBarProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
const currentProduct = useProductStore(s => s.currentProduct)
|
||||||
|
|
||||||
const productMatch = pathname.match(/^\/products\/([^/]+)/)
|
const productMatch = pathname.match(/^\/products\/([^/]+)/)
|
||||||
const productId = productMatch ? productMatch[1] : null
|
const productId = productMatch ? productMatch[1] : null
|
||||||
|
|
@ -81,6 +83,17 @@ export function NavBar({ isDemo, roles }: NavBarProps) {
|
||||||
{roles.map(r => ROLE_LABELS[r]).filter(Boolean).join(' · ')}
|
{roles.map(r => ROLE_LABELS[r]).filter(Boolean).join(' · ')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{currentProduct && (
|
||||||
|
<Link
|
||||||
|
href={`/products/${currentProduct.id}`}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors px-2 truncate max-w-[160px]"
|
||||||
|
title={currentProduct.name}
|
||||||
|
>
|
||||||
|
{currentProduct.name.length > 20
|
||||||
|
? currentProduct.name.slice(0, 20) + '…'
|
||||||
|
: currentProduct.name}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
15
components/shared/set-current-product.tsx
Normal file
15
components/shared/set-current-product.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useProductStore } from '@/stores/product-store'
|
||||||
|
|
||||||
|
export function SetCurrentProduct({ id, name }: { id: string; name: string }) {
|
||||||
|
const { setCurrentProduct, clearCurrentProduct } = useProductStore()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentProduct(id, name)
|
||||||
|
return () => clearCurrentProduct()
|
||||||
|
}, [id, name, setCurrentProduct, clearCurrentProduct])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { cache } from 'react'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
const accessFilter = (userId: string) => ({
|
const accessFilter = (userId: string) => ({
|
||||||
|
|
@ -7,11 +8,11 @@ const accessFilter = (userId: string) => ({
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function getAccessibleProduct(productId: string, userId: string) {
|
export const getAccessibleProduct = cache(async (productId: string, userId: string) => {
|
||||||
return prisma.product.findFirst({
|
return prisma.product.findFirst({
|
||||||
where: { id: productId, ...accessFilter(userId) },
|
where: { id: productId, ...accessFilter(userId) },
|
||||||
})
|
})
|
||||||
}
|
})
|
||||||
|
|
||||||
export function productAccessFilter(userId: string) {
|
export function productAccessFilter(userId: string) {
|
||||||
return accessFilter(userId)
|
return accessFilter(userId)
|
||||||
|
|
|
||||||
13
stores/product-store.ts
Normal file
13
stores/product-store.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
interface ProductStore {
|
||||||
|
currentProduct: { id: string; name: string } | null
|
||||||
|
setCurrentProduct: (id: string, name: string) => void
|
||||||
|
clearCurrentProduct: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useProductStore = create<ProductStore>((set) => ({
|
||||||
|
currentProduct: null,
|
||||||
|
setCurrentProduct: (id, name) => set({ currentProduct: { id, name } }),
|
||||||
|
clearCurrentProduct: () => set({ currentProduct: null }),
|
||||||
|
}))
|
||||||
Loading…
Add table
Add a link
Reference in a new issue