Files
ai-service-front/src/views/FlexibleGenerationView.vue

683 lines
33 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, onMounted, watch, computed } from 'vue'
import { useRouter } from 'vue-router'
import { dataService } from '../services/dataService'
import { aiService } from '../services/aiService'
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 MultiSelect from 'primevue/multiselect'
import ProgressSpinner from 'primevue/progressspinner'
import ProgressBar from 'primevue/progressbar'
import Message from 'primevue/message'
const router = useRouter()
const API_URL = import.meta.env.VITE_API_URL
// --- State ---
const prompt = ref('')
const selectedCharacter = ref(null)
const selectedAssets = ref([])
const quality = ref({ key: 'TWOK', value: '2K' })
const aspectRatio = ref({ key: "NINESIXTEEN", value: "9:16" })
const sendToTelegram = ref(false)
const telegramId = ref('')
const isTelegramIdSaved = ref(false)
const useProfileImage = ref(true)
const isImprovingPrompt = ref(false)
const previousPrompt = ref('')
const characters = ref([])
const allAssets = ref([])
const historyGenerations = ref([])
const historyTotal = ref(0)
const historyRows = ref(20)
const historyFirst = ref(0)
const isSettingsVisible = ref(false)
const isGenerating = ref(false)
const generationStatus = ref('')
const generationProgress = ref(0)
const generationError = ref(null)
const generatedResult = ref(null) // For immediate feedback if needed
// Options
const qualityOptions = ref([
{ key: 'ONEK', value: '1K' },
{ key: 'TWOK', value: '2K' },
{ key: 'FOURK', value: '4K' }
])
const aspectRatioOptions = ref([
{ key: "NINESIXTEEN", value: "9:16" },
{ key: "FOURTHREE", value: "4:3" },
{ key: "THREEFOUR", value: "3:4" },
{ key: "SIXTEENNINE", value: "16:9" }
])
// --- Persistence ---
const STORAGE_KEY = 'flexible_gen_settings'
const saveSettings = () => {
const settings = {
prompt: prompt.value,
selectedCharacterId: selectedCharacter.value?.id,
selectedAssetIds: selectedAssets.value.map(a => a.id),
quality: quality.value,
aspectRatio: aspectRatio.value,
sendToTelegram: sendToTelegram.value,
telegramId: telegramId.value,
useProfileImage: useProfileImage.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) 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
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, selectedAssets, quality, aspectRatio, sendToTelegram, telegramId, useProfileImage], () => {
saveSettings()
}, { deep: true })
// --- 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)
])
// 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
} 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) || 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 loadMoreHistory = async () => {
// Implement pagination/infinite scroll logic here
}
// --- Generation ---
const handleGenerate = async () => {
if (!prompt.value.trim()) return
if (sendToTelegram.value && !telegramId.value) {
alert("Please enter your Telegram ID")
return
}
isGenerating.value = true
generationError.value = null
generationStatus.value = 'starting'
generationProgress.value = 0
// Close settings to show gallery/progress (optional preference)
// isSettingsVisible.value = false
try {
const payload = {
aspect_ratio: aspectRatio.value.key,
quality: quality.value.key,
prompt: prompt.value,
assets_list: selectedAssets.value.map(a => a.id),
linked_character_id: selectedCharacter.value?.id || null,
telegram_id: sendToTelegram.value ? telegramId.value : null,
use_profile_image: selectedCharacter.value ? useProfileImage.value : false
}
const response = await aiService.runGeneration(payload)
if (response && response.id) {
pollStatus(response.id)
} else {
// Immediate result
isGenerating.value = false
loadHistory() // Refresh gallery
}
} catch (e) {
console.error('Generation failed', e)
generationError.value = e.message || 'Generation failed'
isGenerating.value = false
}
}
const pollStatus = async (id) => {
let completed = false
while (!completed && isGenerating.value) {
try {
const response = await aiService.getGenerationStatus(id)
generationStatus.value = response.status
generationProgress.value = response.progress || 0
if (response.status === 'done') {
completed = true
// Refresh history to show new item
const historyRes = await aiService.getGenerations(historyRows.value, 0)
if (historyRes && historyRes.generations) {
historyGenerations.value = historyRes.generations
historyTotal.value = historyRes.total_count || 0
}
} else if (response.status === 'failed') {
completed = true
generationError.value = response.failed_reason || 'Generation failed'
throw new Error(generationError.value)
} else {
await new Promise(resolve => setTimeout(resolve, 2000))
}
} catch (e) {
console.error('Polling failed', e)
completed = true
isGenerating.value = false
}
}
isGenerating.value = false
}
// --- Initial Load ---
onMounted(() => {
loadData()
// Open settings by default if it's a new user or explicitly requested?
// Maybe better to keep it closed or open based on layout preference.
// Let's keep it open initially for better UX as they likely want to generate.
isSettingsVisible.value = true
})
// --- Sidebar Logic (Duplicated for now) ---
const handleLogout = () => {
localStorage.removeItem('auth_code')
router.push('/login')
}
// Image Preview
const isImagePreviewVisible = ref(false)
const previewImage = ref(null)
const openImagePreview = (url) => {
previewImage.value = { url }
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 = ''
}
</script>
<template>
<div class="flex h-screen bg-slate-900 overflow-hidden text-slate-100 font-sans">
<!-- Sidebar -->
<nav
class="glass-panel w-20 m-4 flex flex-col items-center py-6 rounded-3xl z-10 border border-white/5 bg-slate-900/50 backdrop-blur-md">
<div class="mb-12">
<div
class="w-10 h-10 bg-gradient-to-br from-violet-600 to-cyan-500 rounded-xl flex items-center justify-center font-bold text-white text-xl shadow-lg shadow-violet-500/20">
AI
</div>
</div>
<div class="flex-1 flex flex-col gap-6 w-full items-center">
<div class="w-12 h-12 flex items-center justify-center rounded-xl cursor-pointer transition-all duration-300 text-slate-400 hover:bg-white/10 hover:text-slate-50"
@click="router.push('/')" v-tooltip.right="'Home'">
<span class="text-2xl">🏠</span>
</div>
<div class="w-12 h-12 flex items-center justify-center rounded-xl cursor-pointer transition-all duration-300 text-slate-400 hover:bg-white/10 hover:text-slate-50"
@click="router.push('/assets')" v-tooltip.right="'Assets'">
<span class="text-2xl">📂</span>
</div>
<!-- Image Generation -->
<div class="w-12 h-12 flex items-center justify-center rounded-xl cursor-pointer transition-all duration-300 text-slate-400 hover:bg-white/10 hover:text-slate-50"
@click="router.push('/generation')" v-tooltip.right="'Image Generation'">
<span class="text-2xl">🎨</span>
</div>
<!-- Active State for Flexible Generation -->
<div class="w-12 h-12 flex items-center justify-center rounded-xl cursor-pointer transition-all duration-300 bg-white/10 text-slate-50 shadow-inner"
v-tooltip.right="'Flexible Generation'">
<span class="text-2xl">🖌</span>
</div>
<div class="w-12 h-12 flex items-center justify-center rounded-xl cursor-pointer transition-all duration-300 text-slate-400 hover:bg-white/10 hover:text-slate-50"
@click="router.push('/characters')" v-tooltip.right="'Characters'">
<span class="text-2xl">👥</span>
</div>
<div class="w-12 h-12 flex items-center justify-center rounded-xl cursor-pointer transition-all duration-300 text-slate-400 hover:bg-white/10 hover:text-slate-50"
@click="router.push('/image-to-prompt')" v-tooltip.right="'Image to Prompt'">
<span class="text-2xl"></span>
</div>
</div>
<div class="mt-auto">
<div @click="handleLogout"
class="w-10 h-10 rounded-xl bg-red-500/10 text-red-400 flex items-center justify-center cursor-pointer hover:bg-red-500/20 transition-all font-bold">
<i class="pi pi-power-off"></i>
</div>
</div>
</nav>
<!-- Main Content -->
<main class="flex-1 relative flex flex-col h-full overflow-hidden">
<!-- Header -->
<header
class="p-4 flex justify-between items-center z-10 border-b border-white/5 bg-slate-900/80 backdrop-blur-sm">
<div class="flex items-center gap-3">
<h1
class="text-xl font-bold bg-gradient-to-r from-white to-slate-400 bg-clip-text text-transparent m-0">
Gallery</h1>
<span class="text-xs text-slate-500 border-l border-white/10 pl-3">History</span>
</div>
<Button icon="pi pi-cog" @click="isSettingsVisible = true" rounded text
class="!text-slate-400 hover:!bg-white/10 !w-8 !h-8" v-if="!isSettingsVisible" />
</header>
<!-- Gallery Grid -->
<div class="flex-1 overflow-y-auto p-4 pb-32"> <!-- pb-32 to allow space for bottom panel -->
<div v-if="historyGenerations.length > 0"
class="grid grid-cols-3 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8 gap-1">
<div v-for="gen in historyGenerations" :key="gen.id"
class="aspect-[9/16] relative group overflow-hidden bg-slate-800 transition-all duration-300">
<!-- Image -->
<img v-if="gen.result_list && gen.result_list.length > 0"
:src="API_URL + '/assets/' + gen.result_list[0] + '?thumbnail=true'"
class="w-full h-full object-cover"
@click="openImagePreview(API_URL + '/assets/' + gen.result_list[0])" />
<div v-else class="w-full h-full flex items-center justify-center text-slate-600">
<i class="pi pi-image text-4xl"></i>
</div>
<!-- Overlay Info -->
<div
class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex flex-col justify-between p-2">
<!-- Top Actions -->
<div
class="flex justify-end gap-1 translate-y-[-10px] group-hover:translate-y-0 transition-transform duration-200">
<Button icon="pi pi-pencil" v-tooltip.left="'Edit (Use Result)'"
class="!w-6 !h-6 !rounded-full !bg-white/20 !border-none !text-white text-[10px] hover:!bg-violet-500"
@click.stop="useResultAsAsset(gen)" />
</div>
<!-- Bottom Actions -->
<div class="translate-y-[10px] group-hover:translate-y-0 transition-transform duration-200">
<div class="flex gap-1 mb-1">
<Button icon="pi pi-comment" v-tooltip.bottom="'Reuse Prompt'"
class="!w-6 !h-6 flex-1 !bg-white/10 !border-white/10 !text-slate-200 text-[10px] hover:!bg-white/20"
@click.stop="reusePrompt(gen)" />
<Button icon="pi pi-images" v-tooltip.bottom="'Reuse Assets'"
class="!w-6 !h-6 flex-1 !bg-white/10 !border-white/10 !text-slate-200 text-[10px] hover:!bg-white/20"
@click.stop="reuseAsset(gen)" />
</div>
<p class="text-[10px] text-white/70 line-clamp-1 leading-tight">{{ gen.prompt }}</p>
</div>
</div>
</div>
</div>
<div v-else-if="!isGenerating"
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>
</div>
<!-- Bottom Settings Panel -->
<transition name="slide-up">
<div v-if="isSettingsVisible"
class="absolute bottom-0 left-0 right-0 glass-panel border-t border-white/10 bg-slate-900/95 backdrop-blur-xl p-6 z-20 rounded-t-3xl shadow-[0_-10px_40px_rgba(0,0,0,0.5)] flex flex-col gap-4 max-h-[85vh] overflow-y-auto">
<!-- Handle / Close Button -->
<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">
<!-- Left Column: Inputs -->
<div class="flex-1 flex flex-col gap-4">
<!-- Prompt -->
<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-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="3" autoResize
placeholder="Describe what you want to create..."
class="w-full bg-slate-800 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>
<!-- Assets & Character Row -->
<div class="flex 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 items-center gap-2 mt-2 px-1 animate-in fade-in slide-in-from-top-1">
<Checkbox v-model="useProfileImage" :binary="true" inputId="use-profile-img" />
<label for="use-profile-img"
class="text-xs text-slate-300 cursor-pointer select-none">Use Character
Photo</label>
</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>
<MultiSelect v-model="selectedAssets" :options="allAssets" optionLabel="id"
placeholder="Select Assets" display="chip"
class="w-full !bg-slate-800 !border-white/10 !text-white !rounded-xl" :pt="{
root: { class: '!bg-slate-800' },
labelContainer: { 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' },
token: { class: '!bg-violet-600/30 !text-violet-200' }
}">
<template #option="slotProps">
<div class="flex items-center gap-2">
<img :src="API_URL + slotProps.option.url + '?thumbnail=true'"
class="w-8 h-8 rounded object-cover border border-white/10" />
<span class="text-xs truncate max-w-[150px]">{{ slotProps.option.name ||
'Asset ' + slotProps.option.id.substring(0, 4) }}</span>
</div>
</template>
</MultiSelect>
</div>
</div>
</div>
<!-- Right Column: Specs & Action -->
<div class="w-full lg:w-80 flex flex-col gap-4">
<!-- Specs -->
<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">Aspect
Ratio</label>
<Dropdown v-model="aspectRatio" :options="aspectRatioOptions" 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>
<!-- Telegram -->
<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-xs !bg-slate-900 !border-white/10 !text-white !py-1.5" />
</div>
</div>
<!-- Generate Button -->
<div class="mt-auto">
<Button :label="isGenerating ? 'Generating...' : 'Generate'"
:icon="isGenerating ? 'pi pi-spin pi-spinner' : 'pi pi-sparkles'"
:loading="isGenerating" @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 v-if="isGenerating" class="mt-2 text-center">
<ProgressBar :value="generationProgress" class="h-1 bg-slate-700"
:pt="{ value: { class: '!bg-violet-500' } }" :showValue="false" />
<span class="text-[10px] text-slate-500">{{ generationStatus }}</span>
</div>
<div v-if="generationError"
class="mt-2 text-center text-xs text-red-400 bg-red-500/10 p-2 rounded border border-red-500/20">
{{ generationError }}
</div>
</div>
</div>
</div>
</div>
</transition>
<!-- Toggle Button (when hidden) -->
<transition name="fade">
<div v-if="!isSettingsVisible" class="absolute 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>
<!-- Image Preview Modal -->
<Dialog v-model:visible="isImagePreviewVisible" modal dismissableMask
:style="{ width: '90vw', maxWidth: '1000px', background: 'transparent', boxShadow: 'none' }"
:pt="{ root: { class: '!bg-transparent !border-none !shadow-none' }, header: { class: '!hidden' }, content: { class: '!bg-transparent !p-0' } }">
<div class="relative flex items-center justify-center" @click="isImagePreviewVisible = false">
<img v-if="previewImage" :src="previewImage.url"
class="max-w-full max-h-[85vh] object-contain rounded-xl shadow-2xl" />
<Button icon="pi pi-times" @click="isImagePreviewVisible = false" rounded text
class="!absolute -top-4 -right-4 !text-white !bg-black/50 hover:!bg-black/70 !w-10 !h-10" />
</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>