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:
Peter Meier
2025-12-13 23:26:13 +01:00
parent ea288a5bbc
commit b1a556dc6d
167 changed files with 19057 additions and 131 deletions

133
middlelayer/IMPROVEMENTS.md Normal file
View 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

View 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
}

View 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;
}

View 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;
}

View 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;
}

View 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>[];
}

View 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",
}

View 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
}

View 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
}

View 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
}

View 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;
}

View 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;
};

View 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;
}

View 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;
}

View 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;
}

View 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
}

View 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
}

View 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
}

View 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;
}

View 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;
};

View 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
}

View 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;
};

View 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",
}

View 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;
};

View 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;
};

View 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;
};

View File

@@ -0,0 +1,7 @@
export interface CF_Page_Seo {
name: "page-about-seo",
title: "about",
description: "about",
metaRobotsIndex: "index",
metaRobotsFollow: "follow"
}

View 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
}

View 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;
};

View 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;
}

View 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;
};

View 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;
};

View 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
}

View 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,
}

View 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;

View 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;
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,6 @@
export type TextAlignment = 'left' | 'center' | 'right';
export enum TextAlignmentClasses {
'left' = 'text-left',
'center' = 'text-center',
'right' = 'text-right'
}

View 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,
};
}

View 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",
},
},
},
},
},
},
};
}

View 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',
};
}

View 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;
}

View 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;
}

View 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);
}
}

View 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();
}
}

View 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
View 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

View 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
View 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;
}

View 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;
}
}

View 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();

View 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
View 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
View 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);
});

View 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));
}
}

View 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)
```

View 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",
});
};

View 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();
}

View 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);
}

View 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
);
}
},
};
},
};
};

View 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;
}
},
};
},
};
};

View 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
View 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
View 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
}
`;

View 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.

View 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;
};

View File

@@ -0,0 +1,8 @@
import type { contentLayout } from "./contentLayout";
export type c_html = {
type: "html";
name: string;
html: string;
layout: contentLayout;
};

View 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;
};

View 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;
};

View 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;
};

View 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";
};

View 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;
};

View 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;
};

View 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;
}

View 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[];
}

View 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",
}

View 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;
};

View 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;
}

View 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;
};

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,9 @@
export interface Link {
name: string;
internal: string;
linkName: string;
url: string;
icon?: string;
newTab?: boolean;
}

View 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;
};

View 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;
}

View 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;
};

View 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;
}

View 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;
};

View 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;
}

View File

@@ -0,0 +1,7 @@
export type TextAlignment = "left" | "center" | "right";
export enum TextAlignmentClasses {
"left" = "text-left",
"center" = "text-center",
"right" = "text-right",
}

View 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;
}

View File

@@ -0,0 +1,22 @@
// Re-export all types for easier imports
export * from "./ContentType.enum";
export * from "./Layout";
export * from "./Html";
export * from "./Markdown";
export * from "./Img";
export * from "./Iframe";
export * from "./ImageGallery";
export * from "./Image";
export * from "./Quote";
export * from "./YoutubeVideo";
export * from "./Headline";
export * from "./FullwidthBanner";
export * from "./Content";
export * from "./SEO";
export * from "./Page";
export * from "./TextAlignment";
export * from "./CloudinaryImage";
export * from "./Navigation";
export * from "./Link";
export * from "./PageConfig";

View File

@@ -0,0 +1,6 @@
export type contentLayout = {
mobile: string;
tablet?: string;
desktop?: string;
spaceBottom?: number;
};

View File

@@ -0,0 +1,4 @@
export type { PageSeo } from "./pageSeo";
export type { Page } from "./page";
export type { Navigation, NavigationLink } from "./navigation";
export type { Product } from "./product";

View File

@@ -0,0 +1,15 @@
export interface NavigationLink {
slug?: string;
name: string;
linkName: string;
url?: string;
icon?: string;
newTab?: boolean;
}
export interface Navigation {
name: string;
internal: string;
links: NavigationLink[];
}

48
middlelayer/types/page.ts Normal file
View File

@@ -0,0 +1,48 @@
import type { contentLayout } from "./contentLayout";
import type { c_html } from "./c_html";
import type { c_markdown } from "./c_markdown";
import type { c_iframe } from "./c_iframe";
import type { c_imageGallery } from "./c_imageGallery";
import type { c_image } from "./c_image";
import type { c_quote } from "./c_quote";
import type { c_youtubeVideo } from "./c_youtubeVideo";
import type { c_headline } from "./c_headline";
export type { contentLayout };
export type ContentItem =
| c_html
| c_markdown
| c_iframe
| c_imageGallery
| c_image
| c_quote
| c_youtubeVideo
| c_headline;
export type ContentRow = {
justifyContent: "start" | "end" | "center" | "between" | "around" | "evenly";
alignItems: "start" | "end" | "center" | "baseline" | "stretch";
content: ContentItem[];
};
export interface Page {
slug: string;
name: string;
linkName: string;
headline: string;
subheadline: string;
seoTitle: string;
seoMetaRobots: string;
seoDescription: string;
topFullwidthBanner?: {
name: string;
variant: string;
headline: string;
subheadline: string;
text: string;
imageUrl?: string;
};
row1?: ContentRow;
row2?: ContentRow;
row3?: ContentRow;
}

View File

@@ -0,0 +1,7 @@
export interface PageSeo {
title: string;
description: string;
metaRobotsIndex?: string;
metaRobotsFollow?: string;
}

Some files were not shown because too many files have changed in this diff Show More