chore: seminarhof customer project
This commit is contained in:
270
apps/app/src/components/AppLayout.tsx
Normal file
270
apps/app/src/components/AppLayout.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import { Link, useLocation, useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
Avatar,
|
||||
AppShell,
|
||||
Burger,
|
||||
Divider,
|
||||
Group,
|
||||
Menu,
|
||||
NavLink,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
UnstyledButton,
|
||||
} from '@pikku/mantine/core'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { m, mKey } from '@/i18n/messages'
|
||||
import { useLocale } from '@/i18n/config'
|
||||
import { asI18n } from '@pikku/react'
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { signOut } from '../lib/auth'
|
||||
import { BrandMark } from './Brand'
|
||||
|
||||
/**
|
||||
* Per-page title shown in the app header next to the brand mark. Pages opt in
|
||||
* via `usePageTitle(...)`; pages that don't call it leave the header showing
|
||||
* only the brand, so existing pages keep working unchanged.
|
||||
*/
|
||||
const PageTitleContext = createContext<
|
||||
((title: string | undefined) => void) | null
|
||||
>(null)
|
||||
|
||||
export function PageTitle({ title }: { title: string }) {
|
||||
usePageTitle(title)
|
||||
return null
|
||||
}
|
||||
|
||||
export function usePageTitle(title: string | undefined) {
|
||||
const setTitle = useContext(PageTitleContext)
|
||||
useEffect(() => {
|
||||
// No-op safely if used outside the provider (e.g. unauthenticated pages).
|
||||
if (!setTitle) return
|
||||
setTitle(title)
|
||||
return () => setTitle(undefined)
|
||||
}, [setTitle, title])
|
||||
}
|
||||
|
||||
type Role = 'client' | 'admin' | 'owner'
|
||||
|
||||
type NavItem = {
|
||||
to: string
|
||||
labelKey: string
|
||||
roles: Role[]
|
||||
}
|
||||
|
||||
type Section = {
|
||||
titleKey: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
const SECTIONS: Section[] = [
|
||||
{
|
||||
titleKey: 'common.nav.sections.workspace',
|
||||
items: [
|
||||
{ to: '/admin', labelKey: 'common.nav.items.dashboard', roles: ['admin', 'owner'] },
|
||||
{ to: '/admin/bookings', labelKey: 'common.nav.items.bookings', roles: ['admin', 'owner'] },
|
||||
{ to: '/admin/enquiries', labelKey: 'common.nav.items.enquiries', roles: ['admin', 'owner'] },
|
||||
{ to: '/kanban', labelKey: 'common.nav.items.kanban', roles: ['admin', 'owner'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
titleKey: 'common.nav.sections.public',
|
||||
items: [
|
||||
{ to: '/availability', labelKey: 'common.nav.items.availability', roles: ['client', 'admin', 'owner'] },
|
||||
{ to: '/enquiry', labelKey: 'common.nav.items.enquiry', roles: ['admin', 'owner'] },
|
||||
{ to: '/events', labelKey: 'common.nav.items.events', roles: ['client', 'admin', 'owner'] },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const initials = (name: string) =>
|
||||
name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase() ?? '')
|
||||
.join('')
|
||||
|
||||
export function AppLayout({
|
||||
user,
|
||||
children,
|
||||
}: {
|
||||
user: { name: string; role: string; email: string }
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
useLocale()
|
||||
const [opened, { toggle, close }] = useDisclosure()
|
||||
const [pageTitle, setPageTitle] = useState<string | undefined>(undefined)
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const role = user.role as Role
|
||||
|
||||
const [logoutPending, setLogoutPending] = useState(false)
|
||||
const handleLogout = async () => {
|
||||
setLogoutPending(true)
|
||||
try {
|
||||
await signOut()
|
||||
} finally {
|
||||
await qc.invalidateQueries({ queryKey: ['me'] })
|
||||
navigate({ to: '/login' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageTitleContext.Provider value={setPageTitle}>
|
||||
<AppShell
|
||||
header={{ height: 72 }}
|
||||
navbar={{ width: 248, breakpoint: 'sm', collapsed: { mobile: !opened } }}
|
||||
padding={{ base: 'md', md: 'xl' }}
|
||||
styles={{
|
||||
header: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderBottom: '1px solid var(--mantine-color-gray-2)',
|
||||
},
|
||||
navbar: {
|
||||
backgroundColor: '#FBF8FA',
|
||||
borderRight: '1px solid var(--mantine-color-gray-2)',
|
||||
},
|
||||
main: { backgroundColor: 'var(--brand-bg)' },
|
||||
}}
|
||||
>
|
||||
<AppShell.Header>
|
||||
<Group h="100%" px="md" gap="md" wrap="nowrap" justify="space-between">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Burger
|
||||
opened={opened}
|
||||
onClick={toggle}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
color="var(--brand-plum)"
|
||||
aria-label={m.common_nav_toggle()}
|
||||
/>
|
||||
<Link to="/" style={{ textDecoration: 'none' }} onClick={close}>
|
||||
<Group gap="sm" wrap="nowrap" align="center">
|
||||
<BrandMark size={32} />
|
||||
<Title
|
||||
order={5}
|
||||
c="var(--brand-plum-dark)"
|
||||
fw={600}
|
||||
style={{ letterSpacing: '0.01em', lineHeight: 1 }}
|
||||
>
|
||||
{m.common_brand_name()}
|
||||
</Title>
|
||||
</Group>
|
||||
</Link>
|
||||
{pageTitle && (
|
||||
<>
|
||||
<Divider orientation="vertical" my={8} visibleFrom="sm" />
|
||||
<Text
|
||||
fw={600}
|
||||
size="md"
|
||||
c="var(--brand-plum-dark)"
|
||||
visibleFrom="sm"
|
||||
style={{ lineHeight: 1 }}
|
||||
>
|
||||
{asI18n(`${pageTitle}`)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<Menu position="bottom-end" withArrow shadow="sm">
|
||||
<Menu.Target>
|
||||
<UnstyledButton
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '4px 8px',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Stack gap={0} align="flex-end" visibleFrom="sm">
|
||||
<Text size="sm" fw={600} style={{ lineHeight: 1.2 }}>{asI18n(`${user.name}`)}</Text>
|
||||
<Text size="xs" c="dimmed" tt="capitalize" style={{ lineHeight: 1.2 }}>
|
||||
{mKey(`common.nav.role.${user.role}` as any)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Avatar
|
||||
radius="xl"
|
||||
color="plum"
|
||||
variant="light"
|
||||
size={36}
|
||||
>
|
||||
{initials(user.name)}
|
||||
</Avatar>
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{asI18n(`${user.email}`)}</Menu.Label>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
onClick={handleLogout}
|
||||
disabled={logoutPending}
|
||||
>
|
||||
{m.common_nav_logout()}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar p="md">
|
||||
<AppShell.Section grow component={ScrollArea}>
|
||||
<Stack gap="lg">
|
||||
{SECTIONS.map((section) => {
|
||||
const visible = section.items.filter((n) => n.roles.includes(role))
|
||||
if (visible.length === 0) return null
|
||||
return (
|
||||
<Stack key={section.titleKey} gap={4}>
|
||||
<Text
|
||||
size="xs"
|
||||
tt="uppercase"
|
||||
c="dimmed"
|
||||
fw={600}
|
||||
px="sm"
|
||||
mb={4}
|
||||
style={{ letterSpacing: '0.08em' }}
|
||||
>
|
||||
{asI18n(`${mKey(section.titleKey as any)}`)}
|
||||
</Text>
|
||||
{visible.map((item) => {
|
||||
// '/admin' is the dashboard landing — match it exactly so it
|
||||
// doesn't stay highlighted across every /admin/* sub-page.
|
||||
const active =
|
||||
item.to === '/admin'
|
||||
? location.pathname === '/admin' ||
|
||||
location.pathname === '/admin/'
|
||||
: location.pathname.startsWith(item.to)
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
component={Link}
|
||||
to={item.to}
|
||||
label={mKey(item.labelKey as any)}
|
||||
active={active}
|
||||
onClick={close}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Stack>
|
||||
)
|
||||
})}
|
||||
</Stack>
|
||||
</AppShell.Section>
|
||||
<AppShell.Section
|
||||
pt="md"
|
||||
style={{ borderTop: '1px solid var(--mantine-color-gray-2)' }}
|
||||
>
|
||||
<Text size="xs" c="dimmed">{m.common_brand_tagline()}</Text>
|
||||
</AppShell.Section>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>{children}</AppShell.Main>
|
||||
</AppShell>
|
||||
</PageTitleContext.Provider>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user