First commit

This commit is contained in:
Kevin Adametz 2025-10-20 17:50:35 +02:00
commit 7cf3558ba7
12933 changed files with 1180047 additions and 0 deletions

View 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

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

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

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

@ -0,0 +1,563 @@
# Flux CMS - Laravel Package Suite
🚀 **Modern, component-first CMS for Laravel with multi-domain support**
[![Latest Version](https://img.shields.io/packagist/v/flux-cms/core.svg?style=flat-square)](https://packagist.org/packages/flux-cms/core)
[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md)
[![Total Downloads](https://img.shields.io/packagist/dt/flux-cms/core.svg?style=flat-square)](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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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');
}
}

View file

@ -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(),
];
}
}

View file

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

View file

@ -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];
}
}

View file

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

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

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

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

View file

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

View file

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

View file

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

View 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

File diff suppressed because it is too large Load diff

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

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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');
}
};

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

View file

@ -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');
}
}

View file

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

View 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

View file

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

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

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

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

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

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

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

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

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

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

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

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

View file

@ -0,0 +1,4 @@
<div>
{{-- Remember that you should never use @section('content') in a component. --}}
<h1>{{ $content['headline'] ?? 'Headline' }}</h1>
</div>

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

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

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

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

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

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

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

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

View file

@ -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]);
}
}

View file

@ -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'));
}
}

View file

@ -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'));
}
}

View file

@ -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');
}
}

View file

@ -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');
}
}

View file

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

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

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

View file

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

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

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

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

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

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

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

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

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

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

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

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

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

View file

@ -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');
}
}

View file

@ -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');
}
}

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

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

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

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

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

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

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