Files
ai-service-front/src/views/FlexibleGenerationView.vue
2026-02-26 12:30:58 +03:00

1431 lines
66 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import {computed, onBeforeUnmount, onMounted, ref, watch} 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 Tag from 'primevue/tag'
import InputSwitch from 'primevue/inputswitch'
import Skeleton from 'primevue/skeleton'
import {useAlbumStore} from '../stores/albums'
import {useToast} from 'primevue/usetoast'
import Toast from 'primevue/toast'
import GenerationPreviewModal from '../components/GenerationPreviewModal.vue'
import GenerationImage from '../components/GenerationImage.vue'
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('')
const selectedCharacter = ref(null)
const environments = ref([])
const selectedEnvironment = ref(null)
const selectedAssets = ref([])
// Album Picker State
const isAlbumPickerVisible = ref(false)
const generationToAdd = ref(null)
const selectedAlbumForAdd = ref(null)
// Asset Picker State
const isAssetPickerVisible = ref(false)
const assetPickerTab = ref('all') // 'all', 'uploaded', 'generated'
const modalAssets = ref([])
const isModalLoading = ref(false)
const tempSelectedAssets = ref([])
const quality = ref({ key: 'TWOK', value: '2K' })
const aspectRatio = ref('NINESIXTEEN') // Default to Video (9:16)
const generationCount = ref(1)
const sendToTelegram = ref(false)
const telegramId = ref('')
const isTelegramIdSaved = ref(false)
const useProfileImage = ref(true)
const useEnvironment = ref(false)
const isImprovingPrompt = ref(false)
const previousPrompt = ref('')
let _savedEnvironmentId = null
// NSFW Toggle
const showNsfwGlobal = ref(false)
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)
})
const characters = ref([])
const allAssets = ref([])
const historyGenerations = ref([])
const historyTotal = ref(0)
const historyRows = ref(50)
const historyFirst = ref(0)
const isSettingsVisible = ref(localStorage.getItem('flexible_gen_settings_visible') !== 'false')
const isSubmitting = ref(false)
watch(isSettingsVisible, (val) => {
localStorage.setItem('flexible_gen_settings_visible', val)
})
const activeOverlayId = ref(null) // For mobile tap-to-show overlay
const filterCharacter = ref(null) // Character filter for gallery
const onlyLiked = ref(false)
// Options
const qualityOptions = ref([
{ key: 'ONEK', value: '1K' },
{ key: 'TWOK', value: '2K' },
{ key: 'FOURK', value: '4K' }
])
// --- Persistence ---
const STORAGE_KEY = 'flexible_gen_settings'
const saveSettings = () => {
const settings = {
prompt: prompt.value,
selectedCharacterId: selectedCharacter.value?.id || selectedCharacter.value?._id,
selectedEnvironmentId: selectedEnvironment.value?.id || selectedEnvironment.value?._id,
selectedAssetIds: selectedAssets.value.map(a => a.id),
quality: quality.value,
aspectRatio: aspectRatio.value,
sendToTelegram: sendToTelegram.value,
telegramId: telegramId.value,
useProfileImage: useProfileImage.value,
useEnvironment: useEnvironment.value,
generationCount: generationCount.value
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
// Also save Telegram ID separately as it's used elsewhere
if (telegramId.value) {
localStorage.setItem('telegram_id', telegramId.value)
isTelegramIdSaved.value = true
}
}
const restoreSettings = () => {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
try {
const settings = JSON.parse(stored)
prompt.value = settings.prompt || ''
// We need characters and assets loaded to fully restore objects
// For now, we'll store IDs and restore in loadData
if (settings.quality) quality.value = settings.quality
if (settings.aspectRatio) {
// Handle legacy object format if present
if (typeof settings.aspectRatio === 'object' && settings.aspectRatio.key) {
aspectRatio.value = settings.aspectRatio.key
} else {
aspectRatio.value = settings.aspectRatio
}
}
sendToTelegram.value = settings.sendToTelegram || false
telegramId.value = settings.telegramId || localStorage.getItem('telegram_id') || ''
if (telegramId.value) isTelegramIdSaved.value = true
if (settings.useProfileImage !== undefined) useProfileImage.value = settings.useProfileImage
if (settings.useEnvironment !== undefined) useEnvironment.value = settings.useEnvironment
if (settings.generationCount) generationCount.value = Math.min(settings.generationCount, 4)
if (settings.selectedEnvironmentId) _savedEnvironmentId = settings.selectedEnvironmentId
return settings // Return to use in loadData
} catch (e) {
console.error('Failed to parse settings', e)
}
}
return null
}
// Watchers for auto-save
watch([prompt, selectedCharacter, selectedEnvironment, selectedAssets, quality, aspectRatio, sendToTelegram, telegramId, useProfileImage, useEnvironment, generationCount], () => {
saveSettings()
}, { deep: true })
// Watcher for character filter — reload history when filter changes
watch(filterCharacter, async () => {
historyGenerations.value = []
historyTotal.value = 0
historyFirst.value = 0
await refreshHistory()
})
watch(onlyLiked, async () => {
historyGenerations.value = []
historyTotal.value = 0
historyFirst.value = 0
await refreshHistory()
})
// --- Data Loading ---
const loadData = async () => {
try {
const [charsRes, assetsRes, historyRes] = await Promise.all([
dataService.getCharacters(), // Assuming this exists and returns list
dataService.getAssets(100, 0, 'all'), // Load a batch of assets
aiService.getGenerations(historyRows.value, historyFirst.value, filterCharacter.value?.id || filterCharacter.value?._id)
])
// Characters
characters.value = charsRes || []
// Assets
if (assetsRes && assetsRes.assets) {
allAssets.value = assetsRes.assets
} else {
allAssets.value = Array.isArray(assetsRes) ? assetsRes : []
}
// History
if (historyRes && historyRes.generations) {
historyGenerations.value = historyRes.generations
historyTotal.value = historyRes.total_count || 0
// Resume polling for unfinished generations
// Resume polling for unfinished generations
const threeMinutesAgo = Date.now() - 3 * 60 * 1000
historyGenerations.value.forEach(gen => {
const status = gen.status ? gen.status.toLowerCase() : ''
const isActive = ['processing', 'starting', 'running'].includes(status)
let isRecent = true
if (gen.created_at) {
// Force UTC if missing timezone info (simple heuristic)
let timeStr = gen.created_at
if (timeStr.indexOf('Z') === -1 && timeStr.indexOf('+') === -1) {
timeStr += 'Z'
}
const createdTime = new Date(timeStr).getTime()
if (!isNaN(createdTime)) {
isRecent = createdTime > threeMinutesAgo
}
}
if (isActive && isRecent) {
pollGeneration(gen.id)
}
})
} else {
historyGenerations.value = Array.isArray(historyRes) ? historyRes : []
historyTotal.value = historyGenerations.value.length
}
// Restore complex objects from IDs
const savedSettings = restoreSettings()
if (savedSettings) {
if (savedSettings.selectedCharacterId) {
selectedCharacter.value = characters.value.find(c => (c.id === savedSettings.selectedCharacterId || c._id === savedSettings.selectedCharacterId)) || null
}
if (savedSettings.selectedAssetIds && savedSettings.selectedAssetIds.length > 0) {
// Determine which assets to select.
// Note: saved assets might not be in the first 100 loaded.
// For a robust implementation, we might need to fetch specific assets if not found.
// For now, we filter from available.
selectedAssets.value = allAssets.value.filter(a => savedSettings.selectedAssetIds.includes(a.id))
}
}
} catch (e) {
console.error('Failed to load data', e)
}
}
const refreshHistory = async () => {
try {
const response = await aiService.getGenerations(historyRows.value, 0, filterCharacter.value?.id || filterCharacter.value?._id, onlyLiked.value)
if (response && response.generations) {
// Update existing items and add new ones at the top
const newGenerations = []
for (const gen of response.generations) {
const existingIndex = historyGenerations.value.findIndex(g => g.id === gen.id)
if (existingIndex !== -1) {
// Update existing item in place to preserve state/reactivity
// Only update if not currently polling to avoid race conditions,
// or just update fields that might change
const existing = historyGenerations.value[existingIndex]
if (!['processing', 'starting', 'running'].includes(existing.status)) {
Object.assign(existing, gen)
}
} else {
newGenerations.push(gen)
}
}
// Add completely new items to the start of the list
if (newGenerations.length > 0) {
historyGenerations.value = [...newGenerations, ...historyGenerations.value]
}
historyTotal.value = response.total_count || historyTotal.value
}
} catch (e) {
console.error('Failed to refresh history', e)
}
}
// --- Generation ---
const handleGenerate = async () => {
if (!prompt.value.trim()) return
if (sendToTelegram.value && !telegramId.value) {
alert("Please enter your Telegram ID")
return
}
isSubmitting.value = true
// Close settings to show gallery/progress (optional preference)
// isSettingsVisible.value = false
try {
const payload = {
aspect_ratio: aspectRatio.value, // Now a string
quality: quality.value.key,
prompt: prompt.value,
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: generationCount.value
}
const response = await aiService.runGeneration(payload)
// Response can be a single generation, an array, or a Group response with 'generations'
let generations = []
if (response && response.generations) {
generations = response.generations
} else if (Array.isArray(response)) {
generations = response
} else {
generations = [response]
}
for (const gen of generations) {
if (gen && gen.id) {
const newGen = {
id: gen.id,
prompt: prompt.value,
status: gen.status || 'starting',
created_at: new Date().toISOString(),
generation_group_id: gen.generation_group_id || null,
}
historyGenerations.value.unshift(newGen)
historyTotal.value++
pollGeneration(gen.id)
}
}
// Refresh history after a short delay to get full generation data from server
setTimeout(() => refreshHistory(), 2000)
} catch (e) {
console.error('Generation failed', e)
alert(e.message || 'Generation failed to start')
} finally {
isSubmitting.value = false
}
}
const pollGeneration = async (id) => {
let completed = false
let attempts = 0
// Find the generation object in our list to update it specifically
const genIndex = historyGenerations.value.findIndex(g => g.id === id)
if (genIndex === -1) return // Should not happen if we just added it
const gen = historyGenerations.value[genIndex]
while (!completed) {
try {
const response = await aiService.getGenerationStatus(id)
// Update the object in the list
// We use Object.assign to keep the reactive reference valid
Object.assign(gen, response)
if (response.status === 'done' || response.status === 'failed') {
completed = true
} else {
// Exponential backoff or fixed interval
await new Promise(resolve => setTimeout(resolve, 2000))
}
} catch (e) {
console.error(`Polling failed for ${id}`, e)
attempts++
if (attempts > 3) {
completed = true
gen.status = 'failed'
gen.failed_reason = 'Polling connection lost'
}
await new Promise(resolve => setTimeout(resolve, 5000))
}
}
}
// --- Infinite Scroll ---
const isHistoryLoading = ref(false)
const infiniteScrollTrigger = ref(null)
let observer = null
const setupInfiniteScroll = () => {
observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isHistoryLoading.value && historyGenerations.value.length < historyTotal.value) {
loadMoreHistory()
}
}, {
root: null,
rootMargin: '100px',
threshold: 0.1
})
if (infiniteScrollTrigger.value) {
observer.observe(infiniteScrollTrigger.value)
}
}
const loadMoreHistory = async () => {
if (isHistoryLoading.value) return
isHistoryLoading.value = true
try {
const nextOffset = historyGenerations.value.length
const response = await aiService.getGenerations(historyRows.value, nextOffset, filterCharacter.value?.id, onlyLiked.value)
if (response && response.generations) {
const newGenerations = response.generations.filter(gen =>
!historyGenerations.value.some(existing => existing.id === gen.id)
)
historyGenerations.value.push(...newGenerations)
historyTotal.value = response.total_count || historyTotal.value
}
} catch (e) {
console.error('Failed to load more history', e)
} finally {
isHistoryLoading.value = false
}
}
// --- Initial Load ---
onMounted(() => {
// Reset NSFW on page load
showNsfwGlobal.value = false
localStorage.removeItem('show_nsfw_global')
loadData().then(() => {
// slight delay to allow DOM render
setTimeout(setupInfiniteScroll, 500)
})
})
// --- Sidebar Logic (Duplicated for now) ---
// handleLogout removed - handled in AppSidebar
// Image Preview with navigation
const isImagePreviewVisible = ref(false)
const previewImage = ref(null)
const previewIndex = ref(0)
// Grouped history: merge generations with the same generation_group_id into a single gallery item
const groupedHistoryGenerations = computed(() => {
const groups = new Map()
const result = []
for (const gen of historyGenerations.value) {
if (gen.generation_group_id) {
if (groups.has(gen.generation_group_id)) {
groups.get(gen.generation_group_id).children.push(gen)
} else {
const group = {
id: gen.generation_group_id,
generation_group_id: gen.generation_group_id,
prompt: gen.prompt,
created_at: gen.created_at,
isGroup: true,
children: [gen],
}
groups.set(gen.generation_group_id, group)
result.push(group)
}
} else {
result.push(gen)
}
}
return result
})
// Collect all preview-able images from history
const allPreviewImages = computed(() => {
const images = []
for (const gen of historyGenerations.value) {
if (gen.result_list && gen.result_list.length > 0) {
for (const assetId of gen.result_list) {
images.push({
url: API_URL + '/assets/' + assetId,
assetId: assetId,
is_liked: gen.liked_assets?.includes(assetId),
genId: gen.id,
prompt: gen.prompt,
gen: gen
})
}
}
}
return images
})
const handleLiked = ({ id, is_liked }) => {
// Update local state in history
historyGenerations.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)
}
}
const openImagePreview = (url) => {
const idx = allPreviewImages.value.findIndex(img => img.url === url)
previewIndex.value = idx >= 0 ? idx : 0
isImagePreviewVisible.value = true
}
const reusePrompt = (gen) => {
if (gen.prompt) {
prompt.value = gen.prompt
isSettingsVisible.value = true
}
}
const reuseAsset = (gen) => {
const assetIds = gen.assets_list || []
if (assetIds.length > 0) {
// We need to find these assets in allAssets to get full objects for MultiSelect
// If not loaded, we might need value to just be objects with ID and URL (MultiSelect supports object value)
// Let's try to map what we can
const assets = assetIds.map(id => {
const found = allAssets.value.find(a => a.id === id)
if (found) return found
return { id, url: `/assets/${id}`, name: 'Asset ' + id.substring(0, 4) }
})
selectedAssets.value = assets
isSettingsVisible.value = true
}
}
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)
}
// Replace existing selection or add? User said "automatically replaced the attached asset". so Replace.
selectedAssets.value = [asset]
isSettingsVisible.value = true
}
}
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)
}
}
const deleteGeneration = async (gen) => {
if (!gen) return
try {
// Optimistic update
historyGenerations.value = historyGenerations.value.filter(g => g.id !== gen.id)
historyTotal.value--
// Use deleteAsset since generations are essentially assets in this view's context,
// or we need a way to delete the generation record.
// The user said "delete asset", and these are likely linked.
// If gen has a result list, we delete the first asset.
await dataService.deleteGeneration(gen.id)
} catch (e) {
console.error('Failed to delete generation', e)
// Reload to restore state if failed
loadData()
}
}
const toggleMobileOverlay = (id) => {
// Only for touch/small screens if needed, or just general tap behavior
if (activeOverlayId.value === id) {
activeOverlayId.value = null
} else {
activeOverlayId.value = id
}
}
// --- Asset Picker Logic ---
const loadModalAssets = async () => {
isModalLoading.value = true
try {
const typeParam = assetPickerTab.value === 'all' ? undefined : assetPickerTab.value
// Use a larger limit for the modal or implement scrolling/pagination if needed.
// For now, 100 should be enough for a demo, or we can add pagination later.
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()
}
})
// --- Asset Upload in Picker ---
const assetPickerFileInput = ref(null)
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)
// Switch tab to 'uploaded' and reload
assetPickerTab.value = 'uploaded'
loadModalAssets()
} catch (e) {
console.error('Failed to upload asset', e)
} finally {
isModalLoading.value = false
target.value = ''
}
}
}
// --- Album Picker Logic ---
const openAlbumPicker = (gen) => {
generationToAdd.value = gen
isAlbumPickerVisible.value = true
albumStore.fetchAlbums() // Fetch albums when opening picker
}
const confirmAddToAlbum = async () => {
if (!generationToAdd.value || !selectedAlbumForAdd.value) return
try {
await albumStore.addGenerationToAlbum(selectedAlbumForAdd.value.id, generationToAdd.value.id)
isAlbumPickerVisible.value = false
selectedAlbumForAdd.value = null
generationToAdd.value = null
// Optional: Show success toast
} catch (e) {
console.error('Failed to add to album', e)
}
}
</script>
<template>
<div class="flex flex-col h-full font-sans">
<main class="flex-1 relative flex flex-col h-full overflow-hidden">
<header
class="p-2 px-4 flex justify-between items-center z-10 border-b border-white/5 bg-slate-900/80 backdrop-blur-sm">
<div class="flex flex-row !items-center !justify-items-center !justify-center gap-2">
<h1
class="text-base font-bold bg-gradient-to-r from-white to-slate-400 bg-clip-text text-transparent !m-0">
Gallery</h1>
<span class="text-[10px] text-slate-500 border-l border-white/10 pl-2">History</span>
</div>
<div class="flex items-center gap-1.5">
<Dropdown v-model="filterCharacter" :options="characters" optionLabel="name"
placeholder="All Characters" showClear
class="!w-40 !bg-slate-800/60 !border-white/10 !text-white !rounded-lg !text-[8px]" :pt="{
root: { class: '!bg-slate-800/60 !h-7' },
input: { class: '!text-white !text-[8px] !py-0.5 !px-2' },
trigger: { class: '!text-slate-400 !w-5' },
panel: { class: '!bg-slate-800 !border-white/10' },
item: { class: '!text-slate-300 hover:!bg-white/10 hover:!text-white !text-[8px] !py-1' },
clearIcon: { class: '!text-slate-400 hover:!text-white !text-[8px]' }
}">
<template #value="slotProps">
<div class="flex flex-row">
<div v-if="slotProps.value" class="flex items-center gap-1">
<img v-if="slotProps.value.avatar_image" :src="API_URL + slotProps.value.avatar_image"
class="w-4 h-4 rounded-full object-cover" />
<span class="text-[12px] ">{{ slotProps.value.name }}</span>
</div>
<span v-else class="text-slate-400 text-[12px] items-center">{{ slotProps.placeholder }}</span>
</div>
</template>
<template #option="slotProps">
<div class="flex items-center gap-1.5">
<img v-if="slotProps.option.avatar_image" :src="API_URL + slotProps.option.avatar_image"
class="w-5 h-5 rounded-full object-cover" />
<span class="">{{ slotProps.option.name }}</span>
</div>
</template>
</Dropdown>
<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="showNsfwGlobal ? 'pi pi-eye' : 'pi pi-eye-slash'"
@click="showNsfwGlobal = !showNsfwGlobal" rounded text
class="!w-7 !h-7 !p-0"
:class="showNsfwGlobal ? '!text-red-400 !bg-red-500/10' : '!text-slate-400 hover:!bg-white/10'"
v-tooltip.bottom="showNsfwGlobal ? 'Hide NSFW' : 'Show NSFW'" />
<Button icon="pi pi-refresh" @click="refreshHistory" rounded text
class="!text-slate-400 hover:!bg-white/10 !w-7 !h-7 !p-0 md:hidden" />
<Button :icon="isSelectMode ? 'pi pi-times' : 'pi pi-check-square'" @click="toggleSelectMode"
rounded text class="!w-7 !h-7 !p-0"
: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-7 !h-7 !p-0" v-if="!isSettingsVisible" />
</div>
</header>
<div class="flex-1 overflow-y-auto p-4"
:class="{ 'pb-[300px]': isSettingsVisible, 'pb-32': !isSettingsVisible }">
<div v-if="historyGenerations.length > 0"
class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-6 gap-2 md:gap-1">
<div v-for="item in groupedHistoryGenerations" :key="item.id"
class="aspect-[9/16] relative overflow-hidden bg-slate-800 transition-all duration-300">
<!-- ============================================ -->
<!-- GROUPED GENERATION (multiple in one slot) -->
<!-- ============================================ -->
<template v-if="item.isGroup">
<!-- Group badge -->
<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>
<div class="w-full h-full grid gap-0.5"
:class="item.children.length <= 2 ? 'grid-cols-1' : 'grid-cols-2'">
<div v-for="child in item.children" :key="child.id"
class="relative group overflow-hidden">
<GenerationImage
:generation="child"
:api-url="API_URL"
:is-select-mode="isSelectMode"
:is-selected="selectedAssetIds.has(child.result_list?.[0])"
:show-nsfw-global="showNsfwGlobal"
:active-overlay-id="activeOverlayId"
@toggle-select="toggleImageSelection"
@open-preview="openImagePreview"
@toggle-like="toggleLike"
@delete="deleteGeneration"
@reuse-prompt="reusePrompt"
@reuse-asset="reuseAsset"
@use-result="useResultAsAsset"
@toggle-overlay="toggleMobileOverlay"
/>
</div>
</div>
</template>
<!-- ============================================ -->
<!-- SINGLE GENERATION (full slot) -->
<!-- ============================================ -->
<template v-else>
<GenerationImage
:generation="item"
:api-url="API_URL"
:is-select-mode="isSelectMode"
:is-selected="selectedAssetIds.has(item.result_list?.[0])"
:show-nsfw-global="showNsfwGlobal"
:active-overlay-id="activeOverlayId"
@toggle-select="toggleImageSelection"
@open-preview="openImagePreview"
@toggle-like="toggleLike"
@delete="deleteGeneration"
@reuse-prompt="reusePrompt"
@reuse-asset="reuseAsset"
@use-result="useResultAsAsset"
@toggle-overlay="toggleMobileOverlay"
/>
</template>
</div>
</div>
<div v-else-if="!isSubmitting && historyGenerations.length === 0"
class="flex flex-col items-center justify-center h-full text-slate-600 opacity-50">
<i class="pi pi-images text-6xl mb-4"></i>
<p class="text-xl">Your creations will appear here</p>
</div>
<!-- Infinite Scroll Sentinel & Loading Indicator -->
<div ref="infiniteScrollTrigger" class="w-full py-4 flex justify-center items-center min-h-[50px]">
<i v-if="isHistoryLoading" class="pi pi-spin pi-spinner text-violet-500 text-2xl"></i>
<span v-else-if="historyGenerations.length > 0 && historyGenerations.length >= historyTotal"
class="text-slate-600 text-xs">
All items loaded
</span>
</div>
</div>
<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">
<div class="w-16 h-1 bg-white/20 rounded-full hover:bg-white/40 transition-colors"></div>
</div>
<div class="flex flex-col lg:flex-row gap-8">
<div class="flex-1 flex flex-col gap-4">
<div class="flex flex-col gap-2">
<div class="flex justify-between items-center">
<label class="text-xs 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-1 !w-6 !h-6 !text-[10px] !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.5 !px-2 !text-[10px] !h-6 !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.5 !px-2 !text-[10px] !h-6 !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.5 !px-2 !text-[10px] !h-6 !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 bg-slate-800 !h-28 !text-[16px] border-white/10 text-white rounded-xl p-3 focus:border-violet-500 focus:ring-1 focus:ring-violet-500/50 transition-all resize-none shadow-inner" />
</div>
<!-- Character & Assets Row -->
<div class="flex flex-col md:flex-row gap-4">
<div class="flex-1 flex flex-col gap-2">
<label class="text-xs font-bold text-slate-400 uppercase tracking-wider">Character
(Optional)</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="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="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="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="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-2">
<label class="text-xs font-bold text-slate-400 uppercase tracking-wider">Reference
Assets</label>
<div @click="openAssetPicker"
class="w-full bg-slate-800 border border-white/10 rounded-xl p-3 min-h-[46px] cursor-pointer hover:bg-slate-700/50 transition-colors flex flex-wrap gap-2">
<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 flex-col gap-2 animate-in fade-in slide-in-from-top-1 mt-2">
<div class="flex justify-between items-center">
<label class="text-xs 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-5 !w-5 !text-[10px] text-slate-500 hover:text-white" />
</div>
<div v-if="environments.length > 0" class="flex gap-2 overflow-x-auto pb-2 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-3 px-3 py-2 rounded-xl 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_20px_rgba(124,58,237,0.15)]'
: 'border-white/5 hover:border-white/20'
]"
>
<div class="w-8 h-8 rounded-lg 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-xs"
:class="(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id)) ? 'text-violet-400' : 'text-slate-500'"
></i>
</div>
<span class="text-sm 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-xs"></i>
</div>
</div>
<div v-else class="py-4 px-4 bg-slate-800/50 border border-white/5 rounded-2xl text-center">
<p class="text-xs text-slate-600 uppercase m-0">No environments for this character</p>
</div>
</div>
</div>
<div class="w-full lg:w-80 flex flex-col gap-4">
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label class="text-xs 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-2">
<label class="text-xs font-bold text-slate-400 uppercase tracking-wider">Format</label>
<div class="flex items-center">
<div class="flex-1 flex bg-slate-800 rounded-xl border border-white/10 overflow-hidden">
<button @click="aspectRatio = 'THREEFOUR'"
class="flex-1 py-2 text-sm 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-2 text-sm 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-2">
<label class="text-xs font-bold text-slate-400 uppercase tracking-wider">Count</label>
<div class="flex items-center gap-2">
<div class="flex-1 flex bg-slate-800 rounded-xl border border-white/10 overflow-hidden">
<button v-for="n in 4" :key="n" @click="generationCount = n"
class="flex-1 py-2 text-sm font-bold transition-all"
:class="generationCount === 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-2 bg-slate-800/50 p-3 rounded-xl border border-white/5">
<div class="flex items-center gap-2">
<Checkbox v-model="sendToTelegram" :binary="true" inputId="tg-check" />
<label for="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>
<div class="mt-auto">
<Button :label="isSubmitting ? 'Starting...' : 'Generate'"
:icon="isSubmitting ? 'pi pi-spin pi-spinner' : 'pi pi-sparkles'"
:loading="isSubmitting" @click="handleGenerate"
class="w-full !py-3 !text-base !font-bold !bg-gradient-to-r from-violet-600 to-cyan-500 !border-none !rounded-xl !shadow-lg !shadow-violet-500/20 hover:!scale-[1.02] transition-all" />
</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>
<GenerationPreviewModal
v-model:visible="isImagePreviewVisible"
:preview-images="allPreviewImages"
:initial-index="previewIndex"
:api-url="API_URL"
@reuse-prompt="reusePrompt"
@reuse-asset="reuseAsset"
@use-result-as-asset="useResultAsAsset"
@liked="handleLiked"
/>
<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>
<Dialog v-model:visible="isAlbumPickerVisible" modal header="Add to Album" :style="{ width: '400px' }"
: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-4' }, 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 gap-4">
<div v-if="albumStore.loading" class="flex justify-center p-4">
<i class="pi pi-spin pi-spinner text-violet-500 text-2xl"></i>
</div>
<div v-else-if="albumStore.albums.length === 0" class="text-center text-slate-400">
No albums found. Create one first!
</div>
<Dropdown v-else v-model="selectedAlbumForAdd" :options="albumStore.albums" optionLabel="name"
placeholder="Select an Album" class="w-full !bg-slate-800 !border-white/10 !text-white" :pt="{
root: { class: '!bg-slate-800' },
input: { class: '!text-white' },
trigger: { class: '!text-slate-400' },
panel: { class: '!bg-slate-900 !border-white/10' },
item: { class: '!text-slate-300 hover:!bg-white/10 hover:!text-white' }
}" />
</div>
<template #footer>
<div class="flex justify-end gap-2">
<Button label="Cancel" @click="isAlbumPickerVisible = false"
class="!text-slate-300 hover:!bg-white/5" text />
<Button label="Add" @click="confirmAddToAlbum" :disabled="!selectedAlbumForAdd"
class="!bg-violet-600 !border-none hover:!bg-violet-500" />
</div>
</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>
<style scoped>
.glass-panel {
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.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;
}
/* Custom Scrollbar for the gallery */
.overflow-y-auto::-webkit-scrollbar {
width: 6px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.02);
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
</style>