602 lines
19 KiB
Markdown
602 lines
19 KiB
Markdown
# 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
if (minimal) {
|
|
return <>{children}</>;
|
|
}
|
|
```
|
|
|
|
#### Loading State
|
|
|
|
Shows loading spinner while auth is being checked:
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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)
|
|
|
|
```typescript
|
|
// 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:
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// Every page should define its layout
|
|
Page.getLayout = function getLayout(page: ReactElement) {
|
|
return <LayoutAuthenticated permission='...'>{page}</LayoutAuthenticated>;
|
|
};
|
|
```
|
|
|
|
### 2. Use Specific Permissions
|
|
|
|
```typescript
|
|
// Good - specific permission
|
|
<LayoutAuthenticated permission='UPDATE_USERS'>
|
|
|
|
// Avoid - too broad
|
|
<LayoutAuthenticated> // No permission check
|
|
```
|
|
|
|
### 3. Use Minimal Mode for Presentations
|
|
|
|
```typescript
|
|
// Presentations need auth but no UI chrome
|
|
<LayoutAuthenticated permission='READ_TOUR_PAGES' minimal>
|
|
```
|
|
|
|
### 4. Guest Layout for Public Pages
|
|
|
|
```typescript
|
|
// 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](./pages-module.md) - Page structure and getLayout usage
|
|
- [helpers-module.md](./helpers-module.md) - hasPermission function
|
|
- [stores-module.md](./stores-module.md) - authSlice and styleSlice
|
|
- [types-module.md](./types-module.md) - Permission types
|