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.
189 lines
6.0 KiB
TypeScript
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>
|
|
)
|
|
}
|