feat: Implement content planning and post management with a new service and calendar view.
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user