cms/resources/js/components/admin/PageEditor.svelte
Funky Waddle 37ed997989 feat(cms): initialize Laravel project structure and core CMS files
- Added standard Laravel directory structure and configuration.

- Included Svelte and Tailwind configuration for the admin interface.

- Added core PHPUnit and testing scripts.
2026-04-13 12:48:06 -05:00

473 lines
21 KiB
Svelte

<script>
import MediaManager from './MediaManager.svelte';
import NestedBlockEditor from './NestedBlockEditor.svelte';
console.log("Loading Page")
let { page = null, adminPath = 'loom', status = null, errors = [], permissions = {}, availableLocales = ['en'], defaultLocale = 'en', a11yIssues = [] } = $props();
let title = $state('');
let slug = $state('');
let metaDescription = $state('');
let isPublished = $state(true);
let includeInNavigation = $state(false);
// Multi-locale state
let activeLocale = $state(null);
$effect(() => {
if (activeLocale === null && defaultLocale) {
activeLocale = defaultLocale;
}
});
let localizedContent = $state((() => {
console.log("[PageEditor] Initializing Localized Content State");
const initialContent = {};
const locales = availableLocales || ['en'];
locales.forEach(l => {
initialContent[l] = [{ type: 'paragraph', data: { text: '' } }];
});
if (page && page.content) {
console.log("[PageEditor] Page content found:", page.content);
let pageContent = page.content;
// Handle potential double JSON encoding or string format
if (typeof pageContent === 'string' && (pageContent.startsWith('[') || pageContent.startsWith('{'))) {
try {
pageContent = JSON.parse(pageContent);
} catch (e) {
console.error("[PageEditor] Failed to parse page content string:", e);
}
}
if (pageContent && typeof pageContent === 'object' && !Array.isArray(pageContent)) {
// Multi-locale format detected by looking for locale keys
const keys = Object.keys(pageContent);
const isMultiLocale = keys.some(k => locales.includes(k));
if (isMultiLocale) {
console.log("[PageEditor] Multi-locale content detected");
return { ...initialContent, ...pageContent };
}
} else if (Array.isArray(pageContent)) {
// Legacy single-locale format (array of blocks)
console.log("[PageEditor] Single-locale (legacy) content detected");
initialContent[defaultLocale || 'en'] = pageContent;
return initialContent;
}
}
console.log("[PageEditor] Initial Content Result:", initialContent);
return initialContent;
})());
$effect(() => {
if (page) {
title = page.title || '';
slug = page.slug || '';
metaDescription = page.meta_description || '';
isPublished = page.is_published !== false;
// Only update from page if it actually contains the navigation property,
// otherwise prefer the passed prop value.
includeInNavigation = page.include_navigation !== undefined ? page.include_navigation :
(page.include_in_navigation !== undefined ? page.include_in_navigation : includeInNavigation);
}
});
// Use a reactive block reference to avoid binding to derived state
let activeBlocks = $derived.by(() => {
if (!activeLocale) return [{ type: 'paragraph', data: { text: '' } }];
// Ensure the locale exists in localizedContent to prevent undefined errors
if (!localizedContent[activeLocale]) {
localizedContent[activeLocale] = [{ type: 'paragraph', data: { text: '' } }];
}
return localizedContent[activeLocale];
});
let currentA11yIssues = $derived.by(() => {
if (!a11yIssues) return [];
if (Array.isArray(a11yIssues)) {
// Non-multi-locale format
return a11yIssues;
}
// Multi-locale format: a11yIssues is an object keyed by locale
return (activeLocale && a11yIssues[activeLocale]) ? a11yIssues[activeLocale] : [];
});
function getBlockIssues(index) {
return currentA11yIssues.filter(issue => issue.block_index === index);
}
function updateBlockText(index, text) {
if (!localizedContent[activeLocale]) {
localizedContent[activeLocale] = [{ type: 'paragraph', data: { text: '' } }];
}
if (localizedContent[activeLocale][index]) {
localizedContent[activeLocale][index].data.text = text;
localizedContent = { ...localizedContent };
}
}
// Media Picker state
let showMediaPicker = $state(false);
let pickingForIndex = $state(null);
function dropdown(node, initialValue) {
if (typeof window.jQuery !== 'undefined' && typeof window.jQuery.fn.dropdown === 'function') {
const el = window.jQuery(node);
// Initialize the dropdown
el.dropdown();
// Sync the initial value
if (initialValue !== null && typeof initialValue !== 'undefined') {
console.log("[PageEditor] Action: Syncing dropdown to initial value:", initialValue);
el.dropdown('set selected', initialValue.toString());
}
return {
update(newValue) {
if (newValue !== null && typeof newValue !== 'undefined') {
console.log("[PageEditor] Action: Updating dropdown value:", newValue);
el.dropdown('set selected', newValue.toString());
}
},
destroy() {
el.dropdown('destroy');
}
};
}
}
function addBlock(type) {
let data = { text: '' };
if (type === 'heading') {
data.level = 2; // Default to H2
}
if (type === 'media') {
data = {
media_id: null,
filename: null,
text: '',
w: 1200,
h: null,
fit: 'contain',
q: 85,
'fp-x': 0.5,
'fp-y': 0.5,
showAdvanced: false
};
pickingForIndex = (localizedContent[activeLocale] || []).length;
showMediaPicker = true;
}
if (type === 'columns') {
data = { columns: [{ blocks: [] }, { blocks: [] }] };
}
if (type === 'grid') {
data = { cols: 3, items: [{ blocks: [] }, { blocks: [] }, { blocks: [] }] };
}
if (!localizedContent[activeLocale]) {
localizedContent[activeLocale] = [];
}
localizedContent[activeLocale] = [...localizedContent[activeLocale], { type, data }];
localizedContent = { ...localizedContent };
}
function openMediaPicker(index) {
pickingForIndex = index;
showMediaPicker = true;
}
function handleMediaSelect(file) {
if (pickingForIndex !== null) {
localizedContent[activeLocale][pickingForIndex].data.media_id = file.id;
localizedContent[activeLocale][pickingForIndex].data.filename = file.filename;
// Set default focal point if not already set or newly picked
if (localizedContent[activeLocale][pickingForIndex].data['fp-x'] === undefined) {
localizedContent[activeLocale][pickingForIndex].data['fp-x'] = 0.5;
localizedContent[activeLocale][pickingForIndex].data['fp-y'] = 0.5;
}
pickingForIndex = null;
}
showMediaPicker = false;
}
function setFocalPoint(index, event) {
const rect = event.target.getBoundingClientRect();
const x = (event.clientX - rect.left) / rect.width;
const y = (event.clientY - rect.top) / rect.height;
localizedContent[activeLocale][index].data['fp-x'] = parseFloat(x.toFixed(3));
localizedContent[activeLocale][index].data['fp-y'] = parseFloat(y.toFixed(3));
localizedContent = { ...localizedContent };
}
function handleKeydown(event, action) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
action();
}
}
function removeBlock(index) {
if (localizedContent[activeLocale]) {
localizedContent[activeLocale] = localizedContent[activeLocale].filter((_, i) => i !== index);
localizedContent = { ...localizedContent };
}
}
function moveBlock(index, direction) {
if (!localizedContent[activeLocale]) return;
const blocks = [...localizedContent[activeLocale]];
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= blocks.length) return;
const [movedBlock] = blocks.splice(index, 1);
blocks.splice(newIndex, 0, movedBlock);
localizedContent[activeLocale] = blocks;
localizedContent = { ...localizedContent };
}
async function translateBlock(index) {
const from = 'en';
const to = activeLocale;
if (from === to) {
alert('Cannot translate to the same language. Switch to another language first.');
return;
}
const sourceBlock = localizedContent[from][index];
if (!sourceBlock || !sourceBlock.data || !sourceBlock.data.text) {
alert('Source content (English) not found or empty.');
return;
}
try {
const response = await fetch(`${adminPath}/translate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
text: sourceBlock.data.text,
from: from,
to: to
})
});
if (response.ok) {
const result = await response.json();
// Maintain non-translatable data (like media_id, level)
const currentBlock = localizedContent[activeLocale][index];
localizedContent[activeLocale][index] = {
...currentBlock,
data: {
...currentBlock.data,
...sourceBlock.data, // Copy all fields from source block (to get filename, media_id, level)
text: result.translated // But use translated text
}
};
} else {
alert('Translation failed');
}
} catch (err) {
console.error(err);
alert('An error occurred during translation');
}
}
function handleInput(e) {
if (e.target.name === 'title' && !page) {
slug = e.target.value.toLowerCase().replace(/ /g, '-').replace(/[^\w-]+/g, '');
}
}
</script>
<div class="ui container">
<div class="ui grid">
<div class="sixteen wide column">
<h1 class="ui header">
<i class="edit icon"></i>
<div class="content">
{page ? 'Edit Page' : 'Create New Page'}
<div class="sub header">{page ? `Editing: ${page.title}` : 'Fill in the details below to create a new page'}</div>
</div>
</h1>
</div>
<div class="sixteen wide column">
{#if status}
<div class="ui positive message">
<i class="close icon"></i>
<p>{status}</p>
</div>
{/if}
{#if errors.length > 0}
<div class="ui negative message">
<i class="close icon"></i>
<ul class="list">
{#each errors as error}
<li>{error}</li>
{/each}
</ul>
</div>
{/if}
<form class="ui form" method="POST" action="{window.location.origin.endsWith('/') ? window.location.origin : window.location.origin + '/'}{adminPath.startsWith('/') ? adminPath.substring(1) : adminPath}/pages{page ? '/' + page.id : ''}">
<input type="hidden" name="_token" value={document.querySelector('meta[name="csrf-token"]').getAttribute('content')}>
{#if page}
<input type="hidden" name="_method" value="PUT">
{/if}
<div class="two fields">
<div class="field required">
<label for="page-title">Title</label>
<input type="text" id="page-title" name="title" bind:value={title} oninput={handleInput} placeholder="Page Title" required>
</div>
<div class="field required">
<label for="page-slug">Slug</label>
<div class="ui labeled input">
<div class="ui label">/</div>
<input type="text" id="page-slug" name="slug" bind:value={slug} placeholder="page-slug" required>
</div>
</div>
</div>
<div class="field">
<label for="page-meta">Meta Description</label>
<textarea id="page-meta" name="meta_description" bind:value={metaDescription} rows="2" placeholder="Brief description for SEO"></textarea>
</div>
<div class="ui segment">
<div class="ui menu secondary pointing">
<div class="header item">Content Language:</div>
{#each availableLocales as loc}
<button type="button"
class="item {activeLocale === loc ? 'active' : ''}"
onclick={() => activeLocale = loc}>
{(loc || '').toUpperCase()}
</button>
{/each}
</div>
<h3 class="ui dividing header">Content Blocks ({(activeLocale || '').toUpperCase()})</h3>
{#if localizedContent && activeLocale && localizedContent[activeLocale]}
<NestedBlockEditor
bind:blocks={localizedContent[activeLocale]}
{activeLocale}
onRemoveBlock={removeBlock}
onMoveBlock={moveBlock}
onOpenMediaPicker={openMediaPicker}
onUpdateBlockText={updateBlockText}
{setFocalPoint}
{currentA11yIssues}
onTranslateBlock={translateBlock}
/>
{/if}
{#each availableLocales as loc}
{#if loc !== activeLocale}
{#each localizedContent[loc] as block, index}
<input type="hidden" name="content[{loc}][{index}][type]" value={block.type}>
{#if block.type === 'heading'}
<input type="hidden" name="content[{loc}][{index}][data][level]" value={block.data.level}>
<input type="hidden" name="content[{loc}][{index}][data][text]" value={block.data.text || ''}>
<input type="hidden" name="content[{loc}][{index}][data][aria_label]" value={block.data.aria_label || ''}>
{/if}
{#if block.type === 'paragraph'}
<input type="hidden" name="content[{loc}][{index}][data][text]" value={block.data.text || ''}>
{/if}
{#if block.type === 'media'}
<input type="hidden" name="content[{loc}][{index}][data][media_id]" value={block.data.media_id || ''}>
<input type="hidden" name="content[{loc}][{index}][data][filename]" value={block.data.filename || ''}>
<input type="hidden" name="content[{loc}][{index}][data][text]" value={block.data.text || ''}>
<input type="hidden" name="content[{loc}][{index}][data][alt]" value={block.data.alt || ''}>
<input type="hidden" name="content[{loc}][{index}][data][w]" value={block.data.w || ''}>
<input type="hidden" name="content[{loc}][{index}][data][h]" value={block.data.h || ''}>
<input type="hidden" name="content[{loc}][{index}][data][q]" value={block.data.q || 85}>
<input type="hidden" name="content[{loc}][{index}][data][fit]" value={block.data.fit || 'contain'}>
<input type="hidden" name="content[{loc}][{index}][data][fp-x]" value={block.data['fp-x'] || 0.5}>
<input type="hidden" name="content[{loc}][{index}][data][fp-y]" value={block.data['fp-y'] || 0.5}>
{/if}
{#if block.type === 'heading'}
<input type="hidden" name="content[{loc}][{index}][data][aria_label]" value={block.data.aria_label || ''}>
{/if}
<input type="hidden" name="content[{loc}][{index}][data][text]" value={block.data.text}>
{/each}
{/if}
{/each}
<div class="ui basic buttons">
<button type="button" class="ui button" onclick={() => addBlock('paragraph')}>
<i class="plus icon"></i> Add Paragraph
</button>
<button type="button" class="ui button" onclick={() => addBlock('heading')}>
<i class="plus icon"></i> Add Heading
</button>
<button type="button" class="ui button" onclick={() => addBlock('media')}>
<i class="image icon"></i> Add Media
</button>
<button type="button" class="ui button" onclick={() => addBlock('columns')}>
<i class="columns icon"></i> Add Columns
</button>
<button type="button" class="ui button" onclick={() => addBlock('grid')}>
<i class="th icon"></i> Add Grid
</button>
</div>
</div>
{#if showMediaPicker}
<div class="ui modal active" style="display: block; position: fixed; top: 5%; left: 10%; width: 80%; height: 90%; z-index: 1001; background: white; overflow-y: auto; box-shadow: 0 0 20px rgba(0,0,0,0.5); border-radius: 8px;">
<div class="header" style="padding: 1rem; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center;">
<h2 class="ui header" style="margin: 0;">Select Media</h2>
<button type="button" class="ui icon button basic" onclick={() => showMediaPicker = false} aria-label="Close Media Picker" title="Close">
<i class="close icon"></i>
</button>
</div>
<div class="content" style="padding: 1rem;">
<MediaManager isPicker={true} onSelect={handleMediaSelect} {permissions} {adminPath} />
</div>
</div>
<div class="ui dimmer page active" style="z-index: 1000;"></div>
{/if}
<div class="field">
<div class="ui checkbox">
<input type="checkbox" id="page-published" name="is_published" bind:checked={isPublished} value="1">
<label for="page-published">Published</label>
</div>
<input type="hidden" name="is_published" value={isPublished ? "1" : "0"}>
</div>
<div class="field">
<div class="ui checkbox">
<input type="checkbox" id="page-navigation" name="include_in_navigation" bind:checked={includeInNavigation} value="1">
<label for="page-navigation">Include in Navigation</label>
</div>
<input type="hidden" name="include_in_navigation" value={includeInNavigation ? "1" : "0"}>
</div>
<div class="ui divider"></div>
<button class="ui primary button" type="submit">
<i class="save icon"></i> {page ? 'Update Page' : 'Create Page'}
</button>
<a href="{window.location.origin.endsWith('/') ? window.location.origin : window.location.origin + '/'}{adminPath.startsWith('/') ? adminPath.substring(1) : adminPath}/pages" class="ui button">Cancel</a>
</form>
</div>
</div>
</div>