changes
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user