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,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>