Files
pulse/app/layout-content.tsx
Usman Baig 7ae5facd0c fix: proper dashboard layout — header + sidebar + content fill viewport
Use h-screen overflow-hidden on the root container for authenticated
views. Sidebar and content fill the remaining height below the header.
Remove footer from dashboard pages. Content scrolls inside its own
container, sidebar stays fixed in place.
2026-03-18 15:38:58 +01:00

189 lines
6.0 KiB
TypeScript

'use client'
import { OfflineBanner } from '@/components/OfflineBanner'
import { Footer } from '@/components/Footer'
import { Header, type CipheraApp, MenuIcon } from '@ciphera-net/ui'
import NotificationCenter from '@/components/notifications/NotificationCenter'
import { useAuth } from '@/lib/auth/context'
import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { logger } from '@/lib/utils/logger'
import { getUserOrganizations, switchContext, type OrganizationMember } from '@/lib/api/organization'
import { setSessionAction } from '@/app/actions/auth'
import { LoadingOverlay } from '@ciphera-net/ui'
import { useRouter } from 'next/navigation'
import { SettingsModalProvider, useSettingsModal } from '@/lib/settings-modal-context'
import SettingsModalWrapper from '@/components/settings/SettingsModalWrapper'
import { SidebarProvider, useSidebar } from '@/lib/sidebar-context'
const ORG_SWITCH_KEY = 'pulse_switching_org'
// * Available Ciphera apps for the app switcher
const CIPHERA_APPS: CipheraApp[] = [
{
id: 'pulse',
name: 'Pulse',
description: 'Your current app — Privacy-first analytics',
icon: 'https://ciphera.net/pulse_icon_no_margins.png',
href: 'https://pulse.ciphera.net',
isAvailable: false, // * Current app
},
{
id: 'drop',
name: 'Drop',
description: 'Secure file sharing',
icon: 'https://ciphera.net/drop_icon_no_margins.png',
href: 'https://drop.ciphera.net',
isAvailable: true,
},
{
id: 'auth',
name: 'Auth',
description: 'Your Ciphera account settings',
icon: 'https://ciphera.net/auth_icon_no_margins.png',
href: 'https://auth.ciphera.net',
isAvailable: true,
},
]
function MobileSidebarToggle() {
const { openMobile } = useSidebar()
return (
<button
onClick={openMobile}
className="lg:hidden p-2 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors"
aria-label="Open navigation"
>
<MenuIcon className="w-5 h-5" />
</button>
)
}
function LayoutInner({ children }: { children: React.ReactNode }) {
const auth = useAuth()
const router = useRouter()
const isOnline = useOnlineStatus()
const { openSettings } = useSettingsModal()
const [orgs, setOrgs] = useState<OrganizationMember[]>([])
const [isSwitchingOrg, setIsSwitchingOrg] = useState(() => {
if (typeof window === 'undefined') return false
return sessionStorage.getItem(ORG_SWITCH_KEY) === 'true'
})
// * Clear the switching flag once the page has settled after reload
useEffect(() => {
if (isSwitchingOrg) {
sessionStorage.removeItem(ORG_SWITCH_KEY)
const timer = setTimeout(() => setIsSwitchingOrg(false), 600)
return () => clearTimeout(timer)
}
}, [isSwitchingOrg])
// * Fetch organizations for the header organization switcher
useEffect(() => {
if (auth.user) {
getUserOrganizations()
.then((organizations) => setOrgs(Array.isArray(organizations) ? organizations : []))
.catch(err => logger.error('Failed to fetch orgs for header', err))
}
}, [auth.user])
const handleSwitchOrganization = async (orgId: string | null) => {
if (!orgId) return // Pulse doesn't support personal organization context
try {
const { access_token } = await switchContext(orgId)
await setSessionAction(access_token)
sessionStorage.setItem(ORG_SWITCH_KEY, 'true')
window.location.reload()
} catch (err) {
logger.error('Failed to switch organization', err)
}
}
const handleCreateOrganization = () => {
router.push('/onboarding')
}
const isAuthenticated = !!auth.user
const showOfflineBar = Boolean(auth.user && !isOnline)
if (isSwitchingOrg) {
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" portal={false} />
}
const headerElement = (
<Header
auth={auth}
LinkComponent={Link}
logoSrc="/pulse_icon_no_margins.png"
appName="Pulse"
variant={isAuthenticated ? 'static' : 'floating'}
orgs={orgs}
activeOrgId={auth.user?.org_id}
onSwitchOrganization={handleSwitchOrganization}
onCreateOrganization={handleCreateOrganization}
allowPersonalOrganization={false}
showFaq={false}
showSecurity={false}
showPricing={true}
topOffset={!isAuthenticated && showOfflineBar ? '2.5rem' : undefined}
rightSideActions={auth.user ? <NotificationCenter /> : null}
apps={CIPHERA_APPS}
currentAppId="pulse"
onOpenSettings={openSettings}
leftActions={isAuthenticated ? <MobileSidebarToggle /> : undefined}
customNavItems={
<>
{!auth.user && (
<Link
href="/features"
className="px-4 py-2 text-sm font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-800/50 transition-all duration-200"
>
Features
</Link>
)}
</>
}
/>
)
if (isAuthenticated) {
// Dashboard layout: header pinned, sidebar + content fill remaining viewport
return (
<div className="flex flex-col h-screen overflow-hidden">
{auth.user && <OfflineBanner isOnline={isOnline} />}
<div className="shrink-0">{headerElement}</div>
{children}
<SettingsModalWrapper />
</div>
)
}
// Public/marketing layout: floating header, scrollable page, footer
return (
<div className="flex flex-col min-h-screen">
{headerElement}
<main className="flex-1 pb-8 pt-24">
{children}
</main>
<Footer
LinkComponent={Link}
appName="Pulse"
isAuthenticated={false}
/>
<SettingsModalWrapper />
</div>
)
}
export default function LayoutContent({ children }: { children: React.ReactNode }) {
return (
<SettingsModalProvider>
<SidebarProvider>
<LayoutInner>{children}</LayoutInner>
</SidebarProvider>
</SettingsModalProvider>
)
}