First commit
This commit is contained in:
commit
7cf3558ba7
12933 changed files with 1180047 additions and 0 deletions
786
packages/flux-cms/ARCHITECTURE.md
Normal file
786
packages/flux-cms/ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,786 @@
|
|||
# Flux CMS - Architecture Documentation
|
||||
|
||||
## Überblick
|
||||
|
||||
Flux CMS ist ein modular aufgebautes Content Management System für Laravel 12 mit Livewire 3 und Flux UI. Das System folgt einem "Code-as-Schema" Ansatz, bei dem Component-Felder direkt in PHP-Klassen definiert werden statt in der Datenbank.
|
||||
|
||||
## Architektur-Prinzipien
|
||||
|
||||
### 1. **Modularität**
|
||||
- Package-basierte Architektur mit separaten Modulen
|
||||
- Klare Trennung von Core, Components und Starter Components
|
||||
- Erweiterbar durch zusätzliche Packages
|
||||
|
||||
### 2. **Code-as-Schema**
|
||||
- Component-Felder werden in PHP-Klassen definiert
|
||||
- Keine Datenbank-Schemas für Felder
|
||||
- Typsicherheit und IDE-Unterstützung
|
||||
|
||||
### 3. **Multi-Domain & Multi-Language**
|
||||
- Unterstützung für mehrere Domains
|
||||
- Vollständige Mehrsprachigkeit mit spatie/laravel-translatable
|
||||
- Domain-spezifische Konfigurationen
|
||||
|
||||
### 4. **Component-First**
|
||||
- Seiten bestehen aus wiederverwendbaren Livewire Components
|
||||
- Drag & Drop Interface für Component-Anordnung
|
||||
- Flexibles Layout-System
|
||||
|
||||
## Package-Struktur
|
||||
|
||||
```
|
||||
packages/flux-cms/
|
||||
├── core/ # Kern-Funktionalität
|
||||
│ ├── src/
|
||||
│ │ ├── Models/ # Eloquent Models
|
||||
│ │ ├── Services/ # Business Logic
|
||||
│ │ ├── FieldTypes/ # Field Type System
|
||||
│ │ ├── Commands/ # Artisan Commands
|
||||
│ │ ├── Http/ # Controllers & Middleware
|
||||
│ │ │ └── Controllers/
|
||||
│ │ │ ├── Admin/ # Backend Controllers
|
||||
│ │ │ │ ├── PageController.php
|
||||
│ │ │ │ ├── BlogController.php
|
||||
│ │ │ │ └── ...
|
||||
│ │ │ └── PageController.php # Frontend Controller
|
||||
│ │ └── FluxCmsServiceProvider.php
|
||||
│ ├── database/migrations/ # Database Migrations
|
||||
│ ├── config/ # Konfigurationsdateien
|
||||
│ └── tests/ # Tests
|
||||
│
|
||||
├── components/ # Livewire Components
|
||||
│ ├── src/
|
||||
│ │ └── Livewire/
|
||||
│ │ ├── Backend/ # Admin Interface Components
|
||||
│ │ └── Frontend/ # Public Frontend Components
|
||||
│ └── resources/
|
||||
│ └── views/ # Blade Templates
|
||||
│
|
||||
└── starter-components/ # Vorgefertigte Components
|
||||
└── src/Components/ # Ready-to-use Components
|
||||
```
|
||||
|
||||
## Datenbank-Schema
|
||||
|
||||
### Core Tabellen
|
||||
|
||||
#### `flux_cms_pages`
|
||||
```sql
|
||||
- id (Primary Key)
|
||||
- domain_key (String) - Domain-Zuordnung
|
||||
- title (JSON) - Mehrsprachige Titel
|
||||
- slug (JSON) - Mehrsprachige URL-Slugs
|
||||
- content (JSON) - Mehrsprachiger Inhalt
|
||||
- template (String) - Template-Name
|
||||
- meta_title (JSON) - SEO Titel
|
||||
- meta_description (JSON) - SEO Beschreibung
|
||||
- is_published (Boolean)
|
||||
- published_at (Timestamp)
|
||||
- settings (JSON) - Zusätzliche Einstellungen
|
||||
- created_at, updated_at
|
||||
```
|
||||
|
||||
#### `flux_cms_page_components`
|
||||
```sql
|
||||
- id (Primary Key)
|
||||
- page_id (Foreign Key) - Verknüpfung zur Seite
|
||||
- component_type (String) - Livewire Component Klasse
|
||||
- content (JSON) - Component-spezifische Daten
|
||||
- settings (JSON) - Component-Einstellungen
|
||||
- order (Integer) - Sortierreihenfolge
|
||||
- is_active (Boolean)
|
||||
- created_at, updated_at
|
||||
```
|
||||
|
||||
#### `flux_cms_page_versions`
|
||||
```sql
|
||||
- id (Primary Key)
|
||||
- page_id (Foreign Key)
|
||||
- page_data (JSON) - Snapshot der Seite
|
||||
- components_data (JSON) - Snapshot der Components
|
||||
- version_name (String)
|
||||
- change_description (Text)
|
||||
- created_by_type, created_by_id (Polymorphic)
|
||||
- created_at, updated_at
|
||||
```
|
||||
|
||||
#### `flux_cms_navigations`
|
||||
```sql
|
||||
- id (Primary Key)
|
||||
- domain_key (String)
|
||||
- name (String) - Eindeutiger Name
|
||||
- display_name (JSON) - Mehrsprachiger Anzeigename
|
||||
- settings (JSON)
|
||||
- is_active (Boolean)
|
||||
- created_at, updated_at
|
||||
```
|
||||
|
||||
#### `flux_cms_navigation_items`
|
||||
```sql
|
||||
- id (Primary Key)
|
||||
- navigation_id (Foreign Key)
|
||||
- parent_id (Foreign Key, nullable) - Hierarchie
|
||||
- page_id (Foreign Key, nullable) - Verknüpfung zu Seite
|
||||
- title (JSON) - Mehrsprachiger Titel
|
||||
- url (String, nullable) - Externe URL
|
||||
- target (String) - Link-Ziel
|
||||
- order (Integer)
|
||||
- is_active (Boolean)
|
||||
- settings (JSON)
|
||||
- created_at, updated_at
|
||||
```
|
||||
|
||||
#### `flux_cms_blog_posts`
|
||||
```sql
|
||||
- id (Primary Key)
|
||||
- domain_key (String)
|
||||
- title (JSON) - Mehrsprachiger Titel
|
||||
- slug (JSON) - Mehrsprachige Slugs
|
||||
- excerpt (JSON) - Mehrsprachige Auszüge
|
||||
- content (JSON) - Mehrsprachiger Inhalt
|
||||
- meta_title (JSON) - SEO Titel
|
||||
- meta_description (JSON) - SEO Beschreibung
|
||||
- is_published (Boolean)
|
||||
- is_featured (Boolean)
|
||||
- published_at (Timestamp)
|
||||
- author_id (Foreign Key)
|
||||
- category (String)
|
||||
- tags (JSON)
|
||||
- settings (JSON)
|
||||
- created_at, updated_at
|
||||
```
|
||||
|
||||
#### `flux_cms_slugs` (Neu)
|
||||
```sql
|
||||
- id (Primary Key)
|
||||
- model_type, model_id (Polymorphic)
|
||||
- locale (String)
|
||||
- slug (String)
|
||||
- created_at, updated_at
|
||||
```
|
||||
|
||||
#### `tags` & `taggables` (Neu, von `spatie/laravel-tags`)
|
||||
```sql
|
||||
// tags
|
||||
- id
|
||||
- name (JSON)
|
||||
- slug (JSON)
|
||||
- type (String, nullable)
|
||||
- order_column (Integer, nullable)
|
||||
|
||||
// taggables
|
||||
- tag_id (Foreign Key)
|
||||
- taggable_type, taggable_id (Polymorphic)
|
||||
```
|
||||
|
||||
## Component-System
|
||||
|
||||
### Field Types
|
||||
|
||||
Das System bietet verschiedene Field Types für Component-Definitionen:
|
||||
|
||||
#### 1. **TextField**
|
||||
```php
|
||||
TextField::make('title', 'Titel')
|
||||
->translatable()
|
||||
->required()
|
||||
->placeholder('Titel eingeben')
|
||||
->helpText('Der Haupttitel des Elements')
|
||||
```
|
||||
|
||||
#### 2. **MediaField**
|
||||
```php
|
||||
MediaField::make('image', 'Bild')
|
||||
->images()
|
||||
->multiple(false)
|
||||
->required()
|
||||
```
|
||||
|
||||
#### 3. **WysiwygField**
|
||||
```php
|
||||
WysiwygField::make('content', 'Inhalt')
|
||||
->translatable()
|
||||
->toolbar(['bold', 'italic', 'link'])
|
||||
```
|
||||
|
||||
#### 4. **SelectField**
|
||||
```php
|
||||
SelectField::make('layout', 'Layout')
|
||||
->options([
|
||||
'default' => 'Standard',
|
||||
'wide' => 'Breit',
|
||||
'centered' => 'Zentriert'
|
||||
])
|
||||
->default('default')
|
||||
```
|
||||
|
||||
#### 5. **BooleanField**
|
||||
```php
|
||||
BooleanField::make('show_overlay', 'Overlay anzeigen')
|
||||
->toggle()
|
||||
->default(true)
|
||||
->helpText('Dunkles Overlay über dem Hintergrundbild')
|
||||
```
|
||||
|
||||
#### 6. **NumberField**
|
||||
```php
|
||||
NumberField::make('columns', 'Spalten')
|
||||
->min(1)
|
||||
->max(12)
|
||||
->default(3)
|
||||
->helpText('Anzahl der Spalten im Grid')
|
||||
```
|
||||
|
||||
### Component-Definition
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Components;
|
||||
|
||||
use Livewire\Component;
|
||||
use FluxCms\Core\FieldTypes\TextField;
|
||||
use FluxCms\Core\FieldTypes\MediaField;
|
||||
use FluxCms\Core\FieldTypes\BooleanField;
|
||||
|
||||
class HeroSection extends Component
|
||||
{
|
||||
public array $content = [];
|
||||
public array $settings = [];
|
||||
public int $componentId;
|
||||
|
||||
public static function getCmsFields(): array
|
||||
{
|
||||
return [
|
||||
TextField::make('headline', 'Hauptüberschrift')
|
||||
->translatable()
|
||||
->required()
|
||||
->placeholder('Ihre Hauptüberschrift'),
|
||||
|
||||
TextField::make('subheadline', 'Unterüberschrift')
|
||||
->translatable()
|
||||
->placeholder('Optionale Unterüberschrift'),
|
||||
|
||||
MediaField::make('background_image', 'Hintergrundbild')
|
||||
->images()
|
||||
->required(),
|
||||
|
||||
BooleanField::make('dark_overlay', 'Dunkles Overlay')
|
||||
->toggle()
|
||||
->default(true),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getCmsInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'Hero Section',
|
||||
'description' => 'Großer Bereich mit Hintergrundbild und Text',
|
||||
'category' => 'Header',
|
||||
'preview' => 'hero-preview.jpg'
|
||||
];
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('components.hero-section');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Service-Layer
|
||||
|
||||
### ComponentRegistry
|
||||
|
||||
Der ComponentRegistry Service scannt und verwaltet alle verfügbaren CMS Components:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace FluxCms\Core\Services;
|
||||
|
||||
class ComponentRegistry
|
||||
{
|
||||
protected array $components = [];
|
||||
protected bool $loaded = false;
|
||||
|
||||
public function getComponents(): array
|
||||
{
|
||||
if (!$this->loaded) {
|
||||
$this->loadComponents();
|
||||
}
|
||||
return $this->components;
|
||||
}
|
||||
|
||||
protected function loadComponents(): void
|
||||
{
|
||||
// Auto-discovery von Components
|
||||
// Caching für Performance
|
||||
// Validierung der Component-Struktur
|
||||
}
|
||||
|
||||
public function isValidComponent(string $className): bool
|
||||
{
|
||||
// Prüft ob Klasse gültige CMS Component ist
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Multi-Domain Unterstützung
|
||||
|
||||
### Domain-Konfiguration
|
||||
|
||||
```php
|
||||
// config/domains.php
|
||||
return [
|
||||
'portal.b2in.test' => [
|
||||
'type' => 'admin',
|
||||
'name' => 'Admin Portal',
|
||||
'theme' => 'admin',
|
||||
],
|
||||
'b2in.test' => [
|
||||
'type' => 'web',
|
||||
'name' => 'B2in Main',
|
||||
'theme' => 'b2in',
|
||||
'colors' => [
|
||||
'primary' => '#1f2937',
|
||||
'secondary' => '#3b82f6',
|
||||
],
|
||||
],
|
||||
// weitere Domains...
|
||||
];
|
||||
```
|
||||
|
||||
### Domain-spezifische Inhalte
|
||||
|
||||
Alle Inhalte werden über `domain_key` gefiltert:
|
||||
|
||||
```php
|
||||
// Automatische Domain-Filterung
|
||||
Page::forDomain(request()->getHost())->published()->get();
|
||||
BlogPost::forDomain($domainKey)->published()->get();
|
||||
Navigation::forDomain($domainKey)->active()->get();
|
||||
```
|
||||
|
||||
## Mehrsprachigkeit
|
||||
|
||||
### Spatie Translatable Integration
|
||||
|
||||
```php
|
||||
// Model Definition
|
||||
class Page extends Model
|
||||
{
|
||||
use HasTranslations;
|
||||
|
||||
protected $translatable = [
|
||||
'title',
|
||||
'slug',
|
||||
'content',
|
||||
'meta_title',
|
||||
'meta_description'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'title' => 'array',
|
||||
'slug' => 'array',
|
||||
// ...
|
||||
];
|
||||
}
|
||||
|
||||
// Verwendung
|
||||
$page->getTranslation('title', 'de');
|
||||
$page->setTranslation('title', 'de', 'Deutscher Titel');
|
||||
```
|
||||
|
||||
### Component-Inhalte
|
||||
|
||||
```php
|
||||
// Mehrsprachige Component-Inhalte
|
||||
$content = [
|
||||
'title' => [
|
||||
'de' => 'Deutscher Titel',
|
||||
'en' => 'English Title'
|
||||
],
|
||||
'description' => [
|
||||
'de' => 'Deutsche Beschreibung',
|
||||
'en' => 'English Description'
|
||||
]
|
||||
];
|
||||
```
|
||||
|
||||
## Versionierung
|
||||
|
||||
### Page Versions
|
||||
|
||||
```php
|
||||
class Page extends Model
|
||||
{
|
||||
public function createVersion(string $changeDescription = null): PageVersion
|
||||
{
|
||||
return $this->versions()->create([
|
||||
'page_data' => $this->toArray(),
|
||||
'components_data' => $this->allComponents()->get()->toArray(),
|
||||
'change_description' => $changeDescription,
|
||||
'version_name' => $this->generateVersionName(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function restoreVersion(PageVersion $version): void
|
||||
{
|
||||
// Backup current version
|
||||
$this->createVersion('Backup before restoration');
|
||||
|
||||
// Restore page data
|
||||
$this->update($version->page_data);
|
||||
|
||||
// Restore components
|
||||
$this->allComponents()->delete();
|
||||
foreach ($version->components_data as $componentData) {
|
||||
$this->allComponents()->create($componentData);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Medien-Management
|
||||
|
||||
### Spatie Media Library Integration
|
||||
|
||||
```php
|
||||
// Models implementieren HasMedia Interface
|
||||
class BlogPost extends Model implements HasMedia
|
||||
{
|
||||
use InteractsWithMedia;
|
||||
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('featured_image')
|
||||
->singleFile()
|
||||
->acceptsMimeTypes(['image/jpeg', 'image/png']);
|
||||
|
||||
$this->addMediaCollection('gallery')
|
||||
->acceptsMimeTypes(['image/jpeg', 'image/png']);
|
||||
}
|
||||
|
||||
public function registerMediaConversions(Media $media = null): void
|
||||
{
|
||||
$this->addMediaConversion('thumb')
|
||||
->width(300)
|
||||
->height(200);
|
||||
|
||||
$this->addMediaConversion('hero')
|
||||
->width(1200)
|
||||
->height(600);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## SEO & Performance
|
||||
|
||||
### SEO Features
|
||||
|
||||
```php
|
||||
class Page extends Model
|
||||
{
|
||||
public function getSeoTitle(string $locale = null): string
|
||||
{
|
||||
return $this->getTranslation('meta_title', $locale)
|
||||
?? $this->getTranslation('title', $locale);
|
||||
}
|
||||
|
||||
public function getSeoDescription(string $locale = null): string
|
||||
{
|
||||
return $this->getTranslation('meta_description', $locale)
|
||||
?? str_limit(strip_tags($this->getTranslation('content', $locale)), 160);
|
||||
}
|
||||
|
||||
public function getCanonicalUrl(): string
|
||||
{
|
||||
return route('pages.show', ['slug' => $this->slug]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Caching Strategy
|
||||
|
||||
```php
|
||||
// ComponentRegistry Caching
|
||||
Cache::remember('flux_cms_components', 3600, function () {
|
||||
return $this->scanForComponents();
|
||||
});
|
||||
|
||||
// Page Caching
|
||||
Cache::tags(['pages', 'page-'.$page->id])
|
||||
->remember('page-'.$page->id, 3600, function () use ($page) {
|
||||
return $page->with('components')->first();
|
||||
});
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
### Authorization Gates
|
||||
|
||||
```php
|
||||
// FluxCmsServiceProvider
|
||||
Gate::define('manage-flux-cms', function ($user) {
|
||||
return $user->hasRole('cms-manager');
|
||||
});
|
||||
|
||||
Gate::define('edit-page', function ($user, Page $page) {
|
||||
return $user->can('manage-flux-cms') &&
|
||||
$page->domain_key === request()->getHost();
|
||||
});
|
||||
```
|
||||
|
||||
### Input Validation
|
||||
|
||||
```php
|
||||
// Component Content Validation
|
||||
public function getValidationRules(): array
|
||||
{
|
||||
$rules = [];
|
||||
foreach ($this->getCmsFields() as $field) {
|
||||
$rules = array_merge($rules, $field->getValidationRules());
|
||||
}
|
||||
return $rules;
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Das Projekt verfolgt eine umfassende Teststrategie, die Unit-, Feature- und Browser-Tests kombiniert, um eine hohe Codequalität und Stabilität zu gewährleisten.
|
||||
|
||||
### Unit-Tests
|
||||
- **Fokus:** Isolierte Klassen und Methoden.
|
||||
- **Ziele:** Korrektheit von Model-Beziehungen, Scopes, Business-Logik in Services und die Funktionalität der Field Types sicherstellen.
|
||||
- **Beispiele:**
|
||||
- `PageTest`: Überprüft Scopes wie `published()` und Beziehungen wie `slugs()`.
|
||||
- `BlogPostTest`: Testet die polymorphe `author()`-Beziehung und die Tag-Funktionalität.
|
||||
|
||||
### Feature-Tests
|
||||
- **Fokus:** Komplette Anwendungs-Features über HTTP-Anfragen.
|
||||
- **Ziele:** Korrektheit von CRUD-Operationen, Routen, Controller-Logik, Validierung und Autorisierung testen.
|
||||
- **Beispiele:**
|
||||
- `PageControllerTest`: Simuliert das Erstellen, Bearbeiten und Löschen von Seiten, inklusive Tests für Validierungsfehler und Zugriffsrechte.
|
||||
- `BlogControllerTest`: Stellt sicher, dass die Blog-Verwaltung wie erwartet funktioniert.
|
||||
|
||||
### Browser-Tests (Laravel Dusk)
|
||||
- **Fokus:** Echte Benutzerinteraktionen in einem Browser.
|
||||
- **Ziele:** Klick-Pfade, UI-Komponenten, JavaScript-Interaktionen und komplette Workflows im Admin-Panel testen.
|
||||
- **Beispiele:**
|
||||
- `LoginTest`: Simuliert den Admin-Login.
|
||||
- Zukünftige Tests: Erstellen einer Seite mit Komponenten via Drag & Drop, Verwalten von Medien, etc.
|
||||
|
||||
## Deployment & Configuration
|
||||
|
||||
### Package Installation
|
||||
|
||||
```bash
|
||||
# Core Package
|
||||
composer require flux-cms/core
|
||||
|
||||
# Components Package
|
||||
composer require flux-cms/components
|
||||
|
||||
# Starter Components
|
||||
composer require flux-cms/starter-components
|
||||
|
||||
# Installation
|
||||
php artisan flux-cms:install
|
||||
```
|
||||
|
||||
### Konfiguration
|
||||
|
||||
```php
|
||||
// config/flux-cms.php
|
||||
return [
|
||||
'domains' => [
|
||||
'auto_discovery' => true,
|
||||
'cache_timeout' => 3600,
|
||||
],
|
||||
'components' => [
|
||||
'scan_paths' => [
|
||||
'app/Livewire/Components',
|
||||
'vendor/flux-cms/starter-components/src/Components',
|
||||
],
|
||||
'cache_enabled' => true,
|
||||
],
|
||||
'media' => [
|
||||
'disk' => 'public',
|
||||
'conversions' => [
|
||||
'thumb' => [300, 200],
|
||||
'hero' => [1200, 600],
|
||||
],
|
||||
],
|
||||
'seo' => [
|
||||
'auto_sitemap' => true,
|
||||
'meta_defaults' => [
|
||||
'title_suffix' => ' | ' . config('app.name'),
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
## Erweiterbarkeit
|
||||
|
||||
### Custom Field Types
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\FieldTypes;
|
||||
|
||||
use FluxCms\Core\FieldTypes\BaseField;
|
||||
|
||||
class ColorPickerField extends BaseField
|
||||
{
|
||||
public function getType(): string
|
||||
{
|
||||
return 'color_picker';
|
||||
}
|
||||
|
||||
public function getValidationRules(): array
|
||||
{
|
||||
return ['regex:/^#[0-9A-F]{6}$/i'];
|
||||
}
|
||||
|
||||
public function sanitizeValue(mixed $value): mixed
|
||||
{
|
||||
return strtoupper($value);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Components
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\CustomComponents;
|
||||
|
||||
use Livewire\Component;
|
||||
use FluxCms\Core\FieldTypes\TextField;
|
||||
use App\FieldTypes\ColorPickerField;
|
||||
|
||||
class CustomHero extends Component
|
||||
{
|
||||
public static function getCmsFields(): array
|
||||
{
|
||||
return [
|
||||
TextField::make('title', 'Titel')->translatable(),
|
||||
ColorPickerField::make('bg_color', 'Hintergrundfarbe')->default('#ffffff'),
|
||||
];
|
||||
}
|
||||
|
||||
// Implementation...
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. **Component Design**
|
||||
- Kleine, wiederverwendbare Components
|
||||
- Klare Field-Definitionen
|
||||
- Responsive Design
|
||||
- Accessibility beachten
|
||||
|
||||
### 2. **Performance**
|
||||
- Eager Loading von Relationships
|
||||
- Caching strategisch einsetzen
|
||||
- Lazy Loading für große Listen
|
||||
- Image Optimierung
|
||||
|
||||
### 3. **SEO**
|
||||
- Strukturierte Daten (JSON-LD)
|
||||
- Optimierte Meta Tags
|
||||
- Saubere URL-Struktur
|
||||
- Sitemap-Generation
|
||||
|
||||
### 4. **Security**
|
||||
- Input Validation
|
||||
- Authorization Gates
|
||||
- CSRF Protection
|
||||
- XSS Prevention
|
||||
|
||||
### 5. **Wartbarkeit**
|
||||
- Klare Namenskonventionen
|
||||
- Dokumentierte APIs
|
||||
- Typisierte Interfaces
|
||||
- Umfassende Tests
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Status Update (Dezember 2024)
|
||||
Das Flux CMS Package hat einen Vollständigkeitsgrad von **95%** erreicht und ist **produktionsreif**. Die Grundarchitektur ist vollständig implementiert mit umfassender Test-Suite, Admin-Interface und Component-System.
|
||||
|
||||
### Priorität 1 (Kurzfristig - Q1 2025)
|
||||
- [ ] **RESTful API-Endpoints:** Implementierung von API-Controllern für Headless CMS-Funktionalität
|
||||
- `Api/PageController.php` - Seiten-Management via API
|
||||
- `Api/BlogController.php` - Blog-Management via API
|
||||
- `Api/MediaController.php` - Medien-Management via API
|
||||
- `Api/ComponentController.php` - Component-Management via API
|
||||
- [ ] **Erweiterte Starter Components:** Vervollständigung der Component-Bibliothek
|
||||
- `TextSection.php` - Einfache Text-Bereiche
|
||||
- `ImageGallery.php` - Bildergalerien
|
||||
- `ContactForm.php` - Kontaktformulare
|
||||
- `TestimonialSection.php` - Testimonials
|
||||
- `PricingTable.php` - Preistabellen
|
||||
- [ ] **Dashboard Widgets:** Erweiterbares Dashboard-System
|
||||
- `RecentPagesWidget.php` - Zuletzt bearbeitete Seiten
|
||||
- `AnalyticsWidget.php` - Besucherstatistiken
|
||||
- `MediaUsageWidget.php` - Medienverwendung
|
||||
- `ComponentStatsWidget.php` - Component-Statistiken
|
||||
|
||||
### Priorität 2 (Mittelfristig - Q2 2025)
|
||||
- [ ] **Content Scheduling:** Vorausplanung von Inhalten
|
||||
- `ScheduledPublishing.php` - Automatisches Veröffentlichen
|
||||
- `ContentCalendar.php` - Content-Kalender
|
||||
- `Auto-Unpublish.php` - Automatisches Archivieren
|
||||
- [ ] **Advanced Analytics:** Erweiterte Analyse-Features
|
||||
- Integration mit Google Analytics
|
||||
- Content-Performance-Tracking
|
||||
- User-Engagement-Metriken
|
||||
- [ ] **Performance Monitoring:** System-Überwachung
|
||||
- Query-Performance-Monitoring
|
||||
- Cache-Hit-Rate-Tracking
|
||||
- Component-Load-Time-Analyse
|
||||
|
||||
### Priorität 3 (Langfristig - Q3-Q4 2025)
|
||||
- [ ] **A/B Testing Framework:** Content-Optimierung
|
||||
- `VariantManagement.php` - Varianten-Verwaltung
|
||||
- `AnalyticsIntegration.php` - Conversion-Tracking
|
||||
- `ConversionTracking.php` - Ziel-Tracking
|
||||
- [ ] **AI-powered Content Suggestions:** Intelligente Inhaltsvorschläge
|
||||
- Automatische SEO-Optimierung
|
||||
- Content-Gap-Analyse
|
||||
- Intelligente Tag-Vorschläge
|
||||
- [ ] **GraphQL API:** Moderne API-Technologie
|
||||
- Vollständige GraphQL-Implementierung
|
||||
- Real-time Subscriptions
|
||||
- Schema-First Development
|
||||
|
||||
### Bereits implementiert ✅
|
||||
- [x] **Umfassende Test-Suite:** Feature- und Unit-Tests für alle Komponenten und Services
|
||||
- [x] **Browser-Tests:** Laravel Dusk-Tests für kritische Admin-Workflows
|
||||
- [x] **Tagging-UI für Blog Posts:** Bearbeiten von Tags pro Beitrag implementiert
|
||||
- [x] **Slug-System:** Polymorphe Slugs für mehrsprachige URLs
|
||||
- [x] **MakeComponentCommand:** Artisan Command für Component-Erstellung
|
||||
- [x] **Admin-Controller:** Vollständige CRUD-Operationen für alle Entitäten
|
||||
- [x] **Livewire Components:** Backend und Frontend Components
|
||||
- [x] **Multi-Domain Support:** Vollständige Domain-Verwaltung
|
||||
- [x] **Versionierung:** Page-Version-Management
|
||||
- [x] **Media Management:** Spatie Media Library Integration
|
||||
|
||||
### Technische Verbesserungen
|
||||
- [ ] **Statische Code-Analyse:** Integration von PHPStan Level 8
|
||||
- [ ] **CI/CD Pipeline:** Automatisierte Tests und Deployment
|
||||
- [ ] **Documentation:** API-Dokumentation mit OpenAPI/Swagger
|
||||
- [ ] **Performance Optimization:** Advanced Caching-Strategien
|
||||
- [ ] **Security Hardening:** Penetration Testing und Security Audit
|
||||
|
||||
### Community & Ecosystem
|
||||
- [ ] **Plugin-System:** Erweiterbares Plugin-Framework
|
||||
- [ ] **Theme-System:** Vollständiges Theme-Management
|
||||
- [ ] **Marketplace:** Component- und Theme-Marketplace
|
||||
- [ ] **Developer Tools:** CLI-Tools für Entwickler
|
||||
- [ ] **Migration Tools:** Import/Export-Funktionalität
|
||||
34
packages/flux-cms/CONTRIBUTING.md
Normal file
34
packages/flux-cms/CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Contributing to Flux CMS
|
||||
|
||||
We welcome contributions from the community! Thank you for your interest in making Flux CMS better.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
- Use the GitHub issue tracker to report bugs.
|
||||
- Please include a clear title and description, as much relevant information as possible, and a code sample or an executable test case demonstrating the expected behavior that is not occurring.
|
||||
|
||||
### Suggesting Enhancements
|
||||
|
||||
- Use the GitHub issue tracker to suggest new features or enhancements.
|
||||
- Clearly describe the proposed enhancement and the motivation for it.
|
||||
|
||||
### Pull Requests
|
||||
|
||||
1. **Fork the repo** and create your branch from `main`.
|
||||
2. **Add tests!** Your patch won't be accepted if it doesn't have tests.
|
||||
3. **Ensure the test suite passes.**
|
||||
4. **Follow the coding style.** Please follow the PSR-12 coding standard.
|
||||
5. **Write a clear commit message.**
|
||||
6. **Issue that pull request!**
|
||||
|
||||
## Coding Style
|
||||
|
||||
Flux CMS follows the [PSR-12](https://www.php-fig.org/psr/psr-12/) coding standard. You can use tools like PHP-CS-Fixer to automatically format your code.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
We expect all contributors to adhere to our [Code of Conduct](CODE_OF_CONDUCT.md). Please be respectful and considerate of others.
|
||||
|
||||
Thank you for your contribution!
|
||||
597
packages/flux-cms/INSTALLATION.md
Normal file
597
packages/flux-cms/INSTALLATION.md
Normal file
|
|
@ -0,0 +1,597 @@
|
|||
# Flux CMS Installation Guide
|
||||
|
||||
This guide will walk you through installing and setting up Flux CMS in your Laravel application.
|
||||
|
||||
## System Requirements
|
||||
|
||||
- **PHP**: 8.2 or higher
|
||||
- **Laravel**: 11.0 or higher
|
||||
- **Livewire**: 3.0 or higher
|
||||
- **Database**: MySQL 8.0+ or PostgreSQL 13+
|
||||
- **Node.js**: 18+ (for asset compilation)
|
||||
|
||||
## Required Laravel Packages
|
||||
|
||||
Flux CMS requires these packages to be installed:
|
||||
|
||||
- `spatie/laravel-translatable`: For multilingual content
|
||||
- `spatie/laravel-medialibrary`: For media management
|
||||
- `spatie/laravel-tags`: For tagging blog posts
|
||||
- `livewire/livewire`: For reactive components
|
||||
- `livewire/flux`: For UI components (recommended)
|
||||
|
||||
## Step-by-Step Installation
|
||||
|
||||
### 1. Install Required Dependencies
|
||||
|
||||
```bash
|
||||
# Install required packages
|
||||
composer require spatie/laravel-translatable spatie/laravel-medialibrary spatie/laravel-tags
|
||||
|
||||
# Install Livewire if not already installed
|
||||
composer require livewire/livewire
|
||||
|
||||
# Install Flux UI (recommended for admin interface)
|
||||
composer require livewire/flux
|
||||
```
|
||||
|
||||
### 2. Install Flux CMS Packages
|
||||
|
||||
```bash
|
||||
# Install core package
|
||||
composer require flux-cms/core
|
||||
|
||||
# Install components package (recommended)
|
||||
composer require flux-cms/components
|
||||
|
||||
# Install starter components (optional)
|
||||
composer require flux-cms/starter-components
|
||||
```
|
||||
|
||||
### 3. Run Installation Command
|
||||
|
||||
```bash
|
||||
# This will publish config, run migrations, and set up permissions
|
||||
php artisan flux-cms:install
|
||||
|
||||
# Or with options
|
||||
php artisan flux-cms:install --no-migrate --no-publish
|
||||
```
|
||||
|
||||
The installation command will:
|
||||
- ✅ Check system requirements
|
||||
- 📦 Publish configuration files
|
||||
- 🗃️ Run database migrations
|
||||
- 🔗 Create storage link
|
||||
- 📝 Create sample content (optional)
|
||||
- 🔐 Set up permissions (optional)
|
||||
|
||||
### 4. Configure Your Application
|
||||
|
||||
#### Environment Variables
|
||||
|
||||
Add these to your `.env` file:
|
||||
|
||||
```env
|
||||
# Flux CMS Configuration
|
||||
FLUX_CMS_DEFAULT_LOCALE=de
|
||||
FLUX_CMS_CACHE_ENABLED=true
|
||||
FLUX_CMS_ROUTES_ENABLED=true
|
||||
|
||||
# Media Configuration
|
||||
FLUX_CMS_MEDIA_DISK=public
|
||||
FLUX_CMS_MAX_FILE_SIZE=10240
|
||||
|
||||
# Multi-domain (if using)
|
||||
FLUX_CMS_MULTI_DOMAIN=true
|
||||
FLUX_CMS_AUTO_DETECT_DOMAIN=true
|
||||
```
|
||||
|
||||
#### Update App Configuration
|
||||
|
||||
```php
|
||||
// config/app.php
|
||||
'locale' => env('APP_LOCALE', 'de'),
|
||||
'fallback_locale' => 'de',
|
||||
|
||||
// Add available locales
|
||||
'available_locales' => ['de', 'en'],
|
||||
```
|
||||
|
||||
### 5. Set Up Routes
|
||||
|
||||
#### Admin Routes
|
||||
|
||||
Add to your `routes/web.php` or create `routes/admin.php`:
|
||||
|
||||
```php
|
||||
// Admin routes (protected)
|
||||
Route::middleware(['web', 'auth'])->prefix('admin')->name('admin.')->group(function () {
|
||||
Route::prefix('cms')->name('cms.')->group(function () {
|
||||
Route::get('/', [Admin\DashboardController::class, 'index'])->name('index');
|
||||
Route::resource('/pages', Admin\PageController::class)->except(['show']);
|
||||
Route::get('/blog', [Admin\BlogController::class, 'index'])->name('blog.index');
|
||||
Route::get('/media', [Admin\MediaController::class, 'index'])->name('media.index');
|
||||
Route::get('/navigation', [Admin\NavigationController::class, 'index'])->name('navigation.index');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### Frontend Routes
|
||||
|
||||
Add to your `routes/web.php` (MUST be at the end):
|
||||
|
||||
```php
|
||||
// SEO routes
|
||||
Route::get('/sitemap.xml', [PageController::class, 'sitemap'])->name('sitemap');
|
||||
Route::get('/robots.txt', [PageController::class, 'robots'])->name('robots');
|
||||
|
||||
// Blog routes (if using blog)
|
||||
Route::prefix('blog')->name('blog.')->group(function () {
|
||||
Route::get('/', [PageController::class, 'blogIndex'])->name('index');
|
||||
Route::get('/{slug}', [PageController::class, 'blogPost'])->name('show');
|
||||
});
|
||||
|
||||
// CMS pages - MUST BE LAST!
|
||||
Route::get('/{slug?}', [PageController::class, 'show'])
|
||||
->where('slug', '.*')
|
||||
->name('cms.page');
|
||||
```
|
||||
|
||||
### 6. Create Controllers
|
||||
|
||||
#### Page Controller
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use FluxCms\Core\Models\Page;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PageController extends Controller
|
||||
{
|
||||
public function show(Request $request, string $slug = '/')
|
||||
{
|
||||
// Get domain key from existing domains config
|
||||
$domainKey = $this->getCurrentDomainKey($request);
|
||||
$locale = app()->getLocale();
|
||||
|
||||
// Find page
|
||||
$page = Page::forDomain($domainKey)
|
||||
->bySlug($slug, $locale)
|
||||
->published()
|
||||
->firstOrFail();
|
||||
|
||||
// Load components
|
||||
$components = $page->components()->get();
|
||||
|
||||
return view('pages.show', compact('page', 'components'));
|
||||
}
|
||||
|
||||
public function sitemap(Request $request)
|
||||
{
|
||||
$domainKey = $this->getCurrentDomainKey($request);
|
||||
$pages = Page::forDomain($domainKey)->published()->get();
|
||||
|
||||
return response()->view('sitemap', compact('pages'))
|
||||
->header('Content-Type', 'application/xml');
|
||||
}
|
||||
|
||||
protected function getCurrentDomainKey(Request $request): string
|
||||
{
|
||||
$host = $request->getHost();
|
||||
$domains = config('domains.domains', []);
|
||||
|
||||
foreach ($domains as $key => $config) {
|
||||
if (isset($config['url']) && parse_url($config['url'], PHP_URL_HOST) === $host) {
|
||||
return $key;
|
||||
}
|
||||
}
|
||||
|
||||
return config('flux-cms.domains.default_domain', 'default');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Admin Controller
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use FluxCms\Core\Models\Page;
|
||||
|
||||
class CmsController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('can:flux-cms.view');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$stats = [
|
||||
'pages' => Page::count(),
|
||||
'published_pages' => Page::published()->count(),
|
||||
'draft_pages' => Page::where('is_published', false)->count(),
|
||||
];
|
||||
|
||||
return view('admin.cms.index', compact('stats'));
|
||||
}
|
||||
|
||||
public function pages()
|
||||
{
|
||||
$pages = Page::with(['components'])
|
||||
->orderBy('updated_at', 'desc')
|
||||
->paginate(20);
|
||||
|
||||
return view('admin.cms.pages', compact('pages'));
|
||||
}
|
||||
|
||||
public function editPage(Page $page)
|
||||
{
|
||||
$this->authorize('flux-cms.edit');
|
||||
|
||||
return view('admin.cms.edit-page', compact('page'));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Create Views
|
||||
|
||||
#### Frontend Page Template
|
||||
|
||||
```blade
|
||||
{{-- resources/views/pages/show.blade.php --}}
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', $page->getSeoTitle())
|
||||
@section('description', $page->getSeoDescription())
|
||||
|
||||
@push('meta')
|
||||
<meta property="og:title" content="{{ $page->getTranslation('title') }}">
|
||||
<meta property="og:description" content="{{ $page->getSeoDescription() }}">
|
||||
<meta property="og:url" content="{{ request()->url() }}">
|
||||
@if($page->getTranslation('og_image'))
|
||||
<meta property="og:image" content="{{ $page->getTranslation('og_image') }}">
|
||||
@endif
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<main class="cms-page">
|
||||
@foreach($components as $component)
|
||||
@if($component->canRender())
|
||||
<div class="cms-component" data-component="{{ class_basename($component->component_class) }}">
|
||||
@livewire($component->component_class, [
|
||||
'content' => $component->getTranslations('content')
|
||||
], key('component-' . $component->id))
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</main>
|
||||
@endsection
|
||||
```
|
||||
|
||||
#### Admin Views
|
||||
|
||||
```blade
|
||||
{{-- resources/views/admin/cms/edit-page.blade.php --}}
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('title', 'Edit Page: ' . $page->getTranslation('title'))
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
@livewire('flux-cms::page-editor', ['page' => $page])
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// Enable drag & drop sorting
|
||||
Livewire.on('components-reordered', (orderedIds) => {
|
||||
// Handle reordering feedback
|
||||
console.log('Components reordered:', orderedIds);
|
||||
});
|
||||
|
||||
// Preview functionality
|
||||
Livewire.on('open-preview', (url) => {
|
||||
window.open(url, '_blank');
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
```
|
||||
|
||||
### 8. Set Up Permissions
|
||||
|
||||
If you're using Spatie Laravel Permission:
|
||||
|
||||
```php
|
||||
// database/seeders/CmsPermissionSeeder.php
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class CmsPermissionSeeder extends Seeder
|
||||
{
|
||||
public function run()
|
||||
{
|
||||
// Create permissions
|
||||
$permissions = [
|
||||
'flux-cms.view',
|
||||
'flux-cms.edit',
|
||||
'flux-cms.publish',
|
||||
'flux-cms.delete',
|
||||
'flux-cms.admin',
|
||||
];
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
Permission::firstOrCreate(['name' => $permission]);
|
||||
}
|
||||
|
||||
// Create CMS role
|
||||
$cmsRole = Role::firstOrCreate(['name' => 'flux-cms']);
|
||||
$cmsRole->syncPermissions($permissions);
|
||||
|
||||
// Assign to users
|
||||
// User::find(1)->assignRole('flux-cms');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run the seeder:
|
||||
|
||||
```bash
|
||||
php artisan db:seed --class=CmsPermissionSeeder
|
||||
```
|
||||
|
||||
### 9. Configure Multi-Domain (Optional)
|
||||
|
||||
If you're using multi-domain setup, ensure your `config/domains.php` is configured:
|
||||
|
||||
```php
|
||||
// config/domains.php
|
||||
return [
|
||||
'domains' => [
|
||||
'main' => [
|
||||
'name' => 'Main Site',
|
||||
'url' => env('APP_URL', 'https://example.com'),
|
||||
'theme' => 'default',
|
||||
],
|
||||
'blog' => [
|
||||
'name' => 'Blog Site',
|
||||
'url' => 'https://blog.example.com',
|
||||
'theme' => 'blog',
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
Update Flux CMS config:
|
||||
|
||||
```php
|
||||
// config/flux-cms.php
|
||||
'domains' => [
|
||||
'enabled' => true,
|
||||
'config_source' => 'domains', // Use domains.php config
|
||||
'auto_detect' => true,
|
||||
],
|
||||
```
|
||||
|
||||
### 10. Create Your First Component
|
||||
|
||||
Generate a new component:
|
||||
|
||||
```bash
|
||||
php artisan make:livewire Components/WelcomeHero
|
||||
```
|
||||
|
||||
Update the component:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Components;
|
||||
|
||||
use Livewire\Component;
|
||||
use FluxCms\Core\FieldTypes\TextField;
|
||||
use FluxCms\Core\FieldTypes\WysiwygField;
|
||||
|
||||
class WelcomeHero extends Component
|
||||
{
|
||||
public array $content = [];
|
||||
|
||||
public function mount(array $content = [])
|
||||
{
|
||||
$this->content = $content;
|
||||
}
|
||||
|
||||
public static function getCmsName(): string
|
||||
{
|
||||
return 'Welcome Hero';
|
||||
}
|
||||
|
||||
public static function getCmsCategory(): string
|
||||
{
|
||||
return 'Content';
|
||||
}
|
||||
|
||||
public static function getCmsFields(): array
|
||||
{
|
||||
return [
|
||||
TextField::make('headline', 'Headline')
|
||||
->translatable()
|
||||
->required(),
|
||||
|
||||
WysiwygField::make('description', 'Description')
|
||||
->translatable(),
|
||||
];
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.components.welcome-hero');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Create the view:
|
||||
|
||||
```blade
|
||||
{{-- resources/views/livewire/components/welcome-hero.blade.php --}}
|
||||
<section class="hero bg-gradient-to-r from-blue-500 to-purple-600 text-white py-20">
|
||||
<div class="container mx-auto px-4 text-center">
|
||||
@if($headline = $this->content['headline'][app()->getLocale()] ?? '')
|
||||
<h1 class="text-5xl font-bold mb-6">{{ $headline }}</h1>
|
||||
@endif
|
||||
|
||||
@if($description = $this->content['description'][app()->getLocale()] ?? '')
|
||||
<div class="text-xl prose prose-lg prose-invert mx-auto">
|
||||
{!! $description !!}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
### 11. Create Your First Page
|
||||
|
||||
```php
|
||||
// database/seeders/CmsContentSeeder.php
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use FluxCms\Core\Models\Page;
|
||||
|
||||
class CmsContentSeeder extends Seeder
|
||||
{
|
||||
public function run()
|
||||
{
|
||||
$homepage = Page::create([
|
||||
'domain_key' => 'main',
|
||||
'title' => [
|
||||
'de' => 'Startseite',
|
||||
'en' => 'Homepage'
|
||||
],
|
||||
'slug' => [
|
||||
'de' => '/',
|
||||
'en' => '/'
|
||||
],
|
||||
'meta_description' => [
|
||||
'de' => 'Willkommen auf unserer Website',
|
||||
'en' => 'Welcome to our website'
|
||||
],
|
||||
'is_published' => true,
|
||||
]);
|
||||
|
||||
// Add welcome hero component
|
||||
$homepage->allComponents()->create([
|
||||
'component_class' => \App\Livewire\Components\WelcomeHero::class,
|
||||
'order' => 1,
|
||||
'content' => [
|
||||
'headline' => [
|
||||
'de' => 'Willkommen bei Flux CMS',
|
||||
'en' => 'Welcome to Flux CMS'
|
||||
],
|
||||
'description' => [
|
||||
'de' => 'Das moderne Content Management System für Laravel.',
|
||||
'en' => 'The modern Content Management System for Laravel.'
|
||||
]
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run the seeder:
|
||||
|
||||
```bash
|
||||
php artisan db:seed --class=CmsContentSeeder
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After installation, verify everything works:
|
||||
|
||||
1. **Visit admin interface**: `/admin/cms`
|
||||
2. **Edit a page**: Click on your homepage
|
||||
3. **Add components**: Try adding components to your page
|
||||
4. **Check frontend**: Visit your homepage
|
||||
5. **Test translations**: Switch languages if configured
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 1. Component Registry Empty
|
||||
|
||||
```bash
|
||||
# Clear cache and refresh
|
||||
php artisan flux-cms:clear-cache
|
||||
php artisan cache:clear
|
||||
```
|
||||
|
||||
#### 2. Permission Denied
|
||||
|
||||
```bash
|
||||
# Check if user has CMS role
|
||||
php artisan tinker
|
||||
> User::find(1)->assignRole('flux-cms');
|
||||
```
|
||||
|
||||
#### 3. Media Files Not Loading
|
||||
|
||||
```bash
|
||||
# Ensure storage link exists
|
||||
php artisan storage:link
|
||||
|
||||
# Check disk configuration
|
||||
php artisan tinker
|
||||
> Storage::disk('public')->exists('test.txt');
|
||||
```
|
||||
|
||||
#### 4. Routes Not Working
|
||||
|
||||
- Ensure CMS routes are at the **end** of `routes/web.php`
|
||||
- Check middleware and permissions
|
||||
- Verify domain configuration if using multi-domain
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable debug mode in config:
|
||||
|
||||
```php
|
||||
// config/flux-cms.php
|
||||
'development' => [
|
||||
'debug_mode' => true,
|
||||
'show_component_info' => true,
|
||||
'log_queries' => true,
|
||||
],
|
||||
```
|
||||
|
||||
### Getting Help
|
||||
|
||||
- 📖 **Documentation**: Check the full documentation
|
||||
- 💬 **Community**: Join GitHub Discussions
|
||||
- 🐛 **Issues**: Report bugs on GitHub
|
||||
- 📧 **Support**: Contact support team
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Create custom components** for your specific needs
|
||||
2. **Set up navigation** using the navigation manager
|
||||
3. **Configure domains** if using multi-domain
|
||||
4. **Customize styling** to match your brand
|
||||
5. **Set up deployment** with proper caching
|
||||
|
||||
You're now ready to start building amazing content with Flux CMS! 🚀
|
||||
21
packages/flux-cms/LICENSE.md
Normal file
21
packages/flux-cms/LICENSE.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024 Flux CMS Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
563
packages/flux-cms/README.md
Normal file
563
packages/flux-cms/README.md
Normal file
|
|
@ -0,0 +1,563 @@
|
|||
# Flux CMS - Laravel Package Suite
|
||||
|
||||
🚀 **Modern, component-first CMS for Laravel with multi-domain support**
|
||||
|
||||
[](https://packagist.org/packages/flux-cms/core)
|
||||
[](LICENSE.md)
|
||||
[](https://packagist.org/packages/flux-cms/core)
|
||||
|
||||
## Overview
|
||||
|
||||
Flux CMS is a powerful, modern Content Management System built specifically for Laravel applications. It features a revolutionary "Code-as-Schema" approach where content structure is defined directly in PHP components, offering unprecedented flexibility and developer experience.
|
||||
|
||||
### 🎯 Key Features
|
||||
|
||||
- **🧩 Component-First Architecture** - Build pages from reusable Livewire components
|
||||
- **📝 Code-as-Schema** - Define fields in PHP, not in databases
|
||||
- **🌍 Multi-Domain Support** - Manage multiple websites from one installation
|
||||
- **🗣️ Full Multilingual** - Everything is translatable with fallbacks
|
||||
- **📦 Versioning** - Automatic content versioning and rollback
|
||||
- **🎨 Media Management** - Integrated media library with automatic conversions
|
||||
- **⚡ Performance** - Optimized queries and smart caching
|
||||
- **🔒 Secure** - Built-in security best practices
|
||||
|
||||
## Package Architecture
|
||||
|
||||
Flux CMS is split into modular packages for maximum flexibility:
|
||||
|
||||
### Core Packages
|
||||
|
||||
| Package | Description | Installation |
|
||||
|---------|-------------|--------------|
|
||||
| **flux-cms/core** | Core models, services, and field types | `composer require flux-cms/core` |
|
||||
| **flux-cms/components** | Livewire backend and frontend components | `composer require flux-cms/components` |
|
||||
| **flux-cms/starter-components** | Ready-to-use starter components | `composer require flux-cms/starter-components` |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Installation
|
||||
|
||||
```bash
|
||||
# Install core package
|
||||
composer require flux-cms/core
|
||||
|
||||
# Install components (optional but recommended)
|
||||
composer require flux-cms/components flux-cms/starter-components
|
||||
|
||||
# Install and setup
|
||||
php artisan flux-cms:install
|
||||
```
|
||||
|
||||
### 2. Basic Configuration
|
||||
|
||||
```php
|
||||
// config/flux-cms.php
|
||||
return [
|
||||
'locales' => [
|
||||
'de' => 'Deutsch',
|
||||
'en' => 'English',
|
||||
],
|
||||
'component_paths' => [
|
||||
'App\\Livewire\\Components',
|
||||
'FluxCms\\StarterComponents\\Components',
|
||||
],
|
||||
'domains' => [
|
||||
'enabled' => true,
|
||||
'config_source' => 'domains', // Use existing config/domains.php
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
### 3. Create Your First Component
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Components;
|
||||
|
||||
use Livewire\Component;
|
||||
use FluxCms\Core\FieldTypes\TextField;
|
||||
use FluxCms\Core\FieldTypes\WysiwygField;
|
||||
use FluxCms\Core\FieldTypes\MediaField;
|
||||
|
||||
class FeatureSection extends Component
|
||||
{
|
||||
public array $content = [];
|
||||
|
||||
public function mount(array $content = [])
|
||||
{
|
||||
$this->content = $content;
|
||||
}
|
||||
|
||||
public static function getCmsName(): string
|
||||
{
|
||||
return 'Feature Section';
|
||||
}
|
||||
|
||||
public static function getCmsDescription(): string
|
||||
{
|
||||
return 'Showcase features with icons and descriptions';
|
||||
}
|
||||
|
||||
public static function getCmsCategory(): string
|
||||
{
|
||||
return 'Content';
|
||||
}
|
||||
|
||||
public static function getCmsFields(): array
|
||||
{
|
||||
return [
|
||||
TextField::make('headline', 'Headline')
|
||||
->translatable()
|
||||
->required()
|
||||
->maxLength(100),
|
||||
|
||||
WysiwygField::make('description', 'Description')
|
||||
->translatable()
|
||||
->toolbar(['bold', 'italic', 'link']),
|
||||
|
||||
MediaField::make('icon', 'Icon')
|
||||
->images()
|
||||
->helpText('SVG or PNG icon'),
|
||||
];
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('components.feature-section');
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
protected function getHeadline(?string $locale = null): string
|
||||
{
|
||||
$locale = $locale ?? app()->getLocale();
|
||||
return $this->content['headline'][$locale] ?? '';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Create the Blade Template
|
||||
|
||||
```blade
|
||||
{{-- resources/views/components/feature-section.blade.php --}}
|
||||
<section class="feature-section py-12">
|
||||
<div class="container mx-auto px-4">
|
||||
@if($this->getHeadline())
|
||||
<h2 class="text-3xl font-bold text-center mb-8">
|
||||
{{ $this->getHeadline() }}
|
||||
</h2>
|
||||
@endif
|
||||
|
||||
@if($this->getDescription())
|
||||
<div class="prose prose-lg mx-auto text-center">
|
||||
{!! $this->getDescription() !!}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Multi-Domain Setup
|
||||
|
||||
```php
|
||||
// Automatic domain detection from existing config/domains.php
|
||||
$page = Page::forDomain('b2in')->bySlug('/about', 'en')->first();
|
||||
|
||||
// Create domain-specific content
|
||||
$page = Page::create([
|
||||
'domain_key' => 'b2in',
|
||||
'title' => ['de' => 'Über uns', 'en' => 'About us'],
|
||||
'is_published' => true,
|
||||
]);
|
||||
|
||||
$page->slugs()->create(['locale' => 'de', 'slug' => '/ueber-uns']);
|
||||
$page->slugs()->create(['locale' => 'en', 'slug' => '/about']);
|
||||
```
|
||||
|
||||
### Component Registry
|
||||
|
||||
```php
|
||||
// Get all available components
|
||||
$registry = app(ComponentRegistry::class);
|
||||
$components = $registry->getAvailableComponents();
|
||||
|
||||
// Search components
|
||||
$results = $registry->searchComponents('hero');
|
||||
|
||||
// Get by category
|
||||
$layoutComponents = $registry->getComponentsByCategory()['Layout'];
|
||||
|
||||
// Validate component content
|
||||
$errors = $registry->validateComponentContent(HeroSection::class, $content);
|
||||
```
|
||||
|
||||
### Custom Field Types
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\FieldTypes;
|
||||
|
||||
use FluxCms\Core\FieldTypes\BaseField;
|
||||
|
||||
class ColorField extends BaseField
|
||||
{
|
||||
public function getType(): string
|
||||
{
|
||||
return 'color';
|
||||
}
|
||||
|
||||
public function getValidationRules(): array
|
||||
{
|
||||
$rules = ['string', 'regex:/^#[0-9A-Fa-f]{6}$/'];
|
||||
|
||||
if ($this->required) {
|
||||
$rules[] = 'required';
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->getType(),
|
||||
'key' => $this->key,
|
||||
'label' => $this->label,
|
||||
'translatable' => $this->translatable,
|
||||
'required' => $this->required,
|
||||
'default' => $this->default,
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Versioning
|
||||
|
||||
```php
|
||||
// Create version before major changes
|
||||
$page->createVersion('Redesign homepage layout', auth()->id());
|
||||
|
||||
// Restore previous version
|
||||
$version = $page->versions()->first();
|
||||
$version->restore();
|
||||
|
||||
// Compare versions
|
||||
$differences = $version->getDifferences();
|
||||
```
|
||||
|
||||
## Field Types
|
||||
|
||||
Flux CMS includes powerful field types out of the box:
|
||||
|
||||
### Text Fields
|
||||
```php
|
||||
TextField::make('title', 'Title')
|
||||
->translatable()
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->placeholder('Enter title...');
|
||||
|
||||
TextField::make('email', 'Email')
|
||||
->email()
|
||||
->required();
|
||||
|
||||
TextField::make('website', 'Website')
|
||||
->url();
|
||||
```
|
||||
|
||||
### Content Fields
|
||||
```php
|
||||
WysiwygField::make('content', 'Content')
|
||||
->translatable()
|
||||
->toolbar(['bold', 'italic', 'link', 'bulletList'])
|
||||
->allowImages(true)
|
||||
->minHeight(300);
|
||||
```
|
||||
|
||||
### Media Fields
|
||||
```php
|
||||
MediaField::make('image', 'Image')
|
||||
->images()
|
||||
->required();
|
||||
|
||||
MediaField::make('gallery', 'Gallery')
|
||||
->images()
|
||||
->multiple(true, 10);
|
||||
|
||||
MediaField::make('document', 'Document')
|
||||
->documents()
|
||||
->maxFileSize(5120); // 5MB
|
||||
```
|
||||
|
||||
### Selection Fields
|
||||
```php
|
||||
SelectField::make('layout', 'Layout')
|
||||
->options([
|
||||
'left' => 'Image Left',
|
||||
'right' => 'Image Right',
|
||||
'center' => 'Centered'
|
||||
])
|
||||
->default('left')
|
||||
->searchable();
|
||||
```
|
||||
|
||||
### Other Fields
|
||||
```php
|
||||
NumberField::make('count', 'Count')
|
||||
->min(1)
|
||||
->max(100)
|
||||
->default(5);
|
||||
|
||||
BooleanField::make('featured', 'Featured')
|
||||
->default(false)
|
||||
->labels('Yes', 'No');
|
||||
```
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
### Page Controller
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use FluxCms\Core\Models\Page;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PageController extends Controller
|
||||
{
|
||||
public function show(Request $request, string $slug = '/')
|
||||
{
|
||||
$domainKey = $this->getCurrentDomainKey($request);
|
||||
$locale = app()->getLocale();
|
||||
|
||||
$page = Page::forDomain($domainKey)
|
||||
->bySlugWithFallback($slug)
|
||||
->published()
|
||||
->with(['components'])
|
||||
->firstOrFail();
|
||||
|
||||
$components = $page->components()->get();
|
||||
|
||||
return view('pages.show', compact('page', 'components'));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Page Template
|
||||
|
||||
```blade
|
||||
{{-- resources/views/pages/show.blade.php --}}
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', $page->getSeoTitle())
|
||||
@section('description', $page->getSeoDescription())
|
||||
|
||||
@section('content')
|
||||
@foreach($components as $component)
|
||||
@if($component->canRender())
|
||||
@livewire($component->component_class, [
|
||||
'content' => $component->getTranslations('content')
|
||||
], key('component-' . $component->id))
|
||||
@endif
|
||||
@endforeach
|
||||
@endsection
|
||||
```
|
||||
|
||||
## Backend Integration
|
||||
|
||||
### Admin Routes
|
||||
|
||||
```php
|
||||
// routes/admin.php
|
||||
Route::middleware(['web', 'auth'])->prefix('admin/cms')->name('admin.cms.')->group(function () {
|
||||
Route::get('/', [Admin\DashboardController::class, 'index'])->name('index');
|
||||
Route::resource('pages', Admin\PageController::class)->except(['show']);
|
||||
// ... other admin routes
|
||||
});
|
||||
```
|
||||
|
||||
### Admin Controller
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use FluxCms\Core\Models\Page;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PageController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$pages = Page::with(['components'])
|
||||
->orderBy('updated_at', 'desc')
|
||||
->paginate(20);
|
||||
|
||||
return view('admin.cms.pages.index', compact('pages'));
|
||||
}
|
||||
|
||||
public function edit(Page $page)
|
||||
{
|
||||
return view('admin.cms.pages.edit', compact('page'));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Edit Page Template
|
||||
|
||||
```blade
|
||||
{{-- resources/views/admin/cms/edit.blade.php --}}
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('content')
|
||||
@livewire('flux-cms::page-editor', ['page' => $page])
|
||||
@endsection
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Available Locales
|
||||
|
||||
```php
|
||||
// config/flux-cms.php
|
||||
'locales' => [
|
||||
'de' => 'Deutsch',
|
||||
'en' => 'English',
|
||||
'fr' => 'Français',
|
||||
'es' => 'Español',
|
||||
],
|
||||
```
|
||||
|
||||
### Component Paths
|
||||
|
||||
```php
|
||||
'component_paths' => [
|
||||
'App\\Livewire\\Components',
|
||||
'App\\CmsComponents',
|
||||
'FluxCms\\StarterComponents\\Components',
|
||||
],
|
||||
```
|
||||
|
||||
### Media Configuration
|
||||
|
||||
```php
|
||||
'media' => [
|
||||
'disk' => 'public',
|
||||
'max_file_size' => 10240, // 10MB
|
||||
'conversions' => [
|
||||
'thumb' => ['width' => 300, 'height' => 300],
|
||||
'medium' => ['width' => 800, 'height' => 600],
|
||||
'large' => ['width' => 1200, 'height' => 900],
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Install Flux CMS
|
||||
php artisan flux-cms:install
|
||||
|
||||
# Create a new component (in app/Livewire/Web/Components)
|
||||
php artisan flux-cms:make-component MyNewComponent
|
||||
|
||||
# Clear component registry cache
|
||||
php artisan flux-cms:clear-cache
|
||||
|
||||
# Publish package assets
|
||||
php artisan vendor:publish --tag=flux-cms
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run tests from the root of your project
|
||||
./vendor/bin/pest
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
- **XSS Protection**: All user content is sanitized
|
||||
- **CSRF Protection**: All forms include CSRF tokens
|
||||
- **File Upload Security**: MIME type validation and file scanning
|
||||
- **Permission System**: Integration with Spatie Laravel Permission
|
||||
|
||||
## Performance
|
||||
|
||||
- **Component Registry Caching**: Components are cached for fast lookup
|
||||
- **Eager Loading**: Optimized database queries
|
||||
- **Asset Optimization**: Automatic image conversions
|
||||
- **Query Caching**: Smart caching for frequently accessed data
|
||||
|
||||
## Upgrade Guide
|
||||
|
||||
### From Dev Version to Package
|
||||
|
||||
1. Install packages:
|
||||
```bash
|
||||
composer require flux-cms/core flux-cms/components flux-cms/starter-components
|
||||
```
|
||||
|
||||
2. Migrate existing components:
|
||||
```bash
|
||||
# Copy your existing components to app/Livewire/Components/
|
||||
# Update namespaces and field imports
|
||||
```
|
||||
|
||||
3. Update configuration:
|
||||
```bash
|
||||
php artisan vendor:publish --tag=flux-cms-config
|
||||
```
|
||||
|
||||
4. Run migrations:
|
||||
```bash
|
||||
php artisan migrate
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details.
|
||||
|
||||
### Development Setup
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/flux-cms/flux-cms.git
|
||||
|
||||
# Install dependencies
|
||||
composer install
|
||||
|
||||
# Run tests
|
||||
composer test
|
||||
|
||||
# Code style
|
||||
composer format
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Flux CMS is open-sourced software licensed under the [MIT license](LICENSE.md).
|
||||
|
||||
## Support
|
||||
|
||||
- 📖 **Documentation**: [https://flux-cms.com/docs](https://flux-cms.com/docs)
|
||||
- 💬 **Discussions**: [GitHub Discussions](https://github.com/flux-cms/flux-cms/discussions)
|
||||
- 🐛 **Issues**: [GitHub Issues](https://github.com/flux-cms/flux-cms/issues)
|
||||
- 💌 **Email**: support@flux-cms.com
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- Built with [Laravel](https://laravel.com)
|
||||
- Powered by [Livewire](https://laravel-livewire.com)
|
||||
- Uses [Spatie packages](https://spatie.be/open-source)
|
||||
- UI with [Flux UI](https://fluxui.dev)
|
||||
|
||||
---
|
||||
|
||||
Made with ❤️ by the Flux CMS team
|
||||
57
packages/flux-cms/components/composer.json
Normal file
57
packages/flux-cms/components/composer.json
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
{
|
||||
"name": "flux-cms/components",
|
||||
"description": "Flux CMS Livewire Components Package - Backend and Frontend components",
|
||||
"type": "library",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"cms",
|
||||
"livewire",
|
||||
"components",
|
||||
"backend",
|
||||
"frontend"
|
||||
],
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Flux CMS Contributors",
|
||||
"email": "contributors@flux-cms.com"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"laravel/framework": "^11.0|^12.0",
|
||||
"livewire/livewire": "^3.0",
|
||||
"livewire/flux": "^2.1.1",
|
||||
"flux-cms/core": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"orchestra/testbench": "^9.0",
|
||||
"pestphp/pest": "^3.8",
|
||||
"pestphp/pest-plugin-laravel": "^3.2"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"FluxCms\\Components\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"FluxCms\\Components\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"FluxCms\\Components\\FluxCmsComponentsServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true,
|
||||
"config": {
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<div>
|
||||
<form wire:submit.prevent="save">
|
||||
{{-- Post Title --}}
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-gray-700">Title</label>
|
||||
<input type="text" id="title" wire:model.defer="post.title" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
|
||||
</div>
|
||||
|
||||
{{-- Post Content --}}
|
||||
<div class="mt-4">
|
||||
<label for="content" class="block text-sm font-medium text-gray-700">Content</label>
|
||||
<textarea id="content" wire:model.defer="post.content" rows="10" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm"></textarea>
|
||||
</div>
|
||||
|
||||
{{-- Tags --}}
|
||||
<div class="mt-4">
|
||||
<label for="tags" class="block text-sm font-medium text-gray-700">Tags (comma-separated)</label>
|
||||
<input type="text" id="tags" wire:model.defer="tags" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm" placeholder="laravel, php, cms">
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-blue-500 active:bg-blue-700">
|
||||
Save Post
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,366 @@
|
|||
<div class="space-y-6">
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Blog Manager</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
Create and manage blog posts
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<flux:button wire:click="createPost" variant="primary">
|
||||
New Post
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Stats Cards --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
@foreach($this->getStats() as $stat)
|
||||
<flux:card>
|
||||
<div class="text-center p-4">
|
||||
<div class="text-2xl font-bold text-gray-900">{{ $stat['value'] }}</div>
|
||||
<div class="text-sm text-gray-500">{{ $stat['label'] }}</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Filters --}}
|
||||
<flux:card>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div class="flex-1 min-w-64">
|
||||
<flux:input wire:model.live="search"
|
||||
placeholder="Search posts..."
|
||||
class="w-full" />
|
||||
</div>
|
||||
|
||||
<flux:select wire:model.live="statusFilter" placeholder="All Status">
|
||||
<flux:option value="">All Status</flux:option>
|
||||
<flux:option value="published">Published</flux:option>
|
||||
<flux:option value="draft">Draft</flux:option>
|
||||
<flux:option value="featured">Featured</flux:option>
|
||||
</flux:select>
|
||||
|
||||
<flux:select wire:model.live="categoryFilter" placeholder="All Categories">
|
||||
<flux:option value="">All Categories</flux:option>
|
||||
@foreach($this->getCategories() as $category)
|
||||
<flux:option value="{{ $category }}">{{ $category }}</flux:option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
|
||||
<flux:select wire:model.live="sortBy">
|
||||
<flux:option value="created_at">Created Date</flux:option>
|
||||
<flux:option value="published_at">Published Date</flux:option>
|
||||
<flux:option value="title">Title</flux:option>
|
||||
</flux:select>
|
||||
|
||||
<flux:select wire:model.live="sortDirection">
|
||||
<flux:option value="desc">Newest First</flux:option>
|
||||
<flux:option value="asc">Oldest First</flux:option>
|
||||
</flux:select>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Posts Table --}}
|
||||
<flux:card>
|
||||
<flux:table>
|
||||
<flux:columns>
|
||||
<flux:column>Title</flux:column>
|
||||
<flux:column>Status</flux:column>
|
||||
<flux:column>Category</flux:column>
|
||||
<flux:column>Author</flux:column>
|
||||
<flux:column>Published</flux:column>
|
||||
<flux:column>Actions</flux:column>
|
||||
</flux:columns>
|
||||
|
||||
<flux:rows>
|
||||
@forelse($posts as $post)
|
||||
<flux:row wire:key="post-{{ $post->id }}">
|
||||
<flux:cell>
|
||||
<div>
|
||||
<div class="font-medium text-gray-900">
|
||||
{{ $post->title }}
|
||||
</div>
|
||||
@if($post->excerpt)
|
||||
<div class="text-sm text-gray-500 mt-1">
|
||||
{{ str_limit($post->excerpt, 60) }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</flux:cell>
|
||||
|
||||
<flux:cell>
|
||||
<div class="flex items-center space-x-2">
|
||||
@if($post->is_published)
|
||||
<flux:badge color="green" size="sm">Published</flux:badge>
|
||||
@else
|
||||
<flux:badge color="gray" size="sm">Draft</flux:badge>
|
||||
@endif
|
||||
|
||||
@if($post->is_featured)
|
||||
<flux:badge color="blue" size="sm">Featured</flux:badge>
|
||||
@endif
|
||||
</div>
|
||||
</flux:cell>
|
||||
|
||||
<flux:cell>
|
||||
{{ $post->category ?? '—' }}
|
||||
</flux:cell>
|
||||
|
||||
<flux:cell>
|
||||
{{ $post->author?->name ?? '—' }}
|
||||
</flux:cell>
|
||||
|
||||
<flux:cell>
|
||||
{{ $post->published_at?->format('M j, Y') ?? '—' }}
|
||||
</flux:cell>
|
||||
|
||||
<flux:cell>
|
||||
<div class="flex items-center space-x-2">
|
||||
@if($post->is_published)
|
||||
<flux:button href="{{ $post->getUrl() }}"
|
||||
target="_blank"
|
||||
size="sm"
|
||||
variant="ghost">
|
||||
View
|
||||
</flux:button>
|
||||
@endif
|
||||
|
||||
<flux:button wire:click="editPost({{ $post->id }})"
|
||||
size="sm"
|
||||
variant="ghost">
|
||||
Edit
|
||||
</flux:button>
|
||||
|
||||
<flux:button wire:click="duplicatePost({{ $post->id }})"
|
||||
size="sm"
|
||||
variant="ghost">
|
||||
Duplicate
|
||||
</flux:button>
|
||||
|
||||
<flux:button wire:click="deletePost({{ $post->id }})"
|
||||
size="sm"
|
||||
variant="danger">
|
||||
Delete
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:cell>
|
||||
</flux:row>
|
||||
@empty
|
||||
<flux:row>
|
||||
<flux:cell colspan="6">
|
||||
<div class="text-center py-8 text-gray-500">
|
||||
<flux:icon.document-text class="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||
<p>No blog posts found.</p>
|
||||
<p class="text-sm">Create your first blog post to get started.</p>
|
||||
</div>
|
||||
</flux:cell>
|
||||
</flux:row>
|
||||
@endforelse
|
||||
</flux:rows>
|
||||
</flux:table>
|
||||
|
||||
{{-- Pagination --}}
|
||||
@if($posts->hasPages())
|
||||
<div class="px-6 py-4 border-t border-gray-200">
|
||||
{{ $posts->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
|
||||
{{-- Post Form Modal --}}
|
||||
<flux:modal name="post-form" class="md:w-6xl max-h-screen overflow-y-auto">
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<flux:heading size="lg">
|
||||
{{ $editingPost ? 'Edit Post' : 'Create Post' }}
|
||||
</flux:heading>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
@if($editingPost)
|
||||
<flux:button wire:click="saveAsDraft" variant="ghost">
|
||||
Save as Draft
|
||||
</flux:button>
|
||||
<flux:button wire:click="publishPost" variant="primary">
|
||||
{{ $editingPost->is_published ? 'Update' : 'Publish' }}
|
||||
</flux:button>
|
||||
@else
|
||||
<flux:button wire:click="saveDraft" variant="ghost">
|
||||
Save Draft
|
||||
</flux:button>
|
||||
<flux:button wire:click="saveAndPublish" variant="primary">
|
||||
Publish
|
||||
</flux:button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Language Tabs --}}
|
||||
<div class="border-b border-gray-200">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
@foreach($availableLocales as $locale)
|
||||
<button wire:click="setActiveLocale('{{ $locale }}')"
|
||||
class="py-2 px-1 border-b-2 font-medium text-sm {{ $activeLocale === $locale ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
|
||||
{{ strtoupper($locale) }}
|
||||
</button>
|
||||
@endforeach
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{{-- Main Content --}}
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
{{-- Title & Slug --}}
|
||||
<div class="space-y-4">
|
||||
<flux:field>
|
||||
<flux:label>Title ({{ strtoupper($activeLocale) }})</flux:label>
|
||||
<flux:input wire:model.live="postForm.title.{{ $activeLocale }}"
|
||||
placeholder="Enter post title" />
|
||||
<flux:error name="postForm.title.{{ $activeLocale }}" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>Slug ({{ strtoupper($activeLocale) }})</flux:label>
|
||||
<flux:input wire:model.live="postForm.slug.{{ $activeLocale }}"
|
||||
placeholder="post-url-slug" />
|
||||
<flux:error name="postForm.slug.{{ $activeLocale }}" />
|
||||
</flux:field>
|
||||
</div>
|
||||
|
||||
{{-- Excerpt --}}
|
||||
<flux:field>
|
||||
<flux:label>Excerpt ({{ strtoupper($activeLocale) }})</flux:label>
|
||||
<flux:textarea wire:model.live="postForm.excerpt.{{ $activeLocale }}"
|
||||
rows="3"
|
||||
placeholder="Brief description of the post" />
|
||||
<flux:description>Optional excerpt for post listings and SEO</flux:description>
|
||||
<flux:error name="postForm.excerpt.{{ $activeLocale }}" />
|
||||
</flux:field>
|
||||
|
||||
{{-- Content --}}
|
||||
<flux:field>
|
||||
<flux:label>Content ({{ strtoupper($activeLocale) }})</flux:label>
|
||||
<div wire:ignore>
|
||||
<textarea wire:model.live="postForm.content.{{ $activeLocale }}"
|
||||
class="wysiwyg-editor"
|
||||
rows="15"
|
||||
placeholder="Write your post content here..."></textarea>
|
||||
</div>
|
||||
<flux:error name="postForm.content.{{ $activeLocale }}" />
|
||||
</flux:field>
|
||||
</div>
|
||||
|
||||
{{-- Sidebar --}}
|
||||
<div class="space-y-6">
|
||||
{{-- Publishing Options --}}
|
||||
<flux:card>
|
||||
<flux:card.header>
|
||||
<flux:heading size="sm">Publishing</flux:heading>
|
||||
</flux:card.header>
|
||||
|
||||
<div class="space-y-4">
|
||||
<flux:field>
|
||||
<flux:label>Published Date</flux:label>
|
||||
<flux:input wire:model.live="postForm.published_at"
|
||||
type="datetime-local" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:checkbox wire:model.live="postForm.is_featured">
|
||||
Featured Post
|
||||
</flux:checkbox>
|
||||
<flux:description>Featured posts appear prominently on the site</flux:description>
|
||||
</flux:field>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Categories & Tags --}}
|
||||
<flux:card>
|
||||
<flux:card.header>
|
||||
<flux:heading size="sm">Organization</flux:heading>
|
||||
</flux:card.header>
|
||||
|
||||
<div class="space-y-4">
|
||||
<flux:field>
|
||||
<flux:label>Category</flux:label>
|
||||
<flux:input wire:model.live="postForm.category"
|
||||
placeholder="e.g., Technology, News" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>Tags</flux:label>
|
||||
<flux:input wire:model.live="tagsInput"
|
||||
placeholder="Enter tags separated by commas" />
|
||||
<flux:description>Separate multiple tags with commas</flux:description>
|
||||
</flux:field>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- SEO Settings --}}
|
||||
<flux:card>
|
||||
<flux:card.header>
|
||||
<flux:heading size="sm">SEO Settings</flux:heading>
|
||||
</flux:card.header>
|
||||
|
||||
<div class="space-y-4">
|
||||
<flux:field>
|
||||
<flux:label>Meta Title ({{ strtoupper($activeLocale) }})</flux:label>
|
||||
<flux:input wire:model.live="postForm.meta_title.{{ $activeLocale }}"
|
||||
placeholder="SEO title (defaults to post title)" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>Meta Description ({{ strtoupper($activeLocale) }})</flux:label>
|
||||
<flux:textarea wire:model.live="postForm.meta_description.{{ $activeLocale }}"
|
||||
rows="3"
|
||||
placeholder="SEO description (defaults to excerpt)" />
|
||||
</flux:field>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Featured Image --}}
|
||||
<flux:card>
|
||||
<flux:card.header>
|
||||
<flux:heading size="sm">Featured Image</flux:heading>
|
||||
</flux:card.header>
|
||||
|
||||
<div class="space-y-4">
|
||||
@if($featuredImage)
|
||||
<div class="relative">
|
||||
<img src="{{ $featuredImage->getUrl('thumb') }}"
|
||||
alt="Featured image"
|
||||
class="w-full rounded">
|
||||
<button wire:click="removeFeaturedImage"
|
||||
class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@else
|
||||
<flux:button wire:click="selectFeaturedImage" size="sm" class="w-full">
|
||||
Select Featured Image
|
||||
</flux:button>
|
||||
@endif
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-6 border-t border-gray-200">
|
||||
<flux:button wire:click="$dispatch('close-modal')" variant="ghost">
|
||||
Cancel
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize WYSIWYG editor for content
|
||||
// You can integrate your preferred editor here (CKEditor, TinyMCE, Quill, etc.)
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
<div class="space-y-6">
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900">
|
||||
{{ $componentData ? 'Edit Component' : 'Add Component' }}
|
||||
</h1>
|
||||
@if($componentType)
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
Component Type: {{ class_basename($componentType) }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<flux:button wire:click="$dispatch('close-modal')" variant="ghost">
|
||||
Cancel
|
||||
</flux:button>
|
||||
<flux:button wire:click="save" variant="primary">
|
||||
{{ $componentData ? 'Update' : 'Add' }} Component
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Component Settings --}}
|
||||
@if($componentType && $fields)
|
||||
<flux:card>
|
||||
<flux:card.header>
|
||||
<flux:heading size="lg">Component Settings</flux:heading>
|
||||
</flux:card.header>
|
||||
|
||||
<div class="space-y-6">
|
||||
{{-- Language Tabs for Translatable Fields --}}
|
||||
@if($hasTranslatableFields)
|
||||
<div class="border-b border-gray-200">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
@foreach($availableLocales as $locale)
|
||||
<button wire:click="setActiveLocale('{{ $locale }}')"
|
||||
class="py-2 px-1 border-b-2 font-medium text-sm {{ $activeLocale === $locale ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
|
||||
{{ strtoupper($locale) }}
|
||||
</button>
|
||||
@endforeach
|
||||
</nav>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Dynamic Fields --}}
|
||||
@foreach($fields as $field)
|
||||
<div class="space-y-2">
|
||||
@if($field->isTranslatable() && $hasTranslatableFields)
|
||||
{{-- Translatable Field --}}
|
||||
<flux:field>
|
||||
<flux:label>{{ $field->getLabel() }}</flux:label>
|
||||
@if($field->getHelpText())
|
||||
<flux:description>{{ $field->getHelpText() }}</flux:description>
|
||||
@endif
|
||||
|
||||
@php
|
||||
$fieldKey = $field->getKey();
|
||||
$wireModel = "content.{$fieldKey}.{$activeLocale}";
|
||||
@endphp
|
||||
|
||||
@switch($field->getType())
|
||||
@case('text')
|
||||
<flux:input wire:model.live="{{ $wireModel }}"
|
||||
placeholder="{{ $field->getPlaceholder() }}" />
|
||||
@break
|
||||
|
||||
@case('textarea')
|
||||
<flux:textarea wire:model.live="{{ $wireModel }}"
|
||||
placeholder="{{ $field->getPlaceholder() }}"
|
||||
rows="4" />
|
||||
@break
|
||||
|
||||
@case('wysiwyg')
|
||||
<div wire:ignore>
|
||||
<textarea wire:model.live="{{ $wireModel }}"
|
||||
class="wysiwyg-editor"
|
||||
rows="10"></textarea>
|
||||
</div>
|
||||
@break
|
||||
|
||||
@case('select')
|
||||
<flux:select wire:model.live="{{ $wireModel }}">
|
||||
@if($field->getEmptyOption())
|
||||
<flux:option value="">{{ $field->getEmptyOption() }}</flux:option>
|
||||
@endif
|
||||
@foreach($field->getOptions() as $value => $label)
|
||||
<flux:option value="{{ $value }}">{{ $label }}</flux:option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
@break
|
||||
@endswitch
|
||||
|
||||
<flux:error name="{{ $wireModel }}" />
|
||||
</flux:field>
|
||||
@else
|
||||
{{-- Non-translatable Field --}}
|
||||
<flux:field>
|
||||
<flux:label>{{ $field->getLabel() }}</flux:label>
|
||||
@if($field->getHelpText())
|
||||
<flux:description>{{ $field->getHelpText() }}</flux:description>
|
||||
@endif
|
||||
|
||||
@php
|
||||
$fieldKey = $field->getKey();
|
||||
$wireModel = "content.{$fieldKey}";
|
||||
@endphp
|
||||
|
||||
@switch($field->getType())
|
||||
@case('text')
|
||||
<flux:input wire:model.live="{{ $wireModel }}"
|
||||
placeholder="{{ $field->getPlaceholder() }}" />
|
||||
@break
|
||||
|
||||
@case('number')
|
||||
<flux:input wire:model.live="{{ $wireModel }}"
|
||||
type="number"
|
||||
step="{{ $field->getStep() }}"
|
||||
@if($field->getMin() !== null) min="{{ $field->getMin() }}" @endif
|
||||
@if($field->getMax() !== null) max="{{ $field->getMax() }}" @endif
|
||||
placeholder="{{ $field->getPlaceholder() }}" />
|
||||
@break
|
||||
|
||||
@case('boolean')
|
||||
@if($field->getDisplayType() === 'toggle')
|
||||
<flux:switch wire:model.live="{{ $wireModel }}">
|
||||
{{ $field->getTrueLabel() }}
|
||||
</flux:switch>
|
||||
@else
|
||||
<flux:checkbox wire:model.live="{{ $wireModel }}">
|
||||
{{ $field->getTrueLabel() }}
|
||||
</flux:checkbox>
|
||||
@endif
|
||||
@break
|
||||
|
||||
@case('select')
|
||||
@if($field->isMultiple())
|
||||
<flux:select wire:model.live="{{ $wireModel }}" multiple>
|
||||
@foreach($field->getOptions() as $value => $label)
|
||||
<flux:option value="{{ $value }}">{{ $label }}</flux:option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
@else
|
||||
<flux:select wire:model.live="{{ $wireModel }}">
|
||||
@if($field->getEmptyOption())
|
||||
<flux:option value="">{{ $field->getEmptyOption() }}</flux:option>
|
||||
@endif
|
||||
@foreach($field->getOptions() as $value => $label)
|
||||
<flux:option value="{{ $value }}">{{ $label }}</flux:option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
@endif
|
||||
@break
|
||||
|
||||
@case('media')
|
||||
<div class="space-y-3">
|
||||
<flux:button wire:click="openMediaPicker('{{ $fieldKey }}')" size="sm">
|
||||
Select {{ $field->acceptsImages() ? 'Image' : 'File' }}
|
||||
</flux:button>
|
||||
|
||||
@if(!empty($content[$fieldKey]))
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
@if($field->isMultiple())
|
||||
@foreach($content[$fieldKey] as $index => $mediaId)
|
||||
<div class="relative">
|
||||
<img src="{{ $this->getMediaUrl($mediaId) }}"
|
||||
alt="Selected media"
|
||||
class="w-full h-24 object-cover rounded">
|
||||
<button wire:click="removeMedia('{{ $fieldKey }}', {{ $index }})"
|
||||
class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@endforeach
|
||||
@else
|
||||
<div class="relative">
|
||||
<img src="{{ $this->getMediaUrl($content[$fieldKey]) }}"
|
||||
alt="Selected media"
|
||||
class="w-full h-24 object-cover rounded">
|
||||
<button wire:click="removeMedia('{{ $fieldKey }}')"
|
||||
class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@break
|
||||
@endswitch
|
||||
|
||||
<flux:error name="{{ $wireModel }}" />
|
||||
</flux:field>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Component Visibility Settings --}}
|
||||
<flux:card>
|
||||
<flux:card.header>
|
||||
<flux:heading size="lg">Visibility Settings</flux:heading>
|
||||
</flux:card.header>
|
||||
|
||||
<div class="space-y-4">
|
||||
<flux:field>
|
||||
<flux:checkbox wire:model.live="settings.is_active">
|
||||
Component is active
|
||||
</flux:checkbox>
|
||||
<flux:description>Inactive components will not be displayed on the frontend</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>CSS Classes</flux:label>
|
||||
<flux:input wire:model.live="settings.css_classes"
|
||||
placeholder="Additional CSS classes" />
|
||||
<flux:description>Custom CSS classes to apply to this component</flux:description>
|
||||
</flux:field>
|
||||
</div>
|
||||
</flux:card>
|
||||
@else
|
||||
<div class="text-center py-8">
|
||||
<flux:icon.exclamation-triangle class="w-12 h-12 mx-auto text-gray-400 mb-4" />
|
||||
<p class="text-gray-500">No component type selected or fields defined.</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Media Picker Modal --}}
|
||||
<flux:modal name="media-picker" class="md:w-4xl">
|
||||
<div class="space-y-6">
|
||||
<flux:heading size="lg">Select Media</flux:heading>
|
||||
|
||||
<div class="grid grid-cols-4 gap-4 max-h-96 overflow-y-auto">
|
||||
{{-- Media items would be loaded here --}}
|
||||
<div class="text-center py-8 col-span-4 text-gray-500">
|
||||
Media picker implementation needed
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// Initialize WYSIWYG editors
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize your preferred WYSIWYG editor here
|
||||
// Example: CKEditor, TinyMCE, Quill, etc.
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
|
|
@ -0,0 +1,282 @@
|
|||
<div class="space-y-6">
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Media Manager</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
Manage your media files and assets
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<flux:button wire:click="$dispatch('open-upload-modal')" variant="primary">
|
||||
Upload Media
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Filters & Search --}}
|
||||
<flux:card>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex-1">
|
||||
<flux:input wire:model.live="search"
|
||||
placeholder="Search media files..."
|
||||
class="w-full" />
|
||||
</div>
|
||||
|
||||
<flux:select wire:model.live="filterType" placeholder="All Types">
|
||||
<flux:option value="">All Types</flux:option>
|
||||
<flux:option value="image">Images</flux:option>
|
||||
<flux:option value="document">Documents</flux:option>
|
||||
<flux:option value="video">Videos</flux:option>
|
||||
<flux:option value="audio">Audio</flux:option>
|
||||
</flux:select>
|
||||
|
||||
<flux:select wire:model.live="sortBy">
|
||||
<flux:option value="created_at">Upload Date</flux:option>
|
||||
<flux:option value="name">Name</flux:option>
|
||||
<flux:option value="size">Size</flux:option>
|
||||
</flux:select>
|
||||
|
||||
<flux:select wire:model.live="sortDirection">
|
||||
<flux:option value="desc">Newest First</flux:option>
|
||||
<flux:option value="asc">Oldest First</flux:option>
|
||||
</flux:select>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Media Grid --}}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
@forelse($mediaItems as $media)
|
||||
<div wire:key="media-{{ $media->id }}"
|
||||
class="group relative bg-white border border-gray-200 rounded-lg overflow-hidden hover:shadow-md transition-shadow">
|
||||
|
||||
{{-- Media Preview --}}
|
||||
<div class="aspect-square bg-gray-100 flex items-center justify-center">
|
||||
@if($media->hasGeneratedConversion('thumb'))
|
||||
<img src="{{ $media->getUrl('thumb') }}"
|
||||
alt="{{ $media->name }}"
|
||||
class="w-full h-full object-cover">
|
||||
@elseif(str_starts_with($media->mime_type, 'image/'))
|
||||
<img src="{{ $media->getUrl() }}"
|
||||
alt="{{ $media->name }}"
|
||||
class="w-full h-full object-cover">
|
||||
@else
|
||||
<div class="text-center p-4">
|
||||
@switch(explode('/', $media->mime_type)[0])
|
||||
@case('video')
|
||||
<flux:icon.film class="w-8 h-8 mx-auto text-gray-400 mb-2" />
|
||||
@break
|
||||
@case('audio')
|
||||
<flux:icon.musical-note class="w-8 h-8 mx-auto text-gray-400 mb-2" />
|
||||
@break
|
||||
@default
|
||||
<flux:icon.document class="w-8 h-8 mx-auto text-gray-400 mb-2" />
|
||||
@endswitch
|
||||
<span class="text-xs text-gray-500 font-medium">
|
||||
{{ strtoupper(pathinfo($media->name, PATHINFO_EXTENSION)) }}
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Media Info --}}
|
||||
<div class="p-3">
|
||||
<h3 class="text-sm font-medium text-gray-900 truncate"
|
||||
title="{{ $media->name }}">
|
||||
{{ $media->name }}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
{{ $this->formatFileSize($media->size) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- Actions Overlay --}}
|
||||
<div class="absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center space-x-2">
|
||||
<flux:button wire:click="viewMedia({{ $media->id }})"
|
||||
size="sm"
|
||||
variant="white">
|
||||
View
|
||||
</flux:button>
|
||||
<flux:button wire:click="editMedia({{ $media->id }})"
|
||||
size="sm"
|
||||
variant="white">
|
||||
Edit
|
||||
</flux:button>
|
||||
<flux:button wire:click="deleteMedia({{ $media->id }})"
|
||||
size="sm"
|
||||
variant="danger">
|
||||
Delete
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
{{-- Selection Checkbox --}}
|
||||
@if($selectionMode)
|
||||
<div class="absolute top-2 left-2">
|
||||
<flux:checkbox wire:model.live="selectedMedia"
|
||||
value="{{ $media->id }}"
|
||||
class="bg-white" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<div class="col-span-full text-center py-12">
|
||||
<flux:icon.photo class="w-12 h-12 mx-auto text-gray-300 mb-4" />
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">No media files</h3>
|
||||
<p class="text-gray-500 mb-4">Upload your first media file to get started.</p>
|
||||
<flux:button wire:click="$dispatch('open-upload-modal')" variant="primary">
|
||||
Upload Media
|
||||
</flux:button>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{-- Pagination --}}
|
||||
@if($mediaItems->hasPages())
|
||||
<div class="flex justify-center">
|
||||
{{ $mediaItems->links() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Upload Modal --}}
|
||||
<flux:modal name="upload-modal" class="md:w-2xl">
|
||||
<div class="space-y-6">
|
||||
<flux:heading size="lg">Upload Media</flux:heading>
|
||||
|
||||
<div class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center"
|
||||
x-data="{
|
||||
isDragging: false,
|
||||
handleDrop(e) {
|
||||
this.isDragging = false;
|
||||
// Handle file drop
|
||||
}
|
||||
}"
|
||||
x-on:dragover.prevent="isDragging = true"
|
||||
x-on:dragleave.prevent="isDragging = false"
|
||||
x-on:drop.prevent="handleDrop"
|
||||
:class="{ 'border-blue-400 bg-blue-50': isDragging }">
|
||||
|
||||
<flux:icon.cloud-arrow-up class="w-12 h-12 mx-auto text-gray-400 mb-4" />
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Upload files</h3>
|
||||
<p class="text-gray-500 mb-4">Drag and drop files here or click to browse</p>
|
||||
|
||||
<flux:button wire:click="triggerFileInput" variant="primary">
|
||||
Choose Files
|
||||
</flux:button>
|
||||
|
||||
<input type="file"
|
||||
wire:model="uploadFiles"
|
||||
multiple
|
||||
accept="image/*,video/*,audio/*,.pdf,.doc,.docx"
|
||||
class="hidden"
|
||||
id="file-input">
|
||||
</div>
|
||||
|
||||
{{-- Upload Progress --}}
|
||||
@if($uploading)
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium">Uploading...</span>
|
||||
<span class="text-sm text-gray-500">{{ $uploadProgress }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style="width: {{ $uploadProgress }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</flux:modal>
|
||||
|
||||
{{-- Media Detail Modal --}}
|
||||
<flux:modal name="media-detail" class="md:w-4xl">
|
||||
@if($viewingMedia)
|
||||
<div class="space-y-6">
|
||||
<flux:heading size="lg">{{ $viewingMedia->name }}</flux:heading>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{{-- Media Preview --}}
|
||||
<div class="space-y-4">
|
||||
@if(str_starts_with($viewingMedia->mime_type, 'image/'))
|
||||
<img src="{{ $viewingMedia->getUrl() }}"
|
||||
alt="{{ $viewingMedia->name }}"
|
||||
class="w-full rounded-lg">
|
||||
@else
|
||||
<div class="bg-gray-100 rounded-lg p-8 text-center">
|
||||
<flux:icon.document class="w-16 h-16 mx-auto text-gray-400 mb-4" />
|
||||
<p class="text-gray-600">{{ $viewingMedia->name }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<flux:button href="{{ $viewingMedia->getUrl() }}"
|
||||
target="_blank"
|
||||
variant="outline"
|
||||
class="w-full">
|
||||
Download Original
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
{{-- Media Details --}}
|
||||
<div class="space-y-4">
|
||||
<flux:field>
|
||||
<flux:label>File Name</flux:label>
|
||||
<flux:input wire:model.live="editingMedia.name" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>Alt Text</flux:label>
|
||||
<flux:input wire:model.live="editingMedia.alt_text"
|
||||
placeholder="Description for accessibility" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>Caption</flux:label>
|
||||
<flux:textarea wire:model.live="editingMedia.caption"
|
||||
rows="3"
|
||||
placeholder="Optional caption" />
|
||||
</flux:field>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="font-medium text-gray-700">File Type:</span>
|
||||
<span class="text-gray-600">{{ $viewingMedia->mime_type }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-gray-700">File Size:</span>
|
||||
<span class="text-gray-600">{{ $this->formatFileSize($viewingMedia->size) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-gray-700">Uploaded:</span>
|
||||
<span class="text-gray-600">{{ $viewingMedia->created_at->format('M j, Y') }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-gray-700">Collection:</span>
|
||||
<span class="text-gray-600">{{ $viewingMedia->collection_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3 pt-4">
|
||||
<flux:button wire:click="updateMedia" variant="primary">
|
||||
Update
|
||||
</flux:button>
|
||||
<flux:button wire:click="$dispatch('close-modal')" variant="ghost">
|
||||
Cancel
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</flux:modal>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// File input trigger
|
||||
window.addEventListener('triggerFileInput', function() {
|
||||
document.getElementById('file-input').click();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
<div class="space-y-6">
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Navigation Manager</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
Manage site navigation and menu structures
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<flux:button wire:click="createNavigation" variant="primary">
|
||||
Create Navigation
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Navigation Selector --}}
|
||||
<flux:card>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex-1">
|
||||
<flux:select wire:model.live="selectedNavigationId" placeholder="Select Navigation">
|
||||
@foreach($navigations as $nav)
|
||||
<flux:option value="{{ $nav->id }}">{{ $nav->display_name }}</flux:option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</div>
|
||||
|
||||
@if($selectedNavigationId)
|
||||
<flux:button wire:click="editNavigation({{ $selectedNavigationId }})" size="sm">
|
||||
Edit Navigation
|
||||
</flux:button>
|
||||
<flux:button wire:click="deleteNavigation({{ $selectedNavigationId }})"
|
||||
variant="danger" size="sm">
|
||||
Delete
|
||||
</flux:button>
|
||||
@endif
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Navigation Items --}}
|
||||
@if($selectedNavigationId && $navigationItems)
|
||||
<flux:card>
|
||||
<flux:card.header>
|
||||
<div class="flex items-center justify-between">
|
||||
<flux:heading size="lg">Navigation Items</flux:heading>
|
||||
<flux:button wire:click="addNavigationItem" size="sm">
|
||||
Add Item
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:card.header>
|
||||
|
||||
<div class="space-y-2" wire:sortable="updateItemOrder">
|
||||
@forelse($navigationItems as $item)
|
||||
<div wire:sortable.item="{{ $item->id }}"
|
||||
wire:key="nav-item-{{ $item->id }}"
|
||||
class="nested-sortable-item">
|
||||
@include('flux-cms-components::partials.navigation-item', ['item' => $item, 'level' => 0])
|
||||
</div>
|
||||
@empty
|
||||
<div class="text-center py-8 text-gray-500">
|
||||
<flux:icon.bars-3 class="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||
<p>No navigation items yet.</p>
|
||||
<p class="text-sm">Click "Add Item" to create your first navigation item.</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
|
||||
{{-- Navigation Form Modal --}}
|
||||
<flux:modal name="navigation-form" class="md:w-2xl">
|
||||
<div class="space-y-6">
|
||||
<flux:heading size="lg">
|
||||
{{ $editingNavigation ? 'Edit Navigation' : 'Create Navigation' }}
|
||||
</flux:heading>
|
||||
|
||||
<div class="space-y-4">
|
||||
<flux:field>
|
||||
<flux:label>Name</flux:label>
|
||||
<flux:input wire:model.live="navigationForm.name"
|
||||
placeholder="main-menu" />
|
||||
<flux:description>Unique identifier for this navigation (lowercase, no spaces)</flux:description>
|
||||
<flux:error name="navigationForm.name" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>Display Name</flux:label>
|
||||
<flux:input wire:model.live="navigationForm.display_name"
|
||||
placeholder="Main Menu" />
|
||||
<flux:description>Human-readable name for this navigation</flux:description>
|
||||
<flux:error name="navigationForm.display_name" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:checkbox wire:model.live="navigationForm.is_active">
|
||||
Active
|
||||
</flux:checkbox>
|
||||
<flux:description>Inactive navigations will not be displayed</flux:description>
|
||||
</flux:field>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<flux:button wire:click="$dispatch('close-modal')" variant="ghost">
|
||||
Cancel
|
||||
</flux:button>
|
||||
<flux:button wire:click="saveNavigation" variant="primary">
|
||||
{{ $editingNavigation ? 'Update' : 'Create' }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
|
||||
{{-- Navigation Item Form Modal --}}
|
||||
<flux:modal name="navigation-item-form" class="md:w-2xl">
|
||||
<div class="space-y-6">
|
||||
<flux:heading size="lg">
|
||||
{{ $editingItem ? 'Edit Navigation Item' : 'Add Navigation Item' }}
|
||||
</flux:heading>
|
||||
|
||||
{{-- Language Tabs --}}
|
||||
<div class="border-b border-gray-200">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
@foreach($availableLocales as $locale)
|
||||
<button wire:click="setActiveLocale('{{ $locale }}')"
|
||||
class="py-2 px-1 border-b-2 font-medium text-sm {{ $activeLocale === $locale ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
|
||||
{{ strtoupper($locale) }}
|
||||
</button>
|
||||
@endforeach
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{{-- Title (Translatable) --}}
|
||||
<flux:field>
|
||||
<flux:label>Title ({{ strtoupper($activeLocale) }})</flux:label>
|
||||
<flux:input wire:model.live="itemForm.title.{{ $activeLocale }}"
|
||||
placeholder="Navigation item title" />
|
||||
<flux:error name="itemForm.title.{{ $activeLocale }}" />
|
||||
</flux:field>
|
||||
|
||||
{{-- Link Type --}}
|
||||
<flux:field>
|
||||
<flux:label>Link Type</flux:label>
|
||||
<flux:select wire:model.live="itemForm.link_type">
|
||||
<flux:option value="page">Page</flux:option>
|
||||
<flux:option value="url">Custom URL</flux:option>
|
||||
<flux:option value="none">No Link</flux:option>
|
||||
</flux:select>
|
||||
</flux:field>
|
||||
|
||||
{{-- Page Selection --}}
|
||||
@if($itemForm['link_type'] === 'page')
|
||||
<flux:field>
|
||||
<flux:label>Select Page</flux:label>
|
||||
<flux:select wire:model.live="itemForm.page_id" placeholder="Choose a page">
|
||||
@foreach($availablePages as $page)
|
||||
<flux:option value="{{ $page->id }}">{{ $page->title }}</flux:option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:error name="itemForm.page_id" />
|
||||
</flux:field>
|
||||
@endif
|
||||
|
||||
{{-- Custom URL --}}
|
||||
@if($itemForm['link_type'] === 'url')
|
||||
<flux:field>
|
||||
<flux:label>URL</flux:label>
|
||||
<flux:input wire:model.live="itemForm.url"
|
||||
placeholder="https://example.com" />
|
||||
<flux:error name="itemForm.url" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>Target</flux:label>
|
||||
<flux:select wire:model.live="itemForm.target">
|
||||
<flux:option value="_self">Same Window</flux:option>
|
||||
<flux:option value="_blank">New Window</flux:option>
|
||||
</flux:select>
|
||||
</flux:field>
|
||||
@endif
|
||||
|
||||
{{-- Parent Item --}}
|
||||
<flux:field>
|
||||
<flux:label>Parent Item</flux:label>
|
||||
<flux:select wire:model.live="itemForm.parent_id" placeholder="No parent (top level)">
|
||||
@foreach($this->getParentOptions() as $option)
|
||||
<flux:option value="{{ $option['id'] }}">
|
||||
{{ str_repeat('— ', $option['level']) }}{{ $option['title'] }}
|
||||
</flux:option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:description>Choose a parent to create a sub-menu item</flux:description>
|
||||
</flux:field>
|
||||
|
||||
{{-- Settings --}}
|
||||
<flux:field>
|
||||
<flux:checkbox wire:model.live="itemForm.is_active">
|
||||
Active
|
||||
</flux:checkbox>
|
||||
<flux:description>Inactive items will not be displayed</flux:description>
|
||||
</flux:field>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<flux:button wire:click="$dispatch('close-modal')" variant="ghost">
|
||||
Cancel
|
||||
</flux:button>
|
||||
<flux:button wire:click="saveNavigationItem" variant="primary">
|
||||
{{ $editingItem ? 'Update' : 'Add' }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize nested sortable
|
||||
const initializeNestedSortable = () => {
|
||||
// Implementation for nested drag & drop would go here
|
||||
// You might want to use a library like SortableJS with nested support
|
||||
};
|
||||
|
||||
initializeNestedSortable();
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
<div class="space-y-6">
|
||||
{{-- Page Header --}}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900">
|
||||
{{ $editingPage ? 'Edit Page' : 'Create Page' }}
|
||||
</h1>
|
||||
@if($editingPage)
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
Last updated {{ $editingPage->updated_at->diffForHumans() }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
@if($editingPage && $editingPage->is_published)
|
||||
<flux:badge color="green" size="sm">Published</flux:badge>
|
||||
@elseif($editingPage)
|
||||
<flux:badge color="yellow" size="sm">Draft</flux:badge>
|
||||
@endif
|
||||
|
||||
<flux:button wire:click="save" variant="primary">
|
||||
{{ $editingPage ? 'Update' : 'Create' }} Page
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Page Settings --}}
|
||||
<flux:card>
|
||||
<flux:card.header>
|
||||
<flux:heading size="lg">Page Settings</flux:heading>
|
||||
</flux:card.header>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{{-- Basic Information --}}
|
||||
<div class="space-y-4">
|
||||
<flux:field>
|
||||
<flux:label>Title</flux:label>
|
||||
<flux:input wire:model.live="pageData.title" placeholder="Enter page title" />
|
||||
<flux:error name="pageData.title" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>Slug</flux:label>
|
||||
<flux:input wire:model.live="pageData.slug" placeholder="page-url-slug" />
|
||||
<flux:error name="pageData.slug" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>Template</flux:label>
|
||||
<flux:select wire:model.live="pageData.template" placeholder="Select template">
|
||||
@foreach($availableTemplates as $template)
|
||||
<flux:option value="{{ $template }}">{{ $template }}</flux:option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:error name="pageData.template" />
|
||||
</flux:field>
|
||||
</div>
|
||||
|
||||
{{-- SEO & Publishing --}}
|
||||
<div class="space-y-4">
|
||||
<flux:field>
|
||||
<flux:label>Meta Title</flux:label>
|
||||
<flux:input wire:model.live="pageData.meta_title" placeholder="SEO title" />
|
||||
<flux:error name="pageData.meta_title" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>Meta Description</flux:label>
|
||||
<flux:textarea wire:model.live="pageData.meta_description" placeholder="SEO description" rows="3" />
|
||||
<flux:error name="pageData.meta_description" />
|
||||
</flux:field>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<flux:field>
|
||||
<flux:checkbox wire:model.live="pageData.is_published">
|
||||
Published
|
||||
</flux:checkbox>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:checkbox wire:model.live="pageData.show_in_navigation">
|
||||
Show in Navigation
|
||||
</flux:checkbox>
|
||||
</flux:field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Page Components --}}
|
||||
<flux:card>
|
||||
<flux:card.header>
|
||||
<div class="flex items-center justify-between">
|
||||
<flux:heading size="lg">Page Components</flux:heading>
|
||||
<flux:button wire:click="$dispatch('open-component-modal')" size="sm">
|
||||
Add Component
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:card.header>
|
||||
|
||||
<div class="space-y-4" wire:sortable="updateComponentOrder">
|
||||
@forelse($components as $index => $component)
|
||||
<div wire:sortable.item="{{ $component['id'] }}"
|
||||
wire:key="component-{{ $component['id'] }}"
|
||||
class="p-4 border border-gray-200 rounded-lg bg-gray-50">
|
||||
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<flux:icon.bars-3 class="w-5 h-5 text-gray-400 cursor-move" wire:sortable.handle />
|
||||
<span class="font-medium text-gray-900">
|
||||
{{ $component['component_type'] }}
|
||||
</span>
|
||||
@if(!$component['is_active'])
|
||||
<flux:badge color="gray" size="sm">Inactive</flux:badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<flux:button wire:click="editComponent({{ $index }})" size="sm" variant="ghost">
|
||||
Edit
|
||||
</flux:button>
|
||||
<flux:button wire:click="toggleComponent({{ $index }})" size="sm" variant="ghost">
|
||||
{{ $component['is_active'] ? 'Disable' : 'Enable' }}
|
||||
</flux:button>
|
||||
<flux:button wire:click="removeComponent({{ $index }})" size="sm" variant="danger">
|
||||
Remove
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Component Preview --}}
|
||||
<div class="text-sm text-gray-600">
|
||||
@if(!empty($component['content']['title']))
|
||||
<strong>Title:</strong> {{ $component['content']['title'] }}
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="text-center py-8 text-gray-500">
|
||||
<flux:icon.puzzle-piece class="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||
<p>No components added yet.</p>
|
||||
<p class="text-sm">Click "Add Component" to get started.</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Component Selection Modal --}}
|
||||
<flux:modal name="component-modal" class="md:w-2xl">
|
||||
<div class="space-y-6">
|
||||
<flux:heading size="lg">Add Component</flux:heading>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@foreach($availableComponents as $componentClass => $componentInfo)
|
||||
<div wire:click="addComponent('{{ $componentClass }}')"
|
||||
class="p-4 border border-gray-200 rounded-lg cursor-pointer hover:border-blue-300 hover:bg-blue-50 transition-colors">
|
||||
<h3 class="font-medium text-gray-900 mb-2">
|
||||
{{ $componentInfo['name'] }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
{{ $componentInfo['description'] ?? 'No description available' }}
|
||||
</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
<div class="flux-cms-blog-list">
|
||||
{{-- Header --}}
|
||||
@if($showHeader)
|
||||
<div class="blog-header mb-8">
|
||||
@if($title)
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-4">{{ $title }}</h1>
|
||||
@endif
|
||||
|
||||
@if($description)
|
||||
<p class="text-lg text-gray-600">{{ $description }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Filters --}}
|
||||
@if($showFilters)
|
||||
<div class="blog-filters mb-8 space-y-4">
|
||||
<div class="flex flex-wrap gap-4">
|
||||
{{-- Search --}}
|
||||
<div class="flex-1 min-w-64">
|
||||
<input type="text"
|
||||
wire:model.live="search"
|
||||
placeholder="Search posts..."
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
{{-- Category Filter --}}
|
||||
@if($categories->isNotEmpty())
|
||||
<select wire:model.live="selectedCategory"
|
||||
class="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">All Categories</option>
|
||||
@foreach($categories as $category)
|
||||
<option value="{{ $category }}">{{ $category }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@endif
|
||||
|
||||
{{-- Tag Filter --}}
|
||||
@if($tags->isNotEmpty())
|
||||
<select wire:model.live="selectedTag"
|
||||
class="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">All Tags</option>
|
||||
@foreach($tags as $tag)
|
||||
<option value="{{ $tag }}">{{ $tag }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Active Filters --}}
|
||||
@if($search || $selectedCategory || $selectedTag)
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-600">Active filters:</span>
|
||||
|
||||
@if($search)
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
Search: "{{ $search }}"
|
||||
<button wire:click="$set('search', '')" class="ml-1 text-blue-600 hover:text-blue-800">×</button>
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@if($selectedCategory)
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Category: {{ $selectedCategory }}
|
||||
<button wire:click="$set('selectedCategory', '')" class="ml-1 text-green-600 hover:text-green-800">×</button>
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@if($selectedTag)
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
Tag: {{ $selectedTag }}
|
||||
<button wire:click="$set('selectedTag', '')" class="ml-1 text-purple-600 hover:text-purple-800">×</button>
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Posts Grid --}}
|
||||
<div class="blog-posts">
|
||||
@if($posts->isNotEmpty())
|
||||
<div class="grid gap-8 {{ $this->getGridClasses() }}">
|
||||
@foreach($posts as $post)
|
||||
<article class="blog-post-card bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow"
|
||||
wire:key="post-{{ $post->id }}">
|
||||
|
||||
{{-- Featured Image --}}
|
||||
@if($showFeaturedImages && $post->getFeaturedImage())
|
||||
<div class="aspect-w-16 aspect-h-9">
|
||||
<img src="{{ $post->getFeaturedImageUrl('hero') }}"
|
||||
alt="{{ $post->getTranslation('title', app()->getLocale()) }}"
|
||||
class="w-full h-48 object-cover">
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="p-6">
|
||||
{{-- Meta Information --}}
|
||||
@if($showMeta)
|
||||
<div class="flex items-center space-x-4 text-sm text-gray-500 mb-3">
|
||||
@if($showDate)
|
||||
<time datetime="{{ $post->published_at->toISOString() }}">
|
||||
{{ $post->published_at->format('M j, Y') }}
|
||||
</time>
|
||||
@endif
|
||||
|
||||
@if($showAuthor && $post->author)
|
||||
<span>By {{ $post->author->name }}</span>
|
||||
@endif
|
||||
|
||||
@if($showCategory && $post->category)
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
{{ $post->category }}
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@if($showReadingTime)
|
||||
<span>{{ $post->reading_time }} min read</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Title --}}
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-3">
|
||||
<a href="{{ $post->getUrl() }}"
|
||||
class="hover:text-blue-600 transition-colors">
|
||||
{{ $post->getTranslation('title', app()->getLocale()) }}
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
{{-- Excerpt --}}
|
||||
@if($showExcerpts && $post->excerpt)
|
||||
<p class="text-gray-600 mb-4 line-clamp-3">
|
||||
{{ $post->getTranslation('excerpt', app()->getLocale()) }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
{{-- Tags --}}
|
||||
@if($showTags && $post->tags && count($post->tags) > 0)
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
@foreach($post->tags as $tag)
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{{ $tag }}
|
||||
</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Read More Link --}}
|
||||
@if($showReadMore)
|
||||
<a href="{{ $post->getUrl() }}"
|
||||
class="inline-flex items-center text-blue-600 hover:text-blue-800 font-medium">
|
||||
{{ $readMoreText }}
|
||||
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</a>
|
||||
@endif
|
||||
|
||||
{{-- Featured Badge --}}
|
||||
@if($post->is_featured)
|
||||
<div class="absolute top-4 right-4">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
Featured
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Pagination --}}
|
||||
@if($posts->hasPages() && $showPagination)
|
||||
<div class="mt-8 flex justify-center">
|
||||
{{ $posts->links() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@else
|
||||
{{-- Empty State --}}
|
||||
<div class="text-center py-12">
|
||||
<div class="max-w-md mx-auto">
|
||||
<svg class="w-16 h-16 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"></path>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">No posts found</h3>
|
||||
<p class="text-gray-500">
|
||||
@if($search || $selectedCategory || $selectedTag)
|
||||
Try adjusting your filters to find what you're looking for.
|
||||
@else
|
||||
There are no published blog posts yet.
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Styles --}}
|
||||
@pushOnce('styles')
|
||||
<style>
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.aspect-w-16 {
|
||||
position: relative;
|
||||
padding-bottom: 56.25%; /* 16:9 */
|
||||
}
|
||||
|
||||
.aspect-w-16 img {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
</style>
|
||||
@endPushOnce
|
||||
|
|
@ -0,0 +1,339 @@
|
|||
<article class="flux-cms-blog-post max-w-4xl mx-auto">
|
||||
{{-- Post Header --}}
|
||||
<header class="blog-post-header mb-8">
|
||||
{{-- Featured Image --}}
|
||||
@if($post->getFeaturedImage())
|
||||
<div class="aspect-w-16 aspect-h-9 mb-8">
|
||||
<img src="{{ $post->getFeaturedImageUrl('hero') }}"
|
||||
alt="{{ $post->getTranslation('title', app()->getLocale()) }}"
|
||||
class="w-full h-64 md:h-96 object-cover rounded-lg">
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Category & Featured Badge --}}
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center space-x-3">
|
||||
@if($post->category)
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
|
||||
{{ $post->category }}
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@if($post->is_featured)
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800">
|
||||
Featured
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Share Buttons --}}
|
||||
@if($showShareButtons)
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-500">Share:</span>
|
||||
<a href="https://twitter.com/intent/tweet?url={{ urlencode(request()->url()) }}&text={{ urlencode($post->getTranslation('title', app()->getLocale())) }}"
|
||||
target="_blank"
|
||||
class="text-gray-400 hover:text-blue-500 transition-colors">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="https://www.facebook.com/sharer/sharer.php?u={{ urlencode(request()->url()) }}"
|
||||
target="_blank"
|
||||
class="text-gray-400 hover:text-blue-600 transition-colors">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="https://www.linkedin.com/sharing/share-offsite/?url={{ urlencode(request()->url()) }}"
|
||||
target="_blank"
|
||||
class="text-gray-400 hover:text-blue-700 transition-colors">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Title --}}
|
||||
<h1 class="text-3xl md:text-4xl lg:text-5xl font-bold text-gray-900 leading-tight mb-6">
|
||||
{{ $post->getTranslation('title', app()->getLocale()) }}
|
||||
</h1>
|
||||
|
||||
{{-- Meta Information --}}
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm text-gray-600 mb-6">
|
||||
@if($showAuthor && $post->author)
|
||||
<div class="flex items-center space-x-2">
|
||||
@if($post->author->profile_photo_url ?? false)
|
||||
<img src="{{ $post->author->profile_photo_url }}"
|
||||
alt="{{ $post->author->name }}"
|
||||
class="w-8 h-8 rounded-full">
|
||||
@endif
|
||||
<span>By <strong>{{ $post->author->name }}</strong></span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($showDate)
|
||||
<time datetime="{{ $post->published_at->toISOString() }}"
|
||||
class="flex items-center space-x-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
<span>{{ $post->published_at->format('F j, Y') }}</span>
|
||||
</time>
|
||||
@endif
|
||||
|
||||
@if($showReadingTime)
|
||||
<div class="flex items-center space-x-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<span>{{ $post->reading_time }} min read</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($showUpdatedDate && $post->updated_at->gt($post->published_at))
|
||||
<div class="flex items-center space-x-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
<span>Updated {{ $post->updated_at->format('F j, Y') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Excerpt --}}
|
||||
@if($showExcerpt && $post->excerpt)
|
||||
<div class="text-lg text-gray-600 leading-relaxed border-l-4 border-blue-500 pl-6 italic">
|
||||
{{ $post->getTranslation('excerpt', app()->getLocale()) }}
|
||||
</div>
|
||||
@endif
|
||||
</header>
|
||||
|
||||
{{-- Post Content --}}
|
||||
<div class="blog-post-content prose prose-lg max-w-none">
|
||||
{!! $post->getTranslation('content', app()->getLocale()) !!}
|
||||
</div>
|
||||
|
||||
{{-- Tags --}}
|
||||
@if($showTags && $post->tags && count($post->tags) > 0)
|
||||
<div class="mt-8 pt-8 border-t border-gray-200">
|
||||
<h3 class="text-sm font-medium text-gray-900 mb-3">Tags</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach($post->tags as $tag)
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-800 hover:bg-gray-200 transition-colors">
|
||||
#{{ $tag }}
|
||||
</span>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Author Bio --}}
|
||||
@if($showAuthorBio && $post->author)
|
||||
<div class="mt-12 pt-8 border-t border-gray-200">
|
||||
<div class="flex items-start space-x-4">
|
||||
@if($post->author->profile_photo_url ?? false)
|
||||
<img src="{{ $post->author->profile_photo_url }}"
|
||||
alt="{{ $post->author->name }}"
|
||||
class="w-16 h-16 rounded-full">
|
||||
@endif
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">{{ $post->author->name }}</h3>
|
||||
@if($post->author->bio ?? false)
|
||||
<p class="text-gray-600 mt-1">{{ $post->author->bio }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Navigation to Other Posts --}}
|
||||
@if($showNavigation && ($previousPost || $nextPost))
|
||||
<nav class="mt-12 pt-8 border-t border-gray-200">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@if($previousPost)
|
||||
<a href="{{ $previousPost->getUrl() }}" class="group">
|
||||
<div class="flex items-center space-x-3 p-4 border border-gray-200 rounded-lg hover:border-gray-300 transition-colors">
|
||||
<svg class="w-5 h-5 text-gray-400 group-hover:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<div class="text-sm text-gray-500">Previous</div>
|
||||
<div class="font-medium text-gray-900 group-hover:text-blue-600">
|
||||
{{ str_limit($previousPost->getTranslation('title', app()->getLocale()), 50) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@if($nextPost)
|
||||
<a href="{{ $nextPost->getUrl() }}" class="group">
|
||||
<div class="flex items-center justify-end space-x-3 p-4 border border-gray-200 rounded-lg hover:border-gray-300 transition-colors">
|
||||
<div class="text-right">
|
||||
<div class="text-sm text-gray-500">Next</div>
|
||||
<div class="font-medium text-gray-900 group-hover:text-blue-600">
|
||||
{{ str_limit($nextPost->getTranslation('title', app()->getLocale()), 50) }}
|
||||
</div>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-gray-400 group-hover:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</nav>
|
||||
@endif
|
||||
|
||||
{{-- SEO & Structured Data --}}
|
||||
@pushOnce('meta')
|
||||
<title>{{ $post->getSeoTitle() }}</title>
|
||||
<meta name="description" content="{{ $post->getSeoDescription() }}">
|
||||
<meta name="author" content="{{ $post->author?->name }}">
|
||||
|
||||
{{-- Open Graph --}}
|
||||
<meta property="og:title" content="{{ $post->getSeoTitle() }}">
|
||||
<meta property="og:description" content="{{ $post->getSeoDescription() }}">
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:url" content="{{ $post->getUrl() }}">
|
||||
@if($post->getFeaturedImage())
|
||||
<meta property="og:image" content="{{ $post->getFeaturedImageUrl('hero') }}">
|
||||
@endif
|
||||
<meta property="article:published_time" content="{{ $post->published_at->toISOString() }}">
|
||||
<meta property="article:modified_time" content="{{ $post->updated_at->toISOString() }}">
|
||||
@if($post->author)
|
||||
<meta property="article:author" content="{{ $post->author->name }}">
|
||||
@endif
|
||||
|
||||
{{-- Twitter Card --}}
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ $post->getSeoTitle() }}">
|
||||
<meta name="twitter:description" content="{{ $post->getSeoDescription() }}">
|
||||
@if($post->getFeaturedImage())
|
||||
<meta name="twitter:image" content="{{ $post->getFeaturedImageUrl('hero') }}">
|
||||
@endif
|
||||
@endPushOnce
|
||||
|
||||
@pushOnce('structured-data')
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BlogPosting",
|
||||
"headline": "{{ $post->getTranslation('title', app()->getLocale()) }}",
|
||||
"description": "{{ $post->getSeoDescription() }}",
|
||||
"image": "{{ $post->getFeaturedImageUrl('hero') }}",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "{{ $post->author?->name }}"
|
||||
},
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": "{{ config('app.name') }}"
|
||||
},
|
||||
"datePublished": "{{ $post->published_at->toISOString() }}",
|
||||
"dateModified": "{{ $post->updated_at->toISOString() }}",
|
||||
"mainEntityOfPage": {
|
||||
"@type": "WebPage",
|
||||
"@id": "{{ $post->getUrl() }}"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endPushOnce
|
||||
</article>
|
||||
|
||||
{{-- Styles for content --}}
|
||||
@pushOnce('styles')
|
||||
<style>
|
||||
.aspect-w-16 {
|
||||
position: relative;
|
||||
padding-bottom: 56.25%; /* 16:9 */
|
||||
}
|
||||
|
||||
.aspect-w-16 img {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.prose {
|
||||
color: #374151;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6 {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
font-size: 1.875rem;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-top: 1.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.prose a {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.prose a:hover {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.prose blockquote {
|
||||
font-style: italic;
|
||||
border-left: 4px solid #e5e7eb;
|
||||
padding-left: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.prose ul, .prose ol {
|
||||
margin: 1.25rem 0;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.prose li {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.prose img {
|
||||
margin: 2rem 0;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
background-color: #f3f4f6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.prose code {
|
||||
background-color: #f3f4f6;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.125rem 0.25rem;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
</style>
|
||||
@endPushOnce
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
<nav class="flux-cms-navigation" data-navigation="{{ $navigation->name }}">
|
||||
@if($navigationItems->isNotEmpty())
|
||||
<ul class="navigation-items {{ $cssClasses }}">
|
||||
@foreach($navigationItems as $item)
|
||||
<li class="navigation-item {{ $item->hasChildren() ? 'has-children' : '' }} {{ $this->isActive($item) ? 'active' : '' }}"
|
||||
data-item-id="{{ $item->id }}">
|
||||
|
||||
{{-- Item Link --}}
|
||||
@if($item->page_id || $item->url)
|
||||
<a href="{{ $item->getEffectiveUrl() }}"
|
||||
@if($item->target) target="{{ $item->target }}" @endif
|
||||
class="navigation-link {{ $this->isActive($item) ? 'active' : '' }}">
|
||||
{{ $item->getTranslation('title', app()->getLocale()) }}
|
||||
</a>
|
||||
@else
|
||||
<span class="navigation-text">
|
||||
{{ $item->getTranslation('title', app()->getLocale()) }}
|
||||
</span>
|
||||
@endif
|
||||
|
||||
{{-- Sub-navigation --}}
|
||||
@if($item->hasChildren() && $showSubmenus)
|
||||
<ul class="sub-navigation">
|
||||
@foreach($item->children as $child)
|
||||
@if($child->is_active)
|
||||
<li class="sub-navigation-item {{ $this->isActive($child) ? 'active' : '' }}"
|
||||
data-item-id="{{ $child->id }}">
|
||||
|
||||
@if($child->page_id || $child->url)
|
||||
<a href="{{ $child->getEffectiveUrl() }}"
|
||||
@if($child->target) target="{{ $child->target }}" @endif
|
||||
class="sub-navigation-link {{ $this->isActive($child) ? 'active' : '' }}">
|
||||
{{ $child->getTranslation('title', app()->getLocale()) }}
|
||||
</a>
|
||||
@else
|
||||
<span class="sub-navigation-text">
|
||||
{{ $child->getTranslation('title', app()->getLocale()) }}
|
||||
</span>
|
||||
@endif
|
||||
|
||||
{{-- Third level navigation if needed --}}
|
||||
@if($child->hasChildren() && $maxDepth > 2)
|
||||
<ul class="sub-sub-navigation">
|
||||
@foreach($child->children as $grandchild)
|
||||
@if($grandchild->is_active)
|
||||
<li class="sub-sub-navigation-item {{ $this->isActive($grandchild) ? 'active' : '' }}"
|
||||
data-item-id="{{ $grandchild->id }}">
|
||||
|
||||
@if($grandchild->page_id || $grandchild->url)
|
||||
<a href="{{ $grandchild->getEffectiveUrl() }}"
|
||||
@if($grandchild->target) target="{{ $grandchild->target }}" @endif
|
||||
class="sub-sub-navigation-link {{ $this->isActive($grandchild) ? 'active' : '' }}">
|
||||
{{ $grandchild->getTranslation('title', app()->getLocale()) }}
|
||||
</a>
|
||||
@else
|
||||
<span class="sub-sub-navigation-text">
|
||||
{{ $grandchild->getTranslation('title', app()->getLocale()) }}
|
||||
</span>
|
||||
@endif
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@else
|
||||
{{-- Empty navigation state --}}
|
||||
@if(config('app.debug'))
|
||||
<div class="navigation-empty text-gray-500 text-sm">
|
||||
Navigation "{{ $navigation->name }}" has no active items.
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</nav>
|
||||
|
||||
{{-- Default Styles (can be overridden in your theme) --}}
|
||||
@pushOnce('styles')
|
||||
<style>
|
||||
.flux-cms-navigation .navigation-items {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.flux-cms-navigation .navigation-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.flux-cms-navigation .navigation-link,
|
||||
.flux-cms-navigation .navigation-text {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.flux-cms-navigation .navigation-link:hover {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.flux-cms-navigation .navigation-link.active {
|
||||
font-weight: 600;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.flux-cms-navigation .sub-navigation,
|
||||
.flux-cms-navigation .sub-sub-navigation {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.flux-cms-navigation .has-children > .navigation-link::after {
|
||||
content: '▼';
|
||||
font-size: 0.75em;
|
||||
margin-left: 0.5rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Mobile-first responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.flux-cms-navigation .sub-navigation {
|
||||
margin-left: 0;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@endPushOnce
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
<div class="flux-cms-page" data-page-id="{{ $page->id }}">
|
||||
{{-- SEO Meta Tags (if not handled by layout) --}}
|
||||
@pushOnce('meta')
|
||||
<title>{{ $page->getSeoTitle() }}</title>
|
||||
<meta name="description" content="{{ $page->getSeoDescription() }}">
|
||||
|
||||
@if($page->meta_keywords)
|
||||
<meta name="keywords" content="{{ $page->meta_keywords }}">
|
||||
@endif
|
||||
|
||||
{{-- Open Graph --}}
|
||||
<meta property="og:title" content="{{ $page->getSeoTitle() }}">
|
||||
<meta property="og:description" content="{{ $page->getSeoDescription() }}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="{{ $page->getUrl() }}">
|
||||
|
||||
{{-- Twitter Card --}}
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ $page->getSeoTitle() }}">
|
||||
<meta name="twitter:description" content="{{ $page->getSeoDescription() }}">
|
||||
@endPushOnce
|
||||
|
||||
{{-- Page Content --}}
|
||||
<div class="space-y-8">
|
||||
@forelse($components as $component)
|
||||
@if($component->is_active)
|
||||
<div class="flux-cms-component"
|
||||
data-component-id="{{ $component->id }}"
|
||||
data-component-type="{{ $component->component_type }}"
|
||||
@if($component->settings['css_classes'] ?? false)
|
||||
class="{{ $component->settings['css_classes'] }}"
|
||||
@endif>
|
||||
|
||||
{{-- Render the actual Livewire component --}}
|
||||
@livewire($component->component_type, [
|
||||
'content' => $component->content,
|
||||
'settings' => $component->settings,
|
||||
'componentId' => $component->id
|
||||
], key('component-'.$component->id))
|
||||
</div>
|
||||
@endif
|
||||
@empty
|
||||
{{-- Empty state for pages with no components --}}
|
||||
<div class="text-center py-16">
|
||||
<div class="max-w-md mx-auto">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-4">
|
||||
{{ $page->title }}
|
||||
</h1>
|
||||
@if($page->content)
|
||||
<div class="prose prose-lg text-gray-600">
|
||||
{!! $page->content !!}
|
||||
</div>
|
||||
@else
|
||||
<p class="text-gray-600">
|
||||
This page doesn't have any content components yet.
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{-- JSON-LD Structured Data --}}
|
||||
@pushOnce('structured-data')
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": "{{ $page->getSeoTitle() }}",
|
||||
"description": "{{ $page->getSeoDescription() }}",
|
||||
"url": "{{ $page->getUrl() }}",
|
||||
"dateModified": "{{ $page->updated_at->toISOString() }}",
|
||||
"inLanguage": "{{ app()->getLocale() }}"
|
||||
}
|
||||
</script>
|
||||
@endPushOnce
|
||||
</div>
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
<div class="flex items-center justify-between p-3 border border-gray-200 rounded-lg bg-white {{ $level > 0 ? 'ml-6' : '' }}"
|
||||
style="margin-left: {{ $level * 1.5 }}rem;">
|
||||
|
||||
<div class="flex items-center space-x-3 flex-1">
|
||||
{{-- Drag Handle --}}
|
||||
<div wire:sortable.handle class="cursor-move text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{{-- Item Content --}}
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="font-medium text-gray-900">
|
||||
{{ $item->getTranslation('title', app()->getLocale()) }}
|
||||
</span>
|
||||
|
||||
{{-- Status Badges --}}
|
||||
@if(!$item->is_active)
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
Inactive
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@if($item->hasChildren())
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{{ $item->children->count() }} children
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-500 mt-1">
|
||||
@if($item->page_id && $item->page)
|
||||
<span class="inline-flex items-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
Page: {{ $item->page->title }}
|
||||
</span>
|
||||
@elseif($item->url)
|
||||
<span class="inline-flex items-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.102m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
||||
</svg>
|
||||
URL: {{ $item->url }}
|
||||
@if($item->target === '_blank')
|
||||
<svg class="w-3 h-3 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
||||
</svg>
|
||||
@endif
|
||||
</span>
|
||||
@else
|
||||
<span class="text-gray-400">No link</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Actions --}}
|
||||
<div class="flex items-center space-x-2">
|
||||
<button wire:click="editNavigationItem({{ $item->id }})"
|
||||
class="text-gray-400 hover:text-blue-600 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button wire:click="addChildItem({{ $item->id }})"
|
||||
class="text-gray-400 hover:text-green-600 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button wire:click="deleteNavigationItem({{ $item->id }})"
|
||||
class="text-gray-400 hover:text-red-600 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Render children recursively --}}
|
||||
@if($item->children->isNotEmpty() && $level < 3)
|
||||
<div class="mt-2 space-y-2">
|
||||
@foreach($item->children as $child)
|
||||
@include('flux-cms-components::partials.navigation-item', ['item' => $child, 'level' => $level + 1])
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Components;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Livewire\Livewire;
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Str;
|
||||
use ReflectionClass;
|
||||
|
||||
class FluxCmsComponentsServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register services
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
// Merge config if needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap services
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->bootViews();
|
||||
$this->bootPublishing();
|
||||
$this->bootLivewireComponents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot views
|
||||
*/
|
||||
protected function bootViews(): void
|
||||
{
|
||||
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'flux-cms-components');
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot publishing
|
||||
*/
|
||||
protected function bootPublishing(): void
|
||||
{
|
||||
if ($this->app->runningInConsole()) {
|
||||
// Publish views
|
||||
$this->publishes([
|
||||
__DIR__ . '/../resources/views' => resource_path('views/vendor/flux-cms-components'),
|
||||
], 'flux-cms-components-views');
|
||||
|
||||
// Publish assets
|
||||
$this->publishes([
|
||||
__DIR__ . '/../resources/assets' => public_path('vendor/flux-cms-components'),
|
||||
], 'flux-cms-components-assets');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register Livewire components
|
||||
*/
|
||||
protected function bootLivewireComponents(): void
|
||||
{
|
||||
$this->registerLivewireComponentsFrom(__DIR__ . '/Livewire/Backend', 'FluxCms\\Components\\Livewire\\Backend', 'flux-cms::');
|
||||
$this->registerLivewireComponentsFrom(__DIR__ . '/Livewire/Frontend', 'FluxCms\\Components\\Livewire\\Frontend', 'flux-cms::');
|
||||
}
|
||||
|
||||
protected function registerLivewireComponentsFrom(string $path, string $namespace, string $aliasPrefix = ''): void
|
||||
{
|
||||
$filesystem = new Filesystem();
|
||||
if (!$filesystem->isDirectory($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($filesystem->allFiles($path) as $file) {
|
||||
$class = $namespace . '\\' . str_replace(['/', '.php'], ['\\', ''], $file->getRelativePathname());
|
||||
|
||||
if (class_exists($class) && is_subclass_of($class, \Livewire\Component::class) && !(new ReflectionClass($class))->isAbstract()) {
|
||||
$alias = $aliasPrefix . Str::kebab(class_basename($class));
|
||||
Livewire::component($alias, $class);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Components\Livewire\Backend;
|
||||
|
||||
use Livewire\Component;
|
||||
use FluxCms\Core\Models\BlogPost;
|
||||
use Spatie\Tags\Tag;
|
||||
|
||||
class BlogEditor extends Component
|
||||
{
|
||||
public BlogPost $post;
|
||||
public string $tags = '';
|
||||
|
||||
public function mount(BlogPost $post)
|
||||
{
|
||||
$this->post = $post;
|
||||
$this->tags = implode(', ', $this->post->tags->pluck('name')->toArray());
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
$this->post->save();
|
||||
|
||||
$tags = array_filter(array_map('trim', explode(',', $this->tags)));
|
||||
$this->post->syncTags($tags);
|
||||
|
||||
$this->dispatch('saved');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('flux-cms-components::livewire.backend.blog-editor');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,318 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Components\Livewire\Backend;
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use FluxCms\Core\Models\BlogPost;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class BlogManager extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public string $domainKey;
|
||||
public array $availableLanguages = [];
|
||||
public string $currentLocale = 'de';
|
||||
public string $search = '';
|
||||
public string $filterStatus = 'all';
|
||||
public bool $showCreateModal = false;
|
||||
public ?BlogPost $editingPost = null;
|
||||
|
||||
// Form data
|
||||
public array $postData = [];
|
||||
|
||||
protected $paginationTheme = 'simple-bootstrap';
|
||||
|
||||
public function mount(string $domainKey)
|
||||
{
|
||||
$this->domainKey = $domainKey;
|
||||
$this->availableLanguages = array_keys(config('flux-cms.locales', ['de' => 'Deutsch', 'en' => 'English']));
|
||||
$this->currentLocale = app()->getLocale();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$posts = $this->getFilteredPosts();
|
||||
|
||||
return view('flux-cms-components::livewire.backend.blog-manager', [
|
||||
'posts' => $posts,
|
||||
])->layout('flux-cms-components::layouts.admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filtered blog posts
|
||||
*/
|
||||
protected function getFilteredPosts()
|
||||
{
|
||||
$query = BlogPost::forDomain($this->domainKey);
|
||||
|
||||
// Search filter
|
||||
if (!empty($this->search)) {
|
||||
$query->where(function ($q) {
|
||||
$q->where('title->de', 'like', '%' . $this->search . '%')
|
||||
->orWhere('title->en', 'like', '%' . $this->search . '%')
|
||||
->orWhere('content->de', 'like', '%' . $this->search . '%')
|
||||
->orWhere('content->en', 'like', '%' . $this->search . '%');
|
||||
});
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if ($this->filterStatus === 'published') {
|
||||
$query->published();
|
||||
} elseif ($this->filterStatus === 'draft') {
|
||||
$query->where('is_published', false);
|
||||
} elseif ($this->filterStatus === 'featured') {
|
||||
$query->featured();
|
||||
}
|
||||
|
||||
return $query->orderBy('updated_at', 'desc')->paginate(15);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch locale
|
||||
*/
|
||||
public function switchLocale(string $locale)
|
||||
{
|
||||
if (in_array($locale, $this->availableLanguages)) {
|
||||
$this->currentLocale = $locale;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search updated
|
||||
*/
|
||||
public function updatedSearch()
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter updated
|
||||
*/
|
||||
public function updatedFilterStatus()
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show create modal
|
||||
*/
|
||||
public function showCreatePost()
|
||||
{
|
||||
$this->editingPost = null;
|
||||
$this->postData = [
|
||||
'title' => array_fill_keys($this->availableLanguages, ''),
|
||||
'slug' => array_fill_keys($this->availableLanguages, ''),
|
||||
'excerpt' => array_fill_keys($this->availableLanguages, ''),
|
||||
'content' => array_fill_keys($this->availableLanguages, ''),
|
||||
'meta_description' => array_fill_keys($this->availableLanguages, ''),
|
||||
'meta_keywords' => array_fill_keys($this->availableLanguages, ''),
|
||||
'is_published' => false,
|
||||
'is_featured' => false,
|
||||
'published_at' => null,
|
||||
];
|
||||
$this->showCreateModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit post
|
||||
*/
|
||||
public function editPost(int $postId)
|
||||
{
|
||||
$this->editingPost = BlogPost::findOrFail($postId);
|
||||
$this->postData = [
|
||||
'title' => $this->editingPost->getTranslations('title'),
|
||||
'slug' => $this->editingPost->getTranslations('slug'),
|
||||
'excerpt' => $this->editingPost->getTranslations('excerpt'),
|
||||
'content' => $this->editingPost->getTranslations('content'),
|
||||
'meta_description' => $this->editingPost->getTranslations('meta_description'),
|
||||
'meta_keywords' => $this->editingPost->getTranslations('meta_keywords'),
|
||||
'is_published' => $this->editingPost->is_published,
|
||||
'is_featured' => $this->editingPost->is_featured,
|
||||
'published_at' => $this->editingPost->published_at?->format('Y-m-d\TH:i'),
|
||||
];
|
||||
$this->showCreateModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save post
|
||||
*/
|
||||
public function savePost()
|
||||
{
|
||||
$this->validate([
|
||||
'postData.title' => 'required|array',
|
||||
'postData.slug' => 'required|array',
|
||||
'postData.content' => 'required|array',
|
||||
]);
|
||||
|
||||
try {
|
||||
$data = [
|
||||
'domain_key' => $this->domainKey,
|
||||
'title' => $this->postData['title'],
|
||||
'slug' => $this->postData['slug'],
|
||||
'excerpt' => $this->postData['excerpt'],
|
||||
'content' => $this->postData['content'],
|
||||
'meta_description' => $this->postData['meta_description'],
|
||||
'meta_keywords' => $this->postData['meta_keywords'],
|
||||
'is_published' => $this->postData['is_published'] ?? false,
|
||||
'is_featured' => $this->postData['is_featured'] ?? false,
|
||||
'published_at' => $this->postData['published_at'] ?
|
||||
\Carbon\Carbon::parse($this->postData['published_at']) : null,
|
||||
];
|
||||
|
||||
if ($this->editingPost) {
|
||||
$this->editingPost->update($data);
|
||||
$message = 'Blog post updated successfully.';
|
||||
} else {
|
||||
$data['author_id'] = auth()->id();
|
||||
BlogPost::create($data);
|
||||
$message = 'Blog post created successfully.';
|
||||
}
|
||||
|
||||
$this->showCreateModal = false;
|
||||
$this->resetPage();
|
||||
session()->flash('success', $message);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error saving blog post: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete post
|
||||
*/
|
||||
public function deletePost(int $postId)
|
||||
{
|
||||
try {
|
||||
$post = BlogPost::find($postId);
|
||||
if ($post) {
|
||||
$post->delete();
|
||||
session()->flash('success', 'Blog post deleted successfully.');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error deleting blog post: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle publish status
|
||||
*/
|
||||
public function togglePublish(int $postId)
|
||||
{
|
||||
try {
|
||||
$post = BlogPost::find($postId);
|
||||
if ($post) {
|
||||
if ($post->is_published) {
|
||||
$post->unpublish();
|
||||
$message = 'Blog post unpublished.';
|
||||
} else {
|
||||
$post->publish();
|
||||
$message = 'Blog post published.';
|
||||
}
|
||||
session()->flash('success', $message);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error updating publish status: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle featured status
|
||||
*/
|
||||
public function toggleFeatured(int $postId)
|
||||
{
|
||||
try {
|
||||
$post = BlogPost::find($postId);
|
||||
if ($post) {
|
||||
$post->update(['is_featured' => !$post->is_featured]);
|
||||
$message = $post->is_featured ? 'Post marked as featured.' : 'Post removed from featured.';
|
||||
session()->flash('success', $message);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error updating featured status: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate post
|
||||
*/
|
||||
public function duplicatePost(int $postId)
|
||||
{
|
||||
try {
|
||||
$original = BlogPost::find($postId);
|
||||
if ($original) {
|
||||
$duplicate = $original->replicate();
|
||||
$duplicate->is_published = false;
|
||||
$duplicate->published_at = null;
|
||||
|
||||
// Update title to indicate it's a copy
|
||||
$titles = $duplicate->getTranslations('title');
|
||||
foreach ($titles as $locale => $title) {
|
||||
$titles[$locale] = $title . ' (Copy)';
|
||||
}
|
||||
$duplicate->title = $titles;
|
||||
|
||||
// Update slugs to avoid conflicts
|
||||
$slugs = $duplicate->getTranslations('slug');
|
||||
foreach ($slugs as $locale => $slug) {
|
||||
$slugs[$locale] = $slug . '-copy-' . time();
|
||||
}
|
||||
$duplicate->slug = $slugs;
|
||||
|
||||
$duplicate->save();
|
||||
session()->flash('success', 'Blog post duplicated successfully.');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error duplicating blog post: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate slug from title
|
||||
*/
|
||||
public function generateSlug(string $locale)
|
||||
{
|
||||
$title = $this->postData['title'][$locale] ?? '';
|
||||
if ($title) {
|
||||
$slug = \Illuminate\Support\Str::slug($title);
|
||||
$this->postData['slug'][$locale] = '/' . $slug;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close modal
|
||||
*/
|
||||
public function closeModal()
|
||||
{
|
||||
$this->showCreateModal = false;
|
||||
$this->editingPost = null;
|
||||
$this->postData = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available filters
|
||||
*/
|
||||
public function getAvailableFiltersProperty(): array
|
||||
{
|
||||
return [
|
||||
'all' => 'All Posts',
|
||||
'published' => 'Published',
|
||||
'draft' => 'Drafts',
|
||||
'featured' => 'Featured',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post stats
|
||||
*/
|
||||
public function getStatsProperty(): array
|
||||
{
|
||||
return [
|
||||
'total' => BlogPost::forDomain($this->domainKey)->count(),
|
||||
'published' => BlogPost::forDomain($this->domainKey)->published()->count(),
|
||||
'drafts' => BlogPost::forDomain($this->domainKey)->where('is_published', false)->count(),
|
||||
'featured' => BlogPost::forDomain($this->domainKey)->featured()->count(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Components\Livewire\Backend;
|
||||
|
||||
use Livewire\Component;
|
||||
use FluxCms\Core\Models\PageComponent;
|
||||
use FluxCms\Core\Services\ComponentRegistry;
|
||||
|
||||
class ComponentEditor extends Component
|
||||
{
|
||||
public PageComponent $component;
|
||||
public array $content = [];
|
||||
public array $availableLanguages = [];
|
||||
public string $currentLocale = 'de';
|
||||
public bool $expanded = false;
|
||||
public array $validationErrors = [];
|
||||
|
||||
protected ComponentRegistry $componentRegistry;
|
||||
|
||||
public function boot(ComponentRegistry $componentRegistry)
|
||||
{
|
||||
$this->componentRegistry = $componentRegistry;
|
||||
}
|
||||
|
||||
public function mount(PageComponent $component, string $locale = 'de')
|
||||
{
|
||||
$this->component = $component;
|
||||
$this->currentLocale = $locale;
|
||||
$this->availableLanguages = array_keys(config('flux-cms.locales', ['de' => 'Deutsch', 'en' => 'English']));
|
||||
$this->content = $component->getTranslations('content');
|
||||
$this->expanded = false;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$config = $this->componentRegistry->getComponentConfig($this->component->component_class);
|
||||
|
||||
return view('flux-cms-components::livewire.backend.component-editor', [
|
||||
'config' => $config,
|
||||
'fields' => $config['fields'] ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch locale
|
||||
*/
|
||||
public function switchLocale(string $locale)
|
||||
{
|
||||
if (in_array($locale, $this->availableLanguages)) {
|
||||
$this->currentLocale = $locale;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle expanded state
|
||||
*/
|
||||
public function toggleExpanded()
|
||||
{
|
||||
$this->expanded = !$this->expanded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update content when changed
|
||||
*/
|
||||
public function updatedContent()
|
||||
{
|
||||
$this->validateContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate content
|
||||
*/
|
||||
public function validateContent()
|
||||
{
|
||||
$this->validationErrors = $this->componentRegistry->validateComponentContent(
|
||||
$this->component->component_class,
|
||||
$this->content
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save component
|
||||
*/
|
||||
public function save()
|
||||
{
|
||||
$this->validateContent();
|
||||
|
||||
if (!empty($this->validationErrors)) {
|
||||
session()->flash('error', 'Please correct validation errors.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->component->update([
|
||||
'content' => $this->content
|
||||
]);
|
||||
|
||||
session()->flash('success', 'Component saved successfully.');
|
||||
$this->dispatch('component-saved', componentId: $this->component->id);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error saving component: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto save
|
||||
*/
|
||||
public function autoSave()
|
||||
{
|
||||
if (empty($this->validationErrors)) {
|
||||
try {
|
||||
$this->component->update([
|
||||
'content' => $this->content
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// Silent fail for auto-save
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open media manager
|
||||
*/
|
||||
public function selectMedia(string $fieldKey)
|
||||
{
|
||||
$this->dispatch('open-media-manager', [
|
||||
'componentId' => $this->component->id,
|
||||
'fieldKey' => $fieldKey,
|
||||
'locale' => $this->currentLocale,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle media selection
|
||||
*/
|
||||
public function mediaSelected(int $mediaId, string $fieldKey, ?string $locale = null)
|
||||
{
|
||||
if ($locale && in_array($locale, $this->availableLanguages)) {
|
||||
$this->content[$fieldKey][$locale] = $mediaId;
|
||||
} else {
|
||||
$this->content[$fieldKey] = $mediaId;
|
||||
}
|
||||
|
||||
$this->autoSave();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove media
|
||||
*/
|
||||
public function removeMedia(string $fieldKey, ?string $locale = null)
|
||||
{
|
||||
if ($locale && in_array($locale, $this->availableLanguages)) {
|
||||
$this->content[$fieldKey][$locale] = null;
|
||||
} else {
|
||||
$this->content[$fieldKey] = null;
|
||||
}
|
||||
|
||||
$this->autoSave();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get field value
|
||||
*/
|
||||
public function getFieldValue(string $fieldKey, ?string $locale = null): mixed
|
||||
{
|
||||
if ($locale) {
|
||||
return $this->content[$fieldKey][$locale] ?? null;
|
||||
}
|
||||
|
||||
return $this->content[$fieldKey] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set field value
|
||||
*/
|
||||
public function setFieldValue(string $fieldKey, mixed $value, ?string $locale = null)
|
||||
{
|
||||
if ($locale && in_array($locale, $this->availableLanguages)) {
|
||||
$this->content[$fieldKey][$locale] = $value;
|
||||
} else {
|
||||
$this->content[$fieldKey] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if field has error
|
||||
*/
|
||||
public function hasFieldError(string $fieldKey, ?string $locale = null): bool
|
||||
{
|
||||
$errorKey = $locale ? "{$fieldKey}.{$locale}" : $fieldKey;
|
||||
return isset($this->validationErrors[$errorKey]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get field errors
|
||||
*/
|
||||
public function getFieldErrors(string $fieldKey, ?string $locale = null): array
|
||||
{
|
||||
$errorKey = $locale ? "{$fieldKey}.{$locale}" : $fieldKey;
|
||||
return $this->validationErrors[$errorKey] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if component is valid
|
||||
*/
|
||||
public function getIsValidProperty(): bool
|
||||
{
|
||||
return empty($this->validationErrors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get component config
|
||||
*/
|
||||
public function getConfigProperty(): array
|
||||
{
|
||||
return $this->componentRegistry->getComponentConfig($this->component->component_class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to original content
|
||||
*/
|
||||
public function resetContent()
|
||||
{
|
||||
$this->content = $this->component->getTranslations('content');
|
||||
$this->validationErrors = [];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Components\Livewire\Backend;
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
use Livewire\WithPagination;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class MediaManager extends Component
|
||||
{
|
||||
use WithFileUploads, WithPagination;
|
||||
|
||||
public bool $showModal = false;
|
||||
public ?string $targetComponentId = null;
|
||||
public ?string $targetFieldKey = null;
|
||||
public ?string $targetLocale = null;
|
||||
public array $uploadingFiles = [];
|
||||
public string $searchTerm = '';
|
||||
public string $filterType = 'all';
|
||||
public array $selectedMedia = [];
|
||||
public bool $multiSelect = false;
|
||||
|
||||
protected $paginationTheme = 'simple-bootstrap';
|
||||
|
||||
public function render()
|
||||
{
|
||||
$media = $this->getFilteredMedia();
|
||||
|
||||
return view('flux-cms-components::livewire.backend.media-manager', [
|
||||
'media' => $media,
|
||||
'uploadProgress' => $this->getUploadProgress(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open modal
|
||||
*/
|
||||
public function openModal(?string $componentId = null, ?string $fieldKey = null, ?string $locale = null, bool $multiSelect = false)
|
||||
{
|
||||
$this->showModal = true;
|
||||
$this->targetComponentId = $componentId;
|
||||
$this->targetFieldKey = $fieldKey;
|
||||
$this->targetLocale = $locale;
|
||||
$this->multiSelect = $multiSelect;
|
||||
$this->selectedMedia = [];
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close modal
|
||||
*/
|
||||
public function closeModal()
|
||||
{
|
||||
$this->showModal = false;
|
||||
$this->targetComponentId = null;
|
||||
$this->targetFieldKey = null;
|
||||
$this->targetLocale = null;
|
||||
$this->multiSelect = false;
|
||||
$this->selectedMedia = [];
|
||||
$this->uploadingFiles = [];
|
||||
$this->searchTerm = '';
|
||||
$this->filterType = 'all';
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload files
|
||||
*/
|
||||
public function uploadFiles()
|
||||
{
|
||||
$this->validate([
|
||||
'uploadingFiles.*' => 'file|max:' . config('flux-cms.media.max_file_size', 10240),
|
||||
]);
|
||||
|
||||
try {
|
||||
foreach ($this->uploadingFiles as $file) {
|
||||
$this->uploadSingleFile($file);
|
||||
}
|
||||
|
||||
$this->uploadingFiles = [];
|
||||
session()->flash('success', 'Files uploaded successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error uploading files: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload single file
|
||||
*/
|
||||
protected function uploadSingleFile(UploadedFile $file)
|
||||
{
|
||||
// Create a temporary model for media library
|
||||
// In real implementation, you'd use a dedicated media model
|
||||
$mediaModel = new class extends \Illuminate\Database\Eloquent\Model implements \Spatie\MediaLibrary\HasMedia {
|
||||
use \Spatie\MediaLibrary\InteractsWithMedia;
|
||||
protected $table = 'flux_cms_media'; // Would exist in real implementation
|
||||
};
|
||||
|
||||
$media = $mediaModel
|
||||
->addMedia($file)
|
||||
->usingFileName($file->getClientOriginalName())
|
||||
->toMediaCollection('uploads');
|
||||
|
||||
return $media;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select media
|
||||
*/
|
||||
public function selectMedia(int $mediaId)
|
||||
{
|
||||
if ($this->multiSelect) {
|
||||
if (in_array($mediaId, $this->selectedMedia)) {
|
||||
$this->selectedMedia = array_filter($this->selectedMedia, fn($id) => $id !== $mediaId);
|
||||
} else {
|
||||
$this->selectedMedia[] = $mediaId;
|
||||
}
|
||||
} else {
|
||||
$this->selectedMedia = [$mediaId];
|
||||
$this->confirmSelection();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm selection
|
||||
*/
|
||||
public function confirmSelection()
|
||||
{
|
||||
if (empty($this->selectedMedia)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->targetComponentId && $this->targetFieldKey) {
|
||||
// Send event to component
|
||||
$this->dispatch('media-selected', [
|
||||
'componentId' => $this->targetComponentId,
|
||||
'fieldKey' => $this->targetFieldKey,
|
||||
'locale' => $this->targetLocale,
|
||||
'mediaIds' => $this->multiSelect ? $this->selectedMedia : $this->selectedMedia[0],
|
||||
]);
|
||||
} else {
|
||||
// Global event for other purposes
|
||||
$this->dispatch('media-manager-selection', [
|
||||
'mediaIds' => $this->selectedMedia,
|
||||
'multiSelect' => $this->multiSelect,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete media
|
||||
*/
|
||||
public function deleteMedia(int $mediaId)
|
||||
{
|
||||
try {
|
||||
$media = Media::find($mediaId);
|
||||
|
||||
if ($media) {
|
||||
$media->delete();
|
||||
session()->flash('success', 'Media deleted successfully.');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error deleting media: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filtered media
|
||||
*/
|
||||
protected function getFilteredMedia()
|
||||
{
|
||||
$query = Media::query()->orderBy('created_at', 'desc');
|
||||
|
||||
// Search filter
|
||||
if (!empty($this->searchTerm)) {
|
||||
$query->where('name', 'like', '%' . $this->searchTerm . '%');
|
||||
}
|
||||
|
||||
// Type filter
|
||||
if ($this->filterType !== 'all') {
|
||||
$query->where('mime_type', 'like', $this->filterType . '%');
|
||||
}
|
||||
|
||||
return $query->paginate(20);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get upload progress
|
||||
*/
|
||||
protected function getUploadProgress(): array
|
||||
{
|
||||
// Here you would normally track actual upload progress
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset filters
|
||||
*/
|
||||
public function resetFilters()
|
||||
{
|
||||
$this->searchTerm = '';
|
||||
$this->filterType = 'all';
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search term updated
|
||||
*/
|
||||
public function updatedSearchTerm()
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter type updated
|
||||
*/
|
||||
public function updatedFilterType()
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if media is selected
|
||||
*/
|
||||
public function isSelected(int $mediaId): bool
|
||||
{
|
||||
return in_array($mediaId, $this->selectedMedia);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle select all
|
||||
*/
|
||||
public function toggleSelectAll()
|
||||
{
|
||||
if (count($this->selectedMedia) === 20) { // Items per page
|
||||
$this->selectedMedia = [];
|
||||
} else {
|
||||
$media = $this->getFilteredMedia();
|
||||
$this->selectedMedia = $media->pluck('id')->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available filters
|
||||
*/
|
||||
public function getAvailableFiltersProperty(): array
|
||||
{
|
||||
return [
|
||||
'all' => 'All',
|
||||
'image' => 'Images',
|
||||
'video' => 'Videos',
|
||||
'audio' => 'Audio',
|
||||
'application' => 'Documents',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size
|
||||
*/
|
||||
public function formatFileSize(int $bytes): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
$bytes = max($bytes, 0);
|
||||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||
$pow = min($pow, count($units) - 1);
|
||||
|
||||
$bytes /= (1 << (10 * $pow));
|
||||
|
||||
return round($bytes, 2) . ' ' . $units[$pow];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,330 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Components\Livewire\Backend;
|
||||
|
||||
use Livewire\Component;
|
||||
use FluxCms\Core\Models\Navigation;
|
||||
use FluxCms\Core\Models\NavigationItem;
|
||||
use FluxCms\Core\Models\Page;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class NavigationManager extends Component
|
||||
{
|
||||
public string $domainKey;
|
||||
public Collection $navigations;
|
||||
public ?Navigation $selectedNavigation = null;
|
||||
public Collection $navigationItems;
|
||||
public array $availableLanguages = [];
|
||||
public string $currentLocale = 'de';
|
||||
public bool $showCreateModal = false;
|
||||
public bool $showItemModal = false;
|
||||
public ?NavigationItem $editingItem = null;
|
||||
|
||||
// Form data
|
||||
public array $navigationData = [];
|
||||
public array $itemData = [];
|
||||
|
||||
public function mount(string $domainKey)
|
||||
{
|
||||
$this->domainKey = $domainKey;
|
||||
$this->availableLanguages = array_keys(config('flux-cms.locales', ['de' => 'Deutsch', 'en' => 'English']));
|
||||
$this->currentLocale = app()->getLocale();
|
||||
$this->loadNavigations();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$pages = Page::forDomain($this->domainKey)->published()->get();
|
||||
|
||||
return view('flux-cms-components::livewire.backend.navigation-manager', [
|
||||
'pages' => $pages,
|
||||
])->layout('flux-cms-components::layouts.admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load navigations for domain
|
||||
*/
|
||||
public function loadNavigations()
|
||||
{
|
||||
$this->navigations = Navigation::forDomain($this->domainKey)->active()->get();
|
||||
|
||||
if ($this->selectedNavigation) {
|
||||
$this->loadNavigationItems();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load navigation items
|
||||
*/
|
||||
public function loadNavigationItems()
|
||||
{
|
||||
if (!$this->selectedNavigation) {
|
||||
$this->navigationItems = collect();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->navigationItems = $this->selectedNavigation->getHierarchicalItems();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select navigation
|
||||
*/
|
||||
public function selectNavigation(int $navigationId)
|
||||
{
|
||||
$this->selectedNavigation = $this->navigations->firstWhere('id', $navigationId);
|
||||
$this->loadNavigationItems();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch locale
|
||||
*/
|
||||
public function switchLocale(string $locale)
|
||||
{
|
||||
if (in_array($locale, $this->availableLanguages)) {
|
||||
$this->currentLocale = $locale;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show create navigation modal
|
||||
*/
|
||||
public function showCreateNavigation()
|
||||
{
|
||||
$this->navigationData = [
|
||||
'name' => '',
|
||||
'display_name' => array_fill_keys($this->availableLanguages, ''),
|
||||
'is_active' => true,
|
||||
];
|
||||
$this->showCreateModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create navigation
|
||||
*/
|
||||
public function createNavigation()
|
||||
{
|
||||
$this->validate([
|
||||
'navigationData.name' => 'required|string|max:255',
|
||||
'navigationData.display_name' => 'required|array',
|
||||
]);
|
||||
|
||||
try {
|
||||
$navigation = Navigation::create([
|
||||
'domain_key' => $this->domainKey,
|
||||
'name' => $this->navigationData['name'],
|
||||
'display_name' => $this->navigationData['display_name'],
|
||||
'is_active' => $this->navigationData['is_active'] ?? true,
|
||||
]);
|
||||
|
||||
$this->loadNavigations();
|
||||
$this->selectedNavigation = $navigation;
|
||||
$this->loadNavigationItems();
|
||||
$this->showCreateModal = false;
|
||||
|
||||
session()->flash('success', 'Navigation created successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error creating navigation: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show item modal
|
||||
*/
|
||||
public function showItemModal(?int $itemId = null, ?int $parentId = null)
|
||||
{
|
||||
if ($itemId) {
|
||||
$this->editingItem = NavigationItem::find($itemId);
|
||||
$this->itemData = [
|
||||
'label' => $this->editingItem->getTranslations('label'),
|
||||
'page_id' => $this->editingItem->page_id,
|
||||
'external_url' => $this->editingItem->external_url,
|
||||
'parent_id' => $this->editingItem->parent_id,
|
||||
'opens_in_new_tab' => $this->editingItem->opens_in_new_tab,
|
||||
'is_active' => $this->editingItem->is_active,
|
||||
];
|
||||
} else {
|
||||
$this->editingItem = null;
|
||||
$this->itemData = [
|
||||
'label' => array_fill_keys($this->availableLanguages, ''),
|
||||
'page_id' => null,
|
||||
'external_url' => '',
|
||||
'parent_id' => $parentId,
|
||||
'opens_in_new_tab' => false,
|
||||
'is_active' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$this->showItemModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save navigation item
|
||||
*/
|
||||
public function saveItem()
|
||||
{
|
||||
$this->validate([
|
||||
'itemData.label' => 'required|array',
|
||||
'itemData.page_id' => 'nullable|exists:flux_cms_pages,id',
|
||||
'itemData.external_url' => 'nullable|url',
|
||||
]);
|
||||
|
||||
// Validate that either page or external URL is provided
|
||||
if (empty($this->itemData['page_id']) && empty($this->itemData['external_url'])) {
|
||||
$this->addError('itemData.page_id', 'Either select a page or provide an external URL.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$data = [
|
||||
'navigation_id' => $this->selectedNavigation->id,
|
||||
'label' => $this->itemData['label'],
|
||||
'page_id' => $this->itemData['page_id'] ?: null,
|
||||
'external_url' => $this->itemData['external_url'] ?: null,
|
||||
'parent_id' => $this->itemData['parent_id'] ?: null,
|
||||
'opens_in_new_tab' => $this->itemData['opens_in_new_tab'] ?? false,
|
||||
'is_active' => $this->itemData['is_active'] ?? true,
|
||||
];
|
||||
|
||||
if ($this->editingItem) {
|
||||
$this->editingItem->update($data);
|
||||
$message = 'Navigation item updated successfully.';
|
||||
} else {
|
||||
// Set order for new item
|
||||
$maxOrder = $this->selectedNavigation->allItems()
|
||||
->where('parent_id', $data['parent_id'])
|
||||
->max('order') ?? 0;
|
||||
$data['order'] = $maxOrder + 1;
|
||||
|
||||
NavigationItem::create($data);
|
||||
$message = 'Navigation item created successfully.';
|
||||
}
|
||||
|
||||
$this->loadNavigationItems();
|
||||
$this->showItemModal = false;
|
||||
session()->flash('success', $message);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error saving navigation item: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete navigation item
|
||||
*/
|
||||
public function deleteItem(int $itemId)
|
||||
{
|
||||
try {
|
||||
$item = NavigationItem::find($itemId);
|
||||
if ($item) {
|
||||
$item->delete();
|
||||
$this->loadNavigationItems();
|
||||
session()->flash('success', 'Navigation item deleted successfully.');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error deleting navigation item: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle item active state
|
||||
*/
|
||||
public function toggleItem(int $itemId)
|
||||
{
|
||||
try {
|
||||
$item = NavigationItem::find($itemId);
|
||||
if ($item) {
|
||||
$item->update(['is_active' => !$item->is_active]);
|
||||
$this->loadNavigationItems();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error toggling navigation item: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update item order
|
||||
*/
|
||||
public function updateOrder(array $orderedIds)
|
||||
{
|
||||
try {
|
||||
foreach ($orderedIds as $index => $id) {
|
||||
NavigationItem::where('id', $id)->update(['order' => $index + 1]);
|
||||
}
|
||||
|
||||
$this->loadNavigationItems();
|
||||
session()->flash('success', 'Navigation order updated successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error updating order: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete navigation
|
||||
*/
|
||||
public function deleteNavigation(int $navigationId)
|
||||
{
|
||||
try {
|
||||
$navigation = Navigation::find($navigationId);
|
||||
if ($navigation) {
|
||||
$navigation->delete();
|
||||
$this->loadNavigations();
|
||||
|
||||
if ($this->selectedNavigation && $this->selectedNavigation->id === $navigationId) {
|
||||
$this->selectedNavigation = null;
|
||||
$this->navigationItems = collect();
|
||||
}
|
||||
|
||||
session()->flash('success', 'Navigation deleted successfully.');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error deleting navigation: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available parent items
|
||||
*/
|
||||
public function getAvailableParentsProperty(): Collection
|
||||
{
|
||||
if (!$this->selectedNavigation) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$query = $this->selectedNavigation->allItems()->whereNull('parent_id');
|
||||
|
||||
// Exclude current item and its children when editing
|
||||
if ($this->editingItem) {
|
||||
$excludeIds = [$this->editingItem->id];
|
||||
// Add children IDs recursively
|
||||
$this->addChildrenIds($this->editingItem, $excludeIds);
|
||||
$query->whereNotIn('id', $excludeIds);
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively add children IDs
|
||||
*/
|
||||
protected function addChildrenIds(NavigationItem $item, array &$excludeIds): void
|
||||
{
|
||||
foreach ($item->allChildren as $child) {
|
||||
$excludeIds[] = $child->id;
|
||||
$this->addChildrenIds($child, $excludeIds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close modals
|
||||
*/
|
||||
public function closeModals()
|
||||
{
|
||||
$this->showCreateModal = false;
|
||||
$this->showItemModal = false;
|
||||
$this->editingItem = null;
|
||||
$this->navigationData = [];
|
||||
$this->itemData = [];
|
||||
}
|
||||
}
|
||||
375
packages/flux-cms/components/src/Livewire/Backend/PageEditor.php
Normal file
375
packages/flux-cms/components/src/Livewire/Backend/PageEditor.php
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Components\Livewire\Backend;
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\Attributes\On;
|
||||
use FluxCms\Core\Models\Page;
|
||||
use FluxCms\Core\Models\PageComponent;
|
||||
use FluxCms\Core\Services\ComponentRegistry;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class PageEditor extends Component
|
||||
{
|
||||
public Page $page;
|
||||
public Collection $components;
|
||||
public array $availableLanguages = [];
|
||||
public string $currentLocale = 'de';
|
||||
public bool $showComponentModal = false;
|
||||
public array $availableComponents = [];
|
||||
public string $selectedCategory = 'all';
|
||||
public bool $isLoading = false;
|
||||
|
||||
protected ComponentRegistry $componentRegistry;
|
||||
|
||||
public function boot(ComponentRegistry $componentRegistry)
|
||||
{
|
||||
$this->componentRegistry = $componentRegistry;
|
||||
}
|
||||
|
||||
public function mount(Page $page)
|
||||
{
|
||||
$this->page = $page;
|
||||
$this->availableLanguages = array_keys(config('flux-cms.locales', ['de' => 'Deutsch', 'en' => 'English']));
|
||||
$this->currentLocale = app()->getLocale();
|
||||
$this->loadComponents();
|
||||
$this->loadAvailableComponents();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('flux-cms-components::livewire.backend.page-editor')
|
||||
->layout('flux-cms-components::layouts.admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load page components
|
||||
*/
|
||||
public function loadComponents()
|
||||
{
|
||||
$this->components = $this->page->allComponents()->ordered()->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load available components from registry
|
||||
*/
|
||||
public function loadAvailableComponents()
|
||||
{
|
||||
$this->availableComponents = $this->componentRegistry->getComponentsByCategory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch locale
|
||||
*/
|
||||
public function switchLocale(string $locale)
|
||||
{
|
||||
if (in_array($locale, $this->availableLanguages)) {
|
||||
$this->currentLocale = $locale;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show add component modal
|
||||
*/
|
||||
public function showAddComponentModal()
|
||||
{
|
||||
$this->loadAvailableComponents(); // Refresh components
|
||||
$this->showComponentModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close add component modal
|
||||
*/
|
||||
public function closeAddComponentModal()
|
||||
{
|
||||
$this->showComponentModal = false;
|
||||
$this->selectedCategory = 'all';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set category filter
|
||||
*/
|
||||
public function setCategory(string $category)
|
||||
{
|
||||
$this->selectedCategory = $category;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new component
|
||||
*/
|
||||
public function addComponent(string $componentClass)
|
||||
{
|
||||
if (!$this->componentRegistry->isValidComponent($componentClass)) {
|
||||
$this->addError('component', 'Invalid component selected.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$maxOrder = $this->page->allComponents()->max('order') ?? 0;
|
||||
|
||||
$component = $this->page->allComponents()->create([
|
||||
'component_class' => $componentClass,
|
||||
'order' => $maxOrder + 1,
|
||||
'content' => $this->getDefaultContent($componentClass),
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->loadComponents();
|
||||
$this->closeAddComponentModal();
|
||||
|
||||
$this->dispatch('scroll-to-component', componentId: $component->id);
|
||||
session()->flash('success', 'Component added successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('component', 'Error adding component: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete component
|
||||
*/
|
||||
public function deleteComponent(int $componentId)
|
||||
{
|
||||
try {
|
||||
$component = $this->components->firstWhere('id', $componentId);
|
||||
|
||||
if (!$component) {
|
||||
$this->addError('component', 'Component not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
$component->delete();
|
||||
$this->loadComponents();
|
||||
$this->reorderComponents();
|
||||
|
||||
session()->flash('success', 'Component deleted successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('component', 'Error deleting component: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate component
|
||||
*/
|
||||
public function duplicateComponent(int $componentId)
|
||||
{
|
||||
try {
|
||||
$component = $this->components->firstWhere('id', $componentId);
|
||||
|
||||
if (!$component) {
|
||||
$this->addError('component', 'Component not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
$duplicate = $component->duplicate();
|
||||
$this->loadComponents();
|
||||
|
||||
$this->dispatch('scroll-to-component', componentId: $duplicate->id);
|
||||
session()->flash('success', 'Component duplicated successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('component', 'Error duplicating component: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle component active state
|
||||
*/
|
||||
public function toggleComponent(int $componentId)
|
||||
{
|
||||
try {
|
||||
$component = $this->components->firstWhere('id', $componentId);
|
||||
|
||||
if (!$component) {
|
||||
return;
|
||||
}
|
||||
|
||||
$component->update(['is_active' => !$component->is_active]);
|
||||
$this->loadComponents();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('component', 'Error toggling component: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update component order
|
||||
*/
|
||||
#[On('components-reordered')]
|
||||
public function updateOrder(array $orderedIds)
|
||||
{
|
||||
try {
|
||||
foreach ($orderedIds as $index => $id) {
|
||||
PageComponent::where('id', $id)->update(['order' => $index + 1]);
|
||||
}
|
||||
|
||||
$this->loadComponents();
|
||||
session()->flash('success', 'Component order updated.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('order', 'Error updating order: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder components to close gaps
|
||||
*/
|
||||
protected function reorderComponents()
|
||||
{
|
||||
$components = $this->page->allComponents()->orderBy('order')->get();
|
||||
|
||||
foreach ($components as $index => $component) {
|
||||
$component->update(['order' => $index + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate default content for component
|
||||
*/
|
||||
protected function getDefaultContent(string $componentClass): array
|
||||
{
|
||||
$config = $this->componentRegistry->getComponentConfig($componentClass);
|
||||
$content = [];
|
||||
|
||||
if (empty($config['fields'])) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
foreach ($config['fields'] as $field) {
|
||||
if (!$field instanceof \FluxCms\Core\FieldTypes\BaseField) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$defaultValue = $field->getDefault();
|
||||
|
||||
if ($field->isTranslatable()) {
|
||||
foreach ($this->availableLanguages as $locale) {
|
||||
$content[$field->getKey()][$locale] = $defaultValue;
|
||||
}
|
||||
} else {
|
||||
$content[$field->getKey()] = $defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update page data
|
||||
*/
|
||||
public function updatePageData()
|
||||
{
|
||||
try {
|
||||
$this->validate([
|
||||
'page.title' => 'required|array',
|
||||
'page.slug' => 'required|array',
|
||||
'page.meta_description' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$this->page->save();
|
||||
session()->flash('success', 'Page data saved successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('page', 'Error saving page: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle publish status
|
||||
*/
|
||||
public function togglePublish()
|
||||
{
|
||||
try {
|
||||
if ($this->page->is_published) {
|
||||
$this->page->unpublish();
|
||||
$message = 'Page unpublished successfully.';
|
||||
} else {
|
||||
$this->page->publish();
|
||||
$message = 'Page published successfully.';
|
||||
}
|
||||
|
||||
session()->flash('success', $message);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('publish', 'Error updating publish status: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create version
|
||||
*/
|
||||
public function createVersion(string $description = null)
|
||||
{
|
||||
try {
|
||||
$this->page->createVersion($description, auth()->id());
|
||||
session()->flash('success', 'Version created successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('version', 'Error creating version: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview page
|
||||
*/
|
||||
public function preview()
|
||||
{
|
||||
$locale = $this->currentLocale;
|
||||
$slug = $this->page->getTranslation('slug', $locale);
|
||||
|
||||
if (empty($slug)) {
|
||||
$this->addError('preview', 'No slug available for current language.');
|
||||
return;
|
||||
}
|
||||
|
||||
$url = $this->page->getUrl($locale) . '?preview=1';
|
||||
$this->dispatch('open-preview', url: $url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available categories for filter
|
||||
*/
|
||||
public function getAvailableCategoriesProperty(): array
|
||||
{
|
||||
$categories = ['all' => 'All Categories'];
|
||||
|
||||
foreach ($this->availableComponents as $category => $components) {
|
||||
$categories[$category] = $category;
|
||||
}
|
||||
|
||||
return $categories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filtered components for modal
|
||||
*/
|
||||
public function getFilteredComponentsProperty(): array
|
||||
{
|
||||
if ($this->selectedCategory === 'all') {
|
||||
$components = [];
|
||||
foreach ($this->availableComponents as $category => $categoryComponents) {
|
||||
$components = array_merge($components, $categoryComponents);
|
||||
}
|
||||
return $components;
|
||||
}
|
||||
|
||||
return $this->availableComponents[$this->selectedCategory] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get page status
|
||||
*/
|
||||
public function getPageStatusProperty(): string
|
||||
{
|
||||
if (!$this->page->is_published) {
|
||||
return 'draft';
|
||||
}
|
||||
|
||||
if ($this->page->published_at && $this->page->published_at->isFuture()) {
|
||||
return 'scheduled';
|
||||
}
|
||||
|
||||
return 'published';
|
||||
}
|
||||
}
|
||||
265
packages/flux-cms/components/src/Livewire/Frontend/BlogList.php
Normal file
265
packages/flux-cms/components/src/Livewire/Frontend/BlogList.php
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Components\Livewire\Frontend;
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use FluxCms\Core\Models\BlogPost;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class BlogList extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public string $domainKey;
|
||||
public int $perPage = 12;
|
||||
public bool $showFeatured = true;
|
||||
public bool $showPagination = true;
|
||||
public string $orderBy = 'published_at';
|
||||
public string $orderDirection = 'desc';
|
||||
public array $classes = [];
|
||||
|
||||
// Filtering
|
||||
public string $search = '';
|
||||
public array $tags = [];
|
||||
public ?string $category = null;
|
||||
|
||||
protected $paginationTheme = 'simple-bootstrap';
|
||||
|
||||
public function mount(
|
||||
string $domainKey,
|
||||
int $perPage = 12,
|
||||
bool $showFeatured = true,
|
||||
bool $showPagination = true,
|
||||
string $orderBy = 'published_at',
|
||||
string $orderDirection = 'desc',
|
||||
array $classes = []
|
||||
) {
|
||||
$this->domainKey = $domainKey;
|
||||
$this->perPage = $perPage;
|
||||
$this->showFeatured = $showFeatured;
|
||||
$this->showPagination = $showPagination;
|
||||
$this->orderBy = $orderBy;
|
||||
$this->orderDirection = $orderDirection;
|
||||
$this->classes = $classes;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$posts = $this->getFilteredPosts();
|
||||
$featuredPosts = $this->showFeatured ? $this->getFeaturedPosts() : collect();
|
||||
|
||||
return view('flux-cms-components::livewire.frontend.blog-list', [
|
||||
'posts' => $posts,
|
||||
'featuredPosts' => $featuredPosts,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filtered blog posts
|
||||
*/
|
||||
protected function getFilteredPosts()
|
||||
{
|
||||
$query = BlogPost::forDomain($this->domainKey)->published();
|
||||
|
||||
// Search filter
|
||||
if (!empty($this->search)) {
|
||||
$locale = app()->getLocale();
|
||||
$query->where(function ($q) use ($locale) {
|
||||
$q->where("title->{$locale}", 'like', '%' . $this->search . '%')
|
||||
->orWhere("excerpt->{$locale}", 'like', '%' . $this->search . '%')
|
||||
->orWhere("content->{$locale}", 'like', '%' . $this->search . '%');
|
||||
});
|
||||
}
|
||||
|
||||
// Order by
|
||||
$query->orderBy($this->orderBy, $this->orderDirection);
|
||||
|
||||
// Pagination or limit
|
||||
if ($this->showPagination) {
|
||||
return $query->paginate($this->perPage);
|
||||
} else {
|
||||
return $query->limit($this->perPage)->get();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get featured posts
|
||||
*/
|
||||
protected function getFeaturedPosts(): Collection
|
||||
{
|
||||
return BlogPost::forDomain($this->domainKey)
|
||||
->published()
|
||||
->featured()
|
||||
->orderBy('published_at', 'desc')
|
||||
->limit(3)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update search
|
||||
*/
|
||||
public function updatedSearch()
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear search
|
||||
*/
|
||||
public function clearSearch()
|
||||
{
|
||||
$this->search = '';
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post title
|
||||
*/
|
||||
public function getPostTitle(BlogPost $post): string
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
return $post->getTranslation('title', $locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post excerpt
|
||||
*/
|
||||
public function getPostExcerpt(BlogPost $post, int $length = 150): string
|
||||
{
|
||||
return $post->getExcerpt($length, app()->getLocale());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post URL
|
||||
*/
|
||||
public function getPostUrl(BlogPost $post): string
|
||||
{
|
||||
return $post->getUrl(app()->getLocale());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post reading time
|
||||
*/
|
||||
public function getReadingTime(BlogPost $post): int
|
||||
{
|
||||
return $post->getReadingTime(app()->getLocale());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post featured image URL
|
||||
*/
|
||||
public function getFeaturedImageUrl(BlogPost $post, string $conversion = 'card'): ?string
|
||||
{
|
||||
return $post->getFeaturedImageUrl($conversion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if post has featured image
|
||||
*/
|
||||
public function hasFeaturedImage(BlogPost $post): bool
|
||||
{
|
||||
return $post->getFeaturedImage() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format published date
|
||||
*/
|
||||
public function formatPublishedDate(BlogPost $post, string $format = 'd.m.Y'): string
|
||||
{
|
||||
return $post->published_at ? $post->published_at->format($format) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get author name
|
||||
*/
|
||||
public function getAuthorName(BlogPost $post): string
|
||||
{
|
||||
return $post->author?->name ?? 'Unknown Author';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get container CSS classes
|
||||
*/
|
||||
public function getContainerClasses(): string
|
||||
{
|
||||
$defaultClasses = ['flux-cms-blog-list', 'blog-list'];
|
||||
$allClasses = array_merge($defaultClasses, $this->classes);
|
||||
return implode(' ', $allClasses);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post CSS classes
|
||||
*/
|
||||
public function getPostClasses(BlogPost $post): string
|
||||
{
|
||||
$classes = ['blog-post-item'];
|
||||
|
||||
if ($post->is_featured) {
|
||||
$classes[] = 'blog-post-item--featured';
|
||||
}
|
||||
|
||||
if ($this->hasFeaturedImage($post)) {
|
||||
$classes[] = 'blog-post-item--has-image';
|
||||
}
|
||||
|
||||
return implode(' ', $classes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any posts
|
||||
*/
|
||||
public function hasPosts(): bool
|
||||
{
|
||||
return $this->getFilteredPosts()->count() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are featured posts
|
||||
*/
|
||||
public function hasFeaturedPosts(): bool
|
||||
{
|
||||
return $this->showFeatured && $this->getFeaturedPosts()->isNotEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get search placeholder
|
||||
*/
|
||||
public function getSearchPlaceholder(): string
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
return $locale === 'de' ? 'Blog durchsuchen...' : 'Search blog...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get "no posts" message
|
||||
*/
|
||||
public function getNoPostsMessage(): string
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
if (!empty($this->search)) {
|
||||
return $locale === 'de'
|
||||
? 'Keine Artikel für "' . $this->search . '" gefunden.'
|
||||
: 'No posts found for "' . $this->search . '".';
|
||||
}
|
||||
|
||||
return $locale === 'de'
|
||||
? 'Noch keine Blog-Artikel vorhanden.'
|
||||
: 'No blog posts available yet.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reading time text
|
||||
*/
|
||||
public function getReadingTimeText(int $minutes): string
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
if ($locale === 'de') {
|
||||
return $minutes === 1 ? '1 Minute Lesezeit' : "{$minutes} Minuten Lesezeit";
|
||||
}
|
||||
|
||||
return $minutes === 1 ? '1 min read' : "{$minutes} min read";
|
||||
}
|
||||
}
|
||||
314
packages/flux-cms/components/src/Livewire/Frontend/BlogPost.php
Normal file
314
packages/flux-cms/components/src/Livewire/Frontend/BlogPost.php
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Components\Livewire\Frontend;
|
||||
|
||||
use Livewire\Component;
|
||||
use FluxCms\Core\Models\BlogPost as BlogPostModel;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class BlogPost extends Component
|
||||
{
|
||||
public BlogPostModel $post;
|
||||
public string $domainKey;
|
||||
public bool $showRelated = true;
|
||||
public bool $showAuthor = true;
|
||||
public bool $showMeta = true;
|
||||
public bool $showSocial = true;
|
||||
public array $classes = [];
|
||||
|
||||
public function mount(
|
||||
BlogPostModel $post,
|
||||
string $domainKey,
|
||||
bool $showRelated = true,
|
||||
bool $showAuthor = true,
|
||||
bool $showMeta = true,
|
||||
bool $showSocial = true,
|
||||
array $classes = []
|
||||
) {
|
||||
$this->post = $post;
|
||||
$this->domainKey = $domainKey;
|
||||
$this->showRelated = $showRelated;
|
||||
$this->showAuthor = $showAuthor;
|
||||
$this->showMeta = $showMeta;
|
||||
$this->showSocial = $showSocial;
|
||||
$this->classes = $classes;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$relatedPosts = $this->showRelated ? $this->getRelatedPosts() : collect();
|
||||
|
||||
return view('flux-cms-components::livewire.frontend.blog-post', [
|
||||
'relatedPosts' => $relatedPosts,
|
||||
'seoData' => $this->getSeoData(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get related posts
|
||||
*/
|
||||
protected function getRelatedPosts(): Collection
|
||||
{
|
||||
return BlogPostModel::forDomain($this->domainKey)
|
||||
->published()
|
||||
->where('id', '!=', $this->post->id)
|
||||
->orderBy('published_at', 'desc')
|
||||
->limit(3)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SEO data for the post
|
||||
*/
|
||||
protected function getSeoData(): array
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return [
|
||||
'title' => $this->getTitle() . ' - Blog',
|
||||
'description' => $this->getExcerpt(160),
|
||||
'keywords' => $this->post->getTranslation('meta_keywords', $locale),
|
||||
'og_title' => $this->getTitle(),
|
||||
'og_description' => $this->getExcerpt(160),
|
||||
'og_image' => $this->getFeaturedImageUrl('card'),
|
||||
'og_url' => $this->getUrl(),
|
||||
'og_type' => 'article',
|
||||
'article_published_time' => $this->post->published_at?->toISOString(),
|
||||
'article_author' => $this->getAuthorName(),
|
||||
'canonical_url' => $this->getUrl(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post title
|
||||
*/
|
||||
public function getTitle(): string
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
return $this->post->getTranslation('title', $locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post content
|
||||
*/
|
||||
public function getContent(): string
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
return $this->post->getTranslation('content', $locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post excerpt
|
||||
*/
|
||||
public function getExcerpt(int $length = 300): string
|
||||
{
|
||||
return $this->post->getExcerpt($length, app()->getLocale());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post URL
|
||||
*/
|
||||
public function getUrl(): string
|
||||
{
|
||||
return $this->post->getUrl(app()->getLocale());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reading time
|
||||
*/
|
||||
public function getReadingTime(): int
|
||||
{
|
||||
return $this->post->getReadingTime(app()->getLocale());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reading time text
|
||||
*/
|
||||
public function getReadingTimeText(): string
|
||||
{
|
||||
$minutes = $this->getReadingTime();
|
||||
$locale = app()->getLocale();
|
||||
|
||||
if ($locale === 'de') {
|
||||
return $minutes === 1 ? '1 Minute Lesezeit' : "{$minutes} Minuten Lesezeit";
|
||||
}
|
||||
|
||||
return $minutes === 1 ? '1 min read' : "{$minutes} min read";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get featured image URL
|
||||
*/
|
||||
public function getFeaturedImageUrl(string $conversion = 'hero'): ?string
|
||||
{
|
||||
return $this->post->getFeaturedImageUrl($conversion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if post has featured image
|
||||
*/
|
||||
public function hasFeaturedImage(): bool
|
||||
{
|
||||
return $this->post->getFeaturedImage() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format published date
|
||||
*/
|
||||
public function formatPublishedDate(string $format = 'd.m.Y'): string
|
||||
{
|
||||
return $this->post->published_at ? $this->post->published_at->format($format) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get author name
|
||||
*/
|
||||
public function getAuthorName(): string
|
||||
{
|
||||
return $this->post->author?->name ?? 'Unknown Author';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get author avatar URL
|
||||
*/
|
||||
public function getAuthorAvatarUrl(): ?string
|
||||
{
|
||||
// This would depend on your user model implementation
|
||||
return $this->post->author?->avatar_url ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if post is featured
|
||||
*/
|
||||
public function isFeatured(): bool
|
||||
{
|
||||
return $this->post->is_featured;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get container CSS classes
|
||||
*/
|
||||
public function getContainerClasses(): string
|
||||
{
|
||||
$defaultClasses = ['flux-cms-blog-post', 'blog-post'];
|
||||
$allClasses = array_merge($defaultClasses, $this->classes);
|
||||
|
||||
if ($this->isFeatured()) {
|
||||
$allClasses[] = 'blog-post--featured';
|
||||
}
|
||||
|
||||
if ($this->hasFeaturedImage()) {
|
||||
$allClasses[] = 'blog-post--has-image';
|
||||
}
|
||||
|
||||
return implode(' ', $allClasses);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get social sharing URLs
|
||||
*/
|
||||
public function getSocialUrls(): array
|
||||
{
|
||||
$url = urlencode($this->getUrl());
|
||||
$title = urlencode($this->getTitle());
|
||||
$excerpt = urlencode($this->getExcerpt(100));
|
||||
|
||||
return [
|
||||
'twitter' => "https://twitter.com/intent/tweet?url={$url}&text={$title}",
|
||||
'facebook' => "https://www.facebook.com/sharer/sharer.php?u={$url}",
|
||||
'linkedin' => "https://www.linkedin.com/sharing/share-offsite/?url={$url}",
|
||||
'email' => "mailto:?subject={$title}&body={$excerpt}%20{$url}",
|
||||
'whatsapp' => "https://wa.me/?text={$title}%20{$url}",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get related post title
|
||||
*/
|
||||
public function getRelatedPostTitle(BlogPostModel $relatedPost): string
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
return $relatedPost->getTranslation('title', $locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get related post URL
|
||||
*/
|
||||
public function getRelatedPostUrl(BlogPostModel $relatedPost): string
|
||||
{
|
||||
return $relatedPost->getUrl(app()->getLocale());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get related post excerpt
|
||||
*/
|
||||
public function getRelatedPostExcerpt(BlogPostModel $relatedPost, int $length = 100): string
|
||||
{
|
||||
return $relatedPost->getExcerpt($length, app()->getLocale());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get related post featured image URL
|
||||
*/
|
||||
public function getRelatedPostImageUrl(BlogPostModel $relatedPost, string $conversion = 'thumb'): ?string
|
||||
{
|
||||
return $relatedPost->getFeaturedImageUrl($conversion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are related posts
|
||||
*/
|
||||
public function hasRelatedPosts(): bool
|
||||
{
|
||||
return $this->showRelated && $this->getRelatedPosts()->isNotEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get schema.org structured data
|
||||
*/
|
||||
public function getStructuredData(): array
|
||||
{
|
||||
return [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'BlogPosting',
|
||||
'headline' => $this->getTitle(),
|
||||
'description' => $this->getExcerpt(160),
|
||||
'datePublished' => $this->post->published_at?->toISOString(),
|
||||
'dateModified' => $this->post->updated_at->toISOString(),
|
||||
'author' => [
|
||||
'@type' => 'Person',
|
||||
'name' => $this->getAuthorName(),
|
||||
],
|
||||
'publisher' => [
|
||||
'@type' => 'Organization',
|
||||
'name' => config('app.name'),
|
||||
],
|
||||
'url' => $this->getUrl(),
|
||||
'image' => $this->getFeaturedImageUrl('card'),
|
||||
'wordCount' => str_word_count(strip_tags($this->getContent())),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get navigation (previous/next posts)
|
||||
*/
|
||||
public function getNavigation(): array
|
||||
{
|
||||
$previousPost = BlogPostModel::forDomain($this->domainKey)
|
||||
->published()
|
||||
->where('published_at', '<', $this->post->published_at)
|
||||
->orderBy('published_at', 'desc')
|
||||
->first();
|
||||
|
||||
$nextPost = BlogPostModel::forDomain($this->domainKey)
|
||||
->published()
|
||||
->where('published_at', '>', $this->post->published_at)
|
||||
->orderBy('published_at', 'asc')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'previous' => $previousPost,
|
||||
'next' => $nextPost,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Components\Livewire\Frontend;
|
||||
|
||||
use Livewire\Component;
|
||||
use FluxCms\Core\Models\Navigation;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class NavigationRenderer extends Component
|
||||
{
|
||||
public string $domainKey;
|
||||
public string $navigationName;
|
||||
public ?Navigation $navigation = null;
|
||||
public Collection $navigationItems;
|
||||
public string $currentUrl = '';
|
||||
public array $classes = [];
|
||||
public bool $showInactive = false;
|
||||
|
||||
public function mount(
|
||||
string $domainKey,
|
||||
string $navigationName,
|
||||
array $classes = [],
|
||||
bool $showInactive = false
|
||||
) {
|
||||
$this->domainKey = $domainKey;
|
||||
$this->navigationName = $navigationName;
|
||||
$this->classes = $classes;
|
||||
$this->showInactive = $showInactive;
|
||||
$this->currentUrl = request()->url();
|
||||
$this->loadNavigation();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('flux-cms-components::livewire.frontend.navigation-renderer');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load navigation and items
|
||||
*/
|
||||
protected function loadNavigation()
|
||||
{
|
||||
$this->navigation = Navigation::forDomain($this->domainKey)
|
||||
->byName($this->navigationName, $this->domainKey)
|
||||
->active()
|
||||
->first();
|
||||
|
||||
if ($this->navigation) {
|
||||
$this->navigationItems = $this->navigation->getHierarchicalItems();
|
||||
|
||||
// Filter inactive items if needed
|
||||
if (!$this->showInactive) {
|
||||
$this->navigationItems = $this->navigationItems->where('is_active', true);
|
||||
}
|
||||
} else {
|
||||
$this->navigationItems = collect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if navigation item is active
|
||||
*/
|
||||
public function isActive($item): bool
|
||||
{
|
||||
return $item->isActive($this->currentUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get URL for navigation item
|
||||
*/
|
||||
public function getItemUrl($item): string
|
||||
{
|
||||
return $item->getUrl(app()->getLocale());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get label for navigation item
|
||||
*/
|
||||
public function getItemLabel($item): string
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
return $item->getTranslation('label', $locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if item has children
|
||||
*/
|
||||
public function hasChildren($item): bool
|
||||
{
|
||||
return $item->children->isNotEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get children of item
|
||||
*/
|
||||
public function getChildren($item): Collection
|
||||
{
|
||||
if (!$this->showInactive) {
|
||||
return $item->children->where('is_active', true);
|
||||
}
|
||||
return $item->children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if item should open in new tab
|
||||
*/
|
||||
public function opensInNewTab($item): bool
|
||||
{
|
||||
return $item->opens_in_new_tab;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSS classes for navigation container
|
||||
*/
|
||||
public function getNavigationClasses(): string
|
||||
{
|
||||
$defaultClasses = ['flux-cms-navigation', 'navigation-' . $this->navigationName];
|
||||
$allClasses = array_merge($defaultClasses, $this->classes);
|
||||
return implode(' ', $allClasses);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSS classes for navigation item
|
||||
*/
|
||||
public function getItemClasses($item, bool $isChild = false): string
|
||||
{
|
||||
$classes = ['nav-item'];
|
||||
|
||||
if ($isChild) {
|
||||
$classes[] = 'nav-item--child';
|
||||
}
|
||||
|
||||
if ($this->isActive($item)) {
|
||||
$classes[] = 'nav-item--active';
|
||||
}
|
||||
|
||||
if ($this->hasChildren($item)) {
|
||||
$classes[] = 'nav-item--has-children';
|
||||
}
|
||||
|
||||
if (!$item->is_active) {
|
||||
$classes[] = 'nav-item--inactive';
|
||||
}
|
||||
|
||||
return implode(' ', $classes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get link attributes
|
||||
*/
|
||||
public function getLinkAttributes($item): array
|
||||
{
|
||||
$attributes = [
|
||||
'href' => $this->getItemUrl($item),
|
||||
'class' => 'nav-link',
|
||||
];
|
||||
|
||||
if ($this->opensInNewTab($item)) {
|
||||
$attributes['target'] = '_blank';
|
||||
$attributes['rel'] = 'noopener noreferrer';
|
||||
}
|
||||
|
||||
if ($this->isActive($item)) {
|
||||
$attributes['class'] .= ' nav-link--active';
|
||||
$attributes['aria-current'] = 'page';
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render link attributes as string
|
||||
*/
|
||||
public function renderLinkAttributes($item): string
|
||||
{
|
||||
$attributes = $this->getLinkAttributes($item);
|
||||
$attributeStrings = [];
|
||||
|
||||
foreach ($attributes as $key => $value) {
|
||||
$attributeStrings[] = $key . '="' . htmlspecialchars($value) . '"';
|
||||
}
|
||||
|
||||
return implode(' ', $attributeStrings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get breadcrumbs for current page
|
||||
*/
|
||||
public function getBreadcrumbs(): Collection
|
||||
{
|
||||
$breadcrumbs = collect();
|
||||
|
||||
foreach ($this->navigationItems as $item) {
|
||||
if ($item->isActive($this->currentUrl)) {
|
||||
$breadcrumbs = $item->getBreadcrumbs();
|
||||
break;
|
||||
}
|
||||
|
||||
// Check children recursively
|
||||
$childBreadcrumbs = $this->findBreadcrumbsInChildren($item);
|
||||
if ($childBreadcrumbs->isNotEmpty()) {
|
||||
$breadcrumbs = $childBreadcrumbs;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $breadcrumbs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find breadcrumbs in children recursively
|
||||
*/
|
||||
protected function findBreadcrumbsInChildren($item): Collection
|
||||
{
|
||||
foreach ($item->children as $child) {
|
||||
if ($child->isActive($this->currentUrl)) {
|
||||
return $child->getBreadcrumbs();
|
||||
}
|
||||
|
||||
$childBreadcrumbs = $this->findBreadcrumbsInChildren($child);
|
||||
if ($childBreadcrumbs->isNotEmpty()) {
|
||||
return $childBreadcrumbs;
|
||||
}
|
||||
}
|
||||
|
||||
return collect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if navigation exists and has items
|
||||
*/
|
||||
public function hasNavigation(): bool
|
||||
{
|
||||
return $this->navigation !== null && $this->navigationItems->isNotEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get navigation display name
|
||||
*/
|
||||
public function getNavigationDisplayName(): string
|
||||
{
|
||||
if (!$this->navigation) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$locale = app()->getLocale();
|
||||
return $this->navigation->getTranslation('display_name', $locale);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Components\Livewire\Frontend;
|
||||
|
||||
use Livewire\Component;
|
||||
use FluxCms\Core\Models\Page;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class PageRenderer extends Component
|
||||
{
|
||||
public Page $page;
|
||||
public Collection $components;
|
||||
public bool $isPreview = false;
|
||||
public array $seoData = [];
|
||||
|
||||
public function mount(Page $page, bool $isPreview = false)
|
||||
{
|
||||
$this->page = $page;
|
||||
$this->isPreview = $isPreview;
|
||||
$this->loadComponents();
|
||||
$this->prepareSeoData();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('flux-cms-components::livewire.frontend.page-renderer')
|
||||
->layout('flux-cms-components::layouts.frontend', [
|
||||
'seoData' => $this->seoData,
|
||||
'page' => $this->page,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load page components
|
||||
*/
|
||||
protected function loadComponents()
|
||||
{
|
||||
if ($this->isPreview) {
|
||||
// Show all components in preview mode
|
||||
$this->components = $this->page->allComponents()->ordered()->get();
|
||||
} else {
|
||||
// Show only active components
|
||||
$this->components = $this->page->components()->get();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare SEO data
|
||||
*/
|
||||
protected function prepareSeoData()
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
$this->seoData = [
|
||||
'title' => $this->page->getSeoTitle($locale),
|
||||
'description' => $this->page->getSeoDescription($locale),
|
||||
'keywords' => $this->page->getTranslation('meta_keywords', $locale),
|
||||
'canonical_url' => $this->page->getCanonicalUrl(),
|
||||
'og_title' => $this->page->getTranslation('title', $locale),
|
||||
'og_description' => $this->page->getSeoDescription($locale),
|
||||
'og_image' => $this->page->getTranslation('og_image', $locale),
|
||||
'og_url' => request()->url(),
|
||||
'og_type' => 'website',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if component can be rendered
|
||||
*/
|
||||
public function canRenderComponent(PageComponent $component): bool
|
||||
{
|
||||
// In preview mode, show all components
|
||||
if ($this->isPreview) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// In normal mode, only show active components
|
||||
return $component->canRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get component content for current locale
|
||||
*/
|
||||
public function getComponentContent(PageComponent $component): array
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
return $component->getTranslatedContent($locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render component with error handling
|
||||
*/
|
||||
public function renderComponent(PageComponent $component): string
|
||||
{
|
||||
try {
|
||||
if (!$this->canRenderComponent($component)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$content = $this->getComponentContent($component);
|
||||
|
||||
// Check if component class exists
|
||||
if (!class_exists($component->component_class)) {
|
||||
if ($this->isPreview) {
|
||||
return $this->renderComponentError($component, 'Component class not found');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Render component
|
||||
$componentHtml = \Livewire\Livewire::mount($component->component_class, [
|
||||
'content' => $component->getTranslations('content')
|
||||
])->html();
|
||||
|
||||
// Wrap component if enabled
|
||||
if (config('flux-cms.frontend.component_wrapper', true)) {
|
||||
return $this->wrapComponent($component, $componentHtml);
|
||||
}
|
||||
|
||||
return $componentHtml;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Error rendering component', [
|
||||
'component_id' => $component->id,
|
||||
'component_class' => $component->component_class,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
if ($this->isPreview) {
|
||||
return $this->renderComponentError($component, $e->getMessage());
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap component with additional markup
|
||||
*/
|
||||
protected function wrapComponent(PageComponent $component, string $html): string
|
||||
{
|
||||
$classes = [
|
||||
'flux-cms-component',
|
||||
'flux-cms-component--' . class_basename($component->component_class),
|
||||
];
|
||||
|
||||
if (!$component->is_active) {
|
||||
$classes[] = 'flux-cms-component--inactive';
|
||||
}
|
||||
|
||||
$attributes = [];
|
||||
|
||||
if ($this->isPreview) {
|
||||
$attributes['data-component-id'] = $component->id;
|
||||
$attributes['data-component-class'] = $component->component_class;
|
||||
$classes[] = 'flux-cms-component--preview';
|
||||
}
|
||||
|
||||
$attributeString = collect($attributes)
|
||||
->map(fn($value, $key) => "{$key}=\"{$value}\"")
|
||||
->implode(' ');
|
||||
|
||||
$classString = implode(' ', $classes);
|
||||
|
||||
return "<div class=\"{$classString}\" {$attributeString}>{$html}</div>";
|
||||
}
|
||||
|
||||
/**
|
||||
* Render component error for preview mode
|
||||
*/
|
||||
protected function renderComponentError(PageComponent $component, string $error): string
|
||||
{
|
||||
$componentName = $component->getComponentName();
|
||||
|
||||
return "
|
||||
<div class=\"flux-cms-component-error p-4 border-2 border-dashed border-red-300 bg-red-50 text-red-800 rounded\">
|
||||
<h4 class=\"font-bold\">Error in {$componentName}</h4>
|
||||
<p class=\"text-sm mt-1\">{$error}</p>
|
||||
<p class=\"text-xs mt-2 opacity-75\">Component ID: {$component->id}</p>
|
||||
</div>
|
||||
";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get page breadcrumbs
|
||||
*/
|
||||
public function getBreadcrumbs(): array
|
||||
{
|
||||
// This could be extended to build breadcrumbs from navigation
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get related pages
|
||||
*/
|
||||
public function getRelatedPages(): Collection
|
||||
{
|
||||
return Page::forDomain($this->page->domain_key)
|
||||
->published()
|
||||
->where('id', '!=', $this->page->id)
|
||||
->limit(3)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Components\Tests\Feature\Backend;
|
||||
|
||||
use Livewire\Livewire;
|
||||
use FluxCms\Core\Models\BlogPost;
|
||||
use FluxCms\Components\Livewire\Backend\BlogEditor;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Orchestra\Testbench\TestCase;
|
||||
|
||||
class BlogEditorTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_can_render_blog_editor()
|
||||
{
|
||||
$post = BlogPost::factory()->create();
|
||||
Livewire::test(BlogEditor::class, ['post' => $post])
|
||||
->assertSee($post->title);
|
||||
}
|
||||
|
||||
public function test_can_update_post_and_sync_tags()
|
||||
{
|
||||
$post = BlogPost::factory()->create();
|
||||
$post->attachTag('Old Tag');
|
||||
|
||||
Livewire::test(BlogEditor::class, ['post' => $post])
|
||||
->set('post.title', 'Updated Title')
|
||||
->set('tags', 'new tag 1, new tag 2')
|
||||
->call('save');
|
||||
|
||||
$post->refresh();
|
||||
|
||||
$this->assertEquals('Updated Title', $post->title);
|
||||
$this->assertCount(2, $post->tags);
|
||||
$this->assertEquals(['new tag 1', 'new tag 2'], $post->tags->pluck('name')->toArray());
|
||||
}
|
||||
}
|
||||
56
packages/flux-cms/core/composer.json
Normal file
56
packages/flux-cms/core/composer.json
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
{
|
||||
"name": "flux-cms/core",
|
||||
"description": "Flux CMS Core Package - Multi-domain, component-first CMS for Laravel",
|
||||
"type": "library",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"cms",
|
||||
"livewire",
|
||||
"multi-domain",
|
||||
"components"
|
||||
],
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Flux CMS Contributors",
|
||||
"email": "contributors@flux-cms.com"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"laravel/framework": "^11.0|^12.0",
|
||||
"spatie/laravel-translatable": "^6.0",
|
||||
"spatie/laravel-medialibrary": "^11.0",
|
||||
"spatie/laravel-tags": "^4.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"orchestra/testbench": "^9.0",
|
||||
"pestphp/pest": "^3.8",
|
||||
"pestphp/pest-plugin-laravel": "^3.2"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"FluxCms\\Core\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"FluxCms\\Core\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"FluxCms\\Core\\FluxCmsServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true,
|
||||
"config": {
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true
|
||||
}
|
||||
}
|
||||
}
|
||||
10274
packages/flux-cms/core/composer.lock
generated
Normal file
10274
packages/flux-cms/core/composer.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
305
packages/flux-cms/core/config/flux-cms.php
Normal file
305
packages/flux-cms/core/config/flux-cms.php
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Flux CMS Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This file contains the configuration options for Flux CMS.
|
||||
|
|
||||
*/
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Locale
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The default locale for the CMS content.
|
||||
|
|
||||
*/
|
||||
'default_locale' => env('FLUX_CMS_DEFAULT_LOCALE', 'de'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Available Locales
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The available locales for the CMS content.
|
||||
|
|
||||
*/
|
||||
'locales' => [
|
||||
'de' => 'Deutsch',
|
||||
'en' => 'English',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Component Paths
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The namespaces where the CMS will scan for components.
|
||||
|
|
||||
*/
|
||||
'component_paths' => [
|
||||
'App\\Livewire\\Components',
|
||||
'FluxCms\\StarterComponents\\Components',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configuration for caching component registry and other CMS data.
|
||||
|
|
||||
*/
|
||||
'cache' => [
|
||||
'enabled' => env('FLUX_CMS_CACHE_ENABLED', true),
|
||||
'ttl' => env('FLUX_CMS_CACHE_TTL', 3600), // 1 hour
|
||||
'key_prefix' => 'flux_cms',
|
||||
'store' => env('FLUX_CMS_CACHE_STORE', null), // null = default cache store
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Database Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Database table configuration for Flux CMS.
|
||||
|
|
||||
*/
|
||||
'database' => [
|
||||
'table_prefix' => 'flux_cms_',
|
||||
'connection' => env('FLUX_CMS_DB_CONNECTION', null), // null = default connection
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication & Authorization
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configuration for user authentication and authorization.
|
||||
|
|
||||
*/
|
||||
'auth' => [
|
||||
'guard' => env('FLUX_CMS_AUTH_GUARD', 'web'),
|
||||
'default_access' => env('FLUX_CMS_DEFAULT_ACCESS', false),
|
||||
'super_admin_role' => 'admin',
|
||||
'cms_role' => 'flux-cms',
|
||||
'permissions' => [
|
||||
'view' => 'flux-cms.view',
|
||||
'edit' => 'flux-cms.edit',
|
||||
'publish' => 'flux-cms.publish',
|
||||
'delete' => 'flux-cms.delete',
|
||||
'admin' => 'flux-cms.admin',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Routes Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configuration for CMS routes.
|
||||
|
|
||||
*/
|
||||
'routes' => [
|
||||
'enabled' => env('FLUX_CMS_ROUTES_ENABLED', true),
|
||||
'prefix' => env('FLUX_CMS_ROUTE_PREFIX', ''),
|
||||
'middleware' => ['web'],
|
||||
'admin_prefix' => env('FLUX_CMS_ADMIN_PREFIX', 'admin/cms'),
|
||||
'admin_middleware' => ['web', 'auth'],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| SEO Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| SEO-related configuration options.
|
||||
|
|
||||
*/
|
||||
'seo' => [
|
||||
'site_name' => env('FLUX_CMS_SITE_NAME', config('app.name')),
|
||||
'separator' => env('FLUX_CMS_SEO_SEPARATOR', ' - '),
|
||||
'meta_keywords_limit' => 10,
|
||||
'meta_description_limit' => 160,
|
||||
'auto_sitemap' => true,
|
||||
'auto_robots' => true,
|
||||
'canonical_urls' => true,
|
||||
'og_image_default' => null,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Media Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configuration for media handling.
|
||||
|
|
||||
*/
|
||||
'media' => [
|
||||
'disk' => env('FLUX_CMS_MEDIA_DISK', 'public'),
|
||||
'max_file_size' => env('FLUX_CMS_MAX_FILE_SIZE', 10240), // 10MB in KB
|
||||
'allowed_extensions' => [
|
||||
'images' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'],
|
||||
'documents' => ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'csv'],
|
||||
'videos' => ['mp4', 'webm', 'ogg', 'avi', 'mov'],
|
||||
'audio' => ['mp3', 'wav', 'ogg', 'flac'],
|
||||
],
|
||||
'conversions' => [
|
||||
'thumb' => [
|
||||
'width' => 300,
|
||||
'height' => 300,
|
||||
'fit' => 'crop',
|
||||
],
|
||||
'medium' => [
|
||||
'width' => 800,
|
||||
'height' => 600,
|
||||
'fit' => 'contain',
|
||||
],
|
||||
'large' => [
|
||||
'width' => 1200,
|
||||
'height' => 900,
|
||||
'fit' => 'contain',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Editor Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configuration for WYSIWYG editors.
|
||||
|
|
||||
*/
|
||||
'editor' => [
|
||||
'default' => env('FLUX_CMS_EDITOR', 'tiptap'), // tiptap, tinymce, quill
|
||||
'upload_images' => true,
|
||||
'max_image_size' => 2048, // 2MB in KB
|
||||
'toolbar_presets' => [
|
||||
'basic' => ['bold', 'italic'],
|
||||
'standard' => ['bold', 'italic', 'link', 'bulletList', 'orderedList'],
|
||||
'full' => [
|
||||
'bold', 'italic', 'underline', 'strike',
|
||||
'heading1', 'heading2', 'heading3',
|
||||
'bulletList', 'orderedList',
|
||||
'link', 'image', 'table',
|
||||
'code', 'codeBlock',
|
||||
'quote', 'rule'
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Versioning Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configuration for content versioning.
|
||||
|
|
||||
*/
|
||||
'versioning' => [
|
||||
'enabled' => env('FLUX_CMS_VERSIONING_ENABLED', true),
|
||||
'auto_version' => env('FLUX_CMS_AUTO_VERSION', true),
|
||||
'max_versions' => env('FLUX_CMS_MAX_VERSIONS', 50),
|
||||
'cleanup_old_versions' => env('FLUX_CMS_CLEANUP_VERSIONS', true),
|
||||
'cleanup_after_days' => env('FLUX_CMS_CLEANUP_AFTER_DAYS', 365),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Performance Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Performance-related configuration options.
|
||||
|
|
||||
*/
|
||||
'performance' => [
|
||||
'eager_load_relations' => ['components', 'media'],
|
||||
'pagination_size' => 20,
|
||||
'component_registry_cache' => true,
|
||||
'query_cache_ttl' => 300, // 5 minutes
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Development Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configuration options for development mode.
|
||||
|
|
||||
*/
|
||||
'development' => [
|
||||
'debug_mode' => env('FLUX_CMS_DEBUG', false),
|
||||
'show_component_info' => env('FLUX_CMS_SHOW_COMPONENT_INFO', false),
|
||||
'log_queries' => env('FLUX_CMS_LOG_QUERIES', false),
|
||||
'hot_reload_components' => env('FLUX_CMS_HOT_RELOAD', false),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Frontend Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configuration for frontend rendering.
|
||||
|
|
||||
*/
|
||||
'frontend' => [
|
||||
'layout' => env('FLUX_CMS_LAYOUT', 'layouts.app'),
|
||||
'theme' => env('FLUX_CMS_THEME', 'default'),
|
||||
'css_framework' => env('FLUX_CMS_CSS_FRAMEWORK', 'tailwind'), // tailwind, bootstrap
|
||||
'component_wrapper' => env('FLUX_CMS_COMPONENT_WRAPPER', true),
|
||||
'preview_mode' => env('FLUX_CMS_PREVIEW_MODE', true),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| API Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configuration for CMS API endpoints.
|
||||
|
|
||||
*/
|
||||
'api' => [
|
||||
'enabled' => env('FLUX_CMS_API_ENABLED', false),
|
||||
'prefix' => env('FLUX_CMS_API_PREFIX', 'api/cms'),
|
||||
'middleware' => ['api', 'auth:sanctum'],
|
||||
'rate_limiting' => env('FLUX_CMS_API_RATE_LIMIT', '60,1'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Backup Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configuration for content backups.
|
||||
|
|
||||
*/
|
||||
'backup' => [
|
||||
'enabled' => env('FLUX_CMS_BACKUP_ENABLED', true),
|
||||
'disk' => env('FLUX_CMS_BACKUP_DISK', 'local'),
|
||||
'auto_backup' => env('FLUX_CMS_AUTO_BACKUP', true),
|
||||
'backup_schedule' => env('FLUX_CMS_BACKUP_SCHEDULE', 'daily'),
|
||||
'keep_backups' => env('FLUX_CMS_KEEP_BACKUPS', 30),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Domain Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Multi-domain support configuration.
|
||||
|
|
||||
*/
|
||||
'domains' => [
|
||||
'enabled' => env('FLUX_CMS_MULTI_DOMAIN', true),
|
||||
'config_source' => 'domains', // 'domains' config key or 'database'
|
||||
'default_domain' => env('FLUX_CMS_DEFAULT_DOMAIN', 'default'),
|
||||
'auto_detect' => env('FLUX_CMS_AUTO_DETECT_DOMAIN', true),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('flux_cms_pages', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('domain_key')->index();
|
||||
$table->json('title'); // Übersetzbarer Seitentitel
|
||||
$table->json('meta_description')->nullable(); // SEO Meta Description
|
||||
$table->json('meta_keywords')->nullable(); // SEO Keywords
|
||||
$table->json('og_image')->nullable(); // Open Graph Image (Media Library Reference)
|
||||
$table->string('canonical_url')->nullable(); // SEO Canonical URL
|
||||
$table->json('settings')->nullable(); // Zusätzliche Einstellungen
|
||||
$table->boolean('is_published')->default(true);
|
||||
$table->timestamp('published_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
// Composite indexes for performance
|
||||
$table->index(['domain_key', 'is_published']);
|
||||
$table->index(['domain_key', 'is_published', 'published_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('flux_cms_pages');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('flux_cms_page_components', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('page_id')->constrained('flux_cms_pages')->onDelete('cascade');
|
||||
$table->string('component_class'); // Full namespace of the component class
|
||||
$table->integer('order')->default(0);
|
||||
$table->json('content'); // Alle übersetzten Inhalte der Komponente
|
||||
$table->json('settings')->nullable(); // Komponenten-spezifische Einstellungen
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
// Indexes for performance
|
||||
$table->index(['page_id', 'order']);
|
||||
$table->index(['page_id', 'is_active']);
|
||||
$table->index(['component_class']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('flux_cms_page_components');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('flux_cms_page_versions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('page_id')->constrained('flux_cms_pages')->onDelete('cascade');
|
||||
$table->json('page_data'); // Snapshot der kompletten Seite
|
||||
$table->json('components_data'); // Snapshot aller Komponenten
|
||||
$table->string('version_name')->nullable(); // Benutzerdefinierter Name
|
||||
$table->text('change_description')->nullable(); // Was wurde geändert
|
||||
$table->string('created_by_type')->nullable(); // Polymorphic relation type
|
||||
$table->unsignedBigInteger('created_by_id')->nullable(); // Polymorphic relation id
|
||||
$table->timestamps();
|
||||
|
||||
// Indexes
|
||||
$table->index('page_id');
|
||||
$table->index(['page_id', 'created_at']);
|
||||
$table->index(['created_by_type', 'created_by_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('flux_cms_page_versions');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('flux_cms_navigations', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('domain_key')->index();
|
||||
$table->string('name'); // z.B. 'main', 'footer', 'sidebar'
|
||||
$table->json('display_name'); // Übersetzbarer Anzeigename
|
||||
$table->json('settings')->nullable(); // Navigation-spezifische Einstellungen
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
// Unique constraint
|
||||
$table->unique(['domain_key', 'name']);
|
||||
$table->index(['domain_key', 'is_active']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('flux_cms_navigations');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('flux_cms_navigation_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('navigation_id')->constrained('flux_cms_navigations')->onDelete('cascade');
|
||||
$table->foreignId('page_id')->nullable()->constrained('flux_cms_pages')->onDelete('cascade');
|
||||
$table->string('external_url')->nullable();
|
||||
$table->json('label'); // Übersetzbarer Menütext
|
||||
$table->integer('order')->default(0);
|
||||
$table->foreignId('parent_id')->nullable()->constrained('flux_cms_navigation_items')->onDelete('cascade');
|
||||
$table->json('settings')->nullable(); // Item-spezifische Einstellungen
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->boolean('opens_in_new_tab')->default(false);
|
||||
$table->timestamps();
|
||||
|
||||
// Indexes
|
||||
$table->index(['navigation_id', 'order']);
|
||||
$table->index(['navigation_id', 'parent_id']);
|
||||
$table->index(['navigation_id', 'is_active']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('flux_cms_navigation_items');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('flux_cms_blog_posts', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('domain_key')->index();
|
||||
$table->json('title'); // Übersetzbarer Titel
|
||||
$table->json('excerpt')->nullable(); // Übersetzbarer Kurztext
|
||||
$table->json('content'); // Übersetzbarer Haupttext
|
||||
$table->json('meta_description')->nullable(); // SEO
|
||||
$table->json('meta_keywords')->nullable(); // SEO
|
||||
$table->json('og_image')->nullable(); // Open Graph Image
|
||||
$table->json('settings')->nullable(); // Post-spezifische Einstellungen
|
||||
$table->boolean('is_published')->default(false);
|
||||
$table->boolean('is_featured')->default(false);
|
||||
$table->timestamp('published_at')->nullable();
|
||||
$table->string('author_type')->nullable(); // Polymorphic relation type
|
||||
$table->unsignedBigInteger('author_id')->nullable(); // Polymorphic relation id
|
||||
$table->timestamps();
|
||||
|
||||
// Indexes
|
||||
$table->index(['domain_key', 'is_published']);
|
||||
$table->index(['domain_key', 'is_published', 'published_at']);
|
||||
$table->index(['domain_key', 'is_featured']);
|
||||
$table->index(['author_type', 'author_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('flux_cms_blog_posts');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::create('tags', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->json('name');
|
||||
$table->json('slug');
|
||||
$table->string('type')->nullable();
|
||||
$table->integer('order_column')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('taggables', function (Blueprint $table) {
|
||||
$table->foreignId('tag_id')->constrained()->cascadeOnDelete();
|
||||
$table->morphs('taggable');
|
||||
$table->unique(['tag_id', 'taggable_id', 'taggable_type']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('taggables');
|
||||
Schema::dropIfExists('tags');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('flux_cms_slugs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->morphs('model');
|
||||
$table->string('locale')->index();
|
||||
$table->string('slug');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['locale', 'slug']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('flux_cms_slugs');
|
||||
}
|
||||
};
|
||||
175
packages/flux-cms/core/database/seeders/CmsContentSeeder.php
Normal file
175
packages/flux-cms/core/database/seeders/CmsContentSeeder.php
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use FluxCms\Core\Models\Page;
|
||||
use FluxCms\Core\Models\BlogPost;
|
||||
|
||||
class CmsContentSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$this->createSamplePages();
|
||||
$this->createSampleBlogPosts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create sample pages
|
||||
*/
|
||||
protected function createSamplePages(): void
|
||||
{
|
||||
// Homepage
|
||||
$homepage = Page::create([
|
||||
'domain_key' => 'default',
|
||||
'title' => [
|
||||
'de' => 'Willkommen auf unserer Website',
|
||||
'en' => 'Welcome to our Website'
|
||||
],
|
||||
'slug' => [
|
||||
'de' => '/',
|
||||
'en' => '/'
|
||||
],
|
||||
'meta_description' => [
|
||||
'de' => 'Willkommen auf unserer modernen Website, erstellt mit Flux CMS.',
|
||||
'en' => 'Welcome to our modern website, built with Flux CMS.'
|
||||
],
|
||||
'meta_keywords' => [
|
||||
'de' => 'Website, CMS, Flux CMS, Laravel',
|
||||
'en' => 'Website, CMS, Flux CMS, Laravel'
|
||||
],
|
||||
'is_published' => true,
|
||||
'published_at' => now(),
|
||||
]);
|
||||
|
||||
// About page
|
||||
$aboutPage = Page::create([
|
||||
'domain_key' => 'default',
|
||||
'title' => [
|
||||
'de' => 'Über uns',
|
||||
'en' => 'About us'
|
||||
],
|
||||
'slug' => [
|
||||
'de' => '/ueber-uns',
|
||||
'en' => '/about'
|
||||
],
|
||||
'meta_description' => [
|
||||
'de' => 'Erfahren Sie mehr über unser Unternehmen und unsere Mission.',
|
||||
'en' => 'Learn more about our company and our mission.'
|
||||
],
|
||||
'is_published' => true,
|
||||
'published_at' => now(),
|
||||
]);
|
||||
|
||||
// Contact page
|
||||
$contactPage = Page::create([
|
||||
'domain_key' => 'default',
|
||||
'title' => [
|
||||
'de' => 'Kontakt',
|
||||
'en' => 'Contact'
|
||||
],
|
||||
'slug' => [
|
||||
'de' => '/kontakt',
|
||||
'en' => '/contact'
|
||||
],
|
||||
'meta_description' => [
|
||||
'de' => 'Kontaktieren Sie uns für weitere Informationen.',
|
||||
'en' => 'Contact us for more information.'
|
||||
],
|
||||
'is_published' => true,
|
||||
'published_at' => now(),
|
||||
]);
|
||||
|
||||
$this->command->info('Sample pages created successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create sample blog posts
|
||||
*/
|
||||
protected function createSampleBlogPosts(): void
|
||||
{
|
||||
$posts = [
|
||||
[
|
||||
'title' => [
|
||||
'de' => 'Willkommen bei Flux CMS',
|
||||
'en' => 'Welcome to Flux CMS'
|
||||
],
|
||||
'slug' => [
|
||||
'de' => 'willkommen-bei-flux-cms',
|
||||
'en' => 'welcome-to-flux-cms'
|
||||
],
|
||||
'excerpt' => [
|
||||
'de' => 'Flux CMS ist ein modernes, komponentenbasiertes Content Management System für Laravel.',
|
||||
'en' => 'Flux CMS is a modern, component-based Content Management System for Laravel.'
|
||||
],
|
||||
'content' => [
|
||||
'de' => '<p>Flux CMS revolutioniert die Art, wie Sie Inhalte verwalten. Mit seiner einzigartigen "Code-as-Schema" Philosophie definieren Sie Inhaltsstrukturen direkt in PHP-Komponenten.</p><p>Dies bietet beispiellose Flexibilität und eine hervorragende Entwicklererfahrung.</p>',
|
||||
'en' => '<p>Flux CMS revolutionizes the way you manage content. With its unique "Code-as-Schema" philosophy, you define content structures directly in PHP components.</p><p>This offers unprecedented flexibility and an excellent developer experience.</p>'
|
||||
],
|
||||
'category' => 'News',
|
||||
'tags' => ['CMS', 'Laravel', 'Flux'],
|
||||
'is_published' => true,
|
||||
'is_featured' => true,
|
||||
'published_at' => now()->subDays(1),
|
||||
],
|
||||
[
|
||||
'title' => [
|
||||
'de' => 'Multi-Domain Support',
|
||||
'en' => 'Multi-Domain Support'
|
||||
],
|
||||
'slug' => [
|
||||
'de' => 'multi-domain-support',
|
||||
'en' => 'multi-domain-support'
|
||||
],
|
||||
'excerpt' => [
|
||||
'de' => 'Verwalten Sie mehrere Websites von einer Installation aus.',
|
||||
'en' => 'Manage multiple websites from one installation.'
|
||||
],
|
||||
'content' => [
|
||||
'de' => '<p>Mit Flux CMS können Sie mehrere Domains von einer einzigen Installation aus verwalten. Jede Domain kann ihre eigenen Inhalte, Designs und Einstellungen haben.</p>',
|
||||
'en' => '<p>With Flux CMS, you can manage multiple domains from a single installation. Each domain can have its own content, designs, and settings.</p>'
|
||||
],
|
||||
'category' => 'Features',
|
||||
'tags' => ['Multi-Domain', 'Features'],
|
||||
'is_published' => true,
|
||||
'is_featured' => false,
|
||||
'published_at' => now()->subDays(2),
|
||||
],
|
||||
[
|
||||
'title' => [
|
||||
'de' => 'Komponenten-First Architektur',
|
||||
'en' => 'Component-First Architecture'
|
||||
],
|
||||
'slug' => [
|
||||
'de' => 'komponenten-first-architektur',
|
||||
'en' => 'component-first-architecture'
|
||||
],
|
||||
'excerpt' => [
|
||||
'de' => 'Bauen Sie Seiten aus wiederverwendbaren Livewire-Komponenten.',
|
||||
'en' => 'Build pages from reusable Livewire components.'
|
||||
],
|
||||
'content' => [
|
||||
'de' => '<p>Die Komponenten-First Architektur von Flux CMS ermöglicht es Ihnen, komplexe Seiten aus kleinen, wiederverwendbaren Komponenten zu erstellen.</p><p>Jede Komponente kann ihre eigenen Felder und Validierungsregeln definieren.</p>',
|
||||
'en' => '<p>The component-first architecture of Flux CMS allows you to create complex pages from small, reusable components.</p><p>Each component can define its own fields and validation rules.</p>'
|
||||
],
|
||||
'category' => 'Architecture',
|
||||
'tags' => ['Components', 'Livewire', 'Architecture'],
|
||||
'is_published' => true,
|
||||
'is_featured' => false,
|
||||
'published_at' => now()->subDays(3),
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($posts as $postData) {
|
||||
BlogPost::create(array_merge($postData, [
|
||||
'domain_key' => 'default',
|
||||
'author_id' => 1, // Assuming user ID 1 exists
|
||||
]));
|
||||
}
|
||||
|
||||
$this->command->info('Sample blog posts created successfully!');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class CmsPermissionSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$this->createPermissions();
|
||||
$this->createRoles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create CMS permissions
|
||||
*/
|
||||
protected function createPermissions(): void
|
||||
{
|
||||
$permissions = [
|
||||
'flux-cms.view' => 'View CMS content',
|
||||
'flux-cms.edit' => 'Edit CMS content',
|
||||
'flux-cms.publish' => 'Publish CMS content',
|
||||
'flux-cms.delete' => 'Delete CMS content',
|
||||
'flux-cms.admin' => 'Administer CMS',
|
||||
];
|
||||
|
||||
foreach ($permissions as $name => $description) {
|
||||
Permission::firstOrCreate(
|
||||
['name' => $name],
|
||||
['description' => $description]
|
||||
);
|
||||
}
|
||||
|
||||
$this->command->info('CMS permissions created successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create CMS roles
|
||||
*/
|
||||
protected function createRoles(): void
|
||||
{
|
||||
// Create CMS Editor role
|
||||
$editorRole = Role::firstOrCreate(['name' => 'flux-cms-editor']);
|
||||
$editorRole->syncPermissions([
|
||||
'flux-cms.view',
|
||||
'flux-cms.edit',
|
||||
]);
|
||||
|
||||
// Create CMS Publisher role
|
||||
$publisherRole = Role::firstOrCreate(['name' => 'flux-cms-publisher']);
|
||||
$publisherRole->syncPermissions([
|
||||
'flux-cms.view',
|
||||
'flux-cms.edit',
|
||||
'flux-cms.publish',
|
||||
]);
|
||||
|
||||
// Create CMS Admin role
|
||||
$adminRole = Role::firstOrCreate(['name' => 'flux-cms-admin']);
|
||||
$adminRole->syncPermissions([
|
||||
'flux-cms.view',
|
||||
'flux-cms.edit',
|
||||
'flux-cms.publish',
|
||||
'flux-cms.delete',
|
||||
'flux-cms.admin',
|
||||
]);
|
||||
|
||||
// Create general CMS role (for backward compatibility)
|
||||
$cmsRole = Role::firstOrCreate(['name' => 'flux-cms']);
|
||||
$cmsRole->syncPermissions([
|
||||
'flux-cms.view',
|
||||
'flux-cms.edit',
|
||||
'flux-cms.publish',
|
||||
'flux-cms.delete',
|
||||
'flux-cms.admin',
|
||||
]);
|
||||
|
||||
$this->command->info('CMS roles created successfully!');
|
||||
$this->command->info('Available roles: flux-cms-editor, flux-cms-publisher, flux-cms-admin, flux-cms');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
@extends('flux-cms::admin.layouts.app')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-2xl font-bold mb-4">Edit Blog Post</h1>
|
||||
@livewire('flux-cms::blog-editor', ['post' => $post])
|
||||
</div>
|
||||
@endsection
|
||||
185
packages/flux-cms/core/resources/views/admin/dashboard.blade.php
Normal file
185
packages/flux-cms/core/resources/views/admin/dashboard.blade.php
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
@extends('flux-cms::admin.layouts.app')
|
||||
|
||||
@section('title', 'CMS Dashboard')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">CMS Dashboard</h1>
|
||||
<p class="mt-2 text-gray-600">Übersicht über Ihre Website-Inhalte</p>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-8">
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Alle Seiten</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">{{ $stats['pages'] }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Veröffentlichte Seiten</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">{{ $stats['published_pages'] }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-6 w-6 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Entwürfe</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">{{ $stats['draft_pages'] }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-6 w-6 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Blog Posts</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">{{ $stats['blog_posts'] }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-6 w-6 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Veröffentlichte Posts</dt>
|
||||
<dd class="text-lg font-medium text-gray-900">{{ $stats['published_posts'] }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="bg-white shadow rounded-lg mb-8">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">Schnellaktionen</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<a href="{{ route('admin.cms.pages.create') }}" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Neue Seite erstellen
|
||||
</a>
|
||||
<a href="{{ route('admin.cms.blog') }}" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">
|
||||
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
|
||||
</svg>
|
||||
Blog verwalten
|
||||
</a>
|
||||
<a href="{{ route('admin.cms.components') }}" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500">
|
||||
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
Komponenten
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Pages -->
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">Zuletzt bearbeitete Seiten</h3>
|
||||
@if($recentPages->count() > 0)
|
||||
<div class="overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Titel</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Domain</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Aktualisiert</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@foreach($recentPages as $page)
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{{ $page->getTranslation('title', app()->getLocale()) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ $page->domain_key }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
@if($page->is_published)
|
||||
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
|
||||
Veröffentlicht
|
||||
</span>
|
||||
@else
|
||||
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
||||
Entwurf
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ $page->updated_at->format('d.m.Y H:i') }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<a href="{{ route('admin.cms.pages.edit', $page) }}" class="text-blue-600 hover:text-blue-900">Bearbeiten</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<p class="text-gray-500">Noch keine Seiten vorhanden.</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
|
||||
<title>@yield('title', 'Flux CMS') - {{ config('app.name', 'Laravel') }}</title>
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Styles -->
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
|
||||
<!-- Tailwind CSS (optional, remove if Tailwind already built via Vite) -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="font-sans antialiased bg-gray-100">
|
||||
<div class="min-h-screen">
|
||||
<!-- Navigation -->
|
||||
<nav class="bg-white shadow-sm border-b border-gray-200">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex">
|
||||
<!-- Logo -->
|
||||
<div class="flex-shrink-0 flex items-center">
|
||||
<a href="{{ route('admin.cms.index') }}" class="text-xl font-bold text-gray-900">
|
||||
Flux CMS
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Links -->
|
||||
<div class="hidden space-x-8 sm:-my-px sm:ml-10 sm:flex">
|
||||
<a href="{{ route('admin.cms.index') }}" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm {{ request()->routeIs('admin.cms.index') ? 'border-blue-500 text-gray-900' : '' }}">
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="{{ route('admin.cms.pages') }}" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm {{ request()->routeIs('admin.cms.pages*') ? 'border-blue-500 text-gray-900' : '' }}">
|
||||
Seiten
|
||||
</a>
|
||||
<a href="{{ route('admin.cms.blog') }}" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm {{ request()->routeIs('admin.cms.blog*') ? 'border-blue-500 text-gray-900' : '' }}">
|
||||
Blog
|
||||
</a>
|
||||
<a href="{{ route('admin.cms.media') }}" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm {{ request()->routeIs('admin.cms.media*') ? 'border-blue-500 text-gray-900' : '' }}">
|
||||
Medien
|
||||
</a>
|
||||
<a href="{{ route('admin.cms.components') }}" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm {{ request()->routeIs('admin.cms.components*') ? 'border-blue-500 text-gray-900' : '' }}">
|
||||
Komponenten
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side -->
|
||||
<div class="hidden sm:ml-6 sm:flex sm:items-center">
|
||||
<!-- User dropdown -->
|
||||
<div class="ml-3 relative" x-data="{ open: false }">
|
||||
<div>
|
||||
<button @click="open = !open" class="bg-white flex text-sm rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
<span class="sr-only">Open user menu</span>
|
||||
<div class="h-8 w-8 rounded-full bg-gray-300 flex items-center justify-center">
|
||||
<span class="text-sm font-medium text-gray-700">
|
||||
{{ substr(auth()->user()->name ?? 'U', 0, 1) }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-show="open" @click.away="open = false" class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<a href="{{ route('admin.cms.index') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Dashboard</a>
|
||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Einstellungen</a>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
Abmelden
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
<div class="-mr-2 flex items-center sm:hidden">
|
||||
<button @click="mobileMenuOpen = !mobileMenuOpen" class="bg-white inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500">
|
||||
<span class="sr-only">Open main menu</span>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<div x-show="mobileMenuOpen" class="sm:hidden" x-data="{ mobileMenuOpen: false }">
|
||||
<div class="pt-2 pb-3 space-y-1">
|
||||
<a href="{{ route('admin.cms.index') }}" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 block pl-3 pr-4 py-2 border-l-4 text-base font-medium {{ request()->routeIs('admin.cms.index') ? 'border-blue-500 text-gray-900' : '' }}">
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="{{ route('admin.cms.pages') }}" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 block pl-3 pr-4 py-2 border-l-4 text-base font-medium {{ request()->routeIs('admin.cms.pages*') ? 'border-blue-500 text-gray-900' : '' }}">
|
||||
Seiten
|
||||
</a>
|
||||
<a href="{{ route('admin.cms.blog') }}" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 block pl-3 pr-4 py-2 border-l-4 text-base font-medium {{ request()->routeIs('admin.cms.blog*') ? 'border-blue-500 text-gray-900' : '' }}">
|
||||
Blog
|
||||
</a>
|
||||
<a href="{{ route('admin.cms.media') }}" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 block pl-3 pr-4 py-2 border-l-4 text-base font-medium {{ request()->routeIs('admin.cms.media*') ? 'border-blue-500 text-gray-900' : '' }}">
|
||||
Medien
|
||||
</a>
|
||||
<a href="{{ route('admin.cms.components') }}" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 block pl-3 pr-4 py-2 border-l-4 text-base font-medium {{ request()->routeIs('admin.cms.components*') ? 'border-blue-500 text-gray-900' : '' }}">
|
||||
Komponenten
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Page Content -->
|
||||
<main>
|
||||
@yield('content')
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
@if(session('success'))
|
||||
<div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 5000)" class="fixed top-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(session('error'))
|
||||
<div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 5000)" class="fixed top-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50">
|
||||
{{ session('error') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Scripts -->
|
||||
@stack('scripts')
|
||||
</body>
|
||||
</html>
|
||||
34
packages/flux-cms/core/resources/views/fields/text.blade.php
Normal file
34
packages/flux-cms/core/resources/views/fields/text.blade.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<div class="flux-cms-field {{ $field->getCssClasses($hasError) }}">
|
||||
<label for="{{ $fieldId }}" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
{{ $field->getLabel() }}
|
||||
@if($field->isRequired())
|
||||
<span class="text-red-500">*</span>
|
||||
@endif
|
||||
@if($field->isTranslatable())
|
||||
<span class="text-xs text-gray-500">({{ $locale }})</span>
|
||||
@endif
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="{{ $field->getInputType() }}"
|
||||
id="{{ $fieldId }}"
|
||||
name="{{ $field->getKey() }}"
|
||||
wire:model="{{ $wireModel }}"
|
||||
value="{{ $value }}"
|
||||
@if($field->isRequired()) required @endif
|
||||
@if($field->getMaxLength() > 0) maxlength="{{ $field->getMaxLength() }}" @endif
|
||||
@if($field->getMinLength() > 0) minlength="{{ $field->getMinLength() }}" @endif
|
||||
@if($field->getPattern()) pattern="{{ $field->getPattern() }}" @endif
|
||||
@if($field->getPlaceholder()) placeholder="{{ $field->getPlaceholder() }}" @endif
|
||||
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm @if($hasError) border-red-300 @endif"
|
||||
{!! $field->getAttributes() ? ' ' . implode(' ', array_map(fn($k, $v) => "{$k}=\"{$v}\"", array_keys($field->getAttributes()), $field->getAttributes())) : '' !!}
|
||||
>
|
||||
|
||||
@if($field->getHelpText())
|
||||
<p class="mt-1 text-sm text-gray-500">{{ $field->getHelpText() }}</p>
|
||||
@endif
|
||||
|
||||
@if($hasError)
|
||||
<p class="mt-1 text-sm text-red-600">{{ $error }}</p>
|
||||
@endif
|
||||
</div>
|
||||
48
packages/flux-cms/core/resources/views/pages/show.blade.php
Normal file
48
packages/flux-cms/core/resources/views/pages/show.blade.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
|
||||
<title>{{ $page->getSeoTitle() }}</title>
|
||||
<meta name="description" content="{{ $page->getSeoDescription() }}">
|
||||
<meta name="keywords" content="{{ $page->getTranslation('meta_keywords', app()->getLocale()) }}">
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="{{ $page->getTranslation('title', app()->getLocale()) }}">
|
||||
<meta property="og:description" content="{{ $page->getSeoDescription() }}">
|
||||
<meta property="og:url" content="{{ request()->url() }}">
|
||||
<meta property="og:type" content="website">
|
||||
@if($page->getTranslation('og_image', app()->getLocale()))
|
||||
<meta property="og:image" content="{{ $page->getTranslation('og_image', app()->getLocale()) }}">
|
||||
@endif
|
||||
|
||||
<!-- Canonical URL -->
|
||||
<link rel="canonical" href="{{ $page->getCanonicalUrl() }}">
|
||||
|
||||
<!-- Styles -->
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
|
||||
<!-- Tailwind CSS (optional, remove if Tailwind already built via Vite) -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
@stack('head')
|
||||
</head>
|
||||
<body class="font-sans antialiased">
|
||||
<main class="cms-page">
|
||||
@foreach($components as $component)
|
||||
@if($component->canRender())
|
||||
<div class="cms-component" data-component="{{ class_basename($component->component_class) }}">
|
||||
@livewire($component->component_class, [
|
||||
'content' => $component->getTranslations('content')
|
||||
], key('component-' . $component->id))
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</main>
|
||||
|
||||
<!-- Scripts -->
|
||||
@stack('scripts')
|
||||
</body>
|
||||
</html>
|
||||
14
packages/flux-cms/core/resources/views/robots.blade.php
Normal file
14
packages/flux-cms/core/resources/views/robots.blade.php
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
@if(config('flux-cms.seo.auto_sitemap', true))
|
||||
Sitemap: {{ $baseUrl }}/sitemap.xml
|
||||
@endif
|
||||
|
||||
# Disallow admin areas
|
||||
Disallow: /admin/
|
||||
Disallow: /preview/
|
||||
|
||||
# Disallow CMS specific paths
|
||||
Disallow: /flux-cms/
|
||||
Disallow: /vendor/
|
||||
24
packages/flux-cms/core/resources/views/sitemap.blade.php
Normal file
24
packages/flux-cms/core/resources/views/sitemap.blade.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
@foreach($pages as $page)
|
||||
@foreach(config('flux-cms.locales', ['de']) as $locale => $localeName)
|
||||
<url>
|
||||
<loc>{{ $page->getUrl($locale) }}</loc>
|
||||
<lastmod>{{ $page->updated_at->format('Y-m-d') }}</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>{{ $page->getTranslation('slug', $locale) === '/' ? '1.0' : '0.8' }}</priority>
|
||||
</url>
|
||||
@endforeach
|
||||
@endforeach
|
||||
|
||||
@foreach($blogPosts as $post)
|
||||
@foreach(config('flux-cms.locales', ['de']) as $locale => $localeName)
|
||||
<url>
|
||||
<loc>{{ $post->getUrl($locale) }}</loc>
|
||||
<lastmod>{{ $post->updated_at->format('Y-m-d') }}</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>{{ $post->is_featured ? '0.9' : '0.7' }}</priority>
|
||||
</url>
|
||||
@endforeach
|
||||
@endforeach
|
||||
</urlset>
|
||||
61
packages/flux-cms/core/routes/admin.php
Normal file
61
packages/flux-cms/core/routes/admin.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use FluxCms\Core\Http\Controllers\Admin\DashboardController;
|
||||
use FluxCms\Core\Http\Controllers\Admin\PageController as AdminPageController;
|
||||
use FluxCms\Core\Http\Controllers\Admin\BlogController;
|
||||
use FluxCms\Core\Http\Controllers\Admin\MediaController;
|
||||
use FluxCms\Core\Http\Controllers\Admin\NavigationController;
|
||||
use FluxCms\Core\Http\Controllers\Admin\ComponentController;
|
||||
use FluxCms\Core\Http\Controllers\PageController;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Flux CMS Admin Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These routes are for the CMS admin interface. They are protected by
|
||||
| authentication and authorization middleware.
|
||||
|
|
||||
*/
|
||||
|
||||
Route::middleware(['web', 'auth', 'flux-cms:cms-access'])
|
||||
->prefix(config('flux-cms.routes.admin_prefix', 'admin/cms'))
|
||||
->name('admin.cms.')
|
||||
->group(function () {
|
||||
|
||||
// Dashboard
|
||||
Route::get('/', DashboardController::class)->name('index');
|
||||
|
||||
// Pages Management
|
||||
Route::resource('pages', AdminPageController::class)->except(['show']);
|
||||
|
||||
// Blog Management
|
||||
Route::get('blog', [BlogController::class, 'index'])->name('blog.index');
|
||||
Route::get('blog/{blogPost}/edit', [BlogController::class, 'edit'])->name('blog.edit');
|
||||
|
||||
// Media Management
|
||||
Route::get('media', MediaController::class)->name('media.index');
|
||||
|
||||
// Navigation Management
|
||||
Route::get('navigation', NavigationController::class)->name('navigation.index');
|
||||
|
||||
// Component Library
|
||||
Route::get('components', ComponentController::class)->name('components.index');
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Flux CMS Preview Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These routes allow authenticated users to preview unpublished content.
|
||||
|
|
||||
*/
|
||||
|
||||
Route::middleware(['web', 'auth', 'flux-cms:cms-access'])
|
||||
->prefix('preview')
|
||||
->name('cms.preview.')
|
||||
->group(function () {
|
||||
Route::get('/pages/{page}', [PageController::class, 'preview'])->name('page');
|
||||
});
|
||||
30
packages/flux-cms/core/routes/web.php
Normal file
30
packages/flux-cms/core/routes/web.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use FluxCms\Core\Http\Controllers\PageController;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Flux CMS Frontend Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These routes handle the frontend display of CMS content. They should be
|
||||
| placed at the END of your routes/web.php file to avoid conflicts.
|
||||
|
|
||||
*/
|
||||
|
||||
// SEO Routes
|
||||
Route::get('/sitemap.xml', [PageController::class, 'sitemap'])->name('sitemap');
|
||||
Route::get('/robots.txt', [PageController::class, 'robots'])->name('robots');
|
||||
|
||||
// Blog Routes (if using blog functionality)
|
||||
Route::prefix('blog')->name('blog.')->group(function () {
|
||||
Route::get('/', [PageController::class, 'blogIndex'])->name('index');
|
||||
Route::get('/{slug}', [PageController::class, 'blogPost'])->name('show');
|
||||
});
|
||||
|
||||
// CMS Pages - MUST BE LAST!
|
||||
// This catch-all route should be placed at the very end of your routes file
|
||||
Route::get('/{slug?}', [PageController::class, 'show'])
|
||||
->where('slug', '.*')
|
||||
->name('cms.page');
|
||||
20
packages/flux-cms/core/src/Commands/ClearCacheCommand.php
Normal file
20
packages/flux-cms/core/src/Commands/ClearCacheCommand.php
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use FluxCms\Core\Services\ComponentRegistry;
|
||||
|
||||
class ClearCacheCommand extends Command
|
||||
{
|
||||
protected $signature = 'flux-cms:clear-cache';
|
||||
protected $description = 'Clear the Flux CMS component registry cache';
|
||||
|
||||
public function handle(ComponentRegistry $registry): int
|
||||
{
|
||||
$this->info('Clearing Flux CMS component cache...');
|
||||
$registry->clearCache();
|
||||
$this->info('Flux CMS component cache cleared successfully!');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
185
packages/flux-cms/core/src/Commands/InstallCommand.php
Normal file
185
packages/flux-cms/core/src/Commands/InstallCommand.php
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class InstallCommand extends Command
|
||||
{
|
||||
protected $signature = 'flux-cms:install
|
||||
{--force : Overwrite existing files}
|
||||
{--no-migrate : Skip running migrations}
|
||||
{--no-publish : Skip publishing assets}';
|
||||
|
||||
protected $description = 'Install Flux CMS';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('🚀 Installing Flux CMS...');
|
||||
|
||||
// Check requirements
|
||||
if (!$this->checkRequirements()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Publish configuration
|
||||
if (!$this->option('no-publish')) {
|
||||
$this->publishAssets();
|
||||
}
|
||||
|
||||
// Run migrations
|
||||
if (!$this->option('no-migrate')) {
|
||||
$this->runMigrations();
|
||||
}
|
||||
|
||||
// Create storage link if needed
|
||||
$this->createStorageLink();
|
||||
|
||||
// Create sample content
|
||||
$this->createSampleContent();
|
||||
|
||||
// Set permissions
|
||||
$this->setPermissions();
|
||||
|
||||
$this->info('✅ Flux CMS installed successfully!');
|
||||
$this->showNextSteps();
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function checkRequirements(): bool
|
||||
{
|
||||
$this->info('🔍 Checking requirements...');
|
||||
|
||||
$requirements = [
|
||||
'PHP 8.2+' => version_compare(PHP_VERSION, '8.2.0', '>='),
|
||||
'Laravel 11+' => $this->checkLaravelVersion(),
|
||||
'Livewire 3+' => $this->checkLivewireVersion(),
|
||||
'Spatie Translatable' => class_exists(\Spatie\Translatable\HasTranslations::class),
|
||||
'Spatie Media Library' => class_exists(\Spatie\MediaLibrary\HasMedia::class),
|
||||
];
|
||||
|
||||
$allPassed = true;
|
||||
foreach ($requirements as $requirement => $passed) {
|
||||
if ($passed) {
|
||||
$this->line("✅ {$requirement}");
|
||||
} else {
|
||||
$this->error("❌ {$requirement}");
|
||||
$allPassed = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$allPassed) {
|
||||
$this->error('❌ Some requirements are not met. Please install missing dependencies.');
|
||||
$this->line('Run: composer require spatie/laravel-translatable spatie/laravel-medialibrary');
|
||||
}
|
||||
|
||||
return $allPassed;
|
||||
}
|
||||
|
||||
protected function checkLaravelVersion(): bool
|
||||
{
|
||||
return version_compare(app()->version(), '11.0.0', '>=');
|
||||
}
|
||||
|
||||
protected function checkLivewireVersion(): bool
|
||||
{
|
||||
if (!class_exists(\Livewire\Livewire::class)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$version = \Livewire\Livewire::VERSION ?? '2.0.0';
|
||||
return version_compare($version, '3.0.0', '>=');
|
||||
}
|
||||
|
||||
protected function publishAssets(): void
|
||||
{
|
||||
$this->info('📦 Publishing configuration and assets...');
|
||||
|
||||
// Publish config
|
||||
$this->call('vendor:publish', [
|
||||
'--tag' => 'flux-cms-config',
|
||||
'--force' => $this->option('force'),
|
||||
]);
|
||||
|
||||
// Publish migrations
|
||||
$this->call('vendor:publish', [
|
||||
'--tag' => 'flux-cms-migrations',
|
||||
'--force' => $this->option('force'),
|
||||
]);
|
||||
|
||||
// Publish views
|
||||
$this->call('vendor:publish', [
|
||||
'--tag' => 'flux-cms-views',
|
||||
'--force' => $this->option('force'),
|
||||
]);
|
||||
|
||||
$this->line('✅ Assets published');
|
||||
}
|
||||
|
||||
protected function runMigrations(): void
|
||||
{
|
||||
$this->info('🗃️ Running migrations...');
|
||||
|
||||
$this->call('migrate');
|
||||
|
||||
$this->line('✅ Migrations completed');
|
||||
}
|
||||
|
||||
protected function createStorageLink(): void
|
||||
{
|
||||
if (!File::exists(public_path('storage'))) {
|
||||
$this->info('🔗 Creating storage link...');
|
||||
$this->call('storage:link');
|
||||
$this->line('✅ Storage link created');
|
||||
}
|
||||
}
|
||||
|
||||
protected function createSampleContent(): void
|
||||
{
|
||||
if ($this->confirm('Would you like to create sample content?', true)) {
|
||||
$this->info('📝 Creating sample content...');
|
||||
|
||||
// Run CMS content seeder
|
||||
$this->call('db:seed', ['--class' => 'FluxCms\\Core\\Database\\Seeders\\CmsContentSeeder']);
|
||||
|
||||
$this->line('✅ Sample content created');
|
||||
}
|
||||
}
|
||||
|
||||
protected function setPermissions(): void
|
||||
{
|
||||
if ($this->confirm('Would you like to create CMS permissions?', true)) {
|
||||
$this->info('🔐 Setting up permissions...');
|
||||
|
||||
// Check if Spatie Permission is available
|
||||
if (class_exists(\Spatie\Permission\Models\Permission::class)) {
|
||||
$this->createCmsPermissions();
|
||||
$this->line('✅ Permissions created');
|
||||
} else {
|
||||
$this->warn('⚠️ Spatie Permission package not found. Skipping permission setup.');
|
||||
$this->line('Install with: composer require spatie/laravel-permission');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function createCmsPermissions(): void
|
||||
{
|
||||
// Run CMS permission seeder
|
||||
$this->call('db:seed', ['--class' => 'FluxCms\\Core\\Database\\Seeders\\CmsPermissionSeeder']);
|
||||
}
|
||||
|
||||
protected function showNextSteps(): void
|
||||
{
|
||||
$this->newLine();
|
||||
$this->info('🎉 Next steps:');
|
||||
$this->line('1. Assign the "flux-cms" role to users who should access the CMS');
|
||||
$this->line('2. Create your first page: php artisan flux-cms:make:page');
|
||||
$this->line('3. Create custom components: php artisan flux-cms:make:component');
|
||||
$this->line('4. Visit /admin/cms to start editing');
|
||||
$this->newLine();
|
||||
$this->info('📚 Documentation: https://flux-cms.com/docs');
|
||||
$this->info('💬 Support: https://github.com/flux-cms/flux-cms/discussions');
|
||||
}
|
||||
}
|
||||
61
packages/flux-cms/core/src/Commands/MakeComponentCommand.php
Normal file
61
packages/flux-cms/core/src/Commands/MakeComponentCommand.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Commands;
|
||||
|
||||
use Illuminate\Console\GeneratorCommand;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
|
||||
class MakeComponentCommand extends GeneratorCommand
|
||||
{
|
||||
protected $name = 'flux-cms:make-component';
|
||||
protected $description = 'Create a new Flux CMS component';
|
||||
protected $type = 'Flux CMS Component';
|
||||
|
||||
protected function getStub()
|
||||
{
|
||||
return __DIR__ . '/stubs/component.stub';
|
||||
}
|
||||
|
||||
protected function getDefaultNamespace($rootNamespace)
|
||||
{
|
||||
return $rootNamespace . '\Livewire\Web\Components';
|
||||
}
|
||||
|
||||
protected function buildClass($name)
|
||||
{
|
||||
$stub = parent::buildClass($name);
|
||||
return str_replace('{{ componentName }}', $this->argument('name'), $stub);
|
||||
}
|
||||
|
||||
protected function getArguments()
|
||||
{
|
||||
return [
|
||||
['name', InputArgument::REQUIRED, 'The name of the component'],
|
||||
];
|
||||
}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
parent::handle();
|
||||
$this->createView();
|
||||
}
|
||||
|
||||
protected function createView()
|
||||
{
|
||||
$name = $this->argument('name');
|
||||
$viewPath = resource_path('views/livewire/web/components/' . strtolower($name) . '.blade.php');
|
||||
|
||||
if (! is_dir(dirname($viewPath))) {
|
||||
mkdir(dirname($viewPath), 0777, true);
|
||||
}
|
||||
|
||||
if (file_exists($viewPath)) {
|
||||
$this->error('View already exists!');
|
||||
return;
|
||||
}
|
||||
|
||||
$stub = $this->files->get(__DIR__ . '/stubs/view.stub');
|
||||
$this->files->put($viewPath, $stub);
|
||||
$this->info('View created successfully.');
|
||||
}
|
||||
}
|
||||
77
packages/flux-cms/core/src/Commands/PublishCommand.php
Normal file
77
packages/flux-cms/core/src/Commands/PublishCommand.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class PublishCommand extends Command
|
||||
{
|
||||
protected $signature = 'flux-cms:publish
|
||||
{--force : Overwrite existing files}
|
||||
{--tag= : Specific tag to publish}';
|
||||
|
||||
protected $description = 'Publish Flux CMS assets and configuration';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('📦 Publishing Flux CMS assets...');
|
||||
|
||||
$tag = $this->option('tag');
|
||||
$force = $this->option('force');
|
||||
|
||||
if ($tag) {
|
||||
$this->publishTag($tag, $force);
|
||||
} else {
|
||||
$this->publishAll($force);
|
||||
}
|
||||
|
||||
$this->info('✅ Flux CMS assets published successfully!');
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function publishAll(bool $force): void
|
||||
{
|
||||
$this->info('Publishing all Flux CMS assets...');
|
||||
|
||||
// Publish config
|
||||
$this->call('vendor:publish', [
|
||||
'--tag' => 'flux-cms-config',
|
||||
'--force' => $force,
|
||||
]);
|
||||
|
||||
// Publish migrations
|
||||
$this->call('vendor:publish', [
|
||||
'--tag' => 'flux-cms-migrations',
|
||||
'--force' => $force,
|
||||
]);
|
||||
|
||||
// Publish views
|
||||
$this->call('vendor:publish', [
|
||||
'--tag' => 'flux-cms-views',
|
||||
'--force' => $force,
|
||||
]);
|
||||
|
||||
// Publish assets
|
||||
$this->call('vendor:publish', [
|
||||
'--tag' => 'flux-cms-assets',
|
||||
'--force' => $force,
|
||||
]);
|
||||
|
||||
// Publish translations
|
||||
$this->call('vendor:publish', [
|
||||
'--tag' => 'flux-cms-translations',
|
||||
'--force' => $force,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function publishTag(string $tag, bool $force): void
|
||||
{
|
||||
$this->info("Publishing tag: {$tag}");
|
||||
|
||||
$this->call('vendor:publish', [
|
||||
'--tag' => $tag,
|
||||
'--force' => $force,
|
||||
]);
|
||||
}
|
||||
}
|
||||
33
packages/flux-cms/core/src/Commands/stubs/component.stub
Normal file
33
packages/flux-cms/core/src/Commands/stubs/component.stub
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace {{ namespace }};
|
||||
|
||||
use Livewire\Component;
|
||||
use FluxCms\Core\FieldTypes\TextField;
|
||||
|
||||
class {{ class }} extends Component
|
||||
{
|
||||
public array $content = [];
|
||||
|
||||
public function mount(array $content = [])
|
||||
{
|
||||
$this->content = $content;
|
||||
}
|
||||
|
||||
public static function getCmsName(): string
|
||||
{
|
||||
return '{{ componentName }}';
|
||||
}
|
||||
|
||||
public static function getCmsFields(): array
|
||||
{
|
||||
return [
|
||||
TextField::make('headline', 'Headline')->translatable(),
|
||||
];
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.{{ componentName }}');
|
||||
}
|
||||
}
|
||||
4
packages/flux-cms/core/src/Commands/stubs/view.stub
Normal file
4
packages/flux-cms/core/src/Commands/stubs/view.stub
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<div>
|
||||
{{-- Remember that you should never use @section('content') in a component. --}}
|
||||
<h1>{{ $content['headline'] ?? 'Headline' }}</h1>
|
||||
</div>
|
||||
327
packages/flux-cms/core/src/FieldTypes/BaseField.php
Normal file
327
packages/flux-cms/core/src/FieldTypes/BaseField.php
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\FieldTypes;
|
||||
|
||||
abstract class BaseField
|
||||
{
|
||||
protected string $key;
|
||||
protected string $label;
|
||||
protected bool $translatable = false;
|
||||
protected bool $required = false;
|
||||
protected mixed $default = null;
|
||||
protected array $rules = [];
|
||||
protected array $attributes = [];
|
||||
protected ?string $helpText = null;
|
||||
protected ?string $placeholder = null;
|
||||
|
||||
public function __construct(string $key, string $label)
|
||||
{
|
||||
$this->key = $key;
|
||||
$this->label = $label;
|
||||
}
|
||||
|
||||
public static function make(string $key, string $label): static
|
||||
{
|
||||
return new static($key, $label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration Methods
|
||||
*/
|
||||
public function translatable(bool $translatable = true): static
|
||||
{
|
||||
$this->translatable = $translatable;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function required(bool $required = true): static
|
||||
{
|
||||
$this->required = $required;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function default(mixed $default): static
|
||||
{
|
||||
$this->default = $default;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function rules(array|string $rules): static
|
||||
{
|
||||
$this->rules = is_array($rules) ? $rules : [$rules];
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function helpText(string $helpText): static
|
||||
{
|
||||
$this->helpText = $helpText;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function placeholder(string $placeholder): static
|
||||
{
|
||||
$this->placeholder = $placeholder;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function attributes(array $attributes): static
|
||||
{
|
||||
$this->attributes = array_merge($this->attributes, $attributes);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function attribute(string $key, mixed $value): static
|
||||
{
|
||||
$this->attributes[$key] = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Getters
|
||||
*/
|
||||
public function getKey(): string
|
||||
{
|
||||
return $this->key;
|
||||
}
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function isTranslatable(): bool
|
||||
{
|
||||
return $this->translatable;
|
||||
}
|
||||
|
||||
public function isRequired(): bool
|
||||
{
|
||||
return $this->required;
|
||||
}
|
||||
|
||||
public function getDefault(): mixed
|
||||
{
|
||||
return $this->default;
|
||||
}
|
||||
|
||||
public function getRules(): array
|
||||
{
|
||||
$rules = $this->rules;
|
||||
|
||||
if ($this->required) {
|
||||
$rules[] = 'required';
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
public function getHelpText(): ?string
|
||||
{
|
||||
return $this->helpText;
|
||||
}
|
||||
|
||||
public function getPlaceholder(): ?string
|
||||
{
|
||||
return $this->placeholder;
|
||||
}
|
||||
|
||||
public function getAttributes(): array
|
||||
{
|
||||
return $this->attributes;
|
||||
}
|
||||
|
||||
public function getAttribute(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->attributes[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract Methods
|
||||
*/
|
||||
abstract public function getType(): string;
|
||||
abstract public function getValidationRules(): array;
|
||||
abstract public function toArray(): array;
|
||||
|
||||
/**
|
||||
* Value Handling
|
||||
*/
|
||||
public function getValue(array $content, string $locale = null): mixed
|
||||
{
|
||||
if ($this->translatable && $locale) {
|
||||
return $content[$this->key][$locale] ?? $this->default;
|
||||
}
|
||||
|
||||
return $content[$this->key] ?? $this->default;
|
||||
}
|
||||
|
||||
public function setValue(array &$content, mixed $value, string $locale = null): void
|
||||
{
|
||||
if ($this->translatable && $locale) {
|
||||
$content[$this->key][$locale] = $value;
|
||||
} else {
|
||||
$content[$this->key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation
|
||||
*/
|
||||
public function validate(mixed $value, string $locale = null): array
|
||||
{
|
||||
$rules = $this->getValidationRules();
|
||||
|
||||
if (empty($rules)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Für übersetzbare Felder Locale zu Regeln hinzufügen
|
||||
$fieldKey = $this->key;
|
||||
if ($locale && $this->translatable) {
|
||||
$fieldKey .= '.' . $locale;
|
||||
}
|
||||
|
||||
try {
|
||||
$validator = validator(
|
||||
[$fieldKey => $value],
|
||||
[$fieldKey => $rules],
|
||||
$this->getValidationMessages(),
|
||||
$this->getValidationAttributes()
|
||||
);
|
||||
|
||||
return $validator->fails() ? $validator->errors()->get($fieldKey) : [];
|
||||
} catch (\Exception $e) {
|
||||
return ['Validation error: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation Messages
|
||||
*/
|
||||
protected function getValidationMessages(): array
|
||||
{
|
||||
return [
|
||||
'required' => 'The :attribute field is required.',
|
||||
'string' => 'The :attribute field must be a string.',
|
||||
'integer' => 'The :attribute field must be an integer.',
|
||||
'numeric' => 'The :attribute field must be a number.',
|
||||
'boolean' => 'The :attribute field must be true or false.',
|
||||
'array' => 'The :attribute field must be an array.',
|
||||
'email' => 'The :attribute field must be a valid email address.',
|
||||
'url' => 'The :attribute field must be a valid URL.',
|
||||
'max' => 'The :attribute field must not be greater than :max.',
|
||||
'min' => 'The :attribute field must be at least :min.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation Attributes
|
||||
*/
|
||||
protected function getValidationAttributes(): array
|
||||
{
|
||||
return [
|
||||
$this->key => $this->label,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendering
|
||||
*/
|
||||
public function render(array $content = [], string $locale = null): string
|
||||
{
|
||||
$value = $this->getValue($content, $locale);
|
||||
|
||||
$viewData = [
|
||||
'field' => $this,
|
||||
'value' => $value,
|
||||
'locale' => $locale,
|
||||
'content' => $content,
|
||||
'wireModel' => $this->getWireModel($locale),
|
||||
'fieldId' => $this->getFieldId($locale),
|
||||
'hasError' => false, // Will be set by parent component
|
||||
];
|
||||
|
||||
$viewName = "flux-cms::fields.{$this->getType()}";
|
||||
|
||||
if (!view()->exists($viewName)) {
|
||||
$viewName = "flux-cms::fields.fallback";
|
||||
}
|
||||
|
||||
return view($viewName, $viewData)->render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire Model für Livewire
|
||||
*/
|
||||
public function getWireModel(string $locale = null): string
|
||||
{
|
||||
if ($this->translatable && $locale) {
|
||||
return "content.{$this->key}.{$locale}";
|
||||
}
|
||||
|
||||
return "content.{$this->key}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Field ID für Labels
|
||||
*/
|
||||
public function getFieldId(string $locale = null): string
|
||||
{
|
||||
$id = 'field_' . $this->key;
|
||||
|
||||
if ($this->translatable && $locale) {
|
||||
$id .= '_' . $locale;
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS Classes
|
||||
*/
|
||||
public function getCssClasses(bool $hasError = false): string
|
||||
{
|
||||
$classes = ['flux-cms-field', 'flux-cms-field--' . $this->getType()];
|
||||
|
||||
if ($this->required) {
|
||||
$classes[] = 'flux-cms-field--required';
|
||||
}
|
||||
|
||||
if ($hasError) {
|
||||
$classes[] = 'flux-cms-field--error';
|
||||
}
|
||||
|
||||
if ($this->translatable) {
|
||||
$classes[] = 'flux-cms-field--translatable';
|
||||
}
|
||||
|
||||
return implode(' ', $classes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize Input
|
||||
*/
|
||||
public function sanitizeValue(mixed $value): mixed
|
||||
{
|
||||
if (is_string($value)) {
|
||||
return trim($value);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform für Frontend-Ausgabe
|
||||
*/
|
||||
public function transformForDisplay(mixed $value): mixed
|
||||
{
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform für Backend-Bearbeitung
|
||||
*/
|
||||
public function transformForEdit(mixed $value): mixed
|
||||
{
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
115
packages/flux-cms/core/src/FieldTypes/BooleanField.php
Normal file
115
packages/flux-cms/core/src/FieldTypes/BooleanField.php
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\FieldTypes;
|
||||
|
||||
class BooleanField extends BaseField
|
||||
{
|
||||
protected string $trueLabel = 'Yes';
|
||||
protected string $falseLabel = 'No';
|
||||
protected string $displayType = 'checkbox'; // checkbox, toggle, radio
|
||||
|
||||
public function labels(string $trueLabel, string $falseLabel): static
|
||||
{
|
||||
$this->trueLabel = $trueLabel;
|
||||
$this->falseLabel = $falseLabel;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function displayType(string $type): static
|
||||
{
|
||||
$this->displayType = $type;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function toggle(): static
|
||||
{
|
||||
$this->displayType = 'toggle';
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function radio(): static
|
||||
{
|
||||
$this->displayType = 'radio';
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function checkbox(): static
|
||||
{
|
||||
$this->displayType = 'checkbox';
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return 'boolean';
|
||||
}
|
||||
|
||||
public function getTrueLabel(): string
|
||||
{
|
||||
return $this->trueLabel;
|
||||
}
|
||||
|
||||
public function getFalseLabel(): string
|
||||
{
|
||||
return $this->falseLabel;
|
||||
}
|
||||
|
||||
public function getDisplayType(): string
|
||||
{
|
||||
return $this->displayType;
|
||||
}
|
||||
|
||||
public function getValidationRules(): array
|
||||
{
|
||||
$rules = ['boolean'];
|
||||
|
||||
if ($this->required) {
|
||||
$rules[] = 'required';
|
||||
}
|
||||
|
||||
return array_merge($rules, $this->rules);
|
||||
}
|
||||
|
||||
public function sanitizeValue(mixed $value): mixed
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Convert various truthy values to boolean
|
||||
if (is_string($value)) {
|
||||
$value = strtolower($value);
|
||||
return in_array($value, ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
return (bool) $value;
|
||||
}
|
||||
|
||||
public function getValue(array $content, string $locale = null): mixed
|
||||
{
|
||||
$value = parent::getValue($content, $locale);
|
||||
return $this->sanitizeValue($value);
|
||||
}
|
||||
|
||||
public function transformForDisplay(mixed $value): mixed
|
||||
{
|
||||
return $this->sanitizeValue($value) ? $this->trueLabel : $this->falseLabel;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->getType(),
|
||||
'key' => $this->key,
|
||||
'label' => $this->label,
|
||||
'translatable' => $this->translatable,
|
||||
'required' => $this->required,
|
||||
'default' => $this->default,
|
||||
'trueLabel' => $this->trueLabel,
|
||||
'falseLabel' => $this->falseLabel,
|
||||
'displayType' => $this->displayType,
|
||||
'helpText' => $this->helpText,
|
||||
'attributes' => $this->attributes,
|
||||
];
|
||||
}
|
||||
}
|
||||
233
packages/flux-cms/core/src/FieldTypes/MediaField.php
Normal file
233
packages/flux-cms/core/src/FieldTypes/MediaField.php
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\FieldTypes;
|
||||
|
||||
class MediaField extends BaseField
|
||||
{
|
||||
protected array $acceptedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
protected bool $multiple = false;
|
||||
protected int $maxFiles = 1;
|
||||
protected ?string $collection = null;
|
||||
protected array $conversions = [];
|
||||
protected int $maxFileSize = 10240; // 10MB in KB
|
||||
protected bool $showPreview = true;
|
||||
|
||||
public function acceptedMimeTypes(array $mimeTypes): static
|
||||
{
|
||||
$this->acceptedMimeTypes = $mimeTypes;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function multiple(bool $multiple = true, int $maxFiles = 10): static
|
||||
{
|
||||
$this->multiple = $multiple;
|
||||
$this->maxFiles = $maxFiles;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function collection(string $collection): static
|
||||
{
|
||||
$this->collection = $collection;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function conversions(array $conversions): static
|
||||
{
|
||||
$this->conversions = $conversions;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function maxFileSize(int $sizeInKb): static
|
||||
{
|
||||
$this->maxFileSize = $sizeInKb;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function showPreview(bool $show = true): static
|
||||
{
|
||||
$this->showPreview = $show;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function images(): static
|
||||
{
|
||||
$this->acceptedMimeTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml'];
|
||||
$this->collection = 'images';
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function documents(): static
|
||||
{
|
||||
$this->acceptedMimeTypes = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/plain',
|
||||
'text/csv'
|
||||
];
|
||||
$this->collection = 'documents';
|
||||
$this->showPreview = false;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function videos(): static
|
||||
{
|
||||
$this->acceptedMimeTypes = ['video/mp4', 'video/webm', 'video/ogg'];
|
||||
$this->collection = 'videos';
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function audio(): static
|
||||
{
|
||||
$this->acceptedMimeTypes = ['audio/mpeg', 'audio/wav', 'audio/ogg'];
|
||||
$this->collection = 'audio';
|
||||
$this->showPreview = false;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return 'media';
|
||||
}
|
||||
|
||||
public function getAcceptedMimeTypes(): array
|
||||
{
|
||||
return $this->acceptedMimeTypes;
|
||||
}
|
||||
|
||||
public function isMultiple(): bool
|
||||
{
|
||||
return $this->multiple;
|
||||
}
|
||||
|
||||
public function getMaxFiles(): int
|
||||
{
|
||||
return $this->maxFiles;
|
||||
}
|
||||
|
||||
public function getCollection(): ?string
|
||||
{
|
||||
return $this->collection;
|
||||
}
|
||||
|
||||
public function getConversions(): array
|
||||
{
|
||||
return $this->conversions;
|
||||
}
|
||||
|
||||
public function getMaxFileSize(): int
|
||||
{
|
||||
return $this->maxFileSize;
|
||||
}
|
||||
|
||||
public function shouldShowPreview(): bool
|
||||
{
|
||||
return $this->showPreview;
|
||||
}
|
||||
|
||||
public function getValidationRules(): array
|
||||
{
|
||||
$rules = [];
|
||||
|
||||
if ($this->required) {
|
||||
$rules[] = 'required';
|
||||
}
|
||||
|
||||
if ($this->multiple) {
|
||||
$rules[] = 'array';
|
||||
$rules[] = "max:{$this->maxFiles}";
|
||||
// Each item should be integer (media ID)
|
||||
$rules[] = 'array';
|
||||
foreach (range(0, $this->maxFiles - 1) as $index) {
|
||||
$rules["*.{$index}"] = 'integer|exists:media,id';
|
||||
}
|
||||
} else {
|
||||
$rules[] = 'integer';
|
||||
$rules[] = 'exists:media,id';
|
||||
}
|
||||
|
||||
return array_merge($rules, $this->rules);
|
||||
}
|
||||
|
||||
public function getAcceptAttribute(): string
|
||||
{
|
||||
return implode(',', $this->acceptedMimeTypes);
|
||||
}
|
||||
|
||||
public function isImageType(): bool
|
||||
{
|
||||
return !empty(array_filter($this->acceptedMimeTypes, fn($type) => str_starts_with($type, 'image/')));
|
||||
}
|
||||
|
||||
public function isVideoType(): bool
|
||||
{
|
||||
return !empty(array_filter($this->acceptedMimeTypes, fn($type) => str_starts_with($type, 'video/')));
|
||||
}
|
||||
|
||||
public function isAudioType(): bool
|
||||
{
|
||||
return !empty(array_filter($this->acceptedMimeTypes, fn($type) => str_starts_with($type, 'audio/')));
|
||||
}
|
||||
|
||||
public function isDocumentType(): bool
|
||||
{
|
||||
return !empty(array_filter($this->acceptedMimeTypes, fn($type) =>
|
||||
str_starts_with($type, 'application/') || str_starts_with($type, 'text/')
|
||||
));
|
||||
}
|
||||
|
||||
public function getValue(array $content, string $locale = null): mixed
|
||||
{
|
||||
$value = parent::getValue($content, $locale);
|
||||
|
||||
// Ensure array for multiple fields
|
||||
if ($this->multiple && !is_array($value)) {
|
||||
return $value ? [$value] : [];
|
||||
}
|
||||
|
||||
// Ensure integer for single fields
|
||||
if (!$this->multiple && is_array($value)) {
|
||||
return !empty($value) ? (int) $value[0] : null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function getMediaItems(mixed $value): \Illuminate\Support\Collection
|
||||
{
|
||||
if (empty($value)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$mediaIds = is_array($value) ? $value : [$value];
|
||||
$mediaIds = array_filter($mediaIds);
|
||||
|
||||
if (empty($mediaIds)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return \Spatie\MediaLibrary\MediaCollections\Models\Media::whereIn('id', $mediaIds)->get();
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->getType(),
|
||||
'key' => $this->key,
|
||||
'label' => $this->label,
|
||||
'translatable' => $this->translatable,
|
||||
'required' => $this->required,
|
||||
'default' => $this->default,
|
||||
'acceptedMimeTypes' => $this->acceptedMimeTypes,
|
||||
'multiple' => $this->multiple,
|
||||
'maxFiles' => $this->maxFiles,
|
||||
'collection' => $this->collection,
|
||||
'conversions' => $this->conversions,
|
||||
'maxFileSize' => $this->maxFileSize,
|
||||
'showPreview' => $this->showPreview,
|
||||
'helpText' => $this->helpText,
|
||||
'attributes' => $this->attributes,
|
||||
];
|
||||
}
|
||||
}
|
||||
144
packages/flux-cms/core/src/FieldTypes/NumberField.php
Normal file
144
packages/flux-cms/core/src/FieldTypes/NumberField.php
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\FieldTypes;
|
||||
|
||||
class NumberField extends BaseField
|
||||
{
|
||||
protected ?float $min = null;
|
||||
protected ?float $max = null;
|
||||
protected float $step = 1;
|
||||
protected bool $decimal = false;
|
||||
|
||||
public function min(float $min): static
|
||||
{
|
||||
$this->min = $min;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function max(float $max): static
|
||||
{
|
||||
$this->max = $max;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function step(float $step): static
|
||||
{
|
||||
$this->step = $step;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function decimal(bool $decimal = true): static
|
||||
{
|
||||
$this->decimal = $decimal;
|
||||
if ($decimal && $this->step === 1) {
|
||||
$this->step = 0.01;
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function currency(): static
|
||||
{
|
||||
$this->decimal(true);
|
||||
$this->step(0.01);
|
||||
$this->min(0);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function percentage(): static
|
||||
{
|
||||
$this->decimal(true);
|
||||
$this->min(0);
|
||||
$this->max(100);
|
||||
$this->step(0.1);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return 'number';
|
||||
}
|
||||
|
||||
public function getMin(): ?float
|
||||
{
|
||||
return $this->min;
|
||||
}
|
||||
|
||||
public function getMax(): ?float
|
||||
{
|
||||
return $this->max;
|
||||
}
|
||||
|
||||
public function getStep(): float
|
||||
{
|
||||
return $this->step;
|
||||
}
|
||||
|
||||
public function isDecimal(): bool
|
||||
{
|
||||
return $this->decimal;
|
||||
}
|
||||
|
||||
public function getValidationRules(): array
|
||||
{
|
||||
$rules = [$this->decimal ? 'numeric' : 'integer'];
|
||||
|
||||
if ($this->required) {
|
||||
$rules[] = 'required';
|
||||
}
|
||||
|
||||
if ($this->min !== null) {
|
||||
$rules[] = "min:{$this->min}";
|
||||
}
|
||||
|
||||
if ($this->max !== null) {
|
||||
$rules[] = "max:{$this->max}";
|
||||
}
|
||||
|
||||
return array_merge($rules, $this->rules);
|
||||
}
|
||||
|
||||
public function sanitizeValue(mixed $value): mixed
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->decimal) {
|
||||
return (float) $value;
|
||||
}
|
||||
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
public function transformForDisplay(mixed $value): mixed
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($this->decimal) {
|
||||
return number_format((float) $value, 2);
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->getType(),
|
||||
'key' => $this->key,
|
||||
'label' => $this->label,
|
||||
'translatable' => $this->translatable,
|
||||
'required' => $this->required,
|
||||
'default' => $this->default,
|
||||
'min' => $this->min,
|
||||
'max' => $this->max,
|
||||
'step' => $this->step,
|
||||
'decimal' => $this->decimal,
|
||||
'helpText' => $this->helpText,
|
||||
'placeholder' => $this->placeholder,
|
||||
'attributes' => $this->attributes,
|
||||
];
|
||||
}
|
||||
}
|
||||
125
packages/flux-cms/core/src/FieldTypes/SelectField.php
Normal file
125
packages/flux-cms/core/src/FieldTypes/SelectField.php
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\FieldTypes;
|
||||
|
||||
class SelectField extends BaseField
|
||||
{
|
||||
protected array $options = [];
|
||||
protected bool $multiple = false;
|
||||
protected bool $searchable = false;
|
||||
protected ?string $emptyOption = null;
|
||||
|
||||
public function options(array $options): static
|
||||
{
|
||||
$this->options = $options;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function multiple(bool $multiple = true): static
|
||||
{
|
||||
$this->multiple = $multiple;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function searchable(bool $searchable = true): static
|
||||
{
|
||||
$this->searchable = $searchable;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function emptyOption(string $text): static
|
||||
{
|
||||
$this->emptyOption = $text;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return 'select';
|
||||
}
|
||||
|
||||
public function getOptions(): array
|
||||
{
|
||||
return $this->options;
|
||||
}
|
||||
|
||||
public function isMultiple(): bool
|
||||
{
|
||||
return $this->multiple;
|
||||
}
|
||||
|
||||
public function isSearchable(): bool
|
||||
{
|
||||
return $this->searchable;
|
||||
}
|
||||
|
||||
public function getEmptyOption(): ?string
|
||||
{
|
||||
return $this->emptyOption;
|
||||
}
|
||||
|
||||
public function getValidationRules(): array
|
||||
{
|
||||
$rules = [];
|
||||
|
||||
if ($this->required) {
|
||||
$rules[] = 'required';
|
||||
}
|
||||
|
||||
if ($this->multiple) {
|
||||
$rules[] = 'array';
|
||||
} else {
|
||||
$rules[] = 'string';
|
||||
}
|
||||
|
||||
if (!empty($this->options)) {
|
||||
$validValues = array_keys($this->options);
|
||||
if ($this->multiple) {
|
||||
$rules[] = 'array';
|
||||
// Each value must be in the valid options
|
||||
foreach ($validValues as $value) {
|
||||
$rules[] = "array";
|
||||
}
|
||||
} else {
|
||||
$rules[] = "in:" . implode(',', $validValues);
|
||||
}
|
||||
}
|
||||
|
||||
return array_merge($rules, $this->rules);
|
||||
}
|
||||
|
||||
public function getValue(array $content, string $locale = null): mixed
|
||||
{
|
||||
$value = parent::getValue($content, $locale);
|
||||
|
||||
// Ensure array for multiple selects
|
||||
if ($this->multiple && !is_array($value)) {
|
||||
return $value ? [$value] : [];
|
||||
}
|
||||
|
||||
// Ensure string for single selects
|
||||
if (!$this->multiple && is_array($value)) {
|
||||
return !empty($value) ? (string) $value[0] : '';
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->getType(),
|
||||
'key' => $this->key,
|
||||
'label' => $this->label,
|
||||
'translatable' => $this->translatable,
|
||||
'required' => $this->required,
|
||||
'default' => $this->default,
|
||||
'options' => $this->options,
|
||||
'multiple' => $this->multiple,
|
||||
'searchable' => $this->searchable,
|
||||
'emptyOption' => $this->emptyOption,
|
||||
'helpText' => $this->helpText,
|
||||
'attributes' => $this->attributes,
|
||||
];
|
||||
}
|
||||
}
|
||||
166
packages/flux-cms/core/src/FieldTypes/TextField.php
Normal file
166
packages/flux-cms/core/src/FieldTypes/TextField.php
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\FieldTypes;
|
||||
|
||||
class TextField extends BaseField
|
||||
{
|
||||
protected int $maxLength = 255;
|
||||
protected int $minLength = 0;
|
||||
protected ?string $pattern = null;
|
||||
protected string $inputType = 'text';
|
||||
|
||||
public function maxLength(int $maxLength): static
|
||||
{
|
||||
$this->maxLength = $maxLength;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function minLength(int $minLength): static
|
||||
{
|
||||
$this->minLength = $minLength;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function pattern(string $pattern): static
|
||||
{
|
||||
$this->pattern = $pattern;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function email(): static
|
||||
{
|
||||
$this->inputType = 'email';
|
||||
$this->rules(['email']);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function url(): static
|
||||
{
|
||||
$this->inputType = 'url';
|
||||
$this->rules(['url']);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function password(): static
|
||||
{
|
||||
$this->inputType = 'password';
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function tel(): static
|
||||
{
|
||||
$this->inputType = 'tel';
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function search(): static
|
||||
{
|
||||
$this->inputType = 'search';
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return 'text';
|
||||
}
|
||||
|
||||
public function getInputType(): string
|
||||
{
|
||||
return $this->inputType;
|
||||
}
|
||||
|
||||
public function getMaxLength(): int
|
||||
{
|
||||
return $this->maxLength;
|
||||
}
|
||||
|
||||
public function getMinLength(): int
|
||||
{
|
||||
return $this->minLength;
|
||||
}
|
||||
|
||||
public function getPattern(): ?string
|
||||
{
|
||||
return $this->pattern;
|
||||
}
|
||||
|
||||
public function getValidationRules(): array
|
||||
{
|
||||
$rules = ['string'];
|
||||
|
||||
if ($this->required) {
|
||||
$rules[] = 'required';
|
||||
}
|
||||
|
||||
if ($this->maxLength > 0) {
|
||||
$rules[] = "max:{$this->maxLength}";
|
||||
}
|
||||
|
||||
if ($this->minLength > 0) {
|
||||
$rules[] = "min:{$this->minLength}";
|
||||
}
|
||||
|
||||
// Input type specific rules
|
||||
if ($this->inputType === 'email') {
|
||||
$rules[] = 'email';
|
||||
} elseif ($this->inputType === 'url') {
|
||||
$rules[] = 'url';
|
||||
}
|
||||
|
||||
// Pattern validation
|
||||
if ($this->pattern) {
|
||||
$rules[] = "regex:{$this->pattern}";
|
||||
}
|
||||
|
||||
return array_merge($rules, $this->rules);
|
||||
}
|
||||
|
||||
public function sanitizeValue(mixed $value): mixed
|
||||
{
|
||||
if (!is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
// Input type specific sanitization
|
||||
if ($this->inputType === 'email') {
|
||||
$value = strtolower($value);
|
||||
} elseif ($this->inputType === 'url') {
|
||||
// Ensure URL has protocol
|
||||
if ($value && !preg_match('/^https?:\/\//', $value)) {
|
||||
$value = 'https://' . $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->getType(),
|
||||
'key' => $this->key,
|
||||
'label' => $this->label,
|
||||
'translatable' => $this->translatable,
|
||||
'required' => $this->required,
|
||||
'default' => $this->default,
|
||||
'maxLength' => $this->maxLength,
|
||||
'minLength' => $this->minLength,
|
||||
'pattern' => $this->pattern,
|
||||
'inputType' => $this->inputType,
|
||||
'placeholder' => $this->placeholder,
|
||||
'helpText' => $this->helpText,
|
||||
'attributes' => $this->attributes,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getValidationMessages(): array
|
||||
{
|
||||
return array_merge(parent::getValidationMessages(), [
|
||||
'email' => 'The :attribute field must be a valid email address.',
|
||||
'url' => 'The :attribute field must be a valid URL.',
|
||||
'regex' => 'The :attribute field format is invalid.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
179
packages/flux-cms/core/src/FieldTypes/WysiwygField.php
Normal file
179
packages/flux-cms/core/src/FieldTypes/WysiwygField.php
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\FieldTypes;
|
||||
|
||||
class WysiwygField extends BaseField
|
||||
{
|
||||
protected array $toolbar = ['bold', 'italic', 'link', 'bulletList', 'orderedList'];
|
||||
protected int $minHeight = 200;
|
||||
protected bool $allowImages = true;
|
||||
protected bool $allowTables = false;
|
||||
protected bool $allowCode = true;
|
||||
protected string $editor = 'tiptap'; // tiptap, tinymce, quill
|
||||
|
||||
public function toolbar(array $toolbar): static
|
||||
{
|
||||
$this->toolbar = $toolbar;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function minHeight(int $minHeight): static
|
||||
{
|
||||
$this->minHeight = $minHeight;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function allowImages(bool $allowImages = true): static
|
||||
{
|
||||
$this->allowImages = $allowImages;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function allowTables(bool $allowTables = true): static
|
||||
{
|
||||
$this->allowTables = $allowTables;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function allowCode(bool $allowCode = true): static
|
||||
{
|
||||
$this->allowCode = $allowCode;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function editor(string $editor): static
|
||||
{
|
||||
$this->editor = $editor;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function basic(): static
|
||||
{
|
||||
$this->toolbar = ['bold', 'italic'];
|
||||
$this->allowImages = false;
|
||||
$this->allowTables = false;
|
||||
$this->allowCode = false;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function full(): static
|
||||
{
|
||||
$this->toolbar = [
|
||||
'bold', 'italic', 'underline', 'strike',
|
||||
'heading1', 'heading2', 'heading3',
|
||||
'bulletList', 'orderedList',
|
||||
'link', 'image', 'table',
|
||||
'code', 'codeBlock',
|
||||
'quote', 'rule'
|
||||
];
|
||||
$this->allowImages = true;
|
||||
$this->allowTables = true;
|
||||
$this->allowCode = true;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return 'wysiwyg';
|
||||
}
|
||||
|
||||
public function getToolbar(): array
|
||||
{
|
||||
return $this->toolbar;
|
||||
}
|
||||
|
||||
public function getMinHeight(): int
|
||||
{
|
||||
return $this->minHeight;
|
||||
}
|
||||
|
||||
public function getAllowImages(): bool
|
||||
{
|
||||
return $this->allowImages;
|
||||
}
|
||||
|
||||
public function getAllowTables(): bool
|
||||
{
|
||||
return $this->allowTables;
|
||||
}
|
||||
|
||||
public function getAllowCode(): bool
|
||||
{
|
||||
return $this->allowCode;
|
||||
}
|
||||
|
||||
public function getEditor(): string
|
||||
{
|
||||
return $this->editor;
|
||||
}
|
||||
|
||||
public function getValidationRules(): array
|
||||
{
|
||||
$rules = ['string'];
|
||||
|
||||
if ($this->required) {
|
||||
$rules[] = 'required';
|
||||
}
|
||||
|
||||
return array_merge($rules, $this->rules);
|
||||
}
|
||||
|
||||
public function sanitizeValue(mixed $value): mixed
|
||||
{
|
||||
if (!is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// Basic HTML sanitization
|
||||
$value = trim($value);
|
||||
|
||||
// Remove dangerous tags
|
||||
$dangerousTags = ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input'];
|
||||
foreach ($dangerousTags as $tag) {
|
||||
$value = preg_replace('/<' . $tag . '[^>]*>.*?<\/' . $tag . '>/is', '', $value);
|
||||
$value = preg_replace('/<' . $tag . '[^>]*\/?>/is', '', $value);
|
||||
}
|
||||
|
||||
// Remove javascript: links
|
||||
$value = preg_replace('/javascript:/i', '', $value);
|
||||
|
||||
// Remove on* attributes
|
||||
$value = preg_replace('/\s*on\w+\s*=\s*["\'][^"\']*["\']/i', '', $value);
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function transformForDisplay(mixed $value): mixed
|
||||
{
|
||||
if (!is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// Convert relative URLs to absolute
|
||||
$value = preg_replace_callback('/src="(\/[^"]*)"/', function ($matches) {
|
||||
return 'src="' . url($matches[1]) . '"';
|
||||
}, $value);
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->getType(),
|
||||
'key' => $this->key,
|
||||
'label' => $this->label,
|
||||
'translatable' => $this->translatable,
|
||||
'required' => $this->required,
|
||||
'default' => $this->default,
|
||||
'toolbar' => $this->toolbar,
|
||||
'minHeight' => $this->minHeight,
|
||||
'allowImages' => $this->allowImages,
|
||||
'allowTables' => $this->allowTables,
|
||||
'allowCode' => $this->allowCode,
|
||||
'editor' => $this->editor,
|
||||
'helpText' => $this->helpText,
|
||||
'attributes' => $this->attributes,
|
||||
];
|
||||
}
|
||||
}
|
||||
222
packages/flux-cms/core/src/FluxCmsServiceProvider.php
Normal file
222
packages/flux-cms/core/src/FluxCmsServiceProvider.php
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use FluxCms\Core\Services\ComponentRegistry;
|
||||
use FluxCms\Core\Commands\InstallCommand;
|
||||
use FluxCms\Core\Commands\PublishCommand;
|
||||
use FluxCms\Core\Commands\ClearCacheCommand;
|
||||
use FluxCms\Core\Commands\MakeComponentCommand;
|
||||
|
||||
class FluxCmsServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register services
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
// Merge config
|
||||
$this->mergeConfigFrom(__DIR__ . '/../config/flux-cms.php', 'flux-cms');
|
||||
|
||||
// Register services
|
||||
$this->app->singleton(ComponentRegistry::class, function ($app) {
|
||||
return new ComponentRegistry();
|
||||
});
|
||||
|
||||
// Register aliases
|
||||
$this->app->alias(ComponentRegistry::class, 'flux-cms.registry');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap services
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->bootPublishing();
|
||||
$this->bootMigrations();
|
||||
$this->bootViews();
|
||||
$this->bootCommands();
|
||||
$this->bootRoutes();
|
||||
$this->bootMiddleware();
|
||||
$this->bootGates();
|
||||
$this->bootTranslations();
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot publishing
|
||||
*/
|
||||
protected function bootPublishing(): void
|
||||
{
|
||||
if ($this->app->runningInConsole()) {
|
||||
// Publish config
|
||||
$this->publishes([
|
||||
__DIR__ . '/../config/flux-cms.php' => config_path('flux-cms.php'),
|
||||
], 'flux-cms-config');
|
||||
|
||||
// Publish migrations
|
||||
$this->publishes([
|
||||
__DIR__ . '/../database/migrations' => database_path('migrations'),
|
||||
], 'flux-cms-migrations');
|
||||
|
||||
// Publish views
|
||||
$this->publishes([
|
||||
__DIR__ . '/../resources/views' => resource_path('views/vendor/flux-cms'),
|
||||
], 'flux-cms-views');
|
||||
|
||||
// Publish all
|
||||
$this->publishes([
|
||||
__DIR__ . '/../config/flux-cms.php' => config_path('flux-cms.php'),
|
||||
__DIR__ . '/../database/migrations' => database_path('migrations'),
|
||||
__DIR__ . '/../resources/views' => resource_path('views/vendor/flux-cms'),
|
||||
], 'flux-cms');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot migrations
|
||||
*/
|
||||
protected function bootMigrations(): void
|
||||
{
|
||||
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot views
|
||||
*/
|
||||
protected function bootViews(): void
|
||||
{
|
||||
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'flux-cms');
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot commands
|
||||
*/
|
||||
protected function bootCommands(): void
|
||||
{
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->commands([
|
||||
InstallCommand::class,
|
||||
PublishCommand::class,
|
||||
ClearCacheCommand::class,
|
||||
MakeComponentCommand::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot routes
|
||||
*/
|
||||
protected function bootRoutes(): void
|
||||
{
|
||||
if (config('flux-cms.routes.enabled', true)) {
|
||||
$this->loadRoutesFromDirectory(__DIR__ . '/../routes');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot middleware
|
||||
*/
|
||||
protected function bootMiddleware(): void
|
||||
{
|
||||
$router = $this->app['router'];
|
||||
|
||||
// Register middleware aliases
|
||||
$router->aliasMiddleware('flux-cms:cms-access', \FluxCms\Core\Http\Middleware\CmsAccess::class);
|
||||
$router->aliasMiddleware('flux-cms:domain-detection', \FluxCms\Core\Http\Middleware\DomainDetection::class);
|
||||
$router->aliasMiddleware('flux-cms:preview-mode', \FluxCms\Core\Http\Middleware\PreviewMode::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load routes from directory
|
||||
*/
|
||||
protected function loadRoutesFromDirectory(string $directory): void
|
||||
{
|
||||
if (!is_dir($directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = glob($directory . '/*.php');
|
||||
|
||||
foreach ($files as $file) {
|
||||
$this->loadRoutesFrom($file);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot authorization gates
|
||||
*/
|
||||
protected function bootGates(): void
|
||||
{
|
||||
Gate::define('flux-cms.view', function ($user) {
|
||||
return $this->userHasCmsPermission($user, 'view');
|
||||
});
|
||||
|
||||
Gate::define('flux-cms.edit', function ($user) {
|
||||
return $this->userHasCmsPermission($user, 'edit');
|
||||
});
|
||||
|
||||
Gate::define('flux-cms.publish', function ($user) {
|
||||
return $this->userHasCmsPermission($user, 'publish');
|
||||
});
|
||||
|
||||
Gate::define('flux-cms.delete', function ($user) {
|
||||
return $this->userHasCmsPermission($user, 'delete');
|
||||
});
|
||||
|
||||
Gate::define('flux-cms.admin', function ($user) {
|
||||
return $this->userHasCmsPermission($user, 'admin');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has CMS permission
|
||||
*/
|
||||
protected function userHasCmsPermission($user, string $permission): bool
|
||||
{
|
||||
// If no user, deny access
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for Spatie Permission package
|
||||
if (method_exists($user, 'can')) {
|
||||
return $user->can("flux-cms.{$permission}") || $user->hasRole('flux-cms') || $user->hasRole('admin');
|
||||
}
|
||||
|
||||
// Fallback: Check if user has admin role property
|
||||
if (isset($user->is_admin)) {
|
||||
return $user->is_admin;
|
||||
}
|
||||
|
||||
// Default: Allow access for authenticated users (can be overridden in config)
|
||||
return config('flux-cms.auth.default_access', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot translations
|
||||
*/
|
||||
protected function bootTranslations(): void
|
||||
{
|
||||
$this->loadTranslationsFrom(__DIR__ . '/../resources/lang', 'flux-cms');
|
||||
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->publishes([
|
||||
__DIR__ . '/../resources/lang' => resource_path('lang/vendor/flux-cms'),
|
||||
], 'flux-cms-translations');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the services provided by the provider
|
||||
*/
|
||||
public function provides(): array
|
||||
{
|
||||
return [
|
||||
ComponentRegistry::class,
|
||||
'flux-cms.registry',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Http\Controllers\Admin;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use FluxCms\Core\Models\BlogPost;
|
||||
|
||||
class BlogController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('can:flux-cms.view');
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = BlogPost::with(['author']);
|
||||
|
||||
if ($request->has('status')) {
|
||||
switch ($request->status) {
|
||||
case 'published':
|
||||
$query->published();
|
||||
break;
|
||||
case 'draft':
|
||||
$query->where('is_published', false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$posts = $query->orderBy('updated_at', 'desc')->paginate(20);
|
||||
|
||||
return view('flux-cms::admin.blog.index', compact('posts'));
|
||||
}
|
||||
|
||||
public function edit(BlogPost $blogPost)
|
||||
{
|
||||
$this->authorize('flux-cms.edit');
|
||||
return view('flux-cms::admin.blog.edit', ['post' => $blogPost]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Http\Controllers\Admin;
|
||||
|
||||
use Illuminate\Routing\Controller;
|
||||
use FluxCms\Core\Services\ComponentRegistry;
|
||||
|
||||
class ComponentController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('can:flux-cms.view');
|
||||
}
|
||||
|
||||
public function __invoke()
|
||||
{
|
||||
$registry = app(ComponentRegistry::class);
|
||||
$components = $registry->getComponentsByCategory();
|
||||
$stats = $registry->getComponentStats();
|
||||
|
||||
return view('flux-cms::admin.components.index', compact('components', 'stats'));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Http\Controllers\Admin;
|
||||
|
||||
use Illuminate\Routing\Controller;
|
||||
use FluxCms\Core\Models\Page;
|
||||
use FluxCms\Core\Models\BlogPost;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __invoke()
|
||||
{
|
||||
$stats = [
|
||||
'pages' => Page::count(),
|
||||
'published_pages' => Page::published()->count(),
|
||||
'draft_pages' => Page::where('is_published', false)->count(),
|
||||
'blog_posts' => BlogPost::count(),
|
||||
'published_posts' => BlogPost::published()->count(),
|
||||
];
|
||||
|
||||
$recentPages = Page::with(['components'])
|
||||
->orderBy('updated_at', 'desc')
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
return view('flux-cms::admin.dashboard', compact('stats', 'recentPages'));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Http\Controllers\Admin;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
|
||||
class MediaController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('can:flux-cms.view');
|
||||
}
|
||||
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
// This would integrate with Spatie Media Library
|
||||
return view('flux-cms::admin.media.index');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Http\Controllers\Admin;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
|
||||
class NavigationController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('can:flux-cms.view');
|
||||
}
|
||||
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
return view('flux-cms::admin.navigation.index');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Http\Controllers\Admin;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use FluxCms\Core\Models\Page;
|
||||
|
||||
class PageController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('can:flux-cms.view');
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Page::with(['components']);
|
||||
|
||||
if ($request->has('domain') && $request->domain) {
|
||||
$query->forDomain($request->domain);
|
||||
}
|
||||
|
||||
if ($request->has('status')) {
|
||||
switch ($request->status) {
|
||||
case 'published':
|
||||
$query->published();
|
||||
break;
|
||||
case 'draft':
|
||||
$query->where('is_published', false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$pages = $query->orderBy('updated_at', 'desc')->paginate(20);
|
||||
|
||||
return view('flux-cms::admin.pages.index', compact('pages'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$this->authorize('flux-cms.edit');
|
||||
$domains = $this->getAvailableDomains();
|
||||
$locales = config('flux-cms.locales');
|
||||
return view('flux-cms::admin.pages.create', compact('domains', 'locales'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$this->authorize('flux-cms.edit');
|
||||
|
||||
$validated = $request->validate([
|
||||
'domain_key' => 'required|string',
|
||||
'title' => 'required|array',
|
||||
'title.*' => 'required|string|max:255',
|
||||
'slugs' => 'required|array',
|
||||
'slugs.*' => 'required|string',
|
||||
]);
|
||||
|
||||
$page = Page::create([
|
||||
'domain_key' => $validated['domain_key'],
|
||||
'title' => $validated['title'],
|
||||
'is_published' => $request->boolean('is_published'),
|
||||
'published_at' => $request->boolean('is_published') ? now() : null,
|
||||
]);
|
||||
|
||||
foreach ($validated['slugs'] as $locale => $slug) {
|
||||
$page->slugs()->create(['locale' => $locale, 'slug' => $slug]);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.cms.pages.edit', $page)
|
||||
->with('success', 'Page created successfully!');
|
||||
}
|
||||
|
||||
public function edit(Page $page)
|
||||
{
|
||||
$this->authorize('flux-cms.edit');
|
||||
return view('flux-cms::admin.pages.edit', compact('page'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Page $page)
|
||||
{
|
||||
$this->authorize('flux-cms.edit');
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|array',
|
||||
'title.*' => 'required|string|max:255',
|
||||
'slugs' => 'required|array',
|
||||
'slugs.*' => 'required|string',
|
||||
]);
|
||||
|
||||
$page->update([
|
||||
'title' => $validated['title'],
|
||||
'is_published' => $request->boolean('is_published'),
|
||||
'published_at' => $request->boolean('is_published') && !$page->published_at ? now() : $page->published_at,
|
||||
]);
|
||||
|
||||
foreach ($validated['slugs'] as $locale => $slug) {
|
||||
$page->slugs()->updateOrCreate(
|
||||
['locale' => $locale],
|
||||
['slug' => $slug]
|
||||
);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.cms.pages.edit', $page)
|
||||
->with('success', 'Page updated successfully!');
|
||||
}
|
||||
|
||||
public function destroy(Page $page)
|
||||
{
|
||||
$this->authorize('flux-cms.delete');
|
||||
$page->delete();
|
||||
return redirect()->route('admin.cms.pages.index')->with('success', 'Page deleted successfully!');
|
||||
}
|
||||
|
||||
private function getAvailableDomains(): array
|
||||
{
|
||||
if (!config('flux-cms.domains.enabled')) {
|
||||
return ['default' => 'Default'];
|
||||
}
|
||||
|
||||
$domains = config('domains.domains', []);
|
||||
$result = [];
|
||||
|
||||
foreach ($domains as $key => $config) {
|
||||
$result[$key] = $config['name'] ?? $key;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
153
packages/flux-cms/core/src/Http/Controllers/PageController.php
Normal file
153
packages/flux-cms/core/src/Http/Controllers/PageController.php
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use FluxCms\Core\Models\Page;
|
||||
use FluxCms\Core\Models\BlogPost;
|
||||
|
||||
class PageController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a CMS page
|
||||
*/
|
||||
public function show(Request $request, string $slug = '/')
|
||||
{
|
||||
$domainKey = $this->getCurrentDomainKey($request);
|
||||
|
||||
$page = Page::forDomain($domainKey)
|
||||
->published()
|
||||
->bySlugWithFallback($slug)
|
||||
->with(['components'])
|
||||
->firstOrFail();
|
||||
|
||||
// Load active components
|
||||
$components = $page->components()->get();
|
||||
|
||||
return view('flux-cms::pages.show', compact('page', 'components'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Blog index page
|
||||
*/
|
||||
public function blogIndex(Request $request)
|
||||
{
|
||||
$domainKey = $this->getCurrentDomainKey($request);
|
||||
$locale = app()->getLocale();
|
||||
|
||||
$posts = BlogPost::forDomain($domainKey)
|
||||
->published()
|
||||
->orderBy('published_at', 'desc')
|
||||
->paginate(10);
|
||||
|
||||
return view('flux-cms::blog.index', compact('posts'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Blog post page
|
||||
*/
|
||||
public function blogPost(Request $request, string $slug)
|
||||
{
|
||||
$domainKey = $this->getCurrentDomainKey($request);
|
||||
|
||||
$post = BlogPost::forDomain($domainKey)
|
||||
->published()
|
||||
->bySlugWithFallback($slug)
|
||||
->firstOrFail();
|
||||
|
||||
// Get related posts
|
||||
$relatedPosts = BlogPost::forDomain($domainKey)
|
||||
->published()
|
||||
->where('id', '!=', $post->id)
|
||||
->where('category', $post->category)
|
||||
->limit(3)
|
||||
->get();
|
||||
|
||||
return view('flux-cms::blog.show', compact('post', 'relatedPosts'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate sitemap
|
||||
*/
|
||||
public function sitemap(Request $request)
|
||||
{
|
||||
$domainKey = $this->getCurrentDomainKey($request);
|
||||
$locale = app()->getLocale();
|
||||
|
||||
$pages = Page::forDomain($domainKey)
|
||||
->published()
|
||||
->get();
|
||||
|
||||
$blogPosts = BlogPost::forDomain($domainKey)
|
||||
->published()
|
||||
->get();
|
||||
|
||||
return response()->view('flux-cms::sitemap', compact('pages', 'blogPosts'))
|
||||
->header('Content-Type', 'application/xml');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate robots.txt
|
||||
*/
|
||||
public function robots(Request $request)
|
||||
{
|
||||
$domainKey = $this->getCurrentDomainKey($request);
|
||||
$baseUrl = $this->getBaseUrl($request);
|
||||
|
||||
return response()->view('flux-cms::robots', compact('baseUrl'))
|
||||
->header('Content-Type', 'text/plain');
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview page (for authenticated users)
|
||||
*/
|
||||
public function preview(Request $request, Page $page)
|
||||
{
|
||||
$this->middleware('auth');
|
||||
$this->middleware('can:flux-cms.view');
|
||||
|
||||
$components = $page->allComponents()->get();
|
||||
|
||||
return view('flux-cms::pages.preview', compact('page', 'components'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current domain key
|
||||
*/
|
||||
protected function getCurrentDomainKey(Request $request): string
|
||||
{
|
||||
if (!config('flux-cms.domains.enabled')) {
|
||||
return config('flux-cms.domains.default_domain', 'default');
|
||||
}
|
||||
|
||||
$host = $request->getHost();
|
||||
$domains = config('domains.domains', []);
|
||||
|
||||
foreach ($domains as $key => $config) {
|
||||
if (isset($config['url'])) {
|
||||
$domainHost = parse_url($config['url'], PHP_URL_HOST);
|
||||
if ($domainHost === $host) {
|
||||
return $key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config('flux-cms.domains.default_domain', 'default');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base URL for current domain
|
||||
*/
|
||||
protected function getBaseUrl(Request $request): string
|
||||
{
|
||||
$domainKey = $this->getCurrentDomainKey($request);
|
||||
$domains = config('domains.domains', []);
|
||||
|
||||
if (isset($domains[$domainKey]['url'])) {
|
||||
return $domains[$domainKey]['url'];
|
||||
}
|
||||
|
||||
return $request->getSchemeAndHttpHost();
|
||||
}
|
||||
}
|
||||
51
packages/flux-cms/core/src/Http/Middleware/CmsAccess.php
Normal file
51
packages/flux-cms/core/src/Http/Middleware/CmsAccess.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class CmsAccess
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, string $permission = 'view'): Response
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (!$user) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
// Check if user has CMS permission
|
||||
if (!$this->userHasCmsPermission($user, $permission)) {
|
||||
abort(403, 'Access denied. You do not have permission to access the CMS.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has CMS permission
|
||||
*/
|
||||
protected function userHasCmsPermission($user, string $permission): bool
|
||||
{
|
||||
// Check for Spatie Permission package
|
||||
if (method_exists($user, 'can')) {
|
||||
return $user->can("flux-cms.{$permission}") ||
|
||||
$user->hasRole('flux-cms') ||
|
||||
$user->hasRole('admin');
|
||||
}
|
||||
|
||||
// Fallback: Check if user has admin role property
|
||||
if (isset($user->is_admin)) {
|
||||
return $user->is_admin;
|
||||
}
|
||||
|
||||
// Default: Allow access for authenticated users (can be overridden in config)
|
||||
return config('flux-cms.auth.default_access', false);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class DomainDetection
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (!config('flux-cms.domains.enabled')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$domainKey = $this->detectDomainKey($request);
|
||||
|
||||
// Set domain key in request for later use
|
||||
$request->attributes->set('flux_cms_domain_key', $domainKey);
|
||||
|
||||
// Set locale based on domain if configured
|
||||
$this->setLocaleFromDomain($request, $domainKey);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect domain key from request
|
||||
*/
|
||||
protected function detectDomainKey(Request $request): string
|
||||
{
|
||||
$host = $request->getHost();
|
||||
$domains = config('domains.domains', []);
|
||||
|
||||
foreach ($domains as $key => $config) {
|
||||
if (isset($config['url'])) {
|
||||
$domainHost = parse_url($config['url'], PHP_URL_HOST);
|
||||
if ($domainHost === $host) {
|
||||
return $key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config('flux-cms.domains.default_domain', 'default');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set locale based on domain configuration
|
||||
*/
|
||||
protected function setLocaleFromDomain(Request $request, string $domainKey): void
|
||||
{
|
||||
$domains = config('domains.domains', []);
|
||||
|
||||
if (isset($domains[$domainKey]['locale'])) {
|
||||
$locale = $domains[$domainKey]['locale'];
|
||||
app()->setLocale($locale);
|
||||
}
|
||||
}
|
||||
}
|
||||
55
packages/flux-cms/core/src/Http/Middleware/PreviewMode.php
Normal file
55
packages/flux-cms/core/src/Http/Middleware/PreviewMode.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PreviewMode
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// Check if preview mode is enabled via query parameter
|
||||
if ($request->has('preview') && $request->boolean('preview')) {
|
||||
// Verify user has preview permission
|
||||
if (!$this->userCanPreview($request)) {
|
||||
abort(403, 'Preview access denied');
|
||||
}
|
||||
|
||||
// Set preview mode in request
|
||||
$request->attributes->set('flux_cms_preview_mode', true);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can preview content
|
||||
*/
|
||||
protected function userCanPreview(Request $request): bool
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for Spatie Permission package
|
||||
if (method_exists($user, 'can')) {
|
||||
return $user->can('flux-cms.view') ||
|
||||
$user->hasRole('flux-cms') ||
|
||||
$user->hasRole('admin');
|
||||
}
|
||||
|
||||
// Fallback: Check if user has admin role property
|
||||
if (isset($user->is_admin)) {
|
||||
return $user->is_admin;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
257
packages/flux-cms/core/src/Models/BlogPost.php
Normal file
257
packages/flux-cms/core/src/Models/BlogPost.php
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Spatie\Translatable\HasTranslations;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
use Spatie\Tags\HasTags;
|
||||
|
||||
class BlogPost extends Model implements HasMedia
|
||||
{
|
||||
use HasTranslations, InteractsWithMedia, HasTags;
|
||||
|
||||
public function getTable()
|
||||
{
|
||||
return config('flux-cms.database.table_prefix', 'flux_cms_') . 'blog_posts';
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'domain_key',
|
||||
'title',
|
||||
'excerpt',
|
||||
'content',
|
||||
'meta_title',
|
||||
'meta_description',
|
||||
'is_published',
|
||||
'is_featured',
|
||||
'published_at',
|
||||
'author_id',
|
||||
'category',
|
||||
'settings',
|
||||
];
|
||||
|
||||
protected $translatable = [
|
||||
'title',
|
||||
'excerpt',
|
||||
'content',
|
||||
'meta_title',
|
||||
'meta_description'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'title' => 'array',
|
||||
'excerpt' => 'array',
|
||||
'content' => 'array',
|
||||
'meta_title' => 'array',
|
||||
'meta_description' => 'array',
|
||||
'settings' => 'array',
|
||||
'is_published' => 'boolean',
|
||||
'is_featured' => 'boolean',
|
||||
'published_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function author()
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Slug relationship
|
||||
*/
|
||||
public function slugs()
|
||||
{
|
||||
return $this->morphMany(Slug::class, 'model');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scopes
|
||||
*/
|
||||
public function scopeForDomain($query, string $domainKey)
|
||||
{
|
||||
return $query->where('domain_key', $domainKey);
|
||||
}
|
||||
|
||||
public function scopePublished($query)
|
||||
{
|
||||
return $query->where('is_published', true)
|
||||
->where('published_at', '<=', now());
|
||||
}
|
||||
|
||||
public function scopeFeatured($query)
|
||||
{
|
||||
return $query->where('is_featured', true);
|
||||
}
|
||||
|
||||
public function scopeByCategory($query, string $category)
|
||||
{
|
||||
return $query->where('category', $category);
|
||||
}
|
||||
|
||||
public function scopeByTag($query, string $tag)
|
||||
{
|
||||
return $query->withAnyTags([$tag]);
|
||||
}
|
||||
|
||||
public function scopeRecent($query, int $limit = 10)
|
||||
{
|
||||
return $query->orderBy('published_at', 'desc')->limit($limit);
|
||||
}
|
||||
|
||||
public function scopeBySlug($query, string $slug, string $locale = null)
|
||||
{
|
||||
$locale = $locale ?? app()->getLocale();
|
||||
return $query->whereHas('slugs', function ($q) use ($slug, $locale) {
|
||||
$q->where('slug', $slug)->where('locale', $locale);
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeBySlugWithFallback($query, string $slug, string $locale = null)
|
||||
{
|
||||
$locale = $locale ?? app()->getLocale();
|
||||
$fallbackLocale = config('app.fallback_locale');
|
||||
|
||||
return $query->where(function ($q) use ($slug, $locale, $fallbackLocale) {
|
||||
$q->whereHas('slugs', function ($sq) use ($slug, $locale) {
|
||||
$sq->where('slug', $slug)->where('locale', $locale);
|
||||
})->orWhereHas('slugs', function ($sq) use ($slug, $fallbackLocale) {
|
||||
$sq->where('slug', $slug)->where('locale', $fallbackLocale);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL for this blog post
|
||||
*/
|
||||
public function getUrl(string $locale = null): string
|
||||
{
|
||||
$locale = $locale ?? app()->getLocale();
|
||||
$slug = $this->slugs()->where('locale', $locale)->first();
|
||||
|
||||
return route('blog.post', ['slug' => $slug->slug ?? '']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the reading time estimate in minutes
|
||||
*/
|
||||
public function getReadingTimeAttribute(): int
|
||||
{
|
||||
$content = strip_tags($this->getTranslation('content', app()->getLocale()));
|
||||
$wordCount = str_word_count($content);
|
||||
return max(1, ceil($wordCount / 200)); // Assuming 200 words per minute
|
||||
}
|
||||
|
||||
/**
|
||||
* Get excerpt with fallback to content
|
||||
*/
|
||||
public function getExcerptAttribute($value): string
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
$excerpt = $this->getTranslation('excerpt', $locale);
|
||||
|
||||
if (empty($excerpt)) {
|
||||
$content = strip_tags($this->getTranslation('content', $locale));
|
||||
$excerpt = str_limit($content, 160);
|
||||
}
|
||||
|
||||
return $excerpt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if post is published
|
||||
*/
|
||||
public function isPublished(): bool
|
||||
{
|
||||
return $this->is_published &&
|
||||
$this->published_at &&
|
||||
$this->published_at <= now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SEO title with fallback to title
|
||||
*/
|
||||
public function getSeoTitle(string $locale = null): string
|
||||
{
|
||||
$locale = $locale ?? app()->getLocale();
|
||||
|
||||
return $this->getTranslation('meta_title', $locale)
|
||||
?? $this->getTranslation('title', $locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SEO description with fallback to excerpt
|
||||
*/
|
||||
public function getSeoDescription(string $locale = null): string
|
||||
{
|
||||
$locale = $locale ?? app()->getLocale();
|
||||
|
||||
return $this->getTranslation('meta_description', $locale)
|
||||
?? $this->getTranslation('excerpt', $locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Media collections
|
||||
*/
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('featured_image')
|
||||
->singleFile()
|
||||
->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp']);
|
||||
|
||||
$this->addMediaCollection('gallery')
|
||||
->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp']);
|
||||
}
|
||||
|
||||
public function registerMediaConversions(Media $media = null): void
|
||||
{
|
||||
$this->addMediaConversion('thumb')
|
||||
->width(300)
|
||||
->height(200)
|
||||
->sharpen(10);
|
||||
|
||||
$this->addMediaConversion('hero')
|
||||
->width(1200)
|
||||
->height(600)
|
||||
->optimize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get featured image
|
||||
*/
|
||||
public function getFeaturedImage(): ?Media
|
||||
{
|
||||
return $this->getFirstMedia('featured_image');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get featured image URL
|
||||
*/
|
||||
public function getFeaturedImageUrl(string $conversion = ''): ?string
|
||||
{
|
||||
$media = $this->getFeaturedImage();
|
||||
|
||||
if (!$media) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $conversion ? $media->getUrl($conversion) : $media->getUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings management
|
||||
*/
|
||||
public function getSetting(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return data_get($this->settings, $key, $default);
|
||||
}
|
||||
|
||||
public function setSetting(string $key, mixed $value): void
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
data_set($settings, $key, $value);
|
||||
$this->update(['settings' => $settings]);
|
||||
}
|
||||
}
|
||||
92
packages/flux-cms/core/src/Models/Navigation.php
Normal file
92
packages/flux-cms/core/src/Models/Navigation.php
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Spatie\Translatable\HasTranslations;
|
||||
|
||||
class Navigation extends Model
|
||||
{
|
||||
use HasTranslations;
|
||||
|
||||
public function getTable()
|
||||
{
|
||||
return config('flux-cms.database.table_prefix', 'flux_cms_') . 'navigations';
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'domain_key',
|
||||
'name',
|
||||
'display_name',
|
||||
'settings',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $translatable = [
|
||||
'display_name'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'display_name' => 'array',
|
||||
'settings' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(NavigationItem::class, 'navigation_id')
|
||||
->where('is_active', true)
|
||||
->whereNull('parent_id')
|
||||
->orderBy('order');
|
||||
}
|
||||
|
||||
public function allItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(NavigationItem::class, 'navigation_id')
|
||||
->orderBy('order');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scopes
|
||||
*/
|
||||
public function scopeForDomain($query, string $domainKey)
|
||||
{
|
||||
return $query->where('domain_key', $domainKey);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeByName($query, string $name, string $domainKey)
|
||||
{
|
||||
return $query->where('name', $name)->where('domain_key', $domainKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hierarchical navigation structure
|
||||
*/
|
||||
public function getHierarchicalItems(): \Illuminate\Support\Collection
|
||||
{
|
||||
return $this->items()->with(['children' => function ($query) {
|
||||
$query->where('is_active', true)->orderBy('order');
|
||||
}, 'page'])->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings management
|
||||
*/
|
||||
public function getSetting(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return data_get($this->settings, $key, $default);
|
||||
}
|
||||
|
||||
public function setSetting(string $key, mixed $value): void
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
data_set($settings, $key, $value);
|
||||
$this->update(['settings' => $settings]);
|
||||
}
|
||||
}
|
||||
140
packages/flux-cms/core/src/Models/NavigationItem.php
Normal file
140
packages/flux-cms/core/src/Models/NavigationItem.php
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Spatie\Translatable\HasTranslations;
|
||||
|
||||
class NavigationItem extends Model
|
||||
{
|
||||
use HasTranslations;
|
||||
|
||||
public function getTable()
|
||||
{
|
||||
return config('flux-cms.database.table_prefix', 'flux_cms_') . 'navigation_items';
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'navigation_id',
|
||||
'parent_id',
|
||||
'page_id',
|
||||
'title',
|
||||
'url',
|
||||
'target',
|
||||
'order',
|
||||
'is_active',
|
||||
'settings',
|
||||
];
|
||||
|
||||
protected $translatable = [
|
||||
'title'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'title' => 'array',
|
||||
'settings' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'order' => 'integer',
|
||||
];
|
||||
|
||||
public function navigation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Navigation::class, 'navigation_id');
|
||||
}
|
||||
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(NavigationItem::class, 'parent_id');
|
||||
}
|
||||
|
||||
public function children(): HasMany
|
||||
{
|
||||
return $this->hasMany(NavigationItem::class, 'parent_id')
|
||||
->where('is_active', true)
|
||||
->orderBy('order');
|
||||
}
|
||||
|
||||
public function allChildren(): HasMany
|
||||
{
|
||||
return $this->hasMany(NavigationItem::class, 'parent_id')
|
||||
->orderBy('order');
|
||||
}
|
||||
|
||||
public function page(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Page::class, 'page_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scopes
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeTopLevel($query)
|
||||
{
|
||||
return $query->whereNull('parent_id');
|
||||
}
|
||||
|
||||
public function scopeForNavigation($query, int $navigationId)
|
||||
{
|
||||
return $query->where('navigation_id', $navigationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective URL for this navigation item
|
||||
*/
|
||||
public function getEffectiveUrl(): string
|
||||
{
|
||||
if ($this->page_id && $this->page) {
|
||||
return $this->page->getUrl();
|
||||
}
|
||||
|
||||
return $this->url ?? '#';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this item has children
|
||||
*/
|
||||
public function hasChildren(): bool
|
||||
{
|
||||
return $this->children()->count() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all descendant IDs (children, grandchildren, etc.)
|
||||
*/
|
||||
public function getDescendantIds(): array
|
||||
{
|
||||
$ids = [];
|
||||
$this->collectDescendantIds($ids);
|
||||
return $ids;
|
||||
}
|
||||
|
||||
protected function collectDescendantIds(array &$ids): void
|
||||
{
|
||||
foreach ($this->allChildren as $child) {
|
||||
$ids[] = $child->id;
|
||||
$child->collectDescendantIds($ids);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings management
|
||||
*/
|
||||
public function getSetting(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return data_get($this->settings, $key, $default);
|
||||
}
|
||||
|
||||
public function setSetting(string $key, mixed $value): void
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
data_set($settings, $key, $value);
|
||||
$this->update(['settings' => $settings]);
|
||||
}
|
||||
}
|
||||
241
packages/flux-cms/core/src/Models/Page.php
Normal file
241
packages/flux-cms/core/src/Models/Page.php
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Spatie\Translatable\HasTranslations;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
|
||||
class Page extends Model implements HasMedia
|
||||
{
|
||||
use HasTranslations, InteractsWithMedia;
|
||||
|
||||
public function getTable()
|
||||
{
|
||||
return config('flux-cms.database.table_prefix', 'flux_cms_') . 'pages';
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'domain_key',
|
||||
'title',
|
||||
'meta_description',
|
||||
'meta_keywords',
|
||||
'og_image',
|
||||
'canonical_url',
|
||||
'is_published',
|
||||
'published_at',
|
||||
'settings',
|
||||
];
|
||||
|
||||
protected $translatable = [
|
||||
'title',
|
||||
'meta_description',
|
||||
'meta_keywords',
|
||||
'og_image'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'title' => 'array',
|
||||
'meta_description' => 'array',
|
||||
'meta_keywords' => 'array',
|
||||
'og_image' => 'array',
|
||||
'settings' => 'array',
|
||||
'is_published' => 'boolean',
|
||||
'published_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function components(): HasMany
|
||||
{
|
||||
return $this->hasMany(PageComponent::class, 'page_id')
|
||||
->where('is_active', true)
|
||||
->orderBy('order');
|
||||
}
|
||||
|
||||
public function allComponents(): HasMany
|
||||
{
|
||||
return $this->hasMany(PageComponent::class, 'page_id')
|
||||
->orderBy('order');
|
||||
}
|
||||
|
||||
public function versions(): HasMany
|
||||
{
|
||||
return $this->hasMany(PageVersion::class, 'page_id')
|
||||
->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
public function navigationItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(NavigationItem::class, 'page_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Media Collections für SEO Images
|
||||
*/
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('og_images')
|
||||
->singleFile()
|
||||
->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp']);
|
||||
|
||||
$this->addMediaCollection('page_assets')
|
||||
->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml']);
|
||||
}
|
||||
|
||||
public function registerMediaConversions(Media $media = null): void
|
||||
{
|
||||
$this->addMediaConversion('og_thumb')
|
||||
->width(1200)
|
||||
->height(630)
|
||||
->sharpen(10);
|
||||
|
||||
$this->addMediaConversion('preview')
|
||||
->width(400)
|
||||
->height(300)
|
||||
->sharpen(10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scopes
|
||||
*/
|
||||
public function scopeForDomain($query, string $domainKey)
|
||||
{
|
||||
return $query->where('domain_key', $domainKey);
|
||||
}
|
||||
|
||||
public function scopePublished($query)
|
||||
{
|
||||
return $query->where('is_published', true)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('published_at')
|
||||
->orWhere('published_at', '<=', now());
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeBySlug($query, string $slug, string $locale = null)
|
||||
{
|
||||
$locale = $locale ?? app()->getLocale();
|
||||
return $query->whereHas('slugs', function ($q) use ($slug, $locale) {
|
||||
$q->where('slug', $slug)->where('locale', $locale);
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeBySlugWithFallback($query, string $slug, string $locale = null)
|
||||
{
|
||||
$locale = $locale ?? app()->getLocale();
|
||||
$fallbackLocale = config('app.fallback_locale');
|
||||
|
||||
return $query->where(function ($q) use ($slug, $locale, $fallbackLocale) {
|
||||
$q->whereHas('slugs', function ($sq) use ($slug, $locale) {
|
||||
$sq->where('slug', $slug)->where('locale', $locale);
|
||||
})->orWhereHas('slugs', function ($sq) use ($slug, $fallbackLocale) {
|
||||
$sq->where('slug', $slug)->where('locale', $fallbackLocale);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Slug relationship
|
||||
*/
|
||||
public function slugs()
|
||||
{
|
||||
return $this->morphMany(Slug::class, 'model');
|
||||
}
|
||||
|
||||
/**
|
||||
* Version Management
|
||||
*/
|
||||
public function createVersion(string $changeDescription = null, ?Model $user = null): PageVersion
|
||||
{
|
||||
$version = $this->versions()->make([
|
||||
'page_data' => $this->toArray(),
|
||||
'components_data' => $this->allComponents()->with('media')->get()->toArray(),
|
||||
'change_description' => $changeDescription,
|
||||
'version_name' => $this->generateVersionName(),
|
||||
]);
|
||||
|
||||
if ($user) {
|
||||
$version->createdBy()->associate($user);
|
||||
}
|
||||
|
||||
$version->save();
|
||||
return $version;
|
||||
}
|
||||
|
||||
protected function generateVersionName(): string
|
||||
{
|
||||
$count = $this->versions()->count();
|
||||
return "Version " . ($count + 1) . " - " . now()->format('Y-m-d H:i');
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishing Methods
|
||||
*/
|
||||
public function publish(): void
|
||||
{
|
||||
$this->update([
|
||||
'is_published' => true,
|
||||
'published_at' => $this->published_at ?? now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function unpublish(): void
|
||||
{
|
||||
$this->update(['is_published' => false]);
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO Methods
|
||||
*/
|
||||
public function getSeoTitle(string $locale = null): string
|
||||
{
|
||||
$locale = $locale ?? app()->getLocale();
|
||||
$title = $this->getTranslation('title', $locale);
|
||||
$siteName = config('flux-cms.seo.site_name', config('app.name'));
|
||||
|
||||
return $title ? "{$title} - {$siteName}" : $siteName;
|
||||
}
|
||||
|
||||
public function getSeoDescription(string $locale = null): string
|
||||
{
|
||||
$locale = $locale ?? app()->getLocale();
|
||||
return $this->getTranslation('meta_description', $locale) ?? '';
|
||||
}
|
||||
|
||||
public function getCanonicalUrl(): string
|
||||
{
|
||||
return $this->canonical_url ?: request()->url();
|
||||
}
|
||||
|
||||
/**
|
||||
* URL Generation
|
||||
*/
|
||||
public function getUrl(string $locale = null): string
|
||||
{
|
||||
$locale = $locale ?? app()->getLocale();
|
||||
$slug = $this->slugs()->where('locale', $locale)->first();
|
||||
|
||||
if (!$slug || $slug->slug === '/') {
|
||||
return url('/');
|
||||
}
|
||||
|
||||
return url(ltrim($slug->slug, '/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings Management
|
||||
*/
|
||||
public function getSetting(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return data_get($this->settings, $key, $default);
|
||||
}
|
||||
|
||||
public function setSetting(string $key, mixed $value): void
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
data_set($settings, $key, $value);
|
||||
$this->update(['settings' => $settings]);
|
||||
}
|
||||
}
|
||||
217
packages/flux-cms/core/src/Models/PageComponent.php
Normal file
217
packages/flux-cms/core/src/Models/PageComponent.php
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Spatie\Translatable\HasTranslations;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
use FluxCms\Core\Services\ComponentRegistry;
|
||||
|
||||
class PageComponent extends Model implements HasMedia
|
||||
{
|
||||
use HasTranslations, InteractsWithMedia;
|
||||
|
||||
public function getTable()
|
||||
{
|
||||
return config('flux-cms.database.table_prefix', 'flux_cms_') . 'page_components';
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'page_id',
|
||||
'component_class',
|
||||
'order',
|
||||
'content',
|
||||
'is_active',
|
||||
'settings',
|
||||
];
|
||||
|
||||
protected $translatable = [
|
||||
'content'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'content' => 'array',
|
||||
'settings' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
public function page(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Page::class, 'page_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Media Collections für Component Assets
|
||||
*/
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('component_images')
|
||||
->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml']);
|
||||
|
||||
$this->addMediaCollection('component_files')
|
||||
->acceptsMimeTypes([
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'text/plain'
|
||||
]);
|
||||
|
||||
$this->addMediaCollection('component_videos')
|
||||
->acceptsMimeTypes(['video/mp4', 'video/webm', 'video/ogg']);
|
||||
}
|
||||
|
||||
public function registerMediaConversions(Media $media = null): void
|
||||
{
|
||||
$this->addMediaConversion('thumb')
|
||||
->width(300)
|
||||
->height(300)
|
||||
->sharpen(10);
|
||||
|
||||
$this->addMediaConversion('medium')
|
||||
->width(800)
|
||||
->height(600)
|
||||
->sharpen(10);
|
||||
|
||||
$this->addMediaConversion('large')
|
||||
->width(1200)
|
||||
->height(900)
|
||||
->sharpen(10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component Configuration
|
||||
*/
|
||||
public function getComponentConfig(): array
|
||||
{
|
||||
if (!class_exists($this->component_class)) {
|
||||
return [
|
||||
'name' => 'Unknown Component',
|
||||
'fields' => [],
|
||||
'category' => 'Unknown',
|
||||
'error' => 'Component class not found: ' . $this->component_class
|
||||
];
|
||||
}
|
||||
|
||||
$registry = app(ComponentRegistry::class);
|
||||
return $registry->getComponentConfig($this->component_class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Content Validation
|
||||
*/
|
||||
public function validateContent(): array
|
||||
{
|
||||
if (!class_exists($this->component_class)) {
|
||||
return ['component_class' => 'Component class not found'];
|
||||
}
|
||||
|
||||
$registry = app(ComponentRegistry::class);
|
||||
return $registry->validateComponentContent($this->component_class, $this->content ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scopes
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeOrdered($query)
|
||||
{
|
||||
return $query->orderBy('order');
|
||||
}
|
||||
|
||||
public function scopeByComponentClass($query, string $componentClass)
|
||||
{
|
||||
return $query->where('component_class', $componentClass);
|
||||
}
|
||||
|
||||
/**
|
||||
* Content Management
|
||||
*/
|
||||
public function getTranslatedContent(string $locale = null): array
|
||||
{
|
||||
$locale = $locale ?? app()->getLocale();
|
||||
$content = $this->getTranslations('content');
|
||||
|
||||
return $content[$locale] ?? $content[config('app.fallback_locale')] ?? [];
|
||||
}
|
||||
|
||||
public function setTranslatedContent(array $content, string $locale = null): void
|
||||
{
|
||||
$locale = $locale ?? app()->getLocale();
|
||||
$translations = $this->getTranslations('content');
|
||||
$translations[$locale] = $content;
|
||||
$this->content = $translations;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function getContentValue(string $key, string $locale = null): mixed
|
||||
{
|
||||
$content = $this->getTranslatedContent($locale);
|
||||
return data_get($content, $key);
|
||||
}
|
||||
|
||||
public function setContentValue(string $key, mixed $value, string $locale = null): void
|
||||
{
|
||||
$content = $this->getTranslatedContent($locale);
|
||||
data_set($content, $key, $value);
|
||||
$this->setTranslatedContent($content, $locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component Rendering
|
||||
*/
|
||||
public function canRender(): bool
|
||||
{
|
||||
return class_exists($this->component_class) && $this->is_active;
|
||||
}
|
||||
|
||||
public function getComponentName(): string
|
||||
{
|
||||
$config = $this->getComponentConfig();
|
||||
return $config['name'] ?? class_basename($this->component_class);
|
||||
}
|
||||
|
||||
public function getComponentCategory(): string
|
||||
{
|
||||
$config = $this->getComponentConfig();
|
||||
return $config['category'] ?? 'General';
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplication
|
||||
*/
|
||||
public function duplicate(int $newOrder = null): self
|
||||
{
|
||||
$duplicate = $this->replicate();
|
||||
$duplicate->order = $newOrder ?? ($this->page->allComponents()->max('order') + 1);
|
||||
$duplicate->save();
|
||||
|
||||
// Copy media if any
|
||||
foreach ($this->getMedia() as $media) {
|
||||
$media->copy($duplicate);
|
||||
}
|
||||
|
||||
return $duplicate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings Management
|
||||
*/
|
||||
public function getSetting(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return data_get($this->settings, $key, $default);
|
||||
}
|
||||
|
||||
public function setSetting(string $key, mixed $value): void
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
data_set($settings, $key, $value);
|
||||
$this->update(['settings' => $settings]);
|
||||
}
|
||||
}
|
||||
101
packages/flux-cms/core/src/Models/PageVersion.php
Normal file
101
packages/flux-cms/core/src/Models/PageVersion.php
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PageVersion extends Model
|
||||
{
|
||||
public function getTable()
|
||||
{
|
||||
return config('flux-cms.database.table_prefix', 'flux_cms_') . 'page_versions';
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'page_id',
|
||||
'page_data',
|
||||
'components_data',
|
||||
'version_name',
|
||||
'change_description',
|
||||
'created_by_type',
|
||||
'created_by_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'page_data' => 'array',
|
||||
'components_data' => 'array',
|
||||
];
|
||||
|
||||
public function page(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Page::class, 'page_id');
|
||||
}
|
||||
|
||||
public function createdBy()
|
||||
{
|
||||
return $this->morphTo('created_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore this version
|
||||
*/
|
||||
public function restore(): void
|
||||
{
|
||||
$page = $this->page;
|
||||
|
||||
// Create backup of current version
|
||||
$page->createVersion('Backup before restoration');
|
||||
|
||||
// Restore page data
|
||||
$pageData = $this->page_data;
|
||||
unset($pageData['id'], $pageData['created_at'], $pageData['updated_at']);
|
||||
$page->update($pageData);
|
||||
|
||||
// Delete current components and restore from version
|
||||
$page->allComponents()->delete();
|
||||
|
||||
foreach ($this->components_data as $componentData) {
|
||||
unset($componentData['id'], $componentData['page_id'], $componentData['created_at'], $componentData['updated_at']);
|
||||
$page->allComponents()->create($componentData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get differences compared to current version
|
||||
*/
|
||||
public function getDifferences(): array
|
||||
{
|
||||
$currentPage = $this->page->fresh();
|
||||
$currentComponents = $currentPage->allComponents()->get();
|
||||
|
||||
return [
|
||||
'page_changes' => $this->arrayDiff($this->page_data, $currentPage->toArray()),
|
||||
'components_changes' => $this->arrayDiff($this->components_data, $currentComponents->toArray()),
|
||||
];
|
||||
}
|
||||
|
||||
private function arrayDiff(array $old, array $new): array
|
||||
{
|
||||
$changes = [];
|
||||
|
||||
foreach ($old as $key => $value) {
|
||||
if (!isset($new[$key])) {
|
||||
$changes['removed'][$key] = $value;
|
||||
} elseif ($new[$key] !== $value) {
|
||||
$changes['changed'][$key] = [
|
||||
'old' => $value,
|
||||
'new' => $new[$key]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($new as $key => $value) {
|
||||
if (!isset($old[$key])) {
|
||||
$changes['added'][$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $changes;
|
||||
}
|
||||
}
|
||||
22
packages/flux-cms/core/src/Models/Slug.php
Normal file
22
packages/flux-cms/core/src/Models/Slug.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Slug extends Model
|
||||
{
|
||||
protected $table = 'flux_cms_slugs';
|
||||
|
||||
protected $fillable = [
|
||||
'model_type',
|
||||
'model_id',
|
||||
'locale',
|
||||
'slug',
|
||||
];
|
||||
|
||||
public function model()
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
}
|
||||
470
packages/flux-cms/core/src/Services/ComponentRegistry.php
Normal file
470
packages/flux-cms/core/src/Services/ComponentRegistry.php
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use ReflectionClass;
|
||||
use ReflectionException;
|
||||
use FluxCms\Core\FieldTypes\BaseField;
|
||||
|
||||
class ComponentRegistry
|
||||
{
|
||||
protected array $componentPaths = [];
|
||||
protected string $cacheKey = 'flux_cms.component.registry';
|
||||
protected int $cacheTtl = 3600; // 1 Stunde
|
||||
protected bool $cacheEnabled = true;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->componentPaths = config('flux-cms.component_paths', [
|
||||
'App\\Livewire\\Components',
|
||||
'FluxCms\\StarterComponents\\Components',
|
||||
]);
|
||||
|
||||
$this->cacheEnabled = config('flux-cms.cache.components', true);
|
||||
$this->cacheTtl = config('flux-cms.cache.ttl', 3600);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hole alle verfügbaren CMS-Komponenten
|
||||
*/
|
||||
public function getAvailableComponents(): array
|
||||
{
|
||||
if (!$this->cacheEnabled) {
|
||||
return $this->scanComponents();
|
||||
}
|
||||
|
||||
return Cache::remember($this->cacheKey, $this->cacheTtl, function () {
|
||||
return $this->scanComponents();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hole eine spezifische Komponente
|
||||
*/
|
||||
public function getComponent(string $className): ?array
|
||||
{
|
||||
$components = $this->getAvailableComponents();
|
||||
return $components[$className] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüfe ob eine Klasse eine gültige CMS-Komponente ist
|
||||
*/
|
||||
public function isValidComponent(string $className): bool
|
||||
{
|
||||
if (!class_exists($className)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$reflection = new ReflectionClass($className);
|
||||
|
||||
// Muss getCmsFields Methode haben
|
||||
if (!$reflection->hasMethod('getCmsFields')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// getCmsFields muss static sein
|
||||
$method = $reflection->getMethod('getCmsFields');
|
||||
if (!$method->isStatic() || !$method->isPublic()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Muss von Livewire\Component erben
|
||||
if (!$reflection->isSubclassOf(\Livewire\Component::class)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (ReflectionException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hole die CMS-Konfiguration einer Komponente
|
||||
*/
|
||||
public function getComponentConfig(string $className): array
|
||||
{
|
||||
if (!$this->isValidComponent($className)) {
|
||||
return [
|
||||
'class' => $className,
|
||||
'name' => 'Invalid Component',
|
||||
'fields' => [],
|
||||
'category' => 'Error',
|
||||
'error' => 'Component is not valid or does not exist'
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$config = [
|
||||
'class' => $className,
|
||||
'name' => $this->getComponentName($className),
|
||||
'fields' => $className::getCmsFields(),
|
||||
'category' => $this->getComponentCategory($className),
|
||||
'description' => $this->getComponentDescription($className),
|
||||
'preview' => $this->getComponentPreview($className),
|
||||
'icon' => $this->getComponentIcon($className),
|
||||
'tags' => $this->getComponentTags($className),
|
||||
'version' => $this->getComponentVersion($className),
|
||||
];
|
||||
|
||||
// Validiere Felder
|
||||
$config['fields'] = $this->validateFields($config['fields']);
|
||||
|
||||
return $config;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Error getting component config for {$className}: " . $e->getMessage());
|
||||
return [
|
||||
'class' => $className,
|
||||
'name' => 'Error Component',
|
||||
'fields' => [],
|
||||
'category' => 'Error',
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scanne alle Komponenten-Verzeichnisse
|
||||
*/
|
||||
protected function scanComponents(): array
|
||||
{
|
||||
$components = [];
|
||||
|
||||
foreach ($this->componentPaths as $namespace) {
|
||||
$path = $this->namespaceToPath($namespace);
|
||||
|
||||
if (!is_dir($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$components = array_merge($components, $this->scanDirectory($path, $namespace));
|
||||
}
|
||||
|
||||
// Sortiere nach Kategorie und Name
|
||||
uasort($components, function ($a, $b) {
|
||||
$categoryCompare = strcmp($a['category'], $b['category']);
|
||||
if ($categoryCompare !== 0) {
|
||||
return $categoryCompare;
|
||||
}
|
||||
return strcmp($a['name'], $b['name']);
|
||||
});
|
||||
|
||||
return $components;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scanne ein Verzeichnis nach Komponenten
|
||||
*/
|
||||
protected function scanDirectory(string $path, string $namespace): array
|
||||
{
|
||||
$components = [];
|
||||
|
||||
if (!is_dir($path)) {
|
||||
return $components;
|
||||
}
|
||||
|
||||
$files = File::allFiles($path);
|
||||
|
||||
foreach ($files as $file) {
|
||||
if ($file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$className = $this->fileToClassName($file, $namespace, $path);
|
||||
|
||||
if ($this->isValidComponent($className)) {
|
||||
$config = $this->getComponentConfig($className);
|
||||
if (!isset($config['error'])) {
|
||||
$components[$className] = $config;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $components;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiere Namespace zu Dateipfad
|
||||
*/
|
||||
protected function namespaceToPath(string $namespace): string
|
||||
{
|
||||
// Handle App namespace
|
||||
if (str_starts_with($namespace, 'App\\')) {
|
||||
$path = str_replace('App\\', 'app/', $namespace);
|
||||
$path = str_replace('\\', '/', $path);
|
||||
return base_path($path);
|
||||
}
|
||||
|
||||
// Handle package namespaces
|
||||
if (str_starts_with($namespace, 'FluxCms\\')) {
|
||||
$path = str_replace('FluxCms\\', 'packages/flux-cms/', $namespace);
|
||||
$path = str_replace('\\', '/', $path);
|
||||
$path = strtolower($path);
|
||||
return base_path($path . '/src');
|
||||
}
|
||||
|
||||
// Fallback
|
||||
$path = str_replace('\\', '/', $namespace);
|
||||
return base_path('vendor/' . strtolower($path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiere Datei zu Klassenname
|
||||
*/
|
||||
protected function fileToClassName(\SplFileInfo $file, string $namespace, string $basePath): string
|
||||
{
|
||||
$relativePath = str_replace($basePath . '/', '', $file->getPathname());
|
||||
$relativePath = str_replace('.php', '', $relativePath);
|
||||
$relativePath = str_replace('/', '\\', $relativePath);
|
||||
|
||||
return $namespace . '\\' . $relativePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component Metadaten-Methoden
|
||||
*/
|
||||
protected function getComponentName(string $className): string
|
||||
{
|
||||
if (method_exists($className, 'getCmsName')) {
|
||||
return $className::getCmsName();
|
||||
}
|
||||
return $this->classNameToReadable($className);
|
||||
}
|
||||
|
||||
protected function getComponentCategory(string $className): string
|
||||
{
|
||||
if (method_exists($className, 'getCmsCategory')) {
|
||||
return $className::getCmsCategory();
|
||||
}
|
||||
return 'General';
|
||||
}
|
||||
|
||||
protected function getComponentDescription(string $className): ?string
|
||||
{
|
||||
if (method_exists($className, 'getCmsDescription')) {
|
||||
return $className::getCmsDescription();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function getComponentPreview(string $className): ?string
|
||||
{
|
||||
if (method_exists($className, 'getCmsPreview')) {
|
||||
return $className::getCmsPreview();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function getComponentIcon(string $className): string
|
||||
{
|
||||
if (method_exists($className, 'getCmsIcon')) {
|
||||
return $className::getCmsIcon();
|
||||
}
|
||||
return 'puzzle-piece';
|
||||
}
|
||||
|
||||
protected function getComponentTags(string $className): array
|
||||
{
|
||||
if (method_exists($className, 'getCmsTags')) {
|
||||
return $className::getCmsTags();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
protected function getComponentVersion(string $className): string
|
||||
{
|
||||
if (method_exists($className, 'getCmsVersion')) {
|
||||
return $className::getCmsVersion();
|
||||
}
|
||||
return '1.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiere Feld-Definitionen
|
||||
*/
|
||||
protected function validateFields(array $fields): array
|
||||
{
|
||||
$validatedFields = [];
|
||||
|
||||
foreach ($fields as $field) {
|
||||
if ($field instanceof BaseField) {
|
||||
$validatedFields[] = $field;
|
||||
} else {
|
||||
\Log::warning('Invalid field type in component fields', [
|
||||
'field' => $field,
|
||||
'type' => gettype($field)
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $validatedFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiere Klassenname zu lesbarem Namen
|
||||
*/
|
||||
protected function classNameToReadable(string $className): string
|
||||
{
|
||||
$baseName = class_basename($className);
|
||||
return preg_replace('/([a-z])([A-Z])/', '$1 $2', $baseName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiere Komponenten-Content
|
||||
*/
|
||||
public function validateComponentContent(string $className, array $content): array
|
||||
{
|
||||
if (!$this->isValidComponent($className)) {
|
||||
return ['component' => ['Invalid component class: ' . $className]];
|
||||
}
|
||||
|
||||
// Custom Validation der Komponente
|
||||
if (method_exists($className, 'validateContent')) {
|
||||
$customErrors = $className::validateContent($content);
|
||||
if (!empty($customErrors)) {
|
||||
return $customErrors;
|
||||
}
|
||||
}
|
||||
|
||||
// Standard-Validation basierend auf Feldern
|
||||
return $this->validateContentByFields($className, $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiere Content anhand der Feld-Definitionen
|
||||
*/
|
||||
protected function validateContentByFields(string $className, array $content): array
|
||||
{
|
||||
$errors = [];
|
||||
$fields = $className::getCmsFields();
|
||||
$availableLocales = config('flux-cms.locales', ['de', 'en']);
|
||||
|
||||
foreach ($fields as $field) {
|
||||
if (!$field instanceof BaseField) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($field->isTranslatable()) {
|
||||
// Validiere für alle verfügbaren Sprachen
|
||||
foreach ($availableLocales as $locale) {
|
||||
$value = $content[$field->getKey()][$locale] ?? null;
|
||||
$fieldErrors = $field->validate($value, $locale);
|
||||
if (!empty($fieldErrors)) {
|
||||
$errors["{$field->getKey()}.{$locale}"] = $fieldErrors;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$value = $content[$field->getKey()] ?? null;
|
||||
$fieldErrors = $field->validate($value);
|
||||
if (!empty($fieldErrors)) {
|
||||
$errors[$field->getKey()] = $fieldErrors;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache Management
|
||||
*/
|
||||
public function clearCache(): void
|
||||
{
|
||||
Cache::forget($this->cacheKey);
|
||||
}
|
||||
|
||||
public function refreshCache(): array
|
||||
{
|
||||
$this->clearCache();
|
||||
return $this->getAvailableComponents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Component Path Management
|
||||
*/
|
||||
public function addComponentPath(string $namespace): void
|
||||
{
|
||||
if (!in_array($namespace, $this->componentPaths)) {
|
||||
$this->componentPaths[] = $namespace;
|
||||
$this->clearCache();
|
||||
}
|
||||
}
|
||||
|
||||
public function removeComponentPath(string $namespace): void
|
||||
{
|
||||
$this->componentPaths = array_filter($this->componentPaths, fn($path) => $path !== $namespace);
|
||||
$this->clearCache();
|
||||
}
|
||||
|
||||
public function getComponentPaths(): array
|
||||
{
|
||||
return $this->componentPaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hole Komponenten nach Kategorie
|
||||
*/
|
||||
public function getComponentsByCategory(): array
|
||||
{
|
||||
$components = $this->getAvailableComponents();
|
||||
$categorized = [];
|
||||
|
||||
foreach ($components as $component) {
|
||||
$category = $component['category'] ?? 'General';
|
||||
$categorized[$category][] = $component;
|
||||
}
|
||||
|
||||
return $categorized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suche Komponenten
|
||||
*/
|
||||
public function searchComponents(string $query): array
|
||||
{
|
||||
$components = $this->getAvailableComponents();
|
||||
$query = strtolower($query);
|
||||
|
||||
return array_filter($components, function ($component) use ($query) {
|
||||
$searchFields = [
|
||||
strtolower($component['name']),
|
||||
strtolower($component['category']),
|
||||
strtolower($component['description'] ?? ''),
|
||||
strtolower(implode(' ', $component['tags'] ?? []))
|
||||
];
|
||||
|
||||
foreach ($searchFields as $field) {
|
||||
if (str_contains($field, $query)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hole Komponenten-Statistiken
|
||||
*/
|
||||
public function getComponentStats(): array
|
||||
{
|
||||
$components = $this->getAvailableComponents();
|
||||
|
||||
$stats = [
|
||||
'total' => count($components),
|
||||
'categories' => [],
|
||||
'most_used' => [],
|
||||
];
|
||||
|
||||
foreach ($components as $component) {
|
||||
$category = $component['category'] ?? 'General';
|
||||
$stats['categories'][$category] = ($stats['categories'][$category] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
}
|
||||
32
packages/flux-cms/core/tests/Browser/LoginTest.php
Normal file
32
packages/flux-cms/core/tests/Browser/LoginTest.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Tests\Browser;
|
||||
|
||||
use FluxCms\Core\Models\User;
|
||||
use FluxCms\Core\Tests\DuskTestCase;
|
||||
use Laravel\Dusk\Browser;
|
||||
|
||||
class LoginTest extends DuskTestCase
|
||||
{
|
||||
/**
|
||||
* A basic browser test example.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_admin_can_login_successfully()
|
||||
{
|
||||
$admin = User::factory()->create([
|
||||
'email' => 'admin@flux-cms.com',
|
||||
'password' => bcrypt('password'),
|
||||
'is_admin' => true,
|
||||
]);
|
||||
|
||||
$this->browse(function (Browser $browser) use ($admin) {
|
||||
$browser->visit('/login')
|
||||
->type('email', $admin->email)
|
||||
->type('password', 'password')
|
||||
->press('Login')
|
||||
->assertPathIs('/admin/cms');
|
||||
});
|
||||
}
|
||||
}
|
||||
49
packages/flux-cms/core/tests/DuskTestCase.php
Normal file
49
packages/flux-cms/core/tests/DuskTestCase.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Tests;
|
||||
|
||||
use Laravel\Dusk\TestCase as BaseTestCase;
|
||||
use Orchestra\Testbench\Concerns\CreatesApplication;
|
||||
use Facebook\WebDriver\Chrome\ChromeOptions;
|
||||
use Facebook\WebDriver\Remote\RemoteWebDriver;
|
||||
use Facebook\WebDriver\Remote\DesiredCapabilities;
|
||||
|
||||
abstract class DuskTestCase extends BaseTestCase
|
||||
{
|
||||
use CreatesApplication;
|
||||
|
||||
/**
|
||||
* Prepare for Dusk test execution.
|
||||
*
|
||||
* @beforeClass
|
||||
* @return void
|
||||
*/
|
||||
public static function prepare()
|
||||
{
|
||||
if (! static::runningInSail()) {
|
||||
static::startChromeDriver();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the RemoteWebDriver instance.
|
||||
*
|
||||
* @return \Facebook\WebDriver\Remote\RemoteWebDriver
|
||||
*/
|
||||
protected function driver()
|
||||
{
|
||||
$options = (new ChromeOptions)->addArguments([
|
||||
'--disable-gpu',
|
||||
'--headless',
|
||||
'--window-size=1920,1080',
|
||||
]);
|
||||
|
||||
return RemoteWebDriver::create(
|
||||
$_ENV['DUSK_DRIVER_URL'] ?? 'http://localhost:9515',
|
||||
DesiredCapabilities::chrome()->setCapability(
|
||||
ChromeOptions::CAPABILITY,
|
||||
$options
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
197
packages/flux-cms/core/tests/Feature/PageManagementTest.php
Normal file
197
packages/flux-cms/core/tests/Feature/PageManagementTest.php
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Tests\Feature;
|
||||
|
||||
use FluxCms\Core\Models\Page;
|
||||
use FluxCms\Core\Models\PageComponent;
|
||||
use Orchestra\Testbench\TestCase;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
class PageManagementTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->loadLaravelMigrations();
|
||||
$this->artisan('migrate');
|
||||
}
|
||||
|
||||
public function test_can_create_page()
|
||||
{
|
||||
$page = Page::create([
|
||||
'domain_key' => 'test',
|
||||
'title' => ['de' => 'Test Seite', 'en' => 'Test Page'],
|
||||
'slug' => ['de' => '/test-seite', 'en' => '/test-page'],
|
||||
'is_published' => true,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('flux_cms_pages', [
|
||||
'id' => $page->id,
|
||||
'domain_key' => 'test',
|
||||
]);
|
||||
|
||||
$this->assertEquals('Test Seite', $page->getTranslation('title', 'de'));
|
||||
$this->assertEquals('Test Page', $page->getTranslation('title', 'en'));
|
||||
}
|
||||
|
||||
public function test_can_add_components_to_page()
|
||||
{
|
||||
$page = Page::create([
|
||||
'domain_key' => 'test',
|
||||
'title' => ['de' => 'Test Seite'],
|
||||
'slug' => ['de' => '/test'],
|
||||
'is_published' => true,
|
||||
]);
|
||||
|
||||
$component = $page->allComponents()->create([
|
||||
'component_class' => 'TestComponent',
|
||||
'order' => 1,
|
||||
'content' => [
|
||||
'title' => ['de' => 'Komponenten Titel'],
|
||||
'text' => ['de' => 'Komponenten Text']
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('flux_cms_page_components', [
|
||||
'page_id' => $page->id,
|
||||
'component_class' => 'TestComponent',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$this->assertEquals(1, $page->allComponents()->count());
|
||||
$this->assertEquals('Komponenten Titel', $component->getTranslatedContent('de')['title']);
|
||||
}
|
||||
|
||||
public function test_can_publish_and_unpublish_page()
|
||||
{
|
||||
$page = Page::create([
|
||||
'domain_key' => 'test',
|
||||
'title' => ['de' => 'Test Seite'],
|
||||
'slug' => ['de' => '/test'],
|
||||
'is_published' => false,
|
||||
]);
|
||||
|
||||
$this->assertFalse($page->is_published);
|
||||
|
||||
$page->publish();
|
||||
$this->assertTrue($page->fresh()->is_published);
|
||||
$this->assertNotNull($page->fresh()->published_at);
|
||||
|
||||
$page->unpublish();
|
||||
$this->assertFalse($page->fresh()->is_published);
|
||||
}
|
||||
|
||||
public function test_can_create_page_version()
|
||||
{
|
||||
$page = Page::create([
|
||||
'domain_key' => 'test',
|
||||
'title' => ['de' => 'Test Seite'],
|
||||
'slug' => ['de' => '/test'],
|
||||
'is_published' => true,
|
||||
]);
|
||||
|
||||
$page->allComponents()->create([
|
||||
'component_class' => 'TestComponent',
|
||||
'order' => 1,
|
||||
'content' => ['title' => ['de' => 'Original']],
|
||||
]);
|
||||
|
||||
$version = $page->createVersion('Initial version');
|
||||
|
||||
$this->assertDatabaseHas('flux_cms_page_versions', [
|
||||
'page_id' => $page->id,
|
||||
'change_description' => 'Initial version',
|
||||
]);
|
||||
|
||||
$this->assertNotNull($version->page_data);
|
||||
$this->assertNotNull($version->components_data);
|
||||
$this->assertEquals(1, $page->versions()->count());
|
||||
}
|
||||
|
||||
public function test_page_scope_by_domain()
|
||||
{
|
||||
Page::create([
|
||||
'domain_key' => 'domain1',
|
||||
'title' => ['de' => 'Domain 1 Seite'],
|
||||
'slug' => ['de' => '/domain1'],
|
||||
]);
|
||||
|
||||
Page::create([
|
||||
'domain_key' => 'domain2',
|
||||
'title' => ['de' => 'Domain 2 Seite'],
|
||||
'slug' => ['de' => '/domain2'],
|
||||
]);
|
||||
|
||||
$domain1Pages = Page::forDomain('domain1')->get();
|
||||
$domain2Pages = Page::forDomain('domain2')->get();
|
||||
|
||||
$this->assertEquals(1, $domain1Pages->count());
|
||||
$this->assertEquals(1, $domain2Pages->count());
|
||||
$this->assertEquals('Domain 1 Seite', $domain1Pages->first()->getTranslation('title', 'de'));
|
||||
}
|
||||
|
||||
public function test_page_scope_by_slug()
|
||||
{
|
||||
$page = Page::create([
|
||||
'domain_key' => 'test',
|
||||
'title' => ['de' => 'Test Seite'],
|
||||
'slug' => ['de' => '/test-seite', 'en' => '/test-page'],
|
||||
]);
|
||||
|
||||
$foundPage = Page::bySlug('/test-seite', 'de')->first();
|
||||
$this->assertEquals($page->id, $foundPage->id);
|
||||
|
||||
$foundPage = Page::bySlug('/test-page', 'en')->first();
|
||||
$this->assertEquals($page->id, $foundPage->id);
|
||||
|
||||
$notFound = Page::bySlug('/not-found', 'de')->first();
|
||||
$this->assertNull($notFound);
|
||||
}
|
||||
|
||||
public function test_component_can_be_duplicated()
|
||||
{
|
||||
$page = Page::create([
|
||||
'domain_key' => 'test',
|
||||
'title' => ['de' => 'Test Seite'],
|
||||
'slug' => ['de' => '/test'],
|
||||
]);
|
||||
|
||||
$originalComponent = $page->allComponents()->create([
|
||||
'component_class' => 'TestComponent',
|
||||
'order' => 1,
|
||||
'content' => ['title' => ['de' => 'Original']],
|
||||
]);
|
||||
|
||||
$duplicate = $originalComponent->duplicate();
|
||||
|
||||
$this->assertEquals(2, $page->allComponents()->count());
|
||||
$this->assertEquals($originalComponent->content, $duplicate->content);
|
||||
$this->assertEquals(2, $duplicate->order);
|
||||
$this->assertNotEquals($originalComponent->id, $duplicate->id);
|
||||
}
|
||||
|
||||
protected function getPackageProviders($app)
|
||||
{
|
||||
return [
|
||||
\FluxCms\Core\FluxCmsServiceProvider::class,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getEnvironmentSetUp($app)
|
||||
{
|
||||
$app['config']->set('database.default', 'testing');
|
||||
$app['config']->set('database.connections.testing', [
|
||||
'driver' => 'sqlite',
|
||||
'database' => ':memory:',
|
||||
'prefix' => '',
|
||||
]);
|
||||
|
||||
$app['config']->set('flux-cms.locales', [
|
||||
'de' => 'Deutsch',
|
||||
'en' => 'English',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Tests\Unit\Admin;
|
||||
|
||||
use FluxCms\Core\Models\User;
|
||||
use FluxCms\Core\Models\BlogPost;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Orchestra\Testbench\TestCase;
|
||||
|
||||
class BlogControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected User $adminUser;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->adminUser = User::factory()->create(['is_admin' => true]);
|
||||
$this->actingAs($this->adminUser);
|
||||
}
|
||||
|
||||
public function test_can_display_blog_posts_index()
|
||||
{
|
||||
BlogPost::factory()->count(3)->create();
|
||||
$response = $this->get(route('admin.cms.blog.index'));
|
||||
$response->assertOk();
|
||||
$response->assertViewHas('posts');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Tests\Unit\Admin;
|
||||
|
||||
use FluxCms\Core\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Orchestra\Testbench\TestCase;
|
||||
|
||||
class DashboardControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected User $adminUser;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->adminUser = User::factory()->create(['is_admin' => true]);
|
||||
$this->actingAs($this->adminUser);
|
||||
}
|
||||
|
||||
public function test_admin_can_see_dashboard()
|
||||
{
|
||||
$response = $this->get(route('admin.cms.index'));
|
||||
$response->assertOk();
|
||||
$response->assertViewHas('stats');
|
||||
$response->assertViewHas('recentPages');
|
||||
}
|
||||
}
|
||||
123
packages/flux-cms/core/tests/Unit/Admin/PageControllerTest.php
Normal file
123
packages/flux-cms/core/tests/Unit/Admin/PageControllerTest.php
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Tests\Unit\Admin;
|
||||
|
||||
use FluxCms\Core\Models\User;
|
||||
use FluxCms\Core\Models\Page;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Orchestra\Testbench\TestCase;
|
||||
|
||||
class PageControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected User $adminUser;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->adminUser = User::factory()->create(['is_admin' => true]);
|
||||
$this->actingAs($this->adminUser);
|
||||
}
|
||||
|
||||
public function test_can_display_pages_index()
|
||||
{
|
||||
Page::factory()->count(3)->create();
|
||||
$response = $this->get(route('admin.cms.pages.index'));
|
||||
$response->assertOk();
|
||||
$response->assertViewHas('pages');
|
||||
}
|
||||
|
||||
public function test_can_show_create_page_form()
|
||||
{
|
||||
$response = $this->get(route('admin.cms.pages.create'));
|
||||
$response->assertOk();
|
||||
}
|
||||
|
||||
public function test_can_store_a_new_page()
|
||||
{
|
||||
$pageData = [
|
||||
'domain_key' => 'default',
|
||||
'title' => ['en' => 'New Page'],
|
||||
'slugs' => ['en' => '/new-page'],
|
||||
'is_published' => true,
|
||||
];
|
||||
|
||||
$response = $this->post(route('admin.cms.pages.store'), $pageData);
|
||||
|
||||
$this->assertDatabaseHas('flux_cms_pages', ['title' => '{"en":"New Page"}']);
|
||||
$this->assertDatabaseHas('flux_cms_slugs', ['slug' => '/new-page']);
|
||||
$response->assertRedirect();
|
||||
}
|
||||
|
||||
public function test_store_fails_with_invalid_data()
|
||||
{
|
||||
$response = $this->post(route('admin.cms.pages.store'), [
|
||||
'title' => ['en' => ''], // Invalid: title is required
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('title.en');
|
||||
$response->assertStatus(302); // Should redirect back
|
||||
}
|
||||
|
||||
public function test_unauthorized_user_cannot_create_page()
|
||||
{
|
||||
$user = User::factory()->create(); // Non-admin user
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get(route('admin.cms.pages.create'));
|
||||
$response->assertStatus(403);
|
||||
|
||||
$response = $this->post(route('admin.cms.pages.store'), []);
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_can_show_edit_page_form()
|
||||
{
|
||||
$page = Page::factory()->create();
|
||||
$response = $this->get(route('admin.cms.pages.edit', $page));
|
||||
$response->assertOk();
|
||||
$response->assertViewHas('page', $page);
|
||||
}
|
||||
|
||||
public function test_can_update_a_page()
|
||||
{
|
||||
$page = Page::factory()->create();
|
||||
$page->slugs()->create(['locale' => 'en', 'slug' => '/old-slug']);
|
||||
|
||||
$updateData = [
|
||||
'title' => ['en' => 'Updated Title'],
|
||||
'slugs' => ['en' => '/updated-slug'],
|
||||
];
|
||||
|
||||
$response = $this->put(route('admin.cms.pages.update', $page), $updateData);
|
||||
|
||||
$this->assertDatabaseHas('flux_cms_pages', ['id' => $page->id, 'title' => '{"en":"Updated Title"}']);
|
||||
$this->assertDatabaseHas('flux_cms_slugs', ['slug' => '/updated-slug']);
|
||||
$response->assertRedirect();
|
||||
}
|
||||
|
||||
public function test_unauthorized_user_cannot_edit_or_delete_page()
|
||||
{
|
||||
$user = User::factory()->create(); // Non-admin user
|
||||
$page = Page::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get(route('admin.cms.pages.edit', $page));
|
||||
$response->assertStatus(403);
|
||||
|
||||
$response = $this->put(route('admin.cms.pages.update', $page), []);
|
||||
$response->assertStatus(403);
|
||||
|
||||
$response = $this->delete(route('admin.cms.pages.destroy', $page));
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_can_delete_a_page()
|
||||
{
|
||||
$page = Page::factory()->create();
|
||||
$response = $this->delete(route('admin.cms.pages.destroy', $page));
|
||||
$this->assertModelMissing($page);
|
||||
$response->assertRedirect(route('admin.cms.pages.index'));
|
||||
}
|
||||
}
|
||||
165
packages/flux-cms/core/tests/Unit/ComponentRegistryTest.php
Normal file
165
packages/flux-cms/core/tests/Unit/ComponentRegistryTest.php
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Tests\Unit;
|
||||
|
||||
use FluxCms\Core\Services\ComponentRegistry;
|
||||
use FluxCms\Core\FieldTypes\TextField;
|
||||
use FluxCms\Core\FieldTypes\MediaField;
|
||||
use Orchestra\Testbench\TestCase;
|
||||
use Livewire\Component;
|
||||
use Mockery;
|
||||
|
||||
class ComponentRegistryTest extends TestCase
|
||||
{
|
||||
protected ComponentRegistry $registry;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->registry = new ComponentRegistry();
|
||||
}
|
||||
|
||||
public function test_can_detect_valid_component()
|
||||
{
|
||||
$componentClass = TestComponent::class;
|
||||
|
||||
$this->assertTrue($this->registry->isValidComponent($componentClass));
|
||||
}
|
||||
|
||||
public function test_can_get_component_config()
|
||||
{
|
||||
$componentClass = TestComponent::class;
|
||||
|
||||
$config = $this->registry->getComponentConfig($componentClass);
|
||||
|
||||
$this->assertIsArray($config);
|
||||
$this->assertEquals('Test Component', $config['name']);
|
||||
$this->assertEquals('Testing', $config['category']);
|
||||
$this->assertCount(2, $config['fields']);
|
||||
}
|
||||
|
||||
public function test_can_validate_component_content()
|
||||
{
|
||||
$componentClass = TestComponent::class;
|
||||
|
||||
// Valid content
|
||||
$validContent = [
|
||||
'title' => ['de' => 'Test Titel', 'en' => 'Test Title'],
|
||||
'image' => 123
|
||||
];
|
||||
|
||||
$errors = $this->registry->validateComponentContent($componentClass, $validContent);
|
||||
$this->assertEmpty($errors);
|
||||
|
||||
// Invalid content (missing required field)
|
||||
$invalidContent = [
|
||||
'image' => 123
|
||||
];
|
||||
|
||||
$errors = $this->registry->validateComponentContent($componentClass, $invalidContent);
|
||||
$this->assertNotEmpty($errors);
|
||||
}
|
||||
|
||||
public function test_can_search_components()
|
||||
{
|
||||
// Mock some components in registry
|
||||
$components = [
|
||||
TestComponent::class => [
|
||||
'name' => 'Test Component',
|
||||
'category' => 'Testing',
|
||||
'description' => 'A test component',
|
||||
'tags' => ['test', 'example']
|
||||
]
|
||||
];
|
||||
|
||||
$registry = Mockery::mock(ComponentRegistry::class)->makePartial();
|
||||
$registry->shouldReceive('getAvailableComponents')->andReturn($components);
|
||||
|
||||
$results = $registry->searchComponents('test');
|
||||
|
||||
$this->assertCount(1, $results);
|
||||
$this->assertArrayHasKey(TestComponent::class, $results);
|
||||
}
|
||||
|
||||
public function test_can_get_components_by_category()
|
||||
{
|
||||
$components = [
|
||||
TestComponent::class => [
|
||||
'name' => 'Test Component',
|
||||
'category' => 'Testing',
|
||||
],
|
||||
AnotherTestComponent::class => [
|
||||
'name' => 'Another Component',
|
||||
'category' => 'Layout',
|
||||
]
|
||||
];
|
||||
|
||||
$registry = Mockery::mock(ComponentRegistry::class)->makePartial();
|
||||
$registry->shouldReceive('getAvailableComponents')->andReturn($components);
|
||||
|
||||
$categorized = $registry->getComponentsByCategory();
|
||||
|
||||
$this->assertArrayHasKey('Testing', $categorized);
|
||||
$this->assertArrayHasKey('Layout', $categorized);
|
||||
$this->assertCount(1, $categorized['Testing']);
|
||||
$this->assertCount(1, $categorized['Layout']);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
}
|
||||
|
||||
// Test component classes
|
||||
class TestComponent extends Component
|
||||
{
|
||||
public static function getCmsName(): string
|
||||
{
|
||||
return 'Test Component';
|
||||
}
|
||||
|
||||
public static function getCmsCategory(): string
|
||||
{
|
||||
return 'Testing';
|
||||
}
|
||||
|
||||
public static function getCmsFields(): array
|
||||
{
|
||||
return [
|
||||
TextField::make('title', 'Title')->translatable()->required(),
|
||||
MediaField::make('image', 'Image')->images(),
|
||||
];
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return '<div>Test Component</div>';
|
||||
}
|
||||
}
|
||||
|
||||
class AnotherTestComponent extends Component
|
||||
{
|
||||
public static function getCmsName(): string
|
||||
{
|
||||
return 'Another Component';
|
||||
}
|
||||
|
||||
public static function getCmsCategory(): string
|
||||
{
|
||||
return 'Layout';
|
||||
}
|
||||
|
||||
public static function getCmsFields(): array
|
||||
{
|
||||
return [
|
||||
TextField::make('content', 'Content'),
|
||||
];
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return '<div>Another Component</div>';
|
||||
}
|
||||
}
|
||||
33
packages/flux-cms/core/tests/Unit/Models/BlogPostTest.php
Normal file
33
packages/flux-cms/core/tests/Unit/Models/BlogPostTest.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Tests\Unit\Models;
|
||||
|
||||
use FluxCms\Core\Models\BlogPost;
|
||||
use FluxCms\Core\Models\User;
|
||||
use Spatie\Tags\Tag;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Orchestra\Testbench\TestCase;
|
||||
|
||||
class BlogPostTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_blog_post_has_author_relationship()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$post = BlogPost::factory()->create([
|
||||
'author_id' => $user->id,
|
||||
'author_type' => User::class
|
||||
]);
|
||||
$this->assertInstanceOf(User::class, $post->author);
|
||||
}
|
||||
|
||||
public function test_can_attach_and_retrieve_tags()
|
||||
{
|
||||
$post = BlogPost::factory()->create();
|
||||
$post->attachTag('Laravel');
|
||||
|
||||
$this->assertCount(1, $post->tags);
|
||||
$this->assertEquals('Laravel', $post->tags->first()->name);
|
||||
}
|
||||
}
|
||||
49
packages/flux-cms/core/tests/Unit/Models/PageTest.php
Normal file
49
packages/flux-cms/core/tests/Unit/Models/PageTest.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Tests\Unit\Models;
|
||||
|
||||
use FluxCms\Core\Models\Page;
|
||||
use FluxCms\Core\Models\Slug;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Orchestra\Testbench\TestCase;
|
||||
|
||||
class PageTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_page_has_slugs_relationship()
|
||||
{
|
||||
$page = Page::factory()->create();
|
||||
Slug::factory()->create([
|
||||
'model_id' => $page->id,
|
||||
'model_type' => Page::class
|
||||
]);
|
||||
$this->assertInstanceOf(Slug::class, $page->slugs->first());
|
||||
}
|
||||
|
||||
public function test_published_scope_returns_only_published_pages()
|
||||
{
|
||||
Page::factory()->create(['is_published' => true]);
|
||||
Page::factory()->create(['is_published' => false]);
|
||||
|
||||
$this->assertEquals(1, Page::published()->count());
|
||||
}
|
||||
|
||||
public function test_for_domain_scope_returns_pages_for_correct_domain()
|
||||
{
|
||||
Page::factory()->create(['domain_key' => 'domain-a']);
|
||||
Page::factory()->create(['domain_key' => 'domain-b']);
|
||||
|
||||
$this->assertEquals(1, Page::forDomain('domain-a')->count());
|
||||
}
|
||||
|
||||
public function test_by_slug_with_fallback_scope_finds_page_by_slug()
|
||||
{
|
||||
$page = Page::factory()->create();
|
||||
$page->slugs()->create(['locale' => 'en', 'slug' => 'test-slug']);
|
||||
|
||||
$foundPage = Page::bySlugWithFallback('test-slug', 'en')->first();
|
||||
$this->assertNotNull($foundPage);
|
||||
$this->assertEquals($page->id, $foundPage->id);
|
||||
}
|
||||
}
|
||||
22
packages/flux-cms/core/vendor/autoload.php
vendored
Normal file
22
packages/flux-cms/core/vendor/autoload.php
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
// autoload.php @generated by Composer
|
||||
|
||||
if (PHP_VERSION_ID < 50600) {
|
||||
if (!headers_sent()) {
|
||||
header('HTTP/1.1 500 Internal Server Error');
|
||||
}
|
||||
$err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
|
||||
if (!ini_get('display_errors')) {
|
||||
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
|
||||
fwrite(STDERR, $err);
|
||||
} elseif (!headers_sent()) {
|
||||
echo $err;
|
||||
}
|
||||
}
|
||||
throw new RuntimeException($err);
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/composer/autoload_real.php';
|
||||
|
||||
return ComposerAutoloaderInit3b19c1bf4f41f1c6ad6362da08433039::getLoader();
|
||||
119
packages/flux-cms/core/vendor/bin/canvas
vendored
Executable file
119
packages/flux-cms/core/vendor/bin/canvas
vendored
Executable file
|
|
@ -0,0 +1,119 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Proxy PHP file generated by Composer
|
||||
*
|
||||
* This file includes the referenced bin path (../orchestra/canvas/canvas)
|
||||
* using a stream wrapper to prevent the shebang from being output on PHP<8
|
||||
*
|
||||
* @generated
|
||||
*/
|
||||
|
||||
namespace Composer;
|
||||
|
||||
$GLOBALS['_composer_bin_dir'] = __DIR__;
|
||||
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
|
||||
|
||||
if (PHP_VERSION_ID < 80000) {
|
||||
if (!class_exists('Composer\BinProxyWrapper')) {
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class BinProxyWrapper
|
||||
{
|
||||
private $handle;
|
||||
private $position;
|
||||
private $realpath;
|
||||
|
||||
public function stream_open($path, $mode, $options, &$opened_path)
|
||||
{
|
||||
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
|
||||
$opened_path = substr($path, 17);
|
||||
$this->realpath = realpath($opened_path) ?: $opened_path;
|
||||
$opened_path = $this->realpath;
|
||||
$this->handle = fopen($this->realpath, $mode);
|
||||
$this->position = 0;
|
||||
|
||||
return (bool) $this->handle;
|
||||
}
|
||||
|
||||
public function stream_read($count)
|
||||
{
|
||||
$data = fread($this->handle, $count);
|
||||
|
||||
if ($this->position === 0) {
|
||||
$data = preg_replace('{^#!.*\r?\n}', '', $data);
|
||||
}
|
||||
|
||||
$this->position += strlen($data);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function stream_cast($castAs)
|
||||
{
|
||||
return $this->handle;
|
||||
}
|
||||
|
||||
public function stream_close()
|
||||
{
|
||||
fclose($this->handle);
|
||||
}
|
||||
|
||||
public function stream_lock($operation)
|
||||
{
|
||||
return $operation ? flock($this->handle, $operation) : true;
|
||||
}
|
||||
|
||||
public function stream_seek($offset, $whence)
|
||||
{
|
||||
if (0 === fseek($this->handle, $offset, $whence)) {
|
||||
$this->position = ftell($this->handle);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function stream_tell()
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function stream_eof()
|
||||
{
|
||||
return feof($this->handle);
|
||||
}
|
||||
|
||||
public function stream_stat()
|
||||
{
|
||||
return array();
|
||||
}
|
||||
|
||||
public function stream_set_option($option, $arg1, $arg2)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function url_stat($path, $flags)
|
||||
{
|
||||
$path = substr($path, 17);
|
||||
if (file_exists($path)) {
|
||||
return stat($path);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|
||||
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
|
||||
) {
|
||||
return include("phpvfscomposer://" . __DIR__ . '/..'.'/orchestra/canvas/canvas');
|
||||
}
|
||||
}
|
||||
|
||||
return include __DIR__ . '/..'.'/orchestra/canvas/canvas';
|
||||
119
packages/flux-cms/core/vendor/bin/carbon
vendored
Executable file
119
packages/flux-cms/core/vendor/bin/carbon
vendored
Executable file
|
|
@ -0,0 +1,119 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Proxy PHP file generated by Composer
|
||||
*
|
||||
* This file includes the referenced bin path (../nesbot/carbon/bin/carbon)
|
||||
* using a stream wrapper to prevent the shebang from being output on PHP<8
|
||||
*
|
||||
* @generated
|
||||
*/
|
||||
|
||||
namespace Composer;
|
||||
|
||||
$GLOBALS['_composer_bin_dir'] = __DIR__;
|
||||
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
|
||||
|
||||
if (PHP_VERSION_ID < 80000) {
|
||||
if (!class_exists('Composer\BinProxyWrapper')) {
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class BinProxyWrapper
|
||||
{
|
||||
private $handle;
|
||||
private $position;
|
||||
private $realpath;
|
||||
|
||||
public function stream_open($path, $mode, $options, &$opened_path)
|
||||
{
|
||||
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
|
||||
$opened_path = substr($path, 17);
|
||||
$this->realpath = realpath($opened_path) ?: $opened_path;
|
||||
$opened_path = $this->realpath;
|
||||
$this->handle = fopen($this->realpath, $mode);
|
||||
$this->position = 0;
|
||||
|
||||
return (bool) $this->handle;
|
||||
}
|
||||
|
||||
public function stream_read($count)
|
||||
{
|
||||
$data = fread($this->handle, $count);
|
||||
|
||||
if ($this->position === 0) {
|
||||
$data = preg_replace('{^#!.*\r?\n}', '', $data);
|
||||
}
|
||||
|
||||
$this->position += strlen($data);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function stream_cast($castAs)
|
||||
{
|
||||
return $this->handle;
|
||||
}
|
||||
|
||||
public function stream_close()
|
||||
{
|
||||
fclose($this->handle);
|
||||
}
|
||||
|
||||
public function stream_lock($operation)
|
||||
{
|
||||
return $operation ? flock($this->handle, $operation) : true;
|
||||
}
|
||||
|
||||
public function stream_seek($offset, $whence)
|
||||
{
|
||||
if (0 === fseek($this->handle, $offset, $whence)) {
|
||||
$this->position = ftell($this->handle);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function stream_tell()
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function stream_eof()
|
||||
{
|
||||
return feof($this->handle);
|
||||
}
|
||||
|
||||
public function stream_stat()
|
||||
{
|
||||
return array();
|
||||
}
|
||||
|
||||
public function stream_set_option($option, $arg1, $arg2)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function url_stat($path, $flags)
|
||||
{
|
||||
$path = substr($path, 17);
|
||||
if (file_exists($path)) {
|
||||
return stat($path);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|
||||
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
|
||||
) {
|
||||
return include("phpvfscomposer://" . __DIR__ . '/..'.'/nesbot/carbon/bin/carbon');
|
||||
}
|
||||
}
|
||||
|
||||
return include __DIR__ . '/..'.'/nesbot/carbon/bin/carbon';
|
||||
119
packages/flux-cms/core/vendor/bin/paratest
vendored
Executable file
119
packages/flux-cms/core/vendor/bin/paratest
vendored
Executable file
|
|
@ -0,0 +1,119 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Proxy PHP file generated by Composer
|
||||
*
|
||||
* This file includes the referenced bin path (../brianium/paratest/bin/paratest)
|
||||
* using a stream wrapper to prevent the shebang from being output on PHP<8
|
||||
*
|
||||
* @generated
|
||||
*/
|
||||
|
||||
namespace Composer;
|
||||
|
||||
$GLOBALS['_composer_bin_dir'] = __DIR__;
|
||||
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
|
||||
|
||||
if (PHP_VERSION_ID < 80000) {
|
||||
if (!class_exists('Composer\BinProxyWrapper')) {
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class BinProxyWrapper
|
||||
{
|
||||
private $handle;
|
||||
private $position;
|
||||
private $realpath;
|
||||
|
||||
public function stream_open($path, $mode, $options, &$opened_path)
|
||||
{
|
||||
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
|
||||
$opened_path = substr($path, 17);
|
||||
$this->realpath = realpath($opened_path) ?: $opened_path;
|
||||
$opened_path = $this->realpath;
|
||||
$this->handle = fopen($this->realpath, $mode);
|
||||
$this->position = 0;
|
||||
|
||||
return (bool) $this->handle;
|
||||
}
|
||||
|
||||
public function stream_read($count)
|
||||
{
|
||||
$data = fread($this->handle, $count);
|
||||
|
||||
if ($this->position === 0) {
|
||||
$data = preg_replace('{^#!.*\r?\n}', '', $data);
|
||||
}
|
||||
|
||||
$this->position += strlen($data);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function stream_cast($castAs)
|
||||
{
|
||||
return $this->handle;
|
||||
}
|
||||
|
||||
public function stream_close()
|
||||
{
|
||||
fclose($this->handle);
|
||||
}
|
||||
|
||||
public function stream_lock($operation)
|
||||
{
|
||||
return $operation ? flock($this->handle, $operation) : true;
|
||||
}
|
||||
|
||||
public function stream_seek($offset, $whence)
|
||||
{
|
||||
if (0 === fseek($this->handle, $offset, $whence)) {
|
||||
$this->position = ftell($this->handle);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function stream_tell()
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function stream_eof()
|
||||
{
|
||||
return feof($this->handle);
|
||||
}
|
||||
|
||||
public function stream_stat()
|
||||
{
|
||||
return array();
|
||||
}
|
||||
|
||||
public function stream_set_option($option, $arg1, $arg2)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function url_stat($path, $flags)
|
||||
{
|
||||
$path = substr($path, 17);
|
||||
if (file_exists($path)) {
|
||||
return stat($path);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|
||||
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
|
||||
) {
|
||||
return include("phpvfscomposer://" . __DIR__ . '/..'.'/brianium/paratest/bin/paratest');
|
||||
}
|
||||
}
|
||||
|
||||
return include __DIR__ . '/..'.'/brianium/paratest/bin/paratest';
|
||||
119
packages/flux-cms/core/vendor/bin/paratest_for_phpstorm
vendored
Executable file
119
packages/flux-cms/core/vendor/bin/paratest_for_phpstorm
vendored
Executable file
|
|
@ -0,0 +1,119 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Proxy PHP file generated by Composer
|
||||
*
|
||||
* This file includes the referenced bin path (../brianium/paratest/bin/paratest_for_phpstorm)
|
||||
* using a stream wrapper to prevent the shebang from being output on PHP<8
|
||||
*
|
||||
* @generated
|
||||
*/
|
||||
|
||||
namespace Composer;
|
||||
|
||||
$GLOBALS['_composer_bin_dir'] = __DIR__;
|
||||
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
|
||||
|
||||
if (PHP_VERSION_ID < 80000) {
|
||||
if (!class_exists('Composer\BinProxyWrapper')) {
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class BinProxyWrapper
|
||||
{
|
||||
private $handle;
|
||||
private $position;
|
||||
private $realpath;
|
||||
|
||||
public function stream_open($path, $mode, $options, &$opened_path)
|
||||
{
|
||||
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
|
||||
$opened_path = substr($path, 17);
|
||||
$this->realpath = realpath($opened_path) ?: $opened_path;
|
||||
$opened_path = $this->realpath;
|
||||
$this->handle = fopen($this->realpath, $mode);
|
||||
$this->position = 0;
|
||||
|
||||
return (bool) $this->handle;
|
||||
}
|
||||
|
||||
public function stream_read($count)
|
||||
{
|
||||
$data = fread($this->handle, $count);
|
||||
|
||||
if ($this->position === 0) {
|
||||
$data = preg_replace('{^#!.*\r?\n}', '', $data);
|
||||
}
|
||||
|
||||
$this->position += strlen($data);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function stream_cast($castAs)
|
||||
{
|
||||
return $this->handle;
|
||||
}
|
||||
|
||||
public function stream_close()
|
||||
{
|
||||
fclose($this->handle);
|
||||
}
|
||||
|
||||
public function stream_lock($operation)
|
||||
{
|
||||
return $operation ? flock($this->handle, $operation) : true;
|
||||
}
|
||||
|
||||
public function stream_seek($offset, $whence)
|
||||
{
|
||||
if (0 === fseek($this->handle, $offset, $whence)) {
|
||||
$this->position = ftell($this->handle);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function stream_tell()
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function stream_eof()
|
||||
{
|
||||
return feof($this->handle);
|
||||
}
|
||||
|
||||
public function stream_stat()
|
||||
{
|
||||
return array();
|
||||
}
|
||||
|
||||
public function stream_set_option($option, $arg1, $arg2)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function url_stat($path, $flags)
|
||||
{
|
||||
$path = substr($path, 17);
|
||||
if (file_exists($path)) {
|
||||
return stat($path);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|
||||
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
|
||||
) {
|
||||
return include("phpvfscomposer://" . __DIR__ . '/..'.'/brianium/paratest/bin/paratest_for_phpstorm');
|
||||
}
|
||||
}
|
||||
|
||||
return include __DIR__ . '/..'.'/brianium/paratest/bin/paratest_for_phpstorm';
|
||||
119
packages/flux-cms/core/vendor/bin/patch-type-declarations
vendored
Executable file
119
packages/flux-cms/core/vendor/bin/patch-type-declarations
vendored
Executable file
|
|
@ -0,0 +1,119 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Proxy PHP file generated by Composer
|
||||
*
|
||||
* This file includes the referenced bin path (../symfony/error-handler/Resources/bin/patch-type-declarations)
|
||||
* using a stream wrapper to prevent the shebang from being output on PHP<8
|
||||
*
|
||||
* @generated
|
||||
*/
|
||||
|
||||
namespace Composer;
|
||||
|
||||
$GLOBALS['_composer_bin_dir'] = __DIR__;
|
||||
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
|
||||
|
||||
if (PHP_VERSION_ID < 80000) {
|
||||
if (!class_exists('Composer\BinProxyWrapper')) {
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class BinProxyWrapper
|
||||
{
|
||||
private $handle;
|
||||
private $position;
|
||||
private $realpath;
|
||||
|
||||
public function stream_open($path, $mode, $options, &$opened_path)
|
||||
{
|
||||
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
|
||||
$opened_path = substr($path, 17);
|
||||
$this->realpath = realpath($opened_path) ?: $opened_path;
|
||||
$opened_path = $this->realpath;
|
||||
$this->handle = fopen($this->realpath, $mode);
|
||||
$this->position = 0;
|
||||
|
||||
return (bool) $this->handle;
|
||||
}
|
||||
|
||||
public function stream_read($count)
|
||||
{
|
||||
$data = fread($this->handle, $count);
|
||||
|
||||
if ($this->position === 0) {
|
||||
$data = preg_replace('{^#!.*\r?\n}', '', $data);
|
||||
}
|
||||
|
||||
$this->position += strlen($data);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function stream_cast($castAs)
|
||||
{
|
||||
return $this->handle;
|
||||
}
|
||||
|
||||
public function stream_close()
|
||||
{
|
||||
fclose($this->handle);
|
||||
}
|
||||
|
||||
public function stream_lock($operation)
|
||||
{
|
||||
return $operation ? flock($this->handle, $operation) : true;
|
||||
}
|
||||
|
||||
public function stream_seek($offset, $whence)
|
||||
{
|
||||
if (0 === fseek($this->handle, $offset, $whence)) {
|
||||
$this->position = ftell($this->handle);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function stream_tell()
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function stream_eof()
|
||||
{
|
||||
return feof($this->handle);
|
||||
}
|
||||
|
||||
public function stream_stat()
|
||||
{
|
||||
return array();
|
||||
}
|
||||
|
||||
public function stream_set_option($option, $arg1, $arg2)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function url_stat($path, $flags)
|
||||
{
|
||||
$path = substr($path, 17);
|
||||
if (file_exists($path)) {
|
||||
return stat($path);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|
||||
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
|
||||
) {
|
||||
return include("phpvfscomposer://" . __DIR__ . '/..'.'/symfony/error-handler/Resources/bin/patch-type-declarations');
|
||||
}
|
||||
}
|
||||
|
||||
return include __DIR__ . '/..'.'/symfony/error-handler/Resources/bin/patch-type-declarations';
|
||||
119
packages/flux-cms/core/vendor/bin/pest
vendored
Executable file
119
packages/flux-cms/core/vendor/bin/pest
vendored
Executable file
|
|
@ -0,0 +1,119 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Proxy PHP file generated by Composer
|
||||
*
|
||||
* This file includes the referenced bin path (../pestphp/pest/bin/pest)
|
||||
* using a stream wrapper to prevent the shebang from being output on PHP<8
|
||||
*
|
||||
* @generated
|
||||
*/
|
||||
|
||||
namespace Composer;
|
||||
|
||||
$GLOBALS['_composer_bin_dir'] = __DIR__;
|
||||
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
|
||||
|
||||
if (PHP_VERSION_ID < 80000) {
|
||||
if (!class_exists('Composer\BinProxyWrapper')) {
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class BinProxyWrapper
|
||||
{
|
||||
private $handle;
|
||||
private $position;
|
||||
private $realpath;
|
||||
|
||||
public function stream_open($path, $mode, $options, &$opened_path)
|
||||
{
|
||||
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
|
||||
$opened_path = substr($path, 17);
|
||||
$this->realpath = realpath($opened_path) ?: $opened_path;
|
||||
$opened_path = $this->realpath;
|
||||
$this->handle = fopen($this->realpath, $mode);
|
||||
$this->position = 0;
|
||||
|
||||
return (bool) $this->handle;
|
||||
}
|
||||
|
||||
public function stream_read($count)
|
||||
{
|
||||
$data = fread($this->handle, $count);
|
||||
|
||||
if ($this->position === 0) {
|
||||
$data = preg_replace('{^#!.*\r?\n}', '', $data);
|
||||
}
|
||||
|
||||
$this->position += strlen($data);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function stream_cast($castAs)
|
||||
{
|
||||
return $this->handle;
|
||||
}
|
||||
|
||||
public function stream_close()
|
||||
{
|
||||
fclose($this->handle);
|
||||
}
|
||||
|
||||
public function stream_lock($operation)
|
||||
{
|
||||
return $operation ? flock($this->handle, $operation) : true;
|
||||
}
|
||||
|
||||
public function stream_seek($offset, $whence)
|
||||
{
|
||||
if (0 === fseek($this->handle, $offset, $whence)) {
|
||||
$this->position = ftell($this->handle);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function stream_tell()
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function stream_eof()
|
||||
{
|
||||
return feof($this->handle);
|
||||
}
|
||||
|
||||
public function stream_stat()
|
||||
{
|
||||
return array();
|
||||
}
|
||||
|
||||
public function stream_set_option($option, $arg1, $arg2)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function url_stat($path, $flags)
|
||||
{
|
||||
$path = substr($path, 17);
|
||||
if (file_exists($path)) {
|
||||
return stat($path);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|
||||
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
|
||||
) {
|
||||
return include("phpvfscomposer://" . __DIR__ . '/..'.'/pestphp/pest/bin/pest');
|
||||
}
|
||||
}
|
||||
|
||||
return include __DIR__ . '/..'.'/pestphp/pest/bin/pest';
|
||||
119
packages/flux-cms/core/vendor/bin/php-parse
vendored
Executable file
119
packages/flux-cms/core/vendor/bin/php-parse
vendored
Executable file
|
|
@ -0,0 +1,119 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Proxy PHP file generated by Composer
|
||||
*
|
||||
* This file includes the referenced bin path (../nikic/php-parser/bin/php-parse)
|
||||
* using a stream wrapper to prevent the shebang from being output on PHP<8
|
||||
*
|
||||
* @generated
|
||||
*/
|
||||
|
||||
namespace Composer;
|
||||
|
||||
$GLOBALS['_composer_bin_dir'] = __DIR__;
|
||||
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
|
||||
|
||||
if (PHP_VERSION_ID < 80000) {
|
||||
if (!class_exists('Composer\BinProxyWrapper')) {
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class BinProxyWrapper
|
||||
{
|
||||
private $handle;
|
||||
private $position;
|
||||
private $realpath;
|
||||
|
||||
public function stream_open($path, $mode, $options, &$opened_path)
|
||||
{
|
||||
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
|
||||
$opened_path = substr($path, 17);
|
||||
$this->realpath = realpath($opened_path) ?: $opened_path;
|
||||
$opened_path = $this->realpath;
|
||||
$this->handle = fopen($this->realpath, $mode);
|
||||
$this->position = 0;
|
||||
|
||||
return (bool) $this->handle;
|
||||
}
|
||||
|
||||
public function stream_read($count)
|
||||
{
|
||||
$data = fread($this->handle, $count);
|
||||
|
||||
if ($this->position === 0) {
|
||||
$data = preg_replace('{^#!.*\r?\n}', '', $data);
|
||||
}
|
||||
|
||||
$this->position += strlen($data);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function stream_cast($castAs)
|
||||
{
|
||||
return $this->handle;
|
||||
}
|
||||
|
||||
public function stream_close()
|
||||
{
|
||||
fclose($this->handle);
|
||||
}
|
||||
|
||||
public function stream_lock($operation)
|
||||
{
|
||||
return $operation ? flock($this->handle, $operation) : true;
|
||||
}
|
||||
|
||||
public function stream_seek($offset, $whence)
|
||||
{
|
||||
if (0 === fseek($this->handle, $offset, $whence)) {
|
||||
$this->position = ftell($this->handle);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function stream_tell()
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function stream_eof()
|
||||
{
|
||||
return feof($this->handle);
|
||||
}
|
||||
|
||||
public function stream_stat()
|
||||
{
|
||||
return array();
|
||||
}
|
||||
|
||||
public function stream_set_option($option, $arg1, $arg2)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function url_stat($path, $flags)
|
||||
{
|
||||
$path = substr($path, 17);
|
||||
if (file_exists($path)) {
|
||||
return stat($path);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|
||||
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
|
||||
) {
|
||||
return include("phpvfscomposer://" . __DIR__ . '/..'.'/nikic/php-parser/bin/php-parse');
|
||||
}
|
||||
}
|
||||
|
||||
return include __DIR__ . '/..'.'/nikic/php-parser/bin/php-parse';
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue