Add Menu Template functionality: Created MenuTemplate resource with CRUD pages, schemas, and table configuration. Implemented localization for menu template fields and integrated menu rendering capabilities. Enhanced TemplatePreviewController to support menu templates and updated dynamic template documentation accordingly.

This commit is contained in:
Ümit Tunç
2025-11-03 15:43:47 -03:00
parent 9629652d0a
commit 4a2a66c55c
18 changed files with 768 additions and 1 deletions
@@ -0,0 +1,86 @@
<?php
namespace App\Filament\Admin\Resources\MenuTemplates;
use App\Filament\Admin\Resources\MenuTemplates\Pages\CreateMenuTemplate;
use App\Filament\Admin\Resources\MenuTemplates\Pages\EditMenuTemplate;
use App\Filament\Admin\Resources\MenuTemplates\Pages\ListMenuTemplates;
use App\Filament\Admin\Resources\MenuTemplates\Pages\ViewMenuTemplate;
use App\Filament\Admin\Resources\MenuTemplates\Schemas\MenuTemplateForm;
use App\Filament\Admin\Resources\MenuTemplates\Schemas\MenuTemplateInfolist;
use App\Filament\Admin\Resources\MenuTemplates\Tables\MenuTemplatesTable;
use App\Models\MenuTemplate;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
class MenuTemplateResource extends Resource
{
protected static ?string $model = MenuTemplate::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
public static function getNavigationGroup(): string
{
return __('menu-templates.navigation_group');
}
public static function getNavigationLabel(): string
{
return __('menu-templates.navigation_label');
}
public static function getModelLabel(): string
{
return __('menu-templates.model_label');
}
public static function getPluralModelLabel(): string
{
return __('menu-templates.plural_model_label');
}
public static function form(Schema $schema): Schema
{
return MenuTemplateForm::configure($schema);
}
public static function infolist(Schema $schema): Schema
{
return MenuTemplateInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return MenuTemplatesTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListMenuTemplates::route('/'),
'create' => CreateMenuTemplate::route('/create'),
'view' => ViewMenuTemplate::route('/{record}'),
'edit' => EditMenuTemplate::route('/{record}/edit'),
];
}
public static function getRecordRouteBindingEloquentQuery(): Builder
{
return parent::getRecordRouteBindingEloquentQuery()
->withoutGlobalScopes([
SoftDeletingScope::class,
]);
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Filament\Admin\Resources\MenuTemplates\Pages;
use App\Filament\Admin\Resources\MenuTemplates\MenuTemplateResource;
use Filament\Resources\Pages\CreateRecord;
use Filament\Support\Enums\Width;
class CreateMenuTemplate extends CreateRecord
{
protected static string $resource = MenuTemplateResource::class;
public function getMaxContentWidth(): Width | string | null
{
return Width::Full;
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Filament\Admin\Resources\MenuTemplates\Pages;
use App\Filament\Admin\Resources\MenuTemplates\MenuTemplateResource;
use Filament\Actions\DeleteAction;
use Filament\Actions\ForceDeleteAction;
use Filament\Actions\RestoreAction;
use Filament\Actions\ViewAction;
use Filament\Resources\Pages\EditRecord;
use Filament\Support\Enums\Width;
class EditMenuTemplate extends EditRecord
{
protected static string $resource = MenuTemplateResource::class;
public function getMaxContentWidth(): Width | string | null
{
return Width::Full;
}
protected function getHeaderActions(): array
{
return [
ViewAction::make(),
DeleteAction::make(),
ForceDeleteAction::make(),
RestoreAction::make(),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Admin\Resources\MenuTemplates\Pages;
use App\Filament\Admin\Resources\MenuTemplates\MenuTemplateResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListMenuTemplates extends ListRecords
{
protected static string $resource = MenuTemplateResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Admin\Resources\MenuTemplates\Pages;
use App\Filament\Admin\Resources\MenuTemplates\MenuTemplateResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewMenuTemplate extends ViewRecord
{
protected static string $resource = MenuTemplateResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make(),
];
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Filament\Admin\Resources\MenuTemplates\Schemas;
use App\Models\MenuTemplate;
use Filament\Forms\Components\CodeEditor;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\View;
use Filament\Schemas\Schema;
use Filament\Forms\Components\CodeEditor\Enums\Language;
class MenuTemplateForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make(__('menu-templates.section_general'))
->schema([
TextInput::make('title')
->label(__('menu-templates.field_title'))
->required()
->maxLength(255)
->columnSpanFull(),
CodeEditor::make('html_content')
->label(__('menu-templates.field_html_content'))
->required()
->live(onBlur: true)
->language(Language::Html)
->columnSpanFull()
->helperText(__('menu-templates.field_html_content_help'))
->extraAttributes([
'style' => 'max-height: 400px;overflow-y: auto;',
'data-field-name' => 'html_content',
]),
// Preview Component
View::make('components.menu-template-preview')
->viewData(function ($record) {
return [
'type' => 'menu',
'fieldName' => 'html_content',
'recordId' => $record?->id,
];
})
->columnSpanFull(),
Toggle::make('is_active')
->label(__('menu-templates.field_is_active'))
->default(true)
->inline(false),
])
->columnSpanFull(),
]);
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Admin\Resources\MenuTemplates\Schemas;
use Filament\Schemas\Schema;
class MenuTemplateInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
//
]);
}
}
@@ -0,0 +1,65 @@
<?php
namespace App\Filament\Admin\Resources\MenuTemplates\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ForceDeleteBulkAction;
use Filament\Actions\RestoreBulkAction;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table;
class MenuTemplatesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('title')
->label(__('menu-templates.column_title'))
->searchable()
->sortable(),
IconColumn::make('is_active')
->label(__('menu-templates.column_is_active'))
->boolean()
->sortable(),
TextColumn::make('created_at')
->label(__('menu-templates.column_created_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->label(__('menu-templates.column_updated_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('deleted_at')
->label(__('menu-templates.column_deleted_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
TrashedFilter::make(),
])
->recordActions([
ViewAction::make(),
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
ForceDeleteBulkAction::make(),
RestoreBulkAction::make(),
]),
]);
}
}
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\HeaderTemplate;
use App\Models\FooterTemplate;
use App\Models\SectionTemplate;
use App\Models\MenuTemplate;
use App\Services\TemplatePreviewService;
use App\Services\TemplateService;
use Illuminate\Http\Request;
@@ -23,7 +24,7 @@ class TemplatePreviewController extends Controller
$type = $request->input('type', 'section'); // header, footer, section
// Validate type
if (!in_array($type, ['header', 'footer', 'section'])) {
if (!in_array($type, ['header', 'footer', 'section', 'menu'])) {
$type = 'section';
}
@@ -70,6 +71,7 @@ class TemplatePreviewController extends Controller
'header' => HeaderTemplate::find($id),
'footer' => FooterTemplate::find($id),
'section' => SectionTemplate::find($id),
'menu' => MenuTemplate::find($id),
default => null,
};
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class MenuTemplate extends Model
{
use SoftDeletes;
protected $fillable = [
'title',
'html_content',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
}
+121
View File
@@ -0,0 +1,121 @@
<?php
namespace App\Services;
use App\Models\Page;
use Illuminate\Support\Facades\Cache;
class MenuService
{
/**
* Render menu structure as HTML
*
* @param array|null $menuTemplate Optional menu template data
* @return string Rendered menu HTML
*/
public static function render(?array $menuTemplate = null): string
{
// Get menu items from Pages
$menuItems = Cache::remember('menu_items', 3600, function () {
return Page::with(['children' => function ($query) {
$query->where('status', 'published')
->where('show_in_menu', true)
->orderBy('sort_order', 'asc')
->orderBy('title', 'asc');
}])
->whereNull('parent_id')
->where('status', 'published')
->where('show_in_menu', true)
->orderBy('sort_order', 'asc')
->orderBy('title', 'asc')
->get();
});
// If menu template is provided, use it to wrap the menu
if ($menuTemplate && !empty($menuTemplate['html_content'])) {
$html = $menuTemplate['html_content'];
// Replace {menu} placeholder with rendered menu structure
$renderedMenu = self::buildMenuStructure($menuItems);
$html = str_replace('{menu}', $renderedMenu, $html);
return $html;
}
// Otherwise, return default menu structure
return self::buildMenuStructure($menuItems);
}
/**
* Build menu structure HTML from menu items
*
* @param \Illuminate\Support\Collection $menuItems
* @return string HTML structure
*/
protected static function buildMenuStructure($menuItems): string
{
if ($menuItems->isEmpty()) {
return '';
}
$html = '<ul class="menu">';
foreach ($menuItems as $item) {
$hasChildren = $item->children->isNotEmpty();
$html .= '<li class="menu-item">';
$html .= '<a href="' . route('page.show', $item->slug) . '" class="menu-link">';
$html .= e($item->title);
$html .= '</a>';
if ($hasChildren) {
$html .= self::buildSubmenu($item->children);
}
$html .= '</li>';
}
$html .= '</ul>';
return $html;
}
/**
* Build submenu structure recursively
*
* @param \Illuminate\Support\Collection $children
* @return string Submenu HTML
*/
protected static function buildSubmenu($children): string
{
$html = '<ul class="submenu">';
foreach ($children as $child) {
$hasChildren = $child->children->isNotEmpty();
$html .= '<li class="submenu-item">';
$html .= '<a href="' . route('page.show', $child->slug) . '" class="submenu-link">';
$html .= e($child->title);
$html .= '</a>';
if ($hasChildren) {
$html .= self::buildSubmenu($child->children);
}
$html .= '</li>';
}
$html .= '</ul>';
return $html;
}
/**
* Clear menu cache
*/
public static function clearCache(): void
{
Cache::forget('menu_items');
}
}
+1
View File
@@ -214,6 +214,7 @@ class TemplatePreviewService
$bodyContent = match($type) {
'header' => $content,
'footer' => $content,
'menu' => $content,
'section' => '<div class="container py-8">' . $content . '</div>',
default => $content,
};
+7
View File
@@ -267,9 +267,16 @@ class TemplateService
/**
* Replace placeholders in HTML with actual data
* Supports both {text.title} and {{text.title}} formats
* Also supports {menu} placeholder for menu rendering
*/
public static function replacePlaceholders(string $html, array $data): string
{
// Handle special {menu} placeholder first
if (str_contains($html, '{menu}')) {
$renderedMenu = \App\Services\MenuService::render();
$html = str_replace('{menu}', $renderedMenu, $html);
}
// First, parse all placeholders in the format {type.field_name}
preg_match_all('/\{([a-z]+\.[a-z_]+)\}/i', $html, $matches);
$placeholders = array_unique($matches[1] ?? []);
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('menu_templates', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->longText('html_content');
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('menu_templates');
}
};
+13
View File
@@ -35,6 +35,15 @@ Bu sistem, sayfalara dinamik header, section ve footer şablonları ekleyerek i
- `is_active` (boolean) - Aktif/Pasif
- `created_at`, `updated_at`, `deleted_at`
#### 1.4 Menu Template
- **Tablo:** `menu_templates`
- **Alanlar:**
- `id` (bigint, PK)
- `title` (string, 255) - Şablon başlığı
- `html_content` (longtext) - HTML içerik + {menu} placeholder ✅ **4GB kapasiteli**
- `is_active` (boolean) - Aktif/Pasif
- `created_at`, `updated_at`, `deleted_at`
**Not:** `longtext` veri tipi MySQL'de **4,294,967,295 karakter** (4GB) kapasiteye sahiptir. Bu, en karmaşık HTML template'ler ve zengin içerikler için fazlasıyla yeterlidir.
### 2. Placeholder Sistemi
@@ -44,6 +53,10 @@ Bu sistem, sayfalara dinamik header, section ve footer şablonları ekleyerek i
{form_type.field_name}
```
**Özel Placeholder'lar:**
- `{menu}` → Menü yapısını render eder (MenuTemplate için kullanılır)
- Diğer tüm template tiplerinde (header, footer, section) normal placeholder'lar kullanılır
**Desteklenen Form Tipleri:**
#### Text Input Variants:
+42
View File
@@ -0,0 +1,42 @@
<?php
return [
// Navigation
'navigation_group' => 'Templates',
'navigation_label' => 'Menu Templates',
'model_label' => 'Menu Template',
'plural_model_label' => 'Menu Templates',
// Sections
'section_general' => 'General Information',
'section_structure' => 'Menu Structure',
'section_structure_desc' => 'You can edit the menu structure using drag-and-drop or write code in JSON format.',
// Form Fields
'field_title' => 'Title',
'field_html_content' => 'Menu HTML Code',
'field_html_content_help' => 'You can add the menu structure to the HTML using the {menu} placeholder. The menu will be automatically generated with page-based rendering.',
'field_is_active' => 'Active',
// Table Columns
'column_title' => 'Title',
'column_is_active' => 'Active',
'column_created_at' => 'Created At',
'column_updated_at' => 'Updated At',
'column_deleted_at' => 'Deleted At',
// Actions
'create' => 'New Menu Template',
'edit' => 'Edit Menu Template',
'view' => 'View Menu Template',
'delete' => 'Delete',
'restore' => 'Restore',
'force_delete' => 'Force Delete',
// Messages
'created_successfully' => 'Menu template created successfully.',
'updated_successfully' => 'Menu template updated successfully.',
'deleted_successfully' => 'Menu template deleted successfully.',
'restored_successfully' => 'Menu template restored successfully.',
];
+42
View File
@@ -0,0 +1,42 @@
<?php
return [
// Navigation
'navigation_group' => 'Şablonlar',
'navigation_label' => 'Menü Şablonları',
'model_label' => 'Menü Şablonu',
'plural_model_label' => 'Menü Şablonları',
// Sections
'section_general' => 'Genel Bilgiler',
'section_structure' => 'Menü Yapısı',
'section_structure_desc' => 'Menü yapısını sürükle-bırak ile düzenleyebilir veya JSON formatında kod olarak yazabilirsiniz.',
// Form Fields
'field_title' => 'Başlık',
'field_html_content' => 'Menü HTML Kodu',
'field_html_content_help' => 'Menü yapısını {menu} placeholder\'ı ile HTML içine ekleyebilirsiniz. Menü otomatik olarak sayfalar tabanlı rendering ile oluşturulur.',
'field_is_active' => 'Aktif',
// Table Columns
'column_title' => 'Başlık',
'column_is_active' => 'Aktif',
'column_created_at' => 'Oluşturulma',
'column_updated_at' => 'Güncellenme',
'column_deleted_at' => 'Silinme',
// Actions
'create' => 'Yeni Menü Şablonu',
'edit' => 'Menü Şablonunu Düzenle',
'view' => 'Menü Şablonunu Görüntüle',
'delete' => 'Sil',
'restore' => 'Geri Yükle',
'force_delete' => 'Kalıcı Sil',
// Messages
'created_successfully' => 'Menü şablonu başarıyla oluşturuldu.',
'updated_successfully' => 'Menü şablonu başarıyla güncellendi.',
'deleted_successfully' => 'Menü şablonu başarıyla silindi.',
'restored_successfully' => 'Menü şablonu başarıyla geri yüklendi.',
];
@@ -0,0 +1,173 @@
@php
$previewUrl = route('template.preview');
$type = $type ?? 'section';
$fieldName = $fieldName ?? 'html_content';
$recordId = $recordId ?? null;
@endphp
<div
x-data="templatePreview(@js($previewUrl), @js($type), @js($fieldName), @js($recordId))"
class="template-preview-wrapper w-full"
style="width: 100%;"
wire:ignore.self>
<div class="fi-fo-field-wrp-label">
<label class="fi-fo-field-wrp-label-label text-sm font-medium text-gray-700 dark:text-gray-300">
{{ __('Preview') }}
</label>
</div>
<div class="fi-section-content">
<button
@click="updatePreview()"
:disabled="isLoading"
class="fi-btn fi-btn-color-primary fi-btn-size-sm inline-flex items-center justify-center rounded-lg font-semibold outline-none transition duration-75 focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-70 bg-primary-600 text-white hover:bg-primary-500 dark:bg-primary-500 dark:hover:bg-primary-400 p-2"
type="button"
title="Önizlemeyi Çalıştır">
<svg class="fi-icon fi-size-lg fi-sidebar-item-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" data-slot="icon">
<!-- Play icon (Heroicon solid play) -->
<path fill-rule="evenodd" d="M5.25 4.5v15a1.125 1.125 0 0 0 1.666.974l12-7.5a1.125 1.125 0 0 0 0-1.948l-12-7.5A1.125 1.125 0 0 0 5.25 4.5zm1.875 1.99 10.178 6.36-10.179 6.36V6.491z" clip-rule="evenodd"></path>
</svg>
</button>
</div>
<div class="border border-gray-300 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-800"
style="min-height: 400px; position: relative; width: 100%;margin-top: 10px;">
<!-- Loading indicator with modern preloader -->
<div
x-show="isLoading || !iframeSrc"
class="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800"
style="z-index: 10;"
x-transition
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0">
<div class="text-center px-4">
<!-- Modern animated loader -->
<div class="relative inline-block mb-4" x-show="isLoading">
<div class="w-16 h-16 border-4 border-gray-200 dark:border-gray-700 rounded-full animate-pulse"></div>
<div class="absolute top-0 left-0 w-16 h-16 border-4 border-transparent border-t-primary-600 dark:border-t-primary-400 rounded-full animate-spin"></div>
</div>
</div>
</div>
<!-- Preview iframe -->
<iframe
x-show="!isLoading && iframeSrc"
:src="iframeSrc"
class="w-full border-0"
style="min-height: 400px; width: 100%; display: block;"
frameborder="0"
loading="lazy"
x-transition>
</iframe>
</div>
</div>
@push('scripts')
<script>
function templatePreview(previewUrl, type, fieldName, recordIdFromView = null) {
return {
previewUrl: previewUrl,
type: type,
fieldName: fieldName,
iframeSrc: '',
isLoading: false,
recordId: recordIdFromView,
init() {
// Önce viewData'dan gelen ID'yi kullan
if (this.recordId) {
return;
}
// Yoksa URL'den ID'yi al (çeşitli formatları dene)
let urlMatch = window.location.pathname.match(/\/(\d+)\/(edit|view)$/);
if (urlMatch && urlMatch[1]) {
this.recordId = urlMatch[1];
return;
}
// Alternatif format: /admin/resource/{id}/edit
urlMatch = window.location.pathname.match(/\/(\d+)\/edit$/);
if (urlMatch && urlMatch[1]) {
this.recordId = urlMatch[1];
return;
}
// Alternatif format: /admin/resource/{id}
urlMatch = window.location.pathname.match(/\/(\d+)$/);
if (urlMatch && urlMatch[1]) {
this.recordId = urlMatch[1];
return;
}
// Filament Livewire component'inden ID almayı dene
try {
const livewireComponent = window.Livewire?.find(
document.querySelector('[wire\\:id]')?.getAttribute('wire:id')
);
if (livewireComponent?.mounted?.id) {
this.recordId = livewireComponent.mounted.id;
}
} catch (e) {
console.log('[Preview] Could not get ID from Livewire');
}
},
async updatePreview() {
// ID yoksa preview yapma
if (!this.recordId) {
console.log('[Preview] No record ID');
return;
}
this.isLoading = true;
try {
const formData = new FormData();
formData.append('record_id', this.recordId);
formData.append('type', this.type);
formData.append('_token', document.querySelector('meta[name="csrf-token"]')?.content || '');
const response = await fetch(this.previewUrl, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/html',
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const html = await response.text();
if (this.iframeSrc) URL.revokeObjectURL(this.iframeSrc);
const blob = new Blob([html], { type: 'text/html; charset=utf-8' });
this.iframeSrc = URL.createObjectURL(blob);
this.isLoading = false;
} catch (error) {
console.error('[Preview] Error:', error);
this.isLoading = false;
}
}
};
}
</script>
@endpush
@push('styles')
<style>
[x-cloak] {
display: none !important;
}
</style>
@endpush