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,871 @@
<template>
<DashboardLayout>
<template #header-icon>
<DashboardIcon name="sparkles" size="5" class="text-primary" />
</template>
<template #header-title>AI подготовка новостей</template>
<template #header-subtitle>Модерация новостей, подготовленных искусственным интеллектом</template>
<template #header-actions>
<button
@click="parseEmailNews"
:disabled="parseProcessing"
class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white text-sm font-medium rounded-lg hover:bg-emerald-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-sm hover:shadow-md"
>
<svg v-if="parseProcessing" class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<DashboardIcon v-else name="envelope" size="4" />
{{ parseProcessing ? 'Парсинг...' : 'Распарсить Email' }}
</button>
</template>
<!-- Flash Messages -->
<FlashMessages />
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden">
<div class="p-4 flex items-center justify-between">
<div>
<p class="text-sm text-muted-foreground-1">На рассмотрении</p>
<p class="text-2xl font-semibold text-amber-600">{{ verificationCount }}</p>
</div>
<div class="w-12 h-12 bg-amber-500/10 rounded-lg flex items-center justify-center">
<DashboardIcon name="clock" size="6" class="text-amber-600" />
</div>
</div>
</div>
<div class="bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden">
<div class="p-4 flex items-center justify-between">
<div>
<p class="text-sm text-muted-foreground-1">Отклонено</p>
<p class="text-2xl font-semibold text-rose-600">{{ rejectedCount }}</p>
</div>
<div class="w-12 h-12 bg-rose-500/10 rounded-lg flex items-center justify-center">
<DashboardIcon name="exclamation-triangle" size="6" class="text-rose-600" />
</div>
</div>
</div>
</div>
<!-- Upload Card -->
<div class="bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden mb-6">
<div class="px-4 py-3 border-b border-card-line">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-9 h-9 bg-primary/10 rounded-lg flex items-center justify-center">
<DashboardIcon name="cloud-arrow-up" size="5" class="text-primary" />
</div>
<div>
<h2 class="text-sm font-medium text-foreground">Загрузка файлов</h2>
<p class="text-xs text-muted-foreground-1 mt-0.5">DOCX, PDF, XLSX, JPG, PNG, WEBP, ZIP</p>
</div>
</div>
<button
@click="openUploadModal"
class="inline-flex items-center px-3 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all"
>
<DashboardIcon name="plus" size="4" class="mr-1.5" />
Загрузить
</button>
</div>
</div>
<div class="px-4 py-3 bg-surface border-t border-line-2">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1.5 bg-white rounded-md text-xs font-medium text-muted-foreground-1 border border-layer-line">
<DashboardIcon name="bolt" size="4" class="text-primary" />
Авто-распаковка ZIP
</span>
<span class="inline-flex items-center gap-1.5 px-2.5 py-1.5 bg-white rounded-md text-xs font-medium text-muted-foreground-1 border border-layer-line">
<DashboardIcon name="check-circle" size="4" class="text-emerald-600" />
Извлечение изображений
</span>
<span class="inline-flex items-center gap-1.5 px-2.5 py-1.5 bg-white rounded-md text-xs font-medium text-muted-foreground-1 border border-layer-line">
<DashboardIcon name="sparkles" size="4" class="text-amber-600" />
AI-генерация
</span>
</div>
</div>
</div>
<!-- AI Prepared Posts Accordion -->
<div class="bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden">
<div class="px-4 py-3 border-b border-card-line bg-surface">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-9 h-9 bg-primary/10 rounded-lg flex items-center justify-center">
<DashboardIcon name="sparkles" size="5" class="text-primary" />
</div>
<div>
<h2 class="text-sm font-medium text-foreground">Подготовленные новости (AI)</h2>
<p class="text-xs text-muted-foreground-1 mt-0.5">Требуют модерации</p>
</div>
</div>
<span class="inline-flex items-center px-2.5 py-1.5 bg-primary-500/10 border border-primary-200 text-primary-700 rounded-full text-xs font-medium">
{{ aiPreparedPosts.length }}
</span>
</div>
</div>
<div class="divide-y divide-line-2">
<div
v-for="post in aiPreparedPosts"
:key="post.id"
class="group"
>
<!-- Accordion Header -->
<button
@click="togglePost(post.id)"
class="w-full px-4 py-4 flex items-center gap-4 hover:bg-surface/50 transition-colors text-left"
:aria-expanded="expandedPosts[post.id]"
>
<!-- Preview Image -->
<div v-if="post.preview" class="flex-shrink-0">
<div class="w-16 h-16 rounded-lg overflow-hidden border border-layer-line">
<img
:src="`/storage/${post.preview}`"
alt="Preview"
class="w-full h-full object-cover"
/>
</div>
</div>
<div v-else class="flex-shrink-0 w-16 h-16 rounded-lg border border-layer-line bg-surface flex items-center justify-center">
<DashboardIcon name="photo" size="8" class="text-muted-foreground-2" />
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<h3 class="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">
{{ post.title }}
</h3>
<div class="flex flex-wrap items-center gap-2 mt-1.5">
<span
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium uppercase"
:class="{
'bg-amber-50 text-amber-700 border border-amber-200': post.status === 'verification',
'bg-rose-50 text-rose-700 border border-rose-200': post.status === 'rejected',
'bg-emerald-50 text-emerald-700 border border-emerald-200': post.status === 'published'
}"
>
<span class="w-1.5 h-1.5 rounded-full mr-1" :class="{
'bg-amber-500': post.status === 'verification',
'bg-rose-500': post.status === 'rejected',
'bg-emerald-500': post.status === 'published'
}"></span>
{{ post.status === 'verification' ? 'На рассмотрении' : post.status === 'rejected' ? 'Отклонено' : 'Опубликовано' }}
</span>
<span v-if="post.category" class="inline-flex items-center px-2 py-0.5 bg-layer text-muted-foreground-1 rounded text-xs font-medium border border-layer-line">
{{ post.category.title }}
</span>
<span class="text-xs text-muted-foreground-2">
{{ new Date(post.created_at).toLocaleDateString('ru-RU') }}
</span>
</div>
</div>
<!-- Chevron -->
<DashboardIcon
name="chevron-down"
size="5"
class="text-muted-foreground-2 flex-shrink-0 transition-transform duration-200"
:class="{ 'rotate-180': expandedPosts[post.id] }"
/>
</div>
</div>
</button>
<!-- Accordion Content -->
<div v-show="expandedPosts[post.id]" class="px-4 pb-4 border-t border-line-2">
<div class="pt-4 space-y-4">
<!-- Meta Info -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p class="text-xs font-medium text-muted-foreground-1 mb-1">Автор(ы)</p>
<p class="text-sm text-foreground">
{{ Array.isArray(post.authors) ? post.authors.join(', ') : post.authors || '—' }}
</p>
</div>
<div>
<p class="text-xs font-medium text-muted-foreground-1 mb-1">Время чтения</p>
<p class="text-sm text-foreground">{{ post.reading_time ? `${post.reading_time} мин` : '—' }}</p>
</div>
</div>
<!-- Preview Text -->
<div v-if="post.preview_text">
<p class="text-xs font-medium text-muted-foreground-1 mb-1">Превью</p>
<p class="text-sm text-foreground leading-relaxed">{{ post.preview_text }}</p>
</div>
<!-- Full Content -->
<div>
<p class="text-xs font-medium text-muted-foreground-1 mb-1">Полный текст</p>
<div class="prose prose-sm max-w-none bg-surface p-4 rounded-lg border border-layer-line">
<div v-html="renderContent(post.content)" class="text-foreground"></div>
</div>
</div>
<!-- Gallery -->
<div v-if="post.images && post.images.length > 0">
<p class="text-xs font-medium text-muted-foreground-1 mb-2">Галерея ({{ post.images.length }})</p>
<div class="grid grid-cols-3 sm:grid-cols-4 gap-2">
<div
v-for="(image, index) in post.images"
:key="index"
@click="viewImage(image)"
class="rounded-lg overflow-hidden border border-layer-line aspect-square cursor-pointer hover:border-primary transition-all"
>
<img
:src="`/storage/${image}`"
alt="Gallery"
class="w-full h-full object-cover hover:scale-105 transition-transform"
/>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex flex-wrap items-center gap-3 pt-2 border-t border-line-2">
<!-- VK Toggle -->
<label class="flex items-center gap-2 cursor-pointer">
<div class="relative">
<input
v-model="postPublishSettings[post.id]"
type="checkbox"
class="sr-only peer"
>
<div class="w-9 h-5 bg-layer-line peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-primary-200 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-layer-line after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary"></div>
</div>
<span class="text-sm font-medium text-muted-foreground-1 hover:text-primary transition-colors flex items-center gap-1.5">
<DashboardIcon name="globe-alt" size="4" :class="postPublishSettings[post.id] ? 'text-primary' : 'text-muted-foreground-2'" />
Опубликовать в VK
</span>
</label>
<!-- Publish Button -->
<button
v-if="!post.publish_at"
@click="publishPost(post)"
:disabled="publishProcessing"
class="inline-flex items-center px-4 py-2 bg-emerald-600 text-white text-sm font-medium rounded-lg hover:bg-emerald-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<DashboardIcon v-if="!publishProcessing" name="check" size="4" class="mr-1.5" />
<svg v-else class="animate-spin h-4 w-4 mr-1.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ publishProcessing ? 'Публикация...' : 'Опубликовать' }}
</button>
<!-- Edit in Admin -->
<a
:href="`/admin/posts/${post.id}/edit`"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all"
>
<DashboardIcon name="pencil-square" size="4" class="mr-1.5" />
Редактировать
</a>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-if="aiPreparedPosts.length === 0" class="px-4 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 name="sparkles" size="8" class="text-muted-foreground-2" />
</div>
<p class="text-foreground font-medium">AI новостей нет</p>
<p class="text-sm text-muted-foreground-1 mt-1">Загруженные файлы будут автоматически обработаны AI</p>
</div>
</div>
</div>
<!-- Upload Modal -->
<transition
enter-active-class="transition duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-150"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isUploadModalOpen"
class="fixed inset-0 bg-slate-900/70 backdrop-blur-md z-50 flex items-center justify-center p-4"
@click.self="closeUploadModal"
>
<div class="bg-white border border-layer-line rounded-xl shadow-xl max-w-3xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<!-- Header -->
<div class="px-4 py-3 border-b border-card-line flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-9 h-9 bg-primary/10 rounded-lg flex items-center justify-center">
<DashboardIcon name="cloud-arrow-up" size="5" class="text-primary" />
</div>
<h3 class="text-sm font-medium text-foreground">Загрузка файлов</h3>
</div>
<button
@click="closeUploadModal"
class="text-muted-foreground-2 hover:text-foreground hover:bg-layer rounded-lg p-1.5 transition-all"
>
<DashboardIcon name="x-mark" size="5" />
</button>
</div>
<!-- Content -->
<div class="overflow-y-auto flex-1 p-4">
<form @submit.prevent="submitForm" class="space-y-4">
<!-- Dropzone -->
<div
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
@click="triggerFileInput"
:class="[
'relative flex justify-center px-6 pt-8 pb-8 border-2 border-dashed rounded-lg cursor-pointer transition-all',
isDragging
? 'border-primary bg-primary-50'
: 'border-layer-line hover:border-primary hover:bg-primary-50/50'
]"
>
<div class="space-y-3 text-center">
<div class="flex justify-center">
<div :class="['w-14 h-14 rounded-lg flex items-center justify-center transition-all', isDragging ? 'bg-primary/20' : 'bg-primary/10']">
<DashboardIcon name="cloud-arrow-up" size="7" class="text-primary" />
</div>
</div>
<div>
<p class="text-sm font-medium text-foreground">
<span class="text-primary">Нажмите</span> или перетащите файлы
</p>
<p class="text-xs text-muted-foreground-1 mt-0.5">
DOCX, PDF, XLSX, JPG, PNG, WEBP, ZIP до 40MB
</p>
</div>
</div>
</div>
<!-- Hidden Input -->
<input
type="file"
id="aiFilesInput"
multiple
ref="filesInput"
class="hidden"
@change="handleFileInput"
accept=".doc,.docx,.pdf,.xls,.xlsx,.jpg,.jpeg,.png,.webp,.gif,.zip"
/>
<!-- Errors -->
<div v-if="hasFileErrors" class="p-3 bg-rose-50 border border-rose-200 rounded-lg">
<div class="flex items-start gap-2">
<DashboardIcon name="exclamation-circle" size="4" class="text-rose-600 flex-shrink-0 mt-0.5" />
<div class="text-xs text-rose-700">
<p v-if="form.errors.files" class="font-medium mb-1">{{ form.errors.files }}</p>
<ul class="list-disc pl-4 space-y-0.5">
<li v-for="(error, key) in fileSpecificErrors" :key="key">
{{ error }}
</li>
</ul>
</div>
</div>
</div>
<!-- Unzipping -->
<div v-if="isUnzipping" class="p-3 bg-amber-50 border border-amber-200 rounded-lg">
<div class="flex items-center gap-2 mb-2">
<svg class="animate-spin h-4 w-4 text-amber-600" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-xs font-medium text-amber-800">Распаковка...</span>
</div>
<div class="w-full bg-amber-100 rounded-full h-2 overflow-hidden">
<div
class="h-full bg-amber-500 rounded-full transition-all"
:style="{ width: unzippingProgress + '%' }"
></div>
</div>
</div>
<!-- Selected Files -->
<div v-if="form.files.length > 0" class="space-y-3">
<h4 class="text-xs font-medium text-muted-foreground-1 uppercase">
Файлов выбрано: {{ form.files.length }}
</h4>
<!-- Images -->
<div v-if="imageFiles.length > 0" class="space-y-2">
<div class="flex items-center gap-2">
<DashboardIcon name="photo" size="4" class="text-primary" />
<span class="text-sm font-medium text-foreground">Изображения</span>
<span class="text-xs text-muted-foreground-2">({{ imageFiles.length }})</span>
</div>
<div class="grid grid-cols-3 sm:grid-cols-4 gap-2">
<div
v-for="(item, newIndex) in imageFiles"
:key="item.originalIndex"
draggable="true"
@dragstart="handleImageDragStart($event, newIndex)"
@dragover="handleImageDragOver"
@dragleave="handleImageDragLeave"
@drop="handleImageDrop($event, newIndex)"
@dragend="handleImageDragEnd"
class="group relative bg-surface border border-layer-line rounded-lg overflow-hidden cursor-move hover:border-primary transition-all"
>
<div class="aspect-square">
<img
:src="imagePreviews[item.originalIndex]"
alt="Preview"
class="w-full h-full object-cover"
/>
</div>
<button
type="button"
@click.prevent="removeFile(item.originalIndex)"
class="absolute top-1 right-1 p-1 bg-rose-500 text-white rounded opacity-0 group-hover:opacity-100 transition-opacity"
>
<DashboardIcon name="x-mark" size="3" />
</button>
<div class="absolute top-1 left-1 w-5 h-5 bg-white rounded-full flex items-center justify-center text-xs font-medium text-primary shadow-sm">
{{ newIndex + 1 }}
</div>
</div>
</div>
</div>
<!-- Other Files -->
<div v-if="otherFiles.length > 0" class="space-y-1.5">
<div class="flex items-center gap-2">
<DashboardIcon name="document-text" size="4" class="text-primary" />
<span class="text-sm font-medium text-foreground">Файлы</span>
<span class="text-xs text-muted-foreground-2">({{ otherFiles.length }})</span>
</div>
<div class="space-y-1.5">
<div
v-for="(item) in otherFiles"
:key="item.originalIndex"
class="flex items-center gap-3 p-2 bg-surface border border-layer-line rounded-lg hover:border-primary transition-all"
>
<div class="w-10 h-10 rounded-lg bg-primary-500/10 flex items-center justify-center border border-primary-200">
<DashboardIcon name="document-text" size="5" class="text-primary" />
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-foreground truncate">{{ item.file.name }}</p>
<p class="text-xs text-muted-foreground-2">{{ (item.file.size / 1024 / 1024).toFixed(2) }} MB</p>
</div>
<button
type="button"
@click.prevent="removeFile(item.originalIndex)"
class="p-1.5 text-rose-600 hover:bg-rose-50 rounded-lg transition-all"
>
<DashboardIcon name="trash" size="4" />
</button>
</div>
</div>
</div>
</div>
<!-- Progress -->
<div v-if="form.progress" class="space-y-1.5">
<div class="flex justify-between text-xs">
<span class="font-medium text-muted-foreground-1">Загрузка...</span>
<span class="font-medium text-primary">{{ form.progress.percentage }}%</span>
</div>
<div class="w-full bg-layer-line rounded-full h-2 overflow-hidden">
<div
class="h-full bg-primary rounded-full transition-all"
:style="{ width: form.progress.percentage + '%' }"
></div>
</div>
</div>
<!-- Submit -->
<button
type="submit"
:disabled="form.processing || form.files.length === 0"
class="w-full py-2.5 px-4 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover focus:outline-none focus:ring-2 focus:ring-primary-200 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
<span v-if="form.processing" class="flex items-center justify-center gap-2">
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>Обработка ({{ form.progress ? form.progress.percentage : 0 }}%)</span>
</span>
<span v-else class="flex items-center justify-center gap-2">
<DashboardIcon name="arrow-up-tray" size="4" />
Отправить файлы
</span>
</button>
</form>
</div>
</div>
</div>
</transition>
</DashboardLayout>
</template>
<script>
import { computed } from 'vue';
import { useForm } from '@inertiajs/vue3';
import JSZip from 'jszip';
import DashboardLayout from '../Components/DashboardLayout.vue';
import DashboardIcon from '../Components/DashboardIcon.vue';
import FlashMessages from '../Components/shared/FlashMessages.vue';
export default {
name: 'AiPreparedPosts',
components: {
DashboardLayout,
DashboardIcon,
FlashMessages
},
props: {
aiPreparedPosts: {
type: Array,
default: () => []
}
},
data() {
return {
expandedPosts: {},
publishProcessing: false,
parseProcessing: false,
postPublishSettings: {},
isUploadModalOpen: false,
imagePreviews: {},
draggedImageIndex: null,
isUnzipping: false,
unzippingProgress: 0,
isDragging: false,
form: useForm({
files: [],
}),
}
},
mounted() {
this.SET_DOCUMENT_TITLE('AI подготовка новостей');
},
computed: {
verificationCount() {
return this.aiPreparedPosts.filter(p => p.status === 'verification').length;
},
rejectedCount() {
return this.aiPreparedPosts.filter(p => p.status === 'rejected').length;
},
hasFileErrors() {
return Object.keys(this.form.errors).some(key => key.startsWith('files'));
},
fileSpecificErrors() {
const errors = {};
Object.keys(this.form.errors).forEach(key => {
if (key.startsWith('files.')) {
errors[key] = this.form.errors[key];
}
});
return errors;
},
imageFiles() {
return this.form.files
.map((file, index) => ({ file, originalIndex: index, isImage: this.isImageFile(file) }))
.filter(item => item.isImage);
},
otherFiles() {
return this.form.files
.map((file, index) => ({ file, originalIndex: index, isImage: this.isImageFile(file) }))
.filter(item => !item.isImage);
}
},
watch: {
aiPreparedPosts: {
handler(posts) {
posts.forEach(post => {
if (this.postPublishSettings[post.id] === undefined) {
this.postPublishSettings[post.id] = true;
}
});
},
immediate: true
}
},
methods: {
parseEmailNews() {
if (this.parseProcessing) return;
this.parseProcessing = true;
this.$inertia.post(route('dashboard.posts.ai-prepared.parse-email'), {}, {
preserveScroll: true,
onSuccess: () => {
this.parseProcessing = false;
},
onError: () => {
this.parseProcessing = false;
},
});
},
togglePost(postId) {
this.expandedPosts[postId] = !this.expandedPosts[postId];
},
publishPost(post) {
if (!confirm(`Опубликовать новость "${post.title}"?`)) {
return;
}
this.publishProcessing = true;
this.$inertia.post(route('dashboard.posts.publish', post.id), {
publish_to_vk: this.postPublishSettings[post.id] ?? true,
}, {
preserveScroll: true,
onSuccess: () => {
this.publishProcessing = false;
},
onError: () => {
this.publishProcessing = false;
},
});
},
renderContent(content) {
if (!content) return '';
if (Array.isArray(content)) {
return content.map(block => {
if (typeof block === 'string') return block;
if (block.type === 'paragraph' && block.data?.content) {
return block.data.content;
}
if (block.type === 'heading' && block.data?.content) {
return `<h2>${block.data.content}</h2>`;
}
if (block.data?.content) {
return block.data.content;
}
return '';
}).join('');
}
return content;
},
viewImage(imagePath) {
window.open(`/storage/${imagePath}`);
},
openUploadModal() {
this.isUploadModalOpen = true;
},
closeUploadModal() {
this.isUploadModalOpen = false;
},
isImageFile(file) {
return file.type.startsWith('image/');
},
createPreview(file, index) {
if (!this.isImageFile(file)) return;
const reader = new FileReader();
reader.onload = (e) => {
this.imagePreviews[index] = e.target.result;
};
reader.readAsDataURL(file);
},
triggerFileInput() {
this.$refs.filesInput.click();
},
handleDrop(e) {
this.isDragging = false;
const files = Array.from(e.dataTransfer.files);
this.addFilesToForm(files);
},
handleFileInput(e) {
const files = Array.from(e.target.files);
this.addFilesToForm(files);
e.target.value = '';
},
async addFilesToForm(files) {
for (const file of files) {
const exists = this.form.files.some(f => f.name === file.name && f.size === f.size);
if (exists) continue;
if (file.type === 'application/zip' || file.type === 'application/x-zip-compressed' ||
file.name.toLowerCase().endsWith('.zip')) {
await this.unzipFile(file);
} else {
const newIndex = this.form.files.length;
this.form.files.push(file);
this.createPreview(file, newIndex);
}
}
},
async unzipFile(zipFile) {
this.isUnzipping = true;
this.unzippingProgress = 0;
try {
const zip = await JSZip.loadAsync(zipFile);
const fileEntries = Object.entries(zip.files).filter(([name, file]) => !file.dir);
const totalFiles = fileEntries.length;
const supportedExtensions = ['doc', 'docx', 'pdf', 'xls', 'xlsx', 'jpg', 'jpeg', 'png', 'webp', 'gif'];
for (const [relativePath, file] of fileEntries) {
if (relativePath.startsWith('__MACOSX') || relativePath.includes('.DS_Store')) {
continue;
}
const ext = relativePath.split('.').pop().toLowerCase();
if (!supportedExtensions.includes(ext)) {
continue;
}
const blob = await file.async('blob');
const newFile = new File([blob], this.getBasename(relativePath), {
type: blob.type || this.getFileMimeType(ext)
});
const exists = this.form.files.some(f => f.name === newFile.name && f.size === newFile.size);
if (!exists) {
const newIndex = this.form.files.length;
this.form.files.push(newFile);
if (this.isImageFile(newFile)) {
this.createPreview(newFile, newIndex);
}
}
this.unzippingProgress = Math.round(((fileEntries.indexOf([relativePath, file]) + 1) / totalFiles) * 100);
}
} catch (error) {
console.error('Ошибка при распаковке ZIP:', error);
alert('Не удалось распаковать архив.');
} finally {
this.isUnzipping = false;
this.unzippingProgress = 0;
}
},
getBasename(path) {
return path.split('/').pop();
},
getFileMimeType(ext) {
const mimeTypes = {
'doc': 'application/msword',
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'pdf': 'application/pdf',
'xls': 'application/vnd.ms-excel',
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'png': 'image/png',
'webp': 'image/webp',
'gif': 'image/gif'
};
return mimeTypes[ext] || 'application/octet-stream';
},
removeFile(index) {
this.form.files.splice(index, 1);
delete this.imagePreviews[index];
const newPreviews = {};
Object.entries(this.imagePreviews).forEach(([key, value]) => {
const numericKey = parseInt(key);
if (numericKey < index) {
newPreviews[numericKey] = value;
} else if (numericKey > index) {
newPreviews[numericKey - 1] = value;
}
});
this.imagePreviews = newPreviews;
},
submitForm() {
this.form.post(route('dashboard.files.store'), {
preserveScroll: true,
onSuccess: () => {
this.form.reset();
const filesInputElement = document.getElementById('aiFilesInput');
if (filesInputElement) filesInputElement.value = '';
this.closeUploadModal();
},
});
},
handleImageDragStart(event, index) {
this.draggedImageIndex = index;
requestAnimationFrame(() => {
event.target.classList.add('opacity-50');
});
},
handleImageDragOver(event) {
event.preventDefault();
event.currentTarget.classList.add('ring-2', 'ring-primary', 'ring-offset-2');
},
handleImageDragLeave(event) {
event.currentTarget.classList.remove('ring-2', 'ring-primary', 'ring-offset-2');
},
handleImageDrop(event, dropIndex) {
event.preventDefault();
event.currentTarget.classList.remove('ring-2', 'ring-primary', 'ring-offset-2');
const dragIndex = this.draggedImageIndex;
if (dragIndex === null || dragIndex === dropIndex) {
return;
}
const imageIndexes = this.form.files
.map((file, idx) => ({ idx, isImage: this.isImageFile(file) }))
.filter(item => item.isImage)
.map(item => item.idx);
const fromOriginalIndex = imageIndexes[dragIndex];
const toOriginalIndex = imageIndexes[dropIndex];
const temp = this.form.files[fromOriginalIndex];
this.form.files[fromOriginalIndex] = this.form.files[toOriginalIndex];
this.form.files[toOriginalIndex] = temp;
const tempPreview = this.imagePreviews[fromOriginalIndex];
this.imagePreviews[fromOriginalIndex] = this.imagePreviews[toOriginalIndex];
this.imagePreviews[toOriginalIndex] = tempPreview;
this.draggedImageIndex = null;
},
handleImageDragEnd(event) {
event.target.classList.remove('opacity-50');
this.draggedImageIndex = null;
}
}
}
</script>
@@ -0,0 +1,278 @@
# Posts Form Components Guide
## Структура компонентов
Posts/Form.vue (1284 строки) разбит на переиспользуемые подкомпоненты:
```
Posts/Components/
├── PostMainInfo.vue # Заголовок, slug, статус, категория
├── PostTagsAuthors.vue # Теги и авторы
└── PostPublishSettings.vue # Настройки публикации и соцсети
```
## Как использовать
### 1. PostMainInfo.vue
Основная информация о посте.
```vue
<PostMainInfo
v-model:title="form.title"
v-model:slug="form.slug"
v-model:status="form.status"
v-model:category-id="form.category_id"
:categories="categories"
:status-options="[PostStatus.DRAFT, PostStatus.PUBLISHED, PostStatus.VERIFICATION, PostStatus.REJECTED]"
:errors="form.errors"
@generate-slug="generateSlug"
/>
```
**Props:**
- `title` - заголовок новости
- `slug` - URL-адрес
- `status` - статус публикации
- `categoryId` - ID категории
- `categories` - массив категорий
- `statusOptions` - массив опций статуса
- `errors` - объект ошибок валидации
- `inputClass` - CSS классы для инпутов
**Events:**
- `update:title` - обновление заголовка
- `update:slug` - обновление slug
- `update:status` - обновление статуса
- `update:categoryId` - обновление категории
- `generate-slug` - генерация slug из заголовка
---
### 2. PostTagsAuthors.vue
Управление тегами и авторами.
```vue
<PostTagsAuthors
:tags="form.tags"
:authors="form.authors"
@add-tag="addTag"
@remove-tag="removeTag"
@add-author="addAuthor"
@remove-author="removeAuthor"
/>
```
**Props:**
- `tags` - массив тегов
- `authors` - массив авторов
**Events:**
- `add-tag` - добавить тег
- `remove-tag` - удалить тег по индексу
- `add-author` - добавить автора
- `remove-author` - удалить автора по индексу
---
### 3. PostPublishSettings.vue
Настройки публикации и социальных сетей.
```vue
<PostPublishSettings
v-model:publish-after="form.publish_setting.publish_after"
v-model:publish-at="form.publish_setting.publish_at"
v-model:publish-vk="form.publication.vk"
:errors="form.errors"
/>
```
**Props:**
- `publishAfter` - флаг отложенной публикации
- `publishAt` - дата/время публикации
- `publishVk` - публикация ВКонтакте
- `errors` - объект ошибок валидации
- `inputClass` - CSS классы для инпутов
**Events:**
- `update:publishAfter` - обновление флага отложенной публикации
- `update:publishAt` - обновление даты/времени
- `update:publishVk` - обновление флага VK
---
## Пример интеграции в Form.vue
### BEFORE (монолитный подход):
```vue
<!-- Внутри Form.vue - 1284 строки -->
<div class="space-y-6">
<!-- Title & Slug -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<!-- 50+ строк markup -->
</div>
<!-- Status & Category -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<!-- 40+ строк markup -->
</div>
<!-- Tags -->
<div>
<!-- 60+ строк markup -->
</div>
<!-- Authors -->
<div>
<!-- 60+ строк markup -->
</div>
<!-- Publishing Settings -->
<div class="border border-layer-line rounded-lg overflow-hidden">
<!-- 80+ строк markup -->
</div>
<!-- Social Media -->
<div class="border border-layer-line rounded-lg overflow-hidden">
<!-- 50+ строк markup -->
</div>
</div>
```
### AFTER (компонентный подход):
```vue
<template>
<div class="min-h-screen bg-background-2">
<!-- Sticky Header -->
<div class="border-b border-line-2 bg-layer/50 backdrop-blur-sm sticky top-0 z-20">
<!-- Header content -->
</div>
<!-- Main Content -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<FlashMessages />
<form @submit.prevent="submitForm" class="space-y-6">
<!-- Tabs Navigation -->
<div class="bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden">
<div class="border-b border-line-2">
<!-- Tab buttons -->
</div>
<!-- Tab Content -->
<div class="p-6">
<!-- Tab: Main Info -->
<div v-show="activeTab === 'main'" class="space-y-6">
<PostMainInfo
v-model:title="form.title"
v-model:slug="form.slug"
v-model:status="form.status"
v-model:category-id="form.category_id"
:categories="categories"
:status-options="statusOptions"
:errors="form.errors"
@generate-slug="generateSlug"
/>
<PostTagsAuthors
:tags="form.tags"
:authors="form.authors"
@add-tag="addTag"
@remove-tag="removeTag"
@add-author="addAuthor"
@remove-author="removeAuthor"
/>
<PostPublishSettings
v-model:publish-after="form.publish_setting.publish_after"
v-model:publish-at="form.publish_setting.publish_at"
v-model:publish-vk="form.publication.vk"
:errors="form.errors"
/>
</div>
<!-- Tab: Content -->
<div v-show="activeTab === 'content'">
<!-- Content builder stays as is -->
</div>
<!-- Tab: Media -->
<div v-show="activeTab === 'media'">
<!-- Media management -->
</div>
<!-- Tab: Slider -->
<div v-show="activeTab === 'slider'">
<!-- Slider settings -->
</div>
</div>
</div>
</form>
</div>
</div>
</template>
<script>
import PostMainInfo from './Components/PostMainInfo.vue';
import PostTagsAuthors from './Components/PostTagsAuthors.vue';
import PostPublishSettings from './Components/PostPublishSettings.vue';
export default {
components: {
PostMainInfo,
PostTagsAuthors,
PostPublishSettings
},
methods: {
addTag(tag) {
this.form.tags.push(tag);
},
removeTag(index) {
this.form.tags.splice(index, 1);
},
addAuthor(author) {
this.form.authors.push(author);
},
removeAuthor(index) {
this.form.authors.splice(index, 1);
},
generateSlug() {
if (!this.form.slug && this.form.title) {
this.form.slug = this.slugify(this.form.title);
}
}
}
}
</script>
```
---
## Преимущества рефакторинга
### До:
- **1284 строки** в одном файле
- Сложно искать и отлаживать
- Невозможно переиспользовать
- Долгая загрузка
### После:
- **~300-400 строк** в Form.vue (только логика)
- **3 переиспользуемых компонента** (~150 строк каждый)
- Легко тестировать изолированно
- Можно использовать в других формах (например, редактирование новостей)
- Улучшенная читаемость
---
## Следующие шаги
1. **Заменить в Form.vue** использование этих компонентов
2. **Создать дополнительные компоненты:**
- `PostContentBuilder.vue` - конструктор контента
- `PostMediaUploader.vue` - загрузка медиа
- `PostSliderConfig.vue` - настройка слайдера
3. **Протестировать** все сценарии создания/редактирования
@@ -0,0 +1,146 @@
<template>
<div class="space-y-6">
<!-- Title & Slug -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div>
<label for="title" class="block text-sm font-medium text-foreground mb-1.5">
Заголовок <span class="text-rose-500">*</span>
</label>
<input
id="title"
:value="title"
@input="$emit('update:title', $event.target.value)"
@blur="$emit('generate-slug')"
type="text"
:class="inputClass"
placeholder="Введите заголовок новости"
/>
<p v-if="errors.title" class="mt-1.5 text-xs text-rose-600 flex items-center gap-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
{{ errors.title }}
</p>
<p v-else class="mt-1.5 text-xs text-muted-foreground-1">Заголовок будет отображаться на сайте</p>
</div>
<div>
<label for="slug" class="block text-sm font-medium text-foreground mb-1.5">
URL-адрес <span class="text-rose-500">*</span>
</label>
<div class="flex gap-2">
<input
id="slug"
:value="slug"
@input="$emit('update:slug', $event.target.value)"
type="text"
:class="inputClass"
placeholder="url-novosti"
class="font-mono flex-1"
readonly
/>
</div>
<p v-if="errors.slug" class="mt-1.5 text-xs text-rose-600 flex items-center gap-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
{{ errors.slug }}
</p>
<p v-else class="mt-1.5 text-xs text-muted-foreground-1">
URL генерируется автоматически. Нажмите для обновления.
</p>
</div>
</div>
<!-- Status & Category -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div>
<label for="status" class="block text-sm font-medium text-foreground mb-1.5">
Статус публикации <span class="text-rose-500">*</span>
</label>
<select
id="status"
:value="status"
@change="$emit('update:status', $event.target.value)"
:class="inputClass"
>
<option v-for="statusOption in statusOptions"
:key="statusOption.value"
:value="statusOption.value">
{{ statusOption.label }}
</option>
</select>
<p v-if="errors.status" class="mt-1.5 text-xs text-rose-600 flex items-center gap-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
{{ errors.status }}
</p>
</div>
<div>
<label for="category_id" class="block text-sm font-medium text-foreground mb-1.5">
Категория
</label>
<select
id="category_id"
:value="categoryId"
@change="$emit('update:categoryId', $event.target.value)"
:class="inputClass"
>
<option value="">Выберите категорию</option>
<option v-for="category in categories" :key="category.id" :value="category.id">
{{ category.title }}
</option>
</select>
<p v-if="errors.category_id" class="mt-1.5 text-xs text-rose-600 flex items-center gap-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
{{ errors.category_id }}
</p>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'PostMainInfo',
props: {
title: {
type: String,
default: ''
},
slug: {
type: String,
default: ''
},
status: {
type: String,
default: ''
},
categoryId: {
type: [String, Number],
default: ''
},
categories: {
type: Array,
default: () => []
},
statusOptions: {
type: Array,
default: () => []
},
errors: {
type: Object,
default: () => ({})
},
inputClass: {
type: String,
default: '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'
}
},
emits: ['update:title', 'update:slug', 'update:status', 'update:categoryId', 'generate-slug']
}
</script>
@@ -0,0 +1,105 @@
<template>
<div class="space-y-4">
<!-- Publishing Settings -->
<div class="border border-layer-line rounded-lg overflow-hidden">
<div class="px-4 py-3 bg-surface/50 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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 class="text-sm font-medium text-foreground">Публикация по времени</h3>
</div>
</div>
<div class="p-4 space-y-4">
<div class="flex items-center gap-3">
<input
id="publish_after"
:checked="publishAfter"
@change="$emit('update:publishAfter', $event.target.checked)"
:disabled="!!publishAt"
type="checkbox"
class="w-4 h-4 text-primary border-layer-line rounded focus:ring-primary/20"
/>
<label for="publish_after" class="text-sm text-foreground">
Включить отложенную публикацию
</label>
</div>
<div v-if="publishAfter">
<label for="publish_at" class="block text-sm font-medium text-foreground mb-1.5">
Дата и время публикации
</label>
<input
id="publish_at"
:value="publishAt"
@input="$emit('update:publishAt', $event.target.value)"
type="datetime-local"
:class="inputClass"
/>
<p v-if="errors.publish_at" class="mt-1.5 text-xs text-rose-600 flex items-center gap-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
{{ errors.publish_at }}
</p>
</div>
</div>
</div>
<!-- Social Media -->
<div class="border border-layer-line rounded-lg overflow-hidden">
<div class="px-4 py-3 bg-surface/50 border-b border-line-2">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-blue-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2.04c-5.5 0-10 4.49-10 10.02c0 5 3.66 9.15 8.44 9.9v-7H7.9v-2.9h2.54V9.85c0-2.51 1.49-3.89 3.78-3.89c1.09 0 2.23.19 2.23.19v2.47h-1.26c-1.24 0-1.63.77-1.63 1.56v1.88h2.78l-.45 2.9h-2.33v7a10 10 0 0 0 8.44-9.9c0-5.53-4.5-10.02-10-10.02Z"/>
</svg>
<h3 class="text-sm font-medium text-foreground">Публикация в соцсетях</h3>
</div>
</div>
<div class="p-4">
<div class="flex items-center gap-3">
<input
id="publication_vk"
:checked="publishVk"
@change="$emit('update:publishVk', $event.target.checked)"
type="checkbox"
class="w-4 h-4 text-primary border-layer-line rounded focus:ring-primary/20"
/>
<label for="publication_vk" class="text-sm text-foreground">
Опубликовать ВКонтакте
</label>
</div>
<p class="mt-2 text-xs text-muted-foreground-1">Новость будет автоматически опубликована в VK</p>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'PostPublishSettings',
props: {
publishAfter: {
type: Boolean,
default: false
},
publishAt: {
type: String,
default: ''
},
publishVk: {
type: Boolean,
default: false
},
errors: {
type: Object,
default: () => ({})
},
inputClass: {
type: String,
default: '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'
}
},
emits: ['update:publishAfter', 'update:publishAt', 'update:publishVk']
}
</script>
@@ -0,0 +1,115 @@
<template>
<div class="space-y-6">
<!-- Tags -->
<div>
<label class="block text-sm font-medium text-foreground mb-1.5">
Теги
</label>
<div
class="min-h-[42px] flex flex-wrap items-center gap-2 p-2.5 bg-surface border border-layer-line rounded-lg focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/20 transition-all"
>
<span
v-for="(tag, index) in tags"
:key="index"
class="inline-flex items-center gap-1.5 px-3 py-1 bg-primary/10 text-primary rounded-full text-sm font-medium group"
>
{{ tag }}
<button
type="button"
@click="$emit('remove-tag', index)"
class="text-primary/70 hover:text-primary transition-colors"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
<input
ref="tagInput"
v-model="newTag"
type="text"
class="flex-1 min-w-[120px] bg-transparent outline-none text-sm text-foreground placeholder-muted-foreground-2"
placeholder="Добавьте тег и нажмите Enter"
@keydown.enter.prevent="addTag"
@keydown.comma.prevent="addTag"
/>
</div>
<p class="mt-1.5 text-xs text-muted-foreground-1">Нажмите Enter для добавления тега</p>
</div>
<!-- Authors -->
<div>
<label class="block text-sm font-medium text-foreground mb-1.5">
Авторы
</label>
<div
class="min-h-[42px] flex flex-wrap items-center gap-2 p-2.5 bg-surface border border-layer-line rounded-lg focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/20 transition-all"
>
<span
v-for="(author, index) in authors"
:key="index"
class="inline-flex items-center gap-1.5 px-3 py-1 bg-surface-muted text-foreground rounded-full text-sm font-medium group"
>
<svg class="w-3.5 h-3.5 text-muted-foreground-2" 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>
{{ author }}
<button
type="button"
@click="$emit('remove-author', index)"
class="text-muted-foreground-1 hover:text-foreground transition-colors"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
<input
v-model="newAuthor"
type="text"
class="flex-1 min-w-[120px] bg-transparent outline-none text-sm text-foreground placeholder-muted-foreground-2"
placeholder="Добавьте автора"
@keydown.enter.prevent="$emit('add-author')"
/>
</div>
<p class="mt-1.5 text-xs text-muted-foreground-1">Укажите авторов новости</p>
</div>
</div>
</template>
<script>
export default {
name: 'PostTagsAuthors',
props: {
tags: {
type: Array,
default: () => []
},
authors: {
type: Array,
default: () => []
}
},
emits: ['add-tag', 'remove-tag', 'add-author', 'remove-author'],
data() {
return {
newTag: '',
newAuthor: ''
}
},
methods: {
addTag() {
if (this.newTag.trim()) {
this.$emit('add-tag', this.newTag.trim());
this.newTag = '';
}
},
addAuthor() {
if (this.newAuthor.trim()) {
this.$emit('add-author', this.newAuthor.trim());
this.newAuthor = '';
}
}
}
}
</script>
@@ -0,0 +1,35 @@
<template>
<PostForm
:categories="categories"
:sliders="sliders"
:statuses="statuses"
/>
</template>
<script>
import PostForm from './Form.vue';
export default {
name: 'CreatePost',
components: { PostForm },
props: {
categories: {
type: Array,
required: true
},
sliders: {
type: Array,
required: true
},
statuses: {
type: Array,
required: true
}
},
mounted() {
this.SET_DOCUMENT_TITLE('Создание новости');
}
}
</script>
@@ -0,0 +1,40 @@
<template>
<PostForm
:post="post"
:categories="categories"
:sliders="sliders"
:statuses="statuses"
/>
</template>
<script>
import PostForm from './Form.vue';
export default {
name: 'EditPost',
components: { PostForm },
props: {
post: {
type: Object,
required: true
},
categories: {
type: Array,
required: true
},
sliders: {
type: Array,
required: true
},
statuses: {
type: Array,
required: true
}
},
mounted() {
this.SET_DOCUMENT_TITLE(`Редактирование новости - ${this.post.title}`);
}
}
</script>
+938
View File
@@ -0,0 +1,938 @@
<template>
<div class="min-h-screen bg-background-2">
<!-- Sticky Header -->
<div class="border-b border-line-2 bg-layer/50 backdrop-blur-sm sticky top-0 z-20">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
<DashboardIcon v-if="isEdit" name="pencil-square" size="5" class="text-primary" />
<DashboardIcon v-else name="plus" size="5" class="text-primary" />
</div>
<div>
<h1 class="text-lg font-medium text-foreground">{{ isEdit ? 'Редактирование новости' : 'Создание новости' }}</h1>
<p class="text-xs text-muted-foreground-1">{{ isEdit ? 'Внесите изменения в публикацию' : 'Заполните все необходимые поля' }}</p>
</div>
</div>
<div class="flex items-center gap-3">
<a
:href="route('dashboard.posts.index')"
class="inline-flex items-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"
>
<DashboardIcon name="arrow-left" size="4" />
<span class="hidden sm:inline">Назад</span>
</a>
<button
type="button"
@click="submitForm"
:disabled="form.processing"
class="inline-flex items-center gap-2 px-5 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-sm hover:shadow-md"
>
<svg v-if="form.processing" class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<DashboardIcon v-else name="check" size="4" />
{{ isEdit ? 'Сохранить' : 'Создать' }}
</button>
</div>
</div>
</div>
</div>
<!-- Main Content -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<!-- Flash Messages -->
<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">
<DashboardIcon name="check-circle" size="5" class="text-emerald-600 flex-shrink-0 mt-0.5" />
<span class="text-sm text-foreground font-medium">{{ $page.props.flash.success }}</span>
</div>
</div>
</transition>
<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">
<DashboardIcon name="exclamation-circle" size="5" class="text-rose-600 flex-shrink-0 mt-0.5" />
<span class="text-sm text-foreground font-medium">{{ $page.props.flash.error }}</span>
</div>
</div>
</transition>
<form @submit.prevent="submitForm" class="space-y-6">
<!-- Tabs Navigation -->
<div class="bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden">
<div class="border-b border-line-2">
<div class="flex overflow-x-auto">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
@click="switchTab(tab.id)"
:class="[
'group inline-flex items-center gap-2 px-5 py-4 text-sm font-medium border-b-2 transition-all duration-200 whitespace-nowrap',
activeTab === tab.id
? 'border-primary text-primary bg-primary/5'
: 'border-transparent text-muted-foreground-1 hover:text-foreground hover:border-line-2'
]"
>
<DashboardIcon v-if="tab.id === 'main'" name="information-circle" size="4" :class="activeTab === tab.id ? 'text-primary' : 'text-muted-foreground-2'" />
<DashboardIcon v-if="tab.id === 'content'" name="document-text" size="4" :class="activeTab === tab.id ? 'text-primary' : 'text-muted-foreground-2'" />
<DashboardIcon v-if="tab.id === 'media'" name="photo" size="4" :class="activeTab === tab.id ? 'text-primary' : 'text-muted-foreground-2'" />
<DashboardIcon v-if="tab.id === 'slider'" name="presentation-chart-bar" size="4" :class="activeTab === tab.id ? 'text-primary' : 'text-muted-foreground-2'" />
{{ tab.label }}
</button>
</div>
</div>
<!-- Tab Content -->
<div class="p-6">
<!-- Tab: Main Info -->
<div v-if="activeTab === 'main'" class="space-y-6">
<!-- Title & Slug -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div>
<label for="title" class="block text-sm font-medium text-foreground mb-1.5">
Заголовок <span class="text-rose-500">*</span>
</label>
<input
id="title"
v-model="form.title"
type="text"
:class="inputClass('title')"
placeholder="Введите заголовок новости"
@blur="generateSlug"
/>
<p v-if="form.errors.title" class="mt-1.5 text-xs text-rose-600 flex items-center gap-1">
<DashboardIcon name="exclamation-circle" size="3" />
{{ form.errors.title }}
</p>
<p v-else class="mt-1.5 text-xs text-muted-foreground-1">Заголовок будет отображаться на сайте</p>
</div>
<div>
<label for="slug" class="block text-sm font-medium text-foreground mb-1.5">
URL-адрес <span class="text-rose-500">*</span>
</label>
<div class="flex gap-2">
<input
id="slug"
v-model="form.slug"
type="text"
:class="inputClass('slug')"
placeholder="url-novosti"
class="font-mono flex-1"
readonly
/>
</div>
<p v-if="form.errors.slug" class="mt-1.5 text-xs text-rose-600 flex items-center gap-1">
<DashboardIcon name="exclamation-circle" size="3" />
{{ form.errors.slug }}
</p>
<p v-else class="mt-1.5 text-xs text-muted-foreground-1">
URL генерируется автоматически. Нажмите для обновления.
</p>
</div>
</div>
<!-- Status & Category -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div>
<label for="status" class="block text-sm font-medium text-foreground mb-1.5">
Статус публикации <span class="text-rose-500">*</span>
</label>
<select
id="status"
v-model="form.status"
:class="inputClass('status')"
>
<option v-for="status in [PostStatus.DRAFT, PostStatus.PUBLISHED, PostStatus.VERIFICATION, PostStatus.REJECTED]"
:key="status.value"
:value="status.value">
{{ status.label }}
</option>
</select>
<p v-if="form.errors.status" class="mt-1.5 text-xs text-rose-600 flex items-center gap-1">
<DashboardIcon name="exclamation-circle" size="3" />
{{ form.errors.status }}
</p>
</div>
<div>
<label for="category_id" class="block text-sm font-medium text-foreground mb-1.5">
Категория
</label>
<select
id="category_id"
v-model="form.category_id"
:class="inputClass('category_id')"
>
<option value="">Выберите категорию</option>
<option v-for="category in categories" :key="category.id" :value="category.id">
{{ category.title }}
</option>
</select>
<p v-if="form.errors.category_id" class="mt-1.5 text-xs text-rose-600 flex items-center gap-1">
<DashboardIcon name="exclamation-circle" size="3" />
{{ form.errors.category_id }}
</p>
</div>
</div>
<!-- Tags -->
<div>
<label class="block text-sm font-medium text-foreground mb-1.5">
Теги
</label>
<div
class="min-h-[42px] flex flex-wrap items-center gap-2 p-2.5 bg-surface border border-layer-line rounded-lg focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/20 transition-all"
>
<span
v-for="(tag, index) in form.tags"
:key="index"
class="inline-flex items-center gap-1.5 px-3 py-1 bg-primary/10 text-primary rounded-full text-sm font-medium group"
>
{{ tag }}
<button
type="button"
@click="removeTag(index)"
class="text-primary/70 hover:text-primary transition-colors"
>
<DashboardIcon name="x-mark" size="4" />
</button>
</span>
<input
ref="tagInput"
v-model="newTag"
type="text"
class="flex-1 min-w-[120px] bg-transparent outline-none text-sm text-foreground placeholder-muted-foreground-2"
placeholder="Добавьте тег и нажмите Enter"
@keydown.enter.prevent="addTag"
@keydown.comma.prevent="addTag"
/>
</div>
<p class="mt-1.5 text-xs text-muted-foreground-1">Нажмите Enter для добавления тега</p>
</div>
<!-- Authors -->
<div>
<label class="block text-sm font-medium text-foreground mb-1.5">
Авторы
</label>
<div
class="min-h-[42px] flex flex-wrap items-center gap-2 p-2.5 bg-surface border border-layer-line rounded-lg focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/20 transition-all"
>
<span
v-for="(author, index) in form.authors"
:key="index"
class="inline-flex items-center gap-1.5 px-3 py-1 bg-surface-muted text-foreground rounded-full text-sm font-medium group"
>
<DashboardIcon name="user" size="4" class="text-muted-foreground-2" />
{{ author }}
<button
type="button"
@click="removeAuthor(index)"
class="text-muted-foreground-1 hover:text-foreground transition-colors"
>
<DashboardIcon name="x-mark" size="4" />
</button>
</span>
<input
v-model="newAuthor"
type="text"
class="flex-1 min-w-[120px] bg-transparent outline-none text-sm text-foreground placeholder-muted-foreground-2"
placeholder="Добавьте автора"
@keydown.enter.prevent="addAuthor"
/>
</div>
<p class="mt-1.5 text-xs text-muted-foreground-1">Укажите авторов новости</p>
</div>
<!-- Publishing Settings -->
<div class="border border-layer-line rounded-lg overflow-hidden">
<div class="px-4 py-3 bg-surface/50 border-b border-line-2">
<div class="flex items-center gap-2">
<DashboardIcon name="clock" size="4" class="text-muted-foreground-1" />
<h3 class="text-sm font-medium text-foreground">Публикация по времени</h3>
</div>
</div>
<div class="p-4 space-y-4">
<div class="flex items-center gap-3">
<input
id="publish_after"
v-model="form.publish_setting.publish_after"
type="checkbox"
:disabled="!!form.publish_setting.publish_at"
class="w-4 h-4 text-primary border-layer-line rounded focus:ring-primary/20"
/>
<label for="publish_after" class="text-sm text-foreground">
Включить отложенную публикацию
</label>
</div>
<div v-if="form.publish_setting.publish_after">
<label for="publish_at" class="block text-sm font-medium text-foreground mb-1.5">
Дата и время публикации
</label>
<input
id="publish_at"
v-model="form.publish_setting.publish_at"
type="datetime-local"
:class="inputClass('publish_setting.publish_at')"
/>
<p v-if="form.errors['publish_setting.publish_at']" class="mt-1.5 text-xs text-rose-600 flex items-center gap-1">
<DashboardIcon name="exclamation-circle" size="3" />
{{ form.errors['publish_setting.publish_at'] }}
</p>
</div>
</div>
</div>
<!-- Social Media -->
<div class="border border-layer-line rounded-lg overflow-hidden">
<div class="px-4 py-3 bg-surface/50 border-b border-line-2">
<div class="flex items-center gap-2">
<DashboardIcon name="share" size="4" class="text-blue-500" />
<h3 class="text-sm font-medium text-foreground">Публикация в соцсетях</h3>
</div>
</div>
<div class="p-4">
<div class="flex items-center gap-3">
<input
id="publication_vk"
v-model="form.publication.vk"
type="checkbox"
class="w-4 h-4 text-primary border-layer-line rounded focus:ring-primary/20"
/>
<label for="publication_vk" class="text-sm text-foreground">
Опубликовать ВКонтакте
</label>
</div>
<p class="mt-2 text-xs text-muted-foreground-1">Новость будет автоматически опубликована в VK</p>
</div>
</div>
</div>
<!-- Tab: Content -->
<div v-if="activeTab === 'content'" class="space-y-4">
<ContentBuilder
v-model="form.content"
label="Содержание новости"
/>
</div>
<!-- Tab: Media -->
<div v-if="activeTab === 'media'" class="space-y-6">
<!-- Preview Image -->
<div>
<label class="block text-sm font-medium text-foreground mb-1.5">
Главное изображение
</label>
<div class="space-y-3">
<div v-if="form.preview" class="group relative w-full max-w-md mx-auto">
<!-- Квадратный контейнер -->
<div class="aspect-square w-full max-w-[280px] mx-auto relative bg-layer border-2 border-layer-line rounded-xl overflow-hidden shadow-lg hover:shadow-xl transition-all duration-300 hover:border-primary/50">
<img
:src="previewUrl"
alt="Preview"
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
/>
<!-- Overlay с иконкой -->
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-all duration-300 flex items-center justify-center">
<DashboardIcon name="photo" size="12" class="text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300 drop-shadow-lg" />
</div>
<!-- Badge -->
<div class="absolute top-3 left-3 px-3 py-1.5 bg-primary text-white text-xs font-bold rounded-full shadow-lg flex items-center gap-1.5">
<DashboardIcon name="photo" size="4" />
Главное
</div>
<!-- Кнопка удаления -->
<button
type="button"
@click="form.preview = null"
class="absolute top-3 right-3 p-2 bg-rose-500 text-white rounded-full opacity-0 group-hover:opacity-100 hover:bg-rose-600 hover:scale-110 transition-all duration-200 shadow-lg z-10"
title="Удалить изображение"
>
<DashboardIcon name="x-mark" size="4" />
</button>
<!-- Нижняя плашка -->
<div class="absolute bottom-0 left-0 right-0 h-10 bg-gradient-to-t from-black/70 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-end justify-center pb-2">
<span class="text-[10px] font-medium text-white uppercase tracking-wider">Нажмите для замены</span>
</div>
</div>
<!-- Кнопка загрузки под изобраением -->
<button
type="button"
@click="triggerPreviewInput"
class="absolute -bottom-3 left-1/2 -translate-x-1/2 px-4 py-1.5 bg-primary text-white text-xs font-medium rounded-full shadow-lg hover:bg-primary-hover transition-all duration-200 flex items-center gap-1.5 whitespace-nowrap"
>
<DashboardIcon name="arrow-path" size="4" />
Заменить
</button>
</div>
<!-- Drop Zone -->
<div
v-if="!form.preview"
@dragover.prevent="isDraggingPreview = true"
@dragleave.prevent="isDraggingPreview = false"
@drop.prevent="handlePreviewDrop"
@click="triggerPreviewInput"
:class="[
'relative flex flex-col items-center justify-center px-6 py-12 border-2 border-dashed rounded-xl cursor-pointer transition-all duration-300',
isDraggingPreview
? 'border-primary bg-primary/10 scale-[1.02]'
: 'border-layer-line hover:border-primary/50 hover:bg-surface'
]"
>
<div class="space-y-4 text-center">
<!-- Иконка -->
<div
:class="[
'w-16 h-16 rounded-2xl flex items-center justify-center transition-all duration-300 mx-auto',
isDraggingPreview ? 'bg-primary/20 scale-110' : 'bg-primary/10 group-hover:scale-105'
]"
>
<DashboardIcon name="cloud-arrow-up" size="8" class="text-primary" />
</div>
<!-- Текст -->
<div>
<p class="text-base font-medium text-foreground">
<span class="text-primary font-semibold">Нажмите для выбора</span> или перетащите файл
</p>
<p class="text-xs text-muted-foreground-1 mt-2">
JPG, PNG, WEBP (до 20MB)
</p>
</div>
<!-- Подсказка -->
<div class="flex items-center justify-center gap-2 text-[10px] text-muted-foreground-2 uppercase tracking-wider">
<DashboardIcon name="information-circle" size="4" />
Рекомендуемый размер: 1200×800px
</div>
</div>
</div>
<input
id="previewInput"
ref="previewInput"
type="file"
class="hidden"
accept="image/jpeg,image/png,image/webp"
@change="handlePreviewFileSelect"
/>
</div>
<p v-if="form.errors.preview" class="mt-1.5 text-xs text-rose-600 flex items-center gap-1">
<DashboardIcon name="exclamation-circle" size="3" />
{{ form.errors.preview }}
</p>
<p v-else class="mt-1.5 text-xs text-muted-foreground-1">
Главное изображение будет отображаться на сайте и в соцсетях
</p>
</div>
<!-- Gallery -->
<div>
<label class="block text-sm font-medium text-foreground mb-1.5">
Галерея изображений
</label>
<div class="space-y-3">
<div
@dragover.prevent="isDraggingGallery = true"
@dragleave.prevent="isDraggingGallery = false"
@drop.prevent="handleGalleryDrop"
@click="triggerGalleryInput"
:class="[
'relative flex justify-center px-6 pt-8 pb-10 border-2 border-dashed rounded-lg cursor-pointer transition-all',
isDraggingGallery ? 'border-primary bg-primary/5' : 'border-layer-line hover:border-primary/50 hover:bg-surface'
]"
>
<div class="space-y-3 text-center">
<div class="flex justify-center">
<div :class="[isDraggingGallery ? 'bg-primary/20' : 'bg-primary/10', 'w-12 h-12 rounded-lg flex items-center justify-center transition-all']">
<DashboardIcon name="cloud-arrow-up" size="6" class="text-primary" />
</div>
</div>
<div>
<p class="text-sm font-medium text-foreground">
<span class="text-primary">Нажмите для выбора</span> или перетащите файлы
</p>
<p class="text-xs text-muted-foreground-1 mt-1">JPG, PNG (до 20MB каждый, макс. 200 файлов)</p>
</div>
</div>
</div>
<input
id="galleryInput"
ref="galleryInput"
type="file"
class="hidden"
accept="image/jpeg,image/png"
multiple
@change="handleGalleryFileSelect"
/>
<!-- Gallery Grid -->
<div v-if="form.images && form.images.length > 0" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
<div
v-for="(img, index) in galleryUrls"
:key="index"
draggable="true"
@dragstart="handleGalleryDragStart($event, index)"
@dragover="handleGalleryDragOver"
@dragleave="handleGalleryDragLeave"
@drop="handleGalleryDropOnImage($event, index)"
@dragend="handleGalleryDragEnd"
:class="[
'group relative aspect-square bg-layer border-2 rounded-lg overflow-hidden shadow-sm cursor-grab transition-all duration-200',
draggedGalleryIndex === index ? 'opacity-50 scale-95 ring-4 ring-primary ring-offset-2' : 'hover:border-primary hover:shadow-md',
'active:cursor-grabbing'
]"
>
<!-- Drag Handle Overlay -->
<div class="absolute inset-0 z-10 flex items-center justify-center bg-black/0 group-hover:bg-black/20 transition-all duration-200 pointer-events-none">
<DashboardIcon name="bars-4" size="8" class="text-white opacity-0 group-hover:opacity-100 transition-opacity duration-200 drop-shadow-lg" />
</div>
<img :src="img" alt="Gallery" class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-300" />
<!-- Number Badge -->
<div class="absolute top-2 left-2 w-7 h-7 bg-white/95 backdrop-blur-sm rounded-full flex items-center justify-center text-sm font-bold text-primary shadow-lg border-2 border-primary/20">
{{ index + 1 }}
</div>
<!-- Delete Button -->
<button
type="button"
@click="form.images.splice(index, 1)"
class="absolute top-2 right-2 p-1.5 bg-rose-500 text-white rounded-full opacity-0 group-hover:opacity-100 hover:bg-rose-600 hover:scale-110 transition-all duration-200 shadow-lg z-20"
title="Удалить"
>
<DashboardIcon name="x-mark" size="4" />
</button>
<!-- Drag Indicator -->
<div class="absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-end justify-center pb-1.5 pointer-events-none">
<span class="text-[10px] font-medium text-white uppercase tracking-wider">Перетащить</span>
</div>
</div>
</div>
</div>
<p v-if="form.errors.images" class="mt-1.5 text-xs text-rose-600 flex items-center gap-1">
<DashboardIcon name="exclamation-circle" size="3" />
{{ form.errors.images }}
</p>
</div>
</div>
<!-- Tab: Slider -->
<div v-if="activeTab === 'slider'" class="space-y-6">
<div class="text-center py-12">
<DashboardIcon name="information-circle" size="16" class="text-gray-400 mx-auto mb-4" />
<p class="text-sm text-gray-600">Управление слайдами доступно в разделе "Слайдеры" Dashboard</p>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
</template>
<script>
import PostStatus from '@/Enum/PostStatus.js';
import DashboardIcon from '../Components/DashboardIcon.vue';
import ContentBuilder from '../Components/ContentBuilder/ContentBuilder.vue';
export default {
name: 'PostForm',
components: {
DashboardIcon,
ContentBuilder,
},
props: {
post: {
type: Object,
default: null
},
categories: {
type: Array,
required: true
},
sliders: {
type: Array,
required: true
},
statuses: {
type: Array,
required: true
}
},
data() {
return {
activeTab: 'main',
isEdit: false,
isSliderEnabled: false,
activeButton: true,
isDraggingPreview: false,
isDraggingGallery: false,
newTag: '',
newAuthor: '',
PostStatus,
draggedGalleryIndex: null,
form: {
title: '',
slug: '',
status: 'published',
category_id: null,
tags: [],
authors: [],
content: [],
preview: null,
images: [],
publish_setting: {
publish_after: false,
publish_at: null
},
publication: {
vk: true,
telegram: true
},
slide: {
slider_id: null,
title: '',
content: '',
color_theme: '#ffffff',
image: {
url: null,
shading: '0.5'
},
settings: {
text_position: 'left',
link_text: 'Читать'
},
end_time: null
},
errors: {}
},
tabs: [
{ id: 'main', label: 'Основная информация' },
{ id: 'content', label: 'Содержание' },
{ id: 'media', label: 'Медиа' },
{ id: 'slider', label: 'Слайдер' }
],
slugGenerated: false
}
},
watch: {
'form.title': function(newVal, oldVal) {
if (newVal && !this.slugGenerated) {
this.generateSlug();
}
if (!newVal) {
this.slugGenerated = false;
}
}
},
mounted() {
const title = this.isEdit ? `Редактирование новости - ${this.post?.title}` : 'Создание новости';
this.SET_DOCUMENT_TITLE(title);
// Deep linking: восстанавливаем таб из URL
const urlParams = new URLSearchParams(window.location.search);
const tabFromUrl = urlParams.get('tab');
if (tabFromUrl && this.tabs.some(t => t.id === tabFromUrl)) {
this.activeTab = tabFromUrl;
}
},
created() {
if (this.post) {
this.isEdit = true;
this.initializeForm();
}
},
computed: {
previewUrl() {
if (!this.form.preview) return null;
if (typeof this.form.preview === 'string') {
return `/storage/${this.form.preview}`;
}
return URL.createObjectURL(this.form.preview);
},
galleryUrls() {
return this.form.images.map(img => {
if (typeof img === 'string') {
return `/storage/${img}`;
}
return URL.createObjectURL(img);
});
},
slideImageUrl() {
if (!this.form.slide.image.url) return null;
if (typeof this.form.slide.image.url === 'string') {
return `/storage/${this.form.slide.image.url}`;
}
return URL.createObjectURL(this.form.slide.image.url);
}
},
beforeUnmount() {
// Очищаем созданные URL объекты
if (this.form.preview && typeof this.form.preview !== 'string') {
URL.revokeObjectURL(this.previewUrl);
}
this.form.images.forEach((img, index) => {
if (typeof img !== 'string') {
URL.revokeObjectURL(this.galleryUrls[index]);
}
});
},
methods: {
switchTab(tabId) {
this.activeTab = tabId;
// Deep linking: обновляем URL без перезагрузки
const url = new URL(window.location);
url.searchParams.set('tab', tabId);
window.history.replaceState({}, '', url);
},
initializeForm() {
const p = this.post;
this.form = {
...this.form,
title: p.title || '',
slug: p.slug || '',
status: p.status?.value ?? p.status ?? 'published',
category_id: p.category_id || null,
tags: p.tags?.map(t => t.name) || [],
authors: p.authors || [],
content: p.content || [],
preview: p.preview || null,
images: p.images || [],
publish_setting: p.publish_setting || { publish_after: false, publish_at: null },
publication: p.publication || { vk: true, telegram: true },
slide: p.slide || this.form.slide
};
this.isSliderEnabled = !!(p.slide && p.slide.slider_id);
},
inputClass(field) {
const base = '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 placeholder-muted-foreground-2 ';
return this.form.errors[field]
? base + 'border-rose-300 focus:ring-rose-200'
: base;
},
getStatusLabel(value) {
return PostStatus.getLabel(value) || value;
},
generateSlug() {
if (!this.form.title) return;
this.form.slug = this.GENERATE_SLUG(this.form.title);
this.slugGenerated = true;
},
addTag() {
const tag = this.newTag.trim();
if (tag && !this.form.tags.includes(tag)) {
this.form.tags.push(tag);
this.newTag = '';
}
},
removeTag(index) {
this.form.tags.splice(index, 1);
},
addAuthor() {
const author = this.newAuthor.trim();
if (author && !this.form.authors.includes(author)) {
this.form.authors.push(author);
this.newAuthor = '';
}
},
removeAuthor(index) {
this.form.authors.splice(index, 1);
},
handlePreviewDrop(e) {
const files = e.dataTransfer.files;
if (files.length > 0 && this.isImageFile(files[0])) {
this.form.preview = files[0];
}
},
handlePreviewFileSelect(e) {
const file = e.target.files[0];
if (file && this.isImageFile(file)) {
this.form.preview = file;
}
},
triggerPreviewInput() {
this.$refs.previewInput.click();
},
handleGalleryDrop(e) {
const files = Array.from(e.dataTransfer.files).filter(f => this.isImageFile(f));
files.forEach(file => {
this.form.images.push(file);
});
},
handleGalleryFileSelect(e) {
const files = Array.from(e.target.files).filter(f => this.isImageFile(f));
files.forEach(file => {
this.form.images.push(file);
});
},
triggerGalleryInput() {
this.$refs.galleryInput.click();
},
handleGalleryDragStart(event, index) {
this.draggedGalleryIndex = index;
// Скрываем оригинал при перетаскивании
requestAnimationFrame(() => {
event.target.classList.add('opacity-30', 'scale-95');
event.target.classList.remove('opacity-100', 'scale-100');
});
// Создаем кастомный drag image
const dragIcon = document.createElement('div');
dragIcon.className = 'w-20 h-20 bg-primary/10 border-2 border-primary rounded-lg flex items-center justify-center';
dragIcon.innerHTML = '<svg class="w-8 h-8 text-primary" 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>';
event.dataTransfer.setDragImage(dragIcon, 40, 40);
event.dataTransfer.effectAllowed = 'move';
},
handleGalleryDragOver(event) {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
event.currentTarget.classList.add('ring-4', 'ring-primary', 'ring-offset-2', 'border-primary', 'scale-105');
event.currentTarget.classList.remove('border-layer-line');
},
handleGalleryDragLeave(event) {
event.currentTarget.classList.remove('ring-4', 'ring-primary', 'ring-offset-2', 'border-primary', 'scale-105');
event.currentTarget.classList.add('border-layer-line');
},
handleGalleryDropOnImage(event, dropIndex) {
event.preventDefault();
event.stopPropagation();
event.currentTarget.classList.remove('ring-4', 'ring-primary', 'ring-offset-2', 'border-primary', 'scale-105');
event.currentTarget.classList.add('border-layer-line');
if (this.draggedGalleryIndex === null || this.draggedGalleryIndex === dropIndex) {
return;
}
// Анимация обмена
const temp = this.form.images[this.draggedGalleryIndex];
this.form.images[this.draggedGalleryIndex] = this.form.images[dropIndex];
this.form.images[dropIndex] = temp;
this.draggedGalleryIndex = null;
},
handleGalleryDragEnd(event) {
event.target.classList.remove('opacity-30', 'scale-95');
event.target.classList.add('opacity-100', 'scale-100');
this.draggedGalleryIndex = null;
// Очищаем все оставшиеся классы
document.querySelectorAll('.ring-4').forEach(el => {
el.classList.remove('ring-4', 'ring-primary', 'ring-offset-2', 'border-primary', 'scale-105');
el.classList.add('border-layer-line');
});
},
handleSlideImageSelect(e) {
const file = e.target.files[0];
if (file && this.isImageFile(file)) {
this.form.slide.image.url = file;
}
},
isImageFile(file) {
return ['image/jpeg', 'image/png', 'image/webp'].includes(file.type);
},
submitForm() {
this.form.processing = true;
this.form.errors = {};
const formData = new FormData();
Object.keys(this.form).forEach(key => {
if (key === 'errors' || key === 'processing') return;
const value = this.form[key];
if (typeof value === 'object' && value !== null) {
formData.append(key, JSON.stringify(value));
} else {
formData.append(key, value);
}
});
const url = this.isEdit
? route('dashboard.posts.update', this.post.id)
: route('dashboard.posts.store');
const method = this.isEdit ? 'POST' : 'POST';
if (this.isEdit) {
formData.append('_method', 'PUT');
}
this.$inertia.post(url, formData, {
preserveScroll: true,
onSuccess: () => {
this.form.processing = false;
},
onError: (errors) => {
this.form.processing = false;
this.form.errors = errors;
}
});
}
}
}
</script>
@@ -0,0 +1,710 @@
<template>
<DashboardLayout>
<template #header-icon>
<DashboardIcon name="document-text" size="5" class="text-primary" />
</template>
<template #header-title>Новости</template>
<template #header-subtitle>Управление публикациями</template>
<template #header-actions>
<a
:href="route('dashboard.posts.create')"
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 duration-200 shadow-sm hover:shadow-md"
>
<DashboardIcon name="plus" size="4" />
Создать новость
</a>
</template>
<template #breadcrumbs>
<Breadcrumbs :crumbs="[{ label: 'Новости', href: route('dashboard.posts.index') }]" />
</template>
<!-- Flash Messages -->
<FlashMessages />
<!-- Created Post Notification -->
<transition
enter-active-class="transition duration-300"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-200"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div v-if="$page.props.flash?.created_post" class="mb-6 bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden">
<div class="bg-primary-50 px-4 py-3 border-b border-primary-100">
<div class="flex items-center gap-2">
<DashboardIcon name="document-text" size="5" class="text-primary" />
<h3 class="text-sm font-medium text-primary-foreground">Новость создана</h3>
</div>
</div>
<div class="p-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<div>
<p class="text-xs text-muted-foreground-1 mb-1">Заголовок</p>
<p class="text-sm font-medium text-foreground">{{ $page.props.flash.created_post.title }}</p>
</div>
<div class="flex items-center gap-3">
<div>
<p class="text-xs text-muted-foreground-1 mb-1">Статус</p>
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-amber-50 text-amber-700 border border-amber-200">
{{ $page.props.flash.created_post.status }}
</span>
</div>
<div v-if="$page.props.flash.created_post.category">
<p class="text-xs text-muted-foreground-1 mb-1">Категория</p>
<p class="text-sm font-medium text-foreground">{{ $page.props.flash.created_post.category.title }}</p>
</div>
</div>
</div>
<div v-if="$page.props.flash.created_post.preview" class="flex justify-center md:justify-end">
<img
:src="`/storage/${$page.props.flash.created_post.preview}`"
alt="Preview"
class="h-28 w-auto rounded-lg border border-layer-line object-cover"
/>
</div>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<a
:href="`/admin/posts/${$page.props.flash.created_post.id}/edit`"
target="_blank" rel="external"
class="inline-flex items-center px-3 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all"
>
<DashboardIcon name="pencil-square" size="4" class="mr-1.5" />
Редактировать
</a>
<button
@click.prevent="openPostModal($page.props.flash.created_post)"
class="inline-flex items-center px-3 py-2 border border-layer-line text-sm font-medium rounded-lg text-layer-foreground hover:bg-primary-50 transition-all"
>
<DashboardIcon name="eye" size="4" class="mr-1.5" />
Просмотр
</button>
</div>
</div>
</div>
</transition>
<!-- Filters Card -->
<DataFilters title="Фильтры" @reset="resetFilters">
<SearchInput
v-model="searchQuery"
label="Поиск по заголовку"
placeholder="Введите название..."
@search="search"
/>
<SelectFilter
v-model="statusQuery"
label="Статус"
placeholder="Все статусы"
@change="filterByStatus"
>
<option v-for="status in [PostStatus.PUBLISHED, PostStatus.VERIFICATION, PostStatus.REJECTED]"
:key="status.value"
:value="status.value">
{{ status.label }}
</option>
</SelectFilter>
</DataFilters>
<!-- Posts Table -->
<div class="bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden">
<!-- Bulk Actions Bar -->
<transition
enter-active-class="transition duration-200"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-150"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div v-if="selectedPosts.length > 0" class="px-6 py-3 bg-primary/5 border-b border-primary/20 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-primary">{{ selectedPosts.length }} выбрано</span>
<button
@click="selectAllOnPage"
class="text-xs text-muted-foreground-1 hover:text-primary transition-colors"
>
Выбрать все на странице
</button>
</div>
<div class="flex items-center gap-2">
<button
@click="bulkPublish"
:disabled="bulkProcessing"
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-emerald-600 text-white text-xs font-medium rounded-lg hover:bg-emerald-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<DashboardIcon name="check-circle" size="4" />
Опубликовать
</button>
<button
@click="bulkSetVerification"
:disabled="bulkProcessing"
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-amber-600 text-white text-xs font-medium rounded-lg hover:bg-amber-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<DashboardIcon name="clock" size="4" />
На модерацию
</button>
<button
@click="bulkDelete"
:disabled="bulkProcessing"
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-rose-600 text-white text-xs font-medium rounded-lg hover:bg-rose-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<DashboardIcon name="trash" size="4" />
Удалить
</button>
<button
@click="clearSelection"
class="p-1.5 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded-lg transition-all"
title="Снять выделение"
>
<DashboardIcon name="x-mark" size="4" />
</button>
</div>
</div>
</transition>
<!-- Table Header Stats -->
<div class="px-6 py-4 border-b border-line-2 bg-surface/50">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<span class="text-sm text-foreground">
Всего: <span class="font-medium">{{ posts.total }}</span>
</span>
<span class="text-xs text-muted-foreground-1 px-2 py-0.5 bg-primary/10 text-primary rounded-full">
{{ posts.data.length }} на странице
</span>
</div>
</div>
</div>
<!-- Table -->
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-line-2">
<thead class="bg-surface/50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider w-10">
<input
type="checkbox"
:checked="isAllSelected"
:indeterminate.prop="isIndeterminate"
@change="toggleSelectAll"
class="w-4 h-4 text-primary border-layer-line rounded focus:ring-primary/20 cursor-pointer"
/>
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
Материал
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
Категория
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
Статус
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
Дата публикации
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
Автор
</th>
<th class="px-6 py-3 text-right text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
Действия
</th>
</tr>
</thead>
<tbody class="divide-y divide-line-2">
<tr
v-for="post in posts.data"
:key="post.id"
class="group hover:bg-muted-hover/50 transition-all duration-200"
:class="{ 'bg-primary/5': selectedPosts.includes(post.id) }"
>
<td class="px-6 py-4">
<input
type="checkbox"
:value="post.id"
v-model="selectedPosts"
class="w-4 h-4 text-primary border-layer-line rounded focus:ring-primary/20 cursor-pointer"
/>
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div v-if="post.preview" class="w-14 h-14 flex-shrink-0 overflow-hidden rounded-lg border border-layer-line">
<img
:src="`/storage/${post.preview}`"
:alt="post.title"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
</div>
<div v-else class="w-14 h-14 flex-shrink-0 rounded-lg border border-layer-line bg-surface flex items-center justify-center">
<DashboardIcon name="photo" size="6" class="text-muted-foreground-2" />
</div>
<div class="min-w-0 flex-1">
<div class="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors" :title="post.title">
{{ TEXT_LIMIT(post.title, 60) }}
</div>
<div class="text-xs text-muted-foreground-2 font-mono mt-0.5">
{{ post.slug }}
</div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
v-if="post.category"
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-primary/10 text-primary border border-primary/20"
>
{{ post.category.title }}
</span>
<span v-else class="text-xs text-muted-foreground-2"></span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span :class="getStatusBadgeClass(post.status)">
<span class="w-1.5 h-1.5 rounded-full mr-1.5" :class="getStatusDotClass(post.status)"></span>
{{ getStatusLabel(post.status) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-foreground">{{ formatDate(post.publish_at) }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center">
<span class="text-xs font-medium text-primary">
{{ getAuthorInitials(post.author?.name) }}
</span>
</div>
<span class="text-sm text-foreground">
{{ post.author?.name || '—' }}
</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<a
:href="route('dashboard.posts.edit', post.id)"
class="p-2 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded-lg transition-all"
title="Редактировать"
>
<DashboardIcon name="pencil-square" size="4" />
</a>
<button
@click.prevent="confirmDelete(post)"
class="p-2 text-muted-foreground-1 hover:text-rose-600 hover:bg-rose-500/10 rounded-lg transition-all"
title="Удалить"
>
<DashboardIcon name="trash" size="4" />
</button>
<button
@click.prevent="openPostModal(post)"
class="p-2 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded-lg transition-all"
title="Просмотр"
>
<DashboardIcon name="eye" size="4" />
</button>
</div>
</td>
</tr>
<!-- Empty State -->
<EmptyState
v-if="posts.data.length === 0"
:columns="6"
title="Новости не найдены"
description="Создайте первую новость или измените параметры поиска"
:action-url="route('dashboard.posts.create')"
action-text="Создать новость"
/>
</tbody>
</table>
</div>
<!-- Pagination -->
<Pagination :data="posts" />
</div>
<!-- Post Modal -->
<transition
enter-active-class="transition duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-150"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="selectedPost"
class="fixed inset-0 bg-slate-900/70 backdrop-blur-md z-50 flex items-center justify-center p-4"
@click.self="closePostModal"
>
<div class="bg-white border border-layer-line rounded-xl shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<!-- Header -->
<div class="px-4 py-3 border-b border-card-line flex items-center justify-between">
<div class="flex items-center gap-3 flex-1 min-w-0">
<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>
<h3 class="text-sm font-medium text-foreground truncate">{{ selectedPost.title }}</h3>
</div>
<button
@click="closePostModal"
class="ml-3 text-muted-foreground-2 hover:text-foreground hover:bg-layer rounded-lg p-1.5 transition-all"
>
<DashboardIcon name="x-mark" size="5" />
</button>
</div>
<!-- Content -->
<div class="p-4 overflow-y-auto flex-1">
<!-- Meta -->
<div class="flex flex-wrap gap-2 mb-4">
<span class="inline-flex items-center px-2.5 py-1.5 bg-layer text-muted-foreground-1 rounded-md text-xs font-medium border border-layer-line">
ID: {{ selectedPost.id }}
</span>
<span v-if="selectedPost.category" class="inline-flex items-center px-2.5 py-1.5 bg-primary-500/10 text-primary-700 rounded-md text-xs font-medium border border-primary-200">
<DashboardIcon name="tag" size="4" class="mr-1.5" />
{{ selectedPost.category.title }}
</span>
<span
v-if="selectedPost.status"
class="inline-flex items-center px-2.5 py-1.5 rounded-md text-xs font-medium uppercase border"
:class="{
'bg-amber-50 text-amber-700 border-amber-200': selectedPost.status === 'verification',
'bg-rose-50 text-rose-700 border-rose-200': selectedPost.status === 'rejected',
'bg-emerald-50 text-emerald-700 border-emerald-200': selectedPost.status === 'published'
}"
>
<span class="w-1.5 h-1.5 rounded-full mr-1.5" :class="{
'bg-amber-500': selectedPost.status === 'verification',
'bg-rose-500': selectedPost.status === 'rejected',
'bg-emerald-500': selectedPost.status === 'published'
}"></span>
{{ selectedPost.status === 'verification' ? 'На рассмотрении' : selectedPost.status === 'rejected' ? 'Отклонено' : selectedPost.status }}
</span>
</div>
<!-- Preview -->
<div v-if="selectedPost.preview" class="mb-4">
<p class="text-xs font-medium text-muted-foreground-1 mb-2">Главное изображение</p>
<div class="rounded-lg overflow-hidden border border-layer-line">
<img
:src="`/storage/${selectedPost.preview}`"
alt="Preview"
class="w-full h-auto max-h-80 object-cover"
/>
</div>
</div>
<!-- Gallery -->
<div v-if="selectedPost.images && selectedPost.images.length > 0" class="mb-4">
<p class="text-xs font-medium text-muted-foreground-1 mb-2">Галерея ({{ selectedPost.images.length }})</p>
<div class="grid grid-cols-3 sm:grid-cols-4 gap-2">
<div
v-for="(image, index) in selectedPost.images"
:key="index"
@click="viewImage(image)"
class="group relative rounded-lg overflow-hidden border border-layer-line aspect-square cursor-pointer hover:border-primary transition-all"
>
<img
:src="`/storage/${image}`"
alt="Gallery"
class="w-full h-full object-cover group-hover:scale-105 transition-transform"
/>
</div>
</div>
</div>
<!-- Authors -->
<div v-if="selectedPost.authors" class="mb-4 p-3 bg-surface rounded-lg border border-layer-line">
<p class="text-xs font-medium text-muted-foreground-1 mb-1">Автор(ы)</p>
<p class="text-sm text-foreground">
{{ Array.isArray(selectedPost.authors) ? selectedPost.authors.join(', ') : selectedPost.authors }}
</p>
</div>
<!-- Preview Text -->
<div v-if="selectedPost.preview_text" class="mb-4">
<p class="text-xs font-medium text-muted-foreground-1 mb-2">Превью</p>
<p class="text-sm text-foreground leading-relaxed">{{ selectedPost.preview_text }}</p>
</div>
<!-- Content -->
<div class="mb-4">
<p class="text-xs font-medium text-muted-foreground-1 mb-2">Полный текст</p>
<div class="prose prose-sm max-w-none bg-surface p-4 rounded-lg border border-layer-line">
<div v-html="renderContent(selectedPost.content)" class="text-foreground"></div>
</div>
</div>
<!-- Footer -->
<div class="border-t border-line-2 pt-3">
<div class="flex flex-wrap justify-between gap-3 text-xs text-muted-foreground-2">
<div class="flex flex-wrap gap-3">
<span class="flex items-center gap-1">
<DashboardIcon name="clock" size="4" />
{{ new Date(selectedPost.created_at).toLocaleString('ru-RU') }}
</span>
<span class="flex items-center gap-1">
<DashboardIcon name="arrow-path" size="4" />
{{ new Date(selectedPost.updated_at).toLocaleString('ru-RU') }}
</span>
</div>
<span v-if="selectedPost.reading_time" class="flex items-center gap-1">
<DashboardIcon name="book-open" size="4" />
{{ selectedPost.reading_time }} мин
</span>
</div>
</div>
</div>
<!-- Actions -->
<div class="px-4 py-3 border-t border-line-2 bg-surface flex flex-col sm:flex-row justify-between items-stretch sm:items-center gap-2">
<div class="flex items-center gap-3 flex-wrap">
<!-- Edit -->
<a
:href="`/admin/posts/${selectedPost.id}/edit`"
class="inline-flex items-center px-3 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all"
>
<DashboardIcon name="pencil-square" size="4" class="mr-1.5" />
Редактировать
</a>
</div>
<button
@click="closePostModal"
class="w-full sm:w-auto px-3 py-2 border border-layer-line text-sm font-medium rounded-lg text-layer-foreground hover:bg-layer transition-all"
>
Закрыть
</button>
</div>
</div>
</div>
</transition>
</DashboardLayout>
</template>
<script>
import PostStatus from '@/Enum/PostStatus.js';
import DashboardLayout from '../Components/DashboardLayout.vue';
import DashboardIcon from '../Components/DashboardIcon.vue';
import Breadcrumbs from '../Components/shared/Breadcrumbs.vue';
import FlashMessages from '../Components/shared/FlashMessages.vue';
import DataFilters from '../Components/shared/DataFilters.vue';
import SearchInput from '../Components/shared/SearchInput.vue';
import SelectFilter from '../Components/shared/SelectFilter.vue';
import EmptyState from '../Components/shared/EmptyState.vue';
import Pagination from '../Components/shared/Pagination.vue';
export default {
name: 'PostIndex',
components: {
DashboardLayout,
DashboardIcon,
Breadcrumbs,
FlashMessages,
DataFilters,
SearchInput,
SelectFilter,
EmptyState,
Pagination
},
props: {
posts: {
type: Object,
required: true
},
filters: {
type: Object,
default: () => ({
status: '',
search: ''
})
}
},
data() {
return {
searchQuery: this.filters?.search || '',
statusQuery: this.filters?.status || '',
deletingPost: null,
selectedPost: null,
selectedPosts: [],
bulkProcessing: false,
PostStatus,
}
},
mounted() {
this.SET_DOCUMENT_TITLE('Все новости');
},
computed: {
isAllSelected() {
return this.posts.data.length > 0 && this.selectedPosts.length === this.posts.data.length;
},
isIndeterminate() {
return this.selectedPosts.length > 0 && this.selectedPosts.length < this.posts.data.length;
},
},
methods: {
getStatusBadgeClass(status) {
const statusValue = status?.value ?? status;
const statusObj = PostStatus.fromValue(statusValue);
const colorClasses = {
gray: 'bg-stone-500/10 text-stone-700 border-stone-500/20',
success: 'bg-emerald-500/10 text-emerald-700 border-emerald-500/20',
warning: 'bg-amber-500/10 text-amber-700 border-amber-500/20',
danger: 'bg-rose-500/10 text-rose-700 border-rose-500/20'
};
const color = statusObj ? statusObj.color : 'gray';
return `inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium border ${colorClasses[color]}`;
},
getStatusDotClass(status) {
const statusValue = status?.value ?? status;
const statusObj = PostStatus.fromValue(statusValue);
const colorClasses = {
gray: 'bg-stone-500',
success: 'bg-emerald-500',
warning: 'bg-amber-500',
danger: 'bg-rose-500'
};
const color = statusObj ? statusObj.color : 'gray';
return colorClasses[color];
},
getStatusLabel(status) {
const statusValue = status?.value ?? status;
return PostStatus.getLabel(statusValue) || status;
},
formatDate(date) {
return this.FORMAT_DATE(date, 'full');
},
getAuthorInitials(name) {
return this.GET_INITIALS(name) || '?';
},
search() {
this.INERTIA_FILTER('dashboard.posts.index', {
search: this.searchQuery,
status: this.statusQuery
});
},
filterByStatus() {
this.search();
},
resetFilters() {
this.RESET_FILTERS(
['searchQuery', 'statusQuery'],
'dashboard.posts.index'
);
},
confirmDelete(post) {
this.CONFIRM_AND_DELETE(post, 'dashboard.posts.destroy', {
message: `Вы уверены, что хотите удалить новость "${post.title}"?`
});
},
openPostModal(post) {
this.selectedPost = post;
},
closePostModal() {
this.selectedPost = null;
},
renderContent(content) {
if (!content) return '';
if (Array.isArray(content)) {
return content.map(block => {
if (typeof block === 'string') return block;
if (block.data?.content) return block.data.content;
return '';
}).join('');
}
return content;
},
viewImage(imagePath) {
window.open(this.GET_BASE_STORAGE_URL() + imagePath);
},
toggleSelectAll(event) {
if (event.target.checked) {
this.selectedPosts = [...new Set([...this.selectedPosts, ...this.posts.data.map(p => p.id)])];
} else {
const pageIds = new Set(this.posts.data.map(p => p.id));
this.selectedPosts = this.selectedPosts.filter(id => !pageIds.has(id));
}
},
selectAllOnPage() {
this.selectedPosts = [...new Set([...this.selectedPosts, ...this.posts.data.map(p => p.id)])];
},
clearSelection() {
this.selectedPosts = [];
},
bulkDelete() {
if (!confirm(`Удалить ${this.selectedPosts.length} новостей?`)) return;
this.bulkProcessing = true;
this.$inertia.delete(route('dashboard.posts.bulk-destroy'), {
data: { ids: this.selectedPosts },
preserveScroll: true,
onSuccess: () => {
this.selectedPosts = [];
this.bulkProcessing = false;
},
onError: () => {
this.bulkProcessing = false;
},
});
},
bulkPublish() {
if (!confirm(`Опубликовать ${this.selectedPosts.length} новостей?`)) return;
this.bulkProcessing = true;
this.$inertia.post(route('dashboard.posts.bulk-publish'), {
ids: this.selectedPosts,
}, {
preserveScroll: true,
onSuccess: () => {
this.selectedPosts = [];
this.bulkProcessing = false;
},
onError: () => {
this.bulkProcessing = false;
},
});
},
bulkSetVerification() {
if (!confirm(`Перевести ${this.selectedPosts.length} новостей на модерацию?`)) return;
this.bulkProcessing = true;
this.$inertia.post(route('dashboard.posts.bulk-verification'), {
ids: this.selectedPosts,
}, {
preserveScroll: true,
onSuccess: () => {
this.selectedPosts = [];
this.bulkProcessing = false;
},
onError: () => {
this.bulkProcessing = false;
},
});
},
}
}
</script>
+434
View File
@@ -0,0 +1,434 @@
<template>
<div class="min-h-screen bg-background-2">
<!-- Sticky Header -->
<div class="border-b border-line-2 bg-layer/50 backdrop-blur-sm sticky top-0 z-10">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<div class="flex items-center gap-3">
<a
:href="route('dashboard.posts.index')"
class="inline-flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded-lg 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="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Назад
</a>
<div class="w-px h-6 bg-line-2"></div>
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</div>
<div>
<h1 class="text-lg font-medium text-foreground truncate max-w-md">{{ post.title }}</h1>
<p class="text-xs text-muted-foreground-1">Просмотр новости</p>
</div>
</div>
<div class="flex items-center gap-2">
<a
:href="route('dashboard.posts.edit', post.id)"
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 shadow-sm"
>
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span class="hidden sm:inline">Редактировать</span>
</a>
<button
@click.prevent="confirmDelete"
class="inline-flex items-center gap-2 px-4 py-2 bg-surface border border-layer-line text-foreground text-sm font-medium rounded-lg hover:bg-rose-500/10 hover:border-rose-500/30 hover:text-rose-600 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="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>
<!-- Main Content -->
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<!-- Status & Meta -->
<div class="flex flex-wrap items-center gap-3 mb-6">
<span :class="getStatusBadgeClass(post.status)">
<span class="w-2 h-2 rounded-full mr-2" :class="getStatusDotClass(post.status)"></span>
{{ getStatusLabel(post.status) }}
</span>
<span
v-if="post.category"
class="inline-flex items-center px-3 py-1 rounded-lg text-xs font-medium bg-primary/10 text-primary border border-primary/20"
>
{{ post.category.title }}
</span>
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg text-xs font-medium bg-surface text-muted-foreground-1 border border-layer-line">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ formatDate(post.publish_at) }}
</span>
<span
v-if="post.reading_time"
class="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg text-xs font-medium bg-surface text-muted-foreground-1 border border-layer-line"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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>
{{ post.reading_time }} мин. чтения
</span>
</div>
<!-- Preview Image -->
<div v-if="post.preview" class="mb-6">
<img :src="`/storage/${post.preview}`" :alt="post.title" class="w-full h-96 object-cover rounded-xl border border-layer-line shadow-lg" />
</div>
<!-- Info Card -->
<div class="bg-layer border border-layer-line rounded-xl shadow-xs mb-6 overflow-hidden">
<div class="px-5 py-4 bg-surface/50 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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h2 class="text-sm font-medium text-foreground">Информация</h2>
</div>
</div>
<div class="p-5">
<div class="grid grid-cols-2 md:grid-cols-4 gap-5">
<div>
<div class="flex items-center gap-2 mb-1.5">
<svg class="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="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<span class="text-xs font-medium text-muted-foreground-1">URL</span>
</div>
<p class="text-sm text-foreground font-mono bg-surface px-2.5 py-1.5 rounded-md border border-layer-line truncate">
{{ post.slug }}
</p>
</div>
<div>
<div class="flex items-center gap-2 mb-1.5">
<svg class="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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span class="text-xs font-medium text-muted-foreground-1">Автор</span>
</div>
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center">
<span class="text-xs font-medium text-primary">
{{ getAuthorInitials(post.author?.name) }}
</span>
</div>
<p class="text-sm text-foreground">
{{ post.author?.name || '—' }}
</p>
</div>
</div>
<div>
<div class="flex items-center gap-2 mb-1.5">
<svg class="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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-xs font-medium text-muted-foreground-1">Создано</span>
</div>
<p class="text-sm text-foreground">
{{ formatDate(post.created_at) }}
</p>
</div>
<div>
<div class="flex items-center gap-2 mb-1.5">
<svg class="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="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>
<span class="text-xs font-medium text-muted-foreground-1">Обновлено</span>
</div>
<p class="text-sm text-foreground">
{{ formatDate(post.updated_at) }}
</p>
</div>
</div>
<!-- Authors List -->
<div v-if="post.authors && post.authors.length > 0" class="mt-5 pt-5 border-t border-line-2">
<div class="flex items-center gap-2 mb-3">
<svg class="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="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<span class="text-xs font-medium text-muted-foreground-1">Авторы</span>
</div>
<div class="flex flex-wrap gap-2">
<span
v-for="(author, index) in post.authors"
:key="index"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-surface text-foreground border border-layer-line"
>
<div class="w-4 h-4 rounded-full bg-primary/10 flex items-center justify-center">
<span class="text-[10px] font-medium text-primary">
{{ author[0]?.toUpperCase() }}
</span>
</div>
{{ author }}
</span>
</div>
</div>
<!-- Tags -->
<div v-if="post.tags && post.tags.length > 0" class="mt-5 pt-5 border-t border-line-2">
<div class="flex items-center gap-2 mb-3">
<svg class="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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<span class="text-xs font-medium text-muted-foreground-1">Теги</span>
</div>
<div class="flex flex-wrap gap-2">
<span
v-for="(tag, index) in post.tags"
:key="index"
class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-medium bg-primary/10 text-primary border border-primary/20"
>
{{ tag.name }}
</span>
</div>
</div>
</div>
</div>
<!-- Content Card -->
<div v-if="post.content && post.content.length > 0" class="bg-layer border border-layer-line rounded-xl shadow-xs mb-6 overflow-hidden">
<div class="px-5 py-4 bg-surface/50 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="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>
<h2 class="text-sm font-medium text-foreground">Содержимое</h2>
</div>
</div>
<div class="p-5 space-y-4">
<div
v-for="(block, index) in post.content"
:key="index"
class="prose prose-sm max-w-none"
>
<!-- Heading -->
<div v-if="block.type === 'heading'" :class="getHeadingClass(block.data.level)">
{{ block.data.content }}
</div>
<!-- Paragraph -->
<p v-if="block.type === 'paragraph'" class="text-foreground leading-relaxed">
{{ block.data.content }}
</p>
<!-- Image -->
<div v-if="block.type === 'image'" class="my-4">
<img :src="`/storage/${block.data.url}`" :alt="block.data.caption" class="w-full h-auto rounded-lg border border-layer-line" />
<p v-if="block.data.caption" class="text-xs text-muted-foreground-1 text-center mt-2">
{{ block.data.caption }}
</p>
</div>
<!-- Files -->
<div v-if="block.type === 'files'" class="space-y-2">
<div
v-for="(file, fileIndex) in block.data.file"
:key="fileIndex"
class="flex items-center gap-3 p-3 bg-surface border border-layer-line rounded-lg hover:border-primary/50 transition-all"
>
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-primary" 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>
</div>
<span class="text-sm text-foreground flex-1">{{ file.title }}</span>
<span class="text-xs text-muted-foreground-1 font-mono">{{ file.size }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Gallery Card -->
<div v-if="post.images && post.images.length > 0" class="bg-layer border border-layer-line rounded-xl shadow-xs mb-6 overflow-hidden">
<div class="px-5 py-4 bg-surface/50 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="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>
<h2 class="text-sm font-medium text-foreground">Галерея</h2>
<span class="ml-auto text-xs text-muted-foreground-1">{{ post.images.length }} фото</span>
</div>
</div>
<div class="p-5">
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
<div
v-for="(img, index) in post.images"
:key="index"
class="group relative aspect-square overflow-hidden rounded-lg border border-layer-line bg-surface"
>
<img
:src="`/storage/${img}`"
:alt="`Gallery ${index + 1}`"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors"></div>
</div>
</div>
</div>
</div>
<!-- Slider Card -->
<div v-if="post.slide" class="bg-layer border border-layer-line rounded-xl shadow-xs mb-6 overflow-hidden">
<div class="px-5 py-4 bg-surface/50 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="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
</svg>
<h2 class="text-sm font-medium text-foreground">Настройки слайда</h2>
</div>
</div>
<div class="p-5">
<div class="grid grid-cols-2 md:grid-cols-4 gap-5">
<div>
<span class="text-xs font-medium text-muted-foreground-1">Слайдер</span>
<p class="text-sm text-foreground font-medium mt-1">{{ post.slide.title }}</p>
</div>
<div>
<span class="text-xs font-medium text-muted-foreground-1">Статус</span>
<p class="text-sm text-foreground font-medium mt-1">
<span :class="post.slide.is_active ? 'text-emerald-600' : 'text-muted-foreground-2'">
{{ post.slide.is_active ? 'Активен' : 'Не активен' }}
</span>
</p>
</div>
<div>
<span class="text-xs font-medium text-muted-foreground-1">Позиция текста</span>
<p class="text-sm text-foreground font-medium mt-1 capitalize">{{ post.slide.settings?.text_position || 'left' }}</p>
</div>
<div>
<span class="text-xs font-medium text-muted-foreground-1">Цвет текста</span>
<div class="flex items-center gap-2 mt-1">
<div class="w-5 h-5 rounded border border-layer-line" :style="{ backgroundColor: post.slide.color_theme }"></div>
<span class="text-sm text-foreground font-mono">{{ post.slide.color_theme }}</span>
</div>
</div>
</div>
<div v-if="post.slide.image?.url" class="mt-5 pt-5 border-t border-line-2">
<span class="text-xs font-medium text-muted-foreground-1 mb-2 block">Изображение слайда</span>
<img :src="`/storage/${post.slide.image.url}`" alt="Slide" class="w-full h-48 object-cover rounded-lg border border-layer-line" />
</div>
<div v-if="post.slide.end_time" class="mt-5 pt-5 border-t border-line-2">
<div class="flex items-center gap-2">
<svg class="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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-xs font-medium text-muted-foreground-1">Окончание показа</span>
</div>
<p class="text-sm text-foreground mt-1.5">{{ formatDate(post.slide.end_time) }}</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import PostStatus from '@/Enum/PostStatus.js';
export default {
name: 'PostShow',
props: {
post: {
type: Object,
required: true
}
},
mounted() {
this.SET_DOCUMENT_TITLE(`Просмотр новости - ${this.post.title}`);
},
methods: {
getStatusBadgeClass(status) {
const statusValue = status?.value ?? status;
const statusObj = PostStatus.fromValue(statusValue);
const colorClasses = {
gray: 'bg-stone-500/10 text-stone-700 border-stone-500/20',
success: 'bg-emerald-500/10 text-emerald-700 border-emerald-500/20',
warning: 'bg-amber-500/10 text-amber-700 border-amber-500/20',
danger: 'bg-rose-500/10 text-rose-700 border-rose-500/20'
};
const color = statusObj ? statusObj.color : 'gray';
return `inline-flex items-center px-3 py-1 rounded-lg text-xs font-medium border ${colorClasses[color]}`;
},
getStatusDotClass(status) {
const statusValue = status?.value ?? status;
const statusObj = PostStatus.fromValue(statusValue);
const colorClasses = {
gray: 'bg-stone-500',
success: 'bg-emerald-500',
warning: 'bg-amber-500',
danger: 'bg-rose-500'
};
const color = statusObj ? statusObj.color : 'gray';
return colorClasses[color];
},
getStatusLabel(status) {
const statusValue = status?.value ?? status;
return PostStatus.getLabel(statusValue) || status;
},
formatDate(date) {
if (!date) return '—';
return new Date(date).toLocaleDateString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
},
getHeadingClass(level) {
const classes = {
h1: 'text-2xl font-semibold text-foreground mb-4',
h2: 'text-xl font-semibold text-foreground mb-3',
h3: 'text-lg font-semibold text-foreground mb-2.5',
h4: 'text-base font-semibold text-foreground mb-2'
};
return classes[level] || classes.h2;
},
getAuthorInitials(name) {
if (!name) return '?';
const parts = name.split(' ');
if (parts.length >= 2) {
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
}
return name[0].toUpperCase();
},
confirmDelete() {
if (confirm(`Вы уверены, что хотите удалить новость "${this.post.title}"?`)) {
this.$inertia.delete(route('dashboard.posts.destroy', this.post.id), {
preserveScroll: true
});
}
},
route(name, params) {
return route(name, params);
}
}
}
</script>