Files
ntspi-app/resources/js/Pages/Dashboard/Main.vue
T
2026-03-23 10:50:31 +05:00

1242 lines
66 KiB
Vue
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="min-h-screen bg-amber-50/30 py-6 sm:py-8">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Заголовок страницы -->
<div class="mb-6 sm:mb-8">
<h1 class="text-2xl sm:text-3xl font-semibold text-stone-800">Панель управления</h1>
<p class="mt-1 text-sm text-stone-500">Загрузка и модерация новостей, созданных с помощью AI</p>
</div>
<!-- Уведомления (Flash messages) -->
<transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 -translate-y-4"
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-4"
>
<div v-if="$page.props.flash?.success" class="mb-4 p-4 bg-emerald-50/80 border border-emerald-200 rounded-lg">
<div class="flex items-start">
<svg class="w-5 h-5 text-emerald-600 mr-3 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm text-emerald-800 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-4"
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-4"
>
<div v-if="$page.props.flash?.error" class="mb-4 p-4 bg-rose-50/80 border border-rose-200 rounded-lg">
<div class="flex items-start">
<svg class="w-5 h-5 text-rose-600 mr-3 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm text-rose-800 font-medium">{{ $page.props.flash.error }}</span>
</div>
</div>
</transition>
<!-- Информация о созданном посте -->
<transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-200 ease-in"
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-white rounded-lg shadow-sm border border-amber-200 p-5">
<div class="flex items-start gap-4">
<div class="flex-shrink-0">
<div class="w-10 h-10 bg-rose-400/80 rounded-md flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
</svg>
</div>
</div>
<div class="flex-1 min-w-0">
<h4 class="text-lg font-medium text-stone-800 mb-3">Новость успешно создана!</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div class="space-y-2">
<p class="text-stone-600"><span class="font-medium">Заголовок:</span> {{ $page.props.flash.created_post.title }}</p>
<p class="text-stone-600"><span class="font-medium">Статус:</span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-amber-100 text-amber-800">
{{ $page.props.flash.created_post.status }}
</span>
</p>
<p class="text-stone-600"><span class="font-medium">Категория:</span> {{ $page.props.flash.created_post.category?.title || 'Не указана' }}</p>
</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-md shadow-sm border border-amber-200 object-cover"
/>
</div>
</div>
<div class="mt-4 flex flex-wrap gap-3">
<a
:href="`/admin/posts/${$page.props.flash.created_post.id}/edit`"
target="_blank" rel="external"
class="inline-flex items-center px-4 py-2 bg-rose-400/90 text-white text-sm font-medium rounded-md hover:bg-rose-500/90 transition"
>
<svg class="w-4 h-4 mr-2" 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>
Редактировать
</a>
<button
@click.prevent="openPostModal($page.props.flash.created_post)"
class="inline-flex items-center px-4 py-2 bg-white text-rose-500 border border-rose-200 text-sm font-medium rounded-md hover:bg-rose-50 transition"
>
<svg class="w-4 h-4 mr-2" 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>
Просмотр
</button>
</div>
</div>
</div>
</div>
</transition>
<!-- Карточка загрузки файлов -->
<div class="bg-white rounded-lg shadow-sm mb-6 overflow-hidden border border-amber-200">
<div class="bg-gradient-to-r from-amber-50 to-rose-50 px-6 py-5 border-b border-amber-200">
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h2 class="text-lg font-medium text-stone-800 mb-1">Загрузка файлов</h2>
<p class="text-sm text-stone-500 flex items-center flex-wrap gap-2">
<svg class="w-4 h-4 inline flex-shrink-0 text-amber-500" 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 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
<span class="whitespace-nowrap text-stone-600">DOCX, PDF, XLSX, JPG, PNG, WEBP, ZIP</span>
<span class="hidden sm:inline text-stone-400">(до 40MB)</span>
</p>
</div>
<button
@click="openUploadModal"
class="w-full sm:w-auto inline-flex items-center justify-center px-5 py-2.5 bg-rose-400 text-white font-medium rounded-md hover:bg-rose-500 transition text-sm shadow-sm"
>
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
Загрузить
</button>
</div>
</div>
<div class="px-6 py-3 bg-amber-50/50 border-t border-amber-100">
<p class="text-sm text-stone-500">
<span class="font-medium text-rose-500">AI-функции:</span>
<span class="hidden sm:inline">Автоматическое определение текста новости </span>Распаковка ZIP-архивов (до 40MB) Извлечение изображений
</p>
</div>
</div>
<!-- Модальное окно загрузки файлов -->
<transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isUploadModalOpen"
class="fixed inset-0 bg-stone-900/40 backdrop-blur-[2px] z-50 flex items-center justify-center p-4"
@click.self="closeUploadModal"
>
<div class="bg-white rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div class="bg-gradient-to-r from-amber-50 to-rose-50 px-6 py-4 flex items-center justify-between border-b border-amber-200">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-rose-100 rounded-md flex items-center justify-center">
<svg class="w-5 h-5 text-rose-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</div>
<h3 class="text-lg font-medium text-stone-700">Загрузка файлов</h3>
</div>
<button
@click="closeUploadModal"
class="text-stone-400 hover:text-stone-600 transition bg-white hover:bg-amber-50 rounded-md p-2 border border-transparent hover:border-amber-200"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="overflow-y-auto flex-1">
<form @submit.prevent="submitForm" class="p-6 space-y-5">
<!-- Зона Drag & Drop -->
<div
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
@click="triggerFileInput"
:class="[
'relative flex justify-center px-6 pt-8 pb-10 border-2 border-dashed rounded-lg cursor-pointer transition-all',
isDragging
? 'border-rose-400 bg-rose-50'
: 'border-amber-200 hover:border-rose-300 hover:bg-amber-50/50'
]"
>
<div class="space-y-4 text-center">
<div class="flex justify-center">
<div :class="['w-16 h-16 rounded-lg flex items-center justify-center transition-all', isDragging ? 'bg-rose-200' : 'bg-amber-100']">
<svg class="w-8 h-8 text-rose-500" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
</div>
<div>
<p class="text-base font-medium text-stone-700">
<span class="text-rose-500">Нажмите для выбора</span> или перетащите файлы
</p>
<p class="text-sm text-stone-500 mt-1">
DOCX, PDF, XLSX, JPG, PNG, WEBP, ZIP
</p>
</div>
<div class="flex flex-wrap justify-center gap-2 text-xs">
<span class="inline-flex items-center px-3 py-1 bg-amber-100 text-amber-700 rounded-full font-medium">
<svg class="w-3.5 h-3.5 mr-1.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
Авто-распаковка ZIP
</span>
<span class="inline-flex items-center px-3 py-1 bg-emerald-50 text-emerald-700 rounded-full font-medium">
<svg class="w-3.5 h-3.5 mr-1.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
Извлечение файлов
</span>
<span class="inline-flex items-center px-3 py-1 bg-stone-100 text-stone-600 rounded-full font-medium">
до 40MB
</span>
</div>
</div>
</div>
<!-- Скрытый системный инпут -->
<input
type="file"
id="filesInput"
multiple
ref="filesInput"
class="hidden"
@change="handleFileInput"
accept=".doc,.docx,.pdf,.xls,.xlsx,.jpg,.jpeg,.png,.webp,.gif,.zip"
/>
<!-- Ошибки валидации -->
<div v-if="hasFileErrors" class="p-4 bg-rose-50/60 border border-rose-200 rounded-lg">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-rose-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="text-sm text-rose-700">
<p v-if="form.errors.files" class="font-medium mb-1">{{ form.errors.files }}</p>
<ul class="list-disc pl-5 space-y-1">
<li v-for="(error, key) in fileSpecificErrors" :key="key">
{{ error }}
</li>
</ul>
</div>
</div>
</div>
<!-- Индикатор распаковки ZIP -->
<div v-if="isUnzipping" class="p-4 bg-amber-50 border border-amber-200 rounded-lg">
<div class="flex items-center gap-3 mb-3">
<svg class="animate-spin h-5 w-5 text-rose-500" 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-sm font-medium text-stone-700">Распаковка архива...</span>
</div>
<div class="w-full bg-amber-100 rounded-full h-2.5 overflow-hidden">
<div
class="h-full bg-gradient-to-r from-rose-400 to-amber-400 rounded-full transition-all duration-300 ease-out"
:style="{ width: unzippingProgress + '%' }"
></div>
</div>
<p class="text-xs text-stone-500 mt-2 text-center">{{ unzippingProgress }}% завершено</p>
</div>
<!-- Список выбранных файлов -->
<div v-if="form.files.length > 0" class="space-y-6">
<h4 class="text-sm font-medium text-stone-700 flex items-center gap-2">
<svg class="w-4 h-4 text-rose-400" 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>
Выбрано файлов: {{ form.files.length }}
</h4>
<!-- Секция изображений (с drag-and-drop) -->
<div v-if="imageFiles.length > 0" class="space-y-3">
<div class="flex items-center gap-2">
<div class="w-8 h-8 bg-rose-100 rounded-md flex items-center justify-center">
<svg class="w-4 h-4 text-rose-500" 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>
</div>
<h5 class="text-sm font-medium text-stone-700">Изображения <span class="text-xs font-normal text-stone-500">(перетаскивайте для изменения порядка)</span></h5>
<span class="text-xs font-medium text-rose-600 bg-rose-50 px-2 py-1 rounded-full">{{ imageFiles.length }}</span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
<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-white border border-amber-200 rounded-md shadow-sm hover:shadow-md hover:border-rose-300 transition-all overflow-hidden cursor-move"
>
<!-- Миниатюра изображения -->
<div class="aspect-square overflow-hidden">
<img
:src="imagePreviews[item.originalIndex]"
alt="Preview"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
</div>
<!-- Информация при наведении -->
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all duration-200 flex items-center justify-center opacity-0 group-hover:opacity-100">
<div class="text-center p-2">
<p class="text-white text-xs font-medium truncate max-w-[120px]">{{ item.file.name }}</p>
<p class="text-amber-100 text-[10px] mt-0.5">{{ (item.file.size / 1024 / 1024).toFixed(2) }} MB</p>
</div>
</div>
<!-- Кнопка удаления -->
<button
type="button"
@click.prevent="removeFile(item.originalIndex)"
class="absolute top-2 right-2 p-1.5 bg-rose-500 text-white rounded-md opacity-0 group-hover:opacity-100 hover:bg-rose-600 transition-all shadow-sm z-10"
>
<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>
<!-- Бейдж порядка -->
<div class="absolute top-2 left-2 w-6 h-6 bg-white bg-opacity-90 rounded-full flex items-center justify-center text-xs font-medium text-rose-500 shadow-sm">
{{ newIndex + 1 }}
</div>
</div>
</div>
</div>
<!-- Секция остальных файлов -->
<div v-if="otherFiles.length > 0" class="space-y-2">
<div class="flex items-center gap-2">
<div class="w-8 h-8 bg-amber-100 rounded-md flex items-center justify-center">
<svg class="w-4 h-4 text-amber-600" 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>
<h5 class="text-sm font-medium text-stone-700">Файлы</h5>
<span class="text-xs font-medium text-amber-700 bg-amber-50 px-2 py-1 rounded-full">{{ otherFiles.length }}</span>
</div>
<div class="space-y-2">
<div
v-for="(item) in otherFiles"
:key="item.originalIndex"
class="group flex items-center gap-3 p-3 bg-white border border-amber-100 rounded-md shadow-sm hover:shadow-md hover:border-amber-300 transition-all"
>
<!-- Иконка файла -->
<div class="flex-shrink-0">
<div class="w-12 h-12 rounded-md bg-amber-50 flex items-center justify-center border border-amber-200">
<svg class="w-6 h-6 text-amber-600" 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>
</div>
<!-- Информация о файле -->
<div class="flex-1 min-w-0">
<p class="text-stone-700 font-medium truncate text-sm">{{ item.file.name }}</p>
<p class="text-xs text-stone-500 mt-0.5">
{{ (item.file.size / 1024 / 1024).toFixed(2) }} MB
</p>
<p class="text-xs text-stone-600 font-medium mt-1">
{{ getFileTypeIcon(item.file.type) }}
</p>
</div>
<!-- Кнопка удаления -->
<button
type="button"
@click.prevent="removeFile(item.originalIndex)"
class="flex-shrink-0 p-2 text-rose-500 hover:text-rose-700 hover:bg-rose-50 rounded-md transition"
>
<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>
<!-- Прогресс-бар загрузки -->
<div v-if="form.progress" class="space-y-2">
<div class="flex justify-between text-sm">
<span class="font-medium text-stone-600">Загрузка...</span>
<span class="text-rose-500 font-medium">{{ form.progress.percentage }}%</span>
</div>
<div class="w-full bg-amber-100 rounded-full h-2.5 overflow-hidden">
<div
class="h-full bg-gradient-to-r from-rose-400 to-amber-400 rounded-full transition-all duration-300 ease-out relative overflow-hidden"
:style="{ width: form.progress.percentage + '%' }"
>
<div class="absolute inset-0 bg-white bg-opacity-20 animate-pulse"></div>
</div>
</div>
</div>
<!-- Кнопка сабмита -->
<button
type="submit"
:disabled="form.processing || form.files.length === 0"
class="w-full py-3 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-gradient-to-r from-rose-400 to-amber-400 hover:from-rose-500 hover:to-amber-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-rose-300 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-5 w-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>
<span>Обработка ({{ form.progress ? form.progress.percentage : 0 }}%)...</span>
</span>
<span v-else class="flex items-center justify-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Отправить файлы на сервер
</span>
</button>
</form>
</div>
</div>
</div>
</transition>
<!-- БЛОК 0: Список подготовленных AI новостей -->
<transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 translate-y-8"
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-8"
>
<div v-if="aiPreparedPosts && aiPreparedPosts.length > 0" class="bg-white rounded-lg shadow-sm overflow-hidden border border-amber-200 mb-6">
<!-- Заголовок секции -->
<div class="bg-gradient-to-r from-amber-50 to-rose-50 px-6 py-4 border-b border-amber-200">
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-rose-100 rounded-md flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-rose-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.384-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
</div>
<div>
<h2 class="text-base font-medium text-stone-700">Подготовленные новости (AI)</h2>
<p class="text-stone-500 text-sm mt-0.5">
Новости, автоматически сгенерированные из загруженных файлов
</p>
</div>
</div>
<div class="hidden lg:flex items-center gap-2 px-3 py-1.5 bg-rose-50 border border-rose-100 rounded-md">
<svg class="w-4 h-4 text-rose-400" 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>
<span class="text-rose-600 text-sm font-medium">Требуют модерации</span>
</div>
</div>
</div>
<!-- Список постов -->
<div class="p-4 bg-amber-50/20">
<div class="grid gap-3">
<div
v-for="(post, index) in aiPreparedPosts"
:key="post.id"
class="group bg-white rounded-md p-4 border border-amber-100 hover:border-rose-200 hover:shadow-sm transition-all cursor-pointer w-full overflow-hidden"
@click="openPostModal(post)"
>
<div class="flex flex-col gap-3 w-full">
<div class="w-full overflow-hidden">
<!-- Заголовок и бейджи -->
<div class="flex flex-col gap-2 mb-3">
<h3 class="text-sm font-medium text-stone-800 group-hover:text-rose-700 transition-colors line-clamp-2 break-words">
{{ post.title }}
</h3>
<!-- Статус поста -->
<div class="flex flex-wrap gap-2">
<span
v-if="post.status"
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium uppercase tracking-wide"
: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-2" :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' ? 'Отклонено' : post.status }}
</span>
</div>
</div>
<!-- Мета информация -->
<div class="flex flex-wrap items-center gap-2 mb-3">
<span class="inline-flex items-center px-2 py-1 bg-stone-100 text-stone-600 rounded-md text-xs font-medium">
ID: {{ post.id }}
</span>
<span v-if="post.category" class="inline-flex items-center px-2 py-1 bg-amber-50 text-amber-700 rounded-md text-xs font-medium border border-amber-100">
<svg class="w-3.5 h-3.5 mr-1.5 flex-shrink-0" 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="truncate max-w-[120px] sm:max-w-none">{{ post.category.title }}</span>
</span>
<span v-if="post.authors" class="inline-flex items-center px-2 py-1 bg-stone-100 text-stone-600 rounded-md text-xs font-medium">
<svg class="w-3.5 h-3.5 mr-1.5 flex-shrink-0" 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="truncate max-w-[150px] sm:max-w-none">{{ Array.isArray(post.authors) ? post.authors.join(', ') : post.authors }}</span>
</span>
</div>
<!-- Превью текста -->
<p v-if="post.preview_text" class="text-sm text-stone-600 line-clamp-2 leading-relaxed break-words">
{{ post.preview_text }}
</p>
<!-- Даты -->
<div class="mt-3 flex flex-wrap items-center gap-3 text-xs text-stone-500 overflow-hidden">
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5 flex-shrink-0 text-stone-400" 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="truncate">{{ new Date(post.created_at).toLocaleDateString('ru-RU') }}</span>
</span>
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5 flex-shrink-0 text-stone-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span class="truncate">{{ new Date(post.updated_at).toLocaleDateString('ru-RU') }}</span>
</span>
<span v-if="post.reading_time" class="flex items-center gap-1">
<svg class="w-3.5 h-3.5 flex-shrink-0 text-stone-400" 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>
<span>{{ post.reading_time }} мин.</span>
</span>
</div>
</div>
<!-- Панель действий -->
<div class="w-full border-t border-amber-100 pt-3 mt-2">
<div class="flex flex-row items-center justify-between gap-2">
<!-- Переключатель VK -->
<label class="flex items-center gap-2 cursor-pointer" @click.stop>
<div class="relative">
<input
v-model="postPublishSettings[post.id]"
type="checkbox"
class="sr-only peer"
>
<div class="w-9 h-5 bg-stone-300 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-rose-300 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-stone-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-rose-400"></div>
</div>
<span class="text-xs font-medium text-stone-600 hover:text-rose-600 transition-colors flex items-center gap-1">
<svg class="w-3.5 h-3.5" :class="postPublishSettings[post.id] ? 'text-rose-500' : 'text-stone-400'" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.477 2 2 6.477 2 12c0 5.523 4.477 10 10 10s10-4.477 10-10c0-5.523-4.477-10-10-10zm0 18c-4.418 0-8-3.582-8-8s3.582-8 8-8 8 3.582 8 8-3.582 8-8 8z"/>
</svg>
<span class="hidden sm:inline">VK</span>
</span>
</label>
<!-- Кнопки -->
<div class="flex flex-row items-center gap-2">
<button
@click.stop="publishPost(post)"
:disabled="publishProcessing"
class="inline-flex items-center justify-center px-3 py-1.5 bg-emerald-500 text-white text-xs font-medium rounded-md hover:bg-emerald-600 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg v-if="!publishProcessing" class="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<svg v-else class="animate-spin h-3.5 w-3.5 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>
<span class="hidden sm:inline">{{ publishProcessing ? '...' : 'ОК' }}</span>
</button>
<span class="text-rose-400 hover:text-rose-600 text-xs font-medium transition-colors flex items-center gap-1">
<svg class="w-3.5 h-3.5 hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</transition>
<!-- Модальное окно просмотра новости -->
<transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="selectedPost"
class="fixed inset-0 bg-stone-900/40 backdrop-blur-[2px] z-50 flex items-center justify-center p-4"
@click.self="closePostModal"
>
<div class="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<!-- Заголовок -->
<div class="bg-gradient-to-r from-amber-50 to-rose-50 px-6 py-4 flex items-center justify-between border-b border-amber-200">
<div class="flex items-center gap-3 flex-1 min-w-0">
<div class="w-9 h-9 bg-rose-100 rounded-md flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-rose-500" 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>
<h3 class="text-base font-medium text-stone-700 truncate">{{ selectedPost.title }}</h3>
</div>
<button
@click="closePostModal"
class="ml-4 text-stone-400 hover:text-stone-600 transition bg-white hover:bg-amber-50 rounded-md p-2 border border-transparent hover:border-amber-200"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Контент -->
<div class="p-6 overflow-y-auto flex-1">
<!-- Мета информация -->
<div class="flex flex-wrap gap-2 mb-5">
<span class="inline-flex items-center px-2.5 py-1 bg-stone-100 text-stone-700 rounded-md text-sm font-medium">
ID: {{ selectedPost.id }}
</span>
<span v-if="selectedPost.category" class="inline-flex items-center px-2.5 py-1 bg-amber-50 text-amber-700 rounded-md text-sm font-medium border border-amber-100">
<svg class="w-4 h-4 mr-1.5" 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>
{{ selectedPost.category.title }}
</span>
<span
v-if="selectedPost.status"
class="inline-flex items-center px-2.5 py-1 rounded-md text-sm font-medium uppercase tracking-wide"
:class="{
'bg-amber-50 text-amber-700 border border-amber-200': selectedPost.status === 'verification',
'bg-rose-50 text-rose-700 border border-rose-200': selectedPost.status === 'rejected',
'bg-emerald-50 text-emerald-700 border border-emerald-200': selectedPost.status === 'published'
}"
>
<span class="w-1.5 h-1.5 rounded-full mr-2" :class="{
'bg-amber-500': selectedPost.status === 'verification',
'bg-rose-500': selectedPost.status === 'rejected',
'bg-emerald-500': selectedPost.status === 'published'
}"></span>
<span>{{ selectedPost.status === 'verification' ? 'На рассмотрении' : selectedPost.status === 'rejected' ? 'Отклонено' : selectedPost.status }}</span>
</span>
</div>
<!-- Главное изображение (Preview) -->
<div v-if="selectedPost.preview" class="mb-5">
<p class="text-sm font-medium text-stone-700 mb-3 flex items-center gap-2">
<svg class="w-4 h-4 text-rose-400" 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>
Главное изображение
</p>
<div class="rounded-md overflow-hidden shadow-sm border border-amber-200">
<img
:src="`/storage/${selectedPost.preview}`"
alt="Preview"
class="w-full h-auto max-h-80 object-cover"
/>
</div>
</div>
<!-- Галерея изображений -->
<div v-if="selectedPost.images && selectedPost.images.length > 0" class="mb-5">
<p class="text-sm font-medium text-stone-700 mb-3 flex items-center gap-2">
<svg class="w-4 h-4 text-rose-400" 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>
Галерея ({{ selectedPost.images.length }})
</p>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
<div
v-for="(image, index) in selectedPost.images"
:key="index"
@click="viewImage(image)"
class="group relative rounded-md overflow-hidden border border-amber-200 shadow-sm aspect-square cursor-pointer"
>
<img
:src="`/storage/${image}`"
alt="Gallery image {{ index + 1 }}"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-all"></div>
</div>
</div>
</div>
<!-- Авторы -->
<div v-if="selectedPost.authors" class="mb-6 p-4 bg-amber-50 rounded-md border border-amber-200">
<p class="text-sm font-medium text-stone-700 mb-2 flex items-center gap-2">
<svg class="w-4 h-4 text-amber-600" 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>
Автор(ы):
</p>
<p class="text-stone-600">
{{ Array.isArray(selectedPost.authors) ? selectedPost.authors.join(', ') : selectedPost.authors }}
</p>
</div>
<!-- Превью -->
<div v-if="selectedPost.preview_text" class="mb-6">
<p class="text-sm font-medium text-stone-700 mb-3 flex items-center gap-2">
<svg class="w-4 h-4 text-rose-400" 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>
Превью:
</p>
<p class="text-stone-600 leading-relaxed">{{ selectedPost.preview_text }}</p>
</div>
<!-- Полный контент -->
<div class="mb-6">
<p class="text-sm font-medium text-stone-700 mb-3 flex items-center gap-2">
<svg class="w-4 h-4 text-rose-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Полный текст:
</p>
<div class="prose prose-sm max-w-none bg-amber-50 p-5 rounded-md border border-amber-200">
<div v-html="renderContent(selectedPost.content)" class="text-stone-700 leading-relaxed"></div>
</div>
</div>
<!-- Дополнительная информация -->
<div class="border-t border-amber-200 pt-4">
<div class="flex flex-wrap justify-between gap-4 text-xs text-stone-500">
<div class="flex items-center gap-4">
<span class="flex items-center gap-1.5">
<svg class="w-4 h-4 text-stone-400" 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>
Создано: {{ new Date(selectedPost.created_at).toLocaleString('ru-RU') }}
</span>
<span class="flex items-center gap-1.5">
<svg class="w-4 h-4 text-stone-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Обновлено: {{ new Date(selectedPost.updated_at).toLocaleString('ru-RU') }}
</span>
</div>
<span v-if="selectedPost.reading_time" class="flex items-center gap-1.5">
<svg class="w-4 h-4 text-stone-400" 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>
{{ selectedPost.reading_time }} мин.
</span>
</div>
</div>
</div>
<!-- Кнопки действий -->
<div class="bg-gradient-to-r from-amber-50 to-rose-50 border-t border-amber-200 px-6 py-4 flex flex-col sm:flex-row justify-between items-stretch sm:items-center gap-3">
<div class="flex items-center gap-4 flex-wrap">
<!-- Переключатель публикации в VK -->
<button
type="button"
@click.stop="postPublishSettings[selectedPost?.id] = !postPublishSettings[selectedPost?.id]"
class="flex items-center gap-3 cursor-pointer focus:outline-none"
>
<div class="relative">
<div class="w-11 h-6 bg-stone-200 rounded-full transition-colors" :class="postPublishSettings[selectedPost?.id] ? 'bg-rose-400' : 'bg-stone-300'"></div>
<div class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform shadow-sm" :class="postPublishSettings[selectedPost?.id] ? 'translate-x-5' : 'translate-x-0'"></div>
</div>
<span class="text-sm font-medium text-stone-700 hover:text-rose-600 transition-colors flex items-center gap-2">
<svg class="w-4 h-4" :class="postPublishSettings[selectedPost?.id] ? 'text-rose-500' : 'text-stone-400'" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.477 2 2 6.477 2 12c0 5.523 4.477 10 10 10s10-4.477 10-10c0-5.523-4.477-10-10-10zm0 18c-4.418 0-8-3.582-8-8s3.582-8 8-8 8 3.582 8 8-3.582 8-8 8z"/>
</svg>
Опубликовать в VK
</span>
</button>
<!-- Кнопка "Опубликовать" -->
<button
v-if="!selectedPost.publish_at"
@click="publishPost(selectedPost)"
:disabled="publishProcessing"
class="inline-flex items-center px-4 py-2 bg-emerald-500 text-white text-sm font-medium rounded-md hover:bg-emerald-600 transition disabled:opacity-50 disabled:cursor-not-allowed flex-1 sm:flex-none justify-center"
>
<svg v-if="!publishProcessing" class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<svg v-else class="animate-spin h-4 w-4 mr-2" 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>{{ publishProcessing ? 'Публикация...' : 'Опубликовать' }}</span>
</button>
<!-- Кнопка "Редактировать в админке" -->
<a
:href="`/admin/posts/${selectedPost.id}/edit`"
class="inline-flex items-center px-4 py-2 bg-rose-400 text-white text-sm font-medium rounded-md hover:bg-rose-500 transition flex-1 sm:flex-none justify-center"
>
<svg class="w-4 h-4 mr-2" 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>
Редактировать
</a>
</div>
<button
@click="closePostModal"
class="w-full sm:w-auto px-4 py-2 bg-white text-stone-700 rounded-md hover:bg-amber-50 border border-amber-200 transition font-medium text-sm"
>
Закрыть
</button>
</div>
</div>
</div>
</transition>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import { useForm, usePage, router } from '@inertiajs/vue3';
import JSZip from 'jszip';
// Сохраняем имя компонента
defineOptions({
name: "Main",
});
// Получаем данные из Inertia
const page = usePage();
const aiPreparedPosts = computed(() => page.props.aiPreparedPosts || []);
// Состояние для модальных окон
const selectedPost = ref(null);
const isUploadModalOpen = ref(false);
// Состояние для процесса публикации
const publishProcessing = ref(false);
// Состояние для переключателя публикации в VK (отдельно для каждого поста)
const postPublishSettings = ref({});
// Состояние для превью изображений
const imagePreviews = ref({}); // { index: dataUrl }
// Состояние для drag-and-drop изображений
const draggedImageIndex = ref(null);
// Состояние для процесса распаковки ZIP
const isUnzipping = ref(false);
const unzippingProgress = ref(0);
// Инициализируем переключатели для всех постов
watch(aiPreparedPosts, (posts) => {
posts.forEach(post => {
if (postPublishSettings.value[post.id] === undefined) {
postPublishSettings.value[post.id] = true;
}
});
}, { immediate: true });
// Открытие модального окна
const openPostModal = (post) => {
selectedPost.value = post;
};
// Закрытие модального окна
const closePostModal = () => {
selectedPost.value = null;
};
// Открытие модального окна загрузки
const openUploadModal = () => {
isUploadModalOpen.value = true;
};
// Закрытие модального окна загрузки
const closeUploadModal = () => {
isUploadModalOpen.value = false;
};
// Публикация поста
const publishPost = (post) => {
if (!confirm(`Опубликовать новость "${post.title}"?`)) {
return;
}
publishProcessing.value = true;
router.post(route('dashboard.posts.publish', post.id), {
publish_to_vk: postPublishSettings.value[post.id] ?? true,
}, {
preserveScroll: true,
onSuccess: () => {
publishProcessing.value = false;
// Закрываем модальное окно, если оно открыто
if (selectedPost.value?.id === post.id) {
closePostModal();
}
},
onError: () => {
publishProcessing.value = false;
},
});
};
// Рендеринг контента (массив блоков или строка)
const 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;
};
// Просмотр изображения
const viewImage = (imagePath) => {
console.log(123)
window.open(`/storage/${imagePath}`);
};
// Инициализация формы Inertia
const form = useForm({
files: [], // Массив для всех файлов
});
// Состояния для кастомного Dropzone
const isDragging = ref(false);
const filesInput = ref(null);
// Проверка, является ли файл изображением
const isImageFile = (file) => {
return file.type.startsWith('image/');
};
// Создание превью для изображения
const createPreview = (file, index) => {
if (!isImageFile(file)) return;
const reader = new FileReader();
reader.onload = (e) => {
imagePreviews.value[index] = e.target.result;
};
reader.readAsDataURL(file);
};
// Имитация клика по скрытому input type="file"
const triggerFileInput = () => {
filesInput.value.click();
};
// Обработка перетаскивания файлов (Drag & Drop)
const handleDrop = (e) => {
isDragging.value = false;
const files = Array.from(e.dataTransfer.files);
addFilesToForm(files);
};
// Обработка выбора файлов через стандартное окно браузера
const handleFileInput = (e) => {
const files = Array.from(e.target.files);
addFilesToForm(files);
// Сбрасываем value, чтобы можно было удалить файл из списка и выбрать его снова
e.target.value = '';
};
// Функция добавления файлов в массив с защитой от дублей
const addFilesToForm = async (files) => {
for (const file of files) {
// Проверяем, нет ли уже файла с таким именем и размером
const exists = form.files.some(f => f.name === file.name && f.size === f.size);
if (exists) continue;
// Если это ZIP-архив, распаковываем его
if (file.type === 'application/zip' || file.type === 'application/x-zip-compressed' ||
file.name.toLowerCase().endsWith('.zip')) {
await unzipFile(file);
} else {
// Обычный файл
const newIndex = form.files.length;
form.files.push(file);
// Создаём превью для изображений
createPreview(file, newIndex);
}
}
};
// Распаковка ZIP-архива
const unzipFile = async (zipFile) => {
isUnzipping.value = true;
unzippingProgress.value = 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');
// Создаём новый File объект из Blob
const newFile = new File([blob], getBasename(relativePath), {
type: blob.type || getFileMimeType(ext)
});
// Проверяем на дубликаты
const exists = form.files.some(f => f.name === newFile.name && f.size === newFile.size);
if (!exists) {
const newIndex = form.files.length;
form.files.push(newFile);
// Создаём превью для изображений
if (isImageFile(newFile)) {
createPreview(newFile, newIndex);
}
}
// Обновляем прогресс
unzippingProgress.value = Math.round(((fileEntries.indexOf([relativePath, file]) + 1) / totalFiles) * 100);
}
} catch (error) {
console.error('Ошибка при распаковке ZIP:', error);
alert('Не удалось распаковать архив. Убедитесь, что это корректный ZIP-файл.');
} finally {
isUnzipping.value = false;
unzippingProgress.value = 0;
}
};
// Получение имени файла из полного пути
const getBasename = (path) => {
return path.split('/').pop();
};
// Определение MIME-типа по расширению
const 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';
};
// Удаление конкретного файла из массива
const removeFile = (index) => {
form.files.splice(index, 1);
// Удаляем превью для этого индекса
delete imagePreviews.value[index];
// Сдвигаем ключи превью после удаления
const newPreviews = {};
Object.entries(imagePreviews.value).forEach(([key, value]) => {
const numericKey = parseInt(key);
if (numericKey < index) {
newPreviews[numericKey] = value;
} else if (numericKey > index) {
newPreviews[numericKey - 1] = value;
}
});
imagePreviews.value = newPreviews;
};
// Отправка формы
const submitForm = () => {
form.post(route('dashboard.files.store'), {
preserveScroll: true,
onSuccess: () => {
// Очищаем данные в стейте useForm
form.reset();
// Очищаем визуальный input
const filesInputElement = document.getElementById('filesInput');
if (filesInputElement) filesInputElement.value = '';
// Закрываем модальное окно
closeUploadModal();
},
});
};
// Вычисляемые свойства для удобного отображения ошибок валидации массива files
const hasFileErrors = computed(() => {
return Object.keys(form.errors).some(key => key.startsWith('files'));
});
const fileSpecificErrors = computed(() => {
const errors = {};
Object.keys(form.errors).forEach(key => {
if (key.startsWith('files.')) {
errors[key] = form.errors[key];
}
});
return errors;
});
// Вычисляемые свойства для разделения файлов на изображения и остальные
const imageFiles = computed(() => {
return form.files
.map((file, index) => ({ file, originalIndex: index, isImage: isImageFile(file) }))
.filter(item => item.isImage);
});
const otherFiles = computed(() => {
return form.files
.map((file, index) => ({ file, originalIndex: index, isImage: isImageFile(file) }))
.filter(item => !item.isImage);
});
// Определение типа файла для отображения
const getFileTypeIcon = (type) => {
if (!type) return '📁 Файл';
if (type === 'application/pdf') return '📄 PDF';
if (type === 'application/zip' || type === 'application/x-zip-compressed') return '📦 ZIP';
if (type === 'application/msword' || type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') return '📝 DOCX';
if (type === 'application/vnd.ms-excel' || type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') return '📊 XLSX';
return '📁 Файл';
};
// Drag-and-drop методы для изображений
const handleImageDragStart = (event, index) => {
draggedImageIndex.value = index;
// Используем requestAnimationFrame для плавности
requestAnimationFrame(() => {
event.target.classList.add('opacity-50');
});
};
const handleImageDragOver = (event) => {
event.preventDefault();
// Убираем transform, используем только визуальную подсветку
event.currentTarget.classList.add('ring-2', 'ring-indigo-400', 'ring-offset-2');
};
const handleImageDragLeave = (event) => {
event.currentTarget.classList.remove('ring-2', 'ring-indigo-400', 'ring-offset-2');
};
const handleImageDrop = (event, dropIndex) => {
event.preventDefault();
event.currentTarget.classList.remove('ring-2', 'ring-indigo-400', 'ring-offset-2');
const dragIndex = draggedImageIndex.value;
if (dragIndex === null || dragIndex === dropIndex) {
return;
}
// Получаем оригинальные индексы изображений
const imageIndexes = form.files
.map((file, idx) => ({ idx, isImage: isImageFile(file) }))
.filter(item => item.isImage)
.map(item => item.idx);
const fromOriginalIndex = imageIndexes[dragIndex];
const toOriginalIndex = imageIndexes[dropIndex];
// Меняем местами файлы в основном массиве
const temp = form.files[fromOriginalIndex];
form.files[fromOriginalIndex] = form.files[toOriginalIndex];
form.files[toOriginalIndex] = temp;
// Меняем местами превью
const tempPreview = imagePreviews.value[fromOriginalIndex];
imagePreviews.value[fromOriginalIndex] = imagePreviews.value[toOriginalIndex];
imagePreviews.value[toOriginalIndex] = tempPreview;
draggedImageIndex.value = null;
};
const handleImageDragEnd = (event) => {
event.target.classList.remove('opacity-50');
draggedImageIndex.value = null;
};
</script>