- Added standard Laravel directory structure and configuration. - Included Svelte and Tailwind configuration for the admin interface. - Added core PHPUnit and testing scripts.
473 lines
21 KiB
Svelte
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>
|