la_bloger/resources/views/admin/components/block-editor.blade.php
2026-05-22 17:42:10 +02:00

384 lines
No EOL
26 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<div x-data="blockEditor(@js($publication->blocks))"
x-init="init()"
class="space-y-3 transition-all duration-300">
<template x-for="(block, index) in blocks" :key="block._key">
<div class="block-card group relative bg-white border border-gray-200 rounded-lg transition-all duration-300"
@dragover.prevent="onDragOver(index, $event)"
@drop="onDrop(index, $event)"
:class="{
'ring-2 ring-indigo-400 shadow-lg z-10': isFocused(block._key),
'opacity-50': focusedBlockKey && !isFocused(block._key),
'border-indigo-400 border-dashed bg-indigo-50/30': dragIndex !== null && dragIndex !== index,
'opacity-40': dragIndex === index,
}"
:data-block-key="block._key">
{{-- Hover-reveal block handle and delete --}}
<div class="block-handle absolute -left-1 top-3 -translate-x-full opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center gap-1 z-20"
:class="{ 'opacity-100': isFocused(block._key) }">
<span class="text-gray-400 hover:text-gray-500 cursor-grab text-xs leading-none select-none"
draggable="true"
@dragstart="onDragStart(index, $event)"
@dragend="onDragEnd()"
title="Drag to reorder">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5.7 9.3L3 12m0 0l2.7 2.7M3 12h18M9.3 5.7L12 3m0 0l2.7 2.7M12 3v18m2.7-2.7L12 21m0 0l-2.7-2.7m9-9L21 12m0 0l-2.7 2.7" />
</svg>
</span>
<span class="text-[10px] text-gray-400 uppercase tracking-wider font-medium rotate-180"
style="writing-mode: vertical-rl;"
x-text="block.type === 'rich_text' ? 'Text' : block.type.replace(/_/g, ' ')"></span>
</div>
<div class="block-handle absolute right-24 -bottom-4 translate-x-full opacity-0 group-hover:opacity-100 transition-opacity z-20"
:class="{ 'opacity-100': isFocused(block._key) }">
<button type="button" class="group/remove flex items-center gap-1" @click="removeBlock(index)">
<span class="text-gray-300 group-hover/remove:text-red-700 group-hover/remove:cursor-pointer text-sm leading-none select-none"
title="Delete block">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<path fill="currentColor" d="M7 21q-.825 0-1.412-.587T5 19V6H4V4h5V3h6v1h5v2h-1v13q0 .825-.587 1.413T17 21zM17 6H7v13h10zM9 17h2V8H9zm4 0h2V8h-2zM7 6v13z" />
</svg>
</span>
<span class="text-[10px] text-gray-300 group-hover/remove:text-red-700 group-hover/remove:cursor-pointer tracking-wider font-medium rotate-360"
>
Remove block
</span>
</button>
</div>
<div class="p-1">
{{-- ============ RICH TEXT ============ --}}
<div x-show="block.type === 'rich_text'" class="space-y-0">
{{-- Slim toolbar (single row) --}}
<div class="slim-toolbar flex items-center gap-0.5 pb-2 mb-2 border-b border-gray-100 flex-wrap"
@mousedown.prevent>
{{-- Undo / Redo --}}
<button type="button" @click="editorAction(block._key, 'undo')"
:disabled="!editorState(block._key).canUndo"
class="tbtn" title="Undo (Ctrl+Z)"></button>
<button type="button" @click="editorAction(block._key, 'redo')"
:disabled="!editorState(block._key).canRedo"
class="tbtn" title="Redo (Ctrl+Y)"></button>
<span class="tdiv"></span>
{{-- Inline formatting --}}
<button type="button" @click="editorAction(block._key, 'bold')"
:class="{ 'tbtn-active': editorState(block._key).bold }"
class="tbtn font-bold" title="Bold (Ctrl+B)">B</button>
<button type="button" @click="editorAction(block._key, 'italic')"
:class="{ 'tbtn-active': editorState(block._key).italic }"
class="tbtn italic" title="Italic (Ctrl+I)">I</button>
<button type="button" @click="editorAction(block._key, 'strike')"
:class="{ 'tbtn-active': editorState(block._key).strike }"
class="tbtn line-through" title="Strikethrough">S</button>
<button type="button" @click="editorAction(block._key, 'code')"
:class="{ 'tbtn-active': editorState(block._key).code }"
class="tbtn font-mono text-xs" title="Inline code">&lt;/&gt;</button>
<span class="tdiv"></span>
{{-- Block type --}}
<button type="button"
:class="{ 'tbtn-active': editorState(block._key).paragraph && !editorState(block._key).activeHeadingLevel }"
@click="editorAction(block._key, 'paragraph')"
class="tbtn text-xs" title="Paragraph"></button>
<button type="button"
:class="{ 'tbtn-active': editorState(block._key).h2 }"
@click="editorAction(block._key, 'h2')"
class="tbtn text-xs font-bold" title="Heading 2">H2</button>
<button type="button"
:class="{ 'tbtn-active': editorState(block._key).h3 }"
@click="editorAction(block._key, 'h3')"
class="tbtn text-xs font-semibold" title="Heading 3">H3</button>
<span class="tdiv"></span>
{{-- Lists --}}
<button type="button"
:class="{ 'tbtn-active': editorState(block._key).bulletList }"
@click="editorAction(block._key, 'bullet')"
class="tbtn" title="Bullet list">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<path fill="currentColor" d="M2 5h3v3H2zm0 6h3v3H2zm0 6h3v3H2zm6 1h14v1H8zM8 6h14v1H8zm0 6h14v1H8z" />
</svg>
</button>
<button type="button"
:class="{ 'tbtn-active': editorState(block._key).orderedList }"
@click="editorAction(block._key, 'ordered')"
class="tbtn text-xs" title="Numbered list">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32">
<path d="M0 0h32v32H0z" fill="none" />
<path fill="currentColor" d="M16 22h14v2H16zm0-14h14v2H16zm-8 4V4H6v1H4v2h2v5H4v2h6v-2zm2 16H4v-4a2 2 0 0 1 2-2h2v-2H4v-2h4a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6v2h4Z" />
</svg>
</button>
<span class="tdiv"></span>
{{-- Block elements --}}
<button type="button"
:class="{ 'tbtn-active': editorState(block._key).blockquote }"
@click="editorAction(block._key, 'blockquote')"
class="tbtn" title="Blockquote">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 15h15m0 4H6m9-8h6m0-4h-6M9 9h1a1 1 0 1 1-1 1V7.5a2 2 0 0 1 2-2M3 9h1a1 1 0 1 1-1 1V7.5a2 2 0 0 1 2-2" />
</svg>
</button>
<button type="button"
:class="{ 'tbtn-active': editorState(block._key).codeBlock }"
@click="editorAction(block._key, 'codeBlock')"
class="tbtn font-mono text-xs" title="Code block">{ }</button>
<span class="tdiv"></span>
{{-- Media --}}
<button type="button"
:class="{ 'tbtn-active': editorState(block._key).link }"
@click="setLink(block._key)"
class="tbtn" title="Add link">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<path fill="none" stroke="currentColor" stroke-dasharray="28" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 6l2 -2c1 -1 3 -1 4 0l1 1c1 1 1 3 0 4l-5 5c-1 1 -3 1 -4 0M11 18l-2 2c-1 1 -3 1 -4 0l-1 -1c-1 -1 -1 -3 0 -4l5 -5c1 -1 3 -1 4 0">
<animate fill="freeze" attributeName="stroke-dashoffset" dur="0.6s" values="28;0" />
</path>
</svg>
</button>
<button type="button"
@click="insertImage(block._key)"
class="tbtn" title="Insert image">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<path fill="currentColor" d="M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm0-2h14V5H5zm0 0V5zm2-2h10q.3 0 .45-.275t-.05-.525l-2.75-3.675q-.15-.2-.4-.2t-.4.2L11.25 16L9.4 13.525q-.15-.2-.4-.2t-.4.2l-2 2.675q-.2.25-.05.525T7 17" />
</svg>
</button>
<span class="tdiv"></span>
{{-- Table --}}
<button type="button"
:class="{ 'tbtn-active': editorState(block._key).table }"
@click="editorState(block._key).table
? getEditor(block._key)?.chain().deleteTable().run()
: getEditor(block._key)?.chain().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()"
class="tbtn" title="Toggle table">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<path fill="currentColor" d="M6 5h11a3 3 0 0 1 3 3v9a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V8a3 3 0 0 1 3-3M4 17a2 2 0 0 0 2 2h5v-3H4zm7-5H4v3h7zm6 7a2 2 0 0 0 2-2v-1h-7v3zm2-7h-7v3h7zM4 11h7V8H4zm8 0h7V8h-7z" />
</svg>
</button>
<span class="tdiv"></span>
{{-- Clear marks --}}
<button type="button" @click="editorAction(block._key, 'clearMarks')"
class="tbtn text-[10px] text-gray-400" title="Clear formatting">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 16 16">
<path d="M0 0h16v16H0z" fill="none" />
<path fill="currentColor" d="M2.958 2a.75.75 0 0 0-.727.568l-.236.945a.75.75 0 0 0 1.455.363l.094-.376h2.024l-1.225 7h-.388a.75.75 0 0 0 0 1.5h2.067a5.6 5.6 0 0 1 .068-1.499l-.044-.001h-.18l1.224-7h2.227l-.003.013a.75.75 0 0 0 1.455.363l.236-.944A.75.75 0 0 0 10.278 2zM1.75 12.5h4.34A5.5 5.5 0 0 0 6.6 14H1.75a.75.75 0 0 1 0-1.5m14.25-1a4.5 4.5 0 1 1-9 0a4.5 4.5 0 0 1 9 0m-2.646-1.146a.5.5 0 0 0-.708-.708L11.5 10.793l-1.146-1.147a.5.5 0 0 0-.708.708l1.147 1.146l-1.147 1.146a.5.5 0 0 0 .708.708l1.146-1.147l1.146 1.147a.5.5 0 0 0 .708-.708L12.207 11.5z" />
</svg>
</button>
</div>
{{-- Editor area --}}
<div class="tiptap-editor-wrapper relative">
<div class="tiptap-editor min-h-48 text-sm prose max-w-none"
:class="{ 'min-h-96': isFocused(block._key) }"
x-init="mountTiptap($el, block)"></div>
</div>
{{-- Table helpers (shown when table is active) --}}
<div x-show="editorState(block._key).table" x-cloak
class="flex flex-wrap items-center gap-1 pt-2 border-t border-gray-100 mt-2">
<button type="button" @click="getEditor(block._key)?.chain().addColumnBefore().run()"
class="tbtn text-[10px]" title="Add column before">+</button>
<button type="button" @click="getEditor(block._key)?.chain().addColumnAfter().run()"
class="tbtn text-[10px]" title="Add column after">+</button>
<button type="button" @click="getEditor(block._key)?.chain().deleteColumn().run()"
class="tbtn text-[10px]" title="Delete column">Col</button>
<button type="button" @click="getEditor(block._key)?.chain().addRowBefore().run()"
class="tbtn text-[10px]" title="Add row before">+</button>
<button type="button" @click="getEditor(block._key)?.chain().addRowAfter().run()"
class="tbtn text-[10px]" title="Add row after">+</button>
<button type="button" @click="getEditor(block._key)?.chain().deleteRow().run()"
class="tbtn text-[10px]" title="Delete row">Row</button>
<button type="button" @click="getEditor(block._key)?.chain().mergeCells().run()"
class="tbtn text-[10px]">Merge</button>
<button type="button" @click="getEditor(block._key)?.chain().splitCell().run()"
class="tbtn text-[10px]">Split</button>
</div>
{{-- Image size controls (shown when image is selected) --}}
<div x-show="editorState(block._key).image" x-cloak
class="flex items-center gap-1 pt-2 border-t border-gray-100 mt-2">
<span class="text-[10px] text-gray-400 mr-1">Size:</span>
<button type="button" @click="setImageSize(block._key, 'img-xs')"
:class="{ 'tbtn-active': editorState(block._key).currentImageSize === 'img-xs' }"
class="tbtn text-[10px]">XS</button>
<button type="button" @click="setImageSize(block._key, 'img-sm')"
:class="{ 'tbtn-active': editorState(block._key).currentImageSize === 'img-sm' }"
class="tbtn text-[10px]">SM</button>
<button type="button" @click="setImageSize(block._key, 'img-md')"
:class="{ 'tbtn-active': editorState(block._key).currentImageSize === 'img-md' }"
class="tbtn text-[10px]">MD</button>
<button type="button" @click="setImageSize(block._key, 'img-lg')"
:class="{ 'tbtn-active': editorState(block._key).currentImageSize === 'img-lg' }"
class="tbtn text-[10px]">LG</button>
<button type="button" @click="setImageSize(block._key, 'img-full')"
:class="{ 'tbtn-active': editorState(block._key).currentImageSize === 'img-full' }"
class="tbtn text-[10px]">Full</button>
<span class="tdiv"></span>
<button type="button" @click="removeImage(block._key)"
class="tbtn text-[10px] text-red-400"> Remove</button>
</div>
<input type="hidden" :name="`blocks[${index}][content][content]`" x-model="block.content.content">
</div>
{{-- ============ IMAGE ============ --}}
<div x-show="block.type === 'image'" class="space-y-2">
<input type="text" :name="`blocks[${index}][content][url]`" x-model="block.content.url"
placeholder="Image URL"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
<div class="grid grid-cols-2 gap-2">
<input type="text" :name="`blocks[${index}][content][alt]`" x-model="block.content.alt"
placeholder="Alt text"
class="border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
<input type="text" :name="`blocks[${index}][content][caption]`" x-model="block.content.caption"
placeholder="Caption"
class="border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<select :name="`blocks[${index}][content][style]`" x-model="block.content.style"
class="border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
<option value="full">Full width</option>
<option value="contained">Contained</option>
<option value="float-left">Float left</option>
<option value="float-right">Float right</option>
</select>
</div>
{{-- ============ VIDEO ============ --}}
<div x-show="block.type === 'video'" class="space-y-2">
<input type="text" :name="`blocks[${index}][content][url]`" x-model="block.content.url"
placeholder="Video URL"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
<div class="grid grid-cols-2 gap-2">
<input type="text" :name="`blocks[${index}][content][title]`" x-model="block.content.title"
placeholder="Title"
class="border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
<input type="text" :name="`blocks[${index}][content][caption]`" x-model="block.content.caption"
placeholder="Caption"
class="border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
</div>
{{-- ============ TAXONOMY LIST ============ --}}
<div x-show="block.type === 'taxonomy_list'" class="space-y-2">
<select :name="`blocks[${index}][content][taxonomy_term_id]`"
x-model="block.content.taxonomy_term_id"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
<option value=""> Select term </option>
@foreach($channelTerms as $term)
<option value="{{ $term->id }}">{{ $term->title }}</option>
@endforeach
</select>
<div class="flex items-center gap-2">
<label class="text-xs text-gray-500">Limit</label>
<input type="number" :name="`blocks[${index}][content][limit]`" x-model="block.content.limit"
min="1" max="50"
class="w-20 border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
</div>
{{-- ============ POSTS CAROUSEL ============ --}}
<div x-show="block.type === 'posts_carousel'" class="space-y-2">
<input type="text" :name="`blocks[${index}][content][title]`" x-model="block.content.title"
placeholder="Section title"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
<div class="flex items-center gap-2">
<label class="text-xs text-gray-500">Limit</label>
<input type="number" :name="`blocks[${index}][content][limit]`" x-model="block.content.limit"
min="1" max="20"
class="w-20 border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
</div>
</div>
{{-- Hidden control inputs --}}
<input type="hidden" :name="`blocks[${index}][type]`" x-model="block.type">
<input type="hidden" :name="`blocks[${index}][order]`" :value="index">
<input type="hidden" :name="`blocks[${index}][id]`" x-model="block.id">
</div>
</template>
{{-- Add block strip --}}
<div class="flex flex-wrap gap-2 pt-1">
<button type="button" @click="addBlock('rich_text')"
class="px-3 py-1.5 text-xs border border-dashed border-gray-300 rounded-lg hover:border-indigo-400 text-gray-500 hover:text-indigo-700 transition-colors">
+ Text
</button>
<button type="button" @click="addBlock('image')"
class="px-3 py-1.5 text-xs border border-dashed border-gray-300 rounded-lg hover:border-emerald-400 text-gray-500 hover:text-emerald-700 transition-colors">
+ Image
</button>
<button type="button" @click="addBlock('video')"
class="px-3 py-1.5 text-xs border border-dashed border-gray-300 rounded-lg hover:border-amber-400 text-gray-500 hover:text-amber-700 transition-colors">
+ Video
</button>
<button type="button" @click="addBlock('taxonomy_list')"
class="px-3 py-1.5 text-xs border border-dashed border-gray-300 rounded-lg hover:border-cyan-400 text-gray-500 hover:text-cyan-700 transition-colors">
+ Taxonomy list
</button>
<button type="button" @click="addBlock('posts_carousel')"
class="px-3 py-1.5 text-xs border border-dashed border-gray-300 rounded-lg hover:border-pink-400 text-gray-500 hover:text-pink-700 transition-colors">
+ Posts carousel
</button>
</div>
{{-- Focus mode hint --}}
<div x-show="focusedBlockKey" x-cloak
class="fixed bottom-4 right-4 bg-gray-900 text-white text-xs px-3 py-1.5 rounded-full shadow-lg z-50 opacity-80 pointer-events-none">
Press <kbd class="font-mono bg-white/20 px-1 rounded">Esc</kbd> to exit focus mode
</div>
{{-- Delete confirmation modal --}}
<div x-show="confirmDelete !== null" x-cloak
x-transition.opacity
@keydown.escape.window="cancelRemoveBlock()"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white rounded-xl shadow-2xl p-6 w-full max-w-sm mx-4"
@click.outside="cancelRemoveBlock()">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Delete block?</h3>
<p class="text-sm text-gray-500 mb-6">
This will permanently remove block
<span class="font-medium text-gray-700"
x-text="confirmDelete !== null ? blocks[confirmDelete].type.replace(/_/g, ' ') : ''"></span>
at position
<span class="font-medium text-gray-700" x-text="confirmDelete !== null ? confirmDelete + 1 : ''"></span>.
This action cannot be undone.
</p>
<div class="flex justify-end gap-3">
<button type="button" @click="cancelRemoveBlock()"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors">
Cancel
</button>
<button type="button" @click="confirmRemoveBlock()"
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors">
Delete
</button>
</div>
</div>
</div>
</div>