39948-vm/frontend/docs/pages-module.md
2026-07-03 16:11:24 +02:00

1154 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Frontend Pages Module
## Overview
The Pages module implements all Next.js pages using the **Pages Router** pattern. It contains 99 TypeScript files providing entry points for routing, authentication flows, entity CRUD operations, and specialized features like the visual tour builder and runtime presentations.
**Location:** `frontend/src/pages/`
**Total Files:** 100 TypeScript/TSX files
**Router:** Next.js Pages Router (not App Router)
**Rendering:** Client-Side Rendering (CSR) - appropriate for admin/builder applications
---
## Architecture Diagram
```
pages/
├── _app.tsx # App entry point (324 LOC)
│ ├── Redux Provider
│ ├── DownloadContext Provider
│ ├── Axios interceptors
│ ├── PWA service worker
│ ├── ErrorBoundary
│ └── IntroGuide tour system
├── index.tsx # Root redirect → /
├── login.tsx # Auth → /login
├── forgot.tsx # Auth → /forgot
├── verify-email.tsx # Auth → /verify-email
├── password-reset.tsx # Auth → /password-reset
├── dashboard.tsx # Admin home → /dashboard
├── profile.tsx # User profile → /profile
├── search.tsx # Global search → /search
├── error.tsx # Error page → /error
├── constructor.tsx # Visual builder → /constructor (1515 LOC)
├── element-type-defaults.tsx # Global element defaults → /element-type-defaults
├── global-transition-defaults.tsx # Global transitions → /global-transition-defaults
├── global-ui-control-defaults.tsx # Global UI controls → /global-ui-control-defaults
├── global-ui-control-defaults/[controlType].tsx # Single global UI control
├── project-element-defaults.tsx# Project element defaults → /project-element-defaults
├── project-ui-control-settings.tsx # Project UI controls → /project-ui-control-settings
├── privacy-policy.tsx # Legal → /privacy-policy
├── terms-of-use.tsx # Legal → /terms-of-use
├── p/[projectSlug]/ # Public presentations
│ ├── index.tsx # Production → /p/[slug]
│ └── stage.tsx # Stage preview → /p/[slug]/stage
├── api/ # API routes
│ ├── logError.ts # Error logging endpoint
│ └── hello.js # Health check
└── [entity]/ # 13 entity CRUD directories
├── [entity]-list.tsx # List with filters
├── [entity]-new.tsx # Create form
├── [entity]-edit.tsx # Edit form
├── [entity]-view.tsx # Read-only view
├── [entity]-table.tsx # Embedded table
└── [entityId].tsx # Dynamic route
```
---
## Page Categories
### 1. App Entry (`_app.tsx`)
**Location:** `pages/_app.tsx`
**Size:** 324 LOC
The application entry point that wraps all pages with providers and global functionality.
**Responsibilities:**
| Feature | Description |
|---------|-------------|
| Redux Provider | Wraps app with Redux store |
| DownloadContext | PWA download progress tracking |
| Axios Setup | Base URL, JWT interceptors, 401 handling |
| PWA Registration | Service worker registration (production only) |
| ErrorBoundary | Global error catching |
| IntroGuide | Optional tour guide system |
| Head Meta | SEO, OG tags, PWA manifest |
| DevModeBadge | Development mode indicator |
**Key Code Patterns:**
```typescript
// Per-page layout pattern
export type NextPageWithLayout<P = Record<string, unknown>, IP = P> = NextPage<P, IP> & {
getLayout?: (page: ReactElement) => ReactNode;
};
function MyApp({ Component, pageProps }: AppPropsWithLayout) {
const getLayout = Component.getLayout || ((page) => page);
return (
<Provider store={store}>
<DownloadProvider>
{getLayout(
<>
<Head>...</Head>
<ErrorBoundary>
<Component {...pageProps} />
</ErrorBoundary>
<IntroGuide ... />
{isDev && <DevModeBadge />}
</>
)}
</DownloadProvider>
</Provider>
);
}
```
**Axios Interceptors:**
```typescript
// Request interceptor - attach JWT token
axios.interceptors.request.use((config) => {
const token = sessionStorage.getItem('token') || localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response interceptor - handle 401, presigned URL failures
axios.interceptors.response.use(
(response) => response,
(error) => {
// Handle presigned S3 URL failures (CORS issues)
if (isPresignedS3Url(requestUrl) && isNetworkError) {
disablePresignedUrls();
}
// Handle 401 - clear tokens, redirect to login
if (status === 401 && !isLoginRequest) {
sessionStorage.removeItem('token');
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
```
---
### 2. Authentication Pages
| Page | Route | Layout | Description |
|------|-------|--------|-------------|
| `login.tsx` | `/login` | Guest | Email/password login with OAuth hints |
| `forgot.tsx` | `/forgot` | Guest | Password reset request |
| `verify-email.tsx` | `/verify-email` | Guest | Email verification callback |
| `password-reset.tsx` | `/password-reset` | Guest | Password reset with token |
**Login Page Pattern:**
```typescript
export default function Login() {
const dispatch = useAppDispatch();
const { currentUser, isFetching, errorMessage, token } = useAppSelector(
(state) => state.auth
);
// Fetch user data when token exists
useEffect(() => {
if (token) dispatch(findMe());
}, [token]);
// Redirect to dashboard if logged in
useEffect(() => {
if (currentUser?.id) router.push('/dashboard');
}, [currentUser?.id]);
const handleSubmit = async (values) => {
await dispatch(loginUser(values));
};
return (
<SectionFullScreen bg='violet'>
<CardBox>
<Formik initialValues={...} onSubmit={handleSubmit}>
<Form>
<FormField label='Login'>
<Field name='email' />
</FormField>
<FormField label='Password'>
<Field name='password' type='password' />
</FormField>
<BaseButton type='submit' label={isFetching ? 'Loading...' : 'Login'} />
</Form>
</Formik>
</CardBox>
</SectionFullScreen>
);
}
Login.getLayout = (page: ReactElement) => <LayoutGuest>{page}</LayoutGuest>;
```
---
### 3. Dashboard Page
**Location:** `pages/dashboard.tsx`
**Size:** 203 LOC
**Route:** `/dashboard`
Admin home page displaying entity counts and AI-generated widgets.
**Features:**
- Entity count cards with permission-based visibility
- Links to entity list pages
- Redirects viewer-only users with private production grants to the first
allowed private presentation
**Key Implementation:**
```typescript
const Dashboard = () => {
const { currentUser } = useAppSelector((state) => state.auth);
const { getCount, getVisibleEntities } = useDashboardCounts(currentUser);
const visibleEntities = getVisibleEntities();
return (
<SectionMain>
<div className='grid grid-cols-1 lg:grid-cols-3 gap-6'>
{visibleEntities.map((entity) => (
<DashboardCard
key={entity.key}
href={entity.href}
label={entity.label}
count={getCount(entity.key)}
iconKey={entity.icon}
/>
))}
</div>
</SectionMain>
);
};
Dashboard.getLayout = (page) => <LayoutAuthenticated>{page}</LayoutAuthenticated>;
```
---
### 4. Entity CRUD Pages (78 pages)
Each of the 13 entities has 6 standardized pages following consistent patterns.
**Entities:**
- users, roles, permissions
- projects, project_memberships
- assets, asset_variants, presigned_url_requests
- tour_pages, project_audio_tracks
- publish_events, pwa_caches, access_logs
#### 4.1 List Page (`[entity]-list.tsx`)
**Pattern:** Uses `createListPage` factory for most entities.
**Factory Usage:**
```typescript
// pages/users/users-list.tsx
import { createListPage } from '../../factories/createListPage';
import TableUsers from '../../components/Users/TableUsers';
import { uploadCsv, setRefetch } from '../../stores/users/usersSlice';
const filters = [
{ label: 'First Name', title: 'firstName' },
{ label: 'Last Name', title: 'lastName' },
{ label: 'E-Mail', title: 'email' },
];
export default createListPage({
entityName: 'users',
entityTitle: 'Users',
TableComponent: TableUsers,
filters,
readPermission: 'READ_USERS',
createPermission: 'CREATE_USERS',
uploadCsvAction: uploadCsv,
setRefetchAction: setRefetch,
newItemLabel: 'Add/Invite User',
});
```
**Generated Features:**
- Title and breadcrumbs
- "New Item" button (permission-checked)
- Filter button (adds filter rows)
- Download CSV button
- Upload CSV button with modal
- Entity-specific table component
**Custom List Page Example (assets-list.tsx):**
```typescript
const AssetsTablesPage = () => {
const { selectedProjectId } = useProjectSelector({ currentUser });
const { uploadingSections, runBatchUpload } = useAssetUploader({
selectedProjectId,
onUploadComplete: () => loadAssets(selectedProjectId),
});
return (
<SectionMain>
<div className='grid grid-cols-1 xl:grid-cols-2 gap-4'>
{ASSET_SECTIONS.map((section) => (
<AssetSectionCard
key={section.key}
section={section}
assets={assetsBySection[section.key]}
onUpload={(files) => runBatchUpload(section, files)}
onDeleteAsset={handleDeleteAsset}
/>
))}
</div>
</SectionMain>
);
};
AssetsTablesPage.getLayout = (page) => (
<LayoutAuthenticated permission='READ_ASSETS'>{page}</LayoutAuthenticated>
);
```
#### 4.2 New Page (`[entity]-new.tsx`)
**Pattern:** Formik form with initial empty values.
```typescript
// pages/projects/projects-new.tsx
const initialValues = {
name: '',
slug: '',
description: '',
// ... all entity fields
};
const ProjectsNew = () => {
const dispatch = useAppDispatch();
const router = useRouter();
const handleSubmit = async (data) => {
await dispatch(create(data));
await router.push('/projects/projects-list');
};
return (
<SectionMain>
<CardBox>
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
<Form>
<FormField label='Name'>
<Field name='name' placeholder='Name' />
</FormField>
{/* More fields */}
<BaseButtons>
<BaseButton type='submit' label='Submit' />
<BaseButton type='reset' label='Reset' />
<BaseButton label='Cancel' onClick={() => router.push('...-list')} />
</BaseButtons>
</Form>
</Formik>
</CardBox>
</SectionMain>
);
};
ProjectsNew.getLayout = (page) => (
<LayoutAuthenticated permission='CREATE_PROJECTS'>{page}</LayoutAuthenticated>
);
```
#### 4.3 Edit Page (`[entity]-edit.tsx`)
**Pattern:** Uses `useEditPageSync` hook for data loading and form sync.
```typescript
// pages/assets/assets-edit.tsx
const EditAssetsPage = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const { values: initialValues, setValues, id } = useEditPageSync({
entitySelector: (state) => state.assets.assets,
fetchAction: fetch,
initialValues: initVals,
});
const handleSubmit = async (data) => {
if (id) {
await dispatch(update({ id, data }));
await router.push('/assets/assets-list');
}
};
return (
<Formik enableReinitialize initialValues={initialValues} onSubmit={handleSubmit}>
<Form>
{/* Form fields */}
</Form>
</Formik>
);
};
EditAssetsPage.getLayout = (page) => (
<LayoutAuthenticated permission='UPDATE_ASSETS'>{page}</LayoutAuthenticated>
);
```
#### 4.4 View Page (`[entity]-view.tsx`)
**Pattern:** Read-only display with related entity tables.
```typescript
// pages/projects/projects-view.tsx
const ProjectsView = () => {
const dispatch = useAppDispatch();
const project = useAppSelector((state) => state.projects.projects);
const { id } = router.query;
useEffect(() => {
dispatch(fetch({ id }));
}, [id]);
return (
<SectionMain>
<CardBox>
{/* Main entity fields */}
<div className='mb-4'>
<p className='block font-bold mb-2'>Name</p>
<p>{project?.name}</p>
</div>
{/* Related entities tables */}
<p className='block font-bold mb-2'>Assets</p>
<CardBox hasTable>
<table>
<thead>...</thead>
<tbody>
{project?.assets_project?.map((item) => (
<tr key={item.id} onClick={() => router.push(`/assets/assets-view/?id=${item.id}`)}>
<td>{item.name}</td>
{/* More columns */}
</tr>
))}
</tbody>
</table>
</CardBox>
</CardBox>
</SectionMain>
);
};
ProjectsView.getLayout = (page) => (
<LayoutAuthenticated permission='READ_PROJECTS'>{page}</LayoutAuthenticated>
);
```
#### 4.5 Table Page (`[entity]-table.tsx`)
**Pattern:** Embedded table for use within other pages.
```typescript
// Minimal wrapper around the table component
// Usually just renders the table with pagination
```
#### 4.6 Dynamic Route (`[entityId].tsx`)
**Pattern:** Project workspace or redirect to view page.
**Project Workspace Example:**
```typescript
// pages/projects/[projectsId].tsx
const ProjectWorkspacePage = () => {
const { projectsId } = router.query;
const [project, setProject] = useState(null);
// Load project data
useEffect(() => {
if (!projectId) return;
axios.get(`/projects/${projectId}`).then((res) => setProject(res.data));
}, [projectId]);
return (
<SectionMain>
{/* Project info card */}
<CardBox>
<h2>{project?.name}</h2>
<p>Slug: {project?.slug}</p>
</CardBox>
{/* Action cards grid */}
<div className='grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4'>
<CardBox>
<h3>Constructor</h3>
<BaseButton href={`/constructor?projectId=${projectId}`} label='Open Constructor' />
</CardBox>
<CardBox>
<h3>Assets</h3>
<BaseButton href={`/assets/assets-list?projectId=${projectId}`} label='Open Assets' />
</CardBox>
{/* More action cards */}
</div>
{/* Publish section */}
<CardBox>
<h3>Publish</h3>
<BaseButtons>
<BaseButton label='Publish to Production' onClick={() => setIsPublishModalActive(true)} />
<BaseButton label='To Production Presentation' onClick={() => window.open(presentationLinks.production)} />
<BaseButton label='To Stage Presentation' onClick={() => window.open(presentationLinks.stage)} />
</BaseButtons>
</CardBox>
</SectionMain>
);
};
```
---
### 5. Constructor Page
**Location:** `pages/constructor.tsx`
**Size:** 1515 LOC (largest page)
**Route:** `/constructor?projectId=X`
Visual tour builder with canvas-based element editing.
**Features:**
- Canvas with background image/video/audio
- Draggable positioned elements
- Element editor panel with General/CSS/Effects tabs
- Main constructor toolbar with Edit/Interact mode, Page actions, Elements
actions, Save, Stage, Exit, and Collapse sections
- Save and Stage toolbar buttons keep a fixed compact width and reserve the
timestamp row to avoid layout shifts between one-line and two-line states
- Page selector, reorder, create, duplicate, and delete actions
- Element copy/paste across pages in the same presentation
- Transition preview
- Asset preloading with blob URL caching
- Gallery carousel overlay with draggable buttons
**Key Hooks Used:**
| Hook | Purpose |
|------|---------|
| `useConstructorElements` | Element CRUD, selection, nested item helpers, and constructor-local element clipboard |
| `useConstructorPageActions` | Page save/create/duplicate and Save to Stage operations |
| `useCanvasElementDrag` | Element positioning with percentage coordinates |
| `useTransitionPreview` | Transition video preview state |
| `usePreloadOrchestrator` | Asset preloading with S3 presigned URLs |
| `usePageSwitch` | Page navigation with background transitions |
| `useTransitionPlayback` | Transition video playback coordination |
| `useBackgroundTransition` | Background overlay during page switch |
| `useIconPreload` | Icon asset preloading |
| `useMediaDurationProbe` | Media duration extraction with caching |
| `useDraggable` | Draggable panel positioning |
| `useOutsideClick` | Click-outside detection for deselection |
**Component Structure:**
```typescript
const ConstructorPage = () => {
const router = useRouter();
const canvasRef = useRef<HTMLDivElement>(null);
const projectId = router.query.projectId;
const pageIdFromRoute = router.query.pageId;
// Element management
const {
elements,
getElements,
selectedElementId,
selectedElement,
addElement,
updateElement,
removeSelectedElement,
selectElement,
copySelectedElement,
pasteCopiedElement,
canPasteElement,
} = useConstructorElements({
initialElements,
elementDefaultsByType,
allowedNavigationTypes,
});
// Page persistence and page creation/duplication
const {
saveConstructor,
saveToStage,
createPage,
duplicatePage,
} = useConstructorPageActions({
activePageId,
elements,
getElements,
pageBackground,
onReload: handleReload,
});
return (
<div className='flex h-screen'>
<ConstructorToolbar
pages={pages}
activePageId={activePageId}
onPageChange={setActivePageId}
onAddElement={addElement}
onCopyElement={copySelectedElement}
onPasteElement={pasteCopiedElement}
canCopyElement={Boolean(selectedElement)}
canPasteElement={canPasteElement}
onSave={saveConstructor}
onSaveToStage={saveToStage}
/>
{/* Center: Canvas */}
<div ref={canvasRef} className='flex-1 relative'>
<CanvasBackground page={currentPage} />
{elements.map((element) => (
<CanvasElementComponent
key={element.id}
element={element}
isSelected={element.id === selectedElementId}
onSelect={() => selectElement(element.id)}
onUpdate={updateElement}
/>
))}
</div>
{/* Right: Element Editor */}
{selectedElementId && (
<ElementEditorPanel
element={selectedElement}
onUpdate={updateElement}
onDelete={removeSelectedElement}
/>
)}
{/* Overlays */}
<TransitionPreviewOverlay />
</div>
);
};
ConstructorPage.getLayout = (page) => (
<LayoutAuthenticated permission='UPDATE_TOUR_PAGES'>{page}</LayoutAuthenticated>
);
```
---
### 6. Runtime Presentation Pages
Public presentation pages for viewing published tours.
#### Production Presentation
**Location:** `pages/p/[projectSlug]/index.tsx`
**Route:** `/p/[slug]` (e.g., `/p/cardiff`)
```typescript
export default function ProductionPresentation() {
const router = useRouter();
const { projectSlug } = router.query;
if (!projectSlug || typeof projectSlug !== 'string') {
return <div>Loading...</div>;
}
return (
<RuntimePresentation projectSlug={projectSlug} environment='production' />
);
}
ProductionPresentation.getLayout = (page) => <LayoutGuest>{page}</LayoutGuest>;
```
#### Stage Presentation
**Location:** `pages/p/[projectSlug]/stage.tsx`
**Route:** `/p/[slug]/stage`
```typescript
export default function StagePresentation() {
const router = useRouter();
const { projectSlug } = router.query;
return (
<RuntimePresentation projectSlug={projectSlug} environment='stage' />
);
}
StagePresentation.getLayout = (page) => <LayoutGuest>{page}</LayoutGuest>;
```
**Environment Distinction:**
- `production` - Public, no auth required
- `stage` - Preview, may require auth
---
### 7. Element Defaults Pages
#### Global Element Type Defaults
**Location:** `pages/element-type-defaults.tsx`
**Route:** `/element-type-defaults`
Lists all 11 global element type defaults.
```typescript
const ElementTypeDefaultsPage = () => {
const [rows, setRows] = useState([]);
useEffect(() => {
axios.get('/element-type-defaults?limit=1000').then((res) => {
setRows(res.data.rows);
});
}, []);
return (
<SectionMain>
<CardBox>
{rows.map((item) => (
<Link key={item.id} href={`/element-type-defaults/${item.id}`}>
<p>{item.name || toHumanLabel(item.element_type)}</p>
<p>{item.element_type} Order {item.sort_order}</p>
</Link>
))}
</CardBox>
</SectionMain>
);
};
ElementTypeDefaultsPage.getLayout = (page) => (
<LayoutAuthenticated permission='READ_PAGE_ELEMENTS'>{page}</LayoutAuthenticated>
);
```
The page also links to `/global-transition-defaults` for transition defaults
and lists global UI controls as separate items linking to per-control editors.
#### Global Transition Defaults
**Location:** `pages/global-transition-defaults.tsx`
**Route:** `/global-transition-defaults`
Dedicated editor for platform-wide page transition defaults.
#### Global UI Control Defaults
**Location:** `pages/global-ui-control-defaults.tsx`
**Route:** `/global-ui-control-defaults`
List page for platform-wide runtime controls. Each item opens a dedicated
editor at `/global-ui-control-defaults/offline`,
`/global-ui-control-defaults/fullscreen`, or
`/global-ui-control-defaults/sound`. The editor uses `UiControlsSettingsForm`
with General Settings, CSS Styles, and Effects tabs.
#### Project Element Defaults
**Location:** `pages/project-element-defaults.tsx`
**Route:** `/project-element-defaults?projectId=X`
Project-specific element defaults that override global defaults.
#### Project UI Control Settings
**Location:** `pages/project-ui-control-settings.tsx`
**Route:** `/project-ui-control-settings?projectId=X&environment=dev`
Dedicated editor for per-project fullscreen, sound, and offline control
overrides. The form resolves global defaults as fallback, saves project
environment overrides, and can remove the project override to return to global
defaults.
---
### 8. Search Page
**Location:** `pages/search.tsx`
**Route:** `/search?query=X`
Global search results page.
```typescript
const SearchView = () => {
const searchQuery = router.query.query;
const [searchResults, setSearchResults] = useState([]);
useEffect(() => {
axios.post('/search', { searchQuery }).then((res) => {
setSearchResults(res.data);
});
}, [searchQuery]);
// Group results by table
const groupedResults = searchResults.reduce((acc, item) => {
acc[item.tableName] = acc[item.tableName] || [];
acc[item.tableName].push(item);
return acc;
}, {});
return (
<SectionMain>
<CardBox>
<SearchResults searchResults={groupedResults} searchQuery={searchQuery} />
</CardBox>
</SectionMain>
);
};
SearchView.getLayout = (page) => (
<LayoutAuthenticated permission='CREATE_SEARCH'>{page}</LayoutAuthenticated>
);
```
---
### 9. API Routes
Next.js API routes for server-side functionality.
#### Error Logging (`api/logError.ts`)
Stores runtime errors to JSON file for debugging.
```typescript
export default async function handler(req, res) {
const dataFilePath = path.join(process.cwd(), 'json/runtimeError.json');
if (req.method === 'GET') {
const jsonData = await fsPromises.readFile(dataFilePath, 'utf-8');
return res.status(200).json(JSON.parse(jsonData));
}
if (req.method === 'POST') {
await fsPromises.writeFile(dataFilePath, JSON.stringify(req.body));
return res.status(200).json({ message: 'Data stored successfully' });
}
if (req.method === 'DELETE') {
await fsPromises.writeFile(dataFilePath, '{}');
return res.status(200).json({ message: 'Data deleted successfully' });
}
}
```
---
### 10. Static Pages
| Page | Route | Description |
|------|-------|-------------|
| `index.tsx` | `/` | Redirects guests to `/login` and authenticated users to `/projects/projects-list` |
| `error.tsx` | `/error` | Error display page |
| `privacy-policy.tsx` | `/privacy-policy` | Privacy policy text |
| `terms-of-use.tsx` | `/terms-of-use` | Terms of use text |
| `profile.tsx` | `/profile` | User profile edit |
---
## Layout System
### getLayout Pattern
Pages define their layout via a static `getLayout` property:
```typescript
// Authenticated page
MyPage.getLayout = (page: ReactElement) => (
<LayoutAuthenticated permission='READ_USERS'>
{page}
</LayoutAuthenticated>
);
// Guest page (public)
LoginPage.getLayout = (page: ReactElement) => (
<LayoutGuest>{page}</LayoutGuest>
);
// No layout (full-screen)
RuntimePage.getLayout = (page: ReactElement) => page;
```
### Layout Components
| Layout | Purpose | Features |
|--------|---------|----------|
| `LayoutAuthenticated` | Protected pages | JWT validation, permission check, NavBar, Sidebar, Footer |
| `LayoutGuest` | Public pages | Minimal layout, no auth required |
**LayoutAuthenticated Features:**
- JWT token validation (client-side decode)
- Permission checking per route
- Project existence guard
- NavBar with user menu
- AsideMenu sidebar
- FooterBar
- IntroGuide tour integration
---
## Permission System
Pages declare required permissions via layout:
```typescript
// Single permission
<LayoutAuthenticated permission='READ_ASSETS'>{page}</LayoutAuthenticated>
// Multiple permissions (any)
<LayoutAuthenticated permissions={['READ_ASSETS', 'READ_PROJECTS']}>{page}</LayoutAuthenticated>
// No permission required (authenticated only)
<LayoutAuthenticated>{page}</LayoutAuthenticated>
```
**Permission Naming Convention:**
- `READ_[ENTITY]` - View list/details
- `CREATE_[ENTITY]` - Create new records
- `UPDATE_[ENTITY]` - Edit existing records
- `DELETE_[ENTITY]` - Delete records
---
## Factory Pattern
### createListPage Factory
Generates standardized list pages with common functionality.
**Input:**
```typescript
interface ListPageConfig {
entityName: string; // API endpoint name
entityTitle: string; // Page title
TableComponent: Component; // Table component
filters: Filter[]; // Available filters
readPermission: string; // Required permission
createPermission: string; // For "New" button
uploadCsvAction: AsyncThunk; // CSV upload action
setRefetchAction: Action; // Trigger refetch
newItemLabel?: string; // "New Item" button label
cardBoxId?: string; // For tour targeting
}
```
**Generated Output:**
- Page component with title
- "New Item" button (permission-checked)
- Filter button and filter rows
- CSV download/upload buttons
- Table component with pagination
- Layout with permission check
---
## Page File Reference
### By Size (Largest First)
| File | Size | Description |
|------|------|-------------|
| `constructor.tsx` | 1515 LOC | Visual tour builder |
| `projects/projects-view.tsx` | 827 LOC | Project view with all relations |
| `_app.tsx` | 324 LOC | App entry point |
| `privacy-policy.tsx` | 292 LOC | Legal page |
| `login.tsx` | 230 LOC | Authentication |
| `terms-of-use.tsx` | 205 LOC | Legal page |
| `dashboard.tsx` | 203 LOC | Admin home |
| `project-element-defaults.tsx` | 199 LOC | Project defaults |
| `profile.tsx` | 182 LOC | User profile |
| `projects/[projectsId].tsx` | 354 LOC | Project workspace |
| `project-ui-control-settings.tsx` | 226 LOC | Project UI controls |
| `element-type-defaults.tsx` | 277 LOC | Defaults hub |
| `global-transition-defaults.tsx` | 221 LOC | Global transition defaults |
| `global-ui-control-defaults/[controlType].tsx` | 153 LOC | Single global UI control |
| `global-ui-control-defaults.tsx` | 103 LOC | Global UI controls list |
| `search.tsx` | 97 LOC | Search results |
| `index.tsx` | 25 LOC | Root redirect |
| `forgot.tsx` | 85 LOC | Password reset |
### By Category
| Category | Files | Total LOC |
|----------|-------|-----------|
| Entity CRUD | 78 | ~4000 |
| Authentication | 4 | ~400 |
| Special Pages | 10 | ~3100 |
| Static/Legal | 4 | ~600 |
| API Routes | 2 | ~100 |
| Runtime | 2 | ~60 |
| App Entry | 1 | ~324 |
| Element Defaults | 8 | ~1100 |
---
## Routing Reference
### Static Routes
| Route | Page | Layout | Permission |
|-------|------|--------|------------|
| `/` | index.tsx | Guest | Redirect entry |
| `/login` | login.tsx | Guest | - |
| `/forgot` | forgot.tsx | Guest | - |
| `/verify-email` | verify-email.tsx | Guest | - |
| `/password-reset` | password-reset.tsx | Guest | - |
| `/dashboard` | dashboard.tsx | Authenticated | - |
| `/profile` | profile.tsx | Authenticated | - |
| `/search` | search.tsx | Authenticated | CREATE_SEARCH |
| `/error` | error.tsx | Guest | - |
| `/constructor` | constructor.tsx | Authenticated | UPDATE_TOUR_PAGES |
| `/element-type-defaults` | element-type-defaults.tsx | Authenticated | READ_PAGE_ELEMENTS |
| `/global-transition-defaults` | global-transition-defaults.tsx | Authenticated | UPDATE_PAGE_ELEMENTS |
| `/global-ui-control-defaults` | global-ui-control-defaults.tsx | Authenticated | READ_PAGE_ELEMENTS |
| `/project-element-defaults` | project-element-defaults.tsx | Authenticated | READ_PAGE_ELEMENTS |
| `/project-ui-control-settings` | project-ui-control-settings.tsx | Authenticated | UPDATE_PAGE_ELEMENTS |
| `/privacy-policy` | privacy-policy.tsx | Guest | - |
| `/terms-of-use` | terms-of-use.tsx | Guest | - |
### Dynamic Routes
| Route Pattern | Page | Description |
|---------------|------|-------------|
| `/p/[projectSlug]` | p/[projectSlug]/index.tsx | Production presentation |
| `/p/[projectSlug]/stage` | p/[projectSlug]/stage.tsx | Stage presentation |
| `/global-ui-control-defaults/[controlType]` | global-ui-control-defaults/[controlType].tsx | Single global UI control editor |
| `/[entity]/[entityId]` | [entity]/[entityId].tsx | Entity workspace/redirect |
### Entity Routes (×13)
| Entity | List | New | Edit | View |
|--------|------|-----|------|------|
| users | `/users/users-list` | `/users/users-new` | `/users/users-edit?id=X` | `/users/users-view?id=X` |
| roles | `/roles/roles-list` | ... | ... | ... |
| permissions | `/permissions/permissions-list` | ... | ... | ... |
| projects | `/projects/projects-list` | ... | ... | ... |
| project_memberships | `/project_memberships/...` | ... | ... | ... |
| assets | `/assets/assets-list` | ... | ... | ... |
| asset_variants | `/asset_variants/...` | ... | ... | ... |
| presigned_url_requests | `/presigned_url_requests/...` | ... | ... | ... |
| tour_pages | `/tour_pages/tour_pages-list` | ... | ... | ... |
| project_audio_tracks | `/project_audio_tracks/...` | ... | ... | ... |
| publish_events | `/publish_events/...` | ... | ... | ... |
| pwa_caches | `/pwa_caches/pwa_caches-list` | ... | ... | ... |
| access_logs | `/access_logs/access_logs-list` | ... | ... | ... |
---
## Common Patterns
### 1. Page with Formik Form
```typescript
import { Formik, Form, Field } from 'formik';
const MyPage = () => {
const handleSubmit = async (values) => {
await dispatch(create(values));
router.push('/entity/entity-list');
};
return (
<Formik initialValues={{...}} onSubmit={handleSubmit}>
<Form>
<FormField label='Name'>
<Field name='name' />
</FormField>
<BaseButton type='submit' label='Submit' />
</Form>
</Formik>
);
};
```
### 2. Page with Data Fetching
```typescript
const MyPage = () => {
const dispatch = useAppDispatch();
const data = useAppSelector((state) => state.entity.items);
const { id } = router.query;
useEffect(() => {
dispatch(fetch({ id }));
}, [id]);
return <div>{data?.name}</div>;
};
```
### 3. Page with useEditPageSync
```typescript
const EditPage = () => {
const { values, setValues, id } = useEditPageSync({
entitySelector: (state) => state.entity.items,
fetchAction: fetch,
initialValues: { name: '', ... },
});
return (
<Formik enableReinitialize initialValues={values} ...>
...
</Formik>
);
};
```
### 4. Page with Permission Check
```typescript
MyPage.getLayout = (page) => (
<LayoutAuthenticated permission='READ_ENTITY'>
{page}
</LayoutAuthenticated>
);
```
---
## Related Documentation
| Document | Description |
|----------|-------------|
| [frontend-architecture.md](./frontend-architecture.md) | Overall frontend architecture |
| [hooks-reference.md](./hooks-reference.md) | Custom hooks documentation |
| [runtime-presentation.md](./runtime-presentation.md) | Runtime presentation details |
| [constructor-page-editor.md](./constructor-page-editor.md) | Constructor page details |
| [ui-elements.md](../../documentation/ui-elements.md) | UI element types |