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

@@ -7,7 +7,9 @@ import { useConfirm } from 'primevue/useconfirm'
import { useToast } from 'primevue/usetoast'
import { aiService } from '../services/aiService'
import { dataService } from '../services/dataService'
import { postService } from '../services/postService'
import draggable from 'vuedraggable'
import JSZip from 'jszip'
// Components
import Button from 'primevue/button'
@@ -23,6 +25,7 @@ import TabMenu from 'primevue/tabmenu'
import FileUpload from 'primevue/fileupload'
import ConfirmDialog from 'primevue/confirmdialog'
import InputText from 'primevue/inputtext'
import DatePicker from 'primevue/datepicker'
const route = useRoute()
const router = useRouter()
@@ -136,6 +139,15 @@ onMounted(async () => {
])
console.log('Fetched idea:', currentIdea.value)
if (currentIdea.value) {
// Check for autostart query param
if (route.query.autostart === 'true') {
// Slight delay to ensure everything is reactive and mounted
setTimeout(() => {
handleGenerate()
// Remove query param to prevent re-trigger on reload
router.replace({ query: null })
}, 500)
}
fetchGenerations(id)
} else {
console.error('currentIdea is null after fetch')
@@ -662,21 +674,59 @@ const downloadSelected = async () => {
const projectId = localStorage.getItem('active_project_id')
if (projectId) headers['X-Project-ID'] = projectId
// Fetch all blobs
const files = []
for (const assetId of ids) {
// Multi-file ZIP (if > 1)
if (ids.length > 1) {
const zip = new JSZip()
// Sanitize idea name for filename (fallback to 'session' if no name)
const safeName = (currentIdea.value?.name || 'session').replace(/[^a-z0-9_\- ]/gi, '').trim().replace(/\s+/g, '_').toLowerCase()
const folderName = `${safeName}_assets`
let successCount = 0
// Sequential fetch to avoid overwhelming network but could be parallel
await Promise.all(ids.map(async (assetId) => {
try {
const url = API_URL + '/assets/' + assetId
const resp = await fetch(url, { headers })
if (!resp.ok) return
const blob = await resp.blob()
const mime = blob.type
const ext = mime.split('/')[1] || 'png'
zip.file(`${assetId}.${ext}`, blob)
successCount++
} catch (err) {
console.error('Failed to zip asset', assetId, err)
}
}))
if (successCount === 0) throw new Error('No images available for zip')
const content = await zip.generateAsync({ type: 'blob' })
const filename = `${folderName}.zip`
const a = document.createElement('a')
a.href = URL.createObjectURL(content)
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(a.href)
toast.add({ severity: 'success', summary: 'Archived', detail: `${successCount} images saved to zip`, life: 3000 })
} else {
// Single File
const assetId = ids[0]
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' }))
}
// Try native share (iOS share sheet)
if (navigator.canShare && navigator.canShare({ files })) {
await navigator.share({ files })
} else {
// Fallback: browser download
for (const file of files) {
// Use share sheet only on mobile, direct download on desktop
const file = 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: [file] })) {
await navigator.share({ files: [file] })
} else {
const a = document.createElement('a')
a.href = URL.createObjectURL(file)
a.download = file.name
@@ -685,8 +735,8 @@ const downloadSelected = async () => {
document.body.removeChild(a)
URL.revokeObjectURL(a.href)
}
toast.add({ severity: 'success', summary: 'Downloaded', detail: `Image saved`, life: 2000 })
}
toast.add({ severity: 'success', summary: 'Downloaded', detail: `${ids.length} image(s) saved`, life: 2000 })
} catch (e) {
console.error('Download failed', e)
toast.add({ severity: 'error', summary: 'Error', detail: 'Download failed', life: 3000 })
@@ -695,6 +745,49 @@ const downloadSelected = async () => {
}
}
// --- Add to Content Plan ---
const showAddToPlanDialog = ref(false)
const planPostDate = ref(new Date())
const planPostTopic = ref('')
const isSavingToPlan = ref(false)
function openAddToPlanDialog() {
planPostDate.value = new Date()
planPostTopic.value = ''
showAddToPlanDialog.value = true
}
async function confirmAddToPlan() {
if (!planPostTopic.value.trim()) {
toast.add({ severity: 'warn', summary: 'Укажите тему', life: 2000 })
return
}
isSavingToPlan.value = true
try {
// Collect unique generation ids for selected assets
const genIds = new Set()
for (const img of allGalleryImages.value) {
if (selectedAssetIds.value.has(img.assetId)) {
genIds.add(img.assetId)
}
}
await postService.createPost({
date: (() => { const d = new Date(planPostDate.value); d.setHours(12, 0, 0, 0); return d.toISOString() })(),
topic: planPostTopic.value,
generation_ids: [...genIds]
})
toast.add({ severity: 'success', summary: 'Added to content plan', 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: 'Error', detail: 'Failed to add to plan', life: 3000 })
} finally {
isSavingToPlan.value = false
}
}
// Exit select mode when switching to feed
watch(viewMode, (v) => {
if (v !== 'gallery') {
@@ -924,6 +1017,8 @@ watch(viewMode, (v) => {
<div v-if="isSelectMode && selectedAssetIds.size > 0"
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-[70] flex items-center gap-3 bg-slate-900/95 backdrop-blur-xl border border-white/10 rounded-full px-5 py-3 shadow-2xl">
<span class="text-sm text-white font-medium">{{ selectedAssetIds.size }} selected</span>
<Button label="📅 Add to plan" icon="pi pi-calendar" @click="openAddToPlanDialog"
class="!bg-emerald-600 !border-none !rounded-full !text-sm !font-bold hover:!bg-emerald-500 !px-4 !py-2" />
<Button :label="isDownloading ? 'Downloading...' : 'Download'" icon="pi pi-download"
:loading="isDownloading" @click="downloadSelected"
class="!bg-violet-600 !border-none !rounded-full !text-sm !font-bold hover:!bg-violet-500 !px-4 !py-2" />
@@ -934,7 +1029,7 @@ watch(viewMode, (v) => {
</div>
<!-- SETTINGS PANEL (Bottom) -->
<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 px-4 py-3 z-[60] !rounded-[2rem] shadow-2xl flex flex-col gap-1 max-h-[60vh] overflow-y-auto">
<div class="w-full flex justify-center -mt-1 mb-1 cursor-pointer" @click="isSettingsVisible = false">
@@ -1197,6 +1292,35 @@ watch(viewMode, (v) => {
</div>
</Dialog>
<!-- Add to Content Plan Dialog -->
<Dialog v-model:visible="showAddToPlanDialog" header="Add to content plan" modal :style="{ width: '420px' }"
: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">Publication
date</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">Post topic</label>
<InputText v-model="planPostTopic" placeholder="Post topic..."
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 }} images will be added
</p>
<div class="flex justify-end gap-2 mt-2">
<Button label="Отмена" text @click="showAddToPlanDialog = false"
class="!text-slate-400 hover:!text-white" />
<Button label="Добавить" :loading="isSavingToPlan" @click="confirmAddToPlan"
class="!bg-emerald-600 !border-none hover:!bg-emerald-500" />
</div>
</div>
</Dialog>
</div>
</template>