496 lines
20 KiB
Markdown
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
|