Refactor template preview components: Moved preview component placement after the CodeEditor in footer, header, and section template forms for improved user experience. Enhanced loading indicators in the template preview Blade view for better feedback during content processing.

This commit is contained in:
Ümit Tunç
2025-10-31 17:01:27 -03:00
parent 52c4ba1b19
commit 8d6d0b52b9
4 changed files with 294 additions and 194 deletions
@@ -23,14 +23,6 @@ class FooterTemplateForm
->maxLength(255)
->columnSpanFull(),
// Preview Component
View::make('components.template-preview')
->extraAttributes([
'data-type' => 'footer',
'data-field-name' => 'html_content',
])
->columnSpanFull(),
CodeEditor::make('html_content')
->label(__('footer-templates.field_html_content'))
->required()
@@ -42,6 +34,14 @@ class FooterTemplateForm
'data-field-name' => 'html_content',
]),
// Preview Component - placed after editor
View::make('components.template-preview')
->extraAttributes([
'data-type' => 'footer',
'data-field-name' => 'html_content',
])
->columnSpanFull(),
Toggle::make('is_active')
->label(__('footer-templates.field_is_active'))
->default(true)
@@ -23,14 +23,6 @@ class HeaderTemplateForm
->maxLength(255)
->columnSpanFull(),
// Preview Component
View::make('components.template-preview')
->extraAttributes([
'data-type' => 'header',
'data-field-name' => 'html_content',
])
->columnSpanFull(),
CodeEditor::make('html_content')
->label(__('header-templates.field_html_content'))
->required()
@@ -42,6 +34,14 @@ class HeaderTemplateForm
'data-field-name' => 'html_content',
]),
// Preview Component - placed after editor
View::make('components.template-preview')
->extraAttributes([
'data-type' => 'header',
'data-field-name' => 'html_content',
])
->columnSpanFull(),
Toggle::make('is_active')
->label(__('header-templates.field_is_active'))
->default(true)
@@ -23,14 +23,6 @@ class SectionTemplateForm
->maxLength(255)
->columnSpanFull(),
// Preview Component
View::make('components.template-preview')
->extraAttributes([
'data-type' => 'section',
'data-field-name' => 'html_content',
])
->columnSpanFull(),
CodeEditor::make('html_content')
->label(__('section-templates.field_html_content'))
->required()
@@ -42,6 +34,14 @@ class SectionTemplateForm
'data-field-name' => 'html_content',
]),
// Preview Component - placed after editor
View::make('components.template-preview')
->extraAttributes([
'data-type' => 'section',
'data-field-name' => 'html_content',
])
->columnSpanFull(),
Toggle::make('is_active')
->label(__('section-templates.field_is_active'))
->default(true)
@@ -1,6 +1,5 @@
@php
$previewUrl = route('template.preview');
// Get type and fieldName from component attributes or defaults
$type = $attributes->get('data-type') ?? 'section';
$fieldName = $attributes->get('data-field-name') ?? 'html_content';
@endphp
@@ -9,7 +8,7 @@
x-data="templatePreview(@js($previewUrl), @js($type), @js($fieldName))"
class="template-preview-wrapper w-full"
style="width: 100%;"
wire:ignore>
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') }}
@@ -18,21 +17,34 @@
<div class="mt-2 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%;">
<!-- Loading indicator -->
<!-- Loading indicator with modern preloader -->
<div
x-show="isLoading || !iframeSrc"
class="absolute inset-0 flex items-center justify-center bg-gray-50 dark:bg-gray-900"
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>
<div class="text-center">
<svg class="animate-spin h-8 w-8 text-gray-400 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
<span x-show="isLoading">Yükleniyor...</span>
<span x-show="!isLoading && !iframeSrc">Önizleme için içerik girin</span>
</p>
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">
<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 class="space-y-2">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 animate-pulse">
<span x-show="isLoading">Yükleniyor...</span>
<span x-show="!isLoading && !iframeSrc">Önizleme hazırlanıyor</span>
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
<span x-show="isLoading">İçerik işleniyor</span>
<span x-show="!isLoading && !iframeSrc">Önizleme için HTML içeriği girin</span>
</p>
</div>
</div>
</div>
@@ -64,176 +76,256 @@ function templatePreview(previewUrl, type, fieldName) {
isLoading: false,
content: '',
debounceTimer: null,
intervalId: null,
codeMirrorView: null,
init() {
// Watch Livewire events for Filament form updates
if (window.Livewire) {
// Listen for form state updates
Livewire.on('filament-forms::update-state', ({ statePath, state }) => {
if (statePath === this.fieldName || statePath?.endsWith(this.fieldName)) {
this.content = state || '';
this.debouncedUpdate();
}
});
// Listen for any Livewire updates
document.addEventListener('livewire:update', () => {
this.debouncedUpdate();
});
}
console.log('[Preview] Init:', { previewUrl, type, fieldName });
// Watch CodeMirror editor directly
this.watchCodeMirror();
// Watch for direct input changes (fallback for code editor)
const observer = new MutationObserver(() => {
this.debouncedUpdate();
// Wait for everything to load
this.$nextTick(() => {
setTimeout(() => {
this.findCodeMirror();
this.setupWatchers();
this.updatePreview();
}, 1500);
});
// Observe form changes
const form = document.querySelector('form[wire\\:submit]');
if (form) {
observer.observe(form, {
childList: true,
subtree: true,
characterData: true,
});
}
// Initial update after a short delay
setTimeout(() => {
this.updatePreview();
}, 1000);
// Periodic update fallback (every 3 seconds)
this.intervalId = setInterval(() => {
this.updatePreview();
}, 3000);
},
watchCodeMirror() {
// Try to find CodeMirror editor instance
const findCodeMirror = () => {
// Look for CodeMirror editor instances
const editors = document.querySelectorAll('.cm-editor, [data-code-editor]');
editors.forEach(editor => {
// Check if this is our field
const fieldWrapper = editor.closest('[data-field-name], .fi-fo-field-wrp');
if (fieldWrapper) {
const fieldLabel = fieldWrapper.querySelector('label');
if (fieldLabel && fieldLabel.textContent.includes('HTML Content')) {
// Found our editor, try to get CodeMirror instance
const cmInstance = editor.__cm || editor.cm || editor;
if (cmInstance && cmInstance.getValue) {
// Watch for changes
if (cmInstance.on) {
cmInstance.on('change', () => {
this.content = cmInstance.getValue();
this.debouncedUpdate();
});
findCodeMirror() {
console.log('[Preview] Looking for CodeMirror editor for field:', this.fieldName);
// Try multiple wrapper selectors for Filament 4 compatibility
const wrapperSelectors = [
'.fi-fo-field-wrp',
'.fi-input-wrp',
'[wire\\:id]',
];
// Debug: List all field wrappers
let allWrappers = [];
wrapperSelectors.forEach(selector => {
const wrappers = document.querySelectorAll(selector);
if (wrappers.length > 0) {
console.log(`[Preview] Found ${wrappers.length} wrappers with selector: ${selector}`);
allWrappers = Array.from(wrappers);
}
});
// Remove duplicates
allWrappers = Array.from(new Set(allWrappers));
console.log('[Preview] Total unique field wrappers found:', allWrappers.length);
let targetWrapper = null;
// Method 1: Find by data-field-name attribute (anywhere in wrapper)
const dataFieldElements = document.querySelectorAll(`[data-field-name="${this.fieldName}"]`);
if (dataFieldElements.length > 0) {
console.log('[Preview] Found', dataFieldElements.length, 'elements with data-field-name');
// Try to find closest wrapper from any selector
for (const selector of wrapperSelectors) {
targetWrapper = dataFieldElements[0].closest(selector);
if (targetWrapper) break;
}
}
// Method 2: Find by name attribute in textarea or input
if (!targetWrapper) {
const input = document.querySelector(`textarea[name="${this.fieldName}"], input[name="${this.fieldName}"]`);
if (input) {
console.log('[Preview] Found input/textarea with name:', this.fieldName);
for (const selector of wrapperSelectors) {
targetWrapper = input.closest(selector);
if (targetWrapper) break;
}
}
}
// Method 3: Find all CodeMirror editors and check their wrappers
if (!targetWrapper) {
const cmEditors = document.querySelectorAll('.cm-editor');
console.log('[Preview] Found', cmEditors.length, 'CodeMirror editors');
cmEditors.forEach((cmEditor, idx) => {
for (const selector of wrapperSelectors) {
const wrapper = cmEditor.closest(selector);
if (wrapper) {
const label = wrapper.querySelector('label');
console.log(`[Preview] CM Editor ${idx} wrapper label:`, label?.textContent?.substring(0, 30));
// If this is the only CodeMirror editor, use it
// Or if label contains HTML/Content
if (cmEditors.length === 1 || (label && (label.textContent.toLowerCase().includes('html') || label.textContent.toLowerCase().includes('content')))) {
if (!targetWrapper) {
targetWrapper = wrapper;
console.log('[Preview] Selected wrapper for CM Editor', idx);
}
}
break;
}
}
});
}
// Method 4: Find by label text (fallback)
if (!targetWrapper && allWrappers.length > 0) {
allWrappers.forEach((wrapper) => {
const label = wrapper.querySelector('label');
if (label) {
const labelText = label.textContent.toLowerCase();
if ((labelText.includes('html') || labelText.includes('content')) && !targetWrapper) {
const hasEditor = wrapper.querySelector('.cm-editor, textarea');
if (hasEditor) {
targetWrapper = wrapper;
console.log('[Preview] Selected wrapper by label:', labelText.substring(0, 30));
}
}
}
});
};
}
// Try immediately and after a delay
findCodeMirror();
setTimeout(findCodeMirror, 1000);
setTimeout(findCodeMirror, 2000);
if (!targetWrapper) {
console.warn('[Preview] Field wrapper not found. Available wrappers:', allWrappers.length);
console.warn('[Preview] Trying to find any CodeMirror editor...');
// Last resort: Use first CodeMirror editor found
const firstCmEditor = document.querySelector('.cm-editor');
if (firstCmEditor) {
for (const selector of wrapperSelectors) {
targetWrapper = firstCmEditor.closest(selector);
if (targetWrapper) break;
}
console.log('[Preview] Using first CodeMirror editor as fallback');
}
}
if (!targetWrapper) {
console.error('[Preview] Could not find field wrapper. Make sure the CodeEditor is rendered.');
return;
}
console.log('[Preview] Found field wrapper');
// Try to find CodeMirror editor
const cmEditor = targetWrapper.querySelector('.cm-editor');
if (cmEditor) {
console.log('[Preview] Found .cm-editor element');
// Try to get from cm-content (CodeMirror 6)
const cmContent = cmEditor.querySelector('.cm-content');
if (cmContent) {
console.log('[Preview] Found .cm-content element');
// Watch for changes
const observer = new MutationObserver(() => {
const newContent = cmContent.textContent || '';
if (newContent !== this.content) {
this.content = newContent;
console.log('[Preview] Content changed, length:', this.content.length);
this.debouncedUpdate();
}
});
observer.observe(cmContent, {
childList: true,
subtree: true,
characterData: true,
});
// Get initial content
this.content = cmContent.textContent || '';
console.log('[Preview] Initial content from .cm-content, length:', this.content.length);
if (this.content) {
return; // Success!
}
}
// Try CodeMirror 6 View
if (cmEditor.view || cmEditor._view) {
const view = cmEditor.view || cmEditor._view;
if (view.state && view.state.doc) {
this.content = view.state.doc.toString();
console.log('[Preview] Found CodeMirror 6 View, content length:', this.content.length);
if (view.dispatch) {
// Watch for changes
const originalDispatch = view.dispatch;
view.dispatch = (tr) => {
originalDispatch.call(view, tr);
this.content = view.state.doc.toString();
this.debouncedUpdate();
};
}
if (this.content) {
return; // Success!
}
}
}
// Try CodeMirror 5
const cmInstance = cmEditor.__cm || cmEditor.cm;
if (cmInstance) {
console.log('[Preview] Found CodeMirror 5 instance');
if (cmInstance.getValue) {
this.content = cmInstance.getValue();
cmInstance.on('change', () => {
this.content = cmInstance.getValue();
this.debouncedUpdate();
});
console.log('[Preview] CodeMirror 5 content length:', this.content.length);
if (this.content) {
return; // Success!
}
}
}
} else {
// Try textarea
const textarea = targetWrapper.querySelector('textarea');
if (textarea) {
console.log('[Preview] Found textarea');
this.content = textarea.value || '';
textarea.addEventListener('input', () => {
this.content = textarea.value;
this.debouncedUpdate();
});
console.log('[Preview] Textarea content length:', this.content.length);
} else {
console.warn('[Preview] No CodeMirror editor or textarea found in wrapper');
}
}
},
setupWatchers() {
// Watch for Livewire updates
if (window.Livewire) {
document.addEventListener('livewire:update', () => {
this.findCodeMirror();
this.debouncedUpdate();
});
}
// Periodic update
this.intervalId = setInterval(() => {
this.findCodeMirror();
this.updatePreview();
}, 3000);
},
debouncedUpdate() {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
this.updatePreview();
}, 500); // 500ms debounce
}, 800);
},
async updatePreview() {
// Get content from Livewire - try different methods
let content = this.content || '';
try {
// Method 1: Try CodeMirror editor directly (most reliable for CodeEditor)
// Find editor by data-field-name attribute
const fieldWrapper = document.querySelector(`[data-field-name="${this.fieldName}"]`);
if (fieldWrapper) {
const cmEditor = fieldWrapper.querySelector('.cm-editor');
if (cmEditor) {
const cmInstance = cmEditor.__cm || cmEditor.cm || window.CodeMirror?.fromTextArea?.(cmEditor.querySelector('textarea'));
if (cmInstance && typeof cmInstance.getValue === 'function') {
content = cmInstance.getValue() || '';
} else if (cmEditor.querySelector('.cm-content')) {
// Fallback: get text from content div
content = cmEditor.querySelector('.cm-content').textContent || '';
}
}
}
// Method 1b: Try all CodeMirror editors if specific one not found
if (!content) {
const cmEditors = document.querySelectorAll('.cm-editor');
for (const cmEditor of cmEditors) {
const fieldWrapper = cmEditor.closest('[data-field-name]');
if (fieldWrapper && fieldWrapper.getAttribute('data-field-name') === this.fieldName) {
const cmInstance = cmEditor.__cm || cmEditor.cm;
if (cmInstance && typeof cmInstance.getValue === 'function') {
content = cmInstance.getValue() || '';
if (content) break;
} else if (cmEditor.querySelector('.cm-content')) {
content = cmEditor.querySelector('.cm-content').textContent || '';
if (content) break;
}
}
}
}
// Method 2: Try to find CodeEditor textarea/input directly
if (!content) {
const codeEditor = document.querySelector(`textarea[name="${this.fieldName}"],
textarea[wire\\:model*="${this.fieldName}"],
.fi-fo-field-wrp textarea,
.cm-content`);
if (codeEditor) {
content = codeEditor.value || codeEditor.textContent || '';
}
}
// Method 3: Try Livewire $wire
if (!content && window.Livewire) {
// Find the Livewire component
const livewireComponent = document.querySelector('[wire\\:id], form[wire\\:submit]');
if (livewireComponent) {
const wireId = livewireComponent.getAttribute('wire:id');
if (wireId) {
const livewire = window.Livewire.find(wireId);
if (livewire && livewire.get) {
content = livewire.get(this.fieldName) || '';
}
} else {
// Try Alpine.js $wire
const alpineData = Alpine.$data(livewireComponent);
if (alpineData && alpineData.$wire && alpineData.$wire.get) {
content = alpineData.$wire.get(this.fieldName) || '';
}
}
}
}
} catch (e) {
console.warn('Could not get content:', e);
// Try to refresh content
if (!this.content) {
this.findCodeMirror();
}
// Use existing content if new fetch failed
if (!content && this.content) {
content = this.content;
}
this.content = content;
if (!this.content || this.content.trim() === '') {
if (!this.content || !this.content.trim()) {
console.log('[Preview] No content, skipping update');
if (this.iframeSrc) {
URL.revokeObjectURL(this.iframeSrc);
this.iframeSrc = '';
@@ -244,13 +336,11 @@ function templatePreview(previewUrl, type, fieldName) {
this.isLoading = true;
try {
// Create form data
const formData = new FormData();
formData.append('content', this.content);
formData.append('type', this.type);
formData.append('_token', document.querySelector('meta[name="csrf-token"]')?.content || '');
// Fetch preview HTML
const response = await fetch(this.previewUrl, {
method: 'POST',
body: formData,
@@ -261,28 +351,38 @@ function templatePreview(previewUrl, type, fieldName) {
});
if (!response.ok) {
throw new Error('Preview request failed');
throw new Error(`HTTP ${response.status}`);
}
const html = await response.text();
// Create blob URL for iframe
const blob = new Blob([html], { type: 'text/html; charset=utf-8' });
// Revoke old URL if exists
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;
console.log('[Preview] Updated successfully, content length:', this.content.length);
} catch (error) {
console.error('Preview error:', error);
console.error('[Preview] Error:', error);
this.isLoading = false;
}
},
destroy() {
if (this.intervalId) {
clearInterval(this.intervalId);
}
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
if (this.iframeSrc) {
URL.revokeObjectURL(this.iframeSrc);
}
}
};
}
</script>
@endpush