Files
ntspi-app/resources/js/Pages/Dashboard/Posts/AiPrepared.vue
T
2026-04-07 17:54:26 +05:00

872 lines
36 KiB
Vue

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