chore: germantax customer project
This commit is contained in:
207
apps/app/src/components/AppLayout.tsx
Normal file
207
apps/app/src/components/AppLayout.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { Link, useLocation, useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
AppShell,
|
||||
Avatar,
|
||||
Burger,
|
||||
Group,
|
||||
Menu,
|
||||
NavLink,
|
||||
Stack,
|
||||
Text,
|
||||
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 { Building2, LayoutDashboard } from 'lucide-react'
|
||||
|
||||
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(() => {
|
||||
if (!setTitle) return
|
||||
setTitle(title)
|
||||
return () => setTitle(undefined)
|
||||
}, [setTitle, title])
|
||||
}
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/companies', labelKey: 'common.nav.items.companies', Icon: Building2 },
|
||||
{ to: '/portfolio', labelKey: 'common.nav.items.portfolio', Icon: LayoutDashboard },
|
||||
] as const
|
||||
|
||||
const initials = (name: string) =>
|
||||
name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase() ?? '')
|
||||
.join('')
|
||||
|
||||
function QSeal() {
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 26, height: 26, borderRadius: 6, background: '#1F5641',
|
||||
color: '#FBFAF7', fontFamily: "'Source Serif 4', Georgia, serif",
|
||||
fontWeight: 600, fontSize: 15, lineHeight: 1, flexShrink: 0,
|
||||
}}>
|
||||
Q
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppLayout({
|
||||
user,
|
||||
children,
|
||||
}: {
|
||||
user: { userId: string; email: string; displayName: 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 API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4003'
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
const csrfRes = await fetch(`${API_URL}/auth/csrf`, { credentials: 'include' })
|
||||
const { csrfToken } = (await csrfRes.json()) as { csrfToken: string }
|
||||
const body = new URLSearchParams({ csrfToken, callbackUrl: '/login' })
|
||||
await fetch(`${API_URL}/auth/signout`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
credentials: 'include',
|
||||
redirect: 'manual',
|
||||
})
|
||||
qc.clear()
|
||||
navigate({ to: '/login' })
|
||||
} catch {
|
||||
navigate({ to: '/logout' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageTitleContext.Provider value={setPageTitle}>
|
||||
<AppShell
|
||||
header={{ height: 64 }}
|
||||
navbar={{ width: 248, breakpoint: 'sm', collapsed: { mobile: !opened } }}
|
||||
padding={{ base: 'md', md: 'xl' }}
|
||||
styles={{
|
||||
header: { borderBottom: '1px solid #E6E3DB', background: '#fff' },
|
||||
navbar: { borderRight: '1px solid #E6E3DB', background: '#fff' },
|
||||
}}
|
||||
>
|
||||
<AppShell.Header>
|
||||
<Group h="100%" px={0} justify="space-between" wrap="nowrap">
|
||||
{/* Logo area matches sidebar width */}
|
||||
<Group
|
||||
gap={11}
|
||||
style={{
|
||||
width: 248, flexShrink: 0, padding: '0 22px',
|
||||
borderRight: '1px solid #EFEDE6', height: '100%',
|
||||
}}
|
||||
>
|
||||
<Burger
|
||||
opened={opened}
|
||||
onClick={toggle}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
aria-label={m.common_nav_toggle()}
|
||||
/>
|
||||
<Link to="/" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 11 }} onClick={close}>
|
||||
<QSeal />
|
||||
<Text style={{ fontFamily: "'Source Serif 4', Georgia, serif", fontSize: 17, fontWeight: 600, color: '#16201B' }}>
|
||||
{m.common_brand_name()}
|
||||
</Text>
|
||||
</Link>
|
||||
</Group>
|
||||
|
||||
{/* Right: breadcrumb + user */}
|
||||
<Group justify="space-between" style={{ flex: 1, padding: '0 26px' }}>
|
||||
<Group gap={12}>
|
||||
{pageTitle && (
|
||||
<>
|
||||
<Text style={{ color: '#A9B0A6', fontFamily: "'IBM Plex Mono', monospace", fontSize: 12 }}>
|
||||
{m.common_brand_name()}
|
||||
</Text>
|
||||
<span style={{ color: '#D8D3C8' }}>/</span>
|
||||
<Text fw={600} size="sm" visibleFrom="sm" style={{ color: '#16201B' }}>
|
||||
{asI18n(pageTitle ?? '')}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<Menu position="bottom-end" withArrow shadow="sm">
|
||||
<Menu.Target>
|
||||
<UnstyledButton style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '4px 8px', borderRadius: 8 }}>
|
||||
<Stack gap={0} align="flex-end" visibleFrom="sm">
|
||||
<Text size="sm" fw={600} style={{ lineHeight: 1.2, color: '#16201B' }}>{asI18n(user.displayName)}</Text>
|
||||
<Text size="xs" style={{ lineHeight: 1.2, color: '#A9B0A6', fontFamily: "'IBM Plex Mono', monospace" }}>{asI18n(user.email)}</Text>
|
||||
</Stack>
|
||||
<Avatar radius="xl" color="quorum" variant="light" size={34}>
|
||||
{asI18n(initials(user.displayName))}
|
||||
</Avatar>
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{asI18n(user.email)}</Menu.Label>
|
||||
<Menu.Divider />
|
||||
<Menu.Item color="red" onClick={handleLogout}>
|
||||
{m.common_nav_logout()}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar p="md">
|
||||
<AppShell.Section grow>
|
||||
<p style={{
|
||||
color: '#A9B0A6', fontFamily: "'IBM Plex Mono', monospace",
|
||||
letterSpacing: '.06em', textTransform: 'uppercase', fontSize: 9,
|
||||
padding: '0 10px', marginBottom: 10, margin: '0 0 10px 0',
|
||||
}}>
|
||||
Workspace
|
||||
</p>
|
||||
<Stack gap={4}>
|
||||
{NAV_ITEMS.map(({ to, labelKey, Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
component={Link}
|
||||
to={to}
|
||||
label={mKey(labelKey)}
|
||||
leftSection={<Icon size={16} />}
|
||||
active={location.pathname.startsWith(to)}
|
||||
onClick={close}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</AppShell.Section>
|
||||
<AppShell.Section pt="md" style={{ borderTop: '1px solid #EFEDE6' }}>
|
||||
<Text style={{ color: '#B7BDB6', fontFamily: "'IBM Plex Mono', monospace", lineHeight: 1.6, fontSize: 10 }}>
|
||||
{m.common_brand_tagline()}
|
||||
</Text>
|
||||
</AppShell.Section>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main style={{ background: '#FBFAF7' }}>{children}</AppShell.Main>
|
||||
</AppShell>
|
||||
</PageTitleContext.Provider>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user