Files
F4ilji 4a776a42e2 fix: resolve image upload issues in posts
- Dashboard: disable submit button during uploads, show upload errors, guard against pending uploads
- Filament: add maxParallelUploads(1) to fix race condition (filamentphp#13306)
- Filament: add image/webp to gallery acceptedFileTypes, increase resize 30->50
- Dashboard: add image/webp to gallery input accept
- Add missing notifications table migration for Filament databaseNotifications
2026-09-18 02:25:36 +05:00

1054 lines
45 KiB
Vue
Raw Permalink 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-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 || uploadingImages"
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 || uploadingImages" 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" />
{{ uploadStatusText }}
</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>
<!-- Upload Error -->
<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="uploadError" class="mb-4 p-4 bg-amber-500/10 border border-amber-500/20 rounded-lg">
<div class="flex items-start gap-3">
<DashboardIcon name="exclamation-triangle" size="5" class="text-amber-600 flex-shrink-0 mt-0.5" />
<div class="flex-1">
<span class="text-sm text-foreground font-medium">{{ uploadError }}</span>
</div>
<button @click="uploadError = null" class="text-muted-foreground-1 hover:text-foreground">
<DashboardIcon name="x-mark" size="4" />
</button>
</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'" />
{{ 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">
{{ isEdit ? 'Новость будет обновлена в VK' : 'Новость будет автоматически опубликована в 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']">
<svg v-if="uploadingImages" class="animate-spin w-6 h-6 text-primary" 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="cloud-arrow-up" size="6" class="text-primary" />
</div>
</div>
<div>
<p class="text-sm font-medium text-foreground">
<span v-if="uploadingImages">Загрузка...</span>
<span v-else><span class="text-primary">Нажмите для выбора</span> или перетащите файлы</span>
</p>
<p class="text-xs text-muted-foreground-1 mt-1">JPG, PNG, WebP (до 20MB каждый, макс. 200 файлов)</p>
</div>
</div>
</div>
<input
id="galleryInput"
ref="galleryInput"
type="file"
class="hidden"
accept="image/jpeg,image/png,image/webp"
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>
</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
},
statuses: {
type: Array,
required: true
}
},
data() {
return {
activeTab: 'main',
isEdit: false,
activeButton: true,
isDraggingPreview: false,
isDraggingGallery: false,
uploadingImages: false,
uploadError: null,
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: 'Медиа' }
],
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}`;
}
if (this.form.preview instanceof File || this.form.preview instanceof Blob) {
return URL.createObjectURL(this.form.preview);
}
return null;
},
galleryUrls() {
return this.form.images.map(img => {
if (!img) return null;
if (typeof img === 'string') {
return `/storage/${img}`;
}
if (img instanceof File || img instanceof Blob) {
return URL.createObjectURL(img);
}
return null;
}).filter(url => url !== null);
},
uploadStatusText() {
if (this.uploadingImages) return 'Загрузка файлов...';
return this.isEdit ? 'Сохранить' : 'Создать';
},
},
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: Array.isArray(p.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
};
},
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);
},
isImageFile(file) {
if (!file) return false;
const validTypes = [
'image/jpeg',
'image/png',
'image/webp',
'image/jpg'
];
return validTypes.includes(file.type);
},
handlePreviewDrop(e) {
const files = e.dataTransfer.files;
if (files.length > 0 && this.isImageFile(files[0])) {
this.uploadPreviewToServer(files[0]);
}
},
handlePreviewFileSelect(e) {
const file = e.target.files[0];
if (file && this.isImageFile(file)) {
this.uploadPreviewToServer(file);
}
},
async uploadPreviewToServer(file) {
if (!file) return;
this.uploadingImages = true;
this.uploadError = null;
try {
const formData = new FormData();
formData.append('images[]', file);
const response = await fetch(route('dashboard.posts.upload-images'), {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
body: formData,
});
const result = await response.json();
if (result.success && result.paths && result.paths.length > 0) {
this.form.preview = result.paths[0];
} else {
this.uploadError = result.error || 'Не удалось загрузить главное изображение';
}
} catch (error) {
console.error('Preview upload error:', error);
this.uploadError = 'Ошибка сети при загрузке главного изображения. Проверьте подключение.';
} finally {
this.uploadingImages = false;
}
},
triggerPreviewInput() {
this.$refs.previewInput.click();
},
handleGalleryDrop(e) {
const files = Array.from(e.dataTransfer.files).filter(f => this.isImageFile(f));
this.uploadImagesToServer(files);
},
handleGalleryFileSelect(e) {
const files = Array.from(e.target.files).filter(f => this.isImageFile(f));
this.uploadImagesToServer(files);
// Сбрасываем input чтобы можно было выбрать те же файлы снова
e.target.value = '';
},
async uploadImagesToServer(files) {
if (files.length === 0) return;
this.uploadingImages = true;
this.uploadError = null;
try {
const formData = new FormData();
files.forEach(file => {
formData.append('images[]', file);
});
const response = await fetch(route('dashboard.posts.upload-images'), {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
body: formData,
});
const result = await response.json();
if (result.success && result.paths) {
result.paths.forEach(path => {
this.form.images.push(path);
});
} else {
this.uploadError = result.error || 'Не удалось загрузить изображения галереи';
}
} catch (error) {
console.error('Image upload error:', error);
this.uploadError = 'Ошибка сети при загрузке изображений. Проверьте подключение.';
} finally {
this.uploadingImages = false;
}
},
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');
});
},
submitForm() {
if (this.uploadingImages) {
this.uploadError = 'Дождитесь завершения загрузки изображений перед сохранением.';
return;
}
this.form.processing = true;
this.form.errors = {};
this.uploadError = null;
// Фильтруем изображения - оставляем только строки (пути)
this.form.images = this.form.images.filter(img => typeof img === 'string' && img.length > 0);
const formData = new FormData();
Object.keys(this.form).forEach(key => {
if (key === 'errors' || key === 'processing') return;
let value = this.form[key];
// Пропускаем preview если null, пустая строка или пустой объект
if (key === 'preview') {
if (!value || value === '' || (typeof value === 'object' && Object.keys(value).length === 0)) {
return;
}
}
// Пропускаем images если массив пустой
if (key === 'images' && Array.isArray(value) && value.length === 0) return;
// Пропускаем category_id если null
if (key === 'category_id' && (value === null || value === '')) return;
// Пропускаем slide если slider_id null и нет данных
if (key === 'slide' && value && !value.slider_id && !value.title && !value.content) return;
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');
if (this.isEdit) {
formData.append('_method', 'PUT');
}
this.$inertia.post(url, formData, {
preserveScroll: false,
onSuccess: (page) => {
this.form.processing = false;
},
onError: (errors) => {
this.form.processing = false;
this.form.errors = errors;
}
});
}
}
}
</script>