This commit is contained in:
xds
2026-02-20 13:10:50 +03:00
parent b0ce251914
commit 4136f42e70
6 changed files with 403 additions and 195 deletions

View File

@@ -0,0 +1,306 @@
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import Dialog from 'primevue/dialog'
import Button from 'primevue/button'
import Tag from 'primevue/tag'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
previewImages: {
type: Array,
default: () => []
},
initialIndex: {
type: Number,
default: 0
},
apiUrl: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:visible', 'reusePrompt', 'reuseAsset', 'useResultAsAsset'])
const previewIndex = ref(0)
const previewImage = computed(() => props.previewImages[previewIndex.value] || null)
// Reset index when modal opens
watch(() => props.visible, (newVal) => {
if (newVal) {
previewIndex.value = props.initialIndex
window.addEventListener('keydown', handlePreviewKeydown)
} else {
window.removeEventListener('keydown', handlePreviewKeydown)
}
})
const close = () => {
emit('update:visible', false)
}
const navigatePreview = (direction) => {
const count = props.previewImages.length
if (count <= 1) return
let newIndex = previewIndex.value + direction
if (newIndex < 0) newIndex = count - 1
if (newIndex >= count) newIndex = 0
previewIndex.value = newIndex
}
const handlePreviewKeydown = (e) => {
if (!props.visible) return
if (e.key === 'ArrowLeft') { navigatePreview(-1); e.preventDefault() }
if (e.key === 'ArrowRight') { navigatePreview(1); e.preventDefault() }
if (e.key === 'Escape') { close(); e.preventDefault() }
}
onBeforeUnmount(() => {
window.removeEventListener('keydown', handlePreviewKeydown)
})
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const copyToClipboard = (text) => {
if (!text) return
navigator.clipboard.writeText(text).then(() => {
// Parent could show a toast if needed, or we just log
console.log('Copied to clipboard')
})
}
// Actions
const onReusePrompt = () => {
if (previewImage.value?.gen) emit('reusePrompt', previewImage.value.gen)
}
const onReuseAsset = () => {
if (previewImage.value?.gen) emit('reuseAsset', previewImage.value.gen)
}
const onUseResultAsAsset = () => {
if (previewImage.value?.gen) emit('useResultAsAsset', previewImage.value.gen)
}
</script>
<template>
<Dialog :visible="visible" @update:visible="val => emit('update:visible', val)" modal dismissableMask
:style="{ width: '95vw', maxWidth: '1200px' }"
class="preview-dialog"
:pt="{
root: { class: '!bg-transparent !border-none !shadow-none' },
header: { class: '!hidden' },
content: { class: '!p-0 !bg-transparent' }
}">
<div v-if="previewImage" class="flex flex-col lg:flex-row h-[90vh] w-full overflow-hidden relative bg-slate-900/95 backdrop-blur-xl border border-white/10 shadow-2xl rounded-3xl" @click.stop>
<!-- Left: Image Viewer -->
<div class="flex-1 bg-black/40 flex items-center justify-center relative min-h-[40vh] lg:min-h-0 border-r border-white/5">
<!-- Navigation Buttons -->
<Button v-if="previewImages.length > 1" icon="pi pi-chevron-left" @click.stop="navigatePreview(-1)"
rounded text
class="!absolute left-4 top-1/2 -translate-y-1/2 z-20 !text-white !bg-black/50 hover:!bg-black/70 !w-12 !h-12 !rounded-full !border !border-white/10 backdrop-blur-md transition-all hover:scale-110" />
<Button v-if="previewImages.length > 1" icon="pi pi-chevron-right" @click.stop="navigatePreview(1)"
rounded text
class="!absolute right-4 top-1/2 -translate-y-1/2 z-20 !text-white !bg-black/50 hover:!bg-black/70 !w-12 !h-12 !rounded-full !border !border-white/10 backdrop-blur-md transition-all hover:scale-110" />
<!-- Main Image -->
<img :src="previewImage.url"
class="max-w-full max-h-[85vh] object-contain shadow-2xl transition-transform duration-300"
draggable="false" />
<!-- Image Index Badge -->
<div v-if="previewImages.length > 1"
class="absolute bottom-4 left-1/2 -translate-x-1/2 z-20 bg-black/60 backdrop-blur-md text-white text-xs font-mono px-3 py-1.5 rounded-full border border-white/10">
{{ previewIndex + 1 }} / {{ previewImages.length }}
</div>
<!-- Close Button (Mobile Only) -->
<Button icon="pi pi-times" @click="close" rounded text
class="absolute top-4 right-4 z-30 lg:hidden !text-white !bg-black/50 !w-10 !h-10" />
</div>
<!-- Right: Generation Info & Actions -->
<div class="w-full lg:w-96 flex flex-col bg-slate-900/50 backdrop-blur-md overflow-hidden">
<!-- Header -->
<div class="p-5 border-b border-white/10 flex justify-between items-center">
<div>
<h3 class="text-lg font-bold text-white m-0">Generation Details</h3>
<p class="text-[10px] text-slate-500 font-mono uppercase tracking-widest mt-0.5">
{{ previewImage?.gen?.id || previewImage?.gen?._id || 'Unknown ID' }}
</p>
</div>
<Button icon="pi pi-times" @click="close" rounded text
class="!hidden lg:!flex !text-slate-500 hover:!text-white !w-8 !h-8" />
</div>
<!-- Scrollable Content -->
<div class="flex-1 overflow-y-auto p-5 custom-scrollbar flex flex-col gap-6">
<!-- Actions Section -->
<div v-if="previewImage?.gen" class="flex flex-col gap-2">
<label class="text-[10px] font-bold text-slate-500 uppercase tracking-widest px-1">Actions</label>
<div class="grid grid-cols-1 gap-2">
<Button label="Reuse Prompt" icon="pi pi-refresh" @click="onReusePrompt"
class="!bg-violet-600/10 !border-violet-500/30 !text-violet-400 hover:!bg-violet-600/20 !text-sm !justify-start" />
<Button label="Reuse References" icon="pi pi-clone" @click="onReuseAsset"
class="!bg-blue-600/10 !border-blue-500/30 !text-blue-400 hover:!bg-blue-600/20 !text-sm !justify-start" />
<Button label="Reference Result" icon="pi pi-image" @click="onUseResultAsAsset"
class="!bg-emerald-600/10 !border-emerald-500/30 !text-emerald-400 hover:!bg-emerald-600/20 !text-sm !justify-start" />
</div>
</div>
<!-- Prompt Section -->
<div v-if="previewImage?.gen?.prompt" class="flex flex-col gap-2">
<div class="flex justify-between items-center px-1">
<label class="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Prompt</label>
<Button icon="pi pi-copy" @click="copyToClipboard(previewImage.gen.prompt)"
text class="!p-0 !w-6 !h-6 !text-slate-500 hover:!text-white" v-tooltip.top="'Copy Prompt'" />
</div>
<div class="bg-black/30 p-3 rounded-xl border border-white/5 text-sm text-slate-300 leading-relaxed max-h-40 overflow-y-auto custom-scrollbar">
{{ previewImage.gen.prompt }}
</div>
</div>
<!-- Technical Data -->
<div v-if="previewImage?.gen" class="flex flex-col gap-2">
<label class="text-[10px] font-bold text-slate-500 uppercase tracking-widest px-1">Technical Data</label>
<div class="flex flex-col gap-3 bg-black/20 p-4 rounded-xl border border-white/5">
<!-- Grid for main params -->
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-0.5">
<span class="text-[10px] text-slate-500 uppercase">Quality</span>
<span class="text-xs text-slate-200 font-semibold">{{ previewImage.gen.quality || 'N/A' }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-[10px] text-slate-500 uppercase">Aspect Ratio</span>
<span class="text-xs text-slate-200 font-semibold">{{ previewImage.gen.aspect_ratio || 'N/A' }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-[10px] text-slate-500 uppercase">Execution Time</span>
<span class="text-xs text-slate-200 font-semibold">{{ previewImage.gen.execution_time_seconds?.toFixed(2) || '0' }}s (API: {{ previewImage.gen.api_execution_time_seconds?.toFixed(2) || '0' }}s)</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-[10px] text-slate-500 uppercase">Cost</span>
<span class="text-xs text-emerald-400 font-bold">${{ previewImage.gen.cost?.toFixed(4) || '0.00' }}</span>
</div>
</div>
<div class="h-px bg-white/5 w-full"></div>
<!-- Tokens Section -->
<div class="flex flex-col gap-2">
<span class="text-[10px] text-slate-500 uppercase">Token Usage</span>
<div class="grid grid-cols-3 gap-2">
<div class="flex flex-col bg-white/5 p-2 rounded-lg items-center">
<span class="text-[8px] text-slate-500 uppercase">Input</span>
<span class="text-[10px] text-slate-200 font-mono">{{ previewImage.gen.input_token_usage || 0 }}</span>
</div>
<div class="flex flex-col bg-white/5 p-2 rounded-lg items-center">
<span class="text-[8px] text-slate-500 uppercase">Output</span>
<span class="text-[10px] text-slate-200 font-mono">{{ previewImage.gen.output_token_usage || 0 }}</span>
</div>
<div class="flex flex-col bg-violet-600/20 p-2 rounded-lg items-center border border-violet-500/20">
<span class="text-[8px] text-violet-400 uppercase">Total</span>
<span class="text-[10px] text-violet-300 font-bold font-mono">{{ previewImage.gen.token_usage || 0 }}</span>
</div>
</div>
</div>
<div class="h-px bg-white/5 w-full"></div>
<!-- Technical Prompt -->
<div v-if="previewImage?.gen?.tech_prompt" class="flex flex-col gap-2">
<div class="flex justify-between items-center">
<span class="text-[10px] text-slate-500 uppercase">Technical Prompt</span>
<Button icon="pi pi-copy" @click="copyToClipboard(previewImage.gen.tech_prompt)"
text class="!p-0 !w-4 !h-4 !text-slate-500 hover:!text-white" />
</div>
<div class="bg-black/40 p-2 rounded-lg border border-white/5 text-[10px] font-mono text-slate-400 leading-relaxed max-h-24 overflow-y-auto custom-scrollbar whitespace-pre-wrap">
{{ previewImage.gen.tech_prompt }}
</div>
</div>
<div class="h-px bg-white/5 w-full"></div>
<!-- Metadata -->
<div class="flex flex-col gap-1.5 text-[10px]">
<div class="flex justify-between">
<span class="text-slate-500 uppercase">Created:</span>
<span class="text-slate-300">{{ formatDate(previewImage.gen.created_at) }}</span>
</div>
<div v-if="previewImage.gen.generation_group_id" class="flex justify-between">
<span class="text-slate-500 uppercase">Group ID:</span>
<span class="text-slate-300 font-mono">{{ previewImage.gen.generation_group_id }}</span>
</div>
<div v-if="previewImage.gen.idea_id" class="flex justify-between">
<span class="text-slate-500 uppercase">Idea ID:</span>
<span class="text-slate-300 font-mono">{{ previewImage.gen.idea_id }}</span>
</div>
<div class="flex justify-between">
<span class="text-slate-500 uppercase">Status:</span>
<Tag :value="previewImage.gen.status"
:severity="previewImage.gen.status === 'done' ? 'success' : (previewImage.gen.status === 'failed' ? 'danger' : 'info')"
class="!text-[8px] !px-1.5 !py-0 w-fit h-4" />
</div>
<div v-if="previewImage.gen.failed_reason" class="flex flex-col gap-1 mt-1 p-2 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400">
<span class="uppercase font-bold">Error:</span>
<span class="italic">{{ previewImage.gen.failed_reason }}</span>
</div>
</div>
</div>
</div>
<!-- References Used -->
<div v-if="previewImage?.gen?.assets_list?.length > 0" class="flex flex-col gap-2">
<label class="text-[10px] font-bold text-slate-500 uppercase tracking-widest px-1">References Used</label>
<div class="flex flex-wrap gap-2">
<div v-for="assetId in previewImage.gen.assets_list" :key="assetId"
class="w-10 h-10 rounded-lg overflow-hidden border border-white/10 shadow-lg">
<img :src="apiUrl + '/assets/' + assetId + '?thumbnail=true'"
class="w-full h-full object-cover" />
</div>
</div>
</div>
</div>
</div>
</div>
</Dialog>
</template>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.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);
}
</style>

View File

@@ -519,10 +519,16 @@ const qualityOptions = ref([{
}]) }])
const aspectRatio = ref({ key: "NINESIXTEEN", value: "9:16" }) const aspectRatio = ref({ key: "NINESIXTEEN", value: "9:16" })
const aspectRatioOptions = ref([ const aspectRatioOptions = ref([
{ key: "NINESIXTEEN", value: "9:16" }, { key: "ONEONE", value: "1:1" },
{ key: "FOURTHREE", value: "4:3" }, { key: "TWOTHREE", value: "2:3" },
{ key: "THREETWO", value: "3:2" },
{ key: "THREEFOUR", value: "3:4" }, { key: "THREEFOUR", value: "3:4" },
{ key: "SIXTEENNINE", value: "16:9" } { key: "FOURTHREE", value: "4:3" },
{ key: "FOURFIVE", value: "4:5" },
{ key: "FIVEFOUR", value: "5:4" },
{ key: "NINESIXTEEN", value: "9:16" },
{ key: "SIXTEENNINE", value: "16:9" },
{ key: "TWENTYONENINE", value: "21:9" }
]) ])
const assetsFirst = ref(0) const assetsFirst = ref(0)

View File

@@ -1,9 +1,9 @@
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue' import {computed, onBeforeUnmount, onMounted, ref, watch} from 'vue'
import { useRouter } from 'vue-router' import {useRouter} from 'vue-router'
import { dataService } from '../services/dataService' import {dataService} from '../services/dataService'
import { aiService } from '../services/aiService' import {aiService} from '../services/aiService'
import { postService } from '../services/postService' import {postService} from '../services/postService'
import Button from 'primevue/button' import Button from 'primevue/button'
import Textarea from 'primevue/textarea' import Textarea from 'primevue/textarea'
import InputText from 'primevue/inputtext' import InputText from 'primevue/inputtext'
@@ -11,15 +11,13 @@ import Dialog from 'primevue/dialog'
import Checkbox from 'primevue/checkbox' import Checkbox from 'primevue/checkbox'
import Dropdown from 'primevue/dropdown' import Dropdown from 'primevue/dropdown'
import DatePicker from 'primevue/datepicker' import DatePicker from 'primevue/datepicker'
import MultiSelect from 'primevue/multiselect' import Tag from 'primevue/tag'
import ProgressSpinner from 'primevue/progressspinner'
import ProgressBar from 'primevue/progressbar'
import Message from 'primevue/message'
import Skeleton from 'primevue/skeleton' import Skeleton from 'primevue/skeleton'
import { useAlbumStore } from '../stores/albums' import {useAlbumStore} from '../stores/albums'
import { useToast } from 'primevue/usetoast' import {useToast} from 'primevue/usetoast'
import Toast from 'primevue/toast' import Toast from 'primevue/toast'
import GenerationPreviewModal from '../components/GenerationPreviewModal.vue'
const router = useRouter() const router = useRouter()
const API_URL = import.meta.env.VITE_API_URL const API_URL = import.meta.env.VITE_API_URL
@@ -211,10 +209,16 @@ const qualityOptions = ref([
{ key: 'FOURK', value: '4K' } { key: 'FOURK', value: '4K' }
]) ])
const aspectRatioOptions = ref([ const aspectRatioOptions = ref([
{ key: "NINESIXTEEN", value: "9:16" }, { key: "ONEONE", value: "1:1" },
{ key: "FOURTHREE", value: "4:3" }, { key: "TWOTHREE", value: "2:3" },
{ key: "THREETWO", value: "3:2" },
{ key: "THREEFOUR", value: "3:4" }, { key: "THREEFOUR", value: "3:4" },
{ key: "SIXTEENNINE", value: "16:9" } { key: "FOURTHREE", value: "4:3" },
{ key: "FOURFIVE", value: "4:5" },
{ key: "FIVEFOUR", value: "5:4" },
{ key: "NINESIXTEEN", value: "9:16" },
{ key: "SIXTEENNINE", value: "16:9" },
{ key: "TWENTYONENINE", value: "21:9" }
]) ])
// --- Persistence --- // --- Persistence ---
@@ -589,7 +593,8 @@ const allPreviewImages = computed(() => {
images.push({ images.push({
url: API_URL + '/assets/' + assetId, url: API_URL + '/assets/' + assetId,
genId: gen.id, genId: gen.id,
prompt: gen.prompt prompt: gen.prompt,
gen: gen
}) })
} }
} }
@@ -598,48 +603,11 @@ const allPreviewImages = computed(() => {
}) })
const openImagePreview = (url) => { const openImagePreview = (url) => {
// Find index of this image in the flat list
const idx = allPreviewImages.value.findIndex(img => img.url === url) const idx = allPreviewImages.value.findIndex(img => img.url === url)
previewIndex.value = idx >= 0 ? idx : 0 previewIndex.value = idx >= 0 ? idx : 0
previewImage.value = allPreviewImages.value[previewIndex.value] || { url }
isImagePreviewVisible.value = true isImagePreviewVisible.value = true
} }
const navigatePreview = (direction) => {
const images = allPreviewImages.value
if (images.length === 0) return
let newIndex = previewIndex.value + direction
if (newIndex < 0) newIndex = images.length - 1
if (newIndex >= images.length) newIndex = 0
previewIndex.value = newIndex
previewImage.value = images[newIndex]
}
const onPreviewKeydown = (e) => {
if (!isImagePreviewVisible.value) return
if (e.key === 'ArrowLeft') {
e.preventDefault()
navigatePreview(-1)
} else if (e.key === 'ArrowRight') {
e.preventDefault()
navigatePreview(1)
} else if (e.key === 'Escape') {
isImagePreviewVisible.value = false
}
}
watch(isImagePreviewVisible, (visible) => {
if (visible) {
window.addEventListener('keydown', onPreviewKeydown)
} else {
window.removeEventListener('keydown', onPreviewKeydown)
}
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onPreviewKeydown)
})
const reusePrompt = (gen) => { const reusePrompt = (gen) => {
if (gen.prompt) { if (gen.prompt) {
prompt.value = gen.prompt prompt.value = gen.prompt
@@ -1392,44 +1360,15 @@ const confirmAddToAlbum = async () => {
<Dialog v-model:visible="isImagePreviewVisible" modal dismissableMask <GenerationPreviewModal
:style="{ width: '95vw', maxWidth: '1100px', background: 'transparent', boxShadow: 'none' }" v-model:visible="isImagePreviewVisible"
:pt="{ root: { class: '!bg-transparent !border-none !shadow-none' }, header: { class: '!hidden' }, content: { class: '!bg-transparent !p-0' } }"> :preview-images="allPreviewImages"
<div class="relative flex items-center justify-center" @click.self="isImagePreviewVisible = false"> :initial-index="previewIndex"
:api-url="API_URL"
<!-- Previous Button --> @reuse-prompt="reusePrompt"
<Button v-if="allPreviewImages.length > 1" icon="pi pi-chevron-left" @click.stop="navigatePreview(-1)" @reuse-asset="reuseAsset"
rounded text @use-result-as-asset="useResultAsAsset"
class="!absolute left-2 top-1/2 -translate-y-1/2 z-20 !text-white !bg-black/50 hover:!bg-black/70 !w-12 !h-12 !rounded-full !border !border-white/20 backdrop-blur-sm transition-all hover:!scale-110" /> />
<!-- Image -->
<img v-if="previewImage" :src="previewImage.url"
class="max-w-full max-h-[85vh] object-contain rounded-xl shadow-2xl select-none"
draggable="false" />
<!-- Next Button -->
<Button v-if="allPreviewImages.length > 1" icon="pi pi-chevron-right" @click.stop="navigatePreview(1)"
rounded text
class="!absolute right-2 top-1/2 -translate-y-1/2 z-20 !text-white !bg-black/50 hover:!bg-black/70 !w-12 !h-12 !rounded-full !border !border-white/20 backdrop-blur-sm transition-all hover:!scale-110" />
<!-- Close Button -->
<Button icon="pi pi-times" @click="isImagePreviewVisible = false" rounded text
class="!absolute -top-4 -right-4 z-20 !text-white !bg-black/50 hover:!bg-black/70 !w-10 !h-10" />
<!-- Counter -->
<div v-if="allPreviewImages.length > 1"
class="absolute bottom-4 left-1/2 -translate-x-1/2 z-20 bg-black/60 backdrop-blur-sm text-white text-sm font-mono px-4 py-1.5 rounded-full border border-white/10">
{{ previewIndex + 1 }} / {{ allPreviewImages.length }}
</div>
<!-- Prompt (click to copy) -->
<div v-if="previewImage?.prompt"
class="absolute bottom-14 left-1/2 -translate-x-1/2 z-20 bg-black/60 backdrop-blur-sm text-white/80 text-xs px-4 py-2 rounded-xl border border-white/10 max-w-md text-center line-clamp-2 cursor-pointer hover:bg-black/80 hover:border-white/20 transition-all"
v-tooltip.top="'Click to copy'" @click.stop="navigator.clipboard.writeText(previewImage.prompt)">
{{ previewImage.prompt }}
</div>
</div>
</Dialog>
<Dialog v-model:visible="isAssetPickerVisible" modal header="Select Assets" <Dialog v-model:visible="isAssetPickerVisible" modal header="Select Assets"
:style="{ width: '80vw', maxWidth: '900px' }" :style="{ width: '80vw', maxWidth: '900px' }"

View File

@@ -26,6 +26,8 @@ import FileUpload from 'primevue/fileupload'
import ConfirmDialog from 'primevue/confirmdialog' import ConfirmDialog from 'primevue/confirmdialog'
import InputText from 'primevue/inputtext' import InputText from 'primevue/inputtext'
import DatePicker from 'primevue/datepicker' import DatePicker from 'primevue/datepicker'
import Tag from 'primevue/tag'
import GenerationPreviewModal from '../components/GenerationPreviewModal.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -188,10 +190,16 @@ const qualityOptions = ref([
{ key: 'FOURK', value: '4K' } { key: 'FOURK', value: '4K' }
]) ])
const aspectRatioOptions = ref([ const aspectRatioOptions = ref([
{ key: "NINESIXTEEN", value: "9:16" }, { key: "ONEONE", value: "1:1" },
{ key: "FOURTHREE", value: "4:3" }, { key: "TWOTHREE", value: "2:3" },
{ key: "THREETWO", value: "3:2" },
{ key: "THREEFOUR", value: "3:4" }, { key: "THREEFOUR", value: "3:4" },
{ key: "SIXTEENNINE", value: "16:9" } { key: "FOURTHREE", value: "4:3" },
{ key: "FOURFIVE", value: "4:5" },
{ key: "FIVEFOUR", value: "5:4" },
{ key: "NINESIXTEEN", value: "9:16" },
{ key: "SIXTEENNINE", value: "16:9" },
{ key: "TWENTYONENINE", value: "21:9" }
]) ])
// Removed duplicate characters ref // Removed duplicate characters ref
@@ -594,29 +602,13 @@ const useResultAsAsset = (gen) => {
const isImagePreviewVisible = ref(false) const isImagePreviewVisible = ref(false)
const previewImages = ref([]) const previewImages = ref([])
const previewIndex = ref(0) const previewIndex = ref(0)
const openImagePreview = (imageList, startIdx = 0) => { const openImagePreview = (imageList, startIdx = 0) => {
// imageList should now be [{url, gen}]
previewImages.value = imageList previewImages.value = imageList
previewIndex.value = startIdx previewIndex.value = startIdx
isImagePreviewVisible.value = true isImagePreviewVisible.value = true
} }
const prevPreview = () => {
if (previewIndex.value > 0) previewIndex.value--
}
const nextPreview = () => {
if (previewIndex.value < previewImages.value.length - 1) previewIndex.value++
}
// Global keyboard nav for preview modal (Safari doesn't focus Dialog)
const handlePreviewKeydown = (e) => {
if (e.key === 'ArrowLeft') { prevPreview(); e.preventDefault() }
if (e.key === 'ArrowRight') { nextPreview(); e.preventDefault() }
if (e.key === 'Escape') { isImagePreviewVisible.value = false; e.preventDefault() }
}
watch(isImagePreviewVisible, (visible) => {
if (visible) window.addEventListener('keydown', handlePreviewKeydown)
else window.removeEventListener('keydown', handlePreviewKeydown)
})
onUnmounted(() => window.removeEventListener('keydown', handlePreviewKeydown))
// --- Computeds --- // --- Computeds ---
const groupedGenerations = computed(() => { const groupedGenerations = computed(() => {
@@ -1021,7 +1013,7 @@ watch(viewMode, (v) => {
class="relative group/img cursor-pointer aspect-[4/3]"> class="relative group/img cursor-pointer aspect-[4/3]">
<img :src="API_URL + '/assets/' + res + '?thumbnail=true'" <img :src="API_URL + '/assets/' + res + '?thumbnail=true'"
class="w-full h-full object-cover" class="w-full h-full object-cover"
@click="openImagePreview(gen.result_list.map(r => API_URL + '/assets/' + r), resIdx)" /> @click="openImagePreview(gen.result_list.map(r => ({ url: API_URL + '/assets/' + r, gen: gen })), resIdx)" />
<!-- Per-image hover overlay --> <!-- Per-image hover overlay -->
<div <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"> 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">
@@ -1100,7 +1092,7 @@ watch(viewMode, (v) => {
<div v-for="(img, idx) in allGalleryImages" :key="img.assetId" <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="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'" :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.map(i => i.url), idx)"> @click="isSelectMode ? toggleImageSelection(img.assetId) : openImagePreview(allGalleryImages, idx)">
<img :src="img.thumbnailUrl" class="w-full h-full object-cover" /> <img :src="img.thumbnailUrl" class="w-full h-full object-cover" />
@@ -1422,45 +1414,15 @@ watch(viewMode, (v) => {
</template> </template>
</Dialog> </Dialog>
<!-- Preview Modal with Slider --> <GenerationPreviewModal
<Dialog v-model:visible="isImagePreviewVisible" modal dismissableMask v-model:visible="isImagePreviewVisible"
:style="{ width: '90vw', maxWidth: '1200px', background: 'transparent', boxShadow: 'none' }" :preview-images="previewImages"
:pt="{ root: { class: '!bg-transparent !border-none !shadow-none' }, header: { class: '!hidden' }, content: { class: '!bg-transparent !p-0' } }" :initial-index="previewIndex"
@keydown.left.prevent="prevPreview" @keydown.right.prevent="nextPreview"> :api-url="dataService.API_URL || API_URL"
<div class="relative flex items-center justify-center h-[90vh]" @click="isImagePreviewVisible = false"> @reuse-prompt="reusePrompt"
<!-- Main image --> @reuse-asset="reuseAssets"
<img v-if="previewImages.length > 0" :src="previewImages[previewIndex]" @use-result-as-asset="useResultAsAsset"
class="max-w-full max-h-full object-contain rounded-xl shadow-2xl" @click.stop /> />
<!-- Prev arrow -->
<button v-if="previewImages.length > 1 && previewIndex > 0" @click.stop="prevPreview"
class="absolute left-4 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-black/60 hover:bg-black/80 backdrop-blur-sm flex items-center justify-center text-white transition-all shadow-lg z-20">
<i class="pi pi-chevron-left" style="font-size: 16px"></i>
</button>
<!-- Next arrow -->
<button v-if="previewImages.length > 1 && previewIndex < previewImages.length - 1"
@click.stop="nextPreview"
class="absolute right-4 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-black/60 hover:bg-black/80 backdrop-blur-sm flex items-center justify-center text-white transition-all shadow-lg z-20">
<i class="pi pi-chevron-right" style="font-size: 16px"></i>
</button>
<!-- Counter badge -->
<div v-if="previewImages.length > 1"
class="absolute top-4 right-4 bg-black/60 backdrop-blur-sm text-white text-sm font-bold px-3 py-1 rounded-full z-20">
{{ previewIndex + 1 }} / {{ previewImages.length }}
</div>
<!-- Dot indicators -->
<div v-if="previewImages.length > 1"
class="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2 z-20">
<button v-for="(img, idx) in previewImages" :key="'prev-dot-' + idx"
@click.stop="previewIndex = idx" class="w-2.5 h-2.5 rounded-full transition-all"
:class="previewIndex === idx ? 'bg-white scale-125' : 'bg-white/40 hover:bg-white/60'">
</button>
</div>
</div>
</Dialog>
<!-- Add to Content Plan Dialog --> <!-- Add to Content Plan Dialog -->
<Dialog v-model:visible="showAddToPlanDialog" header="Add to content plan" modal :style="{ width: '420px' }" <Dialog v-model:visible="showAddToPlanDialog" header="Add to content plan" modal :style="{ width: '420px' }"

View File

@@ -107,10 +107,16 @@ const qualityOptions = ref([
{ key: 'FOURK', value: '4K' } { key: 'FOURK', value: '4K' }
]) ])
const aspectRatioOptions = ref([ const aspectRatioOptions = ref([
{ key: "NINESIXTEEN", value: "9:16" }, { key: "ONEONE", value: "1:1" },
{ key: "FOURTHREE", value: "4:3" }, { key: "TWOTHREE", value: "2:3" },
{ key: "THREETWO", value: "3:2" },
{ key: "THREEFOUR", value: "3:4" }, { key: "THREEFOUR", value: "3:4" },
{ key: "SIXTEENNINE", value: "16:9" } { key: "FOURTHREE", value: "4:3" },
{ key: "FOURFIVE", value: "4:5" },
{ key: "FIVEFOUR", value: "5:4" },
{ key: "NINESIXTEEN", value: "9:16" },
{ key: "SIXTEENNINE", value: "16:9" },
{ key: "TWENTYONENINE", value: "21:9" }
]) ])
// Restore settings // Restore settings

View File

@@ -11,6 +11,8 @@ import Checkbox from 'primevue/checkbox'
import Dialog from 'primevue/dialog' import Dialog from 'primevue/dialog'
import Paginator from 'primevue/paginator' import Paginator from 'primevue/paginator'
import InputText from 'primevue/inputtext' import InputText from 'primevue/inputtext'
import Tag from 'primevue/tag'
import GenerationPreviewModal from '../components/GenerationPreviewModal.vue'
const router = useRouter() const router = useRouter()
const API_URL = import.meta.env.VITE_API_URL const API_URL = import.meta.env.VITE_API_URL
@@ -64,10 +66,16 @@ const qualityOptions = ref([
const aspectRatio = ref({ key: "NINESIXTEEN", value: "9:16" }) const aspectRatio = ref({ key: "NINESIXTEEN", value: "9:16" })
const aspectRatioOptions = ref([ const aspectRatioOptions = ref([
{ key: "NINESIXTEEN", value: "9:16" }, { key: "ONEONE", value: "1:1" },
{ key: "FOURTHREE", value: "4:3" }, { key: "TWOTHREE", value: "2:3" },
{ key: "THREETWO", value: "3:2" },
{ key: "THREEFOUR", value: "3:4" }, { key: "THREEFOUR", value: "3:4" },
{ key: "SIXTEENNINE", value: "16:9" } { key: "FOURTHREE", value: "4:3" },
{ key: "FOURFIVE", value: "4:5" },
{ key: "FIVEFOUR", value: "5:4" },
{ key: "NINESIXTEEN", value: "9:16" },
{ key: "SIXTEENNINE", value: "16:9" },
{ key: "TWENTYONENINE", value: "21:9" }
]) ])
// --- Data Loading --- // --- Data Loading ---
@@ -293,23 +301,16 @@ const restoreGeneration = async (gen) => {
if (foundAspect) aspectRatio.value = foundAspect if (foundAspect) aspectRatio.value = foundAspect
if (gen.status === 'done' && gen.result_list && gen.result_list.length > 0) { if (gen.status === 'done' && gen.result_list && gen.result_list.length > 0) {
// We need to fetch details or just display the image // Keep original gen object for preview and details
// history list usually has the main image preview
generatedResult.value = { generatedResult.value = {
...gen,
type: 'assets', type: 'assets',
// Mocking asset object structure from history usage in DetailView
assets: gen.result_list.map(id => ({ assets: gen.result_list.map(id => ({
id, id,
url: `/assets/${id}`, // This might need adjustment based on how API serves files url: `/assets/${id}`,
// Ideally history API should return full asset objects or URLs.
// If not, we rely on the implementation in CharacterDetailView:
// :src="API_URL + '/assets/' + gen.result_list[0]"
// So let's construct it similarly
})), })),
tech_prompt: gen.tech_prompt,
execution_time: gen.execution_time_seconds, execution_time: gen.execution_time_seconds,
api_execution_time: gen.api_execution_time_seconds, api_execution_time: gen.api_execution_time_seconds,
token_usage: gen.token_usage
} }
} }
} }
@@ -337,16 +338,11 @@ const handleImprovePrompt = async () => {
const isImagePreviewVisible = ref(false) const isImagePreviewVisible = ref(false)
const previewImage = ref(null) const previewImage = ref(null)
const openImagePreview = (url, name = 'Image Preview', createdAt = null) => { const openImagePreview = (url, name = 'Image Preview', createdAt = null, gen = null) => {
previewImage.value = { url, name, createdAt } previewImage.value = { url, name, createdAt, gen }
isImagePreviewVisible.value = true isImagePreviewVisible.value = true
} }
const formatDate = (dateString) => {
if (!dateString) return ''
return new Date(dateString).toLocaleString()
}
const undoImprovePrompt = () => { const undoImprovePrompt = () => {
if (previousPrompt.value) { if (previousPrompt.value) {
const temp = prompt.value const temp = prompt.value
@@ -419,10 +415,6 @@ const useResultAsReference = (gen) => {
// --- Utils --- // --- Utils ---
const copyToClipboard = () => {
// Implement if needed for prompt copying
}
// --- Lifecycle --- // --- Lifecycle ---
@@ -604,7 +596,7 @@ onMounted(() => {
v-if="generatedResult.type === 'assets' && generatedResult.assets && generatedResult.assets.length > 0"> v-if="generatedResult.type === 'assets' && generatedResult.assets && generatedResult.assets.length > 0">
<!-- Displaying the first asset as main preview --> <!-- Displaying the first asset as main preview -->
<img :src="API_URL + '/assets/' + generatedResult.assets[0].id" <img :src="API_URL + '/assets/' + generatedResult.assets[0].id"
@click="openImagePreview(API_URL + '/assets/' + generatedResult.assets[0].id, 'Generated Result', new Date().toISOString())" @click="openImagePreview(API_URL + '/assets/' + generatedResult.assets[0].id, 'Generated Result', new Date().toISOString(), generatedResult)"
class="w-full h-full object-contain cursor-pointer hover:scale-[1.01] transition-transform duration-300" /> class="w-full h-full object-contain cursor-pointer hover:scale-[1.01] transition-transform duration-300" />
</template> </template>
<template v-else> <template v-else>
@@ -757,18 +749,15 @@ onMounted(() => {
</Dialog> </Dialog>
<!-- Image Preview Modal --> <!-- Image Preview Modal -->
<Dialog v-model:visible="isImagePreviewVisible" modal dismissableMask <GenerationPreviewModal
:header="previewImage?.name || 'Image Preview'" :style="{ width: '90vw', maxWidth: '800px' }" v-model:visible="isImagePreviewVisible"
class="glass-panel rounded-2xl"> :preview-images="[previewImage].filter(i => !!i)"
<div v-if="previewImage" class="flex flex-col items-center"> :initial-index="0"
<img :src="previewImage.url" :alt="previewImage.name" :api-url="API_URL"
class="max-w-full max-h-[70vh] rounded-xl object-contain shadow-2xl" /> @reuse-prompt="reusePrompt"
<div class="mt-6 text-center"> @reuse-asset="reuseAsset"
<h2 class="text-2xl font-bold mb-2">{{ previewImage.name }}</h2> @use-result-as-asset="useResultAsReference"
<p v-if="previewImage.createdAt" class="text-slate-400">{{ formatDate(previewImage.createdAt) }}</p> />
</div>
</div>
</Dialog>
</template> </template>