Repository navigation
Frontend
The frontend is a Vite + React + TypeScript application. It uses Material UI for components, i18next for translations, and React Router v7 for client-side navigation.
At app startup, bootstrap.tsx renders an empty shell immediately for fast TTI, then main.tsx loads the full app during idle time. App.tsx restores the persisted theme mode from localStorage, creates the theme with createAppTheme, applies CssBaseline, and renders the router.
- Present localized site content and detail pages (productions, series, blogs, media files).
- Provide search, filters, pagination, and list/grid view toggles for collection pages.
- Centralize API access and error handling via shared services.
- Expose a comprehensive design system (
tokens,muiPalette,styles) for consistent UI across light/dark modes. - Handle language-aware routing with slug localization (EN
/archive<-> NL/archief). - Normalize API errors and display them via i18n translation.
- Support deferred loading (bootstrap shell -> idle main load) for performance.
- Vite 8 for dev/build tooling with fast HMR
- React 19 for UI composition
- TypeScript 6 for type safety
-
Material UI v9 (
@mui/material,@mui/icons-material,@mui/system,@mui/x-date-pickers) -
Emotion v11 (
@emotion/react,@emotion/styled) - MUI's CSS-in-JS foundation
- react-router-dom v7 for client-side routing with lazy code-splitting
- i18next v26 + react-i18next v17 for multi-language support (EN, NL)
- axios v1 for API requests with custom interceptors
- dompurify v3 for safe HTML sanitization
- embla-carousel-react v8 + embla-carousel-wheel-gestures v8 for carousels
- react-pdf v10 for PDF viewing (mocked in tests)
- dayjs v1 for date manipulation
- pdfjs-dist v5 for PDF rendering
- react-icons v5 for icon sets
- simple-icons v16 for brand/social icons
Styling is intentionally split into layers:
-
@font-facedeclarations (ABC Monument Grotesk: Regular, Medium, Bold, Light, Heavy) - CSS custom properties for runtime theming
- Global CSS reset rules and base styles
- Utility classes
Source of truth for all reusable values, exported as a single tokens constant. Key categories:
- Colors - accent, series, light/dark mode palettes, neutral scale, overlays, media backgrounds, semantic colors
-
Spacing -
xs(4px) to3xl(64px), with numeric variants - Typography - fontFamily, weights, sizes, lineHeights
- Shadows - subtle through xl, plus navbar and mediaControl
- Transitions - fast (150ms) to verySlow (500ms)
- Breakpoints - xs (0) to xl (1920px)
- Z-index - base (0) to tooltip (1080)
- Component-specific - navbar height, card dimensions, chip padding
Converts tokens into a Material UI Theme supporting light/dark modes via createAppTheme(mode).
- Palette - mode-aware, light mode and darkmode have different palattes.
- Typography - h1–h6, body1, body2, button (mapped from tokens)
- Breakpoints - parsed from tokens
- Shape - borderRadius and spacing
- Shadows - 25 entries
- Component overrides - MuiButton, MuiCard, MuiPaper, MuiChip
-
TypeScript augmentation - via
muiPalette.d.tsextendsPalette+PaletteOptionswithaccent, andButtonPropsColorOverrideswithaccent
Reusable sx recipe factories:
-
createNavbarStyles()- returns a style-map specifically for the navigation bar -
createCommonStyles(theme)- returns a mode-aware style map for common components that are reused in the codebase. -
createHomePageStyles(theme)- returns a mode-aware style map for the landing page
Routes are defined in frontend/src/router.tsx with a language-first strategy:
-
Language-prefixed URLs: all routes use
/:lang/...wherelangisnloren -
Language inference: paths like
/archiveare inferred to specific languages (/archief-> NL,/archive-> EN) - Fallback resolution: if no slug-specific inference, use browser i18n language or default (NL)
-
Compatibility aliases: old routes like
/productions,/productiesredirect to canonical localized equivalents -
Deferred loading:
bootstrap.tsxrenders empty shell immediately;main.tsxloads full app viarequestIdleCallback
-
ScrollToTop: scrolls to top on everypathname/searchchange viauseLayoutEffect -
LanguagePathRedirect: detects language from pathname, redirects to properly localized path -
AliasDetailRedirect: redirects alias slug detail paths to canonical localized routes -
LocalizedLayout: main layout with/:lang/*pattern - renders Navbar, Routes, Footer -
Router: root component withBrowserRouterandNotificationProvider
| Route | Component | Notes |
|---|---|---|
/:lang |
HomePage |
Landing page with hero, stats, cards |
/en/archive, /nl/archief
|
ProductionsPage |
Archive listing with filter panel |
/en/productions, /nl/producties
|
(redirect) | Redirects to archive listing |
/en/productions/:id, /nl/producties/:id
|
ProductionDetailPage |
Production detail page |
/en/series, /nl/reeksen
|
SeriesPage |
Series listing |
/en/series/:id, /nl/reeksen/:id
|
SeriesDetailPage |
Series detail with productions timeline |
/en/blogs, /nl/blogs
|
BlogsPage |
Blog listing |
/en/blogs/:id, /nl/blogs/:id
|
BlogDetailPage |
Blog detail page |
/en/media, /nl/media
|
MediaFilesPage |
Media files listing |
/en/media/:id, /nl/media/:id
|
(redirect) | Redirects to media listing with error alert |
/en/*, /nl/*
|
NotFoundPage |
404 fallback |
/ and unprefixed paths |
(redirect) | Normalized to /:lang/... via LanguagePathRedirect
|
- Lazy-loads
Navbar,Footer, and all route pages with Suspense -
ScrollToTopscrolls to top on route changes - Renders
NotificationProviderfor global floating alerts - Supports redirect aliases for old slug names
-
AliasDetailRedirectredirects untranslated detail URLs
Frontend API logic is grouped by feature under frontend/src/services/:
-
Api.ts- shared Axios instance withbaseURL: '/api/v1',Content-Type: application/jsonheader,X-API-Keyfromprocess.env.PUBLIC_API_KEY; request interceptor addsAccept-Languagefrom i18n; response interceptor rejects all errors throughnormalizeApiError() -
ApiErrorMapper.ts- normalizes HTTP errors to typedApiErrorwith i18n-translated messages -
ApiParams.ts- convertsFilteredListOptionsto backend query params (pageSize->page_size, array values comma-separated, scalar values passed directly) -
ApiTypes.ts-ApiErrorclass (extends Error withstatus: number,message: string),PaginationOptions,CommonListFilters(search, ordering, external_id),FilteredListOptions<TFilters>(extends PaginationOptions with filters)
Common HTTP status codes map to i18n keys:
-
400->apiErrors.status.400 -
401,403,404,429,500-> status-specific keys - Network errors (status 0) ->
apiErrors.network - Unknown codes ->
apiErrors.status.genericWithCode(with{status}placeholder)
Each service folder contains <Domain>Options.ts (filter types) and <Domain>.ts (API functions):
| Service | Functions | Filter type |
|---|---|---|
blogs/ |
getBlog(id), getBlogs(options?)
|
BlogFilters { production?, published?, slug?, title? } |
events/ |
getEvent(id), getEvents(options?)
|
EventFilters { production?, hall?, location?, starts_at_after?, starts_at_before?, ends_at_after?, ends_at_before? } |
genres/ |
getGenre(id), getGenres(options?)
|
GenreFilters { type?, vendor_id?, name? } |
halls/ |
getHall(id), getHalls(options?)
|
HallFilters { space?, location?, seat_selection?, open_seating?, name? } |
languages/ |
getLanguage(code), getLanguages(options?)
|
LanguageFilters { code?, name?, is_active? } |
locations/ |
getLocation(id), getLocations(options?)
|
LocationFilters { city?, country?, postal_code?, is_own_location?, name? } |
media/ |
getMediaGalleries(options?), getMediaGallery(id), getMediaItems(options?), getMediaItem(id)
|
MediaGalleryFilters { name? }, MediaItemFilters { gallery?, type?, file_format?, original_filename? }
|
media_files/ |
getMediaFiles(options?), getMediaFile(id)
|
MediaFileFilters { file_type?, mime_type?, filename?, description? } |
pricing/ |
getPrice(id), getPrices(options?), getPriceRank(id), getPriceRanks(options?)
|
PriceFilters { type?, visibility?, membership?, cineville_box?, description? }, PriceRankFilters { position?, position_gte?, position_lte?, description? }
|
productions/ |
getProduction(id, include?), getProductions(options?), getLandingStats()
|
ProductionFilters { attendance_mode?, performer_type?, uit_database_type?, genre?, tag?, has_media?, title?, artist_name?, first_event_start_after?, first_event_start_before? } |
spaces/ |
getSpace(id), getSpaces(options?)
|
SpaceFilters { location?, name? }. Internal type HallInSpaceResponse maps nested halls to set space: null. |
tags/ |
getTag(id), getTags(options?)
|
TagFilters { type?, source?, is_enabled?, name? } |
const api = axios.create({
baseURL: '/api/v1',
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
},
})
api.interceptors.request.use((config) => {
const lang = (i18n.resolvedLanguage || i18n.language || 'nl').split('-', 1)[0]
config.headers.set('Accept-Language', lang)
return config
})
api.interceptors.response.use(
(response) => response,
(error) => Promise.reject(normalizeApiError(error, axios.isAxiosError))
)All domain model types live in src/types/. Key types:
| Type | File | Description |
|---|---|---|
AppThemeMode |
Theme.ts |
'light' | 'dark' |
ModeToggleProps |
Theme.ts |
{ mode: AppThemeMode; onToggleMode: () => void } |
FloatingAlertSeverity |
FloatingAlertConfig.ts |
'error' | 'warning' | 'info' | 'success' |
FloatingAlertProps |
FloatingAlertConfig.ts |
Alert props (open, onClose, message, title, severity, position, etc.) |
AttendanceMode |
Productions.ts |
'offline' | 'online' |
PerformerType |
Productions.ts |
'group' | 'solo' |
Production |
Productions.ts |
Full production model with events, related, genres, blogs |
Blog |
Blogs.ts |
Full blog model (extends BlogCardData with body and productions) |
Event |
Events.ts |
Event with production, hall, dates, prices |
Tag |
Tags.ts |
Tag with multilingual name/excerpt/description |
Genre |
Genres.ts |
Genre with type, name, vendor_id |
MediaFile |
MediaFiles.ts |
File with type ('image'|'pdf'|'other'), mime_type, size |
GenreAndTagChipContext |
GenreAndTagChip.ts |
'search' | 'description' | 'series' | 'static' |
GenreAndTagChipType |
GenreAndTagChip.ts |
'genre' | 'seriesTag' |
SearchSortTarget |
search/types.ts |
'name' | 'date' |
SearchSortDirection |
search/types.ts |
'asc' | 'desc' |
SearchViewMode |
search/types.ts |
'list' | 'grid' |
- Node.js LTS (v20+ recommended)
- npm
cd frontend
npm ciUse npm ci for exact version reproducibility (recommended for CI).
npm run devStarts Vite dev server at http://localhost:5173 with:
- Fast HMR (hot module reloading)
- Dev proxy for
/api,/admin,/static,/media->http://localhost:8000
npm run buildType-checks (tsc) and builds optimized production assets into frontend/dist/.
npm run previewPreviews the production build locally.
npm test # run once
npm run test:watch # watch modenpm run lint # check all (ESLint, zero warnings)
npm run lint:fix # auto-fix
npm run format:check # Prettier check
npm run format:fix # Prettier auto-formatFrontend tests use Jest 30 + jsdom + React Testing Library.
-
jest.config.cjs- ts-jest preset, jsdom environment, setup filesetupTests.ts, mapsreact-pdfto mock -
tsconfig.jest.json- TS config for tests (CommonJS module, Node resolution, includes test files) -
src/setupTests.ts- Registers testing-library matchers, polyfills (TextEncoder, TextDecoder, ResizeObserver, IntersectionObserver, matchMedia), mocks axios, setsprocess.env.PUBLIC_API_KEY = 'test-api-key', suppresses XMLHttpRequest console.error
The test directory mirrors the source structure. See Frontend folder Structure.
- Use React Testing Library for component tests
- Mock external modules in
__mocks__/ - Wrap with
MemoryRouter,ThemeProvider, and i18n when needed -
setupTests.tsprovides global mocks for axios, IntersectionObserver, ResizeObserver, matchMedia
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { ThemeProvider } from '@mui/material/styles'
import { createAppTheme } from '../theme/muiPalette'
test('renders correctly', () => {
render(
<MemoryRouter>
<ThemeProvider theme={createAppTheme('light')}>
<MyComponent />
</ThemeProvider>
</MemoryRouter>
)
expect(screen.getByText(/expected text/i)).toBeInTheDocument()
})Name: viernulvier-frontend, private, ES module. Key scripts: dev, build (tsc + vite), preview, lint (ESLint, zero warnings), lint:fix, format:check, format:fix, test, test:watch.
Configures:
- React plugin for JSX/TSX
- Environment variable loading from
../infrastructure(envDir,envPrefix: 'PUBLIC_') - Defines
process.env.PUBLIC_API_KEY - Dev server proxy (
/api,/admin,/static,/media->http://localhost:8000)
-
tsconfig.json- App code (ES2020 target, ESNext module, Bundler resolution,react-jsx, strict mode) -
tsconfig.node.json- Tooling only (Vite config): composite, ESNext, Bundler resolution -
tsconfig.jest.json- Tests (CommonJS module, Node resolution, includes test files)
Flat config using:
-
@typescript-eslint/parserand plugin -
eslint-plugin-react,eslint-plugin-react-hooks,eslint-plugin-react-refresh -
eslint-plugin-importfor import ordering -
eslint-config-prettierandeslint-plugin-prettier - Enforces: consistent type imports (
verbatimModuleSyntax), import ordering, MUI sx over inline style, strict React rules - Zero warnings policy (
--max-warnings 0)
- ts-jest preset, jsdom environment
- Setup file:
setupTests.ts - Maps
react-pdfto mock - Coverage collection from
src/
{
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "always",
"endOfLine": "lf"
}Entry point loads /src/bootstrap.tsx as module script. Contains meta description and title "Archive".
Lighthouse CI config: static dist dir, 3 runs, assertions for performance (warn >= 1), accessibility (error >= 1), best-practices (error >= 1), SEO (warn >= 0.5).
Text sections (descriptions, excerpts, teaser text) support safe HTML rendering via HtmlText component.
-
HtmlTextcomponent wrapssanitizeHtmlutility fromSanitizeHtml.ts - All HTML is sanitized with DOMPurify (prevents XSS)
- Default rules:
USE_PROFILES.html,FORBID_TAGS [br, script],ADD_TAGS [img],ADD_ATTR [alt, height, src, style, title, width],FORBID_ATTR [onblur, onclick, onerror, onfocus, onkeydown, onkeypress, onkeyup, onmouseleave, onmouseenter, onmouseover, onload] - Additional rules available:
forbidImagesRule(removes images),sanitizeImagesStrictRule(removes style/width/height/align attributes),forbidEmbedsRule(removes iframes) -
htmlToPlainText(): decodes HTML entities, replaces block tags with spaces, extracts textContent - Empty content shows optional fallback message
| Component | Field | Notes |
|---|---|---|
BlogGridCard |
excerpt |
Blog excerpt in grid |
BlogListCard |
excerpt |
Blog excerpt in list |
SeriesGridCard |
description |
Series description in grid |
SeriesListCard |
description |
Series description in list |
SeriesHeader |
description |
Series description on detail page |
Description (production) |
teaser, description
|
Production teaser and description |
import HtmlText from '../components/HtmlText'
export const MyComponent = ({ content }) => {
return <HtmlText html={content} fallback="No description available" />
}npm run dev -- --port 3000Check src/setupTests.ts for polyfills. If adding new browser APIs to components, add corresponding mocks for: TextEncoder, TextDecoder, ResizeObserver, IntersectionObserver, matchMedia, scrollTo.
npm run lint # check (zero warnings policy)
npm run lint:fix # auto-fixVerify plugin compatibility in eslint.config.cjs.
npm run buildCheck tsconfig*.json consistency across configs.
Add to both:
src/locales/en/translation.jsonsrc/locales/nl/translation.json
Keys are hierarchical (nested objects). Key sections: nav, landing, searchbar, apiErrors, genreChip, events, archive.home, productions, blogs.home, blog, notFound, footer, series, media.
Verify vite.config.ts server proxy config. Default routes:
-
/api->http://localhost:8000/api -
/admin->http://localhost:8000/admin -
/static->http://localhost:8000/static -
/media->http://localhost:8000/media
Ensure backend dev server runs on :8000.