1682 lines
85 KiB
Vue
1682 lines
85 KiB
Vue
<script setup>
|
|
import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { useIdeaStore } from '../stores/ideas'
|
|
import { storeToRefs } from 'pinia'
|
|
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'
|
|
import Textarea from 'primevue/textarea'
|
|
import Checkbox from 'primevue/checkbox'
|
|
import Dropdown from 'primevue/dropdown'
|
|
import Dialog from 'primevue/dialog'
|
|
import Menu from 'primevue/menu'
|
|
import Skeleton from 'primevue/skeleton'
|
|
import ProgressSpinner from 'primevue/progressspinner'
|
|
import Image from 'primevue/image'
|
|
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'
|
|
import Tag from 'primevue/tag'
|
|
import InputSwitch from 'primevue/inputswitch'
|
|
import GenerationPreviewModal from '../components/GenerationPreviewModal.vue'
|
|
import GenerationImage from '../components/GenerationImage.vue'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const ideaStore = useIdeaStore()
|
|
const confirm = useConfirm()
|
|
const toast = useToast()
|
|
const { currentIdea, currentInspiration, loading, error } = storeToRefs(ideaStore)
|
|
const generations = ref([])
|
|
const inspirationContentType = ref('')
|
|
|
|
// --- Idea Name Editing ---
|
|
const isEditingName = ref(false)
|
|
const editableName = ref('')
|
|
|
|
const toggleEditName = () => {
|
|
if (!currentIdea.value) return
|
|
editableName.value = currentIdea.value.name
|
|
isEditingName.value = true
|
|
nextTick(() => {
|
|
const input = document.querySelector('.idea-name-input input')
|
|
if (input) input.focus()
|
|
})
|
|
}
|
|
|
|
const saveName = async () => {
|
|
if (!editableName.value.trim() || editableName.value === currentIdea.value.name) {
|
|
isEditingName.value = false
|
|
return
|
|
}
|
|
|
|
try {
|
|
const success = await ideaStore.updateIdea(currentIdea.value.id, {
|
|
name: editableName.value.trim()
|
|
})
|
|
if (success) {
|
|
toast.add({ severity: 'success', summary: 'Success', detail: 'Idea renamed', life: 2000 })
|
|
}
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to rename idea', life: 3000 })
|
|
} finally {
|
|
isEditingName.value = false
|
|
}
|
|
}
|
|
|
|
const prompt = ref('')
|
|
const negativePrompt = ref('')
|
|
const selectedModel = ref('flux-schnell')
|
|
|
|
// Character & Assets (declared early for settings persistence)
|
|
const characters = ref([])
|
|
const selectedCharacter = ref(null)
|
|
const environments = ref([])
|
|
const selectedEnvironment = ref(null)
|
|
const selectedAssets = ref([])
|
|
const showAssetPicker = ref(false) // Deprecated, using isAssetPickerVisible
|
|
|
|
// --- Load saved settings from localStorage ---
|
|
const SETTINGS_KEY = 'idea-gen-settings'
|
|
const quality = ref({ key: 'TWOK', value: '2K' })
|
|
const aspectRatio = ref('NINESIXTEEN') // Default to Video (9:16)
|
|
const imageCount = ref(1)
|
|
const sendToTelegram = ref(false)
|
|
const telegramId = ref('')
|
|
const useProfileImage = ref(true)
|
|
const useEnvironment = ref(false)
|
|
const isImprovingPrompt = ref(false)
|
|
const previousPrompt = ref('')
|
|
let _savedCharacterId = null
|
|
let _savedEnvironmentId = null
|
|
|
|
// NSFW Toggle
|
|
const showNsfwGlobal = ref(localStorage.getItem('show_nsfw_global') === 'true')
|
|
|
|
watch(showNsfwGlobal, (val) => {
|
|
localStorage.setItem('show_nsfw_global', val)
|
|
})
|
|
|
|
const loadEnvironments = async (charId) => {
|
|
if (!charId) {
|
|
environments.value = []
|
|
selectedEnvironment.value = null
|
|
return
|
|
}
|
|
try {
|
|
const response = await dataService.getEnvironments(charId)
|
|
environments.value = Array.isArray(response) ? response : (response.environments || [])
|
|
|
|
if (_savedEnvironmentId) {
|
|
selectedEnvironment.value = environments.value.find(e => (e.id === _savedEnvironmentId || e._id === _savedEnvironmentId)) || null
|
|
_savedEnvironmentId = null
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load environments', e)
|
|
environments.value = []
|
|
}
|
|
}
|
|
|
|
watch(selectedCharacter, (newChar) => {
|
|
loadEnvironments(newChar?.id || newChar?._id)
|
|
})
|
|
|
|
// --- Persist settings to localStorage on change ---
|
|
const saveSettings = () => {
|
|
const settings = {
|
|
prompt: prompt.value,
|
|
quality: quality.value,
|
|
aspectRatio: aspectRatio.value,
|
|
imageCount: imageCount.value,
|
|
selectedModel: selectedModel.value,
|
|
sendToTelegram: sendToTelegram.value,
|
|
telegramId: telegramId.value,
|
|
useProfileImage: useProfileImage.value,
|
|
useEnvironment: useEnvironment.value,
|
|
selectedCharacterId: selectedCharacter.value?.id || selectedCharacter.value?._id || null,
|
|
selectedEnvironmentId: selectedEnvironment.value?.id || selectedEnvironment.value?._id || null,
|
|
selectedAssetIds: selectedAssets.value.map(a => a.id),
|
|
}
|
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings))
|
|
if (telegramId.value) {
|
|
localStorage.setItem('telegram_id', telegramId.value)
|
|
}
|
|
}
|
|
|
|
const restoreSettings = () => {
|
|
const stored = localStorage.getItem(SETTINGS_KEY)
|
|
if (!stored) return
|
|
try {
|
|
const s = JSON.parse(stored)
|
|
if (s.prompt) prompt.value = s.prompt
|
|
if (s.quality) quality.value = s.quality
|
|
if (s.aspectRatio) {
|
|
// Handle legacy object format if present
|
|
if (typeof s.aspectRatio === 'object' && s.aspectRatio.key) {
|
|
aspectRatio.value = s.aspectRatio.key
|
|
} else {
|
|
aspectRatio.value = s.aspectRatio
|
|
}
|
|
}
|
|
if (s.imageCount) imageCount.value = Math.min(s.imageCount, 4)
|
|
if (s.selectedModel) selectedModel.value = s.selectedModel
|
|
sendToTelegram.value = s.sendToTelegram || false
|
|
telegramId.value = s.telegramId || localStorage.getItem('telegram_id') || ''
|
|
if (s.useProfileImage !== undefined) useProfileImage.value = s.useProfileImage
|
|
if (s.useEnvironment !== undefined) useEnvironment.value = s.useEnvironment
|
|
_savedCharacterId = s.selectedCharacterId || null
|
|
_savedEnvironmentId = s.selectedEnvironmentId || null
|
|
if (s.selectedAssetIds && s.selectedAssetIds.length > 0) {
|
|
selectedAssets.value = s.selectedAssetIds.map(id => ({
|
|
id,
|
|
url: `/assets/${id}`,
|
|
name: 'Asset ' + id.substring(0, 4)
|
|
}))
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to restore idea settings', e)
|
|
}
|
|
}
|
|
restoreSettings()
|
|
|
|
watch([prompt, quality, aspectRatio, imageCount, selectedModel, sendToTelegram, telegramId, useProfileImage, useEnvironment, selectedCharacter, selectedEnvironment, selectedAssets], saveSettings, { deep: true })
|
|
|
|
const viewMode = ref('feed') // 'feed' or 'gallery'
|
|
const onlyLiked = ref(false)
|
|
const isSubmitting = ref(false)
|
|
const isSettingsVisible = ref(localStorage.getItem('idea_detail_settings_visible') !== 'false')
|
|
|
|
watch(isSettingsVisible, (val) => {
|
|
localStorage.setItem('idea_detail_settings_visible', val)
|
|
})
|
|
|
|
const API_URL = import.meta.env.VITE_API_URL
|
|
|
|
const qualityOptions = ref([
|
|
{ key: 'ONEK', value: '1K' },
|
|
{ key: 'TWOK', value: '2K' },
|
|
{ key: 'FOURK', value: '4K' }
|
|
])
|
|
|
|
// Removed duplicate characters ref
|
|
const loadingGenerations = ref(false) // Added this ref based on usage in fetchGenerations
|
|
|
|
// --- Initialization ---
|
|
onMounted(async () => {
|
|
const id = route.params.id
|
|
console.log('IdeaDetailView mounted with ID:', id)
|
|
if (id) {
|
|
try {
|
|
await Promise.all([
|
|
ideaStore.fetchIdea(id),
|
|
loadCharacters()
|
|
])
|
|
console.log('Fetched idea:', currentIdea.value)
|
|
if (currentIdea.value) {
|
|
// Check for inspiration
|
|
if (currentIdea.value.inspiration_id) {
|
|
ideaStore.fetchInspiration(currentIdea.value.inspiration_id)
|
|
}
|
|
|
|
// 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')
|
|
}
|
|
} catch (e) {
|
|
console.error('Error in onMounted:', e)
|
|
}
|
|
} else {
|
|
console.error('No ID in route params')
|
|
}
|
|
})
|
|
|
|
const loadCharacters = async () => {
|
|
try {
|
|
characters.value = await dataService.getCharacters()
|
|
// Restore saved character selection after characters are loaded
|
|
if (_savedCharacterId && characters.value.length > 0) {
|
|
const found = characters.value.find(c => (c.id === _savedCharacterId || c._id === _savedCharacterId))
|
|
if (found) selectedCharacter.value = found
|
|
_savedCharacterId = null
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load characters', e)
|
|
}
|
|
}
|
|
|
|
const fetchGenerations = async (ideaId) => {
|
|
loadingGenerations.value = true
|
|
try {
|
|
const response = await ideaStore.fetchIdeaGenerations(ideaId, 100, 0, onlyLiked.value)
|
|
let loadedGens = []
|
|
if (response.data && response.data.generations) {
|
|
loadedGens = response.data.generations
|
|
} else if (response.data && Array.isArray(response.data)) {
|
|
loadedGens = response.data
|
|
} else if (Array.isArray(response)) {
|
|
loadedGens = response
|
|
}
|
|
|
|
// Sort by created_at desc
|
|
generations.value = loadedGens.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
|
|
|
|
// Resume polling for active ones
|
|
generations.value.forEach(gen => {
|
|
const status = gen.status ? gen.status.toLowerCase() : ''
|
|
if (['processing', 'starting', 'running'].includes(status)) {
|
|
pollGeneration(gen.id)
|
|
}
|
|
})
|
|
} catch (e) {
|
|
console.error('Failed to load generations', e)
|
|
} finally {
|
|
loadingGenerations.value = false
|
|
}
|
|
}
|
|
|
|
// --- Generation Logic ---
|
|
const handleGenerate = async () => {
|
|
if (!prompt.value) {
|
|
toast.add({ severity: 'warn', summary: 'Prompt Required', detail: 'Please enter a prompt to generate.', life: 3000 })
|
|
return
|
|
}
|
|
|
|
if (hasActiveGenerations.value) return
|
|
|
|
isSubmitting.value = true
|
|
try {
|
|
// Construct Payload
|
|
const payload = {
|
|
prompt: prompt.value,
|
|
aspect_ratio: aspectRatio.value, // Now a string
|
|
quality: quality.value.key,
|
|
assets_list: selectedAssets.value.map(a => a.id),
|
|
linked_character_id: selectedCharacter.value?.id || selectedCharacter.value?._id || null,
|
|
environment_id: (selectedCharacter.value && useEnvironment.value) ? (selectedEnvironment.value?.id || selectedEnvironment.value?._id || null) : null,
|
|
telegram_id: sendToTelegram.value ? telegramId.value : null,
|
|
use_profile_image: selectedCharacter.value ? useProfileImage.value : false,
|
|
count: imageCount.value,
|
|
idea_id: currentIdea.value.id
|
|
}
|
|
|
|
const response = await aiService.runGeneration(payload)
|
|
|
|
// Parse response: API returns { generation_group_id, generations: [...] }
|
|
let newGenerations = []
|
|
if (response && response.generations && Array.isArray(response.generations)) {
|
|
newGenerations = response.generations
|
|
} else if (Array.isArray(response)) {
|
|
newGenerations = response
|
|
} else if (response && response.id) {
|
|
newGenerations = [response]
|
|
}
|
|
|
|
for (const gen of newGenerations) {
|
|
// Ensure status is set for blocking logic
|
|
if (!gen.status) gen.status = 'running'
|
|
|
|
// Add to local list (check for dupe)
|
|
if (!generations.value.find(g => g.id === gen.id)) {
|
|
generations.value.unshift(gen)
|
|
}
|
|
|
|
// Start polling status
|
|
pollGeneration(gen.id)
|
|
}
|
|
|
|
// Switch to feed view to see progress
|
|
viewMode.value = 'feed'
|
|
|
|
// Scroll to top? Or just let user see it appear.
|
|
|
|
} catch (e) {
|
|
console.error('Generation failed:', e)
|
|
toast.add({ severity: 'error', summary: 'Generation Failed', detail: e.message || 'Unknown error', life: 5000 })
|
|
} finally {
|
|
isSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
const pollGeneration = async (id) => {
|
|
let completed = false
|
|
let attempts = 0
|
|
|
|
// Find in local list
|
|
// Note: We need a reactive reference. find() returns the object from the ref array
|
|
// which IS reactive.
|
|
|
|
while (!completed) {
|
|
const gen = generations.value.find(g => g.id === id)
|
|
if (!gen) return // Removed from list?
|
|
|
|
try {
|
|
const response = await aiService.getGenerationStatus(id)
|
|
Object.assign(gen, response)
|
|
|
|
if (response.status === 'done' || response.status === 'failed') {
|
|
completed = true
|
|
} else {
|
|
await new Promise(r => setTimeout(r, 2000))
|
|
}
|
|
} catch (e) {
|
|
attempts++
|
|
if (attempts > 3) {
|
|
if (gen) gen.status = 'failed'
|
|
completed = true
|
|
}
|
|
await new Promise(r => setTimeout(r, 5000))
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Actions ---
|
|
const deleteGeneration = async (gen) => {
|
|
const isGroup = gen.isGroup && gen.children && gen.children.length > 0
|
|
const message = isGroup
|
|
? `Delete this group of ${gen.children.length} generations?`
|
|
: 'Remove this generation from the idea session?'
|
|
|
|
confirm.require({
|
|
message: message,
|
|
header: 'Delete Generation',
|
|
icon: 'pi pi-trash',
|
|
acceptClass: 'p-button-danger',
|
|
accept: async () => {
|
|
const targets = isGroup ? gen.children : [gen]
|
|
|
|
// Optimistic remove
|
|
// If group, remove all children. If single, remove single.
|
|
// But visually we are removing the "gen" passed in which might be the group wrapper or a single item.
|
|
// We need to filter out ALL IDs involved from the main list.
|
|
const targetIds = targets.map(t => t.id)
|
|
generations.value = generations.value.filter(g => !targetIds.includes(g.id))
|
|
|
|
// API calls
|
|
for (const target of targets) {
|
|
await ideaStore.removeGenerationFromIdea(currentIdea.value.id, target.id)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
const reusePrompt = (gen) => {
|
|
if (gen.prompt) {
|
|
prompt.value = gen.prompt
|
|
isSettingsVisible.value = true
|
|
}
|
|
}
|
|
|
|
const reuseAssets = (gen) => {
|
|
const assetIds = gen.assets_list || []
|
|
if (assetIds.length > 0) {
|
|
const assets = assetIds.map(id => {
|
|
return { id, url: `/assets/${id}`, name: 'Asset ' + id.substring(0, 4) }
|
|
})
|
|
selectedAssets.value = assets
|
|
isSettingsVisible.value = true
|
|
}
|
|
}
|
|
|
|
const setAsReference = (assetId) => {
|
|
const asset = {
|
|
id: assetId,
|
|
url: `/assets/${assetId}`,
|
|
name: 'Gen ' + assetId.substring(0, 4)
|
|
}
|
|
if (!selectedAssets.value.some(a => a.id === asset.id)) {
|
|
selectedAssets.value.push(asset)
|
|
}
|
|
isSettingsVisible.value = true
|
|
toast.add({ severity: 'success', summary: 'Reference Added', detail: 'Image added as reference asset', life: 2000 })
|
|
}
|
|
|
|
const deleteAssetFromGeneration = (gen, assetId) => {
|
|
confirm.require({
|
|
message: 'Remove this image from the generation?',
|
|
header: 'Remove Image',
|
|
icon: 'pi pi-trash',
|
|
acceptClass: 'p-button-danger',
|
|
accept: async () => {
|
|
// Remove from result_list
|
|
const idx = gen.result_list.indexOf(assetId)
|
|
if (idx > -1) {
|
|
gen.result_list.splice(idx, 1)
|
|
}
|
|
// If group, also try children
|
|
if (gen.isGroup && gen.children) {
|
|
gen.children.forEach(child => {
|
|
const ci = child.result_list?.indexOf(assetId)
|
|
if (ci > -1) child.result_list.splice(ci, 1)
|
|
})
|
|
}
|
|
toast.add({ severity: 'info', summary: 'Removed', detail: 'Image removed', life: 2000 })
|
|
}
|
|
})
|
|
}
|
|
|
|
const handleImprovePrompt = async () => {
|
|
if (!prompt.value || prompt.value.length <= 10) return
|
|
isImprovingPrompt.value = true
|
|
try {
|
|
const linkedAssetIds = selectedAssets.value.map(a => a.id)
|
|
const response = await aiService.improvePrompt(prompt.value, linkedAssetIds)
|
|
if (response && response.prompt) {
|
|
previousPrompt.value = prompt.value
|
|
prompt.value = response.prompt
|
|
}
|
|
} catch (e) {
|
|
console.error('Prompt improvement failed', e)
|
|
} finally {
|
|
isImprovingPrompt.value = false
|
|
}
|
|
}
|
|
|
|
const undoImprovePrompt = () => {
|
|
if (previousPrompt.value) {
|
|
const temp = prompt.value
|
|
prompt.value = previousPrompt.value
|
|
previousPrompt.value = temp
|
|
}
|
|
}
|
|
|
|
const clearPrompt = () => {
|
|
prompt.value = ''
|
|
previousPrompt.value = ''
|
|
}
|
|
|
|
const pastePrompt = async () => {
|
|
try {
|
|
const text = await navigator.clipboard.readText()
|
|
if (text) prompt.value = text
|
|
} catch (err) {
|
|
console.error('Failed to read clipboard', err)
|
|
}
|
|
}
|
|
|
|
|
|
// --- Asset Picker Logic ---
|
|
const isAssetPickerVisible = ref(false)
|
|
const assetPickerTab = ref('all') // 'all', 'uploaded', 'generated'
|
|
const modalAssets = ref([])
|
|
const isModalLoading = ref(false)
|
|
const tempSelectedAssets = ref([])
|
|
const assetPickerFileInput = ref(null)
|
|
|
|
const loadModalAssets = async () => {
|
|
isModalLoading.value = true
|
|
try {
|
|
const typeParam = assetPickerTab.value === 'all' ? undefined : assetPickerTab.value
|
|
const response = await dataService.getAssets(100, 0, typeParam)
|
|
if (response && response.assets) {
|
|
modalAssets.value = response.assets
|
|
} else {
|
|
modalAssets.value = Array.isArray(response) ? response : []
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load modal assets', e)
|
|
modalAssets.value = []
|
|
} finally {
|
|
isModalLoading.value = false
|
|
}
|
|
}
|
|
|
|
const openAssetPicker = () => {
|
|
tempSelectedAssets.value = [...selectedAssets.value]
|
|
isAssetPickerVisible.value = true
|
|
loadModalAssets()
|
|
}
|
|
|
|
const toggleAssetSelection = (asset) => {
|
|
const index = tempSelectedAssets.value.findIndex(a => a.id === asset.id)
|
|
if (index === -1) {
|
|
tempSelectedAssets.value.push(asset)
|
|
} else {
|
|
tempSelectedAssets.value.splice(index, 1)
|
|
}
|
|
}
|
|
|
|
const confirmAssetSelection = () => {
|
|
selectedAssets.value = [...tempSelectedAssets.value]
|
|
isAssetPickerVisible.value = false
|
|
}
|
|
|
|
const removeAsset = (asset) => {
|
|
selectedAssets.value = selectedAssets.value.filter(a => a.id !== asset.id)
|
|
}
|
|
|
|
watch(assetPickerTab, () => {
|
|
if (isAssetPickerVisible.value) {
|
|
loadModalAssets()
|
|
}
|
|
})
|
|
|
|
const triggerAssetPickerUpload = () => {
|
|
assetPickerFileInput.value?.click()
|
|
}
|
|
|
|
const handleAssetPickerUpload = async (event) => {
|
|
const target = event.target
|
|
if (target.files && target.files[0]) {
|
|
const file = target.files[0]
|
|
try {
|
|
isModalLoading.value = true
|
|
await dataService.uploadAsset(file)
|
|
assetPickerTab.value = 'uploaded'
|
|
loadModalAssets()
|
|
} catch (e) {
|
|
console.error('Failed to upload asset', e)
|
|
} finally {
|
|
isModalLoading.value = false
|
|
target.value = ''
|
|
}
|
|
}
|
|
}
|
|
|
|
const useResultAsAsset = (gen) => {
|
|
if (gen.result_list && gen.result_list.length > 0) {
|
|
const resultId = gen.result_list[0]
|
|
const asset = {
|
|
id: resultId,
|
|
url: `/assets/${resultId}`,
|
|
name: 'Gen ' + gen.id.substring(0, 4)
|
|
}
|
|
// Add to existing selection instead of replacing?
|
|
// User asked "possibility to use previous generations as reference assets".
|
|
// Usually means appending or replacing. Let's append if not exists.
|
|
if (!selectedAssets.value.some(a => a.id === asset.id)) {
|
|
selectedAssets.value.push(asset)
|
|
}
|
|
isSettingsVisible.value = true
|
|
}
|
|
}
|
|
|
|
// --- Image Preview ---
|
|
const isImagePreviewVisible = ref(false)
|
|
const previewImages = ref([])
|
|
const previewIndex = ref(0)
|
|
|
|
const openImagePreview = (imageList, startIdx = 0) => {
|
|
// imageList should now be [{url, gen}]
|
|
previewImages.value = imageList
|
|
previewIndex.value = startIdx
|
|
isImagePreviewVisible.value = true
|
|
}
|
|
|
|
// --- Inspiration Logic ---
|
|
const showInspirationDialog = ref(false)
|
|
|
|
const openInspirationDialog = () => {
|
|
showInspirationDialog.value = true
|
|
}
|
|
|
|
watch(currentInspiration, async (newVal) => {
|
|
if (newVal && newVal.asset_id) {
|
|
try {
|
|
const meta = await dataService.getAssetMetadata(newVal.asset_id)
|
|
inspirationContentType.value = meta.content_type
|
|
} catch (e) {
|
|
// console.warn('Failed to get asset metadata', e)
|
|
inspirationContentType.value = 'image/jpeg'
|
|
}
|
|
}
|
|
}, { immediate: true })
|
|
|
|
// --- Computeds ---
|
|
const groupedGenerations = computed(() => {
|
|
// Group by generation_group_id if present
|
|
const groups = new Map()
|
|
const result = []
|
|
|
|
// Ensure generations.value is valid
|
|
if (!generations.value) return []
|
|
|
|
for (const gen of generations.value) {
|
|
if (gen.generation_group_id) {
|
|
if (groups.has(gen.generation_group_id)) {
|
|
const group = groups.get(gen.generation_group_id)
|
|
group.children.push(gen)
|
|
|
|
// Merge Results
|
|
if (gen.result_list && gen.result_list.length > 0) {
|
|
group.result_list = [...group.result_list, ...gen.result_list]
|
|
}
|
|
|
|
// Update Status Priority: running > failed > done
|
|
if (['processing', 'starting', 'running'].includes(gen.status)) {
|
|
group.status = 'processing'
|
|
} else if (group.status !== 'processing' && gen.status === 'failed') {
|
|
// Only set to failed if not processing.
|
|
// If all are failed, group is failed. If some done some failed, what?
|
|
// Let's say if NOT processing, and ANY failed, it shows failed?
|
|
// Or checking if ALL descendants are done/failed.
|
|
}
|
|
|
|
} else {
|
|
// Create new group wrapper based on first item
|
|
const group = {
|
|
...gen, // Copy properties of first item
|
|
isGroup: true,
|
|
children: [gen],
|
|
// ensure result_list is a new array
|
|
result_list: gen.result_list ? [...gen.result_list] : []
|
|
}
|
|
groups.set(gen.generation_group_id, group)
|
|
result.push(group)
|
|
}
|
|
} else {
|
|
result.push(gen)
|
|
}
|
|
}
|
|
|
|
// Post-process groups to determine final status if needed
|
|
result.forEach(item => {
|
|
if (item.isGroup) {
|
|
const statuses = item.children.map(c => c.status)
|
|
if (statuses.some(s => ['processing', 'starting', 'running'].includes(s))) {
|
|
item.status = 'processing'
|
|
} else if (statuses.every(s => s === 'failed')) {
|
|
item.status = 'failed'
|
|
} else {
|
|
item.status = 'done'
|
|
}
|
|
}
|
|
})
|
|
|
|
return result
|
|
})
|
|
|
|
const getChildByAssetId = (group, assetId) => {
|
|
if (!group.isGroup) return group;
|
|
return group.children.find(c => c.result_list?.includes(assetId)) || group;
|
|
}
|
|
|
|
const hasActiveGenerations = computed(() => {
|
|
return generations.value.some(g => ['processing', 'starting', 'running'].includes(g.status))
|
|
})
|
|
|
|
// Flat list of all images for gallery view
|
|
const allGalleryImages = computed(() => {
|
|
const images = []
|
|
for (const gen of generations.value) {
|
|
if (gen.result_list && gen.result_list.length > 0) {
|
|
for (const assetId of gen.result_list) {
|
|
images.push({
|
|
assetId,
|
|
url: API_URL + '/assets/' + assetId,
|
|
thumbnailUrl: API_URL + '/assets/' + assetId + '?thumbnail=true',
|
|
gen,
|
|
is_liked: gen.liked_assets?.includes(assetId)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return images
|
|
})
|
|
|
|
const deleteIdea = () => {
|
|
confirm.require({
|
|
message: 'Delete this entire idea session? All generations will remain in your history.',
|
|
header: 'Delete Idea',
|
|
icon: 'pi pi-exclamation-triangle',
|
|
acceptLabel: 'Delete',
|
|
rejectLabel: 'Cancel',
|
|
acceptClass: 'p-button-danger',
|
|
rejectClass: 'p-button-secondary p-button-text',
|
|
accept: async () => {
|
|
await ideaStore.deleteIdea(currentIdea.value.id)
|
|
router.push('/ideas')
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- Gallery Multi-Select & Download ---
|
|
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 selectAllGallery = () => {
|
|
if (selectedAssetIds.value.size === allGalleryImages.value.length) {
|
|
selectedAssetIds.value = new Set()
|
|
} else {
|
|
selectedAssetIds.value = new Set(allGalleryImages.value.map(i => i.assetId))
|
|
}
|
|
}
|
|
|
|
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 isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) || navigator.maxTouchPoints > 1
|
|
const canShare = isMobile && navigator.canShare && navigator.share
|
|
|
|
if (canShare) {
|
|
// Attempt to fetch all files and share
|
|
try {
|
|
const files = []
|
|
for (const assetId of ids) {
|
|
const url = API_URL + '/assets/' + assetId
|
|
const resp = await fetch(url, { headers })
|
|
const blob = await resp.blob()
|
|
const mime = blob.type || 'image/png'
|
|
const ext = mime.split('/')[1] || 'png'
|
|
files.push(new File([blob], `image-${assetId}.${ext}`, { type: mime }))
|
|
}
|
|
|
|
if (navigator.canShare({ files })) {
|
|
await navigator.share({ files })
|
|
toast.add({ severity: 'success', summary: 'Shared', detail: `${files.length} images shared`, life: 2000 })
|
|
return // Success, exit
|
|
}
|
|
} catch (shareError) {
|
|
console.warn('Share failed or canceled, falling back to zip if multiple', shareError)
|
|
// Fallthrough to zip/single download logic if share fails (e.g. user cancelled or limit reached)
|
|
// actually if user canceled, we probably shouldn't zip. But hard to distinguish.
|
|
// If it was a real error, zip might be better.
|
|
if (shareError.name !== 'AbortError' && ids.length > 1) {
|
|
toast.add({ severity: 'info', summary: 'Sharing failed', detail: 'Creating zip archive instead...', life: 2000 })
|
|
} else {
|
|
return // Stop if just cancelled
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: ZIP for multiple, Direct for single (if sharing skipped/failed)
|
|
if (ids.length > 1) {
|
|
const zip = new JSZip()
|
|
const safeName = (currentIdea.value?.name || 'session').replace(/[^a-z0-9_\- ]/gi, '').trim().replace(/\s+/g, '_').toLowerCase()
|
|
const folderName = `${safeName}_assets`
|
|
let successCount = 0
|
|
|
|
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 Download (Desktop or Mobile Fallback)
|
|
const assetId = ids[0]
|
|
const url = API_URL + '/assets/' + assetId
|
|
const resp = await fetch(url, { headers })
|
|
const blob = await resp.blob()
|
|
const file = new File([blob], assetId + '.png', { type: blob.type || 'image/png' })
|
|
|
|
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)
|
|
|
|
toast.add({ severity: 'success', summary: 'Downloaded', detail: `Image saved`, life: 2000 })
|
|
}
|
|
} catch (e) {
|
|
console.error('Download failed', e)
|
|
toast.add({ severity: 'error', summary: 'Error', detail: 'Download failed', 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)
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
const handleLiked = ({ id, is_liked }) => {
|
|
// Update local state in generations
|
|
generations.value.forEach(gen => {
|
|
if (gen.id === id) {
|
|
gen.is_liked = is_liked
|
|
}
|
|
})
|
|
}
|
|
|
|
const toggleLike = async (gen) => {
|
|
if (!gen || !gen.id) return
|
|
try {
|
|
const response = await dataService.toggleLike(gen.id)
|
|
handleLiked({ id: gen.id, is_liked: response.is_liked })
|
|
} catch (e) {
|
|
console.error('Failed to toggle like', e)
|
|
}
|
|
}
|
|
|
|
watch(onlyLiked, (newVal) => {
|
|
if (currentIdea.value) {
|
|
fetchGenerations(currentIdea.value.id)
|
|
}
|
|
})
|
|
|
|
// Exit select mode when switching to feed
|
|
watch(viewMode, (v) => {
|
|
if (v !== 'gallery') {
|
|
isSelectMode.value = false
|
|
selectedAssetIds.value = new Set()
|
|
}
|
|
})
|
|
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex h-full w-full bg-slate-900 text-slate-100 font-sans overflow-hidden">
|
|
<ConfirmDialog></ConfirmDialog>
|
|
|
|
<!-- Main Content (Expanded to full width) -->
|
|
<main class="flex-1 flex flex-col min-w-0 bg-slate-950/50 relative">
|
|
<!-- Header -->
|
|
<header
|
|
class="h-10 border-b border-white/5 flex items-center justify-between px-4 bg-slate-900/80 backdrop-blur z-20">
|
|
<div class="flex items-center gap-3">
|
|
<div class="flex flex-col">
|
|
<div class="flex items-center gap-2 items-center">
|
|
<div v-if="isEditingName" class="flex items-center gap-2">
|
|
<InputText v-model="editableName"
|
|
class="idea-name-input !bg-slate-800 !border-violet-500/50 !text-white !py-0.5 !h-7 !text-[16px] !font-bold"
|
|
@keyup.enter="saveName"
|
|
@blur="saveName"
|
|
/>
|
|
</div>
|
|
<h1 v-else class="text-sm font-bold !m-0 text-slate-200 truncate max-w-[150px] md:max-w-md cursor-pointer hover:text-violet-400 transition-colors"
|
|
@click="toggleEditName">
|
|
{{ currentIdea?.name || 'Loading...' }}
|
|
<i class="pi pi-pencil text-[8px] ml-1 opacity-50"></i>
|
|
</h1>
|
|
<span
|
|
class="px-1.5 py-0.5 rounded-full bg-slate-800 text-[8px] text-slate-500 border border-white/5">Idea</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<!-- View Toggle -->
|
|
<div class="flex bg-slate-800 rounded-lg p-0.5 border border-white/5">
|
|
<button @click="viewMode = 'feed'"
|
|
class="px-2 py-1 rounded-md text-[10px] font-medium transition-all items-center flex"
|
|
:class="viewMode === 'feed' ? 'bg-violet-600 text-white shadow-lg' : 'text-slate-400 hover:text-slate-200'">
|
|
<i class="pi pi-list mr-1"></i> Feed
|
|
</button>
|
|
<button @click="viewMode = 'gallery'"
|
|
class="px-2 py-1 rounded-md text-[10px] font-medium transition-all flex items-center"
|
|
:class="viewMode === 'gallery' ? 'bg-violet-600 text-white shadow-lg' : 'text-slate-400 hover:text-slate-200'">
|
|
<i class="pi pi-th-large mr-1"></i> Gallery
|
|
</button>
|
|
</div>
|
|
|
|
<Button v-if="currentInspiration" icon="pi pi-lightbulb" text rounded size="small"
|
|
class="!w-7 !h-7 !text-yellow-400 hover:!bg-yellow-400/10"
|
|
v-tooltip.bottom="'View Inspiration'"
|
|
@click="openInspirationDialog" />
|
|
|
|
<Button :icon="onlyLiked ? 'pi pi-heart-fill' : 'pi pi-heart'"
|
|
@click="onlyLiked = !onlyLiked" rounded text
|
|
class="!w-7 !h-7 !p-0"
|
|
:class="onlyLiked ? '!text-pink-500 !bg-pink-500/10' : '!text-slate-400 hover:!bg-white/10'"
|
|
v-tooltip.bottom="onlyLiked ? 'Show all' : 'Show liked only'" />
|
|
|
|
<Button icon="pi pi-trash" text rounded severity="danger" size="small"
|
|
class="!w-7 !h-7"
|
|
v-tooltip.bottom="'Delete Idea'"
|
|
@click="deleteIdea" />
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Content Area -->
|
|
<div class="flex-1 overflow-y-auto w-full p-4 md:p-8 custom-scrollbar relative"
|
|
:class="{ 'pb-[400px]': isSettingsVisible, 'pb-32': !isSettingsVisible }">
|
|
|
|
<div v-if="loadingGenerations" class="flex justify-center pt-20">
|
|
<ProgressSpinner />
|
|
</div>
|
|
|
|
<div v-else-if="generations.length === 0"
|
|
class="flex flex-col items-center justify-center h-full text-slate-500 opacity-60">
|
|
<i class="pi pi-sparkles text-6xl mb-4"></i>
|
|
<p>Start generating to populate this idea session.</p>
|
|
</div>
|
|
|
|
<!-- FEED VIEW -->
|
|
<div v-else-if="viewMode === 'feed'" class="max-w-3xl mx-auto flex flex-col gap-12 pb-[250px]">
|
|
<div v-for="gen in groupedGenerations" :key="gen.id" class="flex gap-3 animate-fade-in group">
|
|
<!-- Timeline Line -->
|
|
<div class="flex flex-col items-center pt-1">
|
|
<div class="w-2.5 h-2.5 rounded-full bg-violet-500 shadow-lg shadow-violet-500/50"></div>
|
|
<div class="w-0.5 flex-1 bg-white/5 my-2 group-last:bg-transparent"></div>
|
|
</div>
|
|
|
|
<div class="flex-1 pb-4 border-b border-white/5 last:border-0 relative">
|
|
<!-- Prompt Header -->
|
|
<div class="bg-slate-800/50 px-3 py-2 rounded-lg border border-white/5 mb-2">
|
|
<p class="text-xs text-slate-200 font-medium whitespace-pre-wrap line-clamp-2">{{
|
|
gen.prompt }}</p>
|
|
<div class="mt-2 flex items-center justify-between">
|
|
<div class="flex gap-2 text-[10px] text-slate-500">
|
|
<span>{{ new Date(gen.created_at).toLocaleTimeString([], {
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
}) }}</span>
|
|
<span v-if="gen.status"
|
|
:class="{ 'text-yellow-400': gen.status !== 'done', 'text-green-400': gen.status === 'done' }">{{
|
|
gen.status }}</span>
|
|
<span v-if="gen.isGroup" class="text-violet-400 font-bold ml-2">
|
|
{{ gen.result_list.length }} images
|
|
</span>
|
|
</div>
|
|
<div
|
|
class="flex gap-1 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<Button :icon="gen.is_liked ? 'pi pi-heart-fill' : 'pi pi-heart'" text rounded size="small"
|
|
class="!w-7 !h-7 !p-0"
|
|
:class="gen.is_liked ? '!text-pink-500' : '!text-slate-400 hover:!text-pink-500'"
|
|
v-tooltip.top="gen.is_liked ? 'Unlike' : 'Like'" @click="toggleLike(gen)" />
|
|
<Button icon="pi pi-copy" text rounded size="small"
|
|
class="!w-7 !h-7 !text-slate-400 hover:!text-white"
|
|
v-tooltip.top="'Reuse Prompt'" @click="reusePrompt(gen)" />
|
|
<Button v-if="gen.assets_list && gen.assets_list.length > 0" icon="pi pi-link"
|
|
text rounded size="small"
|
|
class="!w-7 !h-7 !text-slate-400 hover:!text-violet-400"
|
|
v-tooltip.top="'Reuse Assets'" @click="reuseAssets(gen)" />
|
|
<Button icon="pi pi-trash" text rounded size="small"
|
|
class="!w-7 !h-7 !text-slate-400 hover:!text-red-400"
|
|
v-tooltip.top="'Delete'" @click="deleteGeneration(gen)" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Images Grid -->
|
|
<div class="relative rounded-xl overflow-hidden bg-slate-900 border border-white/5">
|
|
<div v-if="gen.result_list && gen.result_list.length > 0" class="grid gap-0.5"
|
|
:class="gen.result_list.length === 1 ? 'grid-cols-1' : gen.result_list.length === 2 ? 'grid-cols-2' : 'grid-cols-3'">
|
|
<div v-for="(res, resIdx) in gen.result_list" :key="res"
|
|
class="relative group/img cursor-pointer aspect-[4/3]">
|
|
<img :src="API_URL + '/assets/' + res + '?thumbnail=true'"
|
|
class="w-full h-full object-cover"
|
|
@click="openImagePreview(gen.result_list.map(r => ({ url: API_URL + '/assets/' + r, gen: getChildByAssetId(gen, r), assetId: r, is_liked: getChildByAssetId(gen, r).is_liked })), resIdx)" />
|
|
|
|
<!-- Liked indicator -->
|
|
<div v-if="getChildByAssetId(gen, res).is_liked"
|
|
class="absolute top-1.5 left-1.5 w-5 h-5 rounded-full bg-pink-500 shadow-lg flex items-center justify-center border border-pink-400 z-10">
|
|
<i class="pi pi-heart-fill text-white text-[8px]"></i>
|
|
</div>
|
|
|
|
<!-- Per-image hover overlay -->
|
|
<div
|
|
class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover/img:opacity-100 transition-opacity duration-200 pointer-events-none">
|
|
</div>
|
|
<div
|
|
class="absolute bottom-1 right-1 flex gap-1 opacity-0 group-hover/img:opacity-100 transition-opacity duration-200">
|
|
<button @click.stop="toggleLike(getChildByAssetId(gen, res))"
|
|
class="w-6 h-6 rounded-md backdrop-blur-sm flex items-center justify-center text-white transition-all hover:scale-110 shadow-lg"
|
|
:class="getChildByAssetId(gen, res).is_liked ? 'bg-pink-500 hover:bg-pink-400' : 'bg-slate-700/80 hover:bg-pink-500'"
|
|
v-tooltip.top="getChildByAssetId(gen, res).is_liked ? 'Unlike' : 'Like'">
|
|
<i :class="getChildByAssetId(gen, res).is_liked ? 'pi pi-heart-fill' : 'pi pi-heart'" style="font-size: 10px"></i>
|
|
</button>
|
|
<button @click.stop="setAsReference(res)"
|
|
class="w-6 h-6 rounded-md bg-violet-600/80 hover:bg-violet-500 backdrop-blur-sm flex items-center justify-center text-white transition-all hover:scale-110 shadow-lg"
|
|
v-tooltip.top="'Use as Reference'">
|
|
<i class="pi pi-pencil" style="font-size: 10px"></i>
|
|
</button>
|
|
<button @click.stop="deleteAssetFromGeneration(getChildByAssetId(gen, res), res)"
|
|
class="w-6 h-6 rounded-md bg-red-600/80 hover:bg-red-500 backdrop-blur-sm flex items-center justify-center text-white transition-all hover:scale-110 shadow-lg"
|
|
v-tooltip.top="'Remove Image'">
|
|
<i class="pi pi-trash" style="font-size: 10px"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else-if="['processing', 'starting', 'running'].includes(gen.status)"
|
|
class="h-32 flex items-center justify-center">
|
|
<ProgressSpinner style="width: 30px; height: 30px" />
|
|
</div>
|
|
<div v-else
|
|
class="h-32 flex items-center justify-center text-red-400 bg-red-500/5 text-xs">
|
|
<span>Generation Failed</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- GALLERY VIEW -->
|
|
<div v-else>
|
|
<!-- Gallery toolbar -->
|
|
<div class="flex items-center justify-between mb-4 gap-2">
|
|
<div class="flex items-center gap-2">
|
|
<button @click="toggleSelectMode"
|
|
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-all border"
|
|
:class="isSelectMode ? 'bg-violet-600 text-white border-violet-500' : 'bg-slate-800 text-slate-400 border-white/10 hover:text-white hover:border-white/20'">
|
|
<i class="pi pi-check-square mr-1"></i>
|
|
{{ isSelectMode ? 'Cancel' : 'Select' }}
|
|
</button>
|
|
<button v-if="isSelectMode" @click="selectAllGallery"
|
|
class="px-3 py-1.5 rounded-lg text-xs font-medium bg-slate-800 text-slate-400 border border-white/10 hover:text-white hover:border-white/20 transition-all">
|
|
{{ selectedAssetIds.size === allGalleryImages.length ? 'Deselect All' : 'Select All' }}
|
|
</button>
|
|
</div>
|
|
<span v-if="isSelectMode" class="text-xs text-slate-400">
|
|
{{ selectedAssetIds.size }} selected
|
|
</span>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
|
<!-- Active generations (spinners) -->
|
|
<div v-for="gen in generations.filter(g => ['processing', 'starting', 'running'].includes(g.status) && (!g.result_list || g.result_list.length === 0))"
|
|
:key="'active-' + gen.id"
|
|
class="aspect-[2/3] relative rounded-xl overflow-hidden bg-slate-800 border border-white/5 flex items-center justify-center">
|
|
<ProgressSpinner style="width: 30px; height: 30px" />
|
|
</div>
|
|
|
|
<!-- Failed generations -->
|
|
<div v-for="gen in generations.filter(g => g.status === 'failed' && (!g.result_list || g.result_list.length === 0))"
|
|
:key="'failed-' + gen.id"
|
|
class="aspect-[2/3] relative rounded-xl overflow-hidden bg-red-950/30 border border-red-500/20 flex flex-col items-center justify-center gap-2 group">
|
|
<i class="pi pi-exclamation-triangle text-red-400 text-2xl"></i>
|
|
<span class="text-red-400 text-xs font-medium">Failed</span>
|
|
<p class="text-[10px] text-red-300/60 px-3 text-center line-clamp-2">{{ gen.prompt }}</p>
|
|
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<Button icon="pi pi-trash" rounded text size="small"
|
|
class="!text-red-400 hover:!bg-red-500/20" @click.stop="deleteGeneration(gen)" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- All result images -->
|
|
<div v-for="(img, idx) in allGalleryImages" :key="img.assetId"
|
|
class="aspect-[2/3] relative rounded-xl overflow-hidden group bg-slate-800 border-2 transition-all cursor-pointer"
|
|
:class="isSelectMode && selectedAssetIds.has(img.assetId) ? 'border-violet-500 ring-2 ring-violet-500/30' : 'border-white/5 hover:border-violet-500/50'"
|
|
@click="isSelectMode ? toggleImageSelection(img.assetId) : openImagePreview(allGalleryImages, idx)">
|
|
|
|
<img :src="img.thumbnailUrl" class="w-full h-full object-cover" />
|
|
|
|
<!-- Liked Badge -->
|
|
<div v-if="img.is_liked"
|
|
class="absolute top-2 right-2 z-10 w-6 h-6 rounded-full bg-pink-500 shadow-lg flex items-center justify-center border border-pink-400">
|
|
<i class="pi pi-heart-fill text-white text-[10px]"></i>
|
|
</div>
|
|
|
|
<!-- Selection checkmark (always visible in select mode) -->
|
|
<div v-if="isSelectMode"
|
|
class="absolute top-2 left-2 w-7 h-7 rounded-full flex items-center justify-center transition-all shadow-lg z-10"
|
|
:class="selectedAssetIds.has(img.assetId) ? 'bg-violet-500' : 'bg-black/40 border border-white/30'">
|
|
<i v-if="selectedAssetIds.has(img.assetId)" class="pi pi-check text-white text-xs"></i>
|
|
</div>
|
|
|
|
<!-- Hover overlay (only in non-select mode) -->
|
|
<div v-if="!isSelectMode"
|
|
class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-end p-3">
|
|
<p class="text-xs text-white line-clamp-2 mb-2">{{ img.gen.prompt }}</p>
|
|
<div class="flex gap-2 justify-end">
|
|
<Button :icon="img.gen.is_liked ? 'pi pi-heart-fill' : 'pi pi-heart'" rounded text size="small"
|
|
class="!text-white transition-colors"
|
|
:class="img.gen.is_liked ? '!text-pink-500' : 'hover:!text-pink-500 hover:!bg-white/20'"
|
|
v-tooltip.top="img.gen.is_liked ? 'Unlike' : 'Like'"
|
|
@click.stop="toggleLike(img.gen)" />
|
|
<Button icon="pi pi-pencil" rounded text size="small"
|
|
class="!text-white hover:!bg-white/20" v-tooltip.top="'Use as Reference'"
|
|
@click.stop="setAsReference(img.assetId)" />
|
|
<Button icon="pi pi-refresh" rounded text size="small"
|
|
class="!text-white hover:!bg-white/20" @click.stop="reusePrompt(img.gen)" />
|
|
<Button icon="pi pi-trash" rounded text size="small"
|
|
class="!text-red-400 hover:!bg-red-500/20"
|
|
@click.stop="deleteAssetFromGeneration(img.gen, img.assetId)" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Floating download bar -->
|
|
<transition name="fade">
|
|
<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" />
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- SETTINGS PANEL (Bottom) -->
|
|
<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">
|
|
<div class="w-12 h-1 bg-white/20 rounded-full hover:bg-white/40 transition-colors"></div>
|
|
</div>
|
|
|
|
<div class="flex flex-col lg:flex-row gap-3">
|
|
<div class="flex-1 flex flex-col gap-2">
|
|
<div class="flex flex-col gap-1">
|
|
<div class="flex justify-between items-center">
|
|
<label
|
|
class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Prompt</label>
|
|
<div class="flex gap-1">
|
|
<Button v-if="previousPrompt" icon="pi pi-undo"
|
|
class="!p-0.5 !w-5 !h-5 !text-[9px] !bg-slate-800 hover:!bg-slate-700 !border-white/10 !text-slate-300"
|
|
@click="undoImprovePrompt" v-tooltip.top="'Undo'" />
|
|
<Button icon="pi pi-sparkles" label="Improve" :loading="isImprovingPrompt"
|
|
:disabled="!prompt || prompt.length <= 10"
|
|
class="!py-0 !px-1.5 !text-[9px] !h-5 !bg-violet-600/20 hover:!bg-violet-600/30 !border-violet-500/30 !text-violet-400 disabled:opacity-50"
|
|
@click="handleImprovePrompt" />
|
|
<Button icon="pi pi-clipboard" label="Paste"
|
|
class="!py-0 !px-1.5 !text-[9px] !h-5 !bg-slate-800 hover:!bg-slate-700 !border-white/10 !text-slate-400"
|
|
@click="pastePrompt" />
|
|
<Button icon="pi pi-times" label="Clear"
|
|
class="!py-0 !px-1.5 !text-[9px] !h-5 !bg-slate-800 hover:!bg-slate-700 !border-white/10 !text-slate-400"
|
|
@click="clearPrompt" />
|
|
</div>
|
|
</div>
|
|
<Textarea v-model="prompt" rows="2" placeholder="Describe what you want to create..."
|
|
class="w-full !h-28 bg-slate-800 !text-[16px] border-white/10 text-white rounded-lg p-2 focus:border-violet-500 focus:ring-1 focus:ring-violet-500/50 transition-all resize-none shadow-inner" />
|
|
</div>
|
|
|
|
<div class="flex flex-col md:flex-row gap-2">
|
|
<div class="flex-1 flex flex-col gap-1">
|
|
<label
|
|
class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Character</label>
|
|
<Dropdown v-model="selectedCharacter" :options="characters" optionLabel="name"
|
|
placeholder="Select Character" filter showClear
|
|
class="w-full !bg-slate-800 !border-white/10 !text-white !rounded-xl" :pt="{
|
|
root: { class: '!bg-slate-800' },
|
|
input: { class: '!text-white' },
|
|
trigger: { class: '!text-slate-400' },
|
|
panel: { class: '!bg-slate-800 !border-white/10' },
|
|
item: { class: '!text-slate-300 hover:!bg-white/10 hover:!text-white' }
|
|
}">
|
|
<template #value="slotProps">
|
|
<div v-if="slotProps.value" class="flex items-center gap-2">
|
|
<img v-if="slotProps.value.avatar_image"
|
|
:src="API_URL + slotProps.value.avatar_image"
|
|
class="w-6 h-6 rounded-full object-cover" />
|
|
<span>{{ slotProps.value.name }}</span>
|
|
</div>
|
|
<span v-else>{{ slotProps.placeholder }}</span>
|
|
</template>
|
|
<template #option="slotProps">
|
|
<div class="flex items-center gap-2">
|
|
<img v-if="slotProps.option.avatar_image"
|
|
:src="API_URL + slotProps.option.avatar_image"
|
|
class="w-8 h-8 rounded-full object-cover" />
|
|
<span>{{ slotProps.option.name }}</span>
|
|
</div>
|
|
</template>
|
|
</Dropdown>
|
|
|
|
<div v-if="selectedCharacter"
|
|
class="flex flex-wrap items-center gap-x-4 gap-y-2 mt-2 px-1 animate-in fade-in slide-in-from-top-1">
|
|
<div class="flex items-center gap-2">
|
|
<Checkbox v-model="useProfileImage" :binary="true" inputId="idea-use-profile-img"
|
|
class="!border-white/20" :pt="{
|
|
box: ({ props, state }) => ({
|
|
class: ['!bg-slate-800 !border-white/20', { '!bg-violet-600 !border-violet-600': props.modelValue }]
|
|
})
|
|
}" />
|
|
<label for="idea-use-profile-img"
|
|
class="text-xs text-slate-300 cursor-pointer select-none">Use Photo</label>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<Checkbox v-model="useEnvironment" :binary="true" inputId="idea-use-env"
|
|
class="!border-white/20" :pt="{
|
|
box: ({ props, state }) => ({
|
|
class: ['!bg-slate-800 !border-white/20', { '!bg-violet-600 !border-violet-600': props.modelValue }]
|
|
})
|
|
}" />
|
|
<label for="idea-use-env"
|
|
class="text-xs text-slate-300 cursor-pointer select-none">Use Environment</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex-1 flex flex-col gap-1">
|
|
<label
|
|
class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Assets</label>
|
|
<div @click="openAssetPicker"
|
|
class="w-full bg-slate-800 border border-white/10 rounded-lg p-2 min-h-[38px] cursor-pointer hover:bg-slate-700/50 transition-colors flex flex-wrap gap-1.5">
|
|
<span v-if="selectedAssets.length === 0"
|
|
class="text-slate-400 text-sm py-0.5">Select
|
|
Assets</span>
|
|
<div v-for="asset in selectedAssets" :key="asset.id"
|
|
class="px-2 py-1 bg-violet-600/30 border border-violet-500/30 text-violet-200 text-xs rounded-md flex items-center gap-2 animate-in fade-in zoom-in duration-200"
|
|
@click.stop>
|
|
<img v-if="asset.url" :src="API_URL + asset.url + '?thumbnail=true'"
|
|
class="w-4 h-4 rounded object-cover" />
|
|
<span class="truncate max-w-[100px]">{{ asset.name || 'Asset ' + (asset.id ?
|
|
asset.id.substring(0, 4) : '') }}</span>
|
|
<i class="pi pi-times cursor-pointer hover:text-white"
|
|
@click.stop="removeAsset(asset)"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Environment Row (Below) -->
|
|
<div v-if="selectedCharacter && useEnvironment" class="flex-1 flex flex-col gap-1 animate-in fade-in slide-in-from-top-1 mt-2">
|
|
<div class="flex justify-between items-center">
|
|
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Environment</label>
|
|
<Button v-if="selectedEnvironment" icon="pi pi-times" @click="selectedEnvironment = null" text size="small"
|
|
class="!p-0 !h-4 !w-4 !text-[8px] text-slate-500 hover:text-white" />
|
|
</div>
|
|
|
|
<div v-if="environments.length > 0" class="flex gap-2 overflow-x-auto pb-1 custom-scrollbar no-scrollbar">
|
|
<div v-for="env in environments" :key="env.id || env._id"
|
|
@click="selectedEnvironment = env"
|
|
class="flex-shrink-0 flex items-center gap-2 px-2 py-1.5 rounded-lg border-2 transition-all cursor-pointer group bg-slate-800/40"
|
|
:class="[
|
|
(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id))
|
|
? 'border-violet-500 bg-violet-500/10 shadow-[0_0_15px_rgba(124,58,237,0.1)]'
|
|
: 'border-white/5 hover:border-white/20'
|
|
]"
|
|
>
|
|
<div class="w-6 h-6 rounded overflow-hidden flex-shrink-0 bg-slate-900 flex items-center justify-center border border-white/5">
|
|
<img v-if="env.asset_ids?.length > 0"
|
|
:src="API_URL + '/assets/' + env.asset_ids[0] + '?thumbnail=true'"
|
|
class="w-full h-full object-cover"
|
|
/>
|
|
<i v-else class="pi pi-map-marker text-[10px]"
|
|
:class="(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id)) ? 'text-violet-400' : 'text-slate-500'"
|
|
></i>
|
|
</div>
|
|
<span class="text-[10px] whitespace-nowrap pr-1"
|
|
:class="(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id)) ? 'text-violet-300 font-bold' : 'text-slate-400 group-hover:text-slate-200'"
|
|
>
|
|
{{ env.name }}
|
|
</span>
|
|
<i v-if="(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id))"
|
|
class="pi pi-check text-violet-400 text-[8px]"></i>
|
|
</div>
|
|
</div>
|
|
<div v-else class="py-2 px-3 bg-slate-800/50 border border-white/5 rounded-xl text-center">
|
|
<p class="text-[9px] text-slate-600 uppercase m-0">No environments</p>
|
|
</div>
|
|
</div> </div>
|
|
|
|
<div class="w-full lg:w-72 flex flex-col gap-2">
|
|
<div class="grid grid-cols-2 gap-2">
|
|
<div class="flex flex-col gap-1">
|
|
<label
|
|
class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Quality</label>
|
|
<Dropdown v-model="quality" :options="qualityOptions" optionLabel="value"
|
|
class="w-full !bg-slate-800 !border-white/10 !text-white !rounded-xl"
|
|
:pt="{ input: { class: '!text-white' }, trigger: { class: '!text-slate-400' }, panel: { class: '!bg-slate-800 !border-white/10' }, item: { class: '!text-slate-300 hover:!bg-white/10 hover:!text-white' } }" />
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<label
|
|
class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Format</label>
|
|
<div class="flex items-center">
|
|
<div class="flex-1 flex bg-slate-800 rounded-lg border border-white/10 overflow-hidden">
|
|
<button @click="aspectRatio = 'THREEFOUR'"
|
|
class="flex-1 py-1 text-xs font-bold transition-all flex items-center justify-center gap-1"
|
|
:class="aspectRatio === 'THREEFOUR' ? 'bg-violet-600 text-white' : 'text-slate-400 hover:text-white hover:bg-white/5'">
|
|
<i class="pi pi-image"></i> Photo
|
|
</button>
|
|
<div class="w-px bg-white/10"></div>
|
|
<button @click="aspectRatio = 'NINESIXTEEN'"
|
|
class="flex-1 py-1 text-xs font-bold transition-all flex items-center justify-center gap-1"
|
|
:class="aspectRatio === 'NINESIXTEEN' ? 'bg-violet-600 text-white' : 'text-slate-400 hover:text-white hover:bg-white/5'">
|
|
<i class="pi pi-video"></i> Video
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Generation Count -->
|
|
<div class="flex flex-col gap-1">
|
|
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Count</label>
|
|
<div class="flex items-center">
|
|
<div class="flex-1 flex bg-slate-800 rounded-lg border border-white/10 overflow-hidden">
|
|
<button v-for="n in 4" :key="n" @click="imageCount = n"
|
|
class="flex-1 py-1 text-xs font-bold transition-all"
|
|
:class="imageCount === n ? 'bg-violet-600 text-white' : 'text-slate-400 hover:text-white hover:bg-white/5'">
|
|
{{ n }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-1 bg-slate-800/50 p-2 rounded-lg border border-white/5">
|
|
<div class="flex items-center gap-2">
|
|
<Checkbox v-model="sendToTelegram" :binary="true" inputId="idea-tg-check" />
|
|
<label for="idea-tg-check" class="text-xs text-slate-300 cursor-pointer">Send to
|
|
Telegram</label>
|
|
</div>
|
|
<div v-if="sendToTelegram" class="animate-in fade-in slide-in-from-top-1">
|
|
<InputText v-model="telegramId" placeholder="Telegram ID"
|
|
class="w-full !text-[16px] !bg-slate-900 !border-white/10 !text-white !py-1.5" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- NSFW Toggle -->
|
|
<div class="flex flex-col gap-1 bg-slate-800/50 p-2 rounded-lg border border-white/5">
|
|
<div class="flex items-center justify-between">
|
|
<label class="text-xs text-slate-300 cursor-pointer">Show NSFW</label>
|
|
<InputSwitch v-model="showNsfwGlobal" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-auto">
|
|
<Button :label="isSubmitting ? 'Starting...' : 'Generate'"
|
|
:icon="isSubmitting ? 'pi pi-spin pi-spinner' : 'pi pi-sparkles'"
|
|
:loading="isSubmitting" :disabled="isSubmitting" @click="handleGenerate"
|
|
class="w-full !py-2 !text-sm !font-bold !bg-gradient-to-r from-violet-600 to-cyan-500 !border-none !rounded-lg !shadow-lg !shadow-violet-500/20 hover:!scale-[1.02] transition-all disabled:opacity-50 disabled:cursor-not-allowed" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<transition name="fade">
|
|
<div v-if="!isSettingsVisible" class="absolute bottom-24 md:bottom-8 left-1/2 -translate-x-1/2 z-10">
|
|
<Button label="Open Controls" icon="pi pi-chevron-up" @click="isSettingsVisible = true" rounded
|
|
class="!bg-violet-600 !border-none !shadow-xl !font-bold shadow-violet-500/40 !px-6 !py-3" />
|
|
</div>
|
|
</transition>
|
|
|
|
</main>
|
|
|
|
<Dialog v-model:visible="isAssetPickerVisible" modal header="Select Assets"
|
|
:style="{ width: '80vw', maxWidth: '900px' }"
|
|
:pt="{ root: { class: '!bg-slate-900 !border !border-white/10' }, header: { class: '!bg-slate-900 !border-b !border-white/5 !text-white' }, content: { class: '!bg-slate-900 !p-0' }, footer: { class: '!bg-slate-900 !border-t !border-white/5 !p-4' }, closeButton: { class: '!text-slate-400 hover:!text-white' } }">
|
|
<div class="flex flex-col h-[70vh]">
|
|
<!-- Tabs -->
|
|
<div class="flex border-b border-white/5 px-4 items-center">
|
|
<button v-for="tab in ['all', 'uploaded', 'generated']" :key="tab" @click="assetPickerTab = tab"
|
|
class="px-4 py-3 text-sm font-medium border-b-2 transition-colors capitalize"
|
|
:class="assetPickerTab === tab ? 'border-violet-500 text-violet-400' : 'border-transparent text-slate-400 hover:text-slate-200'">
|
|
{{ tab }}
|
|
</button>
|
|
<!-- Upload Action -->
|
|
<div class="ml-auto flex items-center">
|
|
<input type="file" ref="assetPickerFileInput" @change="handleAssetPickerUpload" class="hidden"
|
|
accept="image/*" />
|
|
<Button icon="pi pi-upload" label="Upload" @click="triggerAssetPickerUpload"
|
|
class="!text-xs !bg-violet-600/20 !text-violet-300 hover:!bg-violet-600/40 !border-none !px-3 !py-1.5 !rounded-lg" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Grid -->
|
|
<div class="flex-1 overflow-y-auto p-4 custom-scrollbar">
|
|
<div v-if="isModalLoading" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
|
<Skeleton v-for="i in 10" :key="i" height="150px" class="!bg-slate-800 rounded-xl" />
|
|
</div>
|
|
<div v-else-if="modalAssets.length > 0"
|
|
class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
|
<div v-for="asset in modalAssets" :key="asset.id" @click="toggleAssetSelection(asset)"
|
|
class="relative aspect-square rounded-xl overflow-hidden cursor-pointer border-2 transition-all group"
|
|
:class="tempSelectedAssets.some(a => a.id === asset.id) ? 'border-violet-500 ring-2 ring-violet-500/30' : 'border-transparent hover:border-white/20'">
|
|
<img :src="API_URL + asset.url + '?thumbnail=true'" class="w-full h-full object-cover" />
|
|
<div class="absolute bottom-0 left-0 right-0 p-2 bg-black/60 backdrop-blur-sm">
|
|
<p class="text-[10px] text-white truncate">{{ asset.name || 'Asset ' + (asset.id ?
|
|
asset.id.substring(0, 4) : '') }}</p>
|
|
</div>
|
|
<!-- Checkmark -->
|
|
<div v-if="tempSelectedAssets.some(a => a.id === asset.id)"
|
|
class="absolute top-2 right-2 w-6 h-6 bg-violet-500 rounded-full flex items-center justify-center shadow-lg animate-in zoom-in duration-200">
|
|
<i class="pi pi-check text-white text-xs"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="flex flex-col items-center justify-center h-full text-slate-500">
|
|
<i class="pi pi-image text-4xl mb-2 opacity-50"></i>
|
|
<p>No assets found</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<div class="flex justify-end gap-2">
|
|
<Button label="Cancel" @click="isAssetPickerVisible = false"
|
|
class="!text-slate-300 hover:!bg-white/5" text />
|
|
<Button :label="'Select (' + tempSelectedAssets.length + ')'" @click="confirmAssetSelection"
|
|
class="!bg-violet-600 !border-none hover:!bg-violet-500" />
|
|
</div>
|
|
</template>
|
|
</Dialog>
|
|
|
|
<GenerationPreviewModal
|
|
v-model:visible="isImagePreviewVisible"
|
|
:preview-images="previewImages"
|
|
:initial-index="previewIndex"
|
|
:api-url="dataService.API_URL || API_URL"
|
|
@reuse-prompt="reusePrompt"
|
|
@reuse-asset="reuseAssets"
|
|
@use-result-as-asset="useResultAsAsset"
|
|
@liked="handleLiked"
|
|
/>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- Inspiration Dialog -->
|
|
<Dialog v-model:visible="showInspirationDialog" modal header="Inspiration"
|
|
:style="{ width: '90vw', maxWidth: '1000px', height: '90vh' }"
|
|
:pt="{ root: { class: '!bg-slate-900 !border !border-white/10 flex flex-col' }, header: { class: '!bg-slate-900 !border-b !border-white/5 !text-white flex-shrink-0' }, content: { class: '!bg-slate-900 !p-4 flex-1 overflow-hidden flex flex-col' } }">
|
|
<div v-if="currentInspiration" class="flex flex-col gap-4 h-full">
|
|
<!-- Asset Viewer -->
|
|
<div v-if="currentInspiration.asset_id" class="flex-1 min-h-0 rounded-xl overflow-hidden border border-white/10 bg-black/20 flex items-center justify-center">
|
|
<video v-if="inspirationContentType && inspirationContentType.startsWith('video/')"
|
|
:src="API_URL + '/assets/' + currentInspiration.asset_id"
|
|
controls autoplay loop muted
|
|
class="max-w-full max-h-full object-contain" />
|
|
<img v-else
|
|
:src="API_URL + '/assets/' + currentInspiration.asset_id"
|
|
class="max-w-full max-h-full object-contain" />
|
|
</div>
|
|
|
|
<!-- Caption -->
|
|
<div v-if="currentInspiration.caption" class="flex flex-col gap-2 flex-shrink-0">
|
|
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Caption</label>
|
|
<div class="max-h-[150px] overflow-y-auto custom-scrollbar bg-slate-800/50 p-3 rounded-lg border border-white/5">
|
|
<p class="text-sm text-slate-200 whitespace-pre-wrap">{{ currentInspiration.caption }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="flex justify-end mt-2 flex-shrink-0 gap-2">
|
|
<Button label="Close" text @click="showInspirationDialog = false" class="!text-slate-400 hover:!text-white" />
|
|
<a v-if="currentInspiration.source_url" :href="currentInspiration.source_url" target="_blank" rel="noopener noreferrer">
|
|
<Button label="Go to Source" icon="pi pi-external-link"
|
|
class="!bg-violet-600 !border-none hover:!bg-violet-500" />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
<div v-else class="flex justify-center py-8">
|
|
<ProgressSpinner />
|
|
</div>
|
|
</Dialog>
|
|
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.glass-panel {
|
|
background: rgba(255, 255, 255, 0.03);
|
|
backdrop-filter: blur(10px);
|
|
-webkit-backdrop-filter: blur(10px);
|
|
}
|
|
|
|
.custom-scrollbar::-webkit-scrollbar {
|
|
width: 6px;
|
|
}
|
|
|
|
.custom-scrollbar::-webkit-scrollbar-track {
|
|
background: rgba(255, 255, 255, 0.02);
|
|
}
|
|
|
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border-radius: 10px;
|
|
}
|
|
|
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
|
background: rgba(255, 255, 255, 0.2);
|
|
}
|
|
|
|
.animate-fade-in {
|
|
animation: fadeIn 0.5s ease-out forwards;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(10px);
|
|
}
|
|
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
.slide-up-enter-active,
|
|
.slide-up-leave-active {
|
|
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
}
|
|
|
|
.slide-up-enter-from,
|
|
.slide-up-leave-to {
|
|
transform: translateY(100%);
|
|
opacity: 0;
|
|
}
|
|
|
|
.fade-enter-active,
|
|
.fade-leave-active {
|
|
transition: opacity 0.3s ease;
|
|
}
|
|
|
|
.fade-enter-from,
|
|
.fade-leave-to {
|
|
opacity: 0;
|
|
}
|
|
</style>
|