b2in/packages/flux-cms/ARCHITECTURE.md
2026-04-10 17:18:17 +02:00

496 lines
20 KiB
Markdown

# Flux CMS — Technische Architektur
## Datenfluss
```
┌───────────────────────────────────────────────────────┐
│ Blade Template │
│ {{ cms('welcome.hero.heading') }} │
│ {{ cms_media_url('welcome.hero.image', 'hero') }} │
│ {{ media_url('file.webp', 'card') }} │
└────────────────────┬──────────────────────────────────┘
┌───────────▼───────────────┐
│ Helper-Funktionen │
│ cms(), cms_media_url(), │
│ media_url(), tcms() │
│ (app/helpers.php) │
└───────────┬───────────────┘
┌───────────▼───────────────┐
│ CmsContentService │ Singleton via ServiceProvider
│ get($key, $replace) │
└───────────┬───────────────┘
┌─────────▼─────────┐
│ Cache Layer │ Cache::remember() pro Gruppe
│ (config TTL) │ Auto-Invalidierung bei Admin-Save
└─────────┬─────────┘
┌─────────▼─────────┐ ┌──────────────────┐
│ CmsContent Model │ │ __($key) │
│ (DB Lookup) ├─NO──► Fallback zu │
│ Übersetzbar │ │ Laravel Lang │
└───────────────────┘ └──────────────────┘
```
## Medien-Datenfluss
```
┌────────────────────────────────────────────────────────────┐
│ Blade Template │
│ {{ media_url('datei.webp', 'hero') }} │
└─────────────────────┬──────────────────────────────────────┘
┌───────────▼────────────────┐
│ media_url() Helper │
│ 1. In-Memory-Cache prüfen │
│ 2. CmsMedia::where(...) │
│ 3. Conversion vorhanden? │
│ 4. Fallback: asset(...) │
└───────────┬────────────────┘
┌────────────▼────────────────┐
│ CmsMedia Model │
│ - getUrl() │
│ - getConversionUrl($name) │
│ - hasThumbnail() │
│ - isPdf() / isImage() │
└────────────┬────────────────┘
┌────────────▼────────────────┐
│ Storage (public Disk) │
│ cms/media/originals/ │
│ cms/media/conversions/ │
│ cms/media/thumbnails/ │
└─────────────────────────────┘
```
### media_url() im Detail
```php
function media_url(?string $filename, string $profile = ''): string
{
// 1. Null/Leer → leerer String
// 2. In-Memory-Cache: $resolved[$cacheKey]
// 3. CmsMedia::where('filename', $filename)->first()
// 4. Wenn $profile und Conversion existiert → getConversionUrl($profile)
// 5. Ohne Profil → getUrl() (Original)
// 6. Fallback: asset('assets/images/' . $filename)
}
```
### cms_media_url() im Detail
```php
function cms_media_url(string $key, string $profile = ''): string
{
// 1. cms($key) → Dateiname aus CmsContent
// 2. media_url($filename, $profile)
}
```
## Key-Resolution
Der `cms()`-Aufruf `cms('welcome.hero.heading')` wird so aufgelöst:
1. **Parsing**: `welcome``group`, `hero.heading``key`
2. **Cache-Lookup**: `flux_cms.content.welcome` (alle Einträge der Gruppe)
3. **Key-Match**: `firstWhere('key', 'hero.heading')`
4. **Übersetzung**: `getTranslation('value', app()->getLocale())` mit Fallback auf `config('app.fallback_locale')`
5. **Platzhalter**: `:highlight` → Ersetzung aus `$replace` Array
6. **Fallback**: Wenn nichts in DB → `__('welcome.hero.heading', $replace, $locale)`
## Datenbankschema
### flux_cms_contents
```sql
CREATE TABLE flux_cms_contents (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`group` VARCHAR(255) NOT NULL,
`key` VARCHAR(255) NOT NULL,
type VARCHAR(255) DEFAULT 'text', -- text, html, image, json, link
value JSON NOT NULL, -- {"de": "...", "en": "..."}
`order` INT DEFAULT 0,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
UNIQUE INDEX (group, key)
);
```
### flux_cms_media
```sql
CREATE TABLE flux_cms_media (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
filename VARCHAR(255) NOT NULL, -- Original-Dateiname
path VARCHAR(512) NOT NULL, -- Storage-Pfad
type VARCHAR(50) DEFAULT 'image', -- image, pdf, document
mime_type VARCHAR(255) NULL,
file_size BIGINT UNSIGNED NULL,
original_width INT UNSIGNED NULL,
original_height INT UNSIGNED NULL,
disk VARCHAR(50) DEFAULT 'public',
collection VARCHAR(255) NULL, -- Optionale Sammlung
conversions JSON NULL, -- {"hero": "path/...", "card": "path/..."}
title JSON NULL, -- Übersetzbar
alt_text JSON NULL, -- Übersetzbar
is_published BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
INDEX (filename),
INDEX (type),
INDEX (collection)
);
```
### flux_cms_downloads
```sql
CREATE TABLE flux_cms_downloads (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
title JSON NOT NULL, -- Übersetzbar
description JSON NULL, -- Übersetzbar
category VARCHAR(255) NOT NULL, -- case_study, capability, success_story
file_path JSON NOT NULL, -- Übersetzbar: {"de": "file-de.pdf", "en": "file-en.pdf"}
thumbnail VARCHAR(255) NULL, -- CmsMedia-Dateiname
icon VARCHAR(255) NULL, -- Heroicon-Name
sub_category VARCHAR(255) NULL, -- Detail-Kategorie
type_label JSON NULL, -- Übersetzbar
alt JSON NULL, -- Übersetzbar: Alt-Text
open_text JSON NULL, -- Übersetzbar: Button-Text
download_text JSON NULL, -- Übersetzbar: Button-Text
highlights JSON NULL, -- [{"value": "100%", "label": "..."}]
checkpoints JSON NULL, -- [{"value": "..."}]
is_published BOOLEAN DEFAULT TRUE,
`order` INT DEFAULT 0,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL
);
```
### flux_cms_news_items
```sql
CREATE TABLE flux_cms_news_items (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
icon VARCHAR(255) NULL,
text JSON NULL, -- Ticker-Text (übersetzbar)
title JSON NULL,
excerpt JSON NULL,
content JSON NULL, -- Voller HTML-Body
image VARCHAR(255) NULL, -- CmsMedia-Dateiname
date DATE NULL,
author VARCHAR(255) NULL,
link VARCHAR(255) NULL,
pdf_path VARCHAR(255) NULL, -- CmsMedia-Dateiname (PDF)
pdf_open_text JSON NULL,
pdf_download_text JSON NULL,
is_published BOOLEAN DEFAULT TRUE,
`order` INT DEFAULT 0,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL
);
```
### flux_cms_industries
```sql
CREATE TABLE flux_cms_industries (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name JSON NOT NULL, -- Übersetzbar
is_published BOOLEAN DEFAULT TRUE,
`order` INT DEFAULT 0,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL
);
```
### flux_cms_faqs
```sql
CREATE TABLE flux_cms_faqs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
category VARCHAR(255) NOT NULL,
question JSON NOT NULL,
answer JSON NOT NULL,
help JSON NULL,
is_published BOOLEAN DEFAULT TRUE,
`order` INT DEFAULT 0,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL
);
```
### flux_cms_linkedin_posts
```sql
CREATE TABLE flux_cms_linkedin_posts (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
linkedin_id VARCHAR(255) NULL UNIQUE,
title JSON NULL,
excerpt JSON NULL,
content JSON NULL,
author VARCHAR(255) NULL,
date DATE NULL,
url VARCHAR(255) NULL,
image VARCHAR(255) NULL, -- CmsMedia-Dateiname
tags JSON NULL,
source VARCHAR(255) DEFAULT 'manual',
is_published BOOLEAN DEFAULT TRUE,
`order` INT DEFAULT 0,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL
);
```
### flux_cms_search_index
```sql
CREATE TABLE flux_cms_search_index (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
item_id VARCHAR(255) NOT NULL UNIQUE, -- z.B. 'home', 'leistungen'
route VARCHAR(255) NOT NULL, -- Named Route
route_params JSON NULL, -- Route-Parameter
category JSON NULL, -- Übersetzbar: Kategorie
title_key VARCHAR(255) NULL, -- CMS-Key für Titel
title_fallback JSON NULL, -- Übersetzbar: Fallback-Titel
description_key VARCHAR(255) NULL, -- CMS-Key für Beschreibung
description_fallback_key VARCHAR(255) NULL, -- Sekundärer CMS-Key
description_fallback_text JSON NULL, -- Übersetzbar: Statischer Text
keywords JSON NULL, -- Übersetzbar: String-Array
is_published BOOLEAN DEFAULT TRUE,
`order` INT DEFAULT 0,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
INDEX (item_id)
);
```
## Admin UI — Komponenten-Architektur
Alle Admin-Views sind **Livewire Volt Functional Components**:
```php
<?php
use function Livewire\Volt\{state, computed, on, layout};
layout('components.layouts.cms');
state([...]);
$items = computed(fn() => ...);
$save = function () { ... };
?>
<div>
{{-- Blade/Flux UI Template --}}
</div>
```
### Ausnahmen: Class-based Livewire
File-Uploads und modale Auswahl benötigen class-based Livewire-Komponenten:
| Komponente | Grund |
|------------|-------|
| `MediaLibraryUploader` | `WithFileUploads` Trait für Multi-File Upload |
| `MediaPicker` | `WithFileUploads` Trait für Quick-Upload + Event-System |
### MediaPicker — Event-Architektur
```
┌───────────────────────────────────────────┐
│ Admin-View (Volt Functional) │
│ state: $imageMediaId, $image │
│ │
│ on('media-selected', fn($field, ...)) │
│ → setzt MediaId + Dateiname │
│ │
│ <livewire:admin.cms.media-picker │
│ :value="$imageMediaId" │
│ field="news_image" │
│ type="image" │
│ profile="news" /> │
└──────────────┬────────────────────────────┘
│ dispatches 'media-selected'
┌──────────────▼────────────────────────────┐
│ MediaPicker (Class-based) │
│ - Modal mit Medienauswahl │
│ - Filterung nach Typ │
│ - Quick-Upload │
│ - $this->dispatch('media-selected', ...) │
└───────────────────────────────────────────┘
```
### Medien-Speicherung in Modellen
Alle Medien werden über ihren **CmsMedia-Dateiname** referenziert:
```
CmsContent (type=image) → value = "keyvisual.webp"
CmsNewsItem → image = "news-header.webp", pdf_path = "report.pdf"
CmsDownload → thumbnail = "case-study.webp", file_path = {"de": "cs-de.pdf", "en": "cs-en.pdf"}
CmsLinkedinPost → image = "linkedin-post.webp"
Team (JSON in CmsContent) → image = "person.jpg"
```
Die Auflösung zum tatsächlichen URL erfolgt immer über `media_url($filename, $profile)`.
## toFrontendArray() Pattern
Für die Frontend-Ausgabe bieten `CmsNewsItem` und `CmsDownload` eine `toFrontendArray()` Methode:
```php
// CmsDownload::toFrontendArray()
public function toFrontendArray(): array
{
return [
'title' => $this->getTranslation('title', app()->getLocale()),
'description' => $this->getTranslation('description', app()->getLocale()),
'image' => media_url($this->thumbnail ?? '', 'card'),
'pdf_path' => media_url(
$this->getTranslation('file_path', app()->getLocale()) ?? ''
),
'highlights' => $this->highlights ?? [],
'checkpoints' => $this->checkpoints ?? [],
'icon' => $this->icon,
// ... weitere Felder
];
}
```
Frontend-Verwendung:
```blade
@foreach (CmsDownload::published()->byCategory('case_study')->ordered()->get() as $download)
<x-download-article-card :article="$download->toFrontendArray()" :index="$loop->index" />
@endforeach
```
## Bildoptimierung — MediaConversionService
```
┌─────────────────────────────────────────────────────┐
│ MediaConversionService (Singleton) │
│ │
│ Abhängigkeiten: intervention/image v3 │
│ │
│ storeUploadedFile($file, $collection) │
│ ├── Speichert Original in cms/media/originals/ │
│ ├── Erstellt CmsMedia-Eintrag (mit Dimensionen) │
│ ├── Generiert Thumbnail automatisch │
│ └── Gibt CmsMedia zurück │
│ │
│ convert($media, $profileName) │
│ ├── Liest Profil aus Config │
│ ├── Resize (cover fit) via intervention/image │
│ ├── Konvertiert Format (webp, jpg, png) │
│ ├── Setzt Qualität │
│ ├── Speichert in cms/media/conversions/ │
│ └── Aktualisiert $media->conversions JSON │
│ │
│ generateThumbnail($media) │
│ ├── 200x200 Cover, WebP, Q70 │
│ ├── Speichert in cms/media/thumbnails/ │
│ └── Setzt $media->conversions['thumb'] │
│ │
│ generateAllConversions($media) │
│ └── Iteriert alle Profiles aus Config │
└─────────────────────────────────────────────────────┘
```
### Conversion-Profile
Konfigurierbar in `config/flux-cms.php`:
```php
'media' => [
'max_upload_size' => 20480, // KB
'allowed_types' => ['jpg', 'jpeg', 'png', 'webp', 'gif', 'svg', 'pdf', 'doc', 'docx'],
'storage_disk' => 'public',
'profiles' => [
'hero' => ['width' => 1920, 'height' => 800, 'format' => 'webp', 'quality' => 85],
'card' => ['width' => 768, 'height' => 512, 'format' => 'webp', 'quality' => 80],
'thumbnail' => ['width' => 400, 'height' => 300, 'format' => 'webp', 'quality' => 75],
'avatar' => ['width' => 400, 'height' => 400, 'format' => 'webp', 'quality' => 80],
'news' => ['width' => 1200, 'height' => 630, 'format' => 'webp', 'quality' => 80],
'thumb' => ['width' => 200, 'height' => 200, 'format' => 'webp', 'quality' => 70],
],
],
```
## Content-Typ-Erkennung (Seeder)
| Bedingung | Typ |
|-----------|-----|
| PHP-Array (assoziativ → flatten; indexed → direkt) | `json` |
| Enthält HTML-Tags (`<p>`, `<br>`, `<strong>`, etc.) | `html` |
| Dateiendung `.jpg`, `.png`, `.svg`, etc. | `image` |
| Dateiendung `.pdf`, `.doc`, etc. | `link` |
| Alles andere | `text` |
## HTML-Bereinigung (Seeder)
### font-semibold → `<strong>`
```html
<!-- Vorher (aus Lang-Datei) -->
<span class="font-semibold">Wichtig</span>
<!-- Nachher (in DB) -->
<strong>Wichtig</strong>
```
### text-gradient-premium → :highlight Pattern
```php
// Vorher:
'heading' => 'Wie unsere Ingenieurstrategen <span class="text-gradient-premium">die drei Säulen sichern</span>',
// Nachher:
'heading' => ':highlight die drei Säulen sichern',
'heading_highlight' => 'Wie unsere Ingenieurstrategen',
```
## Caching-Strategie
- **Content-Cache**: Pro Gruppe gecached (z.B. `flux_cms.content.welcome`)
- **media_url() Cache**: In-Memory-Cache pro Request (statische Variable)
- **TTL**: Konfigurierbar (`flux-cms.cache.ttl`, Standard: 3600s)
- **Invalidierung**: Automatisch bei Save im Admin via `CmsContentService::clearCache($group)`
- **Cache-Store**: Konfigurierbar (`flux-cms.cache.store`)
- **Seeder**: Ruft `clearCache()` nach dem Seeding auf
## Sortierung
- Inhalte werden in der Reihenfolge der `lang/`-Datei gespeichert (chronologisch)
- `order`-Feld wird beim Seeding sequentiell vergeben
- Industries, Downloads, News haben Up/Down-Buttons + numerisches Order-Feld
- Downloads können zusätzlich nach Kategorie gefiltert werden
## Mehrsprachigkeit
Alle Modelle nutzen `Spatie\Translatable\HasTranslations`:
```php
$content->getTranslation('value', 'de');
$content->setTranslation('value', 'en', 'New value');
$content->save();
```
Besonderheit bei `CmsDownload`:
- `file_path` ist ebenfalls übersetzbar (separates PDF pro Sprache)
- `toFrontendArray()` löst automatisch die aktuelle Locale auf
Im Admin kann zwischen Sprachen gewechselt werden via `switchLocale()`.
## Infrastruktur-Hinweise
### HTTPS / Proxy
- `trustProxies(at: '*')` in `bootstrap/app.php` für korrekte URL-Generierung
- `URL::forceScheme('https')` in `AppServiceProvider`
### File-Uploads
- `flux:file-upload` Komponente (Flux UI) für Livewire-Uploads
- Maximale Upload-Größe konfigurierbar in `config/flux-cms.php`
- Erlaubte MIME-Types in Validation-Rules der Upload-Komponenten