changes
This commit is contained in:
@@ -0,0 +1,448 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-white border border-layer-line rounded-lg">
|
||||
<h3 class="text-sm font-medium text-foreground">
|
||||
{{ label || 'Контент' }}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
@click="showBlockPicker = true"
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-primary bg-primary/10 rounded-lg hover:bg-primary/20 transition-all"
|
||||
>
|
||||
<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 4v16m8-8H4" />
|
||||
</svg>
|
||||
Добавить блок
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Blocks List -->
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(block, index) in blocks"
|
||||
:key="block._uid"
|
||||
class="group relative bg-white border border-layer-line rounded-lg overflow-hidden"
|
||||
>
|
||||
<!-- Block Header -->
|
||||
<div class="flex items-center gap-2 px-4 py-3 bg-muted/30 border-b border-layer-line">
|
||||
<!-- Drag Handle -->
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-move text-muted-foreground-1 hover:text-foreground transition-colors"
|
||||
@mousedown="startDrag(index, $event)"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M7 2a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM13 2a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM7 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM13 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM7 14a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM13 14a2 2 0 1 0 0 4 2 2 0 0 0 0-4z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Block Icon & Label -->
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<component :is="getBlockIcon(block.type)" class="w-4 h-4 text-muted-foreground-1" />
|
||||
<span class="text-sm font-medium text-foreground">{{ getBlockLabel(block.type) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
@click="duplicateBlock(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded transition-all"
|
||||
title="Клонировать"
|
||||
>
|
||||
<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="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
|
||||
type="button"
|
||||
@click="toggleBlock(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded transition-all"
|
||||
:title="collapsedBlocks.includes(index) ? 'Развернуть' : 'Свернуть'"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform"
|
||||
:class="{ 'rotate-180': !collapsedBlocks.includes(index) }"
|
||||
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>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeBlock(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-danger hover:bg-danger/10 rounded transition-all"
|
||||
title="Удалить"
|
||||
>
|
||||
<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 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>
|
||||
|
||||
<!-- Block Content -->
|
||||
<div v-show="!collapsedBlocks.includes(index)" class="p-4">
|
||||
<component
|
||||
:is="getBlockComponent(block.type)"
|
||||
v-model="block.data"
|
||||
:all-blocks="blocks"
|
||||
@update="emitChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-if="blocks.length === 0"
|
||||
class="flex flex-col items-center justify-center py-12 px-4 border-2 border-dashed border-layer-line rounded-lg bg-muted/20"
|
||||
>
|
||||
<svg class="w-12 h-12 text-muted-foreground-1 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p class="text-sm font-medium text-foreground mb-1">Нет блоков контента</p>
|
||||
<p class="text-xs text-muted-foreground-1 text-center">Нажмите "Добавить блок" чтобы начать</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Block Picker Modal -->
|
||||
<div
|
||||
v-if="showBlockPicker"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"
|
||||
@click.self="showBlockPicker = false"
|
||||
>
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-layer-line flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-foreground">Выберите тип блока</h3>
|
||||
<button
|
||||
type="button"
|
||||
@click="showBlockPicker = false"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded transition-all"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6 overflow-y-auto max-h-[60vh]">
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<button
|
||||
v-for="blockType in availableBlocks"
|
||||
:key="blockType.type"
|
||||
type="button"
|
||||
@click="addBlock(blockType.type)"
|
||||
class="flex flex-col items-center gap-2 p-4 border border-layer-line rounded-lg hover:border-primary hover:bg-primary/5 transition-all group"
|
||||
>
|
||||
<component :is="getBlockIcon(blockType.type)" class="w-8 h-8 text-muted-foreground-1 group-hover:text-primary transition-colors" />
|
||||
<span class="text-xs font-medium text-foreground text-center group-hover:text-primary transition-colors">
|
||||
{{ getBlockLabel(blockType.type) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
|
||||
// Block Components
|
||||
import HeadingBlock from './blocks/HeadingBlock.vue';
|
||||
import ParagraphBlock from './blocks/ParagraphBlock.vue';
|
||||
import ImageBlock from './blocks/ImageBlock.vue';
|
||||
import ImagesBlock from './blocks/ImagesBlock.vue';
|
||||
import FilesBlock from './blocks/FilesBlock.vue';
|
||||
import FastFilesBlock from './blocks/FastFilesBlock.vue';
|
||||
import VideoBlock from './blocks/VideoBlock.vue';
|
||||
import PersonBlock from './blocks/PersonBlock.vue';
|
||||
import StepperBlock from './blocks/StepperBlock.vue';
|
||||
import TabBlock from './blocks/TabBlock.vue';
|
||||
import SliderBlock from './blocks/SliderBlock.vue';
|
||||
import PostItemBlock from './blocks/PostItemBlock.vue';
|
||||
import PostListBlock from './blocks/PostListBlock.vue';
|
||||
import PageItemBlock from './blocks/PageItemBlock.vue';
|
||||
import PageResourceListBlock from './blocks/PageResourceListBlock.vue';
|
||||
import ContactBlock from './blocks/ContactBlock.vue';
|
||||
import CustomFormBlock from './blocks/CustomFormBlock.vue';
|
||||
|
||||
// Icons
|
||||
import IconHeading from './icons/IconHeading.vue';
|
||||
import IconParagraph from './icons/IconParagraph.vue';
|
||||
import IconImage from './icons/IconImage.vue';
|
||||
import IconImages from './icons/IconImages.vue';
|
||||
import IconFiles from './icons/IconFiles.vue';
|
||||
import IconVideo from './icons/IconVideo.vue';
|
||||
import IconPerson from './icons/IconPerson.vue';
|
||||
import IconStepper from './icons/IconStepper.vue';
|
||||
import IconTabs from './icons/IconTabs.vue';
|
||||
import IconSlider from './icons/IconSlider.vue';
|
||||
import IconPost from './icons/IconPost.vue';
|
||||
import IconPage from './icons/IconPage.vue';
|
||||
import IconContact from './icons/IconContact.vue';
|
||||
import IconForm from './icons/IconForm.vue';
|
||||
import IconArchive from './icons/IconArchive.vue';
|
||||
|
||||
export default {
|
||||
name: 'ContentBuilder',
|
||||
components: {
|
||||
HeadingBlock,
|
||||
ParagraphBlock,
|
||||
ImageBlock,
|
||||
ImagesBlock,
|
||||
FilesBlock,
|
||||
FastFilesBlock,
|
||||
VideoBlock,
|
||||
PersonBlock,
|
||||
StepperBlock,
|
||||
TabBlock,
|
||||
SliderBlock,
|
||||
PostItemBlock,
|
||||
PostListBlock,
|
||||
PageItemBlock,
|
||||
PageResourceListBlock,
|
||||
ContactBlock,
|
||||
CustomFormBlock,
|
||||
},
|
||||
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
|
||||
emits: ['update:modelValue'],
|
||||
|
||||
setup(props, { emit }) {
|
||||
const blocks = ref([]);
|
||||
const collapsedBlocks = ref([]);
|
||||
const showBlockPicker = ref(false);
|
||||
let uidCounter = 0;
|
||||
|
||||
const availableBlocks = [
|
||||
{ type: 'heading' },
|
||||
{ type: 'paragraph' },
|
||||
{ type: 'image' },
|
||||
{ type: 'images' },
|
||||
{ type: 'files' },
|
||||
{ type: 'fast_files' },
|
||||
{ type: 'video' },
|
||||
{ type: 'person' },
|
||||
{ type: 'stepper' },
|
||||
{ type: 'tabs' },
|
||||
{ type: 'slider' },
|
||||
{ type: 'postItem' },
|
||||
{ type: 'postsList' },
|
||||
{ type: 'pageItem' },
|
||||
{ type: 'pageResourceList' },
|
||||
{ type: 'contact' },
|
||||
{ type: 'customForm' },
|
||||
];
|
||||
|
||||
const blockLabels = {
|
||||
heading: 'Заголовок',
|
||||
paragraph: 'Текст',
|
||||
image: 'Изображение',
|
||||
images: 'Слайдер изображений',
|
||||
files: 'Файлы',
|
||||
fast_files: 'Быстрая загрузка файлов',
|
||||
video: 'Видео',
|
||||
person: 'Персона',
|
||||
stepper: 'Этапы',
|
||||
tabs: 'Вкладки',
|
||||
slider: 'Слайдер',
|
||||
postItem: 'Конкретная новость',
|
||||
postsList: 'Список новостей',
|
||||
pageItem: 'Конкретная страница',
|
||||
pageResourceList: 'Ресурсы',
|
||||
contact: 'Контакты',
|
||||
customForm: 'Пользовательская форма',
|
||||
};
|
||||
|
||||
const blockIcons = {
|
||||
heading: IconHeading,
|
||||
paragraph: IconParagraph,
|
||||
image: IconImage,
|
||||
images: IconImages,
|
||||
files: IconFiles,
|
||||
fast_files: IconFiles,
|
||||
video: IconVideo,
|
||||
person: IconPerson,
|
||||
stepper: IconStepper,
|
||||
tabs: IconTabs,
|
||||
slider: IconSlider,
|
||||
postItem: IconPost,
|
||||
postsList: IconPost,
|
||||
pageItem: IconPage,
|
||||
pageResourceList: IconArchive,
|
||||
contact: IconContact,
|
||||
customForm: IconForm,
|
||||
};
|
||||
|
||||
const blockDefaults = {
|
||||
heading: () => ({ id: `anchor-${Date.now()}`, content: '' }),
|
||||
paragraph: () => ({ seo_active: true, content: '' }),
|
||||
image: () => ({ url: '', alt: '' }),
|
||||
images: () => ({ url: [], alt: '' }),
|
||||
files: () => ({ file: [] }),
|
||||
fast_files: () => ({ path: [] }),
|
||||
video: () => ({ mime: '', title: '', path: '' }),
|
||||
person: () => ({ name: '', photo: '', info: [{ column: '', content: '' }] }),
|
||||
stepper: () => ({ step_name: '', steps: [{ title: '', content: '' }] }),
|
||||
tabs: () => ({ settings: { is_accordion: false }, tab: [{ title: '', content: [] }] }),
|
||||
slider: () => ({ slider: '' }),
|
||||
postItem: () => ({ post: null }),
|
||||
postsList: () => ({ count: 5, category: null }),
|
||||
pageItem: () => ({ page: null }),
|
||||
pageResourceList: () => ({ resource: '' }),
|
||||
contact: () => ({ contact: '' }),
|
||||
customForm: () => ({ form: '', settings: { in_modal: false } }),
|
||||
};
|
||||
|
||||
function generateUid() {
|
||||
return `block-${Date.now()}-${uidCounter++}`;
|
||||
}
|
||||
|
||||
function addBlock(type) {
|
||||
blocks.value.push({
|
||||
_uid: generateUid(),
|
||||
type,
|
||||
data: blockDefaults[type]()
|
||||
});
|
||||
showBlockPicker.value = false;
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function removeBlock(index) {
|
||||
blocks.value.splice(index, 1);
|
||||
collapsedBlocks.value = collapsedBlocks.value.filter(i => i !== index);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function duplicateBlock(index) {
|
||||
const original = blocks.value[index];
|
||||
blocks.value.splice(index + 1, 0, {
|
||||
_uid: generateUid(),
|
||||
type: original.type,
|
||||
data: JSON.parse(JSON.stringify(original.data))
|
||||
});
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function toggleBlock(index) {
|
||||
const idx = collapsedBlocks.value.indexOf(index);
|
||||
if (idx === -1) {
|
||||
collapsedBlocks.value.push(index);
|
||||
} else {
|
||||
collapsedBlocks.value.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function getBlockComponent(type) {
|
||||
const componentMap = {
|
||||
heading: 'HeadingBlock',
|
||||
paragraph: 'ParagraphBlock',
|
||||
image: 'ImageBlock',
|
||||
images: 'ImagesBlock',
|
||||
files: 'FilesBlock',
|
||||
fast_files: 'FastFilesBlock',
|
||||
video: 'VideoBlock',
|
||||
person: 'PersonBlock',
|
||||
stepper: 'StepperBlock',
|
||||
tabs: 'TabBlock',
|
||||
slider: 'SliderBlock',
|
||||
postItem: 'PostItemBlock',
|
||||
postsList: 'PostListBlock',
|
||||
pageItem: 'PageItemBlock',
|
||||
pageResourceList: 'PageResourceListBlock',
|
||||
contact: 'ContactBlock',
|
||||
customForm: 'CustomFormBlock',
|
||||
};
|
||||
return componentMap[type] || 'ParagraphBlock';
|
||||
}
|
||||
|
||||
function getBlockIcon(type) {
|
||||
return blockIcons[type] || IconParagraph;
|
||||
}
|
||||
|
||||
function getBlockLabel(type) {
|
||||
return blockLabels[type] || type;
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
const output = blocks.value.map(({ _uid, ...block }) => block);
|
||||
emit('update:modelValue', output);
|
||||
}
|
||||
|
||||
// Drag and Drop
|
||||
let dragIndex = null;
|
||||
|
||||
function startDrag(index, event) {
|
||||
dragIndex = index;
|
||||
document.addEventListener('mousemove', onDrag);
|
||||
document.addEventListener('mouseup', stopDrag);
|
||||
}
|
||||
|
||||
function onDrag(event) {
|
||||
// Simplified drag logic - can be enhanced with a library like vuedraggable
|
||||
}
|
||||
|
||||
function stopDrag() {
|
||||
dragIndex = null;
|
||||
document.removeEventListener('mousemove', onDrag);
|
||||
document.removeEventListener('mouseup', stopDrag);
|
||||
}
|
||||
|
||||
// Initialize from modelValue
|
||||
onMounted(() => {
|
||||
if (props.modelValue && props.modelValue.length > 0) {
|
||||
blocks.value = props.modelValue.map(block => ({
|
||||
_uid: generateUid(),
|
||||
...block
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for modelValue changes (e.g., when loading existing content)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (newValue && newValue.length > 0 && blocks.value.length === 0) {
|
||||
// Only initialize if blocks are empty (initial load from server)
|
||||
blocks.value = newValue.map(block => ({
|
||||
_uid: generateUid(),
|
||||
...block
|
||||
}));
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
return {
|
||||
blocks,
|
||||
collapsedBlocks,
|
||||
showBlockPicker,
|
||||
availableBlocks,
|
||||
addBlock,
|
||||
removeBlock,
|
||||
duplicateBlock,
|
||||
toggleBlock,
|
||||
getBlockComponent,
|
||||
getBlockIcon,
|
||||
getBlockLabel,
|
||||
emitChange,
|
||||
startDrag,
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-white border border-layer-line rounded-lg">
|
||||
<h3 class="text-sm font-medium text-foreground">
|
||||
{{ label || 'Поля формы' }}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
@click="showBlockPicker = true"
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-primary bg-primary/10 rounded-lg hover:bg-primary/20 transition-all"
|
||||
>
|
||||
<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 4v16m8-8H4" />
|
||||
</svg>
|
||||
Добавить поле
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Fields List -->
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(field, index) in fields"
|
||||
:key="field._uid"
|
||||
class="group relative bg-white border border-layer-line rounded-lg overflow-hidden"
|
||||
>
|
||||
<!-- Field Header -->
|
||||
<div class="flex items-center gap-2 px-4 py-3 bg-muted/30 border-b border-layer-line">
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<span class="text-xs font-mono text-muted-foreground-1 bg-muted px-2 py-0.5 rounded">{{ field.type }}</span>
|
||||
<span class="text-sm font-medium text-foreground">{{ field.data?.title_field || 'Без названия' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
@click="duplicateField(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded transition-all"
|
||||
title="Клонировать"
|
||||
>
|
||||
<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="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
|
||||
type="button"
|
||||
@click="toggleField(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded transition-all"
|
||||
:title="collapsedFields.includes(index) ? 'Развернуть' : 'Свернуть'"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform"
|
||||
:class="{ 'rotate-180': !collapsedFields.includes(index) }"
|
||||
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>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeField(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-danger hover:bg-danger/10 rounded transition-all"
|
||||
title="Удалить"
|
||||
>
|
||||
<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 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>
|
||||
|
||||
<!-- Field Content -->
|
||||
<div v-show="!collapsedFields.includes(index)" class="p-4">
|
||||
<component
|
||||
:is="getFieldComponent(field.type)"
|
||||
v-model="field.data"
|
||||
@update="emitChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-if="fields.length === 0"
|
||||
class="flex flex-col items-center justify-center py-12 px-4 border-2 border-dashed border-layer-line rounded-lg bg-muted/20"
|
||||
>
|
||||
<svg class="w-12 h-12 text-muted-foreground-1 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p class="text-sm font-medium text-foreground mb-1">Нет полей формы</p>
|
||||
<p class="text-xs text-muted-foreground-1 text-center">Нажмите "Добавить поле" чтобы начать</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Block Picker Modal -->
|
||||
<div
|
||||
v-if="showBlockPicker"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"
|
||||
@click.self="showBlockPicker = false"
|
||||
>
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-layer-line flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-foreground">Выберите тип поля</h3>
|
||||
<button
|
||||
type="button"
|
||||
@click="showBlockPicker = false"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded transition-all"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6 overflow-y-auto max-h-[60vh]">
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<button
|
||||
v-for="fieldType in availableFields"
|
||||
:key="fieldType.type"
|
||||
type="button"
|
||||
@click="addField(fieldType.type)"
|
||||
class="flex flex-col items-center gap-2 p-4 border border-layer-line rounded-lg hover:border-primary hover:bg-primary/5 transition-all group"
|
||||
>
|
||||
<DashboardIcon :name="fieldType.icon" size="8" class="text-muted-foreground-1 group-hover:text-primary transition-colors" />
|
||||
<span class="text-xs font-medium text-foreground text-center group-hover:text-primary transition-colors">
|
||||
{{ fieldType.label }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import DashboardIcon from '../DashboardIcon.vue';
|
||||
import FormFieldText from './FormBuilder/blocks/FormFieldText.vue';
|
||||
import FormFieldTextarea from './FormBuilder/blocks/FormFieldTextarea.vue';
|
||||
import FormFieldEmail from './FormBuilder/blocks/FormFieldEmail.vue';
|
||||
import FormFieldPhone from './FormBuilder/blocks/FormFieldPhone.vue';
|
||||
import FormFieldDate from './FormBuilder/blocks/FormFieldDate.vue';
|
||||
import FormFieldUrl from './FormBuilder/blocks/FormFieldUrl.vue';
|
||||
import FormFieldSingleChoice from './FormBuilder/blocks/FormFieldSingleChoice.vue';
|
||||
import FormFieldMultipleChoice from './FormBuilder/blocks/FormFieldMultipleChoice.vue';
|
||||
|
||||
export default {
|
||||
name: 'FormBuilder',
|
||||
components: {
|
||||
DashboardIcon,
|
||||
FormFieldText,
|
||||
FormFieldTextarea,
|
||||
FormFieldEmail,
|
||||
FormFieldPhone,
|
||||
FormFieldDate,
|
||||
FormFieldUrl,
|
||||
FormFieldSingleChoice,
|
||||
FormFieldMultipleChoice,
|
||||
},
|
||||
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
|
||||
emits: ['update:modelValue'],
|
||||
|
||||
setup(props, { emit }) {
|
||||
const fields = ref([]);
|
||||
const collapsedFields = ref([]);
|
||||
const showBlockPicker = ref(false);
|
||||
let uidCounter = 0;
|
||||
|
||||
const availableFields = [
|
||||
{ type: 'text', label: 'Короткий текст', icon: 'pencil' },
|
||||
{ type: 'textarea', label: 'Длинный текст', icon: 'document-text' },
|
||||
{ type: 'email', label: 'Email', icon: 'envelope' },
|
||||
{ type: 'phone', label: 'Телефон', icon: 'phone' },
|
||||
{ type: 'date', label: 'Дата', icon: 'calendar' },
|
||||
{ type: 'url', label: 'Ссылка', icon: 'link' },
|
||||
{ type: 'single_choice', label: 'Одиночный выбор', icon: 'radio' },
|
||||
{ type: 'multiple_choice', label: 'Множественный выбор', icon: 'check-circle' },
|
||||
];
|
||||
|
||||
const fieldDefaults = {
|
||||
text: () => ({ title_field: '', name_field: '', description: '', rules: { required: false } }),
|
||||
textarea: () => ({ title_field: '', name_field: '', description: '', rules: { required: false } }),
|
||||
email: () => ({ title_field: '', name_field: '', description: '', rules: { required: false } }),
|
||||
phone: () => ({ title_field: '', name_field: '', description: '', rules: { required: false } }),
|
||||
date: () => ({ title_field: '', name_field: '', description: '', rules: { required: false } }),
|
||||
url: () => ({ title_field: '', name_field: '', description: '', rules: { required: false } }),
|
||||
single_choice: () => ({ title_field: '', name_field: '', description: '', columns: [], rules: { required: false } }),
|
||||
multiple_choice: () => ({ title_field: '', name_field: '', description: '', columns: [], rules: { required: false } }),
|
||||
};
|
||||
|
||||
function generateUid() {
|
||||
return `field-${Date.now()}-${uidCounter++}`;
|
||||
}
|
||||
|
||||
function addField(type) {
|
||||
fields.value.push({
|
||||
_uid: generateUid(),
|
||||
type,
|
||||
data: fieldDefaults[type]()
|
||||
});
|
||||
showBlockPicker.value = false;
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function removeField(index) {
|
||||
fields.value.splice(index, 1);
|
||||
collapsedFields.value = collapsedFields.value.filter(i => i !== index);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function duplicateField(index) {
|
||||
const original = fields.value[index];
|
||||
fields.value.splice(index + 1, 0, {
|
||||
_uid: generateUid(),
|
||||
type: original.type,
|
||||
data: JSON.parse(JSON.stringify(original.data))
|
||||
});
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function toggleField(index) {
|
||||
const idx = collapsedFields.value.indexOf(index);
|
||||
if (idx === -1) {
|
||||
collapsedFields.value.push(index);
|
||||
} else {
|
||||
collapsedFields.value.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function getFieldComponent(type) {
|
||||
const componentMap = {
|
||||
text: 'FormFieldText',
|
||||
textarea: 'FormFieldTextarea',
|
||||
email: 'FormFieldEmail',
|
||||
phone: 'FormFieldPhone',
|
||||
date: 'FormFieldDate',
|
||||
url: 'FormFieldUrl',
|
||||
single_choice: 'FormFieldSingleChoice',
|
||||
multiple_choice: 'FormFieldMultipleChoice',
|
||||
};
|
||||
return componentMap[type] || 'FormFieldText';
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
const output = fields.value.map(({ _uid, ...field }) => field);
|
||||
emit('update:modelValue', output);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.modelValue && props.modelValue.length > 0) {
|
||||
fields.value = props.modelValue.map(field => ({
|
||||
_uid: generateUid(),
|
||||
...field
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (newValue && newValue.length > 0 && fields.value.length === 0) {
|
||||
fields.value = newValue.map(field => ({
|
||||
_uid: generateUid(),
|
||||
...field
|
||||
}));
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
return {
|
||||
fields,
|
||||
collapsedFields,
|
||||
showBlockPicker,
|
||||
availableFields,
|
||||
addField,
|
||||
removeField,
|
||||
duplicateField,
|
||||
toggleField,
|
||||
getFieldComponent,
|
||||
emitChange,
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<template><FormFieldText v-model="localData" @update:model-value="$emit('update:modelValue', $event)" /></template>
|
||||
<script>
|
||||
import FormFieldText from './FormFieldText.vue';
|
||||
export default { name: 'FormFieldDate', components: { FormFieldText }, props: { modelValue: Object }, emits: ['update:modelValue'], data() { return { localData: { ...this.modelValue } } } }
|
||||
</script>
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<template><FormFieldText v-model="localData" @update:model-value="$emit('update:modelValue', $event)" /></template>
|
||||
<script>
|
||||
import FormFieldText from './FormFieldText.vue';
|
||||
export default { name: 'FormFieldEmail', components: { FormFieldText }, props: { modelValue: Object }, emits: ['update:modelValue'], data() { return { localData: { ...this.modelValue } } } }
|
||||
</script>
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<template><FormFieldSingleChoice v-model="localData" @update:model-value="$emit('update:modelValue', $event)" /></template>
|
||||
<script>
|
||||
import FormFieldSingleChoice from './FormFieldSingleChoice.vue';
|
||||
export default { name: 'FormFieldMultipleChoice', components: { FormFieldSingleChoice }, props: { modelValue: Object }, emits: ['update:modelValue'], data() { return { localData: { ...this.modelValue } } } }
|
||||
</script>
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<template><FormFieldText v-model="localData" @update:model-value="$emit('update:modelValue', $event)" /></template>
|
||||
<script>
|
||||
import FormFieldText from './FormFieldText.vue';
|
||||
export default { name: 'FormFieldPhone', components: { FormFieldText }, props: { modelValue: Object }, emits: ['update:modelValue'], data() { return { localData: { ...this.modelValue } } } }
|
||||
</script>
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Название группы <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="localData.title_field"
|
||||
type="text"
|
||||
@input="emitUpdate"
|
||||
placeholder="Например: Ваши интересы"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="block text-sm font-medium text-foreground">Варианты выбора</label>
|
||||
<button type="button" @click="addOption" class="text-sm text-primary hover:text-primary-hover">+ Добавить вариант</button>
|
||||
</div>
|
||||
<div v-for="(option, i) in (localData.columns || [])" :key="i" class="flex items-center gap-2">
|
||||
<input v-model="option.title_field" @input="emitUpdate" type="text" placeholder="Вариант" class="flex-1 px-3 py-2 border border-layer-line rounded-lg text-sm" />
|
||||
<button type="button" @click="removeOption(i)" class="p-1.5 text-muted-foreground-1 hover:text-danger hover:bg-danger/10 rounded">
|
||||
<DashboardIcon name="trash" size="4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<input v-model="localData.rules.required" @change="emitUpdate" type="checkbox" class="h-4 w-4 rounded border-layer-line text-primary focus:ring-primary" />
|
||||
<label class="text-sm text-foreground">Обязательное поле</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DashboardIcon from '../../../DashboardIcon.vue';
|
||||
export default {
|
||||
name: 'FormFieldSingleChoice',
|
||||
components: { DashboardIcon },
|
||||
props: { modelValue: Object },
|
||||
emits: ['update:modelValue'],
|
||||
data() {
|
||||
return { localData: { columns: [], rules: { required: false }, ...this.modelValue } }
|
||||
},
|
||||
methods: {
|
||||
emitUpdate() { this.$emit('update:modelValue', { ...this.localData }) },
|
||||
addOption() {
|
||||
if (!this.localData.columns) this.localData.columns = [];
|
||||
this.localData.columns.push({ title_field: '', name_field: '' });
|
||||
this.emitUpdate();
|
||||
},
|
||||
removeOption(i) { this.localData.columns.splice(i, 1); this.emitUpdate(); }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Название поля <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="localData.title_field"
|
||||
type="text"
|
||||
@input="emitUpdate"
|
||||
placeholder="Например: Ваше имя"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Подсказка
|
||||
</label>
|
||||
<textarea
|
||||
v-model="localData.description"
|
||||
@input="emitUpdate"
|
||||
rows="2"
|
||||
placeholder="Подсказка для пользователя"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg text-sm resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
v-model="localData.rules.required"
|
||||
@change="emitUpdate"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-layer-line text-primary focus:ring-primary"
|
||||
/>
|
||||
<label class="text-sm text-foreground">Обязательное поле</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FormFieldText',
|
||||
props: {
|
||||
modelValue: { type: Object, default: () => ({}) }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
data() {
|
||||
return {
|
||||
localData: { ...this.modelValue }
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
emitUpdate() {
|
||||
this.$emit('update:modelValue', { ...this.localData });
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<template><FormFieldText v-model="localData" @update:model-value="$emit('update:modelValue', $event)" /></template>
|
||||
<script>
|
||||
import FormFieldText from './FormFieldText.vue';
|
||||
export default { name: 'FormFieldTextarea', components: { FormFieldText }, props: { modelValue: Object }, emits: ['update:modelValue'], data() { return { localData: { ...this.modelValue } } } }
|
||||
</script>
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<template><FormFieldText v-model="localData" @update:model-value="$emit('update:modelValue', $event)" /></template>
|
||||
<script>
|
||||
import FormFieldText from './FormFieldText.vue';
|
||||
export default { name: 'FormFieldUrl', components: { FormFieldText }, props: { modelValue: Object }, emits: ['update:modelValue'], data() { return { localData: { ...this.modelValue } } } }
|
||||
</script>
|
||||
@@ -0,0 +1,442 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-white border border-layer-line rounded-lg">
|
||||
<h3 class="text-sm font-medium text-foreground">
|
||||
{{ label || 'Контент' }}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
@click="showBlockPicker = true"
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-primary bg-primary/10 rounded-lg hover:bg-primary/20 transition-all"
|
||||
>
|
||||
<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 4v16m8-8H4" />
|
||||
</svg>
|
||||
Добавить блок
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Blocks List -->
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(block, index) in blocks"
|
||||
:key="block._uid"
|
||||
class="group relative bg-white border border-layer-line rounded-lg overflow-hidden"
|
||||
>
|
||||
<!-- Block Header -->
|
||||
<div class="flex items-center gap-2 px-4 py-3 bg-muted/30 border-b border-layer-line">
|
||||
<!-- Drag Handle -->
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-move text-muted-foreground-1 hover:text-foreground transition-colors"
|
||||
@mousedown="startDrag(index, $event)"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M7 2a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM13 2a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM7 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM13 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM7 14a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM13 14a2 2 0 1 0 0 4 2 2 0 0 0 0-4z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Block Icon & Label -->
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<component :is="getBlockIcon(block.type)" class="w-4 h-4 text-muted-foreground-1" />
|
||||
<span class="text-sm font-medium text-foreground">{{ getBlockLabel(block.type) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
@click="duplicateBlock(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded transition-all"
|
||||
title="Клонировать"
|
||||
>
|
||||
<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="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
|
||||
type="button"
|
||||
@click="toggleBlock(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded transition-all"
|
||||
:title="collapsedBlocks.includes(index) ? 'Развернуть' : 'Свернуть'"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform"
|
||||
:class="{ 'rotate-180': !collapsedBlocks.includes(index) }"
|
||||
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>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeBlock(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-danger hover:bg-danger/10 rounded transition-all"
|
||||
title="Удалить"
|
||||
>
|
||||
<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 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>
|
||||
|
||||
<!-- Block Content -->
|
||||
<div v-show="!collapsedBlocks.includes(index)" class="p-4">
|
||||
<component
|
||||
:is="getBlockComponent(block.type)"
|
||||
v-model="block.data"
|
||||
:all-blocks="blocks"
|
||||
@update="emitChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-if="blocks.length === 0"
|
||||
class="flex flex-col items-center justify-center py-12 px-4 border-2 border-dashed border-layer-line rounded-lg bg-muted/20"
|
||||
>
|
||||
<svg class="w-12 h-12 text-muted-foreground-1 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p class="text-sm font-medium text-foreground mb-1">Нет блоков контента</p>
|
||||
<p class="text-xs text-muted-foreground-1 text-center">Нажмите "Добавить блок" чтобы начать</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Block Picker Modal -->
|
||||
<div
|
||||
v-if="showBlockPicker"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"
|
||||
@click.self="showBlockPicker = false"
|
||||
>
|
||||
<div class="bg-layer border border-layer-line rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-layer-line flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-foreground">Выберите тип блока</h3>
|
||||
<button
|
||||
type="button"
|
||||
@click="showBlockPicker = false"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded transition-all"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6 overflow-y-auto max-h-[60vh]">
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<button
|
||||
v-for="blockType in availableBlocks"
|
||||
:key="blockType.type"
|
||||
type="button"
|
||||
@click="addBlock(blockType.type)"
|
||||
class="flex flex-col items-center gap-2 p-4 border border-layer-line rounded-lg hover:border-primary hover:bg-primary/5 transition-all group"
|
||||
>
|
||||
<component :is="getBlockIcon(blockType.type)" class="w-8 h-8 text-muted-foreground-1 group-hover:text-primary transition-colors" />
|
||||
<span class="text-xs font-medium text-foreground text-center group-hover:text-primary transition-colors">
|
||||
{{ getBlockLabel(blockType.type) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
|
||||
// Block Components (БЕЗ TabBlock — предотвращаем циклическую зависимость)
|
||||
import HeadingBlock from './blocks/HeadingBlock.vue';
|
||||
import ParagraphBlock from './blocks/ParagraphBlock.vue';
|
||||
import ImageBlock from './blocks/ImageBlock.vue';
|
||||
import ImagesBlock from './blocks/ImagesBlock.vue';
|
||||
import FilesBlock from './blocks/FilesBlock.vue';
|
||||
import FastFilesBlock from './blocks/FastFilesBlock.vue';
|
||||
import VideoBlock from './blocks/VideoBlock.vue';
|
||||
import PersonBlock from './blocks/PersonBlock.vue';
|
||||
import StepperBlock from './blocks/StepperBlock.vue';
|
||||
import SliderBlock from './blocks/SliderBlock.vue';
|
||||
import PostItemBlock from './blocks/PostItemBlock.vue';
|
||||
import PostListBlock from './blocks/PostListBlock.vue';
|
||||
import PageItemBlock from './blocks/PageItemBlock.vue';
|
||||
import PageResourceListBlock from './blocks/PageResourceListBlock.vue';
|
||||
import ContactBlock from './blocks/ContactBlock.vue';
|
||||
import CustomFormBlock from './blocks/CustomFormBlock.vue';
|
||||
|
||||
// Icons
|
||||
import IconHeading from './icons/IconHeading.vue';
|
||||
import IconParagraph from './icons/IconParagraph.vue';
|
||||
import IconImage from './icons/IconImage.vue';
|
||||
import IconImages from './icons/IconImages.vue';
|
||||
import IconFiles from './icons/IconFiles.vue';
|
||||
import IconVideo from './icons/IconVideo.vue';
|
||||
import IconPerson from './icons/IconPerson.vue';
|
||||
import IconStepper from './icons/IconStepper.vue';
|
||||
import IconSlider from './icons/IconSlider.vue';
|
||||
import IconPost from './icons/IconPost.vue';
|
||||
import IconPage from './icons/IconPage.vue';
|
||||
import IconContact from './icons/IconContact.vue';
|
||||
import IconForm from './icons/IconForm.vue';
|
||||
import IconArchive from './icons/IconArchive.vue';
|
||||
|
||||
export default {
|
||||
name: 'TabContentBuilder',
|
||||
components: {
|
||||
HeadingBlock,
|
||||
ParagraphBlock,
|
||||
ImageBlock,
|
||||
ImagesBlock,
|
||||
FilesBlock,
|
||||
FastFilesBlock,
|
||||
VideoBlock,
|
||||
PersonBlock,
|
||||
StepperBlock,
|
||||
// TabBlock намеренно исключён — предотвращаем циклическую зависимость
|
||||
SliderBlock,
|
||||
PostItemBlock,
|
||||
PostListBlock,
|
||||
PageItemBlock,
|
||||
PageResourceListBlock,
|
||||
ContactBlock,
|
||||
CustomFormBlock,
|
||||
},
|
||||
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
|
||||
emits: ['update:modelValue'],
|
||||
|
||||
setup(props, { emit }) {
|
||||
const blocks = ref([]);
|
||||
const collapsedBlocks = ref([]);
|
||||
const showBlockPicker = ref(false);
|
||||
let uidCounter = 0;
|
||||
|
||||
// Доступные блоки (БЕЗ 'tabs')
|
||||
const availableBlocks = [
|
||||
{ type: 'heading' },
|
||||
{ type: 'paragraph' },
|
||||
{ type: 'image' },
|
||||
{ type: 'images' },
|
||||
{ type: 'files' },
|
||||
{ type: 'fast_files' },
|
||||
{ type: 'video' },
|
||||
{ type: 'person' },
|
||||
{ type: 'stepper' },
|
||||
// { type: 'tabs' }, // Исключён — предотвращаем циклическую зависимость
|
||||
{ type: 'slider' },
|
||||
{ type: 'postItem' },
|
||||
{ type: 'postsList' },
|
||||
{ type: 'pageItem' },
|
||||
{ type: 'pageResourceList' },
|
||||
{ type: 'contact' },
|
||||
{ type: 'customForm' },
|
||||
];
|
||||
|
||||
const blockLabels = {
|
||||
heading: 'Заголовок',
|
||||
paragraph: 'Текст',
|
||||
image: 'Изображение',
|
||||
images: 'Слайдер изображений',
|
||||
files: 'Файлы',
|
||||
fast_files: 'Быстрая загрузка файлов',
|
||||
video: 'Видео',
|
||||
person: 'Персона',
|
||||
stepper: 'Этапы',
|
||||
slider: 'Слайдер',
|
||||
postItem: 'Конкретная новость',
|
||||
postsList: 'Список новостей',
|
||||
pageItem: 'Конкретная страница',
|
||||
pageResourceList: 'Ресурсы',
|
||||
contact: 'Контакты',
|
||||
customForm: 'Пользовательская форма',
|
||||
};
|
||||
|
||||
const blockIcons = {
|
||||
heading: IconHeading,
|
||||
paragraph: IconParagraph,
|
||||
image: IconImage,
|
||||
images: IconImages,
|
||||
files: IconFiles,
|
||||
fast_files: IconFiles,
|
||||
video: IconVideo,
|
||||
person: IconPerson,
|
||||
stepper: IconStepper,
|
||||
slider: IconSlider,
|
||||
postItem: IconPost,
|
||||
postsList: IconPost,
|
||||
pageItem: IconPage,
|
||||
pageResourceList: IconArchive,
|
||||
contact: IconContact,
|
||||
customForm: IconForm,
|
||||
};
|
||||
|
||||
const blockDefaults = {
|
||||
heading: () => ({ id: `anchor-${Date.now()}`, content: '' }),
|
||||
paragraph: () => ({ seo_active: true, content: '' }),
|
||||
image: () => ({ url: '', alt: '' }),
|
||||
images: () => ({ url: [], alt: '' }),
|
||||
files: () => ({ file: [] }),
|
||||
fast_files: () => ({ path: [] }),
|
||||
video: () => ({ mime: '', title: '', path: '' }),
|
||||
person: () => ({ name: '', photo: '', info: [{ column: '', content: '' }] }),
|
||||
stepper: () => ({ step_name: '', steps: [{ title: '', content: '' }] }),
|
||||
slider: () => ({ slider: '' }),
|
||||
postItem: () => ({ post: null }),
|
||||
postsList: () => ({ count: 5, category: null }),
|
||||
pageItem: () => ({ page: null }),
|
||||
pageResourceList: () => ({ resource: '' }),
|
||||
contact: () => ({ contact: '' }),
|
||||
customForm: () => ({ form: '', settings: { in_modal: false } }),
|
||||
};
|
||||
|
||||
function generateUid() {
|
||||
return `block-${Date.now()}-${uidCounter++}`;
|
||||
}
|
||||
|
||||
function addBlock(type) {
|
||||
blocks.value.push({
|
||||
_uid: generateUid(),
|
||||
type,
|
||||
data: (blockDefaults[type] || blockDefaults.paragraph)()
|
||||
});
|
||||
showBlockPicker.value = false;
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function removeBlock(index) {
|
||||
blocks.value.splice(index, 1);
|
||||
collapsedBlocks.value = collapsedBlocks.value.filter(i => i !== index);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function duplicateBlock(index) {
|
||||
const original = blocks.value[index];
|
||||
blocks.value.splice(index + 1, 0, {
|
||||
_uid: generateUid(),
|
||||
type: original.type,
|
||||
data: JSON.parse(JSON.stringify(original.data))
|
||||
});
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function toggleBlock(index) {
|
||||
const idx = collapsedBlocks.value.indexOf(index);
|
||||
if (idx === -1) {
|
||||
collapsedBlocks.value.push(index);
|
||||
} else {
|
||||
collapsedBlocks.value.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function getBlockComponent(type) {
|
||||
const componentMap = {
|
||||
heading: 'HeadingBlock',
|
||||
paragraph: 'ParagraphBlock',
|
||||
image: 'ImageBlock',
|
||||
images: 'ImagesBlock',
|
||||
files: 'FilesBlock',
|
||||
fast_files: 'FastFilesBlock',
|
||||
video: 'VideoBlock',
|
||||
person: 'PersonBlock',
|
||||
stepper: 'StepperBlock',
|
||||
slider: 'SliderBlock',
|
||||
postItem: 'PostItemBlock',
|
||||
postsList: 'PostListBlock',
|
||||
pageItem: 'PageItemBlock',
|
||||
pageResourceList: 'PageResourceListBlock',
|
||||
contact: 'ContactBlock',
|
||||
customForm: 'CustomFormBlock',
|
||||
};
|
||||
return componentMap[type] || 'ParagraphBlock';
|
||||
}
|
||||
|
||||
function getBlockIcon(type) {
|
||||
return blockIcons[type] || IconParagraph;
|
||||
}
|
||||
|
||||
function getBlockLabel(type) {
|
||||
return blockLabels[type] || type;
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
const output = blocks.value.map(({ _uid, ...block }) => block);
|
||||
emit('update:modelValue', output);
|
||||
}
|
||||
|
||||
// Drag and Drop
|
||||
let dragIndex = null;
|
||||
|
||||
function startDrag(index, event) {
|
||||
dragIndex = index;
|
||||
document.addEventListener('mousemove', onDrag);
|
||||
document.addEventListener('mouseup', stopDrag);
|
||||
}
|
||||
|
||||
function onDrag(event) {
|
||||
// Simplified drag logic - can be enhanced with a library like vuedraggable
|
||||
}
|
||||
|
||||
function stopDrag() {
|
||||
dragIndex = null;
|
||||
document.removeEventListener('mousemove', onDrag);
|
||||
document.removeEventListener('mouseup', stopDrag);
|
||||
}
|
||||
|
||||
// Initialize from modelValue
|
||||
onMounted(() => {
|
||||
if (props.modelValue && props.modelValue.length > 0) {
|
||||
blocks.value = props.modelValue.map(block => ({
|
||||
_uid: generateUid(),
|
||||
...block
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for modelValue changes (e.g., when loading existing content)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (newValue && newValue.length > 0 && blocks.value.length === 0) {
|
||||
// Only initialize if blocks are empty (initial load from server)
|
||||
blocks.value = newValue.map(block => ({
|
||||
_uid: generateUid(),
|
||||
...block
|
||||
}));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
blocks,
|
||||
collapsedBlocks,
|
||||
showBlockPicker,
|
||||
availableBlocks,
|
||||
addBlock,
|
||||
removeBlock,
|
||||
duplicateBlock,
|
||||
toggleBlock,
|
||||
getBlockComponent,
|
||||
getBlockIcon,
|
||||
getBlockLabel,
|
||||
emitChange,
|
||||
startDrag,
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Контактный виджет <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select
|
||||
:value="modelValue.contact"
|
||||
@change="update('contact', $event.target.value)"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
>
|
||||
<option value="">Выберите контакт...</option>
|
||||
<option v-for="contact in contacts" :key="contact.slug" :value="contact.slug">
|
||||
{{ contact.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ContactBlock',
|
||||
props: { modelValue: { type: Object, required: true } },
|
||||
emits: ['update:modelValue'],
|
||||
data() { return { contacts: [] }; },
|
||||
async mounted() {
|
||||
try {
|
||||
const response = await fetch('/api/dashboard/contact-widgets/active');
|
||||
this.contacts = await response.json();
|
||||
} catch (e) { console.error('Failed to load contacts:', e); }
|
||||
},
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Форма <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select
|
||||
:value="modelValue.form"
|
||||
@change="update('form', $event.target.value)"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
>
|
||||
<option value="">Выберите форму...</option>
|
||||
<option v-for="form in forms" :key="form.form_id" :value="form.form_id">
|
||||
{{ form.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="modelValue.settings.in_modal"
|
||||
@change="updateSettings('in_modal', $event.target.checked)"
|
||||
class="h-4 w-4 text-primary focus:ring-primary border-layer-line rounded"
|
||||
/>
|
||||
<label class="text-sm text-foreground">
|
||||
Открывать в модальном окне
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'CustomFormBlock',
|
||||
props: { modelValue: { type: Object, required: true } },
|
||||
emits: ['update:modelValue'],
|
||||
data() { return { forms: [] }; },
|
||||
async mounted() {
|
||||
try {
|
||||
const response = await fetch('/api/dashboard/custom-forms/published');
|
||||
this.forms = await response.json();
|
||||
} catch (e) { console.error('Failed to load forms:', e); }
|
||||
},
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
||||
},
|
||||
updateSettings(field, value) {
|
||||
this.update('settings', { ...this.modelValue.settings, [field]: value });
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="(path, index) in modelValue.path"
|
||||
:key="index"
|
||||
class="flex items-center gap-3 p-3 border border-layer-line rounded-lg"
|
||||
>
|
||||
<div class="flex-1 text-sm text-foreground truncate">
|
||||
{{ path.split('/').pop() || path }}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeFile(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-danger hover:bg-danger/10 rounded transition-all"
|
||||
title="Удалить"
|
||||
>
|
||||
<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 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>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
@change="handleFilesUpload"
|
||||
accept=".pdf,.docx,.xlsx,.pptx,.zip"
|
||||
multiple
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground-1">
|
||||
PDF, DOCX, XLSX, PPTX, ZIP
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FastFilesBlock',
|
||||
props: {
|
||||
modelValue: { type: Object, required: true }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
||||
},
|
||||
removeFile(index) {
|
||||
this.update('path', this.modelValue.path.filter((_, i) => i !== index));
|
||||
},
|
||||
handleFilesUpload(event) {
|
||||
const files = Array.from(event.target.files);
|
||||
const newPaths = [...this.modelValue.path];
|
||||
files.forEach(file => {
|
||||
newPaths.push(URL.createObjectURL(file));
|
||||
});
|
||||
this.update('path', newPaths);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="(file, index) in modelValue.file"
|
||||
:key="index"
|
||||
class="flex items-center gap-3 p-3 border border-layer-line rounded-lg"
|
||||
>
|
||||
<div class="flex-1 grid grid-cols-2 gap-2">
|
||||
<input
|
||||
:value="file.title"
|
||||
@input="updateFile(index, 'title', $event.target.value)"
|
||||
type="text"
|
||||
placeholder="Название файла"
|
||||
class="px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground text-sm"
|
||||
/>
|
||||
<div class="text-xs text-muted-foreground-1 flex items-center">
|
||||
<span>{{ file.expansion?.toUpperCase() || '—' }}</span>
|
||||
<span class="mx-1">•</span>
|
||||
<span>{{ file.size || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeFile(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-danger hover:bg-danger/10 rounded transition-all"
|
||||
title="Удалить"
|
||||
>
|
||||
<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 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>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
@change="handleFilesUpload"
|
||||
accept=".pdf,.docx,.xlsx,.pptx,.zip"
|
||||
multiple
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground-1">
|
||||
PDF, DOCX, XLSX, PPTX, ZIP. Макс. 512MB
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="addFile"
|
||||
class="text-sm text-primary hover:text-primary-hover transition-colors"
|
||||
>
|
||||
+ Добавить файл
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FilesBlock',
|
||||
props: {
|
||||
modelValue: { type: Object, required: true }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
||||
},
|
||||
updateFile(index, field, value) {
|
||||
const files = [...this.modelValue.file];
|
||||
files[index] = { ...files[index], [field]: value };
|
||||
this.update('file', files);
|
||||
},
|
||||
addFile() {
|
||||
this.update('file', [...this.modelValue.file, { title: '', path: '', expansion: '', size: '', time_added: Date.now() }]);
|
||||
},
|
||||
removeFile(index) {
|
||||
this.update('file', this.modelValue.file.filter((_, i) => i !== index));
|
||||
},
|
||||
handleFilesUpload(event) {
|
||||
const files = Array.from(event.target.files);
|
||||
files.forEach(file => {
|
||||
const extension = file.name.split('.').pop().toLowerCase();
|
||||
const size = (file.size / (1024 * 1024)).toFixed(2) + ' MB';
|
||||
this.update('file', [...this.modelValue.file, {
|
||||
title: file.name,
|
||||
path: URL.createObjectURL(file),
|
||||
expansion: extension,
|
||||
size: size,
|
||||
time_added: Date.now()
|
||||
}]);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Заголовок <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
:value="modelValue.content"
|
||||
@input="update('content', $event.target.value)"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="Введите заголовок раздела"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Якорь (ID)
|
||||
</label>
|
||||
<input
|
||||
:value="modelValue.id"
|
||||
@input="update('id', $event.target.value)"
|
||||
type="text"
|
||||
placeholder="anchor-link-..."
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-muted text-muted-foreground-1 text-sm"
|
||||
readonly
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">Генерируется автоматически</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'HeadingBlock',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', {
|
||||
...this.modelValue,
|
||||
[field]: value
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Изображение <span class="text-danger">*</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<div v-if="modelValue.url" class="relative">
|
||||
<img :src="modelValue.url" alt="Preview" class="w-32 h-32 object-cover rounded-lg border border-layer-line" />
|
||||
<button
|
||||
type="button"
|
||||
@click="update('url', '')"
|
||||
class="absolute -top-2 -right-2 p-1 bg-danger text-white rounded-full hover:bg-danger-hover transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<input
|
||||
type="file"
|
||||
@change="handleFileUpload"
|
||||
accept="image/*"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">
|
||||
Загрузите изображение (JPG, PNG, WebP)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Alt текст
|
||||
</label>
|
||||
<input
|
||||
:value="modelValue.alt"
|
||||
@input="update('alt', $event.target.value)"
|
||||
type="text"
|
||||
placeholder="Описание изображения"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ImageBlock',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', {
|
||||
...this.modelValue,
|
||||
[field]: value
|
||||
});
|
||||
},
|
||||
handleFileUpload(event) {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
this.update('url', e.target.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Изображения <span class="text-danger">*</span> (макс. 5)
|
||||
</label>
|
||||
<div class="grid grid-cols-3 gap-3 mb-3">
|
||||
<div
|
||||
v-for="(url, index) in modelValue.url"
|
||||
:key="index"
|
||||
class="relative group"
|
||||
>
|
||||
<img :src="url" alt="Preview" class="w-full h-24 object-cover rounded-lg border border-layer-line" />
|
||||
<button
|
||||
type="button"
|
||||
@click="removeImage(index)"
|
||||
class="absolute -top-2 -right-2 p-1 bg-danger text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Удалить"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
@change="handleFilesUpload"
|
||||
accept="image/*"
|
||||
multiple
|
||||
:disabled="modelValue.url.length >= 5"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">
|
||||
Можно загрузить до 5 изображений (JPG, PNG, WebP)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Alt текст
|
||||
</label>
|
||||
<input
|
||||
:value="modelValue.alt"
|
||||
@input="update('alt', $event.target.value)"
|
||||
type="text"
|
||||
placeholder="Описание изображений"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ImagesBlock',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', {
|
||||
...this.modelValue,
|
||||
[field]: value
|
||||
});
|
||||
},
|
||||
handleFilesUpload(event) {
|
||||
const files = Array.from(event.target.files);
|
||||
const remaining = 5 - this.modelValue.url.length;
|
||||
const toProcess = files.slice(0, remaining);
|
||||
|
||||
let processed = 0;
|
||||
toProcess.forEach((file) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const newUrls = [...this.modelValue.url, e.target.result];
|
||||
this.update('url', newUrls);
|
||||
processed++;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
},
|
||||
removeImage(index) {
|
||||
const newUrls = this.modelValue.url.filter((_, i) => i !== index);
|
||||
this.update('url', newUrls);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Страница <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select
|
||||
:value="modelValue.page"
|
||||
@change="update('page', parseInt($event.target.value))"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
>
|
||||
<option :value="null">Выберите страницу...</option>
|
||||
<option v-for="page in pages" :key="page.id" :value="page.id">
|
||||
{{ page.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'PageItemBlock',
|
||||
props: { modelValue: { type: Object, required: true } },
|
||||
emits: ['update:modelValue'],
|
||||
data() { return { pages: [] }; },
|
||||
async mounted() {
|
||||
try {
|
||||
const response = await fetch('/api/dashboard/pages/visible');
|
||||
this.pages = await response.json();
|
||||
} catch (e) { console.error('Failed to load pages:', e); }
|
||||
},
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Список ресурсов <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select
|
||||
:value="modelValue.resource"
|
||||
@change="update('resource', $event.target.value)"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
>
|
||||
<option value="">Выберите ресурс...</option>
|
||||
<option v-for="resource in resources" :key="resource.slug" :value="resource.slug">
|
||||
{{ resource.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'PageResourceListBlock',
|
||||
props: { modelValue: { type: Object, required: true } },
|
||||
emits: ['update:modelValue'],
|
||||
data() { return { resources: [] }; },
|
||||
async mounted() {
|
||||
try {
|
||||
const response = await fetch('/api/dashboard/page-reference-lists/active');
|
||||
this.resources = await response.json();
|
||||
} catch (e) { console.error('Failed to load resources:', e); }
|
||||
},
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="modelValue.seo_active"
|
||||
@change="update('seo_active', $event.target.checked)"
|
||||
class="h-4 w-4 text-primary focus:ring-primary border-layer-line rounded"
|
||||
/>
|
||||
<label class="text-sm text-foreground">
|
||||
Активировать SEO для этого блока
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Текст <span class="text-danger">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
:id="editorId"
|
||||
:value="modelValue.content"
|
||||
rows="8"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent resize-y"
|
||||
></textarea>
|
||||
<p v-if="loading" class="mt-1 text-xs text-primary">
|
||||
Загрузка редактора...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
let editorCounter = 0;
|
||||
|
||||
export default {
|
||||
name: 'ParagraphBlock',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
data() {
|
||||
return {
|
||||
editorId: `paragraph-editor-${++editorCounter}`,
|
||||
editor: null,
|
||||
loading: true
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.waitForTinyMCE();
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.destroyEditor();
|
||||
},
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', {
|
||||
...this.modelValue,
|
||||
[field]: value
|
||||
});
|
||||
},
|
||||
waitForTinyMCE() {
|
||||
if (typeof tinymce !== 'undefined') {
|
||||
this.initEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ждем загрузки TinyMCE с таймаутом
|
||||
let attempts = 0;
|
||||
const maxAttempts = 50; // 5 секунд
|
||||
const interval = setInterval(() => {
|
||||
attempts++;
|
||||
if (typeof tinymce !== 'undefined') {
|
||||
clearInterval(interval);
|
||||
this.initEditor();
|
||||
} else if (attempts >= maxAttempts) {
|
||||
clearInterval(interval);
|
||||
this.loading = false;
|
||||
console.error('TinyMCE не загрузился в течение 5 секунд');
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
initEditor() {
|
||||
this.loading = true;
|
||||
|
||||
tinymce.init({
|
||||
selector: `#${this.editorId}`,
|
||||
license_key: 'gpl',
|
||||
autoresize_bottom_margin: 20,
|
||||
autoresize_overflow_padding: 20,
|
||||
max_height: 600,
|
||||
menubar: false,
|
||||
statusbar: true,
|
||||
branding: false,
|
||||
plugins: [
|
||||
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
|
||||
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
|
||||
'insertdatetime', 'media', 'table', 'code', 'help', 'wordcount',
|
||||
'autoresize'
|
||||
],
|
||||
toolbar: 'undo redo | blocks | ' +
|
||||
'bold italic forecolor | alignleft aligncenter ' +
|
||||
'alignright alignjustify | bullist numlist outdent indent | ' +
|
||||
'removeformat | help',
|
||||
content_style: 'body { font-family: Inter, -apple-system, sans-serif; font-size: 14px; }',
|
||||
setup: (editor) => {
|
||||
this.editor = editor;
|
||||
editor.on('init', () => {
|
||||
editor.setContent(this.modelValue.content || '');
|
||||
this.loading = false;
|
||||
});
|
||||
editor.on('change', () => {
|
||||
this.update('content', editor.getContent());
|
||||
});
|
||||
editor.on('Remove', () => {
|
||||
this.editor = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
destroyEditor() {
|
||||
if (this.editor && typeof tinymce !== 'undefined') {
|
||||
tinymce.remove(this.editor);
|
||||
this.editor = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
ФИО <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
:value="modelValue.name"
|
||||
@input="update('name', $event.target.value)"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="Иванов Иван Иванович"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Фото
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
@change="handlePhotoUpload"
|
||||
accept="image/*"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="block text-sm font-medium text-foreground">
|
||||
Информация <span class="text-danger">*</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="addInfoRow"
|
||||
class="text-sm text-primary hover:text-primary-hover transition-colors"
|
||||
>
|
||||
+ Добавить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(info, index) in modelValue.info"
|
||||
:key="index"
|
||||
class="grid grid-cols-2 gap-2 p-3 border border-layer-line rounded-lg"
|
||||
>
|
||||
<input
|
||||
:value="info.column"
|
||||
@input="updateInfo(index, 'column', $event.target.value)"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Должность"
|
||||
class="px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground text-sm"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<textarea
|
||||
:value="info.content"
|
||||
@input="updateInfo(index, 'content', $event.target.value)"
|
||||
required
|
||||
maxlength="1000"
|
||||
placeholder="Описание"
|
||||
rows="2"
|
||||
class="flex-1 px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground text-sm resize-none"
|
||||
></textarea>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeInfo(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-danger hover:bg-danger/10 rounded transition-all self-center"
|
||||
title="Удалить"
|
||||
>
|
||||
<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 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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'PersonBlock',
|
||||
props: {
|
||||
modelValue: { type: Object, required: true }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
||||
},
|
||||
updateInfo(index, field, value) {
|
||||
const info = [...this.modelValue.info];
|
||||
info[index] = { ...info[index], [field]: value };
|
||||
this.update('info', info);
|
||||
},
|
||||
addInfoRow() {
|
||||
this.update('info', [...this.modelValue.info, { column: '', content: '' }]);
|
||||
},
|
||||
removeInfo(index) {
|
||||
if (this.modelValue.info.length > 1) {
|
||||
this.update('info', this.modelValue.info.filter((_, i) => i !== index));
|
||||
}
|
||||
},
|
||||
handlePhotoUpload(event) {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => this.update('photo', e.target.result);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Новость <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select
|
||||
:value="modelValue.post"
|
||||
@change="update('post', parseInt($event.target.value))"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
>
|
||||
<option :value="null">Выберите новость...</option>
|
||||
<option v-for="post in posts" :key="post.id" :value="post.id">
|
||||
{{ post.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'PostItemBlock',
|
||||
props: { modelValue: { type: Object, required: true } },
|
||||
emits: ['update:modelValue'],
|
||||
data() { return { posts: [] }; },
|
||||
async mounted() {
|
||||
try {
|
||||
const response = await fetch('/api/dashboard/posts/published?limit=100');
|
||||
this.posts = await response.json();
|
||||
} catch (e) { console.error('Failed to load posts:', e); }
|
||||
},
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Количество
|
||||
</label>
|
||||
<input
|
||||
:value="modelValue.count"
|
||||
@input="update('count', parseInt($event.target.value) || 5)"
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Категория
|
||||
</label>
|
||||
<select
|
||||
:value="modelValue.category"
|
||||
@change="update('category', $event.target.value ? parseInt($event.target.value) : null)"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
>
|
||||
<option :value="null">Все категории</option>
|
||||
<option v-for="cat in categories" :key="cat.id" :value="cat.id">
|
||||
{{ cat.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'PostListBlock',
|
||||
props: { modelValue: { type: Object, required: true } },
|
||||
emits: ['update:modelValue'],
|
||||
data() { return { categories: [] }; },
|
||||
async mounted() {
|
||||
try {
|
||||
const response = await fetch('/api/dashboard/categories');
|
||||
this.categories = await response.json();
|
||||
} catch (e) { console.error('Failed to load categories:', e); }
|
||||
},
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Слайдер <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select
|
||||
:value="modelValue.slider"
|
||||
@change="update('slider', $event.target.value)"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
>
|
||||
<option value="">Выберите слайдер...</option>
|
||||
<option v-for="slider in sliders" :key="slider.slug" :value="slider.slug">
|
||||
{{ slider.title }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">
|
||||
Только активные слайдеры с изображениями
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'SliderBlock',
|
||||
props: {
|
||||
modelValue: { type: Object, required: true }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
data() {
|
||||
return { sliders: [] };
|
||||
},
|
||||
async mounted() {
|
||||
// Загрузка слайдеров с бэкенда
|
||||
try {
|
||||
const response = await fetch('/api/dashboard/sliders/active');
|
||||
this.sliders = await response.json();
|
||||
} catch (e) {
|
||||
console.error('Failed to load sliders:', e);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Название процесса <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
:value="modelValue.step_name"
|
||||
@input="update('step_name', $event.target.value)"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="Процесс оформления"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="block text-sm font-medium text-foreground">
|
||||
Шаги <span class="text-danger">*</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="addStep"
|
||||
class="text-sm text-primary hover:text-primary-hover transition-colors"
|
||||
>
|
||||
+ Добавить шаг
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(step, index) in modelValue.steps"
|
||||
:key="index"
|
||||
class="p-4 border border-layer-line rounded-lg space-y-2"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-primary/10 text-primary text-xs font-medium">
|
||||
{{ index + 1 }}
|
||||
</span>
|
||||
<input
|
||||
:value="step.title"
|
||||
@input="updateStep(index, 'title', $event.target.value)"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="Название шага"
|
||||
class="flex-1 px-3 py-1.5 border border-layer-line rounded-lg bg-white text-foreground text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeStep(index)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-danger hover:bg-danger/10 rounded transition-all"
|
||||
:disabled="modelValue.steps.length <= 1"
|
||||
title="Удалить"
|
||||
>
|
||||
<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 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>
|
||||
<textarea
|
||||
:id="`step-content-${index}-${_uid}`"
|
||||
:value="step.content"
|
||||
@input="updateStep(index, 'content', $event.target.value)"
|
||||
required
|
||||
rows="3"
|
||||
placeholder="Описание шага"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground text-sm resize-y"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'StepperBlock',
|
||||
props: {
|
||||
modelValue: { type: Object, required: true }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
||||
},
|
||||
updateStep(index, field, value) {
|
||||
const steps = [...this.modelValue.steps];
|
||||
steps[index] = { ...steps[index], [field]: value };
|
||||
this.update('steps', steps);
|
||||
},
|
||||
addStep() {
|
||||
this.update('steps', [...this.modelValue.steps, { title: '', content: '' }]);
|
||||
},
|
||||
removeStep(index) {
|
||||
if (this.modelValue.steps.length > 1) {
|
||||
this.update('steps', this.modelValue.steps.filter((_, i) => i !== index));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Settings -->
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="modelValue.settings.is_accordion"
|
||||
@change="updateSettings('is_accordion', $event.target.checked)"
|
||||
class="h-4 w-4 text-primary focus:ring-primary border-layer-line rounded"
|
||||
/>
|
||||
<label class="text-sm text-foreground">
|
||||
Режим аккордеона (только одна вкладка открыта)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="block text-sm font-medium text-foreground">
|
||||
Вкладки <span class="text-danger">*</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="addTab"
|
||||
class="text-sm text-primary hover:text-primary-hover transition-colors"
|
||||
>
|
||||
+ Добавить вкладку
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(tab, tabIndex) in tabs"
|
||||
:key="tab._uid"
|
||||
class="border border-layer-line rounded-lg overflow-hidden"
|
||||
>
|
||||
<!-- Tab Header -->
|
||||
<div class="flex items-center gap-2 px-4 py-3 bg-muted/30 border-b border-layer-line">
|
||||
<input
|
||||
:value="tab.title"
|
||||
@input="updateTab(tabIndex, 'title', $event.target.value)"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="Название вкладки"
|
||||
class="flex-1 px-3 py-1.5 border border-layer-line rounded-lg bg-white text-foreground text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeTab(tabIndex)"
|
||||
class="p-1.5 text-muted-foreground-1 hover:text-danger hover:bg-danger/10 rounded transition-all"
|
||||
:disabled="tabs.length <= 1"
|
||||
title="Удалить вкладку"
|
||||
>
|
||||
<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 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>
|
||||
|
||||
<!-- Tab Content (Nested TabContentBuilder) -->
|
||||
<div class="p-4">
|
||||
<TabContentBuilder
|
||||
:model-value="tab.content"
|
||||
@update:model-value="updateTab(tabIndex, 'content', $event)"
|
||||
label="Содержимое вкладки"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TabContentBuilder from '../TabContentBuilder.vue';
|
||||
|
||||
export default {
|
||||
name: 'TabBlock',
|
||||
components: {
|
||||
TabContentBuilder
|
||||
},
|
||||
props: {
|
||||
modelValue: { type: Object, required: true }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
computed: {
|
||||
tabs() {
|
||||
const rawTabs = this.modelValue?.tab || [];
|
||||
// Нормализуем _uid для всех вкладок (если пришли с сервера без UID)
|
||||
return rawTabs.map(tab => ({
|
||||
...tab,
|
||||
_uid: tab._uid || `tab-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
}));
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
||||
},
|
||||
updateSettings(field, value) {
|
||||
this.update('settings', { ...this.modelValue.settings, [field]: value });
|
||||
},
|
||||
updateTab(index, field, value) {
|
||||
const tabs = [...this.tabs];
|
||||
tabs[index] = { ...tabs[index], [field]: value };
|
||||
this.update('tab', tabs);
|
||||
},
|
||||
addTab() {
|
||||
const newTab = {
|
||||
_uid: `tab-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
title: '',
|
||||
content: []
|
||||
};
|
||||
this.update('tab', [...this.tabs, newTab]);
|
||||
},
|
||||
removeTab(index) {
|
||||
if (this.tabs.length > 1) {
|
||||
this.update('tab', this.tabs.filter((_, i) => i !== index));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Видеофайл <span class="text-danger">*</span>
|
||||
</label>
|
||||
<div v-if="modelValue.path" class="mb-3">
|
||||
<video :src="modelValue.path" controls class="w-full max-w-md rounded-lg border border-layer-line"></video>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
@change="handleFileUpload"
|
||||
accept="video/*"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground-1">
|
||||
mp4, mov, avi, webm, ogg
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Название видео <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
:value="modelValue.title"
|
||||
@input="update('title', $event.target.value)"
|
||||
type="text"
|
||||
required
|
||||
maxlength="255"
|
||||
placeholder="Введите название видео"
|
||||
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'VideoBlock',
|
||||
props: {
|
||||
modelValue: { type: Object, required: true }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
methods: {
|
||||
update(field, value) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
||||
},
|
||||
handleFileUpload(event) {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
this.update('mime', file.type);
|
||||
this.update('path', URL.createObjectURL(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 3v4a1 1 0 001 1h4" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 8h2m8 8h2m-2-8v2m-8 8v2" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</template>
|
||||
Reference in New Issue
Block a user