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