1154 lines
33 KiB
Markdown
1154 lines
33 KiB
Markdown
# 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 |
|