This commit is contained in:
F4ilji
2026-04-07 17:54:26 +05:00
parent 04adba6682
commit 936b1fb5b0
468 changed files with 43498 additions and 4176 deletions
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -0,0 +1,96 @@
<template>
<BasicIcon
v-if="iconName"
:name="iconName"
:class="iconClass"
:color="color"
/>
</template>
<script>
import BasicIcon from '../../../componentss/ui/icons/BasicIcon.vue';
/**
* DashboardIcon - стандартизированный компонент иконок для Dashboard
*
* Использует SVG sprite систему через BasicIcon.
* Все Heroicons доступны из папки resources/js/assets/icons/svg/
*
* @example
* // Базовое использование
* <DashboardIcon name="building-office" />
*
* @example
* // С размером и цветом
* <DashboardIcon name="plus" size="5" color="text-primary" />
*
* @example
* // С кастомными классами
* <DashboardIcon name="trash" size="4" class="hover:text-danger" />
*/
export default {
name: 'DashboardIcon',
components: {
BasicIcon,
},
props: {
/**
* Имя иконки (без префикса heroicon-)
* Примеры: 'building-office', 'plus', 'trash', 'pencil-square'
*/
name: {
type: String,
required: true,
},
/**
* Размер иконки (в единицах Tailwind: 3, 4, 5, 6 и т.д.)
* @default '5'
*/
size: {
type: [String, Number],
default: '5',
},
/**
* Цвет иконки (Tailwind класс)
* @default 'currentColor'
*/
color: {
type: String,
default: 'currentColor',
},
/**
* Дополнительные CSS классы
*/
class: {
type: String,
default: '',
},
},
computed: {
iconName() {
if (!this.name) return '';
// Добавляем префикс heroicon-o- (outline) по умолчанию
// Если имя уже содержит префикс, используем как есть
if (this.name.startsWith('heroicon-')) {
return this.name;
}
return `heroicon-o-${this.name}`;
},
iconClass() {
const sizeMap = {
'3': 'w-3 h-3',
'4': 'w-4 h-4',
'5': 'w-5 h-5',
'6': 'w-6 h-6',
'7': 'w-7 h-7',
'8': 'w-8 h-8',
'10': 'w-10 h-10',
'12': 'w-12 h-12',
};
const sizeClass = sizeMap[String(this.size)] || 'w-5 h-5';
return `${sizeClass} ${this.class}`.trim();
},
},
}
</script>
@@ -0,0 +1,67 @@
<template>
<div class="min-h-screen bg-background-2">
<!-- Sidebar -->
<DashboardSidebar :is-open="sidebarOpen" @close="closeSidebar" />
<!-- Mobile Menu Button -->
<button
@click="openSidebar"
class="lg:hidden fixed top-4 left-4 z-30 p-2 rounded-lg bg-layer border border-layer-line shadow-sm hover:bg-primary-50 transition-all"
aria-label="Открыть меню"
>
<DashboardIcon name="bars-3" size="5" class="text-foreground" />
</button>
<!-- Main Content (with sidebar offset on desktop) -->
<div class="lg:pl-64">
<!-- Header -->
<header class="bg-layer/95 backdrop-blur-md border-b border-layer-line sticky top-0 z-20 h-16">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full">
<div class="flex items-center h-full justify-between gap-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center flex-shrink-0">
<slot name="header-icon">
<DashboardIcon name="document-text" size="5" class="text-primary" />
</slot>
</div>
<div>
<h1 class="text-sm font-medium text-foreground leading-tight">
<slot name="header-title">Панель управления</slot>
</h1>
<p class="text-xs text-muted-foreground-1 leading-tight">
<slot name="header-subtitle"></slot>
</p>
</div>
</div>
<div class="flex-shrink-0">
<slot name="header-actions"></slot>
</div>
</div>
</div>
</header>
<!-- Page Content -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<!-- Breadcrumbs -->
<slot name="breadcrumbs"></slot>
<slot></slot>
</main>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import DashboardSidebar from './DashboardSidebar.vue';
import DashboardIcon from './DashboardIcon.vue';
const sidebarOpen = ref(false);
const openSidebar = () => {
sidebarOpen.value = true;
};
const closeSidebar = () => {
sidebarOpen.value = false;
};
</script>
@@ -0,0 +1,119 @@
<template>
<div>
<!-- Desktop Sidebar (always visible on lg+) -->
<aside class="hidden lg:flex lg:flex-col lg:w-64 lg:fixed lg:inset-y-0 bg-layer border-r border-layer-line z-30">
<div class="flex flex-col h-full">
<SidebarLogo />
<SidebarNav :mobile="false" @child-click="noop" />
<SidebarUser />
</div>
</aside>
<!-- Mobile Sidebar Overlay -->
<transition
enter-active-class="transition-opacity duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-150"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isOpen"
class="fixed inset-0 bg-black/50 z-40 lg:hidden"
@click="closeSidebar"
aria-hidden="true"
></div>
</transition>
<!-- Mobile Sidebar -->
<transition
enter-active-class="transition-transform duration-200 ease-out"
enter-from-class="-translate-x-full"
enter-to-class="translate-x-0"
leave-active-class="transition-transform duration-150 ease-in"
leave-from-class="translate-x-0"
leave-to-class="-translate-x-full"
>
<div
v-if="isOpen"
class="fixed inset-y-0 left-0 w-72 bg-layer border-r border-layer-line z-50 flex flex-col lg:hidden"
role="dialog"
aria-modal="true"
aria-label="Навигация"
@keydown.escape="closeSidebar"
>
<!-- Mobile Header -->
<div class="flex items-center justify-between px-4 py-4 border-b border-layer-line">
<SidebarLogo />
<button
@click="closeSidebar"
class="p-2 rounded-lg hover:bg-muted-hover transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50"
aria-label="Закрыть меню"
>
<DashboardIcon name="x-mark" size="5" class="text-foreground" />
</button>
</div>
<nav ref="mobileNavRef" class="flex-1 overflow-y-auto focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50" tabindex="-1">
<SidebarNav :mobile="true" @child-click="closeSidebar" />
</nav>
<SidebarUser />
</div>
</transition>
</div>
</template>
<script setup>
import { ref, watch, nextTick, onUnmounted } from 'vue';
import SidebarLogo from './SidebarLogo.vue';
import SidebarNav from './SidebarNav.vue';
import SidebarUser from './SidebarUser.vue';
import DashboardIcon from './DashboardIcon.vue';
const props = defineProps({
isOpen: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['close']);
const mobileNavRef = ref(null);
const previousActiveElement = ref(null);
const closeSidebar = () => {
emit('close');
};
const noop = () => {};
// Блокировка скролла и управление фокусом
watch(() => props.isOpen, async (isOpen) => {
if (isOpen) {
// Сохраняем предыдущий активный элемент
previousActiveElement.value = document.activeElement;
// Блокируем скролл body
document.body.style.overflow = 'hidden';
// Фокус на первый интерактивный элемент внутри модалки
await nextTick();
const focusableElement = mobileNavRef.value?.querySelector('a, button');
if (focusableElement) {
focusableElement.focus();
}
} else {
// Восстанавливаем скролл
document.body.style.overflow = '';
// Возвращаем фокус на предыдущий элемент
if (previousActiveElement.value && document.body.contains(previousActiveElement.value)) {
previousActiveElement.value.focus();
}
}
});
// Cleanup при unmount — предотвращаем утечку scroll lock
onUnmounted(() => {
document.body.style.overflow = '';
});
</script>
@@ -0,0 +1,5 @@
<template>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" v-bind="$attrs">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</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="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</template>
@@ -0,0 +1,5 @@
<template>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" v-bind="$attrs">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 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" v-bind="$attrs">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</template>
@@ -0,0 +1,5 @@
<template>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" v-bind="$attrs">
<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" v-bind="$attrs">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
</template>
@@ -0,0 +1,5 @@
<template>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" v-bind="$attrs">
<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,15 @@
<template>
<div class="flex items-center gap-3 px-4 h-16 border-b border-layer-line">
<div class="w-9 h-9 bg-primary/10 rounded-lg flex items-center justify-center flex-shrink-0">
<DashboardIcon name="document-text" size="5" class="text-primary" />
</div>
<div class="flex flex-col">
<span class="text-sm font-semibold text-foreground">NTSPI</span>
<span class="text-xs text-muted-foreground-1">Dashboard</span>
</div>
</div>
</template>
<script setup>
import DashboardIcon from './DashboardIcon.vue';
</script>
@@ -0,0 +1,74 @@
<template>
<nav class="flex-1 overflow-y-auto px-3 py-4">
<div class="space-y-1">
<SidebarNavItem
v-for="item in menuItems"
:key="item.key"
:item="item"
:mobile="mobile"
:is-expanded="expandedKey === item.key"
@child-click="$emit('child-click')"
/>
</div>
<!-- Divider -->
<div class="my-4 border-t border-layer-line"></div>
<!-- Quick Actions Section -->
<div>
<h3 class="px-3 mb-2 text-xs font-semibold text-muted-foreground-1 uppercase tracking-wide">
Быстрые действия
</h3>
<div class="space-y-1">
<a
v-for="action in quickActions"
:key="action.label"
:href="action.external ? action.href : route(action.route)"
:target="action.external ? '_blank' : null"
:rel="action.external ? 'noopener noreferrer' : null"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-foreground hover:bg-primary-50 hover:text-primary transition-all duration-200 group"
>
<component :is="iconMap[action.icon]" class="w-5 h-5 flex-shrink-0" />
<span>{{ action.label }}</span>
<DashboardIcon
v-if="action.external"
name="arrow-up-right"
size="4"
class="ml-auto text-muted-foreground-2 group-hover:text-primary"
/>
</a>
</div>
</div>
</nav>
</template>
<script setup>
import { computed } from 'vue';
import { menuItems } from './menuConfig';
import { quickActions } from './quickActionsConfig';
import SidebarNavItem from './SidebarNavItem.vue';
import DashboardIcon from './DashboardIcon.vue';
defineProps({
mobile: { type: Boolean, default: false },
});
defineEmits(['child-click']);
const iconMap = {
cog: DashboardIcon,
};
// Открываем только аккордеон активной страницы
const expandedKey = computed(() => {
const currentRoute = route().current();
for (const item of menuItems) {
if (item.activePrefixes && item.activePrefixes.some(prefix => currentRoute.startsWith(prefix))) {
return item.key;
}
}
return null;
});
</script>
@@ -0,0 +1,127 @@
<template>
<!-- Simple link (no children) -->
<Link
v-if="!item.children"
:href="route(item.route)"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 group"
:class="linkClass"
:aria-current="isActiveItem ? 'page' : null"
>
<DashboardIcon :name="iconComponent" size="5" class="flex-shrink-0" />
<span>{{ item.label }}</span>
</Link>
<!-- Collapsible section -->
<div v-else class="space-y-1">
<button
@click="toggle"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 group"
:class="linkClass"
:aria-expanded="isExpanded"
:aria-controls="`sidebar-section-${item.key}`"
>
<DashboardIcon :name="iconComponent" size="5" class="flex-shrink-0" />
<span class="flex-1 text-left">{{ item.label }}</span>
<DashboardIcon
name="chevron-down"
size="4"
class="transition-transform duration-200"
:class="{ 'rotate-180': isExpanded }"
aria-hidden="true"
/>
</button>
<div v-show="isExpanded" :id="`sidebar-section-${item.key}`" class="ml-8 space-y-1">
<Link
v-for="child in item.children"
:key="child.route"
:href="route(child.route)"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-all duration-200"
:class="getChildClass(child.route)"
@click="onChildClick"
>
<span class="w-1.5 h-1.5 rounded-full bg-current" aria-hidden="true"></span>
{{ child.label }}
</Link>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue';
import { Link, usePage } from '@inertiajs/vue3';
import DashboardIcon from './DashboardIcon.vue';
const props = defineProps({
item: { type: Object, required: true },
mobile: { type: Boolean, default: false },
isExpanded: { type: Boolean, default: false },
});
const emit = defineEmits(['child-click', 'toggle']);
const iconMap = {
home: 'home',
document: 'document-text',
book: 'book-open',
calendar: 'calendar',
image: 'photo',
building: 'building-office',
cog: 'cog-6-tooth',
upload: 'document-text',
folder: 'folder',
beaker: 'beaker',
'user-circle': 'user-circle',
'rectangle-stack': 'rectangle-stack',
};
const iconComponent = computed(() => iconMap[props.item.icon] || 'home');
const isActiveItem = computed(() => {
if (!props.item.route) return false;
return route().current(props.item.route);
});
const isSectionActive = computed(() => {
if (!props.item.activePrefixes) return false;
return props.item.activePrefixes.some((prefix) => route().current(prefix + '*'));
});
// Локальное состояние для ручного переключения
const localExpanded = ref(props.isExpanded);
// При изменении пропса обновляем локальное состояние
watch(() => props.isExpanded, (val) => {
localExpanded.value = val;
});
// При смене роута сбрасываем на значение пропса (только активный аккордеон)
watch(() => route().current(), () => {
localExpanded.value = props.isExpanded;
});
const toggle = () => {
localExpanded.value = !localExpanded.value;
};
// Используем локальное состояние в template
const isExpanded = computed(() => localExpanded.value);
const linkClass = computed(() => {
const active = isActiveItem.value || isSectionActive.value;
const hasChildren = !!props.item.children;
return active
? (hasChildren ? 'bg-primary/5 text-primary' : 'bg-primary/10 text-primary')
: 'text-foreground hover:bg-primary-50 hover:text-primary';
});
const getChildClass = (childRoute) => {
return route().current(childRoute)
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground-1 hover:bg-primary-50 hover:text-primary';
};
const onChildClick = () => {
emit('child-click');
};
</script>
@@ -0,0 +1,46 @@
<template>
<div class="border-t border-layer-line px-3 py-4">
<div class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-primary-50 transition-all duration-200 cursor-pointer group">
<div class="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<DashboardIcon name="user" size="5" class="text-primary" />
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">
{{ userName }}
</p>
<p class="text-xs text-muted-foreground-1">
{{ userEmail }}
</p>
</div>
<button
@click="logout"
class="p-1.5 rounded-lg hover:bg-rose-50 text-muted-foreground-1 hover:text-rose-600 transition-all duration-200 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"
title="Выйти"
aria-label="Выйти из системы"
>
<DashboardIcon name="arrow-right-on-rectangle" size="4" />
</button>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { usePage, router } from '@inertiajs/vue3';
import DashboardIcon from './DashboardIcon.vue';
const page = usePage();
const userName = computed(() => page.props.auth?.user?.name || 'Пользователь');
const userEmail = computed(() => page.props.auth?.user?.email || 'user@ntspi.ru');
const logout = () => {
if (confirm('Вы уверены, что хотите выйти?')) {
router.post(route('logout'), {}, {
onSuccess: () => {
// Redirect to login page after logout
},
});
}
};
</script>
@@ -0,0 +1,124 @@
/**
* Sidebar menu configuration.
* Each item defines a navigation section with optional collapsible children.
*
* @typedef {Object} MenuItem
* @property {string} key - Unique identifier (used for expand state)
* @property {string} label - Display name
* @property {string} icon - Icon name (mapped in SidebarNavItem)
* @property {string|null} route - Parent route name (null for simple links)
* @property {Array<{label: string, route: string}>} [children] - Sub-items
* @property {string[]} [activePrefixes] - Route prefixes to detect active state
*/
/** @type {MenuItem[]} */
export const menuItems = [
{
key: 'home',
label: 'Главная',
icon: 'home',
route: 'dashboard.index',
},
{
key: 'posts',
label: 'Новости',
icon: 'document',
route: null,
activePrefixes: ['dashboard.posts', 'dashboard.sliders'],
children: [
{ label: 'Все новости', route: 'dashboard.posts.index' },
{ label: 'AI подготовка', route: 'dashboard.posts.ai-prepared' },
{ label: 'Слайдеры', route: 'dashboard.sliders.index' },
],
},
{
key: 'additional-education',
label: 'Дополнительное образование',
icon: 'academic-cap',
route: null,
activePrefixes: ['dashboard.additional-educations'],
children: [
{ label: 'Все программы ДПО', route: 'dashboard.additional-educations.index' },
{ label: 'Направления', route: 'dashboard.additional-educations.directions.index' },
{ label: 'Категории', route: 'dashboard.additional-educations.categories.index' },
],
},
{
key: 'admission-campaigns',
label: 'Приемные кампании',
icon: 'clipboard-document-check',
route: null,
activePrefixes: ['dashboard.admission-campaigns', 'dashboard.direction-studies', 'dashboard.educational-programs', 'dashboard.admission-plans'],
children: [
{ label: 'Все кампании', route: 'dashboard.admission-campaigns.index' },
{ label: 'Направления подготовки', route: 'dashboard.direction-studies.index' },
{ label: 'Образовательные программы', route: 'dashboard.educational-programs.index' },
{ label: 'Планы приема', route: 'dashboard.admission-plans.index' },
],
},
{
key: 'schedules',
label: 'Расписание',
icon: 'calendar',
route: null,
activePrefixes: ['dashboard.schedules', 'dashboard.schedules.upload', 'dashboard.educational-groups'],
children: [
{ label: 'Все расписания', route: 'dashboard.schedules.index' },
{ label: 'Загрузить файл', route: 'dashboard.schedules.upload.create' },
{ label: 'Учебные группы', route: 'dashboard.educational-groups.index' },
],
},
{
key: 'institute-structure',
label: 'Структура института',
icon: 'building',
route: null,
activePrefixes: ['dashboard.faculties', 'dashboard.divisions', 'dashboard.departments'],
children: [
{ label: 'Факультеты', route: 'dashboard.faculties.index' },
{ label: 'Кафедры', route: 'dashboard.departments.index' },
{ label: 'Подразделения', route: 'dashboard.divisions.index' },
],
},
{
key: 'science',
label: 'Научные журналы',
icon: 'beaker',
route: 'dashboard.academic-journals.index',
activePrefixes: ['dashboard.academic-journals'],
},
{
key: 'site-structure',
label: 'Структура сайта',
icon: 'folder',
route: null,
activePrefixes: ['dashboard.main-sections', 'dashboard.sub-sections', 'dashboard.pages'],
children: [
{ label: 'Главные разделы', route: 'dashboard.main-sections.index' },
{ label: 'Подразделы', route: 'dashboard.sub-sections.index' },
{ label: 'Страницы', route: 'dashboard.pages.index' },
],
},
{
key: 'widgets',
label: 'Виджеты',
icon: 'rectangle-stack',
route: null,
activePrefixes: ['dashboard.contact-widgets', 'dashboard.custom-forms', 'dashboard.page-reference-lists'],
children: [
{ label: 'Контактные виджеты', route: 'dashboard.contact-widgets.index' },
{ label: 'Пользовательские формы', route: 'dashboard.custom-forms.index' },
{ label: 'Списки ресурсов', route: 'dashboard.page-reference-lists.index' },
],
},
{
key: 'users',
label: 'Пользователи',
icon: 'user-circle',
route: null,
activePrefixes: ['dashboard.users'],
children: [
{ label: 'Все пользователи', route: 'dashboard.users.index' },
],
},
];
@@ -0,0 +1,28 @@
/**
* Quick actions configuration.
* These actions appear in the user profile area (below the user info).
*
* @typedef {Object} QuickAction
* @property {string} label - Display name
* @property {string} route - Route name for internal links
* @property {string} icon - Icon name (mapped in SidebarUser)
* @property {boolean} [external] - If true, uses href instead of route
* @property {string} [href] - External URL (if external is true)
*/
/** @type {QuickAction[]} */
export const quickActions = [
{
label: 'Админ-панель',
route: null,
href: '/admin',
icon: 'cog',
external: true,
},
{
label: 'Быстрая загрузка',
route: 'dashboard.quick-upload.create',
href: null,
icon: 'upload',
},
];
@@ -0,0 +1,89 @@
<template>
<div v-if="posts.length > 0" class="bg-layer border border-layer-line rounded-lg shadow-xs">
<div class="p-5 border-b border-layer-line">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-8 h-8 bg-purple-500/10 rounded-lg flex items-center justify-center">
<DashboardIcon name="sparkles" size="4" class="text-purple-600" />
</div>
<div>
<h3 class="text-sm font-semibold text-foreground">AI подготовленные публикации</h3>
<p class="text-xs text-muted-foreground-1">Генерированы автоматически</p>
</div>
</div>
<span class="inline-flex items-center justify-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-500/10 text-purple-700">
{{ posts.length }}
</span>
</div>
</div>
<ul class="divide-y divide-layer-line">
<li
v-for="post in posts"
:key="post.id"
class="p-4 hover:bg-muted/30 transition-colors"
>
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium text-foreground truncate mb-1">
{{ post.title }}
</h4>
<p v-if="post.preview_text" class="text-xs text-muted-foreground-1 line-clamp-2">
{{ post.preview_text }}
</p>
<div class="flex items-center gap-2 mt-2">
<span
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium"
:class="{
'bg-amber-500/10 text-amber-700': post.status === 'verification',
'bg-gray-500/10 text-gray-700': post.status === 'rejected',
}"
>
{{ STATUS_LABEL(post.status) }}
</span>
<span class="text-xs text-muted-foreground-3"></span>
<span class="text-xs text-muted-foreground-1">{{ FORMAT_DATE(post.created_at, 'short') }}</span>
</div>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<Link
:href="route('dashboard.posts.edit', post.id)"
class="inline-flex items-center px-3 py-1.5 border border-primary/20 text-xs font-medium rounded-md text-primary hover:bg-primary/5 transition-colors"
>
Редактировать
</Link>
</div>
</div>
</li>
</ul>
<div class="p-4 border-t border-layer-line">
<Link
:href="route('dashboard.posts.ai-prepared')"
class="text-sm text-primary hover:text-primary/80 transition-colors font-medium"
>
Посмотреть все
</Link>
</div>
</div>
</template>
<script>
import { Link } from '@inertiajs/vue3';
import DashboardIcon from '../DashboardIcon.vue';
export default {
name: 'AiPreparedWidget',
components: {
Link,
DashboardIcon,
},
props: {
posts: {
type: Array,
required: true,
default: () => [],
},
},
}
</script>
@@ -0,0 +1,48 @@
<template>
<nav aria-label="Навигация" class="mb-4">
<ol class="flex items-center gap-1.5 text-sm text-muted-foreground-1 flex-wrap">
<li>
<Link
:href="route('dashboard.index')"
class="inline-flex items-center gap-1 text-muted-foreground-1 hover:text-primary transition-colors"
>
<DashboardIcon name="home" size="4" />
<span>Главная</span>
</Link>
</li>
<li v-for="(crumb, index) in crumbs" :key="index" class="flex items-center gap-1.5">
<DashboardIcon name="chevron-right" size="3" class="text-muted-foreground-2" />
<template v-if="crumb.href && index < crumbs.length - 1">
<Link
:href="crumb.href"
class="text-muted-foreground-1 hover:text-primary transition-colors"
>
{{ crumb.label }}
</Link>
</template>
<template v-else>
<span class="text-foreground font-medium">{{ crumb.label }}</span>
</template>
</li>
</ol>
</nav>
</template>
<script>
import { Link } from '@inertiajs/vue3';
import DashboardIcon from '../DashboardIcon.vue';
export default {
name: 'Breadcrumbs',
components: {
Link,
DashboardIcon,
},
props: {
crumbs: {
type: Array,
default: () => [],
},
},
}
</script>
@@ -0,0 +1,48 @@
<template>
<div class="bg-layer border border-layer-line rounded-lg shadow-xs mb-6">
<div class="p-4 border-b border-line-2">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-muted-foreground-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
<h2 class="text-sm font-medium text-foreground">{{ title }}</h2>
</div>
</div>
<div class="p-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<slot></slot>
<!-- Reset Button -->
<div class="flex items-end">
<button
type="button"
@click="$emit('reset')"
class="w-full inline-flex items-center justify-center gap-2 px-4 py-2 bg-surface border border-layer-line text-foreground text-sm font-medium rounded-lg hover:bg-muted-hover transition-all duration-200"
>
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ resetText }}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'DataFilters',
props: {
title: {
type: String,
default: 'Фильтры'
},
resetText: {
type: String,
default: 'Сбросить фильтры'
}
},
emits: ['reset']
}
</script>
@@ -0,0 +1,84 @@
<template>
<tr>
<td :colspan="columns" class="px-6 py-16 text-center">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-surface border border-layer-line flex items-center justify-center">
<DashboardIcon
v-if="iconName"
:name="iconName"
size="8"
class="text-muted-foreground-2"
/>
<svg v-else class="w-8 h-8 text-muted-foreground-2" fill="none" stroke="currentColor" :viewBox="iconViewBox">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="iconPath" />
</svg>
</div>
<p class="text-foreground font-medium">{{ title }}</p>
<p class="text-sm text-muted-foreground-1 mt-1 mb-4">{{ description }}</p>
<a
v-if="actionUrl"
:href="actionUrl"
class="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all"
>
<DashboardIcon name="plus" size="4" color="currentColor" />
{{ actionText }}
</a>
<button
v-else-if="onAction"
@click="onAction"
class="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all"
>
<DashboardIcon name="plus" size="4" color="currentColor" />
{{ actionText }}
</button>
</td>
</tr>
</template>
<script>
import DashboardIcon from '../DashboardIcon.vue';
export default {
name: 'EmptyState',
components: {
DashboardIcon,
},
props: {
columns: {
type: Number,
default: 6
},
title: {
type: String,
default: 'Ничего не найдено'
},
description: {
type: String,
default: 'Попробуйте изменить параметры поиска или создайте новую запись'
},
actionUrl: {
type: String,
default: null
},
actionText: {
type: String,
default: 'Создать'
},
onAction: {
type: Function,
default: null
},
iconName: {
type: String,
default: null
},
iconPath: {
type: String,
default: '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'
},
iconViewBox: {
type: String,
default: '0 0 24 24'
}
}
}
</script>
@@ -0,0 +1,95 @@
<template>
<div>
<!-- Success Message -->
<transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div v-if="$page.props.flash?.success" class="mb-4 p-4 bg-emerald-500/10 border border-emerald-500/20 rounded-lg">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-emerald-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm text-foreground font-medium">{{ $page.props.flash.success }}</span>
</div>
</div>
</transition>
<!-- Error Message -->
<transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div v-if="$page.props.flash?.error" class="mb-4 p-4 bg-rose-500/10 border border-rose-500/20 rounded-lg">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-rose-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm text-foreground font-medium">{{ $page.props.flash.error }}</span>
</div>
</div>
</transition>
<!-- Local Success Message -->
<transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div v-if="localSuccess" class="mb-4 p-4 bg-emerald-500/10 border border-emerald-500/20 rounded-lg">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-emerald-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm text-foreground font-medium">{{ localSuccess }}</span>
</div>
</div>
</transition>
<!-- Local Error Message -->
<transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div v-if="localError" class="mb-4 p-4 bg-rose-500/10 border border-rose-500/20 rounded-lg">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-rose-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm text-foreground font-medium">{{ localError }}</span>
</div>
</div>
</transition>
</div>
</template>
<script>
export default {
name: 'FlashMessages',
props: {
localSuccess: {
type: String,
default: null
},
localError: {
type: String,
default: null
}
}
}
</script>
@@ -0,0 +1,41 @@
<template>
<div v-if="data.last_page > 1" class="px-6 py-4 border-t border-line-2 bg-surface/50">
<div class="flex items-center justify-between">
<div class="text-sm text-muted-foreground-1">
Показано <span class="font-medium text-foreground">{{ data.from }}</span> <span class="font-medium text-foreground">{{ data.to }}</span> из <span class="font-medium text-foreground">{{ data.total }}</span>
</div>
<div class="flex items-center gap-2">
<template v-for="(link, index) in data.links" :key="index">
<a
v-if="link.url"
:href="link.url"
:class="[
'px-3 py-1.5 text-sm font-medium rounded-lg border transition-all duration-200',
link.active
? 'bg-primary text-white border-primary'
: 'bg-surface text-foreground border-layer-line hover:bg-muted-hover hover:border-primary/30'
]"
v-html="link.label"
></a>
<span
v-else
class="px-3 py-1.5 text-sm text-muted-foreground-2 bg-surface border border-layer-line rounded-lg"
v-html="link.label"
></span>
</template>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Pagination',
props: {
data: {
type: Object,
required: true
}
}
}
</script>
@@ -0,0 +1,83 @@
<template>
<div class="bg-layer border border-layer-line rounded-lg shadow-xs p-5">
<h3 class="text-sm font-semibold text-foreground mb-4">Быстрые действия</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Link
v-for="action in actions"
:key="action.route"
:href="route(action.route)"
class="group flex items-center gap-3 p-3 rounded-lg border border-transparent transition-all duration-150"
:class="action.hoverClass"
>
<div class="w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0 transition-colors" :class="action.bgClass">
<DashboardIcon :name="action.icon" size="4" :class="action.iconClass" />
</div>
<div>
<p class="text-sm font-medium text-foreground transition-colors" :class="action.textClass">
{{ action.label }}
</p>
<p class="text-xs text-muted-foreground-1">{{ action.desc }}</p>
</div>
</Link>
</div>
</div>
</template>
<script>
import { Link } from '@inertiajs/vue3';
import DashboardIcon from '../DashboardIcon.vue';
export default {
name: 'QuickActions',
components: {
Link,
DashboardIcon,
},
data() {
return {
actions: [
{
route: 'dashboard.posts.create',
label: 'Создать новость',
desc: 'Новая публикация на сайт',
icon: 'plus',
bgClass: 'bg-blue-500/10 group-hover:bg-blue-500/20',
iconClass: 'text-blue-600',
textClass: 'group-hover:text-primary',
hoverClass: 'hover:border-primary/20 hover:bg-primary/5',
},
{
route: 'dashboard.schedules.upload.create',
label: 'Загрузить расписание',
desc: 'Массовая загрузка файлов',
icon: 'arrow-up-tray',
bgClass: 'bg-emerald-500/10 group-hover:bg-emerald-500/20',
iconClass: 'text-emerald-600',
textClass: 'group-hover:text-emerald-600',
hoverClass: 'hover:border-emerald-500/20 hover:bg-emerald-500/5',
},
{
route: 'dashboard.educational-groups.create',
label: 'Добавить группу',
desc: 'Учебная группа',
icon: 'user-group',
bgClass: 'bg-violet-500/10 group-hover:bg-violet-500/20',
iconClass: 'text-violet-600',
textClass: 'group-hover:text-violet-600',
hoverClass: 'hover:border-violet-500/20 hover:bg-violet-500/5',
},
{
route: 'dashboard.quick-upload.create',
label: 'Быстрая загрузка',
desc: 'Загрузка файлов',
icon: 'paper-clip',
bgClass: 'bg-amber-500/10 group-hover:bg-amber-500/20',
iconClass: 'text-amber-600',
textClass: 'group-hover:text-amber-600',
hoverClass: 'hover:border-amber-500/20 hover:bg-amber-500/5',
},
],
};
},
}
</script>
@@ -0,0 +1,136 @@
<template>
<div class="bg-layer border border-layer-line rounded-lg shadow-xs">
<div class="p-5 border-b border-layer-line">
<h3 class="text-sm font-semibold text-foreground">Последняя активность</h3>
</div>
<div class="divide-y divide-layer-line">
<!-- Recent Posts -->
<div v-if="recentActivity.recent_posts.length > 0" class="p-5">
<div class="flex items-center justify-between mb-3">
<h4 class="text-xs font-medium text-muted-foreground-1 uppercase tracking-wide">Публикации</h4>
<Link :href="route('dashboard.posts.index')" class="text-xs text-primary hover:text-primary/80 transition-colors">
Все новости
</Link>
</div>
<ul class="space-y-2">
<li v-for="post in recentActivity.recent_posts" :key="post.id" class="flex items-start gap-3">
<div class="flex-shrink-0 mt-0.5">
<div
class="w-2 h-2 rounded-full"
:class="{
'bg-amber-500': post.status === 'verification',
'bg-emerald-500': post.status === 'published',
'bg-gray-400': post.status === 'rejected',
}"
></div>
</div>
<div class="flex-1 min-w-0">
<Link
:href="route('dashboard.posts.edit', post.id)"
class="text-sm font-medium text-foreground hover:text-primary transition-colors truncate block"
>
{{ post.title }}
</Link>
<div class="flex items-center gap-2 mt-1">
<span class="text-xs text-muted-foreground-1">{{ FORMAT_DATE(post.created_at, 'short') }}</span>
<span
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium"
:class="{
'bg-amber-500/10 text-amber-700': post.status === 'verification',
'bg-emerald-500/10 text-emerald-700': post.status === 'published',
'bg-gray-500/10 text-gray-700': post.status === 'rejected',
}"
>
{{ STATUS_LABEL(post.status) }}
</span>
</div>
</div>
</li>
</ul>
</div>
<!-- Recent Schedules -->
<div v-if="recentActivity.recent_schedules.length > 0" class="p-5">
<div class="flex items-center justify-between mb-3">
<h4 class="text-xs font-medium text-muted-foreground-1 uppercase tracking-wide">Расписания</h4>
<Link :href="route('dashboard.schedules.index')" class="text-xs text-primary hover:text-primary/80 transition-colors">
Все расписания
</Link>
</div>
<ul class="space-y-2">
<li v-for="schedule in recentActivity.recent_schedules" :key="schedule.id" class="flex items-center justify-between">
<div class="flex items-center gap-3 min-w-0">
<DashboardIcon name="calendar" size="4" class="text-emerald-600 flex-shrink-0" />
<span class="text-sm text-foreground truncate">
{{ schedule.educational_group?.title || 'Без группы' }}
</span>
</div>
<span class="text-xs text-muted-foreground-1 flex-shrink-0">{{ FORMAT_DATE(schedule.created_at, 'short') }}</span>
</li>
</ul>
</div>
<!-- Recent Sliders -->
<div v-if="recentActivity.recent_sliders.length > 0" class="p-5">
<div class="flex items-center justify-between mb-3">
<h4 class="text-xs font-medium text-muted-foreground-1 uppercase tracking-wide">Слайдеры</h4>
<Link :href="route('dashboard.sliders.index')" class="text-xs text-primary hover:text-primary/80 transition-colors">
Все слайдеры
</Link>
</div>
<ul class="space-y-2">
<li v-for="slider in recentActivity.recent_sliders" :key="slider.id" class="flex items-center justify-between">
<div class="flex items-center gap-3 min-w-0">
<DashboardIcon name="photo" size="4" class="text-rose-600 flex-shrink-0" />
<div class="min-w-0">
<span class="text-sm text-foreground block truncate">{{ slider.title }}</span>
<span class="text-xs text-muted-foreground-1">{{ slider.slides_count }} слайд(ов)</span>
</div>
</div>
<span class="text-xs text-muted-foreground-1 flex-shrink-0">{{ FORMAT_DATE(slider.created_at, 'short') }}</span>
</li>
</ul>
</div>
<!-- Empty State -->
<div v-if="isEmpty" class="p-8 text-center">
<DashboardIcon name="clock" size="8" class="text-muted-foreground-3 mx-auto mb-3" />
<p class="text-sm text-muted-foreground-1">Пока нет активности</p>
</div>
</div>
</div>
</template>
<script>
import { Link } from '@inertiajs/vue3';
import DashboardIcon from '../DashboardIcon.vue';
export default {
name: 'RecentActivity',
components: {
Link,
DashboardIcon,
},
props: {
recentActivity: {
type: Object,
required: true,
default: () => ({
recent_posts: [],
recent_schedules: [],
recent_sliders: [],
}),
},
},
computed: {
isEmpty() {
return (
this.recentActivity.recent_posts.length === 0 &&
this.recentActivity.recent_schedules.length === 0 &&
this.recentActivity.recent_sliders.length === 0
);
},
},
}
</script>
@@ -0,0 +1,46 @@
<template>
<div>
<label :for="id" class="block text-xs font-medium text-muted-foreground-1 mb-1.5">
{{ label }}
</label>
<div class="relative">
<input
:id="id"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
@keyup.enter="$emit('search')"
type="text"
:placeholder="placeholder"
class="w-full pl-9 pr-4 py-2 bg-surface border border-layer-line rounded-lg text-sm text-foreground placeholder-muted-foreground-2 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
/>
<svg class="absolute left-3 top-2.5 w-4 h-4 text-muted-foreground-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
</template>
<script>
export default {
name: 'SearchInput',
props: {
modelValue: {
type: String,
default: ''
},
label: {
type: String,
default: 'Поиск'
},
placeholder: {
type: String,
default: 'Введите текст...'
},
id: {
type: String,
default: 'search'
}
},
emits: ['update:modelValue', 'search']
}
</script>
@@ -0,0 +1,41 @@
<template>
<div>
<label :for="id" class="block text-xs font-medium text-muted-foreground-1 mb-1.5">
{{ label }}
</label>
<select
:id="id"
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value); $emit('change')"
class="w-full px-3 py-2 bg-surface border border-layer-line rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
>
<option value="">{{ placeholder }}</option>
<slot></slot>
</select>
</div>
</template>
<script>
export default {
name: 'SelectFilter',
props: {
modelValue: {
type: [String, Number],
default: ''
},
label: {
type: String,
default: 'Фильтр'
},
placeholder: {
type: String,
default: 'Все'
},
id: {
type: String,
default: 'filter'
}
},
emits: ['update:modelValue', 'change']
}
</script>
@@ -0,0 +1,104 @@
<template>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div
v-for="card in statCards"
:key="card.label"
class="bg-layer border border-layer-line rounded-lg p-5 shadow-xs"
>
<div class="flex items-center justify-between mb-3">
<div class="w-10 h-10 rounded-lg flex items-center justify-center" :class="card.bgClass">
<DashboardIcon :name="card.icon" size="5" :class="card.iconClass" />
</div>
<span
v-if="card.weekCount > 0"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
:class="card.badgeClass"
>
+{{ card.weekCount }}
</span>
</div>
<div class="space-y-1">
<p class="text-2xl font-semibold text-foreground">{{ card.total }}</p>
<p class="text-xs text-muted-foreground-1">{{ card.label }}</p>
</div>
<div v-if="card.link" class="mt-3 pt-3 border-t border-layer-line">
<a
:href="route(card.link.route)"
class="text-xs font-medium transition-colors"
:class="card.link.class"
>
{{ card.link.text }}
</a>
</div>
</div>
</div>
</template>
<script>
import DashboardIcon from '../DashboardIcon.vue';
export default {
name: 'StatsOverview',
components: {
DashboardIcon,
},
props: {
stats: {
type: Object,
required: true,
},
},
computed: {
statCards() {
return [
{
icon: 'document',
bgClass: 'bg-blue-500/10',
iconClass: 'text-blue-600',
badgeClass: 'bg-blue-500/10 text-blue-700',
total: this.stats.posts.total,
weekCount: this.stats.posts.week,
label: 'Всего публикаций',
link: this.stats.posts.verification > 0
? {
route: 'dashboard.posts.index',
text: `${this.stats.posts.verification} на модерации →`,
class: 'text-amber-600 hover:text-amber-700',
}
: null,
},
{
icon: 'calendar',
bgClass: 'bg-emerald-500/10',
iconClass: 'text-emerald-600',
badgeClass: 'bg-emerald-500/10 text-emerald-700',
total: this.stats.schedules.total,
weekCount: this.stats.schedules.week,
label: 'Расписаний',
link: null,
},
{
icon: 'academic-cap',
bgClass: 'bg-violet-500/10',
iconClass: 'text-violet-600',
badgeClass: 'bg-violet-500/10 text-violet-700',
total: this.stats.educational_groups.total,
weekCount: this.stats.educational_groups.week,
label: 'Учебных групп',
link: null,
},
{
icon: 'photo',
bgClass: 'bg-rose-500/10',
iconClass: 'text-rose-600',
badgeClass: 'bg-rose-500/10 text-rose-700',
total: this.stats.sliders.total,
weekCount: this.stats.sliders.week,
label: 'Слайдеров',
link: null,
},
];
},
},
}
</script>