Frontend Projectstructuur - Detailoverzicht
Volledige gids voor de frontend monorepo-structuur, architectuur en ontwikkelingsprincipes.
1. Monorepo Overzicht
Het WildFire Management System frontend bestaat uit een pnpm monorepo met meerdere applicaties en een gedeelde bibliotheek.
wildfire-monorepo/
├── projects/ # Standalone applicaties
│ ├── adminportal/ # Admin dashboard (Vue 3 + Nuxt 3)
│ └── userdashboard/ # Public viewer (Vue 3 + Nuxt 3)
├── shared/ # Gedeelde code (monorepo package)
│ ├── components/ # Vue 3 componenten bibliotheek
│ ├── composables/ # Vue 3 composables/hooks
│ ├── stores/ # Pinia state management
│ ├── types/ # Gedeelde TypeScript types
│ ├── utils/ # Utility functies
│ ├── api/ # API integratie laag
│ ├── assets/ # Shared media/afbeeldingen
│ └── config/ # Configuratie
├── i18n/ # Internationalization
│ └── locales/ # Taalbestanden (nl, fr, de, en)
├── test/ # Test configuratie
├── composables/ # Root-level composables
├── package.json # Monorepo workspace config
├── pnpm-workspace.yaml # pnpm workspace definition
├── pnpm-lock.yaml # Dependency lock file
└── tsconfig.base.json # Base TypeScript config2. Projecten Gedetailleerd
2.1 Admin Portal (projects/adminportal/)
Doel: Management interface voor brandweermedewerkers en administratoren
Key Features:
- Admin dashboard met stats
- Fire CRUD operations
- gpxFile upload bij edit met directe weergave op kaart
- Gebruikersbeheer
Dependencies:
- Vue 3
- Nuxt 4 (meta-framework)
- Pinia (state management)
- TypeScript
- Leaflet
2.2 User Dashboard (projects/userdashboard/)
Doel: Public viewer voor burgers en stakeholders
Key Features:
- Fire map: streetview, satelliet en risico
- Fire details (openbaar)
- Historische data beschikbaar
- Mobile-responsive design
- No authentication required
3. Shared Code (shared/)
Gedeelde Vue 3 componenten bibliotheek, beschikbaar voor beide projecten.
Principe:
- Componenten zijn presentational
- Props voor data
- Emits voor events
- Slot support voor flexibility
3.1 Composables (shared/composables/)
Reusable Vue 3 composables voor gemeenschappelijke logica.
shared/composables/
├── useAuth.ts # Authentication & user management
│
├── useFireForm.ts # Fire form state management
├── useFireFormLogic.ts # Fire form business logic
├── useFireFormOptions.ts # Form options & dropdowns
├── useFireFormState.ts # Form state helpers
├── useFireReportValidation.ts # Validation logic
│
├── useFireStatistics.ts # Statistics calculations
├── useFireStatisticsMember.ts # Member-specific stats
│
├── useLeafletMap.ts # Leaflet map integration
├── useHulpzones.ts # Help zones data
├── useProvince.ts # Province information
│
├── useDashboardStats.ts # Dashboard statistics
├── useLoginForm.ts # Login form logic
├── useAddUser.ts # User creation form
├── useEditProfile.ts # Profile editing
├── useUserList.ts # User list management
│
├── useImport.ts # Data import logic
├── useExport.ts # Data export logic
├── useTalen.ts # Language/translation support
│
└── [other composables] # Additional feature composablesReal Examples from Codebase:
// useAuth.ts - Authentication & user management
import { useUserStore, postLoginUser, postLogout } from '@monorepo/shared'
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { navigateTo, useRoute } from 'nuxt/app'
export const useAuth = () => {
const userStore = useUserStore()
const { user } = storeToRefs(userStore)
const error = ref<string | null>(null)
const fieldErrors = ref<{ email?: string; password?: string }>({})
const loading = ref(false)
const login = async (email: string, wachtwoord: string, client: string) => {
loading.value = true
error.value = null
fieldErrors.value = {}
try {
const response = await postLoginUser(email, wachtwoord, client)
if (response.status === 200 && response.user && response.accessToken) {
userStore.setUser(response.user)
userStore.setAccessToken(response.accessToken)
const route = useRoute()
const redirectTo = route.query.redirectTo as string || '/dashboard'
await navigateTo(redirectTo, { replace: true })
return true
}
if (response.status === 400 && response.errors) {
// Map backend field names to frontend
const errorKeyMap: Record<string, string> = {
wachtwoord: 'password',
email: 'email',
}
for (const key in response.errors) {
const mappedKey = errorKeyMap[key] || key
fieldErrors.value[mappedKey] = response.errors[key]
}
return false
}
error.value = response.error || 'Login failed'
return false
} catch (err: any) {
error.value = err.message || 'Login failed'
return false
} finally {
loading.value = false
}
}
const logout = async () => {
try {
await postLogout()
userStore.clearUser()
} catch (err: any) {
error.value = err.message || 'Logout failed'
}
}
return { user, login, logout, error, fieldErrors, loading }
}
// useFireForm.ts - Fire form options management
import {
infraDamageOptions,
terrainDamageOptions,
causesOptions,
surfacesOptions,
treeTypesOptions,
purposesOptions,
fireTypesOptions,
definitionsOptions,
orientationOptions,
textureOptions,
landcoverOptions,
surfaceGroundCrownOptions,
anbCodeOptions
} from '../config/fireFormOptions'
export const useFireForm = () => {
return {
infraDamage: infraDamageOptions,
terrainDamage: terrainDamageOptions,
causes: causesOptions,
surfaces: surfacesOptions,
treeTypes: treeTypesOptions,
purposes: purposesOptions,
fireTypes: fireTypesOptions,
definitions: definitionsOptions,
orientations: orientationOptions,
textures: textureOptions,
landcovers: landcoverOptions,
surfaceGroundCrown: surfaceGroundCrownOptions,
anbCodes: anbCodeOptions
}
}3.2 State Management (shared/stores/)
Pinia stores voor globale state. Store files volgen use*Store.ts naming.
shared/stores/
├── useUserStore.ts # User/authentication state
│ - user: { id, email, role, name }
│ - isAuthenticated: boolean
│ - setUser(user)
│ - clearUser()
│
├── useFireStore.ts # Fire management state
│ - fires: Fire[]
│ - selectedFire: Fire | null
│ - loading: boolean
│ - fetchFires()
│ - selectFire(fireId)
│ - updateFire(fire)
│
├── useMapStore.ts # Map state
│ - center: { lat, lng }
│ - zoom: number
│ - selectedPerimeter: GeoPolygon | null
│ - setCenter(lat, lng)
│ - setZoom(zoom)
│
├── useGeometryStore.ts # Geometry/perimeter data
│ - geometries: Geometry[]
│ - currentGeometry: Geometry | null
│ - addGeometry(geometry)
│ - removeGeometry(id)
│
└── useWeatherStore.ts # Weather data
- weatherData: WeatherData
- fetchWeather(coords)Real Implementation (Composition API style):
// stores/useFireStore.ts
import { ref } from 'vue'
import type { FireFeatureCollection } from '@monorepo/shared/types'
import { getFires, postFire, updateFire } from '@monorepo/shared'
import { defineStore } from 'pinia'
import { useRuntimeConfig } from 'nuxt/app'
export const useFireStore = defineStore('fireStore', () => {
const fires = ref<FireFeatureCollection | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
const fieldErrors = ref<Record<string, string>>({})
const loadFires = async (filters: any = {}) => {
loading.value = true
error.value = null
const config = useRuntimeConfig()
const client = config.public.clientType as string
try {
const allFires = await getFires(filters, client)
fires.value = allFires
} catch (err: any) {
error.value = err.message || 'Failed to load fires'
} finally {
loading.value = false
}
}
const upload = async (fire: FormData): Promise<boolean> => {
loading.value = true
error.value = null
fieldErrors.value = {}
try {
await postFire(fire)
return true
} catch (err: any) {
if (err?.details) {
fieldErrors.value = err.details
}
error.value = err.message || 'Failed to upload fire'
return false
} finally {
loading.value = false
}
}
return {
fires,
loading,
error,
fieldErrors,
loadFires,
upload
}
})3.3 TypeScript Types (shared/types/)
Centraal beheerde types. In werkelijkheid minimal - types are inline in codebase.
shared/types/
└── index.ts # Main type exports (mostly empty)Types worden gedefinieerd waar gebruikt:
- Components hebben inline prop types
- Stores hebben inline state types
- API calls retourneren untyped
any(was TODO in project)
// Typical type usage in component
interface FireFormProps {
fire?: Fire;
loading?: boolean;
}
interface Fire {
id: string;
name: string;
location: {
lat: number;
lng: number;
};
status: 'active' | 'contained' | 'extinguished';
perimeter?: Array<[number, number]>;
}3.4 API Integratie (shared/api/)
Modulaire API client met endpoints per resource.
shared/api/
├── fires/
│ └── fires.ts # Fire-related API calls
│ - getFires(filters, clientType)
│ - postFire(fireData)
│ - updateFire(fire, geometry)
│ - markFireAsDeleted(fireId)
│
├── objects/
│ └── objects.ts # Objects/perimeters API
│ - getObjects()
│
└── users/
└── users.ts # User management API
- getUsers()
- removeUser(userId)
- updateUserData(userId, data)
- postLoginUser(email, password, client)
- postLogout()Real Implementation Example:
// api/fires/fires.ts
import type { FireFeatureCollection, FireFilters } from '@monorepo/shared/types'
import { apiFetch } from '@monorepo/shared'
import { useRuntimeConfig } from 'nuxt/app'
export const getFires = async (filters: FireFilters = {}, clientType: string): Promise<FireFeatureCollection> => {
try {
const fires = await apiFetch<FireFeatureCollection>(`/fires`, {
method: 'GET',
query: filters,
headers: {
'X-Client-Type': clientType,
},
})
return fires || { type: 'FeatureCollection', features: [] }
} catch (err) {
console.error('Failed to fetch fires:', err)
return { type: 'FeatureCollection', features: [] }
}
}
export const postFire = async (fire: FormData) => {
try {
// Convert FormData to JSON object
const fireData: Record<string, any> = {}
fire.forEach((value, key) => {
if (key === 'geometry' || key === 'users_id') {
if (key === 'geometry') {
try {
fireData[key] = JSON.parse(value as string)
} catch {
fireData[key] = value
}
} else if (key === 'users_id') {
fireData[key] = Number(value)
}
} else {
fireData[key] = value
}
})
return await apiFetch('/fires', {
method: 'POST',
body: fireData,
})
} catch (err: any) {
throw err
}
}3.5 Components (shared/components/)
Atom/Molecule/Organism pattern met herbruikbare UI components.
shared/components/
├── atoms/ # Basic UI elements
│ ├── AppButton.vue # Generic button component
│ ├── InputField.vue # Text input wrapper
│ ├── SelectField.vue # Select dropdown
│ ├── DateField.vue # Date picker input
│ ├── TimePicker.vue # Time selection
│ ├── StatCard.vue # Stat display card
│ └── [other atoms]
│
├── Molecules/ # Component combinations
│ ├── FireEdit.vue # Edit fire functionality
│ ├── UserEdit.vue # Edit user functionality
│ └── [other molecules]
│
└── Organisms/ # Complex components
├── AppHeader.vue # Header
├── AppFooter.vue # Footer
├── AppMap.vue # Leaflet map component
└── [other organisms]Real Component Example:
<!-- components/atoms/InputField.vue -->
<script setup lang="ts">
import { computed, useId } from 'vue'
interface InputFieldProps {
label?: string
placeholder?: string
modelValue?: string
id?: string
name?: string
type?: string
disabled?: boolean
error?: boolean
}
const props = withDefaults(defineProps<InputFieldProps>(), {
type: 'text',
disabled: false,
error: false
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const uid = useId()
const modelValue = computed({
get: () => props.modelValue || '',
set: (value: string) => emit('update:modelValue', value)
})
const inputId = computed(() => props.id || `input-${uid}`)
const inputName = computed(() => props.name || props.label || '')
</script>
<template>
<div :class="['ui-input', { 'ui-input--textarea': type === 'textarea' }]">
<label v-if="label" class="ui-input__label" :for="inputId">{{ label }}</label>
<component
:is="type === 'textarea' ? 'textarea' : 'input'"
class="input-field"
:class="{ 'input-field--error': error, 'textarea-field': type === 'textarea' }"
:type="type === 'textarea' ? undefined : type"
:value="modelValue"
:id="inputId"
:name="inputName"
:placeholder="placeholder"
:aria-label="label || placeholder"
:disabled="disabled"
@input="modelValue = ($event.target as HTMLTextAreaElement | HTMLInputElement).value"
/>
</div>
</template>
<style scoped>
.ui-input {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.ui-input__label {
font-weight: 500;
}
.input-field {
padding: 0.75rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
}
.input-field:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25);
}
.input-field--error {
border-color: #dc3545;
}
.textarea-field {
min-height: 120px;
resize: vertical;
}
</style>3.6 Assets & Config (shared/assets/ & shared/config/)
shared/
├── assets/ # Static assets
└── images # Limited - mostly in projects
│
└── base.css # Global css4. Naming Conventies & Best Practices
4.1 Naming Conventions
| Item | Convention | Voorbeeld |
|---|---|---|
| Componenten | PascalCase | FireForm.vue, UserTable.vue |
| Composables | use + PascalCase | useAuth.ts, useFireForm.ts |
| Stores | use + PascalCase + Store | useFireStore.ts, useUserStore.ts |
| Utility functions | camelCase | formatDate(), calculateDistance() |
| Types/Interfaces | PascalCase | Fire, User, FireFeatureCollection |
| Constants | UPPER_SNAKE_CASE | MAX_RETRIES, API_TIMEOUT |
| Files | PascalCase (components), kebab-case (utils) | FireForm.vue, fire-utils.ts |
4.2 File Structure Best Practices
Component Structure:
<script setup lang="ts">
// Script: Logic, state, methods
// Imports first
import { ref, computed, onMounted } from 'vue'
import { useFireStore } from '@monorepo/shared'
// Props
interface Props {
fireId?: string
}
const props = withDefaults(defineProps<Props>(), {})
// Emits
const emit = defineEmits<{
update: [fireId: string]
}>()
// Reactive state
const loading = ref(false)
// Composables & Stores
const fireStore = useFireStore()
// Computed
const currentFire = computed(() =>
fireStore.fires?.features.find(f => f.properties.id === props.fireId) || null
)
// Lifecycle
onMounted(() => {
fireStore.loadFires()
})
// Methods
const handleUpdate = (fireId: string) => {
emit('update', fireId)
}
</script>
<template>
<!-- Template: HTML structure -->
<div v-if="currentFire" class="fire-detail">
<h2>{{ currentFire.properties.name }}</h2>
<button @click="handleUpdate(currentFire.properties.id)">Update</button>
</div>
</template>
<style scoped>
/* Scoped styles */
</style>5. Development Workflow
5.1 Development Environment Setup
# Install dependencies
pnpm install
# Start development server (admin portal)
cd projects/adminportal
pnpm dev
# Start dev server (user dashboard)
cd projects/userdashboard
pnpm dev5.2 Working with Shared Code
# When updating shared code, both projects recompile automatically
# Because they use pnpm workspaces and have symlinked dependencies
# To manually rebuild shared:
cd shared && pnpm build5.3 Component Development
# Create new component in shared
touch shared/components/atoms/NewField.vue
# Or explicit import with monorepo path after export it in shared/index.ts
import NewField from '@monorepo/shared';
// Type imports
import type { FireFeatureCollection } from '@monorepo/shared/types';6. TypeScript Setup
6.1 Tsconfig Hierarchy
tsconfig.base.json (root)
└── projects/adminportal/tsconfig.json
└── projects/userdashboard/tsconfig.json
└── shared/tsconfig.json6.2 Path Aliases
// tsconfig.base.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@monorepo/shared": ["shared/index.ts"],
"@monorepo/shared/*": ["shared/*"]
}
}
}7. Style Architecture
Scoped CSS
# Components use scoped css that is only for that component or for fall-through attributes
# Scoped styles usage:
<style scoped>
.ui-input {
/* Component styles */
}
</style>8. Testing Structure
test/
│
├── nuxt/components/ # Component tests
│ ├── UserEdit.spec.ts
│ └── FireEdit.spec.ts
│
└── e2e/ # End-to-end tests9. Performance Optimization
State Optimization
// Good: Use destructuring with storeToRefs for performance
import { storeToRefs } from 'pinia'
import { useFireStore } from '@monorepo/shared'
const fireStore = useFireStore()
const { fires, loading } = storeToRefs(fireStore)
// Avoid: Full store reference creates unnecessary reactivity
// const fireStore = useFireStore(); // Returns full store object10. Troubleshooting
Hot Module Replacement (HMR) Issues
# Restart dev server
# pnpm dev
# Clear build cache
rm -rf .nuxt
pnpm dev11. Internationalization (i18n) - Vertalingen Beheren
11.1 Structuur van Vertalingsbestanden
Vertalingen zijn opgeslagen in JSON-bestanden per taal in i18n/locales/:
i18n/locales/
├── nl.json # Nederlands (nl-BE)
├── en.json # Engels
├── fr.json # Frans (fr-BE)
└── de.json # Duits (de-DE)Elk bestand bevat geneste sleutels met vertalingen:
{
"navigation": {
"provinces": "Provincies",
"municipality": "Gemeente",
"zones": "Zones"
},
"buttons": {
"login": "Inloggen",
"logout": "Uitloggen",
"export": "Exporteren"
},
"risk_levels": {
"title": "Risico Indicator",
"low": "Laag",
"medium": "Gemiddeld",
"high": "Hoog",
"extreme": "Extreem"
}
}11.2 Hoe Vertalingen te Gebruiken in Componenten
Gebruik de $t() helper in templates:
<template>
<div>
<!-- Enkelvoudige vertaling -->
<h1>{{ $t('navigation.provinces') }}</h1>
<!-- Met parameters -->
<p>{{ $t('messages.welcome', { name: userName }) }}</p>
<!-- In HTML attributen -->
<button :title="$t('buttons.login')">
{{ $t('buttons.login') }}
</button>
</div>
</template>
<script setup lang="ts">
// In JavaScript
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const title = t('navigation.provinces')
</script>11.3 Vertalingen Aanpassen
Stap 1: Bestand Openen
- Ga naar userdashboard/pages
- Bekijk welke key staat op de plaats van de tekst die je wilt aanpassen zoals t('buttons.export')
- Ga naar het bestand dat je wilt vertalen:
i18n/locales/nl.json(of andere taal)
Stap 2: waarde Identificeren
- Zoek de waarde die je wilt veranderen:
// Huidigtekst:
{
"buttons": {
"export": "Exporteren"
}
}
// Wijziging gewenst? Verander de waarde:
{
"buttons": {
"export": "Data exporteren" // ← Aanpasbare tekst
}
}Stap 3: Wijziging Doorgeven
- Vervang de vertaling bij de overeenkomende waarde
- Sla het bestand op
- Verwerk het via versiebeheer (Git) in de main branch
Stap 4: Applicatie Herladen
- Development: Het wordt meestal automatisch ververst (Hot Module Replacement)
- Production: Deploy een nieuwe versie
11.4 Nieuwe Vertaling Toevoegen
Voor Alle Talen:
// i18n/locales/nl.json
{
"new_section": {
"new_text": "Mijn nieuwe vertaling"
}
}
// i18n/locales/en.json
{
"new_section": {
"new_text": "My new translation"
}
}
// i18n/locales/fr.json
{
"new_section": {
"new_text": "Ma nouvelle traduction"
}
}
// i18n/locales/de.json
{
"new_section": {
"new_text": "Meine neue Übersetzung"
}
}11.5 Veelgestelde Vragen
Wordt de site automatisch vertaald?
Nee. Elke taal moet handmatig in het JSON-bestand ingevuld worden. Je kunt een vertaaldienst gebruiken (Google Translate API, DeepL API) voor efficiency.
Hoe deel ik vertalingen mee?
Stuur een updated JSON-bestand via:
- Email attachment
- Shared document (Google Drive, OneDrive)
- Direct in versiebeheer (als je toegang hebt)
Wat als ik een key vergeet? A: De app toont de key als fallback (bijv. navigation.provinces ipv de vertaalde tekst). Dit helpt je gauw fouten te zien.
Kan ik HTML in vertalingen gebruiken? A: Ja, maar voorzichtig:
{
"info": {
"contact": "Email ons op <a href='mailto:help@example.com'>help@example.com</a>"
}
}Gebruik v-html in component:
<p v-html="$t('info.contact')"></p>11.6 Huidige Vertalingen Overzicht
| Taal | Code | Status | Lokatie |
|---|---|---|---|
| Nederlands | nl | Compleet | i18n/locales/nl.json |
| Engels | en | Compleet | i18n/locales/en.json |
| Frans | fr | Compleet | i18n/locales/fr.json |
| Duits | de | Compleet | i18n/locales/de.json |
11.7 Vertalingen Backup & Versioning
# Backup vertalingen
cp -r i18n/locales i18n/locales.backup
# Controle versies
git log i18n/locales/
# Terugzetten naar vorige versie
git checkout HEAD~1 -- i18n/locales/nl.json12. Bronnen & Documentatie
Officiële Documentatie
- Vue 3 Documentation - Core framework
- Nuxt 4 Documentation - Meta-framework
- Pinia Documentation - State management
- TypeScript Handbook - Type system
- Nuxt Leaflet - Leaflet library
Projectspecifieke Setup
- Frontend Monorepo:
pnpm-workspace.yamlwordt toegevoegd na pnpm install - Shared Package:
shared/package.json - Build Config:
vite.config.tsper project - Type Definitions:
shared/types/ - Translations:
i18n/locales/
Best Practices
Versie: 1.0
Status: Production Ready
Laatst Bijgewerkt: 17 januari 2026