19 KiB
Frontend Layouts Module
Overview
The Layouts module provides page wrapper components that handle authentication, authorization, navigation chrome, and styling for the application. Using Next.js's getLayout pattern, layouts are applied per-page rather than globally.
Location: frontend/src/layouts/
Total Files: 2 files (~262 LOC)
Architecture
frontend/src/layouts/
├── Authenticated.tsx (243 LOC) # Protected routes with full UI chrome
└── Guest.tsx (19 LOC) # Public routes (login, register, etc.)
Related Files:
├── menuAside.ts (42 LOC) # Sidebar menu configuration
├── menuNavBar.ts (51 LOC) # Top navbar menu configuration
└── pages/_app.tsx (324 LOC) # getLayout pattern implementation
Layout Pattern (Next.js getLayout)
Pattern Overview
┌─────────────────────────────────────────────────────────────┐
│ _app.tsx │
│ const getLayout = Component.getLayout || ((page) => page) │
│ return getLayout(<Component {...pageProps} />) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Page Component │
│ Page.getLayout = (page) => <Layout>{page}</Layout> │
└─────────────────────────────────────────────────────────────┘
Type Definition
// In _app.tsx
export type NextPageWithLayout<P = Record<string, unknown>, IP = P> = NextPage<P, IP> & {
getLayout?: (page: ReactElement) => ReactNode;
};
type AppPropsWithLayout = AppProps & {
Component: NextPageWithLayout;
};
Usage in _app.tsx
function MyApp({ Component, pageProps }: AppPropsWithLayout) {
// Use the layout defined at the page level, if available
const getLayout = Component.getLayout || ((page) => page);
return (
<Provider store={store}>
<DownloadProvider>
{getLayout(
<>
<Head>...</Head>
<ErrorBoundary>
<Component {...pageProps} />
</ErrorBoundary>
</>
)}
</DownloadProvider>
</Provider>
);
}
Layout Files
1. LayoutAuthenticated (Authenticated.tsx)
Purpose: Wrapper for protected routes requiring authentication and optional permission checking.
Lines: 243 LOC
Props Interface
type Props = {
children: ReactNode;
permission?: string; // Required permission for this page
minimal?: boolean; // If true, render only auth check (no UI chrome)
};
Features
| Feature | Description |
|---|---|
| JWT Validation | Client-side token decode and expiry check |
| Auto-redirect | Redirects to /login if no valid token |
| Permission Guard | Redirects to /error if permission missing |
| Project Existence Check | Redirects to project creation for certain routes |
| NavBar | Top navigation with search and user menu |
| AsideMenu | Sidebar navigation menu |
| FooterBar | Page footer |
| Dark Mode | Applies dark mode styling |
| Responsive | Mobile-friendly sidebar toggle |
| Minimal Mode | Auth-only mode for presentations |
Authentication Flow
┌─────────────────────────────────────────────────────────────┐
│ LayoutAuthenticated │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────┐
│ Get Token from │
│ Session/Local │
│ Storage │
└────────┬────────┘
│
┌─────────▼─────────┐
│ Token Valid? │
│ (JWT decode + │
│ expiry check) │
└────────┬──────────┘
│
┌───────────────┴───────────────┐
│ No │ Yes
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ dispatch │ │ Set axios │
│ logoutUser() │ │ Authorization │
│ redirect to │ │ header │
│ /login │ └────────┬────────┘
└─────────────────┘ │
┌─────────▼─────────┐
│ currentUser │
│ loaded? │
└────────┬──────────┘
│
┌───────────────┴───────────────┐
│ No │ Yes
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ dispatch │ │ Check │
│ findMe() │ │ permissions │
└─────────────────┘ └────────┬────────┘
│
┌─────────▼─────────┐
│ Permission OK? │
└────────┬──────────┘
│
┌───────────────────┴────────────┐
│ No │ Yes
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ redirect to │ │ Render page │
│ /error │ │ with layout │
└─────────────────┘ └─────────────────┘
Token Validation
const isTokenValid = (tokenToCheck?: string | null) => {
if (!tokenToCheck) return false;
try {
const decoded = jwt.decode(tokenToCheck);
if (!decoded || typeof decoded !== 'object') return false;
if (typeof decoded.exp !== 'number') return true; // No expiry = valid
return Date.now() / 1000 < decoded.exp; // Check expiry
} catch (error) {
logger.error('Failed to decode auth token:', error);
return false;
}
};
Routes Requiring Project
Certain routes redirect to project creation if no projects exist:
const ROUTES_REQUIRING_PROJECT = [
'/access_logs',
'/assets',
'/asset_variants',
'/presigned_url_requests',
'/project_audio_tracks',
'/project_memberships',
'/publish_events',
'/pwa_caches',
'/tour_pages',
];
Constructor Fullscreen Mode
The constructor page (/constructor) hides navigation chrome:
const isConstructorFullscreen = router.pathname === '/constructor';
// When true:
// - NavBar is hidden
// - AsideMenu is hidden
// - FooterBar is hidden
// - No padding applied
Minimal Mode
For presentations that need auth but no UI chrome:
if (minimal) {
return <>{children}</>;
}
Loading State
Shows loading spinner while auth is being checked:
if (!isAuthChecked) {
return (
<div className={`${darkMode ? 'dark' : ''} ...`}>
<div className='animate-spin rounded-full h-8 w-8 ...'></div>
<p>Loading...</p>
</div>
);
}
Layout Structure
┌─────────────────────────────────────────────────────────────┐
│ NavBar │
│ [☰ Mobile] [Desktop Menu] [Search] [User Menu] [Logout] │
└─────────────────────────────────────────────────────────────┘
┌──────────────┬──────────────────────────────────────────────┐
│ │ │
│ AsideMenu │ │
│ │ Page Content │
│ - Dashboard │ (children) │
│ - Projects │ │
│ - Users │ │
│ - Profile │ │
│ - Swagger │ │
│ │ │
└──────────────┴──────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ FooterBar │
└─────────────────────────────────────────────────────────────┘
2. LayoutGuest (Guest.tsx)
Purpose: Simple wrapper for public pages without authentication.
Lines: 19 LOC
Props Interface
type Props = {
children: ReactNode;
};
Features
| Feature | Description |
|---|---|
| Dark Mode | Applies dark mode styling from Redux |
| Background Color | Applies theme background color |
| No Auth | No authentication required |
Implementation
export default function LayoutGuest({ children }: Props) {
const darkMode = useAppSelector((state) => state.style.darkMode);
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
return (
<div className={darkMode ? 'dark' : ''}>
<div className={`${bgColor} dark:bg-slate-800 dark:text-slate-100`}>
{children}
</div>
</div>
);
}
Menu Configuration
Aside Menu (menuAside.ts)
Sidebar navigation items:
const menuAside: MenuAsideItem[] = [
{
href: '/dashboard',
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
},
{
href: '/projects/projects-list',
label: 'Projects',
icon: icon.mdiFolder,
permissions: 'READ_PROJECTS', // Permission-gated
},
{
href: '/element-type-defaults',
label: 'Element Defaults',
icon: icon.mdiPaletteSwatch,
permissions: 'READ_PAGE_ELEMENTS',
},
{
href: '/users/users-list',
label: 'Users',
icon: icon.mdiAccountGroup,
permissions: 'READ_USERS',
},
{
href: '/profile',
label: 'Profile',
icon: icon.mdiAccountCircle,
},
{
href: swaggerDocsUrl,
target: '_blank', // Opens in new tab
label: 'Swagger',
icon: icon.mdiFileCode,
permissions: 'READ_API_DOCS',
},
];
NavBar Menu (menuNavBar.ts)
Top navigation items:
const menuNavBar: MenuNavBarItem[] = [
{
isCurrentUser: true, // User dropdown
menu: [
{ icon: mdiAccount, label: 'My Profile', href: '/profile' },
{ isDivider: true },
{ icon: mdiLogout, label: 'Log Out', isLogout: true },
],
},
{
icon: mdiThemeLightDark,
label: 'Light/Dark',
isDesktopNoLabel: true,
isToggleLightDark: true, // Dark mode toggle
},
{
icon: mdiLogout,
label: 'Log out',
isDesktopNoLabel: true,
isLogout: true,
},
];
// Additional export for web pages (currently empty)
export const webPagesNavBar = [];
Usage Examples
Basic Authenticated Page
// pages/users/users-list.tsx
import LayoutAuthenticated from '../../layouts/Authenticated';
function UsersListPage() {
return (
<SectionMain>
<h1>Users</h1>
{/* page content */}
</SectionMain>
);
}
UsersListPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission='READ_USERS'>
{page}
</LayoutAuthenticated>
);
};
export default UsersListPage;
Minimal Auth for Presentations
// pages/p/[projectSlug]/stage.tsx
import LayoutAuthenticated from '../../../layouts/Authenticated';
function StagePresentation() {
return <RuntimePresentation mode="stage" />;
}
StagePresentation.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission='READ_TOUR_PAGES' minimal>
{page}
</LayoutAuthenticated>
);
};
Public Page with Guest Layout
// pages/login.tsx
import LayoutGuest from '../layouts/Guest';
function Login() {
return (
<SectionMain>
<LoginForm />
</SectionMain>
);
}
Login.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};
export default Login;
Public Page (Production Presentation)
// pages/p/[projectSlug]/index.tsx
import LayoutGuest from '../../../layouts/Guest';
function ProductionPresentation() {
return <RuntimePresentation mode="production" />;
}
ProductionPresentation.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};
Page Layout Mapping
| Route Pattern | Layout | Permission | Mode |
|---|---|---|---|
/login |
Guest | - | Public |
/verify-email |
Guest | - | Public |
/password-reset |
Guest | - | Public |
/terms-of-use |
Guest | - | Public |
/p/[slug] |
Guest | - | Public presentation |
/p/[slug]/stage |
Authenticated | READ_TOUR_PAGES |
Minimal (stage preview) |
/dashboard |
Authenticated | - | Full chrome |
/profile |
Authenticated | - | Full chrome |
/constructor |
Authenticated | - | Fullscreen (no chrome) |
/users/* |
Authenticated | READ/CREATE/UPDATE_USERS |
Full chrome |
/roles/* |
Authenticated | READ/CREATE/UPDATE_ROLES |
Full chrome |
/projects/* |
Authenticated | READ/CREATE/UPDATE_PROJECTS |
Full chrome |
/assets/* |
Authenticated | READ/CREATE/UPDATE_ASSETS |
Full chrome |
/tour_pages/* |
Authenticated | READ/CREATE/UPDATE_TOUR_PAGES |
Full chrome |
/element-type-defaults |
Authenticated | READ_PAGE_ELEMENTS |
Full chrome |
Redux State Dependencies
Both layouts depend on Redux style state:
// In styleSlice.ts
interface StyleState {
darkMode: boolean; // Dark mode toggle
bgLayoutColor: string; // Background color class
// ...
}
// Usage in layouts
const darkMode = useAppSelector((state) => state.style.darkMode);
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
Related Components
NavBar Component
Top navigation bar with:
- Mobile hamburger menu toggle
- Desktop menu button
- Search component
- User menu items
AsideMenu Component
Sidebar navigation with:
- Permission-filtered menu items
- Mobile/desktop responsive
- Collapsible on route change
FooterBar Component
Page footer with copyright/credits.
Search Component
Global search functionality in NavBar.
Best Practices
1. Always Define getLayout
// Every page should define its layout
Page.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated permission='...'>{page}</LayoutAuthenticated>;
};
2. Use Specific Permissions
// Good - specific permission
<LayoutAuthenticated permission='UPDATE_USERS'>
// Avoid - too broad
<LayoutAuthenticated> // No permission check
3. Use Minimal Mode for Presentations
// Presentations need auth but no UI chrome
<LayoutAuthenticated permission='READ_TOUR_PAGES' minimal>
4. Guest Layout for Public Pages
// Public pages should use LayoutGuest
<LayoutGuest>{page}</LayoutGuest>
File Inventory
| File | LOC | Purpose |
|---|---|---|
Authenticated.tsx |
243 | Protected route wrapper with auth, permissions, UI chrome |
Guest.tsx |
19 | Public route wrapper with dark mode support |
menuAside.ts |
42 | Sidebar menu configuration |
menuNavBar.ts |
51 | Top navbar menu configuration |
| Total | 355 |
Related Documentation
- pages-module.md - Page structure and getLayout usage
- helpers-module.md - hasPermission function
- stores-module.md - authSlice and styleSlice
- types-module.md - Permission types