- block-editor.blade.php: 3패널 UI (Palette + Canvas + Properties) - Alpine.js blockEditor() 컴포넌트 (CRUD, Undo/Redo, SortableJS) - 기본 Block 6종: heading, paragraph, table, columns, divider, spacer - 폼 필드 Block 7종: text, number, date, select, checkbox, textarea, signature - BlockRendererService: JSON → HTML 렌더링 서비스 - 컨트롤러 분기: builder_type = 'block' → 블록 빌더 뷰 - 라우트 추가: block-create, block-edit - API store/update에 schema JSON 처리 추가 - index 페이지에 블록 빌더 진입 버튼 추가 - 목록에 builder_type 뱃지 표시
255 lines
17 KiB
PHP
255 lines
17 KiB
PHP
{{-- Block Canvas (중앙 영역) --}}
|
|
<div class="block-canvas overflow-y-auto bg-gray-100 p-6">
|
|
<div class="max-w-3xl mx-auto bg-white shadow-sm rounded-lg min-h-full">
|
|
|
|
{{-- 빈 캔버스 --}}
|
|
<div x-show="blocks.length === 0" class="canvas-empty flex flex-col items-center justify-center rounded-lg m-4 p-12">
|
|
<svg class="w-12 h-12 text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
|
</svg>
|
|
<p class="text-gray-400 text-sm mb-1">좌측 팔레트에서 블록을 추가하세요</p>
|
|
<p class="text-gray-300 text-xs">또는 블록을 클릭하여 시작</p>
|
|
</div>
|
|
|
|
{{-- 블록 목록 --}}
|
|
<div x-ref="canvas" class="p-4 space-y-2" x-show="blocks.length > 0">
|
|
<template x-for="(block, index) in blocks" :key="block.id">
|
|
<div class="block-item group relative rounded-lg border border-gray-200 transition"
|
|
:class="{ 'selected': selectedBlockId === block.id }"
|
|
@click.stop="selectBlock(block.id)">
|
|
|
|
{{-- 블록 컨트롤 바 --}}
|
|
<div class="absolute -top-0 left-0 right-0 flex items-center justify-between px-2 py-0.5 opacity-0 group-hover:opacity-100 transition pointer-events-none group-hover:pointer-events-auto"
|
|
style="z-index: 10;">
|
|
<div class="flex items-center gap-1">
|
|
{{-- 드래그 핸들 --}}
|
|
<div class="block-handle px-1 py-0.5 text-gray-400 hover:text-gray-600 cursor-grab">
|
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
|
<path d="M7 2a2 2 0 10.001 4.001A2 2 0 007 2zm0 6a2 2 0 10.001 4.001A2 2 0 007 8zm0 6a2 2 0 10.001 4.001A2 2 0 007 14zm6-8a2 2 0 10-.001-4.001A2 2 0 0013 6zm0 2a2 2 0 10.001 4.001A2 2 0 0013 8zm0 6a2 2 0 10.001 4.001A2 2 0 0013 14z"/>
|
|
</svg>
|
|
</div>
|
|
{{-- 블록 타입 라벨 --}}
|
|
<span class="text-[10px] text-gray-400 bg-gray-50 px-1.5 py-0.5 rounded"
|
|
x-text="blockTypes[block.type]?.label || block.type"></span>
|
|
</div>
|
|
<div class="flex items-center gap-0.5">
|
|
{{-- 위 블록 추가 --}}
|
|
<button @click.stop="addBlock(block.type, index - 1)"
|
|
class="p-0.5 text-gray-400 hover:text-blue-600 rounded" title="위에 추가">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
|
|
</svg>
|
|
</button>
|
|
{{-- 복제 --}}
|
|
<button @click.stop="duplicateBlock(block.id)"
|
|
class="p-0.5 text-gray-400 hover:text-blue-600 rounded" title="복제">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
|
</svg>
|
|
</button>
|
|
{{-- 삭제 --}}
|
|
<button @click.stop="removeBlock(block.id)"
|
|
class="p-0.5 text-gray-400 hover:text-red-600 rounded" title="삭제">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- 블록 내용 렌더링 --}}
|
|
<div class="p-3">
|
|
{{-- Heading --}}
|
|
<template x-if="block.type === 'heading'">
|
|
<div>
|
|
<div x-show="block.props.level === 1" class="text-2xl font-bold"
|
|
:style="'text-align:' + (block.props.align || 'left')"
|
|
x-text="block.props.text || '제목 1'"></div>
|
|
<div x-show="block.props.level === 2" class="text-xl font-bold"
|
|
:style="'text-align:' + (block.props.align || 'left')"
|
|
x-text="block.props.text || '제목 2'"></div>
|
|
<div x-show="block.props.level === 3" class="text-lg font-semibold"
|
|
:style="'text-align:' + (block.props.align || 'left')"
|
|
x-text="block.props.text || '제목 3'"></div>
|
|
<div x-show="block.props.level >= 4" class="text-base font-semibold"
|
|
:style="'text-align:' + (block.props.align || 'left')"
|
|
x-text="block.props.text || '제목 4'"></div>
|
|
</div>
|
|
</template>
|
|
|
|
{{-- Paragraph --}}
|
|
<template x-if="block.type === 'paragraph'">
|
|
<p class="text-sm text-gray-700 whitespace-pre-wrap"
|
|
:style="'text-align:' + (block.props.align || 'left')"
|
|
x-text="block.props.text || '텍스트를 입력하세요'"></p>
|
|
</template>
|
|
|
|
{{-- Table --}}
|
|
<template x-if="block.type === 'table'">
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full border-collapse border border-gray-300 text-sm">
|
|
<thead x-show="block.props.showHeader !== false">
|
|
<tr class="bg-gray-50">
|
|
<template x-for="(header, hIdx) in block.props.headers" :key="'th_' + hIdx">
|
|
<th class="border border-gray-300 px-2 py-1 text-left font-medium text-gray-600"
|
|
x-text="header"></th>
|
|
</template>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="(row, rIdx) in block.props.rows" :key="'tr_' + rIdx">
|
|
<tr>
|
|
<template x-for="(cell, cIdx) in row" :key="'td_' + rIdx + '_' + cIdx">
|
|
<td class="border border-gray-300 px-2 py-1 text-gray-600"
|
|
x-text="cell || '—'"></td>
|
|
</template>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</template>
|
|
|
|
{{-- Columns --}}
|
|
<template x-if="block.type === 'columns'">
|
|
<div class="flex gap-3">
|
|
<template x-for="(col, colIdx) in block.props.children" :key="'col_' + colIdx">
|
|
<div class="flex-1 column-drop-zone rounded p-2 min-h-[60px]">
|
|
<div class="text-xs text-gray-300 text-center" x-show="col.length === 0">
|
|
열 <span x-text="colIdx + 1"></span>
|
|
</div>
|
|
<template x-for="child in col" :key="child.id">
|
|
<div class="text-xs text-gray-500 bg-gray-50 px-2 py-1 rounded mb-1">
|
|
<span x-text="blockTypes[child.type]?.label || child.type"></span>:
|
|
<span x-text="child.props?.label || child.props?.text || ''"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
{{-- Divider --}}
|
|
<template x-if="block.type === 'divider'">
|
|
<hr class="border-gray-300" :class="{ 'border-dashed': block.props.style === 'dashed' }">
|
|
</template>
|
|
|
|
{{-- Spacer --}}
|
|
<template x-if="block.type === 'spacer'">
|
|
<div class="flex items-center justify-center text-xs text-gray-300"
|
|
:style="'height:' + (block.props.height || 20) + 'px'">
|
|
↕ <span x-text="block.props.height || 20"></span>px
|
|
</div>
|
|
</template>
|
|
|
|
{{-- Text Field --}}
|
|
<template x-if="block.type === 'text_field'">
|
|
<div>
|
|
<label class="text-xs font-medium text-gray-600 mb-1 block">
|
|
<span x-text="block.props.label"></span>
|
|
<span x-show="block.props.required" class="text-red-500">*</span>
|
|
</label>
|
|
<div class="bg-gray-50 border border-gray-200 rounded px-3 py-1.5 text-sm text-gray-400"
|
|
x-text="block.props.placeholder || '텍스트 입력'"></div>
|
|
</div>
|
|
</template>
|
|
|
|
{{-- Number Field --}}
|
|
<template x-if="block.type === 'number_field'">
|
|
<div>
|
|
<label class="text-xs font-medium text-gray-600 mb-1 block">
|
|
<span x-text="block.props.label"></span>
|
|
<span x-show="block.props.unit" class="text-gray-400" x-text="'(' + block.props.unit + ')'"></span>
|
|
</label>
|
|
<div class="bg-gray-50 border border-gray-200 rounded px-3 py-1.5 text-sm text-gray-400">0</div>
|
|
</div>
|
|
</template>
|
|
|
|
{{-- Date Field --}}
|
|
<template x-if="block.type === 'date_field'">
|
|
<div>
|
|
<label class="text-xs font-medium text-gray-600 mb-1 block" x-text="block.props.label"></label>
|
|
<div class="bg-gray-50 border border-gray-200 rounded px-3 py-1.5 text-sm text-gray-400">YYYY-MM-DD</div>
|
|
</div>
|
|
</template>
|
|
|
|
{{-- Select Field --}}
|
|
<template x-if="block.type === 'select_field'">
|
|
<div>
|
|
<label class="text-xs font-medium text-gray-600 mb-1 block" x-text="block.props.label"></label>
|
|
<div class="bg-gray-50 border border-gray-200 rounded px-3 py-1.5 text-sm text-gray-400 flex items-center justify-between">
|
|
<span>선택하세요</span>
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
{{-- Checkbox Field --}}
|
|
<template x-if="block.type === 'checkbox_field'">
|
|
<div>
|
|
<label class="text-xs font-medium text-gray-600 mb-1 block" x-text="block.props.label"></label>
|
|
<div class="flex flex-wrap gap-3">
|
|
<template x-for="(opt, oi) in block.props.options" :key="'cb_' + oi">
|
|
<label class="flex items-center gap-1.5 text-sm text-gray-600">
|
|
<input type="checkbox" disabled class="rounded border-gray-300">
|
|
<span x-text="opt"></span>
|
|
</label>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
{{-- Textarea Field --}}
|
|
<template x-if="block.type === 'textarea_field'">
|
|
<div>
|
|
<label class="text-xs font-medium text-gray-600 mb-1 block" x-text="block.props.label"></label>
|
|
<div class="bg-gray-50 border border-gray-200 rounded px-3 py-2 text-sm text-gray-400"
|
|
:style="'min-height:' + ((block.props.rows || 3) * 24) + 'px'">
|
|
내용을 입력하세요
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
{{-- Signature Field --}}
|
|
<template x-if="block.type === 'signature_field'">
|
|
<div>
|
|
<label class="text-xs font-medium text-gray-600 mb-1 block" x-text="block.props.label"></label>
|
|
<div class="border-2 border-dashed border-gray-200 rounded-lg flex items-center justify-center bg-gray-50" style="height: 80px;">
|
|
<span class="text-gray-300 text-sm">서명 영역</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
{{-- 하단 블록 추가 버튼 --}}
|
|
<div x-show="blocks.length > 0" class="p-4 flex justify-center">
|
|
<div x-data="{ showMenu: false }" class="relative">
|
|
<button @click="showMenu = !showMenu"
|
|
class="flex items-center gap-1.5 px-4 py-2 text-sm text-gray-500 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-600 transition">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
|
</svg>
|
|
블록 추가
|
|
</button>
|
|
<div x-show="showMenu" @click.away="showMenu = false"
|
|
class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 bg-white shadow-lg rounded-lg border border-gray-200 p-2 w-52 z-20">
|
|
<template x-for="(def, type) in blockTypes" :key="type + '_add'">
|
|
<button @click="addBlock(type); showMenu = false"
|
|
class="w-full flex items-center gap-2 px-2 py-1 rounded text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-700">
|
|
<span class="w-5 h-5 flex items-center justify-center text-xs font-bold rounded"
|
|
:class="def.category === 'basic' ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'"
|
|
x-text="def.icon"></span>
|
|
<span x-text="def.label"></span>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|