cms/resources/js/components/admin/NestedBlockEditor.svelte

355 lines
22 KiB
Svelte
Raw Permalink Normal View History

<script>
import { onMount } from 'svelte';
import NestedBlockEditor from './NestedBlockEditor.svelte';
let {
blocks = $bindable([]),
activeLocale,
onRemoveBlock,
onMoveBlock,
onOpenMediaPicker,
onUpdateBlockText,
onTranslateBlock,
setFocalPoint,
currentA11yIssues = []
} = $props();
function dropdown(node, initialValue) {
if (typeof window.jQuery !== 'undefined' && typeof window.jQuery.fn.dropdown === 'function') {
const el = window.jQuery(node);
el.dropdown();
return {
update(newValue) {
if (newValue !== undefined) {
el.dropdown('set selected', newValue.toString());
}
},
destroy() {
el.dropdown('destroy');
}
};
}
}
function addSubBlock(parentBlock, containerKey, subIndex = null) {
const type = 'paragraph'; // Default to paragraph for simplicity in nested slots
const newBlock = { type, data: { text: '' } };
if (subIndex === null) {
if (!parentBlock.data[containerKey]) parentBlock.data[containerKey] = [];
parentBlock.data[containerKey] = [...parentBlock.data[containerKey], newBlock];
} else {
if (!parentBlock.data[containerKey][subIndex].blocks) parentBlock.data[containerKey][subIndex].blocks = [];
parentBlock.data[containerKey][subIndex].blocks = [...parentBlock.data[containerKey][subIndex].blocks, newBlock];
}
blocks = [...blocks];
}
function removeSubBlock(parentBlock, containerKey, subIndex, blockIndex) {
if (subIndex === null) {
parentBlock.data[containerKey] = parentBlock.data[containerKey].filter((_, i) => i !== blockIndex);
} else {
parentBlock.data[containerKey][subIndex].blocks = parentBlock.data[containerKey][subIndex].blocks.filter((_, i) => i !== blockIndex);
}
blocks = [...blocks];
}
function addSlot(block, type) {
if (type === 'columns') {
if (!block.data.columns) block.data.columns = [];
block.data.columns = [...block.data.columns, { blocks: [] }];
} else if (type === 'grid') {
if (!block.data.items) block.data.items = [];
block.data.items = [...block.data.items, { blocks: [] }];
}
blocks = [...blocks];
}
function removeSlot(block, type, index) {
if (type === 'columns') {
block.data.columns = block.data.columns.filter((_, i) => i !== index);
} else if (type === 'grid') {
block.data.items = block.data.items.filter((_, i) => i !== index);
}
blocks = [...blocks];
}
function getBlockIssues(index) {
return currentA11yIssues.filter(issue => issue.block_index === index);
}
</script>
<div class="block-list">
{#each blocks as block, index}
<div class="ui raised segment block-item" style="padding: 0; margin-bottom: 2rem; border-left: 5px solid {getBlockIssues(index).some(i => i.type === 'error') ? '#db2828' : (getBlockIssues(index).length > 0 ? '#fbbd08' : '#2185d0')};">
<div class="ui top attached secondary segment" style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 1rem;">
<div>
<i class="grip vertical icon" style="cursor: move;"></i>
<span style="font-weight: bold; text-transform: uppercase;">{block.type}</span>
</div>
<div class="ui mini basic icon buttons">
<button type="button" class="ui button" onclick={() => onMoveBlock(index, -1)} title="Move Up">
<i class="angle up icon"></i>
</button>
<button type="button" class="ui button" onclick={() => onMoveBlock(index, 1)} title="Move Down">
<i class="angle down icon"></i>
</button>
{#if activeLocale !== 'en'}
<button type="button" class="ui blue button" onclick={() => onTranslateBlock(index)} title="Translate from English">
<i class="translate icon"></i>
</button>
{/if}
<button type="button" class="ui red button" onclick={() => onRemoveBlock(index)} title="Remove Block">
<i class="trash icon"></i>
</button>
</div>
</div>
<div class="content" style="padding: 1.5rem;">
<input type="hidden" name="content[{activeLocale}][{index}][type]" value={block.type}>
{#if getBlockIssues(index).length > 0}
<div class="ui message mini {getBlockIssues(index).some(i => i.type === 'error') ? 'error' : 'warning'}">
<ul class="list">
{#each getBlockIssues(index) as issue}
<li><strong>{issue.type === 'error' ? 'ERROR' : 'A11Y'}:</strong> {issue.message}</li>
{/each}
</ul>
</div>
{/if}
{#if block.type === 'paragraph'}
<div class="field" style="margin-top: 1rem;">
<label for="paragraph-text-{index}">Text</label>
<textarea id="paragraph-text-{index}" name="content[{activeLocale}][{index}][data][text]"
bind:value={block.data.text}
placeholder="Enter text..."
rows="3"></textarea>
<input type="hidden" name="content[{activeLocale}][{index}][data][text]" value={block.data.text || ''}>
</div>
{:else if block.type === 'heading'}
<div class="two fields">
<div class="field three wide">
<label for="heading-level-{index}">Level</label>
<select id="heading-level-{index}" name="content[{activeLocale}][{index}][data][level]" bind:value={block.data.level} class="ui dropdown" use:dropdown={block.data.level}>
<option value="1">H1</option>
<option value="2">H2</option>
<option value="3">H3</option>
<option value="4">H4</option>
<option value="5">H5</option>
<option value="6">H6</option>
</select>
</div>
<div class="field thirteen wide">
<label for="heading-text-{index}">Heading Text</label>
<input id="heading-text-{index}" type="text" name="content[{activeLocale}][{index}][data][text]" bind:value={block.data.text} placeholder="Enter heading...">
<input type="hidden" name="content[{activeLocale}][{index}][data][level]" value={block.data.level}>
<input type="hidden" name="content[{activeLocale}][{index}][data][aria_label]" value={block.data.aria_label || ''}>
</div>
</div>
<div class="field">
<label for="heading-aria-{index}">ARIA Label (Optional)</label>
<input id="heading-aria-{index}" type="text" name="content[{activeLocale}][{index}][data][aria_label]" bind:value={block.data.aria_label} placeholder="Descriptive label for screen readers">
</div>
{:else if block.type === 'media'}
{#if block.data.media_id && block.data.filename}
<div class="ui card fluid">
<div class="image-editor-container" style="position: relative; user-select: none;">
<div class="image"
style="max-height: 400px; overflow: hidden; background: #f9f9f9; display: flex; align-items: center; justify-content: center; cursor: crosshair;"
onclick={(e) => setFocalPoint(index, e)}
onkeydown={(e) => e.key === 'Enter' && setFocalPoint(index, e)}
role="button"
tabindex="0"
aria-label="Set focal point for image">
<img src="/media/{block.data.filename}?w=800&q=60"
alt={block.data.filename}
style="pointer-events: none; max-width: 100%; max-height: 400px;" />
<div class="focal-point-marker"
style="position: absolute;
left: {(block.data['fp-x'] || 0.5) * 100}%;
top: {(block.data['fp-y'] || 0.5) * 100}%;
width: 20px;
height: 20px;
border: 2px solid white;
border-radius: 50%;
background: rgba(219, 40, 40, 0.6);
transform: translate(-50%, -50%);
box-shadow: 0 0 5px rgba(0,0,0,0.5);
pointer-events: none;">
</div>
</div>
<div style="position: absolute; bottom: 5px; right: 5px; background: rgba(0,0,0,0.6); color: white; padding: 2px 5px; font-size: 10px; border-radius: 3px;">
Click to set Focal Point: {Number(block.data['fp-x'] || 0.5).toFixed(2)}, {Number(block.data['fp-y'] || 0.5).toFixed(2)}
</div>
</div>
<div class="content">
<div class="header">{block.data.filename}</div>
<div class="meta">
<button type="button" class="ui button tiny basic" onclick={() => block.data.showAdvanced = !block.data.showAdvanced}>
<i class="cog icon"></i> {block.data.showAdvanced ? 'Hide' : 'Show'} JIT Settings
</button>
</div>
{#if block.data.showAdvanced}
<div class="ui divider"></div>
<div class="four fields">
<div class="field">
<label for="media-w-{index}">Width</label>
<input id="media-w-{index}" type="number" name="content[{activeLocale}][{index}][data][w]" bind:value={block.data.w} placeholder="Auto">
</div>
<div class="field">
<label for="media-h-{index}">Height</label>
<input id="media-h-{index}" type="number" name="content[{activeLocale}][{index}][data][h]" bind:value={block.data.h} placeholder="Auto">
</div>
<div class="field">
<label for="media-q-{index}">Quality</label>
<input id="media-q-{index}" type="number" name="content[{activeLocale}][{index}][data][q]" bind:value={block.data.q} min="1" max="100">
</div>
<div class="field">
<label for="media-fit-{index}">Fit</label>
<select id="media-fit-{index}" name="content[{activeLocale}][{index}][data][fit]" bind:value={block.data.fit} class="ui dropdown compact" use:dropdown={block.data.fit}>
<option value="contain">Contain</option>
<option value="crop">Crop (Focal)</option>
<option value="max">Max</option>
<option value="fill">Fill</option>
</select>
</div>
</div>
{/if}
<input type="hidden" name="content[{activeLocale}][{index}][data][media_id]" value={block.data.media_id || ''}>
<input type="hidden" name="content[{activeLocale}][{index}][data][filename]" value={block.data.filename || ''}>
<input type="hidden" name="content[{activeLocale}][{index}][data][fp-x]" value={block.data['fp-x'] || 0.5}>
<input type="hidden" name="content[{activeLocale}][{index}][data][fp-y]" value={block.data['fp-y'] || 0.5}>
<input type="hidden" name="content[{activeLocale}][{index}][data][alt]" value={block.data.alt || ''}>
<input type="hidden" name="content[{activeLocale}][{index}][data][w]" value={block.data.w || ''}>
<input type="hidden" name="content[{activeLocale}][{index}][data][h]" value={block.data.h || ''}>
<input type="hidden" name="content[{activeLocale}][{index}][data][q]" value={block.data.q || 85}>
<input type="hidden" name="content[{activeLocale}][{index}][data][fit]" value={block.data.fit || 'contain'}>
<div class="field" style="margin-top: 1rem;">
<label for="media-caption-{index}">Caption / Alt Text</label>
<input id="media-caption-{index}" type="text" name="content[{activeLocale}][{index}][data][text]" bind:value={block.data.text} placeholder="Enter caption...">
<input type="hidden" name="content[{activeLocale}][{index}][data][text]" value={block.data.text || ''}>
</div>
</div>
<div class="extra content">
<button type="button" class="ui button basic small" onclick={() => onOpenMediaPicker(index)}>
Change Media
</button>
</div>
</div>
{:else}
<div class="ui placeholder segment"
onclick={() => onOpenMediaPicker(index)}
onkeydown={(e) => e.key === 'Enter' && onOpenMediaPicker(index)}
role="button"
tabindex="0"
style="cursor: pointer;">
<div class="ui icon header">
<i class="image outline icon"></i>
No media selected. Click to select.
</div>
</div>
<input type="hidden" name="content[{activeLocale}][{index}][data][media_id]" value="">
{/if}
{:else if block.type === 'columns'}
<div class="ui segment secondary">
<div class="ui header small">Columns Layout</div>
<div class="ui grid">
{#each block.data.columns || [] as column, colIndex}
<div class="column" style="width: {100 / (block.data.columns.length)}%;">
<div class="ui segment basic p-0">
<div class="ui top attached label mini">
Col {colIndex + 1}
<i class="trash icon right floated red"
style="cursor: pointer;"
onclick={() => removeSlot(block, 'columns', colIndex)}
onkeydown={(e) => e.key === 'Enter' && removeSlot(block, 'columns', colIndex)}
role="button"
tabindex="0"
aria-label="Remove Column"></i>
</div>
<div style="padding: 1rem 0;">
<NestedBlockEditor
blocks={column.blocks}
{activeLocale}
onRemoveBlock={(i) => removeSubBlock(block, 'columns', colIndex, i)}
onMoveBlock={(i, dir) => { /* TODO */ }}
{onOpenMediaPicker}
{onUpdateBlockText}
{setFocalPoint}
/>
<button type="button" class="ui button mini fluid basic" onclick={() => addSubBlock(block, 'columns', colIndex)}>
<i class="plus icon"></i> Add to Column
</button>
</div>
</div>
</div>
{/each}
</div>
<button type="button" class="ui button small basic" onclick={() => addSlot(block, 'columns')}>
<i class="plus icon"></i> Add Column
</button>
</div>
{:else if block.type === 'grid'}
<div class="ui segment secondary">
<div class="ui header small">Grid Layout</div>
<div class="two fields">
<div class="field">
<label for="grid-cols-{index}">Columns</label>
<input id="grid-cols-{index}" type="number" name="content[{activeLocale}][{index}][data][cols]" bind:value={block.data.cols} min="1" max="6">
</div>
</div>
<div class="ui grid doubling">
{#each block.data.items || [] as item, itemIndex}
<div class="column" style="width: {100 / (block.data.cols || 3)}%;">
<div class="ui segment basic p-0">
<div class="ui top attached label mini">
Item {itemIndex + 1}
<i class="trash icon right floated red"
style="cursor: pointer;"
onclick={() => removeSlot(block, 'grid', itemIndex)}
onkeydown={(e) => e.key === 'Enter' && removeSlot(block, 'grid', itemIndex)}
role="button"
tabindex="0"
aria-label="Remove Grid Item"></i>
</div>
<div style="padding: 1rem 0;">
<NestedBlockEditor
blocks={item.blocks}
{activeLocale}
onRemoveBlock={(i) => removeSubBlock(block, 'grid', itemIndex, i)}
onMoveBlock={(i, dir) => { /* TODO */ }}
{onOpenMediaPicker}
{onUpdateBlockText}
{setFocalPoint}
/>
<button type="button" class="ui button mini fluid basic" onclick={() => addSubBlock(block, 'grid', itemIndex)}>
<i class="plus icon"></i> Add to Item
</button>
</div>
</div>
</div>
{/each}
</div>
<button type="button" class="ui button small basic" onclick={() => addSlot(block, 'grid')}>
<i class="plus icon"></i> Add Grid Item
</button>
</div>
{/if}
</div>
</div>
{/each}
</div>
<style>
.block-list {
margin-top: 1rem;
}
.block-item {
position: relative;
}
.p-0 { padding: 0 !important; }
</style>