355 lines
22 KiB
Svelte
355 lines
22 KiB
Svelte
|
|
<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>
|