project setup with core files including configuration, package management, and basic structure. Added .gitignore, README, and various TypeScript types for CMS components. Implemented initial components and layouts for the application.
This commit is contained in:
0
.cursor/commands/about.md
Normal file
0
.cursor/commands/about.md
Normal file
174
.cursor/context.md
Normal file
174
.cursor/context.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Projekt-Kontext: GraphQL Middlelayer + Astro Frontend
|
||||
|
||||
## Projekt-Übersicht
|
||||
|
||||
Ein GraphQL-basierter Middlelayer mit Astro-Frontend für einen Onlineshop. Der Middlelayer fungiert als Abstraktionsschicht zwischen Frontend und verschiedenen Datenquellen (aktuell Mock-Daten, später Headless CMS, etc.).
|
||||
|
||||
## Architektur
|
||||
|
||||
### Middlelayer (`middlelayer/`)
|
||||
- **GraphQL Server** (Apollo Server v5) auf Port 4000
|
||||
- **Adapter Pattern** für Datenquellen-Abstraktion
|
||||
- **DataService** als Singleton-Aggregator
|
||||
- **Monitoring & Observability**:
|
||||
- Structured Logging (Winston)
|
||||
- Prometheus Metrics (Port 9090)
|
||||
- Distributed Tracing
|
||||
- **Performance-Optimierungen**:
|
||||
- Dataloader für Batch-Loading (verhindert N+1 Queries)
|
||||
- Response Caching
|
||||
- Query Complexity Limits (mit Workaround für Schema-Realm-Konflikt)
|
||||
|
||||
### Frontend (`src/`)
|
||||
- **Astro Framework** mit SSR
|
||||
- **Tailwind CSS** für Styling
|
||||
- **Alpine.js** für Client-Side Interaktivität
|
||||
- **GraphQL Client** für Datenabfragen
|
||||
|
||||
## Datenstrukturen
|
||||
|
||||
### Produkte
|
||||
- `Product` mit `originalPrice` (Streichpreis) und `promotion` (Objekt mit `category` und `text`)
|
||||
- Promotion-Typen: `"sale"` oder `"topseller"`
|
||||
- Promotion-Text: z.B. `"-30%"` oder `"top"`
|
||||
|
||||
### CMS-Daten
|
||||
- `Page` - Seiten mit SEO, Headlines, Bannern (mit Locale-Support)
|
||||
- `PageSeo` - SEO-Metadaten (mit Locale-Support)
|
||||
- `Navigation` - Navigationsstruktur mit Links (mit Locale-Support)
|
||||
|
||||
### Content-System
|
||||
- **CMS-Typen** (`middlelayer/types/cms/`) - Struktur wie Daten vom CMS kommen
|
||||
- `*Skeleton` - Wrapper mit `contentTypeId` und `fields`
|
||||
- Verwendet `ComponentLayout` (Alias für `contentLayout`)
|
||||
- Nur für Mapper und Mock-Daten
|
||||
- **Domain-Typen** (`middlelayer/types/c_*.ts`) - Struktur wie Daten in der App verwendet werden
|
||||
- `c_*` - Content-Item-Typen mit `type`-Feld für Discriminated Union
|
||||
- Verwendet `contentLayout` direkt
|
||||
- Für GraphQL Schema, Astro Components, etc.
|
||||
- **Content-Komponenten**: HTML, Markdown, Iframe, ImageGallery, Image, Quote, YoutubeVideo, Headline
|
||||
- **Mapper** (`middlelayer/mappers/pageMapper.ts`) - Konvertiert CMS-Typen zu Domain-Typen
|
||||
|
||||
### Internationalization (i18n)
|
||||
- **URL-basierte Locales**: `/de` und `/en` URLs
|
||||
- **Übersetzungen**: Defaults + Middlelayer-Überschreibungen
|
||||
- **React**: `useI18n()` Hook für Komponenten
|
||||
- **Alpine.js**: `window.i18n.t()` für Navigation
|
||||
- **CMS**: Locale-Parameter in allen CMS-Queries
|
||||
- **Contentful-Ansatz**: Vorbereitet für Contentful Locale-System (jedes Feld lokalisiert)
|
||||
|
||||
## Wichtige Dateien
|
||||
|
||||
### Middlelayer
|
||||
- `middlelayer/index.ts` - Server-Entry-Point
|
||||
- `middlelayer/schema.ts` - GraphQL Schema
|
||||
- `middlelayer/resolvers.ts` - GraphQL Resolvers
|
||||
- `middlelayer/dataService.ts` - DataService (Singleton)
|
||||
- `middlelayer/adapters/interface.ts` - DataAdapter Interface
|
||||
- `middlelayer/adapters/mockdata.ts` - MockdataAdapter
|
||||
- `middlelayer/adapters/Mock/_cms/` - Mock-Daten für CMS
|
||||
- `middlelayer/mappers/pageMapper.ts` - Konvertiert CMS-Typen zu Domain-Typen
|
||||
- `middlelayer/types/cms/` - CMS-spezifische Typen (für Mapper/Mock)
|
||||
- `middlelayer/types/c_*.ts` - Domain-Typen (für App)
|
||||
- `middlelayer/types/contentLayout.ts` - Einheitlicher Layout-Typ
|
||||
- `middlelayer/utils/dataloaders.ts` - Dataloader für Batch-Loading + GraphQLContext
|
||||
- `middlelayer/utils/cache.ts` - In-Memory Cache mit Metrics
|
||||
- `middlelayer/monitoring/` - Logging, Metrics, Tracing
|
||||
- `middlelayer/plugins/` - Apollo Server Plugins
|
||||
|
||||
### Frontend
|
||||
- `src/components/Product.astro` - Produkt-Komponente mit Promotion & Streichpreis
|
||||
- `src/components/LoginModal.tsx` - React Login-Modal mit i18n
|
||||
- `src/components/RegisterModal.tsx` - React Register-Modal mit i18n
|
||||
- `src/lib/graphql/client.ts` - GraphQL Client
|
||||
- `src/lib/graphql/queries.ts` - Produkt-Queries
|
||||
- `src/lib/graphql/cmsQueries.ts` - CMS-Queries (mit Locale-Support)
|
||||
- `src/lib/i18n/` - i18n-System (defaults, i18n.ts, useI18n.tsx, alpine.ts)
|
||||
- `src/layouts/Layout.astro` - Layout mit Navigation & SEO
|
||||
- `src/middleware.ts` - URL-basierte Locale-Routing
|
||||
- `src/pages/[locale]/` - Locale-basierte Routen
|
||||
|
||||
## Konfiguration
|
||||
|
||||
### Environment Variables
|
||||
```bash
|
||||
PORT=4000 # GraphQL Server Port
|
||||
METRICS_PORT=9090 # Metrics Server Port
|
||||
LOG_LEVEL=info # Log-Level (debug, info, warn, error)
|
||||
MAX_QUERY_COMPLEXITY=1000 # Query Complexity Limit
|
||||
NODE_ENV=development # Environment
|
||||
```
|
||||
|
||||
### Cache-TTLs (konfigurierbar via Environment oder `middlelayer/config/cache.ts`)
|
||||
- Pages: 60 Sekunden
|
||||
- SEO/Navigation: 5 Minuten
|
||||
- Products: 30 Sekunden
|
||||
|
||||
## Bekannte Probleme & Workarounds
|
||||
|
||||
1. **Query Complexity Schema-Realm-Konflikt**
|
||||
- Problem: `graphql-query-complexity` hat Schema-Realm-Konflikt mit Apollo Server
|
||||
- Workaround: Plugin überspringt Check bei Realm-Konflikten (siehe `middlelayer/plugins/queryComplexity.ts`)
|
||||
- Status: Funktioniert, aber Complexity-Check wird manchmal übersprungen
|
||||
|
||||
2. **GraphQL Version**
|
||||
- Alle Pakete verwenden `graphql@16.12.0`
|
||||
- `overrides` in `package.json` stellt sicher, dass nur eine Version verwendet wird
|
||||
|
||||
## NPM Scripts
|
||||
|
||||
```bash
|
||||
npm run mock:server # Startet nur GraphQL Server
|
||||
npm start # Startet GraphQL Server + Astro (concurrently)
|
||||
npm run dev # Startet nur Astro
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
- **GraphQL**: `http://localhost:4000`
|
||||
- **GraphQL Playground**: `http://localhost:4000`
|
||||
- **Metrics**: `http://localhost:9090/metrics`
|
||||
- **Health Check**: `http://localhost:9090/health`
|
||||
- **Astro**: `http://localhost:4321`
|
||||
|
||||
## Implementierte Features
|
||||
|
||||
✅ GraphQL Server mit Apollo Server v5
|
||||
✅ Adapter Pattern für Datenquellen
|
||||
✅ Mock-Daten für Produkte und CMS
|
||||
✅ Product-Komponente mit Promotion & Streichpreis
|
||||
✅ Structured Logging (Winston)
|
||||
✅ Prometheus Metrics
|
||||
✅ Distributed Tracing
|
||||
✅ Dataloader für Batch-Loading
|
||||
✅ Response Caching
|
||||
✅ Query Complexity Limits (mit Workaround)
|
||||
✅ Cache mit Metrics-Tracking
|
||||
✅ DataService mit Metrics-Tracking
|
||||
|
||||
## Nächste Schritte / Offene Punkte
|
||||
|
||||
- [ ] Query Complexity Schema-Realm-Problem dauerhaft lösen
|
||||
- [ ] Redis Cache Integration (statt In-Memory)
|
||||
- [ ] Database Adapter (für echte Datenbank)
|
||||
- [ ] Headless CMS Adapter (Contentful, Strapi, etc.)
|
||||
- [ ] Rate Limiting
|
||||
- [ ] Authentication/Authorization
|
||||
- [ ] GraphQL Subscriptions (falls benötigt)
|
||||
|
||||
## Wichtige Design-Entscheidungen
|
||||
|
||||
1. **Adapter Pattern**: Ermöglicht einfaches Wechseln zwischen Datenquellen
|
||||
2. **Singleton DataService**: Zentrale Stelle für alle Datenoperationen
|
||||
3. **Monitoring First**: Von Anfang an Monitoring integriert
|
||||
4. **Type Safety**: TypeScript durchgängig verwendet
|
||||
5. **Structured Logging**: JSON-Logs für bessere Analyse
|
||||
6. **Metrics**: Prometheus-kompatible Metriken für Monitoring
|
||||
|
||||
## Code-Stil
|
||||
|
||||
- TypeScript mit ES Modules
|
||||
- Doppelte Anführungszeichen für Strings (Prettier)
|
||||
- Deutsche Kommentare und Fehlermeldungen
|
||||
- Englische Code-Namen und API-Namen
|
||||
|
||||
150
.gitignore
vendored
150
.gitignore
vendored
@@ -1,138 +1,28 @@
|
||||
# ---> Node
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
# build output
|
||||
dist/
|
||||
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
# environment variables
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
|
||||
# vscode settings folder
|
||||
.vscode/
|
||||
.history/
|
||||
443
README.md
443
README.md
@@ -1,2 +1,443 @@
|
||||
# sell
|
||||
# GraphQL Middlelayer + Astro Frontend
|
||||
|
||||
Ein moderner, skalierbarer Onlineshop mit GraphQL-basiertem Middlelayer und Astro-Frontend. Der Middlelayer fungiert als flexible Abstraktionsschicht zwischen Frontend und verschiedenen Datenquellen.
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
- **GraphQL API** mit Apollo Server v5
|
||||
- **Astro Frontend** mit SSR und Client-Side Interaktivität
|
||||
- **Adapter Pattern** für flexible Datenquellen (Mock, Headless CMS, Database)
|
||||
- **Performance-Optimierungen**:
|
||||
- Dataloader für Batch-Loading (verhindert N+1 Queries)
|
||||
- Redis/In-Memory Caching
|
||||
- Response Caching
|
||||
- Query Complexity Limits
|
||||
- **Monitoring & Observability**:
|
||||
- Structured Logging (Winston)
|
||||
- Prometheus Metrics
|
||||
- Distributed Tracing
|
||||
- **Type-Safe** mit TypeScript durchgängig
|
||||
- **Modern UI** mit Tailwind CSS und Alpine.js
|
||||
- **Internationalization (i18n)**:
|
||||
- URL-basierte Locales (`/de`, `/en`)
|
||||
- Übersetzungen aus Middlelayer (mit Default-Fallback)
|
||||
- CMS-Inhalte mehrsprachig (vorbereitet für Contentful)
|
||||
|
||||
## 📋 Voraussetzungen
|
||||
|
||||
- Node.js 18+
|
||||
- npm oder yarn
|
||||
- (Optional) Redis für verteiltes Caching
|
||||
|
||||
## 🛠️ Installation
|
||||
|
||||
```bash
|
||||
# Dependencies installieren
|
||||
npm install
|
||||
|
||||
# (Optional) Redis installieren (macOS)
|
||||
brew install redis
|
||||
brew services start redis
|
||||
```
|
||||
|
||||
## 🏃 Quick Start
|
||||
|
||||
```bash
|
||||
# GraphQL Server + Astro Frontend starten
|
||||
npm start
|
||||
|
||||
# Oder einzeln:
|
||||
npm run mock:server # Nur GraphQL Server (Port 4000)
|
||||
npm run dev # Nur Astro Frontend (Port 4321)
|
||||
```
|
||||
|
||||
## 📁 Projektstruktur
|
||||
|
||||
```
|
||||
/
|
||||
├── middlelayer/ # GraphQL Middlelayer
|
||||
│ ├── adapters/ # Datenquellen-Adapter (Mock, CMS, etc.)
|
||||
│ ├── config/ # Konfiguration
|
||||
│ ├── monitoring/ # Logging, Metrics, Tracing
|
||||
│ ├── plugins/ # Apollo Server Plugins
|
||||
│ ├── types/ # TypeScript Typen
|
||||
│ ├── utils/ # Utilities (Cache, Dataloader, etc.)
|
||||
│ ├── index.ts # Server Entry Point
|
||||
│ ├── schema.ts # GraphQL Schema
|
||||
│ └── resolvers.ts # GraphQL Resolvers
|
||||
│
|
||||
├── src/ # Astro Frontend
|
||||
│ ├── components/ # Astro Komponenten
|
||||
│ ├── layouts/ # Layout Templates
|
||||
│ ├── lib/ # Utilities & GraphQL Client
|
||||
│ ├── pages/ # Astro Pages
|
||||
│ └── styles/ # Global Styles
|
||||
│
|
||||
└── docs/ # Dokumentation
|
||||
```
|
||||
|
||||
## ⚙️ Konfiguration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Erstelle eine `.env` Datei im Root-Verzeichnis:
|
||||
|
||||
```env
|
||||
# Server Ports
|
||||
PORT=4000 # GraphQL Server Port
|
||||
METRICS_PORT=9090 # Metrics Server Port
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info # debug, info, warn, error
|
||||
NODE_ENV=development # development, production
|
||||
|
||||
# GraphQL
|
||||
MAX_QUERY_COMPLEXITY=1000 # Query Complexity Limit
|
||||
|
||||
# Redis (Optional)
|
||||
REDIS_ENABLED=true # Redis Cache aktivieren
|
||||
REDIS_HOST=localhost # Redis Host
|
||||
REDIS_PORT=6379 # Redis Port
|
||||
REDIS_PASSWORD= # Optional: Redis Password
|
||||
|
||||
# Cache TTLs (in Millisekunden)
|
||||
CACHE_PAGES_TTL=60000 # 60 Sekunden
|
||||
CACHE_PAGE_SEO_TTL=300000 # 5 Minuten
|
||||
CACHE_NAVIGATION_TTL=300000 # 5 Minuten
|
||||
CACHE_PRODUCTS_TTL=30000 # 30 Sekunden
|
||||
```
|
||||
|
||||
### Cache-Konfiguration
|
||||
|
||||
Cache-TTLs können über Environment Variables oder direkt in `middlelayer/config/cache.ts` konfiguriert werden.
|
||||
|
||||
## 🌐 Endpoints
|
||||
|
||||
| Service | URL | Beschreibung |
|
||||
|---------|-----|--------------|
|
||||
| GraphQL API | `http://localhost:4000` | GraphQL Endpoint & Playground |
|
||||
| Metrics | `http://localhost:9090/metrics` | Prometheus Metrics |
|
||||
| Health Check | `http://localhost:9090/health` | Service Health Status |
|
||||
| Astro Frontend | `http://localhost:4321` | Frontend Application |
|
||||
|
||||
## 📊 GraphQL Schema
|
||||
|
||||
### Queries
|
||||
|
||||
```graphql
|
||||
# Produkte
|
||||
query {
|
||||
products(limit: 4) {
|
||||
id
|
||||
name
|
||||
price
|
||||
originalPrice
|
||||
promotion {
|
||||
category
|
||||
text
|
||||
}
|
||||
}
|
||||
|
||||
product(id: "prod-123") {
|
||||
id
|
||||
name
|
||||
description
|
||||
price
|
||||
}
|
||||
}
|
||||
|
||||
# CMS (mit Locale-Support)
|
||||
query {
|
||||
pageSeo(locale: "de") {
|
||||
title
|
||||
description
|
||||
}
|
||||
|
||||
page(slug: "/about", locale: "de") {
|
||||
headline
|
||||
subheadline
|
||||
}
|
||||
|
||||
pages(locale: "en") {
|
||||
slug
|
||||
name
|
||||
}
|
||||
|
||||
navigation(locale: "de") {
|
||||
links {
|
||||
name
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Übersetzungen
|
||||
query {
|
||||
translations(locale: "de", namespace: "auth") {
|
||||
locale
|
||||
translations {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
page(slug: "/") {
|
||||
slug
|
||||
name
|
||||
headline
|
||||
}
|
||||
|
||||
navigation {
|
||||
name
|
||||
links {
|
||||
name
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🏗️ Architektur
|
||||
|
||||
### Middlelayer
|
||||
|
||||
Der Middlelayer verwendet das **Adapter Pattern** für flexible Datenquellen:
|
||||
|
||||
```
|
||||
Frontend (Astro)
|
||||
↓
|
||||
GraphQL API (Apollo Server)
|
||||
↓
|
||||
DataService (Singleton)
|
||||
↓
|
||||
DataAdapter (Interface)
|
||||
↓
|
||||
┌─────────────┬──────────────┬─────────────┐
|
||||
│ Mock Adapter│ CMS Adapter │ DB Adapter │
|
||||
└─────────────┴──────────────┴─────────────┘
|
||||
```
|
||||
|
||||
**Vorteile:**
|
||||
- Einfaches Wechseln zwischen Datenquellen
|
||||
- Testbarkeit durch Mock-Adapter
|
||||
- Zentrale Logik im DataService
|
||||
- Caching auf Service-Ebene
|
||||
|
||||
### Frontend
|
||||
|
||||
- **Astro** für Server-Side Rendering
|
||||
- **Alpine.js** für Client-Side Interaktivität
|
||||
- **Tailwind CSS** für Styling
|
||||
- **GraphQL Client** für Datenabfragen
|
||||
|
||||
## 🔧 Entwicklung
|
||||
|
||||
### NPM Scripts
|
||||
|
||||
```bash
|
||||
npm run mock:server # Startet GraphQL Server
|
||||
npm run dev # Startet Astro Dev Server
|
||||
npm start # Startet beide (concurrently)
|
||||
npm run build # Production Build
|
||||
npm run preview # Preview Production Build
|
||||
```
|
||||
|
||||
### Neuen Adapter hinzufügen
|
||||
|
||||
1. Implementiere `DataAdapter` Interface:
|
||||
```typescript
|
||||
// middlelayer/adapters/myAdapter.ts
|
||||
import type { DataAdapter } from './interface.js';
|
||||
|
||||
export class MyAdapter implements DataAdapter {
|
||||
async getProducts(limit?: number): Promise<Product[]> {
|
||||
// Implementierung
|
||||
}
|
||||
// ... weitere Methoden
|
||||
}
|
||||
```
|
||||
|
||||
2. In `middlelayer/adapters/config.ts` registrieren:
|
||||
```typescript
|
||||
export function createAdapter(): DataAdapter {
|
||||
const adapterType = process.env.ADAPTER_TYPE || 'mock';
|
||||
|
||||
if (adapterType === 'myAdapter') {
|
||||
return new MyAdapter();
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## 📈 Monitoring
|
||||
|
||||
### Prometheus Metrics
|
||||
|
||||
Metriken sind verfügbar unter `http://localhost:9090/metrics`:
|
||||
|
||||
- `graphql_queries_total` - Anzahl der Queries
|
||||
- `graphql_query_duration_seconds` - Query-Dauer
|
||||
- `cache_hits_total` / `cache_misses_total` - Cache-Statistiken
|
||||
- `dataservice_calls_total` - DataService-Aufrufe
|
||||
- `errors_total` - Fehler-Anzahl
|
||||
|
||||
### Logging
|
||||
|
||||
Structured Logging mit Winston:
|
||||
- Console Output (Development)
|
||||
- JSON Logs (Production)
|
||||
- Log-Level konfigurierbar
|
||||
|
||||
### Tracing
|
||||
|
||||
Automatische Trace-ID-Generierung für Request-Tracking.
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
### Production Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Docker (Beispiel)
|
||||
|
||||
```dockerfile
|
||||
FROM node:18-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --production
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
CMD ["npm", "start"]
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```bash
|
||||
# GraphQL Query testen
|
||||
curl -X POST http://localhost:4000 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query":"{ products(limit: 4) { id name price } }"}'
|
||||
|
||||
# Health Check
|
||||
curl http://localhost:9090/health
|
||||
|
||||
# Metrics
|
||||
curl http://localhost:9090/metrics
|
||||
```
|
||||
|
||||
## 📚 Weitere Dokumentation
|
||||
|
||||
- [Architektur & Skalierung](./docs/ARCHITECTURE_SCALING.md) - Detaillierte Architektur-Analyse
|
||||
- [Redis Setup](./middlelayer/utils/REDIS_SETUP.md) - Redis Cache Konfiguration
|
||||
- [Monitoring](./middlelayer/monitoring/README.md) - Monitoring & Observability
|
||||
|
||||
## 🐛 Bekannte Probleme
|
||||
|
||||
### Query Complexity Schema-Realm-Konflikt
|
||||
|
||||
**Problem:** `graphql-query-complexity` hat einen Schema-Realm-Konflikt mit Apollo Server.
|
||||
|
||||
**Workaround:** Das Plugin überspringt den Check bei Realm-Konflikten automatisch. Funktioniert, aber Complexity-Check wird manchmal übersprungen.
|
||||
|
||||
**Status:** Funktioniert, permanente Lösung in Arbeit.
|
||||
|
||||
## 🌍 Internationalization (i18n)
|
||||
|
||||
### URL-basierte Locales
|
||||
|
||||
Das System unterstützt URL-basierte Locales:
|
||||
- `/de` - Deutsche Version
|
||||
- `/en` - Englische Version
|
||||
- `/` - Auto-Redirect zu Browser-Locale oder Cookie
|
||||
|
||||
### Übersetzungen
|
||||
|
||||
**Architektur:**
|
||||
- **Defaults**: Fallback-Übersetzungen in `src/lib/i18n/defaults.ts`
|
||||
- **Middlelayer**: Übersetzungen können vom GraphQL-Server geladen werden
|
||||
- **Überschreibungen**: Middlelayer-Übersetzungen überschreiben Defaults
|
||||
|
||||
**Verwendung in React:**
|
||||
```tsx
|
||||
import { useI18n } from "../lib/i18n/useI18n.js";
|
||||
|
||||
function MyComponent() {
|
||||
const { t } = useI18n("auth");
|
||||
return <h1>{t("login.title")}</h1>;
|
||||
}
|
||||
```
|
||||
|
||||
**Verwendung in Alpine.js:**
|
||||
```html
|
||||
<div x-data="{ t: window.i18n?.t || ((key) => key) }">
|
||||
<button x-text="t('nav.login')"></button>
|
||||
</div>
|
||||
```
|
||||
|
||||
### CMS-Locale-Support
|
||||
|
||||
**Contentful-Ansatz:**
|
||||
Contentful verwendet ein Locale-System, bei dem:
|
||||
- Jedes Content-Feld lokalisiert werden kann
|
||||
- Standard-Locale wird definiert (z.B. `en-US`)
|
||||
- Fallback-Locales können konfiguriert werden
|
||||
- API-Aufrufe enthalten `locale`-Parameter: `fields.headline['en-US']`
|
||||
|
||||
**Unser System:**
|
||||
- GraphQL-Schema unterstützt `locale`-Parameter für alle CMS-Queries
|
||||
- Adapter-Interface ist vorbereitet für Locale-Parameter
|
||||
- Mock-Adapter gibt aktuell alle Locales gleich zurück (TODO: Locale-spezifische Mock-Daten)
|
||||
- Bei Contentful-Integration: Adapter würde `locale`-Parameter an Contentful API weitergeben
|
||||
|
||||
**Beispiel GraphQL Query:**
|
||||
```graphql
|
||||
query {
|
||||
page(slug: "/about", locale: "de") {
|
||||
headline # Deutsche Übersetzung
|
||||
subheadline
|
||||
}
|
||||
|
||||
page(slug: "/about", locale: "en") {
|
||||
headline # Englische Übersetzung
|
||||
subheadline
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔮 Roadmap
|
||||
|
||||
- [ ] Query Complexity Schema-Realm-Problem dauerhaft lösen
|
||||
- [ ] Database Adapter (PostgreSQL, MySQL)
|
||||
- [ ] Headless CMS Adapter (Contentful, Strapi)
|
||||
- [ ] Locale-spezifische Inhalte implementieren
|
||||
- [ ] Fallback-Locales konfigurieren
|
||||
- [ ] Rate Limiting
|
||||
- [ ] Authentication/Authorization ✅ (implementiert)
|
||||
- [ ] GraphQL Subscriptions
|
||||
- [ ] i18n: Weitere Sprachen hinzufügen
|
||||
- [ ] i18n: Locale-spezifische Mock-Daten für CMS
|
||||
- [ ] E2E Tests
|
||||
|
||||
## 🤝 Beitragen
|
||||
|
||||
1. Fork das Repository
|
||||
2. Erstelle einen Feature Branch (`git checkout -b feature/AmazingFeature`)
|
||||
3. Committe deine Änderungen (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. Push zum Branch (`git push origin feature/AmazingFeature`)
|
||||
5. Öffne einen Pull Request
|
||||
|
||||
## 📝 Lizenz
|
||||
|
||||
Dieses Projekt ist privat.
|
||||
|
||||
## 👥 Autoren
|
||||
|
||||
- Entwickelt mit ❤️ für moderne E-Commerce-Lösungen
|
||||
|
||||
---
|
||||
|
||||
**Hinweis:** Dieses Projekt ist in aktiver Entwicklung. Für Fragen oder Probleme öffne bitte ein Issue.
|
||||
|
||||
29
astro.config.mjs
Normal file
29
astro.config.mjs
Normal file
@@ -0,0 +1,29 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from "astro/config";
|
||||
import tailwind from "@tailwindcss/vite";
|
||||
import react from "@astrojs/react";
|
||||
import alpinejs from "@astrojs/alpinejs";
|
||||
|
||||
// https://astro.build/config
|
||||
import { fileURLToPath } from "url";
|
||||
import { resolve } from "path";
|
||||
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
vite: {
|
||||
plugins: [tailwind()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@middlelayer-types": resolve(__dirname, "./middlelayer/types"),
|
||||
"@middlelayer": resolve(__dirname, "./middlelayer"),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
integrations: [react(), alpinejs()],
|
||||
|
||||
// URL-basierte Locale-Routing
|
||||
output: "server",
|
||||
adapter: undefined, // Für SSR (später kann ein Adapter hinzugefügt werden)
|
||||
});
|
||||
395
docs/ARCHITECTURE_SCALING.md
Normal file
395
docs/ARCHITECTURE_SCALING.md
Normal file
@@ -0,0 +1,395 @@
|
||||
# Architektur-Analyse: Skalierung für großen Onlineshop
|
||||
|
||||
## Aktuelle Architektur - Stärken ✅
|
||||
|
||||
1. **Adapter Pattern** - Gute Abstraktion für Datenquellen
|
||||
2. **Separation of Concerns** - Klare Trennung zwischen GraphQL, DataService und Adaptern
|
||||
3. **Type Safety** - TypeScript durchgängig verwendet
|
||||
4. **Caching-Layer** - Grundlegende Caching-Strategie vorhanden
|
||||
5. **Error Handling** - Strukturierte Fehlerbehandlung
|
||||
|
||||
## Kritische Verbesserungen für hohen Traffic 🚨
|
||||
|
||||
### 1. **Caching-Strategie**
|
||||
|
||||
**Problem:**
|
||||
- In-Memory Cache ist pro Server-Instanz isoliert
|
||||
- Cache geht bei Neustart verloren
|
||||
- Keine Cache-Invalidierung bei Updates
|
||||
- Keine Cache-Warming-Strategie
|
||||
|
||||
**Lösung:**
|
||||
```typescript
|
||||
// Redis-basierter Cache mit Clustering
|
||||
import Redis from 'ioredis';
|
||||
|
||||
class RedisCache<T> {
|
||||
private client: Redis;
|
||||
private cluster: Redis.Cluster;
|
||||
|
||||
// Cache-Tags für gezielte Invalidierung
|
||||
async invalidateByTag(tag: string) { ... }
|
||||
|
||||
// Cache-Warming beim Start
|
||||
async warmCache() { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Empfehlungen:**
|
||||
- ✅ Redis Cluster für verteilten Cache
|
||||
- ✅ Cache-Tags für gezielte Invalidierung (z.B. `product:123`, `category:electronics`)
|
||||
- ✅ Cache-Warming beim Deployment
|
||||
- ✅ Stale-While-Revalidate Pattern
|
||||
- ✅ CDN für statische Assets (Bilder, CSS, JS)
|
||||
|
||||
### 2. **Database Connection Pooling**
|
||||
|
||||
**Problem:**
|
||||
- Keine Connection Pooling sichtbar
|
||||
- Risiko von Connection Exhaustion bei hohem Traffic
|
||||
|
||||
**Lösung:**
|
||||
```typescript
|
||||
// Connection Pool für Datenbank-Adapter
|
||||
class DatabaseAdapter implements DataAdapter {
|
||||
private pool: Pool;
|
||||
|
||||
constructor() {
|
||||
this.pool = new Pool({
|
||||
max: 20, // Max Connections
|
||||
min: 5, // Min Connections
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Empfehlungen:**
|
||||
- ✅ Connection Pooling (PostgreSQL, MySQL)
|
||||
- ✅ Read Replicas für Read-Heavy Operations
|
||||
- ✅ Database Query Optimization (Indizes, Query-Analyse)
|
||||
- ✅ Connection Monitoring & Alerting
|
||||
|
||||
### 3. **GraphQL Performance**
|
||||
|
||||
**Problem:**
|
||||
- Keine Query Complexity Limits
|
||||
- Keine Dataloader für N+1 Queries
|
||||
- Keine Query Caching
|
||||
- Keine Rate Limiting
|
||||
|
||||
**Lösung:**
|
||||
```typescript
|
||||
// Apollo Server mit Performance-Features
|
||||
const server = new ApolloServer({
|
||||
typeDefs,
|
||||
resolvers,
|
||||
plugins: [
|
||||
// Query Complexity
|
||||
{
|
||||
requestDidStart() {
|
||||
return {
|
||||
didResolveOperation({ request, operation }) {
|
||||
const complexity = calculateComplexity(operation);
|
||||
if (complexity > 1000) {
|
||||
throw new Error('Query too complex');
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
// Response Caching
|
||||
responseCachePlugin({
|
||||
sessionId: (requestContext) =>
|
||||
requestContext.request.http?.headers.get('session-id') ?? null,
|
||||
}),
|
||||
// Rate Limiting
|
||||
rateLimitPlugin({
|
||||
identifyContext: (ctx) => ctx.request.http?.headers.get('x-user-id'),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
**Empfehlungen:**
|
||||
- ✅ Query Complexity Limits
|
||||
- ✅ Dataloader für Batch-Loading
|
||||
- ✅ Response Caching (Apollo Server)
|
||||
- ✅ Rate Limiting (pro User/IP)
|
||||
- ✅ Query Persisted Queries
|
||||
- ✅ GraphQL Query Analysis & Monitoring
|
||||
|
||||
### 4. **Load Balancing & Horizontal Scaling**
|
||||
|
||||
**Problem:**
|
||||
- Single Server Instance
|
||||
- Keine Load Balancing
|
||||
- Keine Health Checks
|
||||
|
||||
**Lösung:**
|
||||
```yaml
|
||||
# Docker Compose / Kubernetes
|
||||
services:
|
||||
graphql:
|
||||
replicas: 5
|
||||
healthcheck:
|
||||
path: /health
|
||||
interval: 10s
|
||||
redis:
|
||||
cluster: true
|
||||
database:
|
||||
read-replicas: 3
|
||||
```
|
||||
|
||||
**Empfehlungen:**
|
||||
- ✅ Kubernetes / Docker Swarm für Orchestrierung
|
||||
- ✅ Load Balancer (NGINX, HAProxy, AWS ALB)
|
||||
- ✅ Health Check Endpoints
|
||||
- ✅ Auto-Scaling basierend auf CPU/Memory
|
||||
- ✅ Blue-Green Deployments
|
||||
|
||||
### 5. **Monitoring & Observability**
|
||||
|
||||
**Problem:**
|
||||
- Nur Console-Logging
|
||||
- Keine Metriken
|
||||
- Keine Distributed Tracing
|
||||
|
||||
**Lösung:**
|
||||
```typescript
|
||||
// Structured Logging + Metrics
|
||||
import { createLogger } from 'winston';
|
||||
import { PrometheusMetrics } from './metrics';
|
||||
|
||||
const logger = createLogger({
|
||||
format: winston.format.json(),
|
||||
transports: [
|
||||
new winston.transports.Console(),
|
||||
new winston.transports.File({ filename: 'error.log' }),
|
||||
],
|
||||
});
|
||||
|
||||
const metrics = new PrometheusMetrics();
|
||||
|
||||
// In Resolvers
|
||||
async getProducts(limit: number) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const products = await dataService.getProducts(limit);
|
||||
metrics.recordQueryDuration('getProducts', Date.now() - start);
|
||||
metrics.incrementQueryCount('getProducts', 'success');
|
||||
return products;
|
||||
} catch (error) {
|
||||
metrics.incrementQueryCount('getProducts', 'error');
|
||||
logger.error('Failed to get products', { error, limit });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Empfehlungen:**
|
||||
- ✅ Structured Logging (Winston, Pino)
|
||||
- ✅ Metrics (Prometheus + Grafana)
|
||||
- ✅ Distributed Tracing (Jaeger, Zipkin)
|
||||
- ✅ APM (Application Performance Monitoring)
|
||||
- ✅ Error Tracking (Sentry, Rollbar)
|
||||
- ✅ Real-time Dashboards
|
||||
|
||||
### 6. **Security**
|
||||
|
||||
**Problem:**
|
||||
- Keine Authentication/Authorization
|
||||
- Keine Input Validation
|
||||
- Keine CORS-Konfiguration
|
||||
- Keine Rate Limiting
|
||||
|
||||
**Lösung:**
|
||||
```typescript
|
||||
// Security Middleware
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
import helmet from 'helmet';
|
||||
import { validate } from 'graphql-validate';
|
||||
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100, // Limit each IP to 100 requests per windowMs
|
||||
});
|
||||
|
||||
// GraphQL Input Validation
|
||||
const validateInput = (schema, input) => {
|
||||
const errors = validate(schema, input);
|
||||
if (errors.length > 0) {
|
||||
throw new ValidationError(errors);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Empfehlungen:**
|
||||
- ✅ Authentication (JWT, OAuth)
|
||||
- ✅ Authorization (Role-Based Access Control)
|
||||
- ✅ Input Validation (Zod, Yup)
|
||||
- ✅ Rate Limiting (pro Endpoint/User)
|
||||
- ✅ CORS-Konfiguration
|
||||
- ✅ SQL Injection Prevention (Parameterized Queries)
|
||||
- ✅ XSS Protection
|
||||
- ✅ CSRF Protection
|
||||
- ✅ Security Headers (Helmet.js)
|
||||
|
||||
### 7. **Database Optimierungen**
|
||||
|
||||
**Problem:**
|
||||
- Keine Indizes sichtbar
|
||||
- Keine Query-Optimierung
|
||||
- Keine Pagination für große Datensätze
|
||||
|
||||
**Lösung:**
|
||||
```typescript
|
||||
// Optimierte Queries mit Pagination
|
||||
async getProducts(limit: number, offset: number, filters?: ProductFilters) {
|
||||
// Indexed Query
|
||||
const query = `
|
||||
SELECT * FROM products
|
||||
WHERE category = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`;
|
||||
|
||||
// Mit Indizes:
|
||||
// CREATE INDEX idx_products_category ON products(category);
|
||||
// CREATE INDEX idx_products_created_at ON products(created_at);
|
||||
}
|
||||
```
|
||||
|
||||
**Empfehlungen:**
|
||||
- ✅ Database Indizes für häufige Queries
|
||||
- ✅ Pagination (Cursor-based für große Datensätze)
|
||||
- ✅ Query Optimization (EXPLAIN ANALYZE)
|
||||
- ✅ Database Sharding für sehr große Datenmengen
|
||||
- ✅ Read Replicas für Read-Heavy Workloads
|
||||
- ✅ Materialized Views für komplexe Aggregationen
|
||||
|
||||
### 8. **Error Handling & Resilience**
|
||||
|
||||
**Problem:**
|
||||
- Keine Retry-Logik
|
||||
- Keine Circuit Breaker
|
||||
- Keine Fallback-Strategien
|
||||
|
||||
**Lösung:**
|
||||
```typescript
|
||||
// Circuit Breaker Pattern
|
||||
import { CircuitBreaker } from 'opossum';
|
||||
|
||||
const breaker = new CircuitBreaker(dataService.getProducts, {
|
||||
timeout: 3000,
|
||||
errorThresholdPercentage: 50,
|
||||
resetTimeout: 30000,
|
||||
});
|
||||
|
||||
// Retry mit Exponential Backoff
|
||||
async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
maxRetries = 3
|
||||
): Promise<T> {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
if (i === maxRetries - 1) throw error;
|
||||
await sleep(2 ** i * 1000); // Exponential backoff
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Empfehlungen:**
|
||||
- ✅ Circuit Breaker Pattern
|
||||
- ✅ Retry mit Exponential Backoff
|
||||
- ✅ Fallback zu Cache bei DB-Fehlern
|
||||
- ✅ Graceful Degradation
|
||||
- ✅ Bulkhead Pattern (Isolation von Ressourcen)
|
||||
|
||||
### 9. **API Versioning & Backward Compatibility**
|
||||
|
||||
**Problem:**
|
||||
- Keine API-Versionierung
|
||||
- Breaking Changes könnten Frontend brechen
|
||||
|
||||
**Lösung:**
|
||||
```typescript
|
||||
// GraphQL Schema Versioning
|
||||
const typeDefsV1 = `...`;
|
||||
const typeDefsV2 = `...`;
|
||||
|
||||
const server = new ApolloServer({
|
||||
typeDefs: [typeDefsV1, typeDefsV2],
|
||||
resolvers: {
|
||||
Query: {
|
||||
productsV1: resolvers.products,
|
||||
productsV2: resolvers.productsV2,
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Empfehlungen:**
|
||||
- ✅ GraphQL Schema Versioning
|
||||
- ✅ Deprecation Warnings
|
||||
- ✅ Feature Flags für neue Features
|
||||
- ✅ Backward Compatibility Tests
|
||||
|
||||
### 10. **Deployment & CI/CD**
|
||||
|
||||
**Empfehlungen:**
|
||||
- ✅ Automated Testing (Unit, Integration, E2E)
|
||||
- ✅ CI/CD Pipeline (GitHub Actions, GitLab CI)
|
||||
- ✅ Blue-Green Deployments
|
||||
- ✅ Canary Releases
|
||||
- ✅ Database Migrations (automatisiert)
|
||||
- ✅ Rollback-Strategien
|
||||
|
||||
## Priorisierte Roadmap 🗺️
|
||||
|
||||
### Phase 1: Foundation (Woche 1-2)
|
||||
1. ✅ Redis Cache Integration
|
||||
2. ✅ Database Connection Pooling
|
||||
3. ✅ Structured Logging
|
||||
4. ✅ Basic Monitoring (Prometheus)
|
||||
|
||||
### Phase 2: Performance (Woche 3-4)
|
||||
1. ✅ Dataloader für N+1 Queries
|
||||
2. ✅ Query Complexity Limits
|
||||
3. ✅ Response Caching
|
||||
4. ✅ Database Indizes
|
||||
|
||||
### Phase 3: Resilience (Woche 5-6)
|
||||
1. ✅ Circuit Breaker
|
||||
2. ✅ Retry Logic
|
||||
3. ✅ Health Checks
|
||||
4. ✅ Rate Limiting
|
||||
|
||||
### Phase 4: Scale (Woche 7-8)
|
||||
1. ✅ Load Balancing
|
||||
2. ✅ Horizontal Scaling (Kubernetes)
|
||||
3. ✅ Read Replicas
|
||||
4. ✅ CDN Integration
|
||||
|
||||
### Phase 5: Advanced (Woche 9+)
|
||||
1. ✅ Distributed Tracing
|
||||
2. ✅ Advanced Monitoring
|
||||
3. ✅ Auto-Scaling
|
||||
4. ✅ Database Sharding (falls nötig)
|
||||
|
||||
## Fazit
|
||||
|
||||
Die aktuelle Architektur ist **gut strukturiert** und bietet eine **solide Basis**. Für einen **großen Onlineshop mit hohem Traffic** müssen jedoch folgende Bereiche priorisiert werden:
|
||||
|
||||
1. **Caching** (Redis) - Höchste Priorität
|
||||
2. **Database Optimierung** - Kritisch für Performance
|
||||
3. **Monitoring** - Essentiell für Operations
|
||||
4. **Horizontal Scaling** - Notwendig für Wachstum
|
||||
5. **Resilience Patterns** - Wichtig für Verfügbarkeit
|
||||
|
||||
Mit diesen Verbesserungen kann die Architektur **tausende von Requests pro Sekunde** handhaben.
|
||||
|
||||
133
middlelayer/IMPROVEMENTS.md
Normal file
133
middlelayer/IMPROVEMENTS.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Middlelayer Verbesserungsvorschläge
|
||||
|
||||
## 🔴 Kritisch / Wichtig
|
||||
|
||||
### 1. **Redundante `mapLayout` Methode entfernen**
|
||||
**Problem:** `mapLayout` gibt nur den Parameter zurück - komplett redundant
|
||||
**Lösung:** Direkt `layout` verwenden statt `this.mapLayout(layout)`
|
||||
|
||||
**Datei:** `mappers/pageMapper.ts:32-36`
|
||||
|
||||
### 2. **console.error durch logger ersetzen**
|
||||
**Problem:** Inkonsistente Logging - manche Stellen nutzen `console.error` statt `logger`
|
||||
**Lösung:** Alle `console.error/warn` durch `logger.error/warn` ersetzen
|
||||
|
||||
**Dateien:**
|
||||
- `resolvers.ts:16,19`
|
||||
- `adapters/config.ts:18`
|
||||
- `plugins/queryComplexity.ts:56`
|
||||
|
||||
### 3. **Error Handling in Resolvers vereinfachen**
|
||||
**Problem:** Wiederholende try-catch Blöcke in jedem Resolver
|
||||
**Lösung:** Wrapper-Funktion für Resolver erstellen
|
||||
|
||||
## 🟡 Wichtig / Code-Qualität
|
||||
|
||||
### 4. **PageMapper: Strategy Pattern statt if-Statements**
|
||||
**Problem:** 8 if-Statements in `mapContentItem` - schwer wartbar
|
||||
**Lösung:** Map-basiertes Strategy Pattern
|
||||
|
||||
```typescript
|
||||
private static contentMappers = new Map<ContentType, (entry: ContentEntry) => ContentItem>([
|
||||
[ContentType.html, this.mapHtml],
|
||||
[ContentType.markdown, this.mapMarkdown],
|
||||
// ...
|
||||
]);
|
||||
```
|
||||
|
||||
### 5. **DataService: Code-Duplikation reduzieren**
|
||||
**Problem:** Wiederholende Cache + Metrics Logik
|
||||
**Lösung:** Helper-Methode `withCacheAndMetrics` erstellen
|
||||
|
||||
### 6. **Cache Keys zentralisieren**
|
||||
**Problem:** Cache Keys werden überall manuell erstellt
|
||||
**Lösung:** `CacheKeyBuilder` Utility-Klasse
|
||||
|
||||
```typescript
|
||||
class CacheKeyBuilder {
|
||||
static page(slug: string, locale?: string) {
|
||||
return `page:${slug}:${locale || "default"}`;
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 7. **Type Safety verbessern**
|
||||
**Problem:** Einige `any` Types (z.B. `page` Loader)
|
||||
**Lösung:** ✅ Bereits behoben in `dataloaders.ts`
|
||||
|
||||
## 🟢 Nice-to-Have / Refactoring
|
||||
|
||||
### 8. **__cms Verzeichnis dokumentieren/archivieren**
|
||||
**Problem:** Alte Contentful-Typen mit `Contentful_` Präfix - werden nicht mehr verwendet
|
||||
**Lösung:**
|
||||
- README.md hinzufügen: "Legacy - nicht mehr verwendet"
|
||||
- Oder in `_legacy/` verschieben
|
||||
|
||||
### 9. **Resolver Wrapper für Error Handling**
|
||||
**Lösung:**
|
||||
```typescript
|
||||
function withErrorHandling<T>(
|
||||
resolver: () => Promise<T>
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await resolver();
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10. **DataService: Metrics-Tracking vereinheitlichen**
|
||||
**Problem:** Nur `getPage` und `getProducts` haben Metrics, andere nicht
|
||||
**Lösung:** Alle Methoden mit Metrics versehen oder Helper-Methode
|
||||
|
||||
### 11. **ContentEntry Union Type verbessern**
|
||||
**Problem:** Type Guards könnten besser sein
|
||||
**Lösung:** Type Guard Functions für jeden Content-Type
|
||||
|
||||
### 12. **Dokumentation erweitern**
|
||||
- JSDoc Kommentare für alle öffentlichen Methoden
|
||||
- Beispiele für Adapter-Implementierung
|
||||
- Performance-Best-Practices
|
||||
|
||||
## 📊 Priorisierung
|
||||
|
||||
1. **Sofort:** #1, #2 (Redundanz entfernen, Logging konsistent) ✅
|
||||
2. **Bald:** #3, #4 (Code-Qualität verbessern) ✅
|
||||
3. **Später:** #5, #6, #7 (Refactoring für Wartbarkeit) ✅
|
||||
4. **Optional:** #8-12 (Nice-to-Have)
|
||||
|
||||
## ✅ Umgesetzte Verbesserungen
|
||||
|
||||
### ✅ 1. Redundante `mapLayout` Methode entfernt
|
||||
- Entfernt und direkt `layout` verwendet
|
||||
|
||||
### ✅ 2. console.error durch logger ersetzt
|
||||
- Alle `console.error` durch `logger.error` ersetzt
|
||||
|
||||
### ✅ 3. Error Handling in Resolvers vereinfacht
|
||||
- `withErrorHandling` Wrapper erstellt
|
||||
- Alle Query- und Mutation-Resolvers verwenden den Wrapper
|
||||
|
||||
### ✅ 4. PageMapper: Strategy Pattern
|
||||
- Map-basiertes Strategy Pattern implementiert
|
||||
- 8 if-Statements durch wartbare Map ersetzt
|
||||
|
||||
### ✅ 5. DataService: Code-Duplikation reduziert
|
||||
- `DataServiceHelpers` Klasse erstellt
|
||||
- `withCacheAndMetrics` und `withCache` Methoden
|
||||
- Alle DataService-Methoden vereinfacht
|
||||
|
||||
### ✅ 6. Cache Keys zentralisiert
|
||||
- `CacheKeyBuilder` Utility-Klasse erstellt
|
||||
- Alle Cache-Keys an einem Ort
|
||||
|
||||
### ✅ 7. Type Safety verbessert
|
||||
- `ContentItem.__resolveType` verwendet jetzt `ContentItem` statt `any`
|
||||
- Map-basiertes Type-Resolution statt if-Statements
|
||||
|
||||
### ✅ 8. Mutation Resolver vereinfacht
|
||||
- `register` und `login` verwenden jetzt auch `withErrorHandling`
|
||||
- Konsistentes Error Handling überall
|
||||
|
||||
14
middlelayer/__cms/Contentful_Badges.ts
Normal file
14
middlelayer/__cms/Contentful_Badges.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { CF_ContentType } from "src/@types/Contentful_ContentType.enum";
|
||||
import type { CF_ComponentListSkeleton } from "src/@types/Contentful_List";
|
||||
|
||||
export interface CF_ComponentBadges {
|
||||
internal: string;
|
||||
badges: CF_ComponentListSkeleton;
|
||||
variants: "light" | "dark";
|
||||
layout?: any;
|
||||
}
|
||||
|
||||
export interface CF_ComponentBadgesSkeleton {
|
||||
contentTypeId: CF_ContentType.badges
|
||||
fields: CF_ComponentBadges
|
||||
}
|
||||
24
middlelayer/__cms/Contentful_Campaign.ts
Normal file
24
middlelayer/__cms/Contentful_Campaign.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { CF_ContentType } from "./Contentful_ContentType.enum";
|
||||
import type { Asset } from "contentful";
|
||||
|
||||
export interface CF_Campaign {
|
||||
campaignName: string;
|
||||
urlPatter: string;
|
||||
selector: string;
|
||||
insertHtml:
|
||||
| "afterbegin"
|
||||
| "beforeend"
|
||||
| "afterend"
|
||||
| "beforebegin"
|
||||
| "replace";
|
||||
timeUntil?: string;
|
||||
javascript?: string;
|
||||
medias?: Asset[];
|
||||
html?: string;
|
||||
css?: string;
|
||||
}
|
||||
|
||||
export interface CF_CampaignSkeleton {
|
||||
contentTypeId: CF_ContentType.campaign;
|
||||
fields: CF_Campaign;
|
||||
}
|
||||
13
middlelayer/__cms/Contentful_Campaigns.ts
Normal file
13
middlelayer/__cms/Contentful_Campaigns.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { CF_ContentType } from "./Contentful_ContentType.enum";
|
||||
import type { CF_CampaignSkeleton } from "./Contentful_Campaign";
|
||||
|
||||
export interface CF_Campaigns {
|
||||
id: string;
|
||||
campaings: CF_CampaignSkeleton[];
|
||||
enable: boolean;
|
||||
}
|
||||
|
||||
export interface CF_CampaignsSkeleton {
|
||||
contentTypeId: CF_ContentType.campaigns;
|
||||
fields: CF_Campaigns;
|
||||
}
|
||||
15
middlelayer/__cms/Contentful_CloudinaryImage.ts
Normal file
15
middlelayer/__cms/Contentful_CloudinaryImage.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export interface CF_CloudinaryImage {
|
||||
bytes: number;
|
||||
created_at: string;
|
||||
format: string;
|
||||
height: number;
|
||||
original_secure_url: string;
|
||||
original_url: string;
|
||||
public_id: string;
|
||||
resource_type: string;
|
||||
secure_url: string;
|
||||
type: string;
|
||||
url: string;
|
||||
version: number;
|
||||
width: number;
|
||||
}
|
||||
24
middlelayer/__cms/Contentful_Content.ts
Normal file
24
middlelayer/__cms/Contentful_Content.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { EntrySkeletonType } from "contentful";
|
||||
|
||||
export type rowJutify =
|
||||
| "start"
|
||||
| "end"
|
||||
| "center"
|
||||
| "between"
|
||||
| "around"
|
||||
| "evenly";
|
||||
export type rowAlignItems = "start" | "end" | "center" | "baseline" | "stretch";
|
||||
|
||||
export interface CF_Content {
|
||||
row1JustifyContent: rowJutify;
|
||||
row1AlignItems: rowAlignItems;
|
||||
row1Content: EntrySkeletonType<any>[];
|
||||
|
||||
row2JustifyContent: rowJutify;
|
||||
row2AlignItems: rowAlignItems;
|
||||
row2Content: EntrySkeletonType<any>[];
|
||||
|
||||
row3JustifyContent: rowJutify;
|
||||
row3AlignItems: rowAlignItems;
|
||||
row3Content: EntrySkeletonType<any>[];
|
||||
}
|
||||
31
middlelayer/__cms/Contentful_ContentType.enum.ts
Normal file
31
middlelayer/__cms/Contentful_ContentType.enum.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export enum CF_ContentType {
|
||||
"componentLinkList" = "componentLinkList",
|
||||
"badges" = "badges",
|
||||
"componentPostOverview" = "componentPostOverview",
|
||||
"footer" = "footer",
|
||||
"fullwidthBanner" = "fullwidthBanner",
|
||||
"headline" = "headline",
|
||||
"html" = "html",
|
||||
"image" = "image",
|
||||
"img" = "img",
|
||||
"iframe" = "iframe",
|
||||
"imgGallery" = "imageGallery",
|
||||
"internalReference" = "internalComponent",
|
||||
"link" = "link",
|
||||
"list" = "list",
|
||||
"markdown" = "markdown",
|
||||
"navigation" = "navigation",
|
||||
"page" = "page",
|
||||
"pageConfig" = "pageConfig",
|
||||
"picture" = "picture",
|
||||
"post" = "post",
|
||||
"postComponent" = "postComponent",
|
||||
"quote" = "quoteComponent",
|
||||
"richtext" = "richtext",
|
||||
"row" = "row",
|
||||
"rowLayout" = "rowLayout",
|
||||
"tag" = "tag",
|
||||
"youtubeVideo" = "youtubeVideo",
|
||||
"campaign" = "campaign",
|
||||
"campaigns" = "campaigns",
|
||||
}
|
||||
10
middlelayer/__cms/Contentful_Footer.ts
Normal file
10
middlelayer/__cms/Contentful_Footer.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { CF_ContentType } from "src/@types/Contentful_ContentType.enum";
|
||||
import type { CF_Content } from "./Contentful_Content";
|
||||
|
||||
export interface CF_Footer extends CF_Content {
|
||||
id : string;
|
||||
}
|
||||
export type CF_FooterSkeleton = {
|
||||
contentTypeId: CF_ContentType.footer
|
||||
fields: CF_Footer
|
||||
}
|
||||
24
middlelayer/__cms/Contentful_FullwidthBanner.ts
Normal file
24
middlelayer/__cms/Contentful_FullwidthBanner.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { CF_ComponentImgSkeleton } from "./Contentful_Img";
|
||||
import type { CF_CloudinaryImage } from "./Contentful_CloudinaryImage";
|
||||
import type { CF_ContentType } from "./Contentful_ContentType.enum";
|
||||
|
||||
|
||||
export enum CF_FullwidthBannerVariant {
|
||||
"dark"= "dark",
|
||||
"light" = "light"
|
||||
}
|
||||
|
||||
export interface CF_FullwidthBanner {
|
||||
name: string,
|
||||
variant : CF_FullwidthBannerVariant,
|
||||
headline : string,
|
||||
subheadline: string,
|
||||
text : string,
|
||||
image: CF_CloudinaryImage[];
|
||||
img: CF_ComponentImgSkeleton;
|
||||
}
|
||||
|
||||
export type CF_FullwidthBannerSkeleton = {
|
||||
contentTypeId: CF_ContentType.fullwidthBanner
|
||||
fields: CF_FullwidthBanner
|
||||
}
|
||||
29
middlelayer/__cms/Contentful_Grid.ts
Normal file
29
middlelayer/__cms/Contentful_Grid.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { EntrySkeletonType } from "contentful";
|
||||
import type { CF_ContentType } from "src/@types/Contentful_ContentType.enum";
|
||||
|
||||
export type CF_justfyContent = "start" | "end" | "center" | "between" | "around" | "evenly";
|
||||
export type CF_alignItems = "start" | "end" | "center" | "baseline" | "stretch"
|
||||
|
||||
export interface CF_Column_Alignment {
|
||||
justifyContent: CF_justfyContent,
|
||||
alignItems: CF_alignItems
|
||||
}
|
||||
|
||||
export interface CF_Column_Layout<T> {
|
||||
layoutMobile: T
|
||||
layoutTablet: T
|
||||
layoutDesktop: T
|
||||
}
|
||||
|
||||
export type CF_Row_1_Column_Layout = "auto" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10" | "11" | "12";
|
||||
|
||||
export interface CF_Row {
|
||||
alignment: EntrySkeletonType<CF_Column_Alignment>
|
||||
layout: EntrySkeletonType<CF_Column_Layout<CF_Row_1_Column_Layout>>
|
||||
content: EntrySkeletonType<any>[]
|
||||
}
|
||||
|
||||
export interface CF_RowSkeleton {
|
||||
contentTypeId: CF_ContentType.row
|
||||
fields: CF_Row
|
||||
}
|
||||
21
middlelayer/__cms/Contentful_Headline.ts
Normal file
21
middlelayer/__cms/Contentful_Headline.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { CF_ContentType } from "./Contentful_ContentType.enum.js";
|
||||
import type { CF_ComponentLayout } from "./Contentful_Layout.js";
|
||||
|
||||
export type CF_Component_Headline_Align = "left" | "center" | "right";
|
||||
|
||||
export type CF_Component_Headline_Tag = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
||||
|
||||
export type CF_alignTextClasses = "text-left" | "text-center" | "text-right";
|
||||
|
||||
export interface CF_ComponentHeadline {
|
||||
internal: string;
|
||||
text: string;
|
||||
tag: CF_Component_Headline_Tag;
|
||||
layout: CF_ComponentLayout;
|
||||
align?: CF_Component_Headline_Align;
|
||||
}
|
||||
|
||||
export interface CF_ComponentHeadlineSkeleton {
|
||||
contentTypeId: CF_ContentType.headline;
|
||||
fields: CF_ComponentHeadline;
|
||||
}
|
||||
13
middlelayer/__cms/Contentful_Html.ts
Normal file
13
middlelayer/__cms/Contentful_Html.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { CF_ContentType } from "./Contentful_ContentType.enum";
|
||||
import type { CF_ComponentLayout } from "./Contentful_Layout";
|
||||
|
||||
export interface CF_HTML {
|
||||
id: string;
|
||||
html: string;
|
||||
layout: CF_ComponentLayout;
|
||||
}
|
||||
|
||||
export type CF_HTMLSkeleton = {
|
||||
contentTypeId: CF_ContentType.html;
|
||||
fields: CF_HTML;
|
||||
};
|
||||
16
middlelayer/__cms/Contentful_Iframe.ts
Normal file
16
middlelayer/__cms/Contentful_Iframe.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { CF_ContentType } from "./Contentful_ContentType.enum";
|
||||
import type { CF_ComponentImgSkeleton } from "./Contentful_Img";
|
||||
import type { CF_ComponentLayout } from "./Contentful_Layout";
|
||||
|
||||
export interface CF_ComponentIframe {
|
||||
name: string;
|
||||
content: string;
|
||||
iframe: string;
|
||||
overlayImage?: CF_ComponentImgSkeleton;
|
||||
layout: CF_ComponentLayout;
|
||||
}
|
||||
|
||||
export interface CF_ComponentIframeSkeleton {
|
||||
contentTypeId: CF_ContentType.iframe;
|
||||
fields: CF_ComponentIframe;
|
||||
}
|
||||
17
middlelayer/__cms/Contentful_Image.ts
Normal file
17
middlelayer/__cms/Contentful_Image.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { CF_ContentType } from "./Contentful_ContentType.enum";
|
||||
import type { CF_ComponentImgSkeleton } from "./Contentful_Img";
|
||||
import type { CF_ComponentLayout } from "./Contentful_Layout";
|
||||
|
||||
export interface CF_ComponentImage {
|
||||
name: string;
|
||||
image: CF_ComponentImgSkeleton;
|
||||
caption: string;
|
||||
layout: CF_ComponentLayout;
|
||||
maxWidth?: number;
|
||||
aspectRatio?: number;
|
||||
}
|
||||
|
||||
export interface CF_ComponentImageSkeleton {
|
||||
contentTypeId: CF_ContentType.image;
|
||||
fields: CF_ComponentImage;
|
||||
}
|
||||
15
middlelayer/__cms/Contentful_ImageGallery.ts
Normal file
15
middlelayer/__cms/Contentful_ImageGallery.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { CF_ContentType } from "./Contentful_ContentType.enum.js";
|
||||
import type { CF_ComponentImgSkeleton } from "./Contentful_Img.js";
|
||||
import type { CF_ComponentLayout } from "./Contentful_Layout.js";
|
||||
|
||||
export interface CF_ImageGallery {
|
||||
name: string;
|
||||
images: CF_ComponentImgSkeleton[];
|
||||
layout: CF_ComponentLayout;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CF_ImageGallerySkeleton {
|
||||
contentTypeId: CF_ContentType.imgGallery;
|
||||
fields: CF_ImageGallery;
|
||||
}
|
||||
26
middlelayer/__cms/Contentful_Img.ts
Normal file
26
middlelayer/__cms/Contentful_Img.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { CF_ContentType } from "./Contentful_ContentType.enum";
|
||||
|
||||
|
||||
export interface CF_ComponentImgDetails {
|
||||
size: number,
|
||||
image: {
|
||||
width: number,
|
||||
height: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface CF_ComponentImg {
|
||||
title: string;
|
||||
description: string;
|
||||
file: {
|
||||
url: string;
|
||||
details: CF_ComponentImgDetails;
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CF_ComponentImgSkeleton {
|
||||
contentTypeId: CF_ContentType.img
|
||||
fields: CF_ComponentImg
|
||||
}
|
||||
14
middlelayer/__cms/Contentful_InternalReference.ts
Normal file
14
middlelayer/__cms/Contentful_InternalReference.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { CF_ContentType } from "src/@types/Contentful_ContentType.enum";
|
||||
import type { CF_ComponentLayout } from "src/@types/Contentful_Layout";
|
||||
import type { EntryFieldTypes } from "contentful";
|
||||
|
||||
export interface CF_internalReference {
|
||||
data: EntryFieldTypes.Object,
|
||||
reference: string,
|
||||
layout: CF_ComponentLayout
|
||||
}
|
||||
|
||||
export type CF_internalReferenceSkeleton = {
|
||||
contentTypeId: CF_ContentType.internalReference
|
||||
fields: CF_internalReference
|
||||
}
|
||||
100
middlelayer/__cms/Contentful_Layout.ts
Normal file
100
middlelayer/__cms/Contentful_Layout.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { CF_ContentType } from "src/@types/Contentful_ContentType.enum";
|
||||
|
||||
export type CF_Component_Layout_Width =
|
||||
| "1"
|
||||
| "2"
|
||||
| "3"
|
||||
| "4"
|
||||
| "5"
|
||||
| "6"
|
||||
| "7"
|
||||
| "8"
|
||||
| "9"
|
||||
| "10"
|
||||
| "11"
|
||||
| "12";
|
||||
|
||||
export type CF_widths_mobile =
|
||||
| "w-full"
|
||||
| "w-1/12"
|
||||
| "w-2/12"
|
||||
| "w-3/12"
|
||||
| "w-4/12"
|
||||
| "w-5/12"
|
||||
| "w-6/12"
|
||||
| "w-7/12"
|
||||
| "w-8/12"
|
||||
| "w-9/12"
|
||||
| "w-10/12"
|
||||
| "w-11/12";
|
||||
|
||||
export type CF_widths_tablet =
|
||||
| ""
|
||||
| "md:w-full"
|
||||
| "md:w-1/12"
|
||||
| "md:w-2/12"
|
||||
| "md:w-3/12"
|
||||
| "md:w-4/12"
|
||||
| "md:w-5/12"
|
||||
| "md:w-6/12"
|
||||
| "md:w-7/12"
|
||||
| "md:w-8/12"
|
||||
| "md:w-9/12"
|
||||
| "md:w-10/12"
|
||||
| "md:w-11/12";
|
||||
|
||||
export type CF_widths_desktop =
|
||||
| ""
|
||||
| "lg:w-full"
|
||||
| "lg:w-1/12"
|
||||
| "lg:w-2/12"
|
||||
| "lg:w-3/12"
|
||||
| "lg:w-4/12"
|
||||
| "lg:w-5/12"
|
||||
| "lg:w-6/12"
|
||||
| "lg:w-7/12"
|
||||
| "lg:w-8/12"
|
||||
| "lg:w-9/12"
|
||||
| "lg:w-10/12"
|
||||
| "lg:w-11/12";
|
||||
|
||||
export type CF_Component_Layout_Space =
|
||||
| 0
|
||||
| .5
|
||||
| 1
|
||||
| 1.5
|
||||
| 2
|
||||
|
||||
export type CF_Component_Space =
|
||||
| ""
|
||||
| "mb-[0.5rem]"
|
||||
| "mb-[1rem]"
|
||||
| "mb-[1.5rem]"
|
||||
| "mb-[2rem]"
|
||||
|
||||
export type CF_justfyContent =
|
||||
| "justify-start"
|
||||
| "justify-end"
|
||||
| "justify-center"
|
||||
| "justify-between"
|
||||
| "justify-around"
|
||||
| "justify-evenly";
|
||||
|
||||
export type CF_alignItems =
|
||||
| "items-start"
|
||||
| "items-end"
|
||||
| "items-center"
|
||||
| "items-baseline"
|
||||
| "items-stretch";
|
||||
|
||||
export interface CF_ComponentLayout {
|
||||
mobile: CF_Component_Layout_Width;
|
||||
tablet?: CF_Component_Layout_Width;
|
||||
desktop?: CF_Component_Layout_Width;
|
||||
spaceBottom?: CF_Component_Layout_Space
|
||||
}
|
||||
|
||||
export interface CF_ComponentLayoutSkeleton {
|
||||
contentTypeId: CF_ContentType.rowLayout
|
||||
fields: CF_ComponentLayout
|
||||
}
|
||||
23
middlelayer/__cms/Contentful_Link.ts
Normal file
23
middlelayer/__cms/Contentful_Link.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { CF_ContentType } from "./Contentful_ContentType.enum";
|
||||
|
||||
export interface CF_Link {
|
||||
name: string;
|
||||
internal: string;
|
||||
linkName: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
url: string;
|
||||
newTab?: boolean;
|
||||
external?: boolean;
|
||||
description?: string;
|
||||
alt?: string;
|
||||
showText?: boolean;
|
||||
author: string;
|
||||
date: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface CF_LinkSkeleton {
|
||||
contentTypeId: CF_ContentType.link;
|
||||
fields: CF_Link;
|
||||
}
|
||||
12
middlelayer/__cms/Contentful_Link_List.ts
Normal file
12
middlelayer/__cms/Contentful_Link_List.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { CF_ContentType } from "./Contentful_ContentType.enum";
|
||||
import type { CF_LinkSkeleton } from "./Contentful_Link";
|
||||
|
||||
export interface CF_Link_List {
|
||||
headline: string;
|
||||
links: CF_LinkSkeleton[];
|
||||
}
|
||||
|
||||
export type CF_LinkListSkeleton = {
|
||||
contentTypeId: CF_ContentType.componentLinkList;
|
||||
fields: CF_Link_List;
|
||||
};
|
||||
11
middlelayer/__cms/Contentful_List.ts
Normal file
11
middlelayer/__cms/Contentful_List.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { CF_ContentType } from "src/@types/Contentful_ContentType.enum";
|
||||
|
||||
export interface CF_ComponentList {
|
||||
internal: string;
|
||||
item: string[];
|
||||
}
|
||||
|
||||
export interface CF_ComponentListSkeleton {
|
||||
contentTypeId: CF_ContentType.list
|
||||
fields: CF_ComponentList
|
||||
}
|
||||
15
middlelayer/__cms/Contentful_Markdown.ts
Normal file
15
middlelayer/__cms/Contentful_Markdown.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { CF_ContentType } from "./Contentful_ContentType.enum";
|
||||
import type { CF_ComponentLayout } from "./Contentful_Layout";
|
||||
import type { TextAlignment } from "./TextAlignment";
|
||||
|
||||
export interface CF_Markdown {
|
||||
name: string;
|
||||
content: string;
|
||||
layout: CF_ComponentLayout;
|
||||
alignment: TextAlignment;
|
||||
}
|
||||
|
||||
export type CF_MarkdownSkeleton = {
|
||||
contentTypeId: CF_ContentType.markdown;
|
||||
fields: CF_Markdown;
|
||||
};
|
||||
17
middlelayer/__cms/Contentful_Names.enum.ts
Normal file
17
middlelayer/__cms/Contentful_Names.enum.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export enum CF_Navigation_Keys {
|
||||
"header" = "navigation-header",
|
||||
"socialMedia" = "navigation-social-media",
|
||||
"footer" = "navigation-footer",
|
||||
}
|
||||
|
||||
export enum CF_PageConfigKey {
|
||||
"pageConfig" = "page-config",
|
||||
}
|
||||
|
||||
export enum CF_Footer_Keys {
|
||||
"main" = "main",
|
||||
}
|
||||
|
||||
export enum CF_Campaigns_Keys {
|
||||
"campaigns" = "campaigns",
|
||||
}
|
||||
14
middlelayer/__cms/Contentful_Navigation.ts
Normal file
14
middlelayer/__cms/Contentful_Navigation.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { CF_Link } from "./Contentful_Link";
|
||||
import type { CF_ContentType } from "./Contentful_ContentType.enum";
|
||||
import type { CF_Page } from "./Contentful_Page";
|
||||
|
||||
export interface CF_Navigation {
|
||||
name: string;
|
||||
internal: string;
|
||||
links: Array<{ fields: CF_Link | CF_Page }>;
|
||||
}
|
||||
|
||||
export type CF_NavigationSkeleton = {
|
||||
contentTypeId: CF_ContentType.navigation;
|
||||
fields: CF_Navigation;
|
||||
};
|
||||
19
middlelayer/__cms/Contentful_Page.ts
Normal file
19
middlelayer/__cms/Contentful_Page.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { CF_ContentType } from "./Contentful_ContentType.enum";
|
||||
import type { CF_FullwidthBannerSkeleton } from "./Contentful_FullwidthBanner";
|
||||
import type { CF_Content } from "./Contentful_Content";
|
||||
import type { CF_SEO } from "./Contentful_SEO";
|
||||
|
||||
export interface CF_Page extends CF_Content, CF_SEO {
|
||||
slug: string;
|
||||
name: string;
|
||||
linkName: string;
|
||||
icon?: string;
|
||||
headline: string;
|
||||
subheadline: string;
|
||||
topFullwidthBanner: CF_FullwidthBannerSkeleton;
|
||||
}
|
||||
|
||||
export type CF_PageSkeleton = {
|
||||
contentTypeId: CF_ContentType.page;
|
||||
fields: CF_Page;
|
||||
};
|
||||
18
middlelayer/__cms/Contentful_PageConfig.ts
Normal file
18
middlelayer/__cms/Contentful_PageConfig.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { CF_ContentType } from "./Contentful_ContentType.enum";
|
||||
import type { CF_ComponentImgSkeleton } from "./Contentful_Img";
|
||||
|
||||
export interface CF_PageConfig {
|
||||
logo: CF_ComponentImgSkeleton;
|
||||
footerText1: string;
|
||||
seoTitle: string;
|
||||
seoDescription: string;
|
||||
blogTagPageHeadline: string;
|
||||
blogPostsPageHeadline: string;
|
||||
blogPostsPageSubHeadline: string;
|
||||
website: string;
|
||||
}
|
||||
|
||||
export type CF_PageConfigSkeleton = {
|
||||
contentTypeId: CF_ContentType.pageConfig;
|
||||
fields: CF_PageConfig;
|
||||
};
|
||||
7
middlelayer/__cms/Contentful_Page_Seo.ts
Normal file
7
middlelayer/__cms/Contentful_Page_Seo.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface CF_Page_Seo {
|
||||
name: "page-about-seo",
|
||||
title: "about",
|
||||
description: "about",
|
||||
metaRobotsIndex: "index",
|
||||
metaRobotsFollow: "follow"
|
||||
}
|
||||
57
middlelayer/__cms/Contentful_Picture.ts
Normal file
57
middlelayer/__cms/Contentful_Picture.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { EntryFieldTypes } from "contentful";
|
||||
import type { CF_ContentType } from "src/@types/Contentful_ContentType.enum";
|
||||
import type { CF_CloudinaryImage } from "src/@types/Contentful_CloudinaryImage";
|
||||
|
||||
|
||||
export type CF_PictureWidths = 400 | 800 | 1200 | 1400
|
||||
export type CF_PictureFormats = "aviv" | "jpg" | "png" | "webp"
|
||||
export type CF_PictureFit = "contain" | "cover" | "fill" | "inside" | "outside"
|
||||
export type CF_PicturePosition = "top"
|
||||
| "right top"
|
||||
| "right"
|
||||
| "right bottom"
|
||||
| "bottom"
|
||||
| "left bottom"
|
||||
| "left"
|
||||
| "left top"
|
||||
| "north"
|
||||
| "northeast"
|
||||
| "east"
|
||||
| "southeast"
|
||||
| "south"
|
||||
| "southwest"
|
||||
| "west"
|
||||
| "northwest"
|
||||
| "center"
|
||||
| "centre"
|
||||
| "cover"
|
||||
| "entropy"
|
||||
| "attention"
|
||||
export type CF_PictureAspectRatio = 'original'
|
||||
| '32:9'
|
||||
| '16:9'
|
||||
| '5:4'
|
||||
| '4:3'
|
||||
| '3:2'
|
||||
| '1:1'
|
||||
| '2:3'
|
||||
| '3:4'
|
||||
| '4:5'
|
||||
|
||||
|
||||
export interface CF_Picture {
|
||||
name: EntryFieldTypes.Text;
|
||||
image: CF_CloudinaryImage[];
|
||||
alt?: EntryFieldTypes.Text;
|
||||
widths: Array<CF_PictureWidths>;
|
||||
aspectRatio: CF_PictureAspectRatio;
|
||||
formats: CF_PictureFormats;
|
||||
fit: CF_PictureFit;
|
||||
position: CF_PicturePosition;
|
||||
layout?: any;
|
||||
}
|
||||
|
||||
export type CF_PictureSkeleton = {
|
||||
contentTypeId: CF_ContentType.picture
|
||||
fields: CF_Picture
|
||||
}
|
||||
25
middlelayer/__cms/Contentful_Post.ts
Normal file
25
middlelayer/__cms/Contentful_Post.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { CF_ContentType } from "src/@types/Contentful_ContentType.enum";
|
||||
import type { CF_ComponentImgSkeleton } from "./Contentful_Img";
|
||||
import type { CF_Content } from "./Contentful_Content";
|
||||
import type { CF_SEO } from "./Contentful_SEO";
|
||||
import type { CF_TagSkeleton } from "./Contentful_Tag";
|
||||
|
||||
export interface CF_Post extends CF_Content, CF_SEO {
|
||||
postImage: CF_ComponentImgSkeleton;
|
||||
postTag: CF_TagSkeleton[];
|
||||
slug: string;
|
||||
linkName: string;
|
||||
icon?: string;
|
||||
headline: string;
|
||||
important: boolean;
|
||||
created: string;
|
||||
date?: string;
|
||||
subheadline: string;
|
||||
excerpt: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export type CF_PostEntrySkeleton = {
|
||||
contentTypeId: CF_ContentType.post;
|
||||
fields: CF_Post;
|
||||
};
|
||||
14
middlelayer/__cms/Contentful_Post_Component.ts
Normal file
14
middlelayer/__cms/Contentful_Post_Component.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { CF_ContentType } from "./Contentful_ContentType.enum";
|
||||
import type { CF_ComponentLayout } from "./Contentful_Layout";
|
||||
import type { CF_PostEntrySkeleton } from "./Contentful_Post";
|
||||
|
||||
export interface CF_PostComponent {
|
||||
id: string;
|
||||
post: CF_PostEntrySkeleton;
|
||||
layout: CF_ComponentLayout;
|
||||
}
|
||||
|
||||
export interface CF_PostComponentSkeleton {
|
||||
contentTypeId: CF_ContentType.postComponent;
|
||||
fields: CF_PostComponent;
|
||||
}
|
||||
24
middlelayer/__cms/Contentful_Post_Overview.ts
Normal file
24
middlelayer/__cms/Contentful_Post_Overview.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { CF_ContentType } from "src/@types/Contentful_ContentType.enum";
|
||||
import type { CF_Content } from "./Contentful_Content";
|
||||
import type { CF_SEO } from "src/@types/Contentful_SEO";
|
||||
import type { CF_ComponentLayout } from "src/@types/Contentful_Layout";
|
||||
import type { CF_PostEntrySkeleton } from "src/@types/Contentful_Post";
|
||||
import type { CF_TagSkeleton } from "src/@types/Contentful_Tag";
|
||||
import type { Document } from "@contentful/rich-text-types";
|
||||
|
||||
export interface CF_Post_Overview extends CF_Content, CF_SEO {
|
||||
id: string;
|
||||
headline: string;
|
||||
text: Document;
|
||||
layout: CF_ComponentLayout;
|
||||
allPosts: boolean;
|
||||
filterByTag: CF_TagSkeleton[];
|
||||
posts: CF_PostEntrySkeleton[];
|
||||
numberItems: number;
|
||||
design?: "cards" | "list";
|
||||
}
|
||||
|
||||
export type CF_Post_OverviewEntrySkeleton = {
|
||||
contentTypeId: CF_ContentType.post;
|
||||
fields: CF_Post_Overview;
|
||||
};
|
||||
14
middlelayer/__cms/Contentful_Quote.ts
Normal file
14
middlelayer/__cms/Contentful_Quote.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { CF_ContentType } from "./Contentful_ContentType.enum.js";
|
||||
import type { CF_ComponentLayout } from "./Contentful_Layout.js";
|
||||
|
||||
export interface CF_Quote {
|
||||
quote: string;
|
||||
author: string;
|
||||
variant: "left" | "right";
|
||||
layout: CF_ComponentLayout;
|
||||
}
|
||||
|
||||
export type CF_QuoteSkeleton = {
|
||||
contentTypeId: CF_ContentType.quote;
|
||||
fields: CF_Quote;
|
||||
};
|
||||
11
middlelayer/__cms/Contentful_Richtext.ts
Normal file
11
middlelayer/__cms/Contentful_Richtext.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { CF_ContentType } from "src/@types/Contentful_ContentType.enum";
|
||||
|
||||
export interface CF_ComponentRichtext {
|
||||
content?: Document;
|
||||
layout?: any;
|
||||
}
|
||||
|
||||
export interface CF_ComponentRichtextSkeleton {
|
||||
contentTypeId: CF_ContentType.richtext
|
||||
fields: CF_ComponentRichtext
|
||||
}
|
||||
8
middlelayer/__cms/Contentful_SEO.ts
Normal file
8
middlelayer/__cms/Contentful_SEO.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
export type metaRobots = "index, follow" | "noindex, follow" | "index, nofollow" | "noindex, nofollow";
|
||||
|
||||
export interface CF_SEO {
|
||||
seoTitle : string,
|
||||
seoMetaRobots : metaRobots,
|
||||
seoDescription : string,
|
||||
}
|
||||
6
middlelayer/__cms/Contentful_SkeletonTypes.ts
Normal file
6
middlelayer/__cms/Contentful_SkeletonTypes.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { CF_PostEntrySkeleton } from "src/@types/Contentful_Post";
|
||||
import type { CF_NavigationSkeleton } from "src/@types/Contentful_Navigation";
|
||||
import type { CF_PageSkeleton } from "src/@types/Contentful_Page";
|
||||
import type { CF_PageConfigSkeleton } from "src/@types/Contentful_PageConfig";
|
||||
|
||||
export type CF_SkeletonTypes = CF_PostEntrySkeleton | CF_NavigationSkeleton | CF_PageSkeleton | CF_PageConfigSkeleton;
|
||||
11
middlelayer/__cms/Contentful_Tag.ts
Normal file
11
middlelayer/__cms/Contentful_Tag.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { CF_ContentType } from "./Contentful_ContentType.enum";
|
||||
|
||||
export interface CF_Tag {
|
||||
name: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface CF_TagSkeleton {
|
||||
contentTypeId: CF_ContentType.tag;
|
||||
fields: CF_Tag;
|
||||
}
|
||||
16
middlelayer/__cms/Contentful_YoutubeVideo.ts
Normal file
16
middlelayer/__cms/Contentful_YoutubeVideo.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { CF_ContentType } from "./Contentful_ContentType.enum";
|
||||
import type { CF_ComponentLayout } from "./Contentful_Layout";
|
||||
|
||||
export interface CF_YoutubeVideo {
|
||||
id: string;
|
||||
youtubeId: string;
|
||||
params?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
layout: CF_ComponentLayout;
|
||||
}
|
||||
|
||||
export interface CF_ComponentYoutubeVideoSkeleton {
|
||||
contentTypeId: CF_ContentType.youtubeVideo;
|
||||
fields: CF_YoutubeVideo;
|
||||
}
|
||||
72
middlelayer/__cms/SeoProps.ts
Normal file
72
middlelayer/__cms/SeoProps.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export type TwitterCardType = "summary" | "summary_large_image" | "app" | "player";
|
||||
|
||||
export interface Link extends HTMLLinkElement {
|
||||
prefetch: boolean;
|
||||
crossorigin: string;
|
||||
}
|
||||
|
||||
export interface Meta extends HTMLMetaElement {
|
||||
property: string;
|
||||
}
|
||||
|
||||
export interface SeoProperties {
|
||||
title?: string;
|
||||
titleTemplate?: string;
|
||||
titleDefault?: string;
|
||||
charset?: string;
|
||||
description?: string;
|
||||
canonical?: URL | string;
|
||||
nofollow?: boolean;
|
||||
noindex?: boolean;
|
||||
languageAlternates?: {
|
||||
href: URL | string;
|
||||
hrefLang: string;
|
||||
}[];
|
||||
openGraph?: {
|
||||
basic: {
|
||||
title: string;
|
||||
type: string;
|
||||
image: string;
|
||||
url?: URL | string;
|
||||
};
|
||||
optional?: {
|
||||
audio?: string;
|
||||
description?: string;
|
||||
determiner?: string;
|
||||
locale?: string;
|
||||
localeAlternate?: string[];
|
||||
siteName?: string;
|
||||
video?: string;
|
||||
};
|
||||
image?: {
|
||||
url?: URL | string;
|
||||
secureUrl?: URL | string;
|
||||
type?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
alt?: string;
|
||||
};
|
||||
article?: {
|
||||
publishedTime?: string;
|
||||
modifiedTime?: string;
|
||||
expirationTime?: string;
|
||||
authors?: string[];
|
||||
section?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
};
|
||||
twitter?: {
|
||||
card?: TwitterCardType;
|
||||
site?: string;
|
||||
creator?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: URL | string;
|
||||
imageAlt?: string;
|
||||
};
|
||||
extend?: {
|
||||
link?: Partial<Link>[];
|
||||
meta?: Partial<Meta>[];
|
||||
};
|
||||
surpressWarnings?: boolean;
|
||||
}
|
||||
6
middlelayer/__cms/TextAlignment.ts
Normal file
6
middlelayer/__cms/TextAlignment.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type TextAlignment = 'left' | 'center' | 'right';
|
||||
export enum TextAlignmentClasses {
|
||||
'left' = 'text-left',
|
||||
'center' = 'text-center',
|
||||
'right' = 'text-right'
|
||||
}
|
||||
37
middlelayer/adapters/Mock/_cms/mockNavigation.ts
Normal file
37
middlelayer/adapters/Mock/_cms/mockNavigation.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Navigation } from "../../../types/cms/Navigation";
|
||||
import type { Link } from "../../../types/cms/Link";
|
||||
import type { Page } from "../../../types/cms/Page";
|
||||
import { generateMockPages } from "./mockPage";
|
||||
|
||||
/**
|
||||
* Generiert Mock-Navigation mit locale-spezifischen Inhalten
|
||||
* @param locale - Die gewünschte Locale ("de" oder "en")
|
||||
*/
|
||||
export function generateMockNavigation(locale: string = "de"): Navigation {
|
||||
const isEn = locale === "en";
|
||||
const pages = generateMockPages(locale);
|
||||
|
||||
// Erstelle Links basierend auf den Pages
|
||||
const links: Array<{ fields: Link | Page }> = [
|
||||
{
|
||||
fields: pages["/"] as Page,
|
||||
},
|
||||
{
|
||||
fields: pages["/about"] as Page,
|
||||
},
|
||||
{
|
||||
fields: {
|
||||
name: isEn ? "Products" : "Produkte",
|
||||
internal: "products",
|
||||
linkName: isEn ? "Products" : "Produkte",
|
||||
url: "/products",
|
||||
} as Link,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
name: isEn ? "Main Navigation" : "Hauptnavigation",
|
||||
internal: "main-nav",
|
||||
links: links as any,
|
||||
};
|
||||
}
|
||||
871
middlelayer/adapters/Mock/_cms/mockPage.ts
Normal file
871
middlelayer/adapters/Mock/_cms/mockPage.ts
Normal file
@@ -0,0 +1,871 @@
|
||||
import type { Page } from "../../../types/cms/Page";
|
||||
import type { HTMLSkeleton } from "../../../types/cms/Html";
|
||||
import type { MarkdownSkeleton } from "../../../types/cms/Markdown";
|
||||
import type { ComponentIframeSkeleton } from "../../../types/cms/Iframe";
|
||||
import type { ImageGallerySkeleton } from "../../../types/cms/ImageGallery";
|
||||
import type { ComponentImageSkeleton } from "../../../types/cms/Image";
|
||||
import type { QuoteSkeleton } from "../../../types/cms/Quote";
|
||||
import type { ComponentYoutubeVideoSkeleton } from "../../../types/cms/YoutubeVideo";
|
||||
import type { ComponentHeadlineSkeleton } from "../../../types/cms/Headline";
|
||||
import type { ComponentImgSkeleton } from "../../../types/cms/Img";
|
||||
import { ContentType } from "../../../types/cms/ContentType.enum";
|
||||
import { FullwidthBannerVariant } from "../../../types/cms/FullwidthBanner";
|
||||
|
||||
/**
|
||||
* Erstellt eine Mock HTML-Komponente
|
||||
*/
|
||||
function createMockHTML(
|
||||
id: string,
|
||||
html: string,
|
||||
mobile: string = "12",
|
||||
tablet?: string,
|
||||
desktop?: string,
|
||||
spaceBottom?: number
|
||||
): HTMLSkeleton {
|
||||
return {
|
||||
contentTypeId: ContentType.html,
|
||||
fields: {
|
||||
id,
|
||||
html,
|
||||
layout: {
|
||||
mobile: mobile as any,
|
||||
tablet: tablet as any,
|
||||
desktop: desktop as any,
|
||||
spaceBottom: spaceBottom as any,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine Mock Markdown-Komponente
|
||||
*/
|
||||
function createMockMarkdown(
|
||||
name: string,
|
||||
content: string,
|
||||
alignment: "left" | "center" | "right" = "left",
|
||||
mobile: string = "12",
|
||||
tablet?: string,
|
||||
desktop?: string,
|
||||
spaceBottom?: number
|
||||
): MarkdownSkeleton {
|
||||
return {
|
||||
contentTypeId: ContentType.markdown,
|
||||
fields: {
|
||||
name,
|
||||
content,
|
||||
alignment,
|
||||
layout: {
|
||||
mobile: mobile as any,
|
||||
tablet: tablet as any,
|
||||
desktop: desktop as any,
|
||||
spaceBottom: spaceBottom as any,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine Mock-Image-Komponente
|
||||
*/
|
||||
function createMockImage(
|
||||
title: string,
|
||||
description: string,
|
||||
url: string
|
||||
): ComponentImgSkeleton {
|
||||
return {
|
||||
contentTypeId: ContentType.img,
|
||||
fields: {
|
||||
title,
|
||||
description,
|
||||
file: {
|
||||
url,
|
||||
details: {
|
||||
size: 100000,
|
||||
image: {
|
||||
width: 800,
|
||||
height: 600,
|
||||
},
|
||||
},
|
||||
fileName: `${title.toLowerCase().replace(/\s+/g, "-")}.jpg`,
|
||||
contentType: "image/jpeg",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine Mock Iframe-Komponente
|
||||
*/
|
||||
function createMockIframe(
|
||||
name: string,
|
||||
content: string,
|
||||
iframe: string,
|
||||
overlayImageUrl?: string,
|
||||
mobile: string = "12",
|
||||
tablet?: string,
|
||||
desktop?: string,
|
||||
spaceBottom?: number
|
||||
): ComponentIframeSkeleton {
|
||||
return {
|
||||
contentTypeId: ContentType.iframe,
|
||||
fields: {
|
||||
name,
|
||||
content,
|
||||
iframe,
|
||||
overlayImage: overlayImageUrl
|
||||
? createMockImage(name, "Overlay Image", overlayImageUrl)
|
||||
: undefined,
|
||||
layout: {
|
||||
mobile: mobile as any,
|
||||
tablet: tablet as any,
|
||||
desktop: desktop as any,
|
||||
spaceBottom: spaceBottom as any,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine Mock ImageGallery-Komponente
|
||||
*/
|
||||
function createMockImageGallery(
|
||||
name: string,
|
||||
images: Array<{ title: string; description?: string; url: string }>,
|
||||
description?: string,
|
||||
mobile: string = "12",
|
||||
tablet?: string,
|
||||
desktop?: string,
|
||||
spaceBottom?: number
|
||||
): ImageGallerySkeleton {
|
||||
return {
|
||||
contentTypeId: ContentType.imgGallery,
|
||||
fields: {
|
||||
name,
|
||||
images: images.map((img) =>
|
||||
createMockImage(img.title, img.description || "", img.url)
|
||||
),
|
||||
description,
|
||||
layout: {
|
||||
mobile: mobile as any,
|
||||
tablet: tablet as any,
|
||||
desktop: desktop as any,
|
||||
spaceBottom: spaceBottom as any,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine Mock Image-Komponente
|
||||
*/
|
||||
function createMockImageComponent(
|
||||
name: string,
|
||||
imageUrl: string,
|
||||
caption: string,
|
||||
maxWidth?: number,
|
||||
aspectRatio?: number,
|
||||
mobile: string = "12",
|
||||
tablet?: string,
|
||||
desktop?: string,
|
||||
spaceBottom?: number
|
||||
): ComponentImageSkeleton {
|
||||
return {
|
||||
contentTypeId: ContentType.image,
|
||||
fields: {
|
||||
name,
|
||||
image: createMockImage(name, caption, imageUrl),
|
||||
caption,
|
||||
maxWidth,
|
||||
aspectRatio,
|
||||
layout: {
|
||||
mobile: mobile as any,
|
||||
tablet: tablet as any,
|
||||
desktop: desktop as any,
|
||||
spaceBottom: spaceBottom as any,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine Mock Quote-Komponente
|
||||
*/
|
||||
function createMockQuote(
|
||||
quote: string,
|
||||
author: string,
|
||||
variant: "left" | "right" = "left",
|
||||
mobile: string = "12",
|
||||
tablet?: string,
|
||||
desktop?: string,
|
||||
spaceBottom?: number
|
||||
): QuoteSkeleton {
|
||||
return {
|
||||
contentTypeId: ContentType.quote,
|
||||
fields: {
|
||||
quote,
|
||||
author,
|
||||
variant,
|
||||
layout: {
|
||||
mobile: mobile as any,
|
||||
tablet: tablet as any,
|
||||
desktop: desktop as any,
|
||||
spaceBottom: spaceBottom as any,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine Mock YouTube-Video-Komponente
|
||||
*/
|
||||
function createMockYoutubeVideo(
|
||||
id: string,
|
||||
youtubeId: string,
|
||||
params?: string,
|
||||
title?: string,
|
||||
description?: string,
|
||||
mobile: string = "12",
|
||||
tablet?: string,
|
||||
desktop?: string,
|
||||
spaceBottom?: number
|
||||
): ComponentYoutubeVideoSkeleton {
|
||||
return {
|
||||
contentTypeId: ContentType.youtubeVideo,
|
||||
fields: {
|
||||
id,
|
||||
youtubeId,
|
||||
params,
|
||||
title,
|
||||
description,
|
||||
layout: {
|
||||
mobile: mobile as any,
|
||||
tablet: tablet as any,
|
||||
desktop: desktop as any,
|
||||
spaceBottom: spaceBottom as any,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine Mock Headline-Komponente
|
||||
*/
|
||||
function createMockHeadline(
|
||||
internal: string,
|
||||
text: string,
|
||||
tag: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" = "h2",
|
||||
align?: "left" | "center" | "right",
|
||||
mobile: string = "12",
|
||||
tablet?: string,
|
||||
desktop?: string,
|
||||
spaceBottom?: number
|
||||
): ComponentHeadlineSkeleton {
|
||||
return {
|
||||
contentTypeId: ContentType.headline,
|
||||
fields: {
|
||||
internal,
|
||||
text,
|
||||
tag,
|
||||
align,
|
||||
layout: {
|
||||
mobile: mobile as any,
|
||||
tablet: tablet as any,
|
||||
desktop: desktop as any,
|
||||
spaceBottom: spaceBottom as any,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert Mock-Seiten mit locale-spezifischen Inhalten
|
||||
* @param locale - Die gewünschte Locale ("de" oder "en")
|
||||
* @returns Record von Seiten mit locale-spezifischen Inhalten
|
||||
*/
|
||||
export function generateMockPages(locale: string = "de"): Record<string, Page> {
|
||||
const isEn = locale === "en";
|
||||
|
||||
return {
|
||||
"/": {
|
||||
slug: "/",
|
||||
name: isEn ? "Home" : "Home",
|
||||
linkName: isEn ? "Home" : "Startseite",
|
||||
headline: isEn
|
||||
? "Welcome to our website"
|
||||
: "Willkommen auf unserer Website",
|
||||
subheadline: isEn
|
||||
? "Discover our products and services"
|
||||
: "Entdecken Sie unsere Produkte und Dienstleistungen",
|
||||
seoTitle: isEn ? "Home - Welcome" : "Home - Willkommen",
|
||||
seoMetaRobots: "index, follow",
|
||||
seoDescription: isEn
|
||||
? "Welcome to our website. Discover our products and services."
|
||||
: "Willkommen auf unserer Website. Entdecken Sie unsere Produkte und Dienstleistungen.",
|
||||
row1JustifyContent: "center",
|
||||
row1AlignItems: "center",
|
||||
row1Content: [
|
||||
createMockMarkdown(
|
||||
"welcome-intro",
|
||||
isEn
|
||||
? "# Welcome\n\nThis is a **markdown** component showcasing our content system.\n\n- Feature 1\n- Feature 2\n- Feature 3"
|
||||
: "# Willkommen\n\nDies ist eine **Markdown**-Komponente, die unser Content-System zeigt.\n\n- Funktion 1\n- Funktion 2\n- Funktion 3",
|
||||
"center",
|
||||
"12",
|
||||
"10",
|
||||
"8",
|
||||
2
|
||||
),
|
||||
],
|
||||
row2JustifyContent: "start",
|
||||
row2AlignItems: "start",
|
||||
row2Content: [
|
||||
createMockHTML(
|
||||
"intro-html",
|
||||
isEn
|
||||
? '<div class="p-4 bg-blue-50 rounded-lg"><h2 class="text-2xl font-bold mb-2">HTML Content</h2><p>This is an HTML component with custom styling.</p></div>'
|
||||
: '<div class="p-4 bg-blue-50 rounded-lg"><h2 class="text-2xl font-bold mb-2">HTML Inhalt</h2><p>Dies ist eine HTML-Komponente mit benutzerdefiniertem Styling.</p></div>',
|
||||
"12",
|
||||
"6",
|
||||
"6",
|
||||
1.5
|
||||
),
|
||||
createMockMarkdown(
|
||||
"features",
|
||||
isEn
|
||||
? "## Features\n\n- Fast and reliable\n- Modern technology\n- Great support"
|
||||
: "## Funktionen\n\n- Schnell und zuverlässig\n- Moderne Technologie\n- Großer Support",
|
||||
"left",
|
||||
"12",
|
||||
"6",
|
||||
"6",
|
||||
1.5
|
||||
),
|
||||
],
|
||||
row3JustifyContent: "start",
|
||||
row3AlignItems: "start",
|
||||
row3Content: [
|
||||
createMockMarkdown(
|
||||
"footer-note",
|
||||
isEn
|
||||
? "---\n\n*Thank you for visiting our website!*"
|
||||
: "---\n\n*Vielen Dank für Ihren Besuch auf unserer Website!*",
|
||||
"center",
|
||||
"12",
|
||||
"8",
|
||||
"6"
|
||||
),
|
||||
],
|
||||
topFullwidthBanner: {
|
||||
contentTypeId: ContentType.fullwidthBanner,
|
||||
fields: {
|
||||
name: "home-banner",
|
||||
variant: FullwidthBannerVariant.light,
|
||||
headline: isEn ? "Welcome" : "Herzlich Willkommen",
|
||||
subheadline: isEn
|
||||
? "Your solution for all needs"
|
||||
: "Ihre Lösung für alle Bedürfnisse",
|
||||
text: isEn
|
||||
? "Discover our diverse range of offerings and find exactly what you're looking for."
|
||||
: "Entdecken Sie unsere vielfältigen Angebote und finden Sie genau das, was Sie suchen.",
|
||||
image: [],
|
||||
img: {
|
||||
contentTypeId: ContentType.img,
|
||||
fields: {
|
||||
title: isEn ? "Home Banner" : "Home Banner",
|
||||
description: isEn
|
||||
? "Banner for the homepage"
|
||||
: "Banner für die Startseite",
|
||||
file: {
|
||||
url: "https://picsum.photos/1200/400?random=home",
|
||||
details: {
|
||||
size: 45678,
|
||||
image: {
|
||||
width: 1200,
|
||||
height: 400,
|
||||
},
|
||||
},
|
||||
fileName: "home-banner.jpg",
|
||||
contentType: "image/jpeg",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/about": {
|
||||
slug: "/about",
|
||||
name: isEn ? "About Us" : "Über uns",
|
||||
linkName: isEn ? "About Us" : "Über uns",
|
||||
headline: isEn ? "About Us" : "Über uns",
|
||||
subheadline: isEn
|
||||
? "Get to know us better"
|
||||
: "Lernen Sie uns besser kennen",
|
||||
seoTitle: isEn ? "About Us - Our Story" : "Über uns - Unsere Geschichte",
|
||||
seoMetaRobots: "index, follow",
|
||||
seoDescription: isEn
|
||||
? "Learn more about our company, our values and our mission."
|
||||
: "Erfahren Sie mehr über unsere Firma, unsere Werte und unsere Mission.",
|
||||
row1JustifyContent: "start",
|
||||
row1AlignItems: "start",
|
||||
row1Content: [
|
||||
createMockMarkdown(
|
||||
"about-intro",
|
||||
isEn
|
||||
? "# Our Story\n\nWe are a company dedicated to providing excellent service and innovative solutions."
|
||||
: "# Unsere Geschichte\n\nWir sind ein Unternehmen, das sich der Bereitstellung exzellenter Dienstleistungen und innovativer Lösungen widmet.",
|
||||
"left",
|
||||
"12",
|
||||
"8",
|
||||
"8"
|
||||
),
|
||||
],
|
||||
row2JustifyContent: "between",
|
||||
row2AlignItems: "start",
|
||||
row2Content: [
|
||||
createMockHTML(
|
||||
"mission",
|
||||
isEn
|
||||
? '<div class="p-6 border-l-4 border-blue-500"><h3 class="text-xl font-semibold mb-2">Our Mission</h3><p>To deliver exceptional value to our customers.</p></div>'
|
||||
: '<div class="p-6 border-l-4 border-blue-500"><h3 class="text-xl font-semibold mb-2">Unsere Mission</h3><p>Außergewöhnlichen Mehrwert für unsere Kunden zu schaffen.</p></div>',
|
||||
"12",
|
||||
"5",
|
||||
"5",
|
||||
1
|
||||
),
|
||||
createMockHTML(
|
||||
"vision",
|
||||
isEn
|
||||
? '<div class="p-6 border-l-4 border-green-500"><h3 class="text-xl font-semibold mb-2">Our Vision</h3><p>To be the leading provider in our industry.</p></div>'
|
||||
: '<div class="p-6 border-l-4 border-green-500"><h3 class="text-xl font-semibold mb-2">Unsere Vision</h3><p>Der führende Anbieter in unserer Branche zu sein.</p></div>',
|
||||
"12",
|
||||
"5",
|
||||
"5",
|
||||
1
|
||||
),
|
||||
],
|
||||
row3JustifyContent: "start",
|
||||
row3AlignItems: "start",
|
||||
row3Content: [
|
||||
createMockMarkdown(
|
||||
"values",
|
||||
isEn
|
||||
? "## Our Values\n\n1. **Integrity** - We do what we say\n2. **Innovation** - We embrace new ideas\n3. **Excellence** - We strive for the best"
|
||||
: "## Unsere Werte\n\n1. **Integrität** - Wir halten, was wir versprechen\n2. **Innovation** - Wir begrüßen neue Ideen\n3. **Exzellenz** - Wir streben nach dem Besten",
|
||||
"left",
|
||||
"12",
|
||||
"10",
|
||||
"8"
|
||||
),
|
||||
],
|
||||
topFullwidthBanner: {
|
||||
contentTypeId: ContentType.fullwidthBanner,
|
||||
fields: {
|
||||
name: "about-banner",
|
||||
variant: FullwidthBannerVariant.dark,
|
||||
headline: isEn ? "About Us" : "Über uns",
|
||||
subheadline: isEn
|
||||
? "Our Story and Values"
|
||||
: "Unsere Geschichte und Werte",
|
||||
text: isEn
|
||||
? "For many years, we have been your reliable partner for innovative solutions."
|
||||
: "Seit vielen Jahren sind wir Ihr zuverlässiger Partner für innovative Lösungen.",
|
||||
image: [],
|
||||
img: {
|
||||
contentTypeId: ContentType.img,
|
||||
fields: {
|
||||
title: isEn ? "About Banner" : "About Banner",
|
||||
description: isEn
|
||||
? "Banner for the about page"
|
||||
: "Banner für die Über-uns-Seite",
|
||||
file: {
|
||||
url: "https://picsum.photos/1200/400?random=about",
|
||||
details: {
|
||||
size: 45678,
|
||||
image: {
|
||||
width: 1200,
|
||||
height: 400,
|
||||
},
|
||||
},
|
||||
fileName: "about-banner.jpg",
|
||||
contentType: "image/jpeg",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/404": {
|
||||
slug: "/404",
|
||||
name: isEn ? "404" : "404",
|
||||
linkName: isEn ? "404" : "404",
|
||||
headline: isEn ? "404 - Page Not Found" : "404 - Seite nicht gefunden",
|
||||
subheadline: isEn
|
||||
? "The page you are looking for does not exist."
|
||||
: "Die gesuchte Seite existiert nicht.",
|
||||
seoTitle: isEn ? "Page Not Found" : "Seite nicht gefunden",
|
||||
seoMetaRobots: "noindex, follow",
|
||||
seoDescription: isEn
|
||||
? "The page you are looking for does not exist."
|
||||
: "Die gesuchte Seite existiert nicht.",
|
||||
row1JustifyContent: "center",
|
||||
row1AlignItems: "center",
|
||||
row1Content: [
|
||||
createMockMarkdown(
|
||||
"404-message",
|
||||
isEn
|
||||
? "# Page Not Found\n\nThe page you are looking for does not exist or has been moved.\n\nPlease check the URL or return to the [homepage](/)."
|
||||
: "# Seite nicht gefunden\n\nDie gesuchte Seite existiert nicht oder wurde verschoben.\n\nBitte überprüfen Sie die URL oder kehren Sie zur [Startseite](/) zurück.",
|
||||
"center",
|
||||
"12",
|
||||
"10",
|
||||
"8"
|
||||
),
|
||||
],
|
||||
row2JustifyContent: "start",
|
||||
row2AlignItems: "start",
|
||||
row2Content: [],
|
||||
row3JustifyContent: "start",
|
||||
row3AlignItems: "start",
|
||||
row3Content: [],
|
||||
topFullwidthBanner: {
|
||||
contentTypeId: ContentType.fullwidthBanner,
|
||||
fields: {
|
||||
name: "404-banner",
|
||||
variant: FullwidthBannerVariant.dark,
|
||||
headline: isEn ? "404" : "404",
|
||||
subheadline: isEn ? "Page Not Found" : "Seite nicht gefunden",
|
||||
text: isEn
|
||||
? "The page you are looking for does not exist."
|
||||
: "Die gesuchte Seite existiert nicht.",
|
||||
image: [],
|
||||
img: {
|
||||
contentTypeId: ContentType.img,
|
||||
fields: {
|
||||
title: isEn ? "404 Banner" : "404 Banner",
|
||||
description: isEn
|
||||
? "Banner for the 404 page"
|
||||
: "Banner für die 404-Seite",
|
||||
file: {
|
||||
url: "https://picsum.photos/1200/400?random=404",
|
||||
details: {
|
||||
size: 45678,
|
||||
image: {
|
||||
width: 1200,
|
||||
height: 400,
|
||||
},
|
||||
},
|
||||
fileName: "404-banner.jpg",
|
||||
contentType: "image/jpeg",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/500": {
|
||||
slug: "/500",
|
||||
name: isEn ? "500" : "500",
|
||||
linkName: isEn ? "500" : "500",
|
||||
headline: isEn ? "500 - Server Error" : "500 - Serverfehler",
|
||||
subheadline: isEn
|
||||
? "Something went wrong on our end. Please try again later."
|
||||
: "Etwas ist auf unserer Seite schiefgelaufen. Bitte versuchen Sie es später erneut.",
|
||||
seoTitle: isEn ? "Server Error" : "Serverfehler",
|
||||
seoMetaRobots: "noindex, follow",
|
||||
seoDescription: isEn
|
||||
? "Something went wrong on our end. Please try again later."
|
||||
: "Etwas ist auf unserer Seite schiefgelaufen. Bitte versuchen Sie es später erneut.",
|
||||
row1JustifyContent: "center",
|
||||
row1AlignItems: "center",
|
||||
row1Content: [
|
||||
createMockMarkdown(
|
||||
"500-message",
|
||||
isEn
|
||||
? "# Server Error\n\nWe're sorry, but something went wrong on our end.\n\nOur team has been notified and is working on fixing the issue. Please try again later or return to the [homepage](/)."
|
||||
: "# Serverfehler\n\nEs tut uns leid, aber etwas ist auf unserer Seite schiefgelaufen.\n\nUnser Team wurde benachrichtigt und arbeitet an der Behebung des Problems. Bitte versuchen Sie es später erneut oder kehren Sie zur [Startseite](/) zurück.",
|
||||
"center",
|
||||
"12",
|
||||
"10",
|
||||
"8"
|
||||
),
|
||||
],
|
||||
row2JustifyContent: "start",
|
||||
row2AlignItems: "start",
|
||||
row2Content: [],
|
||||
row3JustifyContent: "start",
|
||||
row3AlignItems: "start",
|
||||
row3Content: [],
|
||||
topFullwidthBanner: {
|
||||
contentTypeId: ContentType.fullwidthBanner,
|
||||
fields: {
|
||||
name: "500-banner",
|
||||
variant: FullwidthBannerVariant.dark,
|
||||
headline: isEn ? "500" : "500",
|
||||
subheadline: isEn ? "Server Error" : "Serverfehler",
|
||||
text: isEn
|
||||
? "Something went wrong on our end. Please try again later."
|
||||
: "Etwas ist auf unserer Seite schiefgelaufen. Bitte versuchen Sie es später erneut.",
|
||||
image: [],
|
||||
img: {
|
||||
contentTypeId: ContentType.img,
|
||||
fields: {
|
||||
title: isEn ? "500 Banner" : "500 Banner",
|
||||
description: isEn
|
||||
? "Banner for the 500 page"
|
||||
: "Banner für die 500-Seite",
|
||||
file: {
|
||||
url: "https://picsum.photos/1200/400?random=500",
|
||||
details: {
|
||||
size: 45678,
|
||||
image: {
|
||||
width: 1200,
|
||||
height: 400,
|
||||
},
|
||||
},
|
||||
fileName: "500-banner.jpg",
|
||||
contentType: "image/jpeg",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/components": {
|
||||
slug: "/components",
|
||||
name: isEn ? "Components" : "Komponenten",
|
||||
linkName: isEn ? "Components" : "Komponenten",
|
||||
headline: isEn ? "Component Showcase" : "Komponenten-Showcase",
|
||||
subheadline: isEn
|
||||
? "All available content components"
|
||||
: "Alle verfügbaren Content-Komponenten",
|
||||
seoTitle: isEn ? "Components - Showcase" : "Komponenten - Showcase",
|
||||
seoMetaRobots: "index, follow",
|
||||
seoDescription: isEn
|
||||
? "Showcase of all available content components."
|
||||
: "Showcase aller verfügbaren Content-Komponenten.",
|
||||
row1JustifyContent: "start",
|
||||
row1AlignItems: "start",
|
||||
row1Content: [
|
||||
createMockHeadline(
|
||||
"headline-h1",
|
||||
isEn ? "Component Showcase" : "Komponenten-Showcase",
|
||||
"h1",
|
||||
"center",
|
||||
"12"
|
||||
),
|
||||
createMockMarkdown(
|
||||
"intro",
|
||||
isEn
|
||||
? "This page demonstrates all available content components in our CMS system."
|
||||
: "Diese Seite demonstriert alle verfügbaren Content-Komponenten in unserem CMS-System.",
|
||||
"center",
|
||||
"12",
|
||||
"10",
|
||||
"8"
|
||||
),
|
||||
],
|
||||
row2JustifyContent: "start",
|
||||
row2AlignItems: "start",
|
||||
row2Content: [
|
||||
createMockHeadline(
|
||||
"headline-html",
|
||||
isEn ? "HTML Component" : "HTML-Komponente",
|
||||
"h2",
|
||||
"left",
|
||||
"12",
|
||||
"6",
|
||||
"6"
|
||||
),
|
||||
createMockHTML(
|
||||
"html-example",
|
||||
isEn
|
||||
? '<div class="p-4 bg-blue-50 rounded-lg border-l-4 border-blue-500"><h3 class="text-xl font-semibold mb-2">HTML Content</h3><p>This is an <strong>HTML</strong> component with custom styling.</p></div>'
|
||||
: '<div class="p-4 bg-blue-50 rounded-lg border-l-4 border-blue-500"><h3 class="text-xl font-semibold mb-2">HTML Inhalt</h3><p>Dies ist eine <strong>HTML</strong>-Komponente mit benutzerdefiniertem Styling.</p></div>',
|
||||
"12",
|
||||
"6",
|
||||
"6"
|
||||
),
|
||||
createMockHeadline(
|
||||
"headline-markdown",
|
||||
isEn ? "Markdown Component" : "Markdown-Komponente",
|
||||
"h2",
|
||||
"left",
|
||||
"12",
|
||||
"6",
|
||||
"6"
|
||||
),
|
||||
createMockMarkdown(
|
||||
"markdown-example",
|
||||
isEn
|
||||
? "## Markdown Content\n\nThis is a **Markdown** component with:\n\n- Lists\n- **Bold** text\n- *Italic* text\n- [Links](/)"
|
||||
: "## Markdown Inhalt\n\nDies ist eine **Markdown**-Komponente mit:\n\n- Listen\n- **Fettem** Text\n- *Kursivem* Text\n- [Links](/)",
|
||||
"left",
|
||||
"12",
|
||||
"6",
|
||||
"6"
|
||||
),
|
||||
],
|
||||
row3JustifyContent: "start",
|
||||
row3AlignItems: "start",
|
||||
row3Content: [
|
||||
createMockHeadline(
|
||||
"headline-image",
|
||||
isEn ? "Image Component" : "Bild-Komponente",
|
||||
"h2",
|
||||
"left",
|
||||
"12"
|
||||
),
|
||||
createMockImageComponent(
|
||||
"sample-image",
|
||||
"https://picsum.photos/800/600?random=1",
|
||||
isEn
|
||||
? "A sample image with caption"
|
||||
: "Ein Beispielbild mit Beschriftung",
|
||||
undefined,
|
||||
undefined,
|
||||
"12",
|
||||
"8",
|
||||
"6"
|
||||
),
|
||||
createMockHeadline(
|
||||
"headline-gallery",
|
||||
isEn ? "Image Gallery" : "Bildergalerie",
|
||||
"h2",
|
||||
"left",
|
||||
"12"
|
||||
),
|
||||
createMockImageGallery(
|
||||
"gallery-example",
|
||||
[
|
||||
{
|
||||
title: isEn ? "Image 1" : "Bild 1",
|
||||
description: isEn ? "First gallery image" : "Erstes Galeriebild",
|
||||
url: "https://picsum.photos/400/300?random=2",
|
||||
},
|
||||
{
|
||||
title: isEn ? "Image 2" : "Bild 2",
|
||||
description: isEn
|
||||
? "Second gallery image"
|
||||
: "Zweites Galeriebild",
|
||||
url: "https://picsum.photos/400/300?random=3",
|
||||
},
|
||||
{
|
||||
title: isEn ? "Image 3" : "Bild 3",
|
||||
description: isEn ? "Third gallery image" : "Drittes Galeriebild",
|
||||
url: "https://picsum.photos/400/300?random=4",
|
||||
},
|
||||
{
|
||||
title: isEn ? "Image 4" : "Bild 4",
|
||||
url: "https://picsum.photos/400/300?random=5",
|
||||
},
|
||||
],
|
||||
isEn
|
||||
? "A collection of sample images"
|
||||
: "Eine Sammlung von Beispielbildern",
|
||||
"12"
|
||||
),
|
||||
createMockHeadline(
|
||||
"headline-quote",
|
||||
isEn ? "Quote Component" : "Zitat-Komponente",
|
||||
"h2",
|
||||
"left",
|
||||
"12"
|
||||
),
|
||||
createMockQuote(
|
||||
isEn
|
||||
? "The only way to do great work is to love what you do."
|
||||
: "Die einzige Möglichkeit, großartige Arbeit zu leisten, ist, das zu lieben, was man tut.",
|
||||
isEn ? "Steve Jobs" : "Steve Jobs",
|
||||
"left",
|
||||
"12",
|
||||
"6",
|
||||
"6"
|
||||
),
|
||||
createMockQuote(
|
||||
isEn
|
||||
? "Innovation distinguishes between a leader and a follower."
|
||||
: "Innovation unterscheidet einen Führer von einem Anhänger.",
|
||||
isEn ? "Steve Jobs" : "Steve Jobs",
|
||||
"right",
|
||||
"12",
|
||||
"6",
|
||||
"6"
|
||||
),
|
||||
createMockHeadline(
|
||||
"headline-youtube",
|
||||
isEn ? "YouTube Video" : "YouTube-Video",
|
||||
"h2",
|
||||
"left",
|
||||
"12"
|
||||
),
|
||||
createMockYoutubeVideo(
|
||||
"youtube-1",
|
||||
"dQw4w9WgXcQ",
|
||||
"autoplay=0",
|
||||
isEn ? "Sample YouTube Video" : "Beispiel-YouTube-Video",
|
||||
isEn
|
||||
? "A sample YouTube video embedded in the page"
|
||||
: "Ein eingebettetes YouTube-Video auf der Seite",
|
||||
"12",
|
||||
"10",
|
||||
"8"
|
||||
),
|
||||
createMockHeadline(
|
||||
"headline-iframe",
|
||||
isEn ? "Iframe Component" : "Iframe-Komponente",
|
||||
"h2",
|
||||
"left",
|
||||
"12"
|
||||
),
|
||||
createMockIframe(
|
||||
"iframe-example",
|
||||
isEn
|
||||
? "<p>This is an iframe component with embedded content.</p>"
|
||||
: "<p>Dies ist eine Iframe-Komponente mit eingebettetem Inhalt.</p>",
|
||||
"https://example.com",
|
||||
"https://picsum.photos/800/400?random=6",
|
||||
"12",
|
||||
"10",
|
||||
"8"
|
||||
),
|
||||
],
|
||||
topFullwidthBanner: {
|
||||
contentTypeId: ContentType.fullwidthBanner,
|
||||
fields: {
|
||||
name: "components-banner",
|
||||
variant: FullwidthBannerVariant.light,
|
||||
headline: isEn ? "Component Showcase" : "Komponenten-Showcase",
|
||||
subheadline: isEn
|
||||
? "All available content components"
|
||||
: "Alle verfügbaren Content-Komponenten",
|
||||
text: isEn
|
||||
? "Explore all the different content components available in our CMS system."
|
||||
: "Entdecken Sie alle verschiedenen Content-Komponenten, die in unserem CMS-System verfügbar sind.",
|
||||
image: [],
|
||||
img: {
|
||||
contentTypeId: ContentType.img,
|
||||
fields: {
|
||||
title: isEn ? "Components Banner" : "Komponenten Banner",
|
||||
description: isEn
|
||||
? "Banner for the components page"
|
||||
: "Banner für die Komponenten-Seite",
|
||||
file: {
|
||||
url: "https://picsum.photos/1200/400?random=components",
|
||||
details: {
|
||||
size: 45678,
|
||||
image: {
|
||||
width: 1200,
|
||||
height: 400,
|
||||
},
|
||||
},
|
||||
fileName: "components-banner.jpg",
|
||||
contentType: "image/jpeg",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
50
middlelayer/adapters/Mock/_cms/mockPageConfig.ts
Normal file
50
middlelayer/adapters/Mock/_cms/mockPageConfig.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { PageConfig } from "../../../types/cms/PageConfig";
|
||||
import { ContentType } from "../../../types/cms/ContentType.enum";
|
||||
|
||||
/**
|
||||
* Generiert Mock-PageConfig mit locale-spezifischen Inhalten
|
||||
* @param locale - Die gewünschte Locale ("de" oder "en")
|
||||
*/
|
||||
export function generateMockPageConfig(locale: string = "de"): PageConfig {
|
||||
const isEn = locale === "en";
|
||||
|
||||
return {
|
||||
logo: {
|
||||
contentTypeId: ContentType.img,
|
||||
fields: {
|
||||
title: isEn ? 'Company Logo' : 'Firmenlogo',
|
||||
description: isEn ? 'Main company logo' : 'Haupt-Firmenlogo',
|
||||
file: {
|
||||
url: 'https://picsum.photos/200/60?random=logo',
|
||||
details: {
|
||||
size: 12345,
|
||||
image: {
|
||||
width: 200,
|
||||
height: 60,
|
||||
},
|
||||
},
|
||||
fileName: 'logo.png',
|
||||
contentType: 'image/png',
|
||||
},
|
||||
},
|
||||
},
|
||||
footerText1: isEn
|
||||
? '© 2024 My Company. All rights reserved.'
|
||||
: '© 2024 Meine Firma. Alle Rechte vorbehalten.',
|
||||
seoTitle: isEn
|
||||
? 'Welcome to our website'
|
||||
: 'Willkommen auf unserer Website',
|
||||
seoDescription: isEn
|
||||
? 'Discover our products and services. We offer high-quality solutions for your needs.'
|
||||
: 'Entdecken Sie unsere Produkte und Dienstleistungen. Wir bieten hochwertige Lösungen für Ihre Bedürfnisse.',
|
||||
blogTagPageHeadline: isEn ? 'Blog Tags' : 'Blog Tags',
|
||||
blogPostsPageHeadline: isEn
|
||||
? 'Our Blog Posts'
|
||||
: 'Unsere Blog-Beiträge',
|
||||
blogPostsPageSubHeadline: isEn
|
||||
? 'Current articles and news'
|
||||
: 'Aktuelle Artikel und Neuigkeiten',
|
||||
website: 'https://example.com',
|
||||
};
|
||||
}
|
||||
|
||||
113
middlelayer/adapters/Mock/_cms/mockProducts.ts
Normal file
113
middlelayer/adapters/Mock/_cms/mockProducts.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { Product } from "../../../types/product";
|
||||
|
||||
const productNames = [
|
||||
"Laptop Pro 15",
|
||||
"Wireless Headphones",
|
||||
"Smart Watch Series 8",
|
||||
"Mechanical Keyboard",
|
||||
"Gaming Mouse",
|
||||
"USB-C Hub",
|
||||
"External SSD 1TB",
|
||||
"Webcam HD 1080p",
|
||||
"Standing Desk",
|
||||
"Ergonomic Chair",
|
||||
'Monitor 27" 4K',
|
||||
"Tablet Pro",
|
||||
"Smart Speaker",
|
||||
"Action Camera",
|
||||
"Drone Mini",
|
||||
];
|
||||
|
||||
const categories = [
|
||||
"Electronics",
|
||||
"Computers",
|
||||
"Audio",
|
||||
"Accessories",
|
||||
"Furniture",
|
||||
"Photography",
|
||||
];
|
||||
|
||||
const descriptions = [
|
||||
"High-performance device with cutting-edge technology",
|
||||
"Premium quality product designed for professionals",
|
||||
"Latest model with advanced features",
|
||||
"Durable and reliable for everyday use",
|
||||
"Compact design with powerful capabilities",
|
||||
];
|
||||
|
||||
function getRandomElement<T>(array: T[]): T {
|
||||
return array[Math.floor(Math.random() * array.length)];
|
||||
}
|
||||
|
||||
function getRandomPrice(): number {
|
||||
return Math.round((Math.random() * 900 + 10) * 100) / 100;
|
||||
}
|
||||
|
||||
function generateProduct(id: string): Product {
|
||||
const basePrice = getRandomPrice();
|
||||
|
||||
// 40% Chance auf Promotion
|
||||
const hasPromotion = Math.random() < 0.4;
|
||||
let price = basePrice;
|
||||
let originalPrice: number | undefined;
|
||||
let promotion: Product["promotion"];
|
||||
|
||||
if (hasPromotion) {
|
||||
// Zufällige Promotion auswählen
|
||||
const promotionType = Math.random();
|
||||
if (promotionType < 0.33) {
|
||||
// Sale mit Rabatt
|
||||
const discountPercent = [10, 20, 30, 40, 50][
|
||||
Math.floor(Math.random() * 5)
|
||||
];
|
||||
originalPrice = basePrice;
|
||||
price = Math.round(basePrice * (1 - discountPercent / 100) * 100) / 100;
|
||||
promotion = {
|
||||
category: "sale",
|
||||
text: `-${discountPercent}%`,
|
||||
};
|
||||
} else if (promotionType < 0.66) {
|
||||
// Sale (generisch)
|
||||
originalPrice = basePrice;
|
||||
price = Math.round(basePrice * 0.7 * 100) / 100; // 30% Rabatt
|
||||
promotion = {
|
||||
category: "sale",
|
||||
text: "-30%",
|
||||
};
|
||||
} else {
|
||||
// Topseller
|
||||
promotion = {
|
||||
category: "topseller",
|
||||
text: "top",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name: getRandomElement(productNames),
|
||||
description: getRandomElement(descriptions),
|
||||
price,
|
||||
originalPrice,
|
||||
currency: "EUR",
|
||||
imageUrl: `https://picsum.photos/400/300?random=${id}`,
|
||||
category: getRandomElement(categories),
|
||||
inStock: Math.random() > 0.2, // 80% chance of being in stock
|
||||
promotion,
|
||||
};
|
||||
}
|
||||
|
||||
export function generateRandomProducts(count: number = 4): Product[] {
|
||||
const products: Product[] = [];
|
||||
const usedIds = new Set<string>();
|
||||
|
||||
while (products.length < count) {
|
||||
const id = `prod-${Math.floor(Math.random() * 10000)}`;
|
||||
if (!usedIds.has(id)) {
|
||||
usedIds.add(id);
|
||||
products.push(generateProduct(id));
|
||||
}
|
||||
}
|
||||
|
||||
return products;
|
||||
}
|
||||
191
middlelayer/adapters/Mock/_i18n/mockTranslations.ts
Normal file
191
middlelayer/adapters/Mock/_i18n/mockTranslations.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Mock-Daten für Übersetzungen
|
||||
* Diese können später durch einen echten CMS-Adapter ersetzt werden
|
||||
*/
|
||||
|
||||
export interface Translation {
|
||||
key: string;
|
||||
value: string;
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
export interface TranslationsData {
|
||||
locale: string;
|
||||
translations: Translation[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock-Übersetzungen für Deutsch (de)
|
||||
*/
|
||||
export const mockTranslationsDe: TranslationsData = {
|
||||
locale: "de",
|
||||
translations: [
|
||||
// Login Modal
|
||||
{ key: "login.title", value: "Anmelden", namespace: "auth" },
|
||||
{ key: "login.email", value: "E-Mail", namespace: "auth" },
|
||||
{ key: "login.password", value: "Passwort", namespace: "auth" },
|
||||
{ key: "login.submit", value: "Anmelden", namespace: "auth" },
|
||||
{ key: "login.loading", value: "Wird geladen...", namespace: "auth" },
|
||||
{
|
||||
key: "login.error",
|
||||
value: "Login fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||
namespace: "auth",
|
||||
},
|
||||
{
|
||||
key: "login.noAccount",
|
||||
value: "Noch kein Konto?",
|
||||
namespace: "auth",
|
||||
},
|
||||
{
|
||||
key: "login.registerNow",
|
||||
value: "Jetzt registrieren",
|
||||
namespace: "auth",
|
||||
},
|
||||
// Register Modal
|
||||
{ key: "register.title", value: "Registrieren", namespace: "auth" },
|
||||
{ key: "register.name", value: "Name", namespace: "auth" },
|
||||
{ key: "register.email", value: "E-Mail", namespace: "auth" },
|
||||
{ key: "register.password", value: "Passwort", namespace: "auth" },
|
||||
{
|
||||
key: "register.confirmPassword",
|
||||
value: "Passwort bestätigen",
|
||||
namespace: "auth",
|
||||
},
|
||||
{ key: "register.submit", value: "Registrieren", namespace: "auth" },
|
||||
{
|
||||
key: "register.loading",
|
||||
value: "Wird geladen...",
|
||||
namespace: "auth",
|
||||
},
|
||||
{
|
||||
key: "register.error",
|
||||
value: "Registrierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||
namespace: "auth",
|
||||
},
|
||||
{
|
||||
key: "register.passwordMismatch",
|
||||
value: "Passwörter stimmen nicht überein",
|
||||
namespace: "auth",
|
||||
},
|
||||
{
|
||||
key: "register.passwordTooShort",
|
||||
value: "Passwort muss mindestens 6 Zeichen lang sein",
|
||||
namespace: "auth",
|
||||
},
|
||||
{
|
||||
key: "register.hasAccount",
|
||||
value: "Bereits ein Konto?",
|
||||
namespace: "auth",
|
||||
},
|
||||
{
|
||||
key: "register.loginNow",
|
||||
value: "Jetzt anmelden",
|
||||
namespace: "auth",
|
||||
},
|
||||
// Navigation
|
||||
{ key: "nav.login", value: "Anmelden", namespace: "common" },
|
||||
{ key: "nav.register", value: "Registrieren", namespace: "common" },
|
||||
{ key: "nav.logout", value: "Abmelden", namespace: "common" },
|
||||
{ key: "nav.greeting", value: "Hallo, {name}", namespace: "common" },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock-Übersetzungen für Englisch (en)
|
||||
*/
|
||||
export const mockTranslationsEn: TranslationsData = {
|
||||
locale: "en",
|
||||
translations: [
|
||||
// Login Modal
|
||||
{ key: "login.title", value: "Sign In", namespace: "auth" },
|
||||
{ key: "login.email", value: "Email", namespace: "auth" },
|
||||
{ key: "login.password", value: "Password", namespace: "auth" },
|
||||
{ key: "login.submit", value: "Sign In", namespace: "auth" },
|
||||
{ key: "login.loading", value: "Loading...", namespace: "auth" },
|
||||
{
|
||||
key: "login.error",
|
||||
value: "Login failed. Please try again.",
|
||||
namespace: "auth",
|
||||
},
|
||||
{
|
||||
key: "login.noAccount",
|
||||
value: "Don't have an account?",
|
||||
namespace: "auth",
|
||||
},
|
||||
{
|
||||
key: "login.registerNow",
|
||||
value: "Register now",
|
||||
namespace: "auth",
|
||||
},
|
||||
// Register Modal
|
||||
{ key: "register.title", value: "Register", namespace: "auth" },
|
||||
{ key: "register.name", value: "Name", namespace: "auth" },
|
||||
{ key: "register.email", value: "Email", namespace: "auth" },
|
||||
{ key: "register.password", value: "Password", namespace: "auth" },
|
||||
{
|
||||
key: "register.confirmPassword",
|
||||
value: "Confirm Password",
|
||||
namespace: "auth",
|
||||
},
|
||||
{ key: "register.submit", value: "Register", namespace: "auth" },
|
||||
{
|
||||
key: "register.loading",
|
||||
value: "Loading...",
|
||||
namespace: "auth",
|
||||
},
|
||||
{
|
||||
key: "register.error",
|
||||
value: "Registration failed. Please try again.",
|
||||
namespace: "auth",
|
||||
},
|
||||
{
|
||||
key: "register.passwordMismatch",
|
||||
value: "Passwords do not match",
|
||||
namespace: "auth",
|
||||
},
|
||||
{
|
||||
key: "register.passwordTooShort",
|
||||
value: "Password must be at least 6 characters long",
|
||||
namespace: "auth",
|
||||
},
|
||||
{
|
||||
key: "register.hasAccount",
|
||||
value: "Already have an account?",
|
||||
namespace: "auth",
|
||||
},
|
||||
{
|
||||
key: "register.loginNow",
|
||||
value: "Sign in now",
|
||||
namespace: "auth",
|
||||
},
|
||||
// Navigation
|
||||
{ key: "nav.login", value: "Sign In", namespace: "common" },
|
||||
{ key: "nav.register", value: "Register", namespace: "common" },
|
||||
{ key: "nav.logout", value: "Logout", namespace: "common" },
|
||||
{ key: "nav.greeting", value: "Hello, {name}", namespace: "common" },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Gibt Übersetzungen für eine bestimmte Locale zurück
|
||||
*/
|
||||
export function getTranslations(
|
||||
locale: string = "de",
|
||||
namespace?: string
|
||||
): TranslationsData {
|
||||
const translations =
|
||||
locale === "en" ? mockTranslationsEn : mockTranslationsDe;
|
||||
|
||||
// Filter nach Namespace, falls angegeben
|
||||
if (namespace) {
|
||||
return {
|
||||
locale: translations.locale,
|
||||
translations: translations.translations.filter(
|
||||
(t) => t.namespace === namespace
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return translations;
|
||||
}
|
||||
|
||||
93
middlelayer/adapters/Mock/mockdata.ts
Normal file
93
middlelayer/adapters/Mock/mockdata.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { DataAdapter } from "../interface";
|
||||
import type { PageSeo, Page, Navigation, Product } from "../../types/index";
|
||||
import { generateMockPageConfig } from "./_cms/mockPageConfig";
|
||||
import { generateMockPages } from "./_cms/mockPage";
|
||||
import { generateMockNavigation } from "./_cms/mockNavigation";
|
||||
import { generateRandomProducts } from "./_cms/mockProducts";
|
||||
import { PageMapper } from "../../mappers/pageMapper";
|
||||
import { getTranslations } from "./_i18n/mockTranslations";
|
||||
import type { TranslationsData } from "./_i18n/mockTranslations";
|
||||
|
||||
/**
|
||||
* Mockdata Adapter - verwendet lokale Mock-Daten
|
||||
*/
|
||||
export class MockdataAdapter implements DataAdapter {
|
||||
async getProducts(limit: number = 4): Promise<Product[]> {
|
||||
return generateRandomProducts(limit);
|
||||
}
|
||||
|
||||
async getProduct(id: string): Promise<Product | null> {
|
||||
const products = generateRandomProducts(1);
|
||||
return products[0] ? { ...products[0], id } : null;
|
||||
}
|
||||
async getPage(slug: string, locale?: string): Promise<Page | null> {
|
||||
// Verwende Locale für locale-spezifische Inhalte
|
||||
const pages = generateMockPages(locale || "de");
|
||||
const page = pages[slug];
|
||||
if (!page) return null;
|
||||
|
||||
return PageMapper.fromCms(page);
|
||||
}
|
||||
|
||||
async getPages(locale?: string): Promise<Page[]> {
|
||||
// Verwende Locale für locale-spezifische Inhalte
|
||||
const pages = generateMockPages(locale || "de");
|
||||
return PageMapper.fromCmsArray(Object.values(pages));
|
||||
}
|
||||
|
||||
async getPageSeo(locale?: string): Promise<PageSeo> {
|
||||
// Verwende Locale für locale-spezifische SEO-Daten
|
||||
const pageConfig = generateMockPageConfig(locale || "de");
|
||||
return {
|
||||
title: pageConfig.seoTitle,
|
||||
description: pageConfig.seoDescription,
|
||||
metaRobotsIndex: "index",
|
||||
metaRobotsFollow: "follow",
|
||||
};
|
||||
}
|
||||
|
||||
async getNavigation(locale?: string): Promise<Navigation> {
|
||||
// Verwende Locale für locale-spezifische Navigation
|
||||
const nav = generateMockNavigation(locale || "de");
|
||||
const pages = generateMockPages(locale || "de");
|
||||
|
||||
// Konvertiere die Links zu NavigationLink-Format
|
||||
const links = nav.links.map((link: any) => {
|
||||
// Wenn es eine Page ist (hat slug)
|
||||
if (link.fields.slug) {
|
||||
const page = pages[link.fields.slug];
|
||||
if (page) {
|
||||
return {
|
||||
slug: page.slug,
|
||||
name: page.name,
|
||||
linkName: page.linkName,
|
||||
url: page.slug,
|
||||
icon: page.icon,
|
||||
newTab: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
// Wenn es ein Link ist
|
||||
return {
|
||||
name: link.fields.name || link.fields.linkName,
|
||||
linkName: link.fields.linkName,
|
||||
url: link.fields.url,
|
||||
icon: link.fields.icon,
|
||||
newTab: link.fields.newTab || false,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
name: nav.name,
|
||||
internal: nav.internal,
|
||||
links: links.filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
async getTranslations(
|
||||
locale: string = "de",
|
||||
namespace?: string
|
||||
): Promise<TranslationsData> {
|
||||
return getTranslations(locale, namespace);
|
||||
}
|
||||
}
|
||||
23
middlelayer/adapters/config.ts
Normal file
23
middlelayer/adapters/config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { MockdataAdapter } from "./Mock/mockdata";
|
||||
import type { DataAdapter } from "./interface";
|
||||
|
||||
/**
|
||||
* Adapter-Konfiguration
|
||||
* Bestimmt welcher Adapter basierend auf Environment-Variablen verwendet wird
|
||||
*/
|
||||
export function createAdapter(): DataAdapter {
|
||||
const adapterType = process.env.DATA_ADAPTER || "mock";
|
||||
|
||||
switch (adapterType) {
|
||||
case "mock":
|
||||
return new MockdataAdapter();
|
||||
// Weitere Adapter können hier hinzugefügt werden:
|
||||
// case 'contentful':
|
||||
// return new ContentfulAdapter(process.env.CONTENTFUL_SPACE_ID!, process.env.CONTENTFUL_ACCESS_TOKEN!);
|
||||
default:
|
||||
console.warn(
|
||||
`Unbekannter Adapter-Typ: ${adapterType}. Verwende Mock-Adapter.`
|
||||
);
|
||||
return new MockdataAdapter();
|
||||
}
|
||||
}
|
||||
27
middlelayer/adapters/interface.ts
Normal file
27
middlelayer/adapters/interface.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { PageSeo, Page, Navigation, Product } from "../types/index";
|
||||
import type {
|
||||
TranslationsData,
|
||||
} from "./Mock/_i18n/mockTranslations";
|
||||
|
||||
/**
|
||||
* Adapter Interface für Datenquellen
|
||||
* Jeder Adapter muss diese Schnittstelle implementieren
|
||||
*/
|
||||
export interface DataAdapter {
|
||||
// Product Operations
|
||||
getProducts(limit?: number): Promise<Product[]>;
|
||||
getProduct(id: string): Promise<Product | null>;
|
||||
|
||||
// Page Operations
|
||||
getPage(slug: string, locale?: string): Promise<Page | null>;
|
||||
getPages(locale?: string): Promise<Page[]>;
|
||||
|
||||
// SEO Operations
|
||||
getPageSeo(locale?: string): Promise<PageSeo>;
|
||||
|
||||
// Navigation Operations
|
||||
getNavigation(locale?: string): Promise<Navigation>;
|
||||
|
||||
// Translation Operations
|
||||
getTranslations(locale?: string, namespace?: string): Promise<TranslationsData>;
|
||||
}
|
||||
150
middlelayer/auth/README.md
Normal file
150
middlelayer/auth/README.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Authentication & Authorization
|
||||
|
||||
## Übersicht
|
||||
|
||||
Der Middlelayer unterstützt JWT-basierte Authentication und Role-Based Access Control (RBAC).
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ JWT-basierte Authentication
|
||||
- ✅ Passwort-Hashing mit bcrypt
|
||||
- ✅ Role-Based Access Control (Admin, Customer, Guest)
|
||||
- ✅ Protected Resolvers
|
||||
- ✅ User-Context in GraphQL Requests
|
||||
|
||||
## User-Rollen
|
||||
|
||||
- **ADMIN**: Vollzugriff auf alle Ressourcen
|
||||
- **CUSTOMER**: Zugriff auf Kunden-spezifische Ressourcen
|
||||
- **GUEST**: Nur öffentliche Ressourcen
|
||||
|
||||
## GraphQL Mutations
|
||||
|
||||
### Register
|
||||
|
||||
```graphql
|
||||
mutation Register {
|
||||
register(email: "user@example.com", password: "secure123", name: "Max Mustermann") {
|
||||
user {
|
||||
id
|
||||
email
|
||||
name
|
||||
role
|
||||
}
|
||||
token
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Login
|
||||
|
||||
```graphql
|
||||
mutation Login {
|
||||
login(email: "user@example.com", password: "secure123") {
|
||||
user {
|
||||
id
|
||||
email
|
||||
name
|
||||
role
|
||||
}
|
||||
token
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## GraphQL Queries
|
||||
|
||||
### Aktueller User
|
||||
|
||||
```graphql
|
||||
query Me {
|
||||
me {
|
||||
id
|
||||
email
|
||||
name
|
||||
role
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Authorization in Resolvers
|
||||
|
||||
### Beispiel: Protected Resolver
|
||||
|
||||
```typescript
|
||||
import { requireAuth, requireAdmin } from "./auth/authorization.js";
|
||||
|
||||
export const resolvers = {
|
||||
Query: {
|
||||
adminOnlyData: async (_: unknown, __: unknown, context: GraphQLContext) => {
|
||||
// Prüft ob User Admin ist
|
||||
requireAdmin(context.user);
|
||||
|
||||
// Resolver-Logik...
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Verfügbare Authorization-Helper
|
||||
|
||||
- `requireAuth(user)` - Prüft ob User authentifiziert ist
|
||||
- `requireRole(user, roles)` - Prüft ob User eine bestimmte Rolle hat
|
||||
- `requireAdmin(user)` - Prüft ob User Admin ist
|
||||
- `requireCustomer(user)` - Prüft ob User Customer oder Admin ist
|
||||
|
||||
## Verwendung im Frontend
|
||||
|
||||
### Token speichern
|
||||
|
||||
```typescript
|
||||
// Nach Login/Register
|
||||
const { token } = await login(email, password);
|
||||
localStorage.setItem('authToken', token);
|
||||
```
|
||||
|
||||
### Token in Requests verwenden
|
||||
|
||||
```typescript
|
||||
const response = await fetch('http://localhost:4000', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
```
|
||||
|
||||
## Konfiguration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
JWT_SECRET=your-secret-key-change-in-production
|
||||
JWT_EXPIRES_IN=7d # Token-Gültigkeitsdauer
|
||||
```
|
||||
|
||||
**Wichtig:** In Production muss `JWT_SECRET` sicher gesetzt werden!
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **JWT Secret**: Verwende einen starken, zufälligen Secret
|
||||
2. **HTTPS**: Immer HTTPS in Production verwenden
|
||||
3. **Token Expiration**: Setze angemessene Expiration-Zeiten
|
||||
4. **Password Hashing**: Passwörter werden automatisch mit bcrypt gehasht
|
||||
5. **Rate Limiting**: (Noch zu implementieren) Verhindere Brute-Force-Angriffe
|
||||
|
||||
## Mock User Store
|
||||
|
||||
Aktuell werden User in einem In-Memory Store gespeichert. Für Production sollte dies durch eine Datenbank ersetzt werden.
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
- [ ] Database-Integration für User-Speicherung
|
||||
- [ ] Refresh Tokens
|
||||
- [ ] Password Reset
|
||||
- [ ] Email Verification
|
||||
- [ ] Rate Limiting für Login/Register
|
||||
- [ ] Session Management
|
||||
|
||||
59
middlelayer/auth/authorization.ts
Normal file
59
middlelayer/auth/authorization.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { GraphQLError } from "graphql";
|
||||
import type { User, UserRole } from "../types/user.js";
|
||||
import { UserRole as UserRoleEnum } from "../types/user.js";
|
||||
|
||||
/**
|
||||
* Authorization-Fehler
|
||||
*/
|
||||
export class AuthorizationError extends GraphQLError {
|
||||
constructor(message: string) {
|
||||
super(message, {
|
||||
extensions: {
|
||||
code: "UNAUTHORIZED",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob User authentifiziert ist
|
||||
*/
|
||||
export function requireAuth(user: User | null): User {
|
||||
if (!user) {
|
||||
throw new AuthorizationError("Authentifizierung erforderlich");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob User eine bestimmte Rolle hat
|
||||
*/
|
||||
export function requireRole(
|
||||
user: User | null,
|
||||
requiredRoles: UserRole[]
|
||||
): User {
|
||||
const authenticatedUser = requireAuth(user);
|
||||
|
||||
if (!requiredRoles.includes(authenticatedUser.role)) {
|
||||
throw new AuthorizationError(
|
||||
`Zugriff verweigert. Erforderliche Rollen: ${requiredRoles.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
return authenticatedUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob User Admin ist
|
||||
*/
|
||||
export function requireAdmin(user: User | null): User {
|
||||
return requireRole(user, [UserRoleEnum.ADMIN]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob User Customer oder Admin ist
|
||||
*/
|
||||
export function requireCustomer(user: User | null): User {
|
||||
return requireRole(user, [UserRoleEnum.CUSTOMER, UserRoleEnum.ADMIN]);
|
||||
}
|
||||
|
||||
63
middlelayer/auth/jwt.ts
Normal file
63
middlelayer/auth/jwt.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import type { JWTPayload, UserRole } from "../types/user.js";
|
||||
import { logger } from "../monitoring/logger.js";
|
||||
|
||||
const JWT_SECRET =
|
||||
process.env.JWT_SECRET || "your-secret-key-change-in-production";
|
||||
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "7d";
|
||||
|
||||
/**
|
||||
* Erstellt ein JWT Token für einen User
|
||||
*/
|
||||
export function createToken(payload: JWTPayload): string {
|
||||
return jwt.sign(payload, JWT_SECRET, {
|
||||
expiresIn: JWT_EXPIRES_IN,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifiziert ein JWT Token
|
||||
*/
|
||||
export function verifyToken(token: string): JWTPayload | null {
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload;
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
logger.warn("JWT verification failed", { error });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert Token aus Authorization Header
|
||||
*/
|
||||
export function extractTokenFromHeader(
|
||||
authHeader: string | null
|
||||
): string | null {
|
||||
if (!authHeader) return null;
|
||||
|
||||
// Format: "Bearer <token>"
|
||||
const parts = authHeader.split(" ");
|
||||
if (parts.length !== 2 || parts[0] !== "Bearer") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob User eine bestimmte Rolle hat
|
||||
*/
|
||||
export function hasRole(
|
||||
userRole: UserRole,
|
||||
requiredRoles: UserRole[]
|
||||
): boolean {
|
||||
return requiredRoles.includes(userRole);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob User Admin ist
|
||||
*/
|
||||
export function isAdmin(userRole: UserRole): boolean {
|
||||
return userRole === UserRole.ADMIN;
|
||||
}
|
||||
31
middlelayer/auth/password.ts
Normal file
31
middlelayer/auth/password.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { logger } from "../monitoring/logger.js";
|
||||
|
||||
const SALT_ROUNDS = 10;
|
||||
|
||||
/**
|
||||
* Hasht ein Passwort
|
||||
*/
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
try {
|
||||
return await bcrypt.hash(password, SALT_ROUNDS);
|
||||
} catch (error) {
|
||||
logger.error("Password hashing failed", { error });
|
||||
throw new Error("Fehler beim Hashen des Passworts");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vergleicht ein Passwort mit einem Hash
|
||||
*/
|
||||
export async function comparePassword(
|
||||
password: string,
|
||||
hash: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return await bcrypt.compare(password, hash);
|
||||
} catch (error) {
|
||||
logger.error("Password comparison failed", { error });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
140
middlelayer/auth/userService.ts
Normal file
140
middlelayer/auth/userService.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type {
|
||||
User,
|
||||
UserRole,
|
||||
LoginCredentials,
|
||||
RegisterData,
|
||||
} from "../types/user.js";
|
||||
import { hashPassword, comparePassword } from "./password.js";
|
||||
import { createToken, verifyToken } from "./jwt.js";
|
||||
import { logger } from "../monitoring/logger.js";
|
||||
|
||||
/**
|
||||
* Mock User Store (später durch Datenbank ersetzen)
|
||||
*/
|
||||
const users = new Map<string, User & { passwordHash: string }>();
|
||||
|
||||
/**
|
||||
* User Service für Authentication
|
||||
*/
|
||||
export class UserService {
|
||||
/**
|
||||
* Registriert einen neuen User
|
||||
*/
|
||||
async register(
|
||||
data: RegisterData,
|
||||
role: UserRole = "customer"
|
||||
): Promise<{
|
||||
user: User;
|
||||
token: string;
|
||||
}> {
|
||||
// Prüfe ob User bereits existiert
|
||||
const existingUser = Array.from(users.values()).find(
|
||||
(u) => u.email === data.email
|
||||
);
|
||||
if (existingUser) {
|
||||
throw new Error("User mit dieser E-Mail existiert bereits");
|
||||
}
|
||||
|
||||
// Hashe Passwort
|
||||
const passwordHash = await hashPassword(data.password);
|
||||
|
||||
// Erstelle User
|
||||
const user: User = {
|
||||
id: `user-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
role,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
// Speichere User
|
||||
users.set(user.id, { ...user, passwordHash });
|
||||
|
||||
// Erstelle Token
|
||||
const token = createToken({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
});
|
||||
|
||||
logger.info("User registered", { userId: user.id, email: user.email });
|
||||
|
||||
return { user, token };
|
||||
}
|
||||
|
||||
/**
|
||||
* Login eines Users
|
||||
*/
|
||||
async login(credentials: LoginCredentials): Promise<{
|
||||
user: User;
|
||||
token: string;
|
||||
}> {
|
||||
// Finde User
|
||||
const userEntry = Array.from(users.values()).find(
|
||||
(u) => u.email === credentials.email
|
||||
);
|
||||
|
||||
if (!userEntry) {
|
||||
throw new Error("Ungültige E-Mail oder Passwort");
|
||||
}
|
||||
|
||||
// Vergleiche Passwort
|
||||
const isValid = await comparePassword(
|
||||
credentials.password,
|
||||
userEntry.passwordHash
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error("Ungültige E-Mail oder Passwort");
|
||||
}
|
||||
|
||||
// Erstelle User-Objekt ohne Passwort
|
||||
const user: User = {
|
||||
id: userEntry.id,
|
||||
email: userEntry.email,
|
||||
name: userEntry.name,
|
||||
role: userEntry.role,
|
||||
createdAt: userEntry.createdAt,
|
||||
};
|
||||
|
||||
// Erstelle Token
|
||||
const token = createToken({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
});
|
||||
|
||||
logger.info("User logged in", { userId: user.id, email: user.email });
|
||||
|
||||
return { user, token };
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt User anhand der ID
|
||||
*/
|
||||
async getUserById(userId: string): Promise<User | null> {
|
||||
const userEntry = users.get(userId);
|
||||
if (!userEntry) return null;
|
||||
|
||||
return {
|
||||
id: userEntry.id,
|
||||
email: userEntry.email,
|
||||
name: userEntry.name,
|
||||
role: userEntry.role,
|
||||
createdAt: userEntry.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt User anhand des Tokens
|
||||
*/
|
||||
async getUserFromToken(token: string): Promise<User | null> {
|
||||
const payload = verifyToken(token);
|
||||
if (!payload) return null;
|
||||
|
||||
return this.getUserById(payload.userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton-Instanz
|
||||
export const userService = new UserService();
|
||||
19
middlelayer/config/cache.ts
Normal file
19
middlelayer/config/cache.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Cache-Konfiguration
|
||||
* TTL-Werte in Millisekunden
|
||||
*/
|
||||
export const cacheConfig = {
|
||||
pages: {
|
||||
ttl: parseInt(process.env.CACHE_PAGES_TTL || '60000'), // 60 Sekunden
|
||||
},
|
||||
pageSeo: {
|
||||
ttl: parseInt(process.env.CACHE_PAGE_SEO_TTL || '300000'), // 5 Minuten
|
||||
},
|
||||
navigation: {
|
||||
ttl: parseInt(process.env.CACHE_NAVIGATION_TTL || '300000'), // 5 Minuten
|
||||
},
|
||||
products: {
|
||||
ttl: parseInt(process.env.CACHE_PRODUCTS_TTL || '30000'), // 30 Sekunden
|
||||
},
|
||||
} as const;
|
||||
|
||||
129
middlelayer/dataService.ts
Normal file
129
middlelayer/dataService.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { DataAdapter } from "./adapters/interface.js";
|
||||
import { createAdapter } from "./adapters/config.js";
|
||||
import type { PageSeo, Page, Navigation, Product } from "./types/index.js";
|
||||
import type { TranslationsData } from "./adapters/Mock/_i18n/mockTranslations.js";
|
||||
import { AdapterError } from "./utils/errors.js";
|
||||
import { cache } from "./utils/cache.js";
|
||||
import { CacheKeyBuilder } from "./utils/cacheKeys.js";
|
||||
import { DataServiceHelpers } from "./utils/dataServiceHelpers.js";
|
||||
|
||||
/**
|
||||
* DataService - Aggregator für Datenoperationen
|
||||
* Verwendet den konfigurierten Adapter für alle Datenzugriffe
|
||||
* Mit Caching und Fehlerbehandlung
|
||||
*/
|
||||
class DataService {
|
||||
private adapter: DataAdapter;
|
||||
|
||||
constructor(adapter?: DataAdapter) {
|
||||
this.adapter = adapter || createAdapter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt einen neuen Adapter
|
||||
*/
|
||||
async setAdapter(adapter: DataAdapter): Promise<void> {
|
||||
this.adapter = adapter;
|
||||
// Cache leeren bei Adapter-Wechsel
|
||||
await Promise.all([
|
||||
cache.pages.clear(),
|
||||
cache.pageSeo.clear(),
|
||||
cache.navigation.clear(),
|
||||
cache.products.clear(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt eine einzelne Seite
|
||||
*/
|
||||
async getPage(slug: string, locale?: string): Promise<Page | null> {
|
||||
return DataServiceHelpers.withCacheAndMetrics(
|
||||
"getPage",
|
||||
cache.pages,
|
||||
CacheKeyBuilder.page(slug, locale),
|
||||
() => this.adapter.getPage(slug, locale),
|
||||
`Fehler beim Laden der Seite '${slug}'`,
|
||||
{ slug, locale }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt alle Seiten
|
||||
*/
|
||||
async getPages(locale?: string): Promise<Page[]> {
|
||||
return DataServiceHelpers.withCache(
|
||||
cache.pages,
|
||||
CacheKeyBuilder.pages(locale),
|
||||
() => this.adapter.getPages(locale),
|
||||
"Fehler beim Laden der Seiten"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt SEO-Daten
|
||||
*/
|
||||
async getPageSeo(locale?: string): Promise<PageSeo> {
|
||||
return DataServiceHelpers.withCache(
|
||||
cache.pageSeo,
|
||||
CacheKeyBuilder.pageSeo(locale),
|
||||
() => this.adapter.getPageSeo(locale),
|
||||
"Fehler beim Laden der SEO-Daten"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt Navigation
|
||||
*/
|
||||
async getNavigation(locale?: string): Promise<Navigation> {
|
||||
return DataServiceHelpers.withCache(
|
||||
cache.navigation,
|
||||
CacheKeyBuilder.navigation(locale),
|
||||
() => this.adapter.getNavigation(locale),
|
||||
"Fehler beim Laden der Navigation"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt Produkte
|
||||
*/
|
||||
async getProducts(limit?: number): Promise<Product[]> {
|
||||
return DataServiceHelpers.withCacheAndMetrics(
|
||||
"getProducts",
|
||||
cache.products,
|
||||
CacheKeyBuilder.products(limit),
|
||||
() => this.adapter.getProducts(limit),
|
||||
"Fehler beim Laden der Produkte",
|
||||
{ limit }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt ein einzelnes Produkt
|
||||
*/
|
||||
async getProduct(id: string): Promise<Product | null> {
|
||||
return DataServiceHelpers.withCache(
|
||||
cache.products,
|
||||
CacheKeyBuilder.product(id),
|
||||
() => this.adapter.getProduct(id),
|
||||
`Fehler beim Laden des Produkts '${id}'`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt Übersetzungen
|
||||
*/
|
||||
async getTranslations(
|
||||
locale: string = "de",
|
||||
namespace?: string
|
||||
): Promise<TranslationsData> {
|
||||
return DataServiceHelpers.withCache(
|
||||
cache.pages,
|
||||
CacheKeyBuilder.translations(locale, namespace),
|
||||
() => this.adapter.getTranslations(locale, namespace),
|
||||
`Fehler beim Laden der Übersetzungen für '${locale}'`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton-Instanz mit konfiguriertem Adapter
|
||||
export const dataService = new DataService();
|
||||
119
middlelayer/index.ts
Normal file
119
middlelayer/index.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { ApolloServer } from "@apollo/server";
|
||||
import { startStandaloneServer } from "@apollo/server/standalone";
|
||||
import { createServer } from "http";
|
||||
import { typeDefs } from "./schema.js";
|
||||
import { resolvers } from "./resolvers.js";
|
||||
import { queryComplexityPlugin } from "./plugins/queryComplexity.js";
|
||||
import { createResponseCachePlugin } from "./plugins/responseCache.js";
|
||||
import {
|
||||
monitoringPlugin,
|
||||
queryComplexityMonitoringPlugin,
|
||||
} from "./plugins/monitoring.js";
|
||||
import { createContext, type GraphQLContext } from "./utils/dataloaders.js";
|
||||
import { logger } from "./monitoring/logger.js";
|
||||
import { getMetrics } from "./monitoring/metrics.js";
|
||||
import { extractTokenFromHeader } from "./auth/jwt.js";
|
||||
import { userService } from "./auth/userService.js";
|
||||
|
||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 4000;
|
||||
const METRICS_PORT = process.env.METRICS_PORT
|
||||
? parseInt(process.env.METRICS_PORT)
|
||||
: 9090;
|
||||
|
||||
// Konfiguration aus Environment Variables
|
||||
const MAX_QUERY_COMPLEXITY = process.env.MAX_QUERY_COMPLEXITY
|
||||
? parseInt(process.env.MAX_QUERY_COMPLEXITY)
|
||||
: 1000;
|
||||
|
||||
/**
|
||||
* Startet einen separaten HTTP-Server für Metrics (Prometheus)
|
||||
*/
|
||||
function startMetricsServer() {
|
||||
const server = createServer(async (req, res) => {
|
||||
if (req.url === "/metrics" && req.method === "GET") {
|
||||
try {
|
||||
const metrics = await getMetrics();
|
||||
res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4" });
|
||||
res.end(metrics);
|
||||
} catch (error) {
|
||||
logger.error("Failed to get metrics", { error });
|
||||
res.writeHead(500, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Failed to get metrics" }));
|
||||
}
|
||||
} else if (req.url === "/health" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok", service: "graphql-middlelayer" }));
|
||||
} else {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Not found" }));
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(METRICS_PORT, () => {
|
||||
logger.info(
|
||||
`📊 Metrics Server läuft auf: http://localhost:${METRICS_PORT}/metrics`
|
||||
);
|
||||
logger.info(
|
||||
`❤️ Health Check verfügbar unter: http://localhost:${METRICS_PORT}/health`
|
||||
);
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
const server = new ApolloServer<GraphQLContext>({
|
||||
typeDefs,
|
||||
resolvers,
|
||||
plugins: [
|
||||
// Monitoring (muss zuerst sein für vollständiges Tracking)
|
||||
monitoringPlugin(),
|
||||
queryComplexityMonitoringPlugin(),
|
||||
// Query Complexity Limit
|
||||
queryComplexityPlugin({
|
||||
maxComplexity: MAX_QUERY_COMPLEXITY,
|
||||
defaultComplexity: 1,
|
||||
}),
|
||||
// Response Caching
|
||||
createResponseCachePlugin(),
|
||||
],
|
||||
});
|
||||
|
||||
const { url } = await startStandaloneServer(server, {
|
||||
listen: { port: PORT },
|
||||
context: async ({ req }) => {
|
||||
// Extrahiere User aus Authorization Header
|
||||
let user = null;
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = extractTokenFromHeader(authHeader || null);
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
user = await userService.getUserFromToken(token);
|
||||
} catch (error) {
|
||||
logger.warn("Token verification failed", { error });
|
||||
}
|
||||
}
|
||||
|
||||
// Erstelle Context mit Dataloadern und User
|
||||
return createContext(user);
|
||||
},
|
||||
});
|
||||
|
||||
// Starte Metrics Server
|
||||
startMetricsServer();
|
||||
|
||||
logger.info(`🚀 GraphQL Middlelayer läuft auf: ${url}`);
|
||||
logger.info(`📊 GraphQL Playground verfügbar unter: ${url}`);
|
||||
logger.info(`⚡ Query Complexity Limit: ${MAX_QUERY_COMPLEXITY}`);
|
||||
logger.info(`💾 Response Caching: Aktiviert`);
|
||||
logger.info(`🔄 Dataloader: Aktiviert`);
|
||||
logger.info(
|
||||
`📈 Monitoring: Aktiviert (Structured Logging, Prometheus, Tracing)`
|
||||
);
|
||||
}
|
||||
|
||||
startServer().catch((error) => {
|
||||
logger.error("Fehler beim Starten des Servers", { error });
|
||||
process.exit(1);
|
||||
});
|
||||
235
middlelayer/mappers/pageMapper.ts
Normal file
235
middlelayer/mappers/pageMapper.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import type { Page as CmsPage } from "../types/cms/Page";
|
||||
import type { HTMLSkeleton } from "../types/cms/Html";
|
||||
import type { MarkdownSkeleton } from "../types/cms/Markdown";
|
||||
import type { ComponentIframeSkeleton } from "../types/cms/Iframe";
|
||||
import type { ImageGallerySkeleton } from "../types/cms/ImageGallery";
|
||||
import type { ComponentImageSkeleton } from "../types/cms/Image";
|
||||
import type { QuoteSkeleton } from "../types/cms/Quote";
|
||||
import type { ComponentYoutubeVideoSkeleton } from "../types/cms/YoutubeVideo";
|
||||
import type { ComponentHeadlineSkeleton } from "../types/cms/Headline";
|
||||
import type { Page, ContentItem, ContentRow } from "../types/page";
|
||||
import { ContentType } from "../types/cms/ContentType.enum";
|
||||
|
||||
type ContentEntry =
|
||||
| HTMLSkeleton
|
||||
| MarkdownSkeleton
|
||||
| ComponentIframeSkeleton
|
||||
| ImageGallerySkeleton
|
||||
| ComponentImageSkeleton
|
||||
| QuoteSkeleton
|
||||
| ComponentYoutubeVideoSkeleton
|
||||
| ComponentHeadlineSkeleton;
|
||||
|
||||
/**
|
||||
* Mapper für Page-Transformationen
|
||||
* Konvertiert CMS-Typen zu unseren Domain-Typen
|
||||
*/
|
||||
export class PageMapper {
|
||||
/**
|
||||
* Strategy Pattern: Map mit Mapper-Funktionen für jeden Content-Type
|
||||
*/
|
||||
private static contentMappers = new Map<
|
||||
ContentType,
|
||||
(entry: ContentEntry) => ContentItem | null
|
||||
>([
|
||||
[
|
||||
ContentType.html,
|
||||
(entry) => {
|
||||
const htmlEntry = entry as HTMLSkeleton;
|
||||
return {
|
||||
type: "html",
|
||||
name: htmlEntry.fields.id || "",
|
||||
html: htmlEntry.fields.html,
|
||||
layout: htmlEntry.fields.layout,
|
||||
};
|
||||
},
|
||||
],
|
||||
[
|
||||
ContentType.markdown,
|
||||
(entry) => {
|
||||
const markdownEntry = entry as MarkdownSkeleton;
|
||||
return {
|
||||
type: "markdown",
|
||||
name: markdownEntry.fields.name,
|
||||
content: markdownEntry.fields.content,
|
||||
layout: markdownEntry.fields.layout,
|
||||
alignment: markdownEntry.fields.alignment,
|
||||
};
|
||||
},
|
||||
],
|
||||
[
|
||||
ContentType.iframe,
|
||||
(entry) => {
|
||||
const iframeEntry = entry as ComponentIframeSkeleton;
|
||||
return {
|
||||
type: "iframe",
|
||||
name: iframeEntry.fields.name,
|
||||
content: iframeEntry.fields.content,
|
||||
iframe: iframeEntry.fields.iframe,
|
||||
overlayImageUrl: iframeEntry.fields.overlayImage?.fields.file.url,
|
||||
layout: iframeEntry.fields.layout,
|
||||
};
|
||||
},
|
||||
],
|
||||
[
|
||||
ContentType.imgGallery,
|
||||
(entry) => {
|
||||
const galleryEntry = entry as ImageGallerySkeleton;
|
||||
return {
|
||||
type: "imageGallery",
|
||||
name: galleryEntry.fields.name,
|
||||
images: galleryEntry.fields.images.map((img) => ({
|
||||
url: img.fields.file.url,
|
||||
title: img.fields.title,
|
||||
description: img.fields.description,
|
||||
})),
|
||||
description: galleryEntry.fields.description,
|
||||
layout: galleryEntry.fields.layout,
|
||||
};
|
||||
},
|
||||
],
|
||||
[
|
||||
ContentType.image,
|
||||
(entry) => {
|
||||
const imageEntry = entry as ComponentImageSkeleton;
|
||||
return {
|
||||
type: "image",
|
||||
name: imageEntry.fields.name,
|
||||
imageUrl: imageEntry.fields.image.fields.file.url,
|
||||
caption: imageEntry.fields.caption,
|
||||
maxWidth: imageEntry.fields.maxWidth,
|
||||
aspectRatio: imageEntry.fields.aspectRatio,
|
||||
layout: imageEntry.fields.layout,
|
||||
};
|
||||
},
|
||||
],
|
||||
[
|
||||
ContentType.quote,
|
||||
(entry) => {
|
||||
const quoteEntry = entry as QuoteSkeleton;
|
||||
return {
|
||||
type: "quote",
|
||||
quote: quoteEntry.fields.quote,
|
||||
author: quoteEntry.fields.author,
|
||||
variant: quoteEntry.fields.variant,
|
||||
layout: quoteEntry.fields.layout,
|
||||
};
|
||||
},
|
||||
],
|
||||
[
|
||||
ContentType.youtubeVideo,
|
||||
(entry) => {
|
||||
const videoEntry = entry as ComponentYoutubeVideoSkeleton;
|
||||
return {
|
||||
type: "youtubeVideo",
|
||||
id: videoEntry.fields.id,
|
||||
youtubeId: videoEntry.fields.youtubeId,
|
||||
params: videoEntry.fields.params,
|
||||
title: videoEntry.fields.title,
|
||||
description: videoEntry.fields.description,
|
||||
layout: videoEntry.fields.layout,
|
||||
};
|
||||
},
|
||||
],
|
||||
[
|
||||
ContentType.headline,
|
||||
(entry) => {
|
||||
const headlineEntry = entry as ComponentHeadlineSkeleton;
|
||||
return {
|
||||
type: "headline",
|
||||
internal: headlineEntry.fields.internal,
|
||||
text: headlineEntry.fields.text,
|
||||
tag: headlineEntry.fields.tag,
|
||||
align: headlineEntry.fields.align,
|
||||
layout: headlineEntry.fields.layout,
|
||||
};
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Mappt ein Contentful Content-Item zu unserem ContentItem
|
||||
* Verwendet Strategy Pattern für wartbaren Code
|
||||
*/
|
||||
private static mapContentItem(entry: ContentEntry): ContentItem | null {
|
||||
if (!entry.contentTypeId || !entry.fields) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mapper = this.contentMappers.get(entry.contentTypeId);
|
||||
if (!mapper) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mapper(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mappt eine CMS Content-Row zu unserer ContentRow
|
||||
*/
|
||||
private static mapContentRow(
|
||||
content: ContentEntry[],
|
||||
justifyContent: string,
|
||||
alignItems: string
|
||||
): ContentRow | undefined {
|
||||
if (!content || content.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const mappedContent = content
|
||||
.map((entry) => this.mapContentItem(entry))
|
||||
.filter((item): item is ContentItem => item !== null);
|
||||
|
||||
if (mappedContent.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
justifyContent: justifyContent as ContentRow["justifyContent"],
|
||||
alignItems: alignItems as ContentRow["alignItems"],
|
||||
content: mappedContent,
|
||||
};
|
||||
}
|
||||
|
||||
static fromCms(cmsPage: CmsPage): Page {
|
||||
return {
|
||||
slug: cmsPage.slug,
|
||||
name: cmsPage.name,
|
||||
linkName: cmsPage.linkName,
|
||||
headline: cmsPage.headline,
|
||||
subheadline: cmsPage.subheadline,
|
||||
seoTitle: cmsPage.seoTitle,
|
||||
seoMetaRobots: cmsPage.seoMetaRobots,
|
||||
seoDescription: cmsPage.seoDescription,
|
||||
topFullwidthBanner: cmsPage.topFullwidthBanner
|
||||
? {
|
||||
name: cmsPage.topFullwidthBanner.fields.name,
|
||||
variant: cmsPage.topFullwidthBanner.fields.variant,
|
||||
headline: cmsPage.topFullwidthBanner.fields.headline,
|
||||
subheadline: cmsPage.topFullwidthBanner.fields.subheadline,
|
||||
text: cmsPage.topFullwidthBanner.fields.text,
|
||||
imageUrl: cmsPage.topFullwidthBanner.fields.img.fields.file.url,
|
||||
}
|
||||
: undefined,
|
||||
row1: this.mapContentRow(
|
||||
cmsPage.row1Content,
|
||||
cmsPage.row1JustifyContent,
|
||||
cmsPage.row1AlignItems
|
||||
),
|
||||
row2: this.mapContentRow(
|
||||
cmsPage.row2Content,
|
||||
cmsPage.row2JustifyContent,
|
||||
cmsPage.row2AlignItems
|
||||
),
|
||||
row3: this.mapContentRow(
|
||||
cmsPage.row3Content,
|
||||
cmsPage.row3JustifyContent,
|
||||
cmsPage.row3AlignItems
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
static fromCmsArray(cmsPages: CmsPage[]): Page[] {
|
||||
return cmsPages.map((page) => this.fromCms(page));
|
||||
}
|
||||
}
|
||||
112
middlelayer/monitoring/README.md
Normal file
112
middlelayer/monitoring/README.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Monitoring & Observability
|
||||
|
||||
Der Middlelayer ist mit umfassendem Monitoring ausgestattet:
|
||||
|
||||
## 1. Structured Logging (Winston)
|
||||
|
||||
**Konfiguration:**
|
||||
- Log-Level: `LOG_LEVEL` (default: `info`)
|
||||
- Format: JSON in Production, farbig in Development
|
||||
- Output: Console + Files (`logs/error.log`, `logs/combined.log`)
|
||||
|
||||
**Verwendung:**
|
||||
```typescript
|
||||
import { logger, logQuery, logError } from './monitoring/logger.js';
|
||||
|
||||
logger.info('Info message', { context: 'data' });
|
||||
logQuery('GetProducts', { limit: 10 }, 45);
|
||||
logError(error, { operation: 'getProducts' });
|
||||
```
|
||||
|
||||
## 2. Prometheus Metrics
|
||||
|
||||
**Endpoints:**
|
||||
- `GET http://localhost:9090/metrics` - Prometheus Metrics
|
||||
- `GET http://localhost:9090/health` - Health Check
|
||||
|
||||
**Verfügbare Metriken:**
|
||||
|
||||
### Query Metrics
|
||||
- `graphql_queries_total` - Anzahl der Queries (Labels: `operation`, `status`)
|
||||
- `graphql_query_duration_seconds` - Query-Dauer (Histogram)
|
||||
- `graphql_query_complexity` - Query-Komplexität (Gauge)
|
||||
|
||||
### Cache Metrics
|
||||
- `cache_hits_total` - Cache Hits (Label: `cache_type`)
|
||||
- `cache_misses_total` - Cache Misses (Label: `cache_type`)
|
||||
|
||||
### DataService Metrics
|
||||
- `dataservice_calls_total` - DataService Aufrufe (Labels: `method`, `status`)
|
||||
- `dataservice_duration_seconds` - DataService Dauer (Histogram)
|
||||
|
||||
### Error Metrics
|
||||
- `errors_total` - Anzahl der Fehler (Labels: `type`, `operation`)
|
||||
|
||||
**Beispiel Prometheus Query:**
|
||||
```promql
|
||||
# Query Rate
|
||||
rate(graphql_queries_total[5m])
|
||||
|
||||
# Error Rate
|
||||
rate(errors_total[5m])
|
||||
|
||||
# Cache Hit Ratio
|
||||
rate(cache_hits_total[5m]) / (rate(cache_hits_total[5m]) + rate(cache_misses_total[5m]))
|
||||
```
|
||||
|
||||
## 3. Distributed Tracing
|
||||
|
||||
**Features:**
|
||||
- Automatische Trace-ID-Generierung pro Request
|
||||
- Span-Tracking für verschachtelte Operationen
|
||||
- Dauer-Messung für Performance-Analyse
|
||||
|
||||
**Trace-IDs werden automatisch in Logs und Metrics eingebunden.**
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
# Logging
|
||||
LOG_LEVEL=info # debug, info, warn, error
|
||||
|
||||
# Metrics
|
||||
METRICS_PORT=9090 # Port für Metrics-Endpoint
|
||||
|
||||
# Query Complexity
|
||||
MAX_QUERY_COMPLEXITY=1000 # Max. Query-Komplexität
|
||||
```
|
||||
|
||||
## Integration mit Grafana
|
||||
|
||||
**Prometheus Scrape Config:**
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: 'graphql-middlelayer'
|
||||
static_configs:
|
||||
- targets: ['localhost:9090']
|
||||
```
|
||||
|
||||
**Grafana Dashboard:**
|
||||
- Importiere die Metriken in Grafana
|
||||
- Erstelle Dashboards für:
|
||||
- Query Performance
|
||||
- Cache Hit Rates
|
||||
- Error Rates
|
||||
- Request Throughput
|
||||
|
||||
## Beispiel-Dashboard Queries
|
||||
|
||||
```promql
|
||||
# Requests pro Sekunde
|
||||
sum(rate(graphql_queries_total[1m])) by (operation)
|
||||
|
||||
# Durchschnittliche Query-Dauer
|
||||
avg(graphql_query_duration_seconds) by (operation)
|
||||
|
||||
# Cache Hit Rate
|
||||
sum(rate(cache_hits_total[5m])) / (sum(rate(cache_hits_total[5m])) + sum(rate(cache_misses_total[5m])))
|
||||
|
||||
# Error Rate
|
||||
sum(rate(errors_total[5m])) by (type, operation)
|
||||
```
|
||||
|
||||
86
middlelayer/monitoring/logger.ts
Normal file
86
middlelayer/monitoring/logger.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import winston from "winston";
|
||||
|
||||
/**
|
||||
* Structured Logging mit Winston
|
||||
* Erstellt JSON-Logs für bessere Analyse und Monitoring
|
||||
*/
|
||||
export const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || "info",
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
),
|
||||
defaultMeta: {
|
||||
service: "graphql-middlelayer",
|
||||
environment: process.env.NODE_ENV || "development",
|
||||
},
|
||||
transports: [
|
||||
// Console Output (für Development)
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.printf(({ timestamp, level, message, ...meta }) => {
|
||||
const metaStr = Object.keys(meta).length
|
||||
? JSON.stringify(meta, null, 2)
|
||||
: "";
|
||||
return `${timestamp} [${level}]: ${message} ${metaStr}`;
|
||||
})
|
||||
),
|
||||
}),
|
||||
// File Output (für Production)
|
||||
...(process.env.NODE_ENV === "production"
|
||||
? [
|
||||
new winston.transports.File({
|
||||
filename: "logs/error.log",
|
||||
level: "error",
|
||||
}),
|
||||
new winston.transports.File({
|
||||
filename: "logs/combined.log",
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
|
||||
// Helper-Funktionen für strukturiertes Logging
|
||||
export const logQuery = (
|
||||
operationName: string,
|
||||
variables: any,
|
||||
duration: number
|
||||
) => {
|
||||
logger.info("GraphQL Query executed", {
|
||||
operation: operationName,
|
||||
variables,
|
||||
duration: `${duration}ms`,
|
||||
type: "query",
|
||||
});
|
||||
};
|
||||
|
||||
export const logError = (error: Error, context?: Record<string, any>) => {
|
||||
logger.error("Error occurred", {
|
||||
error: {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
name: error.name,
|
||||
},
|
||||
...context,
|
||||
type: "error",
|
||||
});
|
||||
};
|
||||
|
||||
export const logCacheHit = (key: string, type: string) => {
|
||||
logger.debug("Cache hit", {
|
||||
cacheKey: key,
|
||||
cacheType: type,
|
||||
type: "cache",
|
||||
});
|
||||
};
|
||||
|
||||
export const logCacheMiss = (key: string, type: string) => {
|
||||
logger.debug("Cache miss", {
|
||||
cacheKey: key,
|
||||
cacheType: type,
|
||||
type: "cache",
|
||||
});
|
||||
};
|
||||
90
middlelayer/monitoring/metrics.ts
Normal file
90
middlelayer/monitoring/metrics.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Registry, Counter, Histogram, Gauge } from 'prom-client';
|
||||
|
||||
/**
|
||||
* Prometheus Metrics Registry
|
||||
* Sammelt Metriken für Monitoring und Alerting
|
||||
*/
|
||||
export const register = new Registry();
|
||||
|
||||
// Default Metrics (CPU, Memory, etc.)
|
||||
register.setDefaultLabels({
|
||||
app: 'graphql-middlelayer',
|
||||
});
|
||||
|
||||
// Query Metrics
|
||||
export const queryCounter = new Counter({
|
||||
name: 'graphql_queries_total',
|
||||
help: 'Total number of GraphQL queries',
|
||||
labelNames: ['operation', 'status'],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
export const queryDuration = new Histogram({
|
||||
name: 'graphql_query_duration_seconds',
|
||||
help: 'Duration of GraphQL queries in seconds',
|
||||
labelNames: ['operation'],
|
||||
buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
// Cache Metrics
|
||||
export const cacheHits = new Counter({
|
||||
name: 'cache_hits_total',
|
||||
help: 'Total number of cache hits',
|
||||
labelNames: ['cache_type'],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
export const cacheMisses = new Counter({
|
||||
name: 'cache_misses_total',
|
||||
help: 'Total number of cache misses',
|
||||
labelNames: ['cache_type'],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
// DataService Metrics
|
||||
export const dataServiceCalls = new Counter({
|
||||
name: 'dataservice_calls_total',
|
||||
help: 'Total number of DataService calls',
|
||||
labelNames: ['method', 'status'],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
export const dataServiceDuration = new Histogram({
|
||||
name: 'dataservice_duration_seconds',
|
||||
help: 'Duration of DataService calls in seconds',
|
||||
labelNames: ['method'],
|
||||
buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
// Error Metrics
|
||||
export const errorCounter = new Counter({
|
||||
name: 'errors_total',
|
||||
help: 'Total number of errors',
|
||||
labelNames: ['type', 'operation'],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
// Active Connections
|
||||
export const activeConnections = new Gauge({
|
||||
name: 'active_connections',
|
||||
help: 'Number of active connections',
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
// Query Complexity
|
||||
export const queryComplexityGauge = new Gauge({
|
||||
name: 'graphql_query_complexity',
|
||||
help: 'Complexity of GraphQL queries',
|
||||
labelNames: ['operation'],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
/**
|
||||
* Exportiert Metriken im Prometheus-Format
|
||||
*/
|
||||
export async function getMetrics(): Promise<string> {
|
||||
return register.metrics();
|
||||
}
|
||||
|
||||
78
middlelayer/monitoring/tracing.ts
Normal file
78
middlelayer/monitoring/tracing.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Einfaches Distributed Tracing
|
||||
* Erstellt Trace-IDs für Request-Tracking
|
||||
*/
|
||||
|
||||
interface TraceContext {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
parentSpanId?: string;
|
||||
startTime: number;
|
||||
}
|
||||
|
||||
const traces = new Map<string, TraceContext>();
|
||||
|
||||
/**
|
||||
* Erstellt einen neuen Trace
|
||||
*/
|
||||
export function createTrace(traceId?: string): TraceContext {
|
||||
const id = traceId || generateTraceId();
|
||||
const trace: TraceContext = {
|
||||
traceId: id,
|
||||
spanId: generateSpanId(),
|
||||
startTime: Date.now(),
|
||||
};
|
||||
traces.set(id, trace);
|
||||
return trace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen Child-Span
|
||||
*/
|
||||
export function createSpan(traceId: string, parentSpanId?: string): string {
|
||||
const trace = traces.get(traceId);
|
||||
if (!trace) {
|
||||
throw new Error(`Trace ${traceId} not found`);
|
||||
}
|
||||
|
||||
const spanId = generateSpanId();
|
||||
trace.parentSpanId = parentSpanId || trace.spanId;
|
||||
|
||||
return spanId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Beendet einen Trace und gibt die Dauer zurück
|
||||
*/
|
||||
export function endTrace(traceId: string): number {
|
||||
const trace = traces.get(traceId);
|
||||
if (!trace) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const duration = Date.now() - trace.startTime;
|
||||
traces.delete(traceId);
|
||||
return duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert eine Trace-ID
|
||||
*/
|
||||
function generateTraceId(): string {
|
||||
return `trace-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert eine Span-ID
|
||||
*/
|
||||
function generateSpanId(): string {
|
||||
return `span-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt Trace-Informationen
|
||||
*/
|
||||
export function getTrace(traceId: string): TraceContext | undefined {
|
||||
return traces.get(traceId);
|
||||
}
|
||||
|
||||
101
middlelayer/plugins/monitoring.ts
Normal file
101
middlelayer/plugins/monitoring.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { ApolloServerPlugin } from '@apollo/server';
|
||||
import { logger, logQuery, logError } from '../monitoring/logger.js';
|
||||
import {
|
||||
queryCounter,
|
||||
queryDuration,
|
||||
errorCounter,
|
||||
queryComplexityGauge,
|
||||
} from '../monitoring/metrics.js';
|
||||
import { createTrace, endTrace } from '../monitoring/tracing.js';
|
||||
|
||||
/**
|
||||
* Monitoring Plugin für Apollo Server
|
||||
* Sammelt Logs, Metrics und Traces für jeden Request
|
||||
*/
|
||||
export const monitoringPlugin = (): ApolloServerPlugin => {
|
||||
return {
|
||||
async requestDidStart() {
|
||||
return {
|
||||
async didResolveOperation({ request, operationName }) {
|
||||
// Erstelle Trace für Request
|
||||
const traceId = createTrace().traceId;
|
||||
(request as any).traceId = traceId;
|
||||
|
||||
logger.info('GraphQL operation started', {
|
||||
operationName: operationName || 'unknown',
|
||||
query: request.query,
|
||||
variables: request.variables,
|
||||
traceId,
|
||||
});
|
||||
},
|
||||
|
||||
async willSendResponse({ request, response }) {
|
||||
const traceId = (request as any).traceId;
|
||||
const operationName = request.operationName || 'unknown';
|
||||
const duration = traceId ? endTrace(traceId) : 0;
|
||||
|
||||
// Log Query
|
||||
logQuery(operationName, request.variables, duration);
|
||||
|
||||
// Metrics
|
||||
const status = response.errors && response.errors.length > 0 ? 'error' : 'success';
|
||||
queryCounter.inc({ operation: operationName, status });
|
||||
queryDuration.observe({ operation: operationName }, duration / 1000);
|
||||
|
||||
// Log Errors
|
||||
if (response.errors && response.errors.length > 0) {
|
||||
response.errors.forEach((error) => {
|
||||
logError(error as Error, {
|
||||
operationName,
|
||||
traceId,
|
||||
});
|
||||
errorCounter.inc({
|
||||
type: error.extensions?.code as string || 'UNKNOWN',
|
||||
operation: operationName,
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async didEncounterErrors({ request, errors }) {
|
||||
const traceId = (request as any).traceId;
|
||||
const operationName = request.operationName || 'unknown';
|
||||
|
||||
errors.forEach((error) => {
|
||||
logError(error as Error, {
|
||||
operationName,
|
||||
traceId,
|
||||
});
|
||||
errorCounter.inc({
|
||||
type: error.extensions?.code as string || 'UNKNOWN',
|
||||
operation: operationName,
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Plugin für Query Complexity Tracking
|
||||
*/
|
||||
export const queryComplexityMonitoringPlugin = (): ApolloServerPlugin => {
|
||||
return {
|
||||
async requestDidStart() {
|
||||
return {
|
||||
async didResolveOperation({ request, operationName }) {
|
||||
// Wird vom queryComplexityPlugin gesetzt
|
||||
const complexity = (request as any).complexity;
|
||||
if (complexity) {
|
||||
queryComplexityGauge.set(
|
||||
{ operation: operationName || 'unknown' },
|
||||
complexity
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
67
middlelayer/plugins/queryComplexity.ts
Normal file
67
middlelayer/plugins/queryComplexity.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { ApolloServerPlugin } from "@apollo/server";
|
||||
import { getComplexity, simpleEstimator } from "graphql-query-complexity";
|
||||
import { GraphQLError } from "graphql";
|
||||
|
||||
interface QueryComplexityPluginOptions {
|
||||
maxComplexity?: number;
|
||||
defaultComplexity?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apollo Server Plugin für Query Complexity Limits
|
||||
* Verhindert zu komplexe Queries, die das System überlasten könnten
|
||||
*/
|
||||
export const queryComplexityPlugin = (
|
||||
options: QueryComplexityPluginOptions = {}
|
||||
): ApolloServerPlugin => {
|
||||
const maxComplexity = options.maxComplexity ?? 1000;
|
||||
const defaultComplexity = options.defaultComplexity ?? 1;
|
||||
|
||||
return {
|
||||
async requestDidStart() {
|
||||
return {
|
||||
async didResolveOperation({ request, document, schema }) {
|
||||
if (!schema) return;
|
||||
|
||||
try {
|
||||
const complexity = getComplexity({
|
||||
schema,
|
||||
operationName: request.operationName || undefined,
|
||||
query: document,
|
||||
variables: request.variables || {},
|
||||
estimators: [
|
||||
// Basis-Komplexität für jeden Field
|
||||
simpleEstimator({ defaultComplexity }),
|
||||
],
|
||||
});
|
||||
|
||||
// Speichere Complexity im Request für Monitoring
|
||||
(request as any).complexity = complexity;
|
||||
|
||||
if (complexity > maxComplexity) {
|
||||
throw new GraphQLError(
|
||||
`Query zu komplex (${complexity}). Maximum: ${maxComplexity}`,
|
||||
{
|
||||
extensions: {
|
||||
code: "QUERY_TOO_COMPLEX",
|
||||
complexity,
|
||||
maxComplexity,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Wenn es ein Schema-Realm-Problem gibt, logge es aber blockiere nicht
|
||||
if (error.message?.includes("another module or realm")) {
|
||||
console.warn(
|
||||
"[Query Complexity] Schema-Realm-Konflikt, Complexity-Check übersprungen"
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
32
middlelayer/plugins/responseCache.ts
Normal file
32
middlelayer/plugins/responseCache.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import ApolloServerPluginResponseCache from "@apollo/server-plugin-response-cache";
|
||||
import type { GraphQLRequestContext } from "@apollo/server";
|
||||
|
||||
/**
|
||||
* Response Caching Plugin für Apollo Server
|
||||
* Cached GraphQL Responses basierend auf Query und Variablen
|
||||
*/
|
||||
export const createResponseCachePlugin = () => {
|
||||
return ApolloServerPluginResponseCache({
|
||||
// Session-ID für User-spezifisches Caching
|
||||
sessionId: async (
|
||||
requestContext: GraphQLRequestContext<any>
|
||||
): Promise<string | null> => {
|
||||
// Optional: User-ID aus Headers oder Context
|
||||
const userId = requestContext.request.http?.headers.get("x-user-id");
|
||||
return userId || null;
|
||||
},
|
||||
|
||||
// Cache nur bei erfolgreichen Queries
|
||||
shouldWriteToCache: async (
|
||||
requestContext: GraphQLRequestContext<any>
|
||||
): Promise<boolean> => {
|
||||
const query = requestContext.request.query;
|
||||
|
||||
if (!query) return false;
|
||||
|
||||
// Cache nur bestimmte Queries
|
||||
const cacheableQueries = ["products", "pageSeo", "navigation", "page"];
|
||||
return cacheableQueries.some((q) => query.includes(q));
|
||||
},
|
||||
});
|
||||
};
|
||||
207
middlelayer/resolvers.ts
Normal file
207
middlelayer/resolvers.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { dataService } from "./dataService.js";
|
||||
import { AdapterError, NotFoundError } from "./utils/errors.js";
|
||||
import type { GraphQLContext } from "./utils/dataloaders.js";
|
||||
import { userService } from "./auth/userService.js";
|
||||
import { logger } from "./monitoring/logger.js";
|
||||
import type { ContentItem } from "./types/page.js";
|
||||
import type { User } from "./types/user.js";
|
||||
|
||||
/**
|
||||
* Fehlerbehandlung für GraphQL Resolver
|
||||
*/
|
||||
function handleError(error: unknown): never {
|
||||
if (error instanceof NotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof AdapterError) {
|
||||
logger.error("Adapter-Fehler", {
|
||||
message: error.message,
|
||||
originalError: error.originalError,
|
||||
});
|
||||
throw new Error(`Datenfehler: ${error.message}`);
|
||||
}
|
||||
logger.error("Unerwarteter Fehler", { error });
|
||||
throw new Error("Ein unerwarteter Fehler ist aufgetreten");
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper-Funktion für GraphQL Resolver mit automatischem Error Handling
|
||||
*/
|
||||
async function withErrorHandling<T>(resolver: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await resolver();
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatiert einen User für GraphQL (konvertiert Date zu ISO String)
|
||||
*/
|
||||
function formatUserForGraphQL(user: User) {
|
||||
return {
|
||||
...user,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export const resolvers = {
|
||||
Query: {
|
||||
products: async (
|
||||
_: unknown,
|
||||
args: { limit?: number },
|
||||
context: GraphQLContext
|
||||
) => {
|
||||
return withErrorHandling(() => dataService.getProducts(args.limit));
|
||||
},
|
||||
product: async (
|
||||
_: unknown,
|
||||
args: { id: string },
|
||||
context: GraphQLContext
|
||||
) => {
|
||||
return withErrorHandling(async () => {
|
||||
// Verwende Dataloader für Batch-Loading
|
||||
const product = await context.loaders.product.load(args.id);
|
||||
if (!product) {
|
||||
throw new NotFoundError("Produkt", args.id);
|
||||
}
|
||||
return product;
|
||||
});
|
||||
},
|
||||
pageSeo: async (
|
||||
_: unknown,
|
||||
args: { locale?: string },
|
||||
context: GraphQLContext
|
||||
) => {
|
||||
return withErrorHandling(() => dataService.getPageSeo(args.locale));
|
||||
},
|
||||
page: async (
|
||||
_: unknown,
|
||||
args: { slug: string; locale?: string },
|
||||
context: GraphQLContext
|
||||
) => {
|
||||
return withErrorHandling(async () => {
|
||||
// Verwende Dataloader für Batch-Loading (mit Locale im Key)
|
||||
// Format: "slug:locale" oder "slug" (ohne "page:" Präfix)
|
||||
const cacheKey = args.locale
|
||||
? `${args.slug}:${args.locale}`
|
||||
: args.slug;
|
||||
const page = await context.loaders.page.load(cacheKey);
|
||||
if (!page) {
|
||||
throw new NotFoundError("Seite", args.slug);
|
||||
}
|
||||
return page;
|
||||
});
|
||||
},
|
||||
pages: async (
|
||||
_: unknown,
|
||||
args: { locale?: string },
|
||||
context: GraphQLContext
|
||||
) => {
|
||||
return withErrorHandling(() => dataService.getPages(args.locale));
|
||||
},
|
||||
homepage: async (
|
||||
_: unknown,
|
||||
args: { locale?: string },
|
||||
context: GraphQLContext
|
||||
) => {
|
||||
return withErrorHandling(async () => {
|
||||
// Homepage ist immer die Seite mit dem Slug "/"
|
||||
const homepage = await dataService.getPage("/", args.locale);
|
||||
if (!homepage) {
|
||||
throw new NotFoundError("Homepage", "/");
|
||||
}
|
||||
return homepage;
|
||||
});
|
||||
},
|
||||
navigation: async (
|
||||
_: unknown,
|
||||
args: { locale?: string },
|
||||
context: GraphQLContext
|
||||
) => {
|
||||
return withErrorHandling(() => dataService.getNavigation(args.locale));
|
||||
},
|
||||
me: async (_: unknown, __: unknown, context: GraphQLContext) => {
|
||||
return withErrorHandling(async () => {
|
||||
// Gibt aktuellen User zurück (null wenn nicht authentifiziert)
|
||||
if (!context.user) {
|
||||
return null;
|
||||
}
|
||||
return formatUserForGraphQL(context.user);
|
||||
});
|
||||
},
|
||||
translations: async (
|
||||
_: unknown,
|
||||
args: { locale?: string; namespace?: string },
|
||||
context: GraphQLContext
|
||||
) => {
|
||||
return withErrorHandling(() =>
|
||||
dataService.getTranslations(args.locale, args.namespace)
|
||||
);
|
||||
},
|
||||
},
|
||||
Mutation: {
|
||||
register: async (
|
||||
_: unknown,
|
||||
args: { email: string; password: string; name: string },
|
||||
context: GraphQLContext
|
||||
) => {
|
||||
return withErrorHandling(async () => {
|
||||
const result = await userService.register({
|
||||
email: args.email,
|
||||
password: args.password,
|
||||
name: args.name,
|
||||
});
|
||||
|
||||
logger.info("User registered via GraphQL", {
|
||||
userId: result.user.id,
|
||||
email: result.user.email,
|
||||
});
|
||||
|
||||
return {
|
||||
user: formatUserForGraphQL(result.user),
|
||||
token: result.token,
|
||||
};
|
||||
});
|
||||
},
|
||||
login: async (
|
||||
_: unknown,
|
||||
args: { email: string; password: string },
|
||||
context: GraphQLContext
|
||||
) => {
|
||||
return withErrorHandling(async () => {
|
||||
const result = await userService.login({
|
||||
email: args.email,
|
||||
password: args.password,
|
||||
});
|
||||
|
||||
logger.info("User logged in via GraphQL", {
|
||||
userId: result.user.id,
|
||||
email: result.user.email,
|
||||
});
|
||||
|
||||
return {
|
||||
user: formatUserForGraphQL(result.user),
|
||||
token: result.token,
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
ContentItem: {
|
||||
__resolveType(obj: ContentItem): string | null {
|
||||
// Map für Type-Resolution (Strategy Pattern)
|
||||
const typeMap: Record<ContentItem["type"], string> = {
|
||||
html: "HTMLContent",
|
||||
markdown: "MarkdownContent",
|
||||
iframe: "IframeContent",
|
||||
imageGallery: "ImageGalleryContent",
|
||||
image: "ImageContent",
|
||||
quote: "QuoteContent",
|
||||
youtubeVideo: "YoutubeVideoContent",
|
||||
headline: "HeadlineContent",
|
||||
};
|
||||
|
||||
return typeMap[obj.type] || null;
|
||||
},
|
||||
},
|
||||
};
|
||||
217
middlelayer/schema.ts
Normal file
217
middlelayer/schema.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
export const typeDefs = `#graphql
|
||||
type ProductPromotion {
|
||||
category: String!
|
||||
text: String!
|
||||
}
|
||||
|
||||
type Product {
|
||||
id: ID!
|
||||
name: String!
|
||||
description: String
|
||||
price: Float!
|
||||
originalPrice: Float
|
||||
currency: String!
|
||||
imageUrl: String
|
||||
category: String
|
||||
inStock: Boolean!
|
||||
promotion: ProductPromotion
|
||||
}
|
||||
|
||||
type PageSeo {
|
||||
title: String!
|
||||
description: String!
|
||||
metaRobotsIndex: String
|
||||
metaRobotsFollow: String
|
||||
}
|
||||
|
||||
type contentLayout {
|
||||
mobile: String!
|
||||
tablet: String
|
||||
desktop: String
|
||||
spaceBottom: Float
|
||||
}
|
||||
|
||||
type HTMLContent {
|
||||
type: String!
|
||||
name: String!
|
||||
html: String!
|
||||
layout: contentLayout!
|
||||
}
|
||||
|
||||
type MarkdownContent {
|
||||
type: String!
|
||||
name: String!
|
||||
content: String!
|
||||
layout: contentLayout!
|
||||
alignment: String!
|
||||
}
|
||||
|
||||
type IframeContent {
|
||||
type: String!
|
||||
name: String!
|
||||
content: String!
|
||||
iframe: String!
|
||||
overlayImageUrl: String
|
||||
layout: contentLayout!
|
||||
}
|
||||
|
||||
type ImageGalleryContent {
|
||||
type: String!
|
||||
name: String!
|
||||
images: [ImageGalleryImage!]!
|
||||
description: String
|
||||
layout: contentLayout!
|
||||
}
|
||||
|
||||
type ImageGalleryImage {
|
||||
url: String!
|
||||
title: String
|
||||
description: String
|
||||
}
|
||||
|
||||
type ImageContent {
|
||||
type: String!
|
||||
name: String!
|
||||
imageUrl: String!
|
||||
caption: String!
|
||||
maxWidth: Float
|
||||
aspectRatio: Float
|
||||
layout: contentLayout!
|
||||
}
|
||||
|
||||
type QuoteContent {
|
||||
type: String!
|
||||
quote: String!
|
||||
author: String!
|
||||
variant: String!
|
||||
layout: contentLayout!
|
||||
}
|
||||
|
||||
type YoutubeVideoContent {
|
||||
type: String!
|
||||
id: String!
|
||||
youtubeId: String!
|
||||
params: String
|
||||
title: String
|
||||
description: String
|
||||
layout: contentLayout!
|
||||
}
|
||||
|
||||
type HeadlineContent {
|
||||
type: String!
|
||||
internal: String!
|
||||
text: String!
|
||||
tag: String!
|
||||
align: String
|
||||
layout: contentLayout!
|
||||
}
|
||||
|
||||
union ContentItem = HTMLContent | MarkdownContent | IframeContent | ImageGalleryContent | ImageContent | QuoteContent | YoutubeVideoContent | HeadlineContent
|
||||
|
||||
type ContentRow {
|
||||
justifyContent: String!
|
||||
alignItems: String!
|
||||
content: [ContentItem!]!
|
||||
}
|
||||
|
||||
type Page {
|
||||
slug: String!
|
||||
name: String!
|
||||
linkName: String!
|
||||
headline: String!
|
||||
subheadline: String!
|
||||
seoTitle: String!
|
||||
seoMetaRobots: String!
|
||||
seoDescription: String!
|
||||
topFullwidthBanner: FullwidthBanner
|
||||
row1: ContentRow
|
||||
row2: ContentRow
|
||||
row3: ContentRow
|
||||
}
|
||||
|
||||
type FullwidthBanner {
|
||||
name: String!
|
||||
variant: String!
|
||||
headline: String!
|
||||
subheadline: String!
|
||||
text: String!
|
||||
imageUrl: String
|
||||
}
|
||||
|
||||
type NavigationLink {
|
||||
slug: String
|
||||
name: String!
|
||||
linkName: String!
|
||||
url: String
|
||||
icon: String
|
||||
newTab: Boolean
|
||||
}
|
||||
|
||||
type Navigation {
|
||||
name: String!
|
||||
internal: String!
|
||||
links: [NavigationLink!]!
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
ADMIN
|
||||
CUSTOMER
|
||||
GUEST
|
||||
}
|
||||
|
||||
type User {
|
||||
id: ID!
|
||||
email: String!
|
||||
name: String!
|
||||
role: UserRole!
|
||||
createdAt: String!
|
||||
}
|
||||
|
||||
type AuthResponse {
|
||||
user: User!
|
||||
token: String!
|
||||
}
|
||||
|
||||
type Translation {
|
||||
key: String!
|
||||
value: String!
|
||||
namespace: String
|
||||
}
|
||||
|
||||
type Translations {
|
||||
locale: String!
|
||||
translations: [Translation!]!
|
||||
}
|
||||
|
||||
type Query {
|
||||
products(limit: Int): [Product!]!
|
||||
product(id: ID!): Product
|
||||
pageSeo(locale: String): PageSeo!
|
||||
page(slug: String!, locale: String): Page
|
||||
pages(locale: String): [Page!]!
|
||||
homepage(locale: String): Page
|
||||
navigation(locale: String): Navigation!
|
||||
me: User
|
||||
translations(locale: String!, namespace: String): Translations!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
register(email: String!, password: String!, name: String!): AuthResponse!
|
||||
login(email: String!, password: String!): AuthResponse!
|
||||
}
|
||||
|
||||
type __Type {
|
||||
kind: __TypeKind!
|
||||
}
|
||||
|
||||
enum __TypeKind {
|
||||
SCALAR
|
||||
OBJECT
|
||||
INTERFACE
|
||||
UNION
|
||||
ENUM
|
||||
INPUT_OBJECT
|
||||
LIST
|
||||
NON_NULL
|
||||
}
|
||||
`;
|
||||
98
middlelayer/types/README.md
Normal file
98
middlelayer/types/README.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Type Structure
|
||||
|
||||
## Übersicht
|
||||
|
||||
Die Typen sind in zwei Kategorien aufgeteilt:
|
||||
|
||||
### 1. CMS-Typen (`types/cms/`)
|
||||
**Zweck:** Struktur, wie Daten vom CMS (Contentful) kommen
|
||||
|
||||
- `*Skeleton` - Wrapper mit `contentTypeId` und `fields`
|
||||
- Verwendet `ComponentLayout` (ist jetzt ein Alias für `contentLayout`)
|
||||
- Beispiel: `ComponentHeadlineSkeleton`, `HTMLSkeleton`, `MarkdownSkeleton`
|
||||
|
||||
**Verwendung:** Nur im Mapper (`mappers/pageMapper.ts`) und Mock-Daten (`adapters/Mock/_cms/`)
|
||||
|
||||
**Dateien:**
|
||||
- `Headline.ts`, `Html.ts`, `Markdown.ts`, `Iframe.ts`, `ImageGallery.ts`, `Image.ts`, `Quote.ts`, `YoutubeVideo.ts`
|
||||
- `Page.ts`, `Navigation.ts`, `SEO.ts`, `FullwidthBanner.ts`
|
||||
- `Layout.ts` - `ComponentLayout` (Alias für `contentLayout`)
|
||||
- `ContentType.enum.ts` - Enum für alle Content-Typen
|
||||
|
||||
### 2. Domain-Typen (`types/c_*.ts`, `types/page.ts`, etc.)
|
||||
**Zweck:** Struktur, wie Daten in der App verwendet werden
|
||||
|
||||
- `c_*` - Content-Item-Typen mit `type`-Feld für Discriminated Union
|
||||
- Verwendet `contentLayout` direkt
|
||||
- Beispiel: `c_headline`, `c_html`, `c_markdown`, `c_iframe`, `c_imageGallery`, `c_image`, `c_quote`, `c_youtubeVideo`
|
||||
|
||||
**Verwendung:** Überall in der App (GraphQL Schema, Astro Components, etc.)
|
||||
|
||||
**Dateien:**
|
||||
- `c_*.ts` - Alle Content-Item-Typen
|
||||
- `page.ts` - Page-Typ mit `ContentItem` Union und `ContentRow`
|
||||
- `contentLayout.ts` - Einheitlicher Layout-Typ
|
||||
- `pageSeo.ts`, `navigation.ts`, `product.ts`, `user.ts` - Weitere Domain-Typen
|
||||
|
||||
## Mapping
|
||||
|
||||
Der `PageMapper` konvertiert von CMS-Typen zu Domain-Typen:
|
||||
|
||||
```typescript
|
||||
// Beispiel: Headline
|
||||
CMS: ComponentHeadlineSkeleton {
|
||||
contentTypeId: ContentType.headline,
|
||||
fields: {
|
||||
internal: string,
|
||||
text: string,
|
||||
tag: "h1" | "h2" | ...,
|
||||
align?: "left" | "center" | "right",
|
||||
layout: ComponentLayout
|
||||
}
|
||||
}
|
||||
↓ (PageMapper.mapContentItem)
|
||||
Domain: c_headline {
|
||||
type: "headline",
|
||||
internal: string,
|
||||
text: string,
|
||||
tag: "h1" | "h2" | ...,
|
||||
align?: "left" | "center" | "right",
|
||||
layout: contentLayout
|
||||
}
|
||||
```
|
||||
|
||||
## Layout-Typen
|
||||
|
||||
- `ComponentLayout` (in `types/cms/Layout.ts`) = Alias für `contentLayout`
|
||||
- `contentLayout` (in `types/contentLayout.ts`) = Einheitlicher Layout-Typ für alle Content-Items
|
||||
|
||||
**Struktur:**
|
||||
```typescript
|
||||
{
|
||||
mobile: string; // z.B. "12", "6", "4"
|
||||
tablet?: string; // Optional, z.B. "8", "6"
|
||||
desktop?: string; // Optional, z.B. "6", "4"
|
||||
spaceBottom?: number; // Optional, z.B. 0, 0.5, 1, 1.5, 2
|
||||
}
|
||||
```
|
||||
|
||||
**Warum:** Redundanz vermeiden - beide haben die gleiche Struktur. `ComponentLayout` ist nur ein Alias, um die Semantik klar zu machen (CMS vs Domain).
|
||||
|
||||
## Content-Items Union
|
||||
|
||||
Alle Content-Items werden in `types/page.ts` zu einer Union zusammengefasst:
|
||||
|
||||
```typescript
|
||||
export type ContentItem =
|
||||
| c_html
|
||||
| c_markdown
|
||||
| c_iframe
|
||||
| c_imageGallery
|
||||
| c_image
|
||||
| c_quote
|
||||
| c_youtubeVideo
|
||||
| c_headline;
|
||||
```
|
||||
|
||||
Dies ermöglicht Type-Safe Discriminated Unions über das `type`-Feld.
|
||||
|
||||
10
middlelayer/types/c_headline.ts
Normal file
10
middlelayer/types/c_headline.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { contentLayout } from "./contentLayout";
|
||||
|
||||
export type c_headline = {
|
||||
type: "headline";
|
||||
internal: string;
|
||||
text: string;
|
||||
tag: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
||||
align?: "left" | "center" | "right";
|
||||
layout: contentLayout;
|
||||
};
|
||||
8
middlelayer/types/c_html.ts
Normal file
8
middlelayer/types/c_html.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { contentLayout } from "./contentLayout";
|
||||
|
||||
export type c_html = {
|
||||
type: "html";
|
||||
name: string;
|
||||
html: string;
|
||||
layout: contentLayout;
|
||||
};
|
||||
10
middlelayer/types/c_iframe.ts
Normal file
10
middlelayer/types/c_iframe.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { contentLayout } from "./contentLayout";
|
||||
|
||||
export type c_iframe = {
|
||||
type: "iframe";
|
||||
name: string;
|
||||
content: string;
|
||||
iframe: string;
|
||||
overlayImageUrl?: string;
|
||||
layout: contentLayout;
|
||||
};
|
||||
11
middlelayer/types/c_image.ts
Normal file
11
middlelayer/types/c_image.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { contentLayout } from "./contentLayout";
|
||||
|
||||
export type c_image = {
|
||||
type: "image";
|
||||
name: string;
|
||||
imageUrl: string;
|
||||
caption: string;
|
||||
maxWidth?: number;
|
||||
aspectRatio?: number;
|
||||
layout: contentLayout;
|
||||
};
|
||||
13
middlelayer/types/c_imageGallery.ts
Normal file
13
middlelayer/types/c_imageGallery.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { contentLayout } from "./contentLayout";
|
||||
|
||||
export type c_imageGallery = {
|
||||
type: "imageGallery";
|
||||
name: string;
|
||||
images: Array<{
|
||||
url: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}>;
|
||||
description?: string;
|
||||
layout: contentLayout;
|
||||
};
|
||||
9
middlelayer/types/c_markdown.ts
Normal file
9
middlelayer/types/c_markdown.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { contentLayout } from "./contentLayout";
|
||||
|
||||
export type c_markdown = {
|
||||
type: "markdown";
|
||||
name: string;
|
||||
content: string;
|
||||
layout: contentLayout;
|
||||
alignment: "left" | "center" | "right";
|
||||
};
|
||||
9
middlelayer/types/c_quote.ts
Normal file
9
middlelayer/types/c_quote.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { contentLayout } from "./contentLayout";
|
||||
|
||||
export type c_quote = {
|
||||
type: "quote";
|
||||
quote: string;
|
||||
author: string;
|
||||
variant: "left" | "right";
|
||||
layout: contentLayout;
|
||||
};
|
||||
11
middlelayer/types/c_youtubeVideo.ts
Normal file
11
middlelayer/types/c_youtubeVideo.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { contentLayout } from "./contentLayout";
|
||||
|
||||
export type c_youtubeVideo = {
|
||||
type: "youtubeVideo";
|
||||
id: string;
|
||||
youtubeId: string;
|
||||
params?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
layout: contentLayout;
|
||||
};
|
||||
16
middlelayer/types/cms/CloudinaryImage.ts
Normal file
16
middlelayer/types/cms/CloudinaryImage.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface CloudinaryImage {
|
||||
bytes: number;
|
||||
created_at: string;
|
||||
format: string;
|
||||
height: number;
|
||||
original_secure_url: string;
|
||||
original_url: string;
|
||||
public_id: string;
|
||||
resource_type: string;
|
||||
secure_url: string;
|
||||
type: string;
|
||||
url: string;
|
||||
version: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
42
middlelayer/types/cms/Content.ts
Normal file
42
middlelayer/types/cms/Content.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { HTMLSkeleton } from "./Html";
|
||||
import type { MarkdownSkeleton } from "./Markdown";
|
||||
import type { ComponentIframeSkeleton } from "./Iframe";
|
||||
import type { ImageGallerySkeleton } from "./ImageGallery";
|
||||
import type { ComponentImageSkeleton } from "./Image";
|
||||
import type { QuoteSkeleton } from "./Quote";
|
||||
import type { ComponentYoutubeVideoSkeleton } from "./YoutubeVideo";
|
||||
import type { ComponentHeadlineSkeleton } from "./Headline";
|
||||
|
||||
export type rowJutify =
|
||||
| "start"
|
||||
| "end"
|
||||
| "center"
|
||||
| "between"
|
||||
| "around"
|
||||
| "evenly";
|
||||
export type rowAlignItems = "start" | "end" | "center" | "baseline" | "stretch";
|
||||
|
||||
export type ContentEntry =
|
||||
| HTMLSkeleton
|
||||
| MarkdownSkeleton
|
||||
| ComponentIframeSkeleton
|
||||
| ImageGallerySkeleton
|
||||
| ComponentImageSkeleton
|
||||
| QuoteSkeleton
|
||||
| ComponentYoutubeVideoSkeleton
|
||||
| ComponentHeadlineSkeleton;
|
||||
|
||||
export interface Content {
|
||||
row1JustifyContent: rowJutify;
|
||||
row1AlignItems: rowAlignItems;
|
||||
row1Content: ContentEntry[];
|
||||
|
||||
row2JustifyContent: rowJutify;
|
||||
row2AlignItems: rowAlignItems;
|
||||
row2Content: ContentEntry[];
|
||||
|
||||
row3JustifyContent: rowJutify;
|
||||
row3AlignItems: rowAlignItems;
|
||||
row3Content: ContentEntry[];
|
||||
}
|
||||
|
||||
32
middlelayer/types/cms/ContentType.enum.ts
Normal file
32
middlelayer/types/cms/ContentType.enum.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export enum ContentType {
|
||||
"componentLinkList" = "componentLinkList",
|
||||
"badges" = "badges",
|
||||
"componentPostOverview" = "componentPostOverview",
|
||||
"footer" = "footer",
|
||||
"fullwidthBanner" = "fullwidthBanner",
|
||||
"headline" = "headline",
|
||||
"html" = "html",
|
||||
"image" = "image",
|
||||
"img" = "img",
|
||||
"iframe" = "iframe",
|
||||
"imgGallery" = "imageGallery",
|
||||
"internalReference" = "internalComponent",
|
||||
"link" = "link",
|
||||
"list" = "list",
|
||||
"markdown" = "markdown",
|
||||
"navigation" = "navigation",
|
||||
"page" = "page",
|
||||
"pageConfig" = "pageConfig",
|
||||
"picture" = "picture",
|
||||
"post" = "post",
|
||||
"postComponent" = "postComponent",
|
||||
"quote" = "quoteComponent",
|
||||
"richtext" = "richtext",
|
||||
"row" = "row",
|
||||
"rowLayout" = "rowLayout",
|
||||
"tag" = "tag",
|
||||
"youtubeVideo" = "youtubeVideo",
|
||||
"campaign" = "campaign",
|
||||
"campaigns" = "campaigns",
|
||||
}
|
||||
|
||||
24
middlelayer/types/cms/FullwidthBanner.ts
Normal file
24
middlelayer/types/cms/FullwidthBanner.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ComponentImgSkeleton } from "./Img";
|
||||
import type { CloudinaryImage } from "./CloudinaryImage";
|
||||
import type { ContentType } from "./ContentType.enum";
|
||||
|
||||
export enum FullwidthBannerVariant {
|
||||
"dark" = "dark",
|
||||
"light" = "light",
|
||||
}
|
||||
|
||||
export interface FullwidthBanner {
|
||||
name: string;
|
||||
variant: FullwidthBannerVariant;
|
||||
headline: string;
|
||||
subheadline: string;
|
||||
text: string;
|
||||
image: CloudinaryImage[];
|
||||
img: ComponentImgSkeleton;
|
||||
}
|
||||
|
||||
export type FullwidthBannerSkeleton = {
|
||||
contentTypeId: ContentType.fullwidthBanner;
|
||||
fields: FullwidthBanner;
|
||||
};
|
||||
|
||||
21
middlelayer/types/cms/Headline.ts
Normal file
21
middlelayer/types/cms/Headline.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ContentType } from "./ContentType.enum";
|
||||
import type { ComponentLayout } from "./Layout";
|
||||
|
||||
export type Component_Headline_Align = "left" | "center" | "right";
|
||||
|
||||
export type Component_Headline_Tag = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
||||
|
||||
export type alignTextClasses = "text-left" | "text-center" | "text-right";
|
||||
|
||||
export interface ComponentHeadline {
|
||||
internal: string;
|
||||
text: string;
|
||||
tag: Component_Headline_Tag;
|
||||
layout: ComponentLayout;
|
||||
align?: Component_Headline_Align;
|
||||
}
|
||||
|
||||
export interface ComponentHeadlineSkeleton {
|
||||
contentTypeId: ContentType.headline;
|
||||
fields: ComponentHeadline;
|
||||
}
|
||||
14
middlelayer/types/cms/Html.ts
Normal file
14
middlelayer/types/cms/Html.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ContentType } from "./ContentType.enum";
|
||||
import type { ComponentLayout } from "./Layout";
|
||||
|
||||
export interface HTML {
|
||||
id: string;
|
||||
html: string;
|
||||
layout: ComponentLayout;
|
||||
}
|
||||
|
||||
export type HTMLSkeleton = {
|
||||
contentTypeId: ContentType.html;
|
||||
fields: HTML;
|
||||
};
|
||||
|
||||
17
middlelayer/types/cms/Iframe.ts
Normal file
17
middlelayer/types/cms/Iframe.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ContentType } from "./ContentType.enum";
|
||||
import type { ComponentImgSkeleton } from "./Img";
|
||||
import type { ComponentLayout } from "./Layout";
|
||||
|
||||
export interface ComponentIframe {
|
||||
name: string;
|
||||
content: string;
|
||||
iframe: string;
|
||||
overlayImage?: ComponentImgSkeleton;
|
||||
layout: ComponentLayout;
|
||||
}
|
||||
|
||||
export interface ComponentIframeSkeleton {
|
||||
contentTypeId: ContentType.iframe;
|
||||
fields: ComponentIframe;
|
||||
}
|
||||
|
||||
18
middlelayer/types/cms/Image.ts
Normal file
18
middlelayer/types/cms/Image.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { ContentType } from "./ContentType.enum";
|
||||
import type { ComponentImgSkeleton } from "./Img";
|
||||
import type { ComponentLayout } from "./Layout";
|
||||
|
||||
export interface ComponentImage {
|
||||
name: string;
|
||||
image: ComponentImgSkeleton;
|
||||
caption: string;
|
||||
layout: ComponentLayout;
|
||||
maxWidth?: number;
|
||||
aspectRatio?: number;
|
||||
}
|
||||
|
||||
export interface ComponentImageSkeleton {
|
||||
contentTypeId: ContentType.image;
|
||||
fields: ComponentImage;
|
||||
}
|
||||
|
||||
16
middlelayer/types/cms/ImageGallery.ts
Normal file
16
middlelayer/types/cms/ImageGallery.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ContentType } from "./ContentType.enum";
|
||||
import type { ComponentImgSkeleton } from "./Img";
|
||||
import type { ComponentLayout } from "./Layout";
|
||||
|
||||
export interface ImageGallery {
|
||||
name: string;
|
||||
images: ComponentImgSkeleton[];
|
||||
layout: ComponentLayout;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ImageGallerySkeleton {
|
||||
contentTypeId: ContentType.imgGallery;
|
||||
fields: ImageGallery;
|
||||
}
|
||||
|
||||
26
middlelayer/types/cms/Img.ts
Normal file
26
middlelayer/types/cms/Img.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ContentType } from "./ContentType.enum";
|
||||
|
||||
export interface ComponentImgDetails {
|
||||
size: number;
|
||||
image: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ComponentImg {
|
||||
title: string;
|
||||
description: string;
|
||||
file: {
|
||||
url: string;
|
||||
details: ComponentImgDetails;
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ComponentImgSkeleton {
|
||||
contentTypeId: ContentType.img;
|
||||
fields: ComponentImg;
|
||||
}
|
||||
|
||||
13
middlelayer/types/cms/Layout.ts
Normal file
13
middlelayer/types/cms/Layout.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { ContentType } from "./ContentType.enum";
|
||||
import type { contentLayout } from "../contentLayout";
|
||||
|
||||
/**
|
||||
* CMS-spezifisches Layout (wird vom Mapper zu contentLayout konvertiert)
|
||||
* Verwendet contentLayout direkt, um Redundanz zu vermeiden
|
||||
*/
|
||||
export type ComponentLayout = contentLayout;
|
||||
|
||||
export interface ComponentLayoutSkeleton {
|
||||
contentTypeId: ContentType.rowLayout;
|
||||
fields: ComponentLayout;
|
||||
}
|
||||
9
middlelayer/types/cms/Link.ts
Normal file
9
middlelayer/types/cms/Link.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface Link {
|
||||
name: string;
|
||||
internal: string;
|
||||
linkName: string;
|
||||
url: string;
|
||||
icon?: string;
|
||||
newTab?: boolean;
|
||||
}
|
||||
|
||||
16
middlelayer/types/cms/Markdown.ts
Normal file
16
middlelayer/types/cms/Markdown.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ContentType } from "./ContentType.enum";
|
||||
import type { ComponentLayout } from "./Layout";
|
||||
import type { TextAlignment } from "./TextAlignment";
|
||||
|
||||
export interface Markdown {
|
||||
name: string;
|
||||
content: string;
|
||||
layout: ComponentLayout;
|
||||
alignment: TextAlignment;
|
||||
}
|
||||
|
||||
export type MarkdownSkeleton = {
|
||||
contentTypeId: ContentType.markdown;
|
||||
fields: Markdown;
|
||||
};
|
||||
|
||||
15
middlelayer/types/cms/Navigation.ts
Normal file
15
middlelayer/types/cms/Navigation.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { ContentType } from "./ContentType.enum";
|
||||
import type { Link } from "./Link";
|
||||
import type { Page } from "./Page";
|
||||
|
||||
export interface Navigation {
|
||||
name: string;
|
||||
internal: string;
|
||||
links: Array<{ fields: Link | Page }>;
|
||||
}
|
||||
|
||||
export interface NavigationSkeleton {
|
||||
contentTypeId: ContentType.navigation;
|
||||
fields: Navigation;
|
||||
}
|
||||
|
||||
20
middlelayer/types/cms/Page.ts
Normal file
20
middlelayer/types/cms/Page.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { ContentType } from "./ContentType.enum";
|
||||
import type { FullwidthBannerSkeleton } from "./FullwidthBanner";
|
||||
import type { Content } from "./Content";
|
||||
import type { SEO } from "./SEO";
|
||||
|
||||
export interface Page extends Content, SEO {
|
||||
slug: string;
|
||||
name: string;
|
||||
linkName: string;
|
||||
icon?: string;
|
||||
headline: string;
|
||||
subheadline: string;
|
||||
topFullwidthBanner: FullwidthBannerSkeleton;
|
||||
}
|
||||
|
||||
export type PageSkeleton = {
|
||||
contentTypeId: ContentType.page;
|
||||
fields: Page;
|
||||
};
|
||||
|
||||
19
middlelayer/types/cms/PageConfig.ts
Normal file
19
middlelayer/types/cms/PageConfig.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ContentType } from "./ContentType.enum";
|
||||
import type { ComponentImgSkeleton } from "./Img";
|
||||
|
||||
export interface PageConfig {
|
||||
logo: ComponentImgSkeleton;
|
||||
footerText1: string;
|
||||
seoTitle: string;
|
||||
seoDescription: string;
|
||||
blogTagPageHeadline: string;
|
||||
blogPostsPageHeadline: string;
|
||||
blogPostsPageSubHeadline: string;
|
||||
website: string;
|
||||
}
|
||||
|
||||
export interface PageConfigSkeleton {
|
||||
contentTypeId: ContentType.pageConfig;
|
||||
fields: PageConfig;
|
||||
}
|
||||
|
||||
15
middlelayer/types/cms/Quote.ts
Normal file
15
middlelayer/types/cms/Quote.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { ContentType } from "./ContentType.enum";
|
||||
import type { ComponentLayout } from "./Layout";
|
||||
|
||||
export interface Quote {
|
||||
quote: string;
|
||||
author: string;
|
||||
variant: "left" | "right";
|
||||
layout: ComponentLayout;
|
||||
}
|
||||
|
||||
export type QuoteSkeleton = {
|
||||
contentTypeId: ContentType.quote;
|
||||
fields: Quote;
|
||||
};
|
||||
|
||||
12
middlelayer/types/cms/SEO.ts
Normal file
12
middlelayer/types/cms/SEO.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export type metaRobots =
|
||||
| "index, follow"
|
||||
| "noindex, follow"
|
||||
| "index, nofollow"
|
||||
| "noindex, nofollow";
|
||||
|
||||
export interface SEO {
|
||||
seoTitle: string;
|
||||
seoMetaRobots: metaRobots;
|
||||
seoDescription: string;
|
||||
}
|
||||
|
||||
7
middlelayer/types/cms/TextAlignment.ts
Normal file
7
middlelayer/types/cms/TextAlignment.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type TextAlignment = "left" | "center" | "right";
|
||||
export enum TextAlignmentClasses {
|
||||
"left" = "text-left",
|
||||
"center" = "text-center",
|
||||
"right" = "text-right",
|
||||
}
|
||||
|
||||
17
middlelayer/types/cms/YoutubeVideo.ts
Normal file
17
middlelayer/types/cms/YoutubeVideo.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ContentType } from "./ContentType.enum";
|
||||
import type { ComponentLayout } from "./Layout";
|
||||
|
||||
export interface YoutubeVideo {
|
||||
id: string;
|
||||
youtubeId: string;
|
||||
params?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
layout: ComponentLayout;
|
||||
}
|
||||
|
||||
export interface ComponentYoutubeVideoSkeleton {
|
||||
contentTypeId: ContentType.youtubeVideo;
|
||||
fields: YoutubeVideo;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user