feat: Implement content planning and post management with a new service and calendar view.

This commit is contained in:
xds
2026-02-17 15:54:36 +03:00
parent 6bda0db181
commit ff07ca6ae0
11 changed files with 1906 additions and 2356 deletions

View File

@@ -3,12 +3,14 @@ import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
import { useRouter } from 'vue-router'
import { dataService } from '../services/dataService'
import { aiService } from '../services/aiService'
import { postService } from '../services/postService'
import Button from 'primevue/button'
import Textarea from 'primevue/textarea'
import InputText from 'primevue/inputtext'
import Dialog from 'primevue/dialog'
import Checkbox from 'primevue/checkbox'
import Dropdown from 'primevue/dropdown'
import DatePicker from 'primevue/datepicker'
import MultiSelect from 'primevue/multiselect'
import ProgressSpinner from 'primevue/progressspinner'
import ProgressBar from 'primevue/progressbar'
@@ -16,10 +18,122 @@ import Message from 'primevue/message'
import Skeleton from 'primevue/skeleton'
import { useAlbumStore } from '../stores/albums'
import { useToast } from 'primevue/usetoast'
import Toast from 'primevue/toast'
const router = useRouter()
const API_URL = import.meta.env.VITE_API_URL
const albumStore = useAlbumStore()
const toast = useToast()
// --- Multi-Select ---
const isSelectMode = ref(false)
const selectedAssetIds = ref(new Set())
const isDownloading = ref(false)
const toggleSelectMode = () => {
isSelectMode.value = !isSelectMode.value
if (!isSelectMode.value) selectedAssetIds.value = new Set()
}
const toggleImageSelection = (assetId) => {
const s = new Set(selectedAssetIds.value)
if (s.has(assetId)) s.delete(assetId)
else s.add(assetId)
selectedAssetIds.value = s
}
const selectAllImages = () => {
const s = new Set()
for (const gen of historyGenerations.value) {
if (gen.result_list) {
for (const id of gen.result_list) s.add(id)
}
}
selectedAssetIds.value = s
}
const downloadSelected = async () => {
const ids = [...selectedAssetIds.value]
if (ids.length === 0) return
isDownloading.value = true
try {
const user = JSON.parse(localStorage.getItem('user'))
const headers = {}
if (user && user.access_token) headers['Authorization'] = `Bearer ${user.access_token}`
else if (user && user.token) headers['Authorization'] = `${user.tokenType} ${user.token}`
const projectId = localStorage.getItem('active_project_id')
if (projectId) headers['X-Project-ID'] = projectId
const files = []
for (const assetId of ids) {
const url = API_URL + '/assets/' + assetId
const resp = await fetch(url, { headers })
const blob = await resp.blob()
files.push(new File([blob], assetId + '.png', { type: blob.type || 'image/png' }))
}
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) || navigator.maxTouchPoints > 1
if (isMobile && navigator.canShare && navigator.canShare({ files })) {
await navigator.share({ files })
} else {
for (let i = 0; i < files.length; i++) {
const file = files[i]
const a = document.createElement('a')
a.href = URL.createObjectURL(file)
a.download = file.name
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(a.href)
if (i < files.length - 1) await new Promise(r => setTimeout(r, 300))
}
}
toast.add({ severity: 'success', summary: `Скачано ${ids.length} файлов`, life: 2000 })
} catch (e) {
console.error('Download failed', e)
toast.add({ severity: 'error', summary: 'Ошибка скачивания', life: 3000 })
} finally {
isDownloading.value = false
}
}
// --- Add to Content Plan ---
const showAddToPlanDialog = ref(false)
const planPostDate = ref(new Date())
const planPostTopic = ref('')
const isSavingToPlan = ref(false)
const openAddToPlan = () => {
planPostDate.value = new Date()
planPostTopic.value = ''
showAddToPlanDialog.value = true
}
const confirmAddToPlan = async () => {
if (!planPostTopic.value.trim()) {
toast.add({ severity: 'warn', summary: 'Укажите тему', life: 2000 })
return
}
isSavingToPlan.value = true
try {
const d = new Date(planPostDate.value); d.setHours(12, 0, 0, 0)
await postService.createPost({
date: d.toISOString(),
topic: planPostTopic.value,
generation_ids: [...selectedAssetIds.value]
})
toast.add({ severity: 'success', summary: 'Добавлено в контент-план', life: 2000 })
showAddToPlanDialog.value = false
isSelectMode.value = false
selectedAssetIds.value = new Set()
} catch (e) {
console.error('Add to plan failed', e)
toast.add({ severity: 'error', summary: 'Ошибка', detail: 'Не удалось добавить', life: 3000 })
} finally {
isSavingToPlan.value = false
}
}
// --- State ---
const prompt = ref('')
@@ -731,6 +845,9 @@ const confirmAddToAlbum = async () => {
</Dropdown>
<Button icon="pi pi-refresh" @click="refreshHistory" rounded text
class="!text-slate-400 hover:!bg-white/10 !w-8 !h-8 md:hidden" />
<Button :icon="isSelectMode ? 'pi pi-times' : 'pi pi-check-square'" @click="toggleSelectMode"
rounded text class="!w-8 !h-8"
:class="isSelectMode ? '!text-violet-400 !bg-violet-500/20' : '!text-slate-400 hover:!bg-white/10'" />
<Button icon="pi pi-cog" @click="isSettingsVisible = true" rounded text
class="!text-slate-400 hover:!bg-white/10 !w-8 !h-8" v-if="!isSettingsVisible" />
</div>
@@ -748,7 +865,7 @@ const confirmAddToAlbum = async () => {
<!-- ============================================ -->
<template v-if="item.isGroup">
<!-- Group badge -->
<div
<div v-if="!isSelectMode"
class="absolute top-1.5 left-1.5 z-20 bg-violet-600/80 backdrop-blur-sm text-white text-[9px] font-bold px-1.5 py-0.5 rounded-md pointer-events-none">
{{ item.children.length }}x
</div>
@@ -807,14 +924,11 @@ const confirmAddToAlbum = async () => {
class="absolute inset-0 bg-black/60 transition-opacity duration-200 flex flex-col justify-between p-1 z-10"
:class="{ 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto': activeOverlayId !== child.id, 'opacity-100 pointer-events-auto': activeOverlayId === child.id }">
<!-- Top right: edit, album, delete -->
<!-- Top right: edit, delete -->
<div class="flex justify-end items-start gap-0.5">
<Button icon="pi pi-pencil" size="small"
class="!w-5 !h-5 !rounded-full !bg-white/20 !border-none !text-white !text-[8px] hover:!bg-violet-500"
@click.stop="useResultAsAsset(child)" />
<Button icon="pi pi-folder-plus" size="small"
class="!w-5 !h-5 !rounded-full !bg-white/20 !border-none !text-white !text-[8px] hover:!bg-violet-500"
@click.stop="openAlbumPicker(child)" />
<Button icon="pi pi-trash" size="small"
class="!w-5 !h-5 !rounded-full !bg-red-500/20 !border-none !text-red-400 !text-[8px] hover:!bg-red-500 hover:!text-white"
@click.stop="deleteGeneration(child)" />
@@ -842,6 +956,20 @@ const confirmAddToAlbum = async () => {
</div>
</div>
</div>
<!-- Selection overlay for group children -->
<div v-if="isSelectMode" class="absolute inset-0 z-30 pointer-events-none">
<div class="absolute top-1 left-1 z-30 flex flex-col gap-0.5 pointer-events-auto">
<template v-for="child in item.children" :key="'chk-'+child.id">
<div v-if="child.result_list && child.result_list.length > 0"
@click.stop="toggleImageSelection(child.result_list[0])"
class="w-5 h-5 rounded-md flex items-center justify-center cursor-pointer transition-all"
:class="selectedAssetIds.has(child.result_list[0]) ? 'bg-violet-600 text-white' : 'bg-black/40 border border-white/30 text-transparent'">
<i class="pi pi-check" style="font-size: 10px"></i>
</div>
</template>
</div>
</div>
</template>
<!-- ============================================ -->
@@ -854,7 +982,7 @@ const confirmAddToAlbum = async () => {
<img v-if="item.result_list && item.result_list.length > 0"
:src="API_URL + '/assets/' + item.result_list[0] + '?thumbnail=true'"
class="w-full h-full object-cover cursor-pointer"
@click.stop="openImagePreview(API_URL + '/assets/' + item.result_list[0])" />
@click.stop="isSelectMode ? toggleImageSelection(item.result_list[0]) : openImagePreview(API_URL + '/assets/' + item.result_list[0])" />
<!-- FAILED: error display -->
<div v-else-if="item.status === 'failed'"
@@ -924,9 +1052,6 @@ const confirmAddToAlbum = async () => {
icon="pi pi-pencil" v-tooltip.left="'Edit (Use Result)'"
class="!w-6 !h-6 !rounded-full !bg-white/20 !border-none !text-white text-[10px] hover:!bg-violet-500"
@click.stop="useResultAsAsset(item)" />
<Button icon="pi pi-folder-plus" v-tooltip.left="'Add to Album'"
class="!w-6 !h-6 !rounded-full !bg-white/20 !border-none !text-white text-[10px] hover:!bg-violet-500"
@click.stop="openAlbumPicker(item)" />
<Button icon="pi pi-trash" v-tooltip.left="'Delete'"
class="!w-6 !h-6 !rounded-full !bg-red-500/20 !border-none !text-red-400 text-[10px] hover:!bg-red-500 hover:!text-white"
@click.stop="deleteGeneration(item)" />
@@ -966,6 +1091,18 @@ const confirmAddToAlbum = async () => {
}}</p>
</div>
</div>
<!-- Select mode checkbox overlay -->
<div v-if="isSelectMode && item.result_list && item.result_list.length > 0"
class="absolute inset-0 z-20 cursor-pointer"
@click.stop="toggleImageSelection(item.result_list[0])">
<div class="absolute top-2 left-2 w-6 h-6 rounded-lg flex items-center justify-center transition-all"
:class="selectedAssetIds.has(item.result_list[0]) ? 'bg-violet-600 text-white' : 'bg-black/40 border border-white/30 text-transparent hover:border-white/60'">
<i class="pi pi-check" style="font-size: 12px"></i>
</div>
<div v-if="selectedAssetIds.has(item.result_list[0])"
class="absolute inset-0 bg-violet-600/20 pointer-events-none"></div>
</div>
</div>
</template>
</div>
@@ -987,7 +1124,7 @@ const confirmAddToAlbum = async () => {
</div>
</div>
<div v-if="isSettingsVisible"
<div v-if="isSettingsVisible && !isSelectMode"
class="absolute bottom-2 left-1/2 -translate-x-1/2 w-[98%] max-w-6xl glass-panel border border-white/10 bg-slate-900/95 backdrop-blur-xl p-4 z-[60] !rounded-[2.5rem] shadow-2xl flex flex-col gap-3 max-h-[85vh] overflow-y-auto">
<div class="w-full flex justify-center -mt-2 mb-2 cursor-pointer" @click="isSettingsVisible = false">
@@ -1282,6 +1419,53 @@ const confirmAddToAlbum = async () => {
</template>
</Dialog>
<Toast />
<!-- Multi-select floating bar -->
<Transition name="slide-up">
<div v-if="isSelectMode && selectedAssetIds.size > 0"
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-[100] bg-slate-800/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl px-5 py-3 flex items-center gap-3">
<span class="text-sm text-slate-300 font-medium">
<i class="pi pi-check-circle mr-1 text-violet-400"></i>
{{ selectedAssetIds.size }} selected
</span>
<div class="w-px h-6 bg-white/10"></div>
<Button icon="pi pi-check-square" label="All" size="small" text @click="selectAllImages"
class="!text-slate-400 hover:!text-white !text-xs" />
<Button icon="pi pi-download" label="Download" size="small" :loading="isDownloading"
@click="downloadSelected" class="!bg-violet-600 !border-none hover:!bg-violet-500 !text-sm" />
<Button icon="pi pi-calendar" label="To plan" size="small" @click="openAddToPlan"
class="!bg-emerald-600 !border-none hover:!bg-emerald-500 !text-sm" />
</div>
</Transition>
<!-- Add to Plan Dialog -->
<Dialog v-model:visible="showAddToPlanDialog" header="Добавить в контент-план" modal :style="{ width: '400px' }"
:pt="{ root: { class: '!bg-slate-800 !border-white/10' }, header: { class: '!bg-slate-800' }, content: { class: '!bg-slate-800' } }">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Дата</label>
<DatePicker v-model="planPostDate" dateFormat="dd.mm.yy" showIcon class="w-full" :pt="{
root: { class: '!bg-slate-700 !border-white/10' },
pcInputText: { root: { class: '!bg-slate-700 !border-white/10 !text-white' } }
}" />
</div>
<div class="flex flex-col gap-1">
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Тема</label>
<InputText v-model="planPostTopic" placeholder="Тема поста..."
class="w-full !bg-slate-700 !border-white/10 !text-white" />
</div>
<p class="text-xs text-slate-500"><i class="pi pi-images mr-1"></i>{{ selectedAssetIds.size }}
изображений
будет добавлено</p>
<div class="flex justify-end gap-2">
<Button label="Отмена" text @click="showAddToPlanDialog = false"
class="!text-slate-400 hover:!text-white" />
<Button label="Добавить" icon="pi pi-check" :loading="isSavingToPlan" @click="confirmAddToPlan"
class="!bg-violet-600 !border-none hover:!bg-violet-500" />
</div>
</div>
</Dialog>
</div>
</template>