This commit is contained in:
xds
2026-02-24 12:04:17 +03:00
parent 1a7295aa77
commit a1d37ac517
8 changed files with 341 additions and 56 deletions

View File

@@ -3,6 +3,7 @@ import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import Dialog from 'primevue/dialog'
import Button from 'primevue/button'
import Tag from 'primevue/tag'
import { dataService } from '../services/dataService'
const props = defineProps({
visible: {
@@ -23,11 +24,40 @@ const props = defineProps({
}
})
const emit = defineEmits(['update:visible', 'reusePrompt', 'reuseAsset', 'useResultAsAsset'])
const emit = defineEmits(['update:visible', 'reusePrompt', 'reuseAsset', 'useResultAsAsset', 'liked'])
const previewIndex = ref(0)
const previewImage = computed(() => props.previewImages[previewIndex.value] || null)
// Like state management
const isTogglingLike = ref(false)
const localLikedStates = ref({}) // id -> bool
const isLiked = computed(() => {
if (!previewImage.value?.gen) return false
const id = previewImage.value.gen.id || previewImage.value.gen._id
if (localLikedStates.value[id] !== undefined) return localLikedStates.value[id]
return previewImage.value.gen.is_liked || false
})
const toggleLike = async () => {
const id = previewImage.value?.gen?.id || previewImage.value?.gen?._id
if (!id || isTogglingLike.value) return
isTogglingLike.value = true
try {
const response = await dataService.toggleLike(id)
// Assume response returns the new state or we just toggle it
const newState = response.is_liked !== undefined ? response.is_liked : !isLiked.value
localLikedStates.value[id] = newState
emit('liked', { id, is_liked: newState })
} catch (e) {
console.error('Failed to toggle like', e)
} finally {
isTogglingLike.value = false
}
}
// Reset index when modal opens
watch(() => props.visible, (newVal) => {
if (newVal) {
@@ -125,6 +155,13 @@ const onUseResultAsAsset = () => {
class="max-w-full max-h-[85vh] object-contain shadow-2xl transition-transform duration-300"
draggable="false" />
<!-- Like Button Overlay -->
<button v-if="previewImage?.gen" @click.stop="toggleLike"
class="absolute top-6 left-6 z-30 w-12 h-12 rounded-full backdrop-blur-md flex items-center justify-center transition-all hover:scale-110 active:scale-90 border border-white/10"
:class="isLiked ? 'bg-pink-500 text-white border-pink-400 shadow-[0_0_20px_rgba(236,72,153,0.4)]' : 'bg-black/40 text-white/70 hover:text-white'">
<i :class="isLiked ? 'pi pi-heart-fill' : 'pi pi-heart'" class="text-xl"></i>
</button>
<!-- 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">
@@ -158,6 +195,13 @@ const onUseResultAsAsset = () => {
<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="isLiked ? 'Liked' : 'Like'" :icon="isLiked ? 'pi pi-heart-fill' : 'pi pi-heart'"
@click="toggleLike" :loading="isTogglingLike"
:class="[
'!text-sm !justify-start',
isLiked ? '!bg-pink-600/20 !border-pink-500/30 !text-pink-400 hover:!bg-pink-600/30' : '!bg-slate-800/50 !border-white/10 !text-slate-300 hover:!bg-slate-800'
]" />
<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" />

View File

@@ -66,6 +66,11 @@ export const dataService = {
return response.data
},
toggleLike: async (id) => {
const response = await api.post(`/generations/${id}/like`)
return response.data
},
deleteGeneration: async (id) => {
const response = await api.delete(`/generations/${id}`)
return response.data

View File

@@ -11,6 +11,9 @@ import ConfirmDialog from 'primevue/confirmdialog'
import { useConfirm } from 'primevue/useconfirm'
import Dialog from 'primevue/dialog'
import GenerationPreviewModal from '../components/GenerationPreviewModal.vue'
import { dataService } from '../services/dataService'
const route = useRoute()
const router = useRouter()
const albumStore = useAlbumStore()
@@ -20,6 +23,32 @@ const confirm = useConfirm()
const generations = ref([])
const loadingGenerations = ref(false)
// Preview Modal State
const isImagePreviewVisible = ref(false)
const previewIndex = ref(0)
const previewImages = computed(() => {
return generations.value.map(gen => ({
url: API_URL + '/assets/' + (gen.result_list?.[0] || ''),
assetId: gen.result_list?.[0],
is_liked: gen.liked_assets?.includes(gen.result_list?.[0]),
gen: gen
}))
})
const handleLiked = ({ id, is_liked }) => {
// Update local state in generations
generations.value.forEach(gen => {
if (gen.result_list?.includes(id)) {
if (!gen.liked_assets) gen.liked_assets = []
if (is_liked) {
if (!gen.liked_assets.includes(id)) gen.liked_assets.push(id)
} else {
gen.liked_assets = gen.liked_assets.filter(aid => aid !== id)
}
}
})
}
// Gen Picker State
const isGenerationPickerVisible = ref(false)
const availableGenerations = ref([])
@@ -130,10 +159,9 @@ const removeGeneration = (gen) => {
}
// --- Image Preview ---
const isImagePreviewVisible = ref(false)
const previewImage = ref(null)
const openImagePreview = (url) => {
previewImage.value = { url }
const openImagePreview = (gen) => {
const idx = generations.value.findIndex(g => g.id === gen.id)
previewIndex.value = idx >= 0 ? idx : 0
isImagePreviewVisible.value = true
}
</script>
@@ -191,11 +219,17 @@ const openImagePreview = (url) => {
class="glass-panel rounded-xl overflow-hidden group relative transition-all hover:bg-white/5">
<div class="aspect-[2/3] w-full bg-slate-800 relative overflow-hidden cursor-pointer"
@click="gen.result_list && gen.result_list.length > 0 ? openImagePreview(API_URL + '/assets/' + gen.result_list[0]) : null">
@click="gen.result_list && gen.result_list.length > 0 ? openImagePreview(gen) : null">
<img v-if="gen.result_list && gen.result_list.length > 0"
:src="gen.result || API_URL + `/assets/${gen.result_list[0]}` + '?thumbnail=true'"
class="w-full h-full object-cover" />
<!-- Liked Badge -->
<div v-if="gen.liked_assets?.includes(gen.result_list?.[0])"
class="absolute top-2 right-2 z-10 w-6 h-6 rounded-full bg-pink-500 shadow-lg flex items-center justify-center border border-pink-400">
<i class="pi pi-heart-fill text-white text-[10px]"></i>
</div>
<!-- Overlay Actions -->
<div
class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2 pointer-events-none">
@@ -273,16 +307,13 @@ const openImagePreview = (url) => {
</Dialog>
<!-- 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>
<GenerationPreviewModal
v-model:visible="isImagePreviewVisible"
:preview-images="previewImages"
:initial-index="previewIndex"
:api-url="API_URL"
@liked="handleLiked"
/>
</div>
</template>

View File

@@ -17,6 +17,8 @@ import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import Image from 'primevue/image'
import GenerationPreviewModal from '../components/GenerationPreviewModal.vue'
const router = useRouter()
const confirm = useConfirm()
const toast = useToast()
@@ -26,6 +28,28 @@ const activeFilter = ref('all')
// @ts-ignore
const API_URL = import.meta.env.VITE_API_URL
// Preview Modal State
const isImagePreviewVisible = ref(false)
const previewIndex = ref(0)
const previewImages = computed(() => {
return assets.value.map(asset => ({
url: API_URL + (asset.url || asset.link),
assetId: asset.id,
is_liked: asset.is_liked || asset.liked,
name: asset.name,
created_at: asset.created_at,
// For assets, we might not have a full 'gen' object, but modal expects it for technical data
gen: asset.generation_id ? { id: asset.generation_id, prompt: asset.prompt } : null
}))
})
const handleLiked = ({ id, is_liked }: { id: string, is_liked: boolean }) => {
const asset = assets.value.find(a => a.id === id)
if (asset) {
asset.is_liked = is_liked
}
}
// Albums Logic
const albumStore = useAlbumStore()
const { albums, loading: albumsLoading } = storeToRefs(albumStore)
@@ -34,7 +58,6 @@ const showCreateDialog = ref(false)
const newAlbum = ref({ name: '', description: '' })
const submittingAlbum = ref(false)
const selectedAsset = ref<Asset | null>(null)
const isModalVisible = ref(false)
const first = ref(0)
@@ -69,8 +92,9 @@ const handleFileUpload = async (event: Event) => {
}
const openModal = (asset: Asset) => {
selectedAsset.value = asset
isModalVisible.value = true
const idx = assets.value.findIndex(a => a.id === asset.id)
previewIndex.value = idx >= 0 ? idx : 0
isImagePreviewVisible.value = true
}
const loadAssets = async () => {
@@ -245,6 +269,12 @@ const formatDate = (dateString: string) => {
:alt="asset.name"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" />
<!-- Liked Badge -->
<div v-if="asset.is_liked || asset.liked"
class="absolute top-2 left-2 z-10 w-6 h-6 rounded-full bg-pink-500 shadow-lg flex items-center justify-center border border-pink-400">
<i class="pi pi-heart-fill text-white text-[10px]"></i>
</div>
<!-- Type Badge -->
<div v-if="asset.type !== 'image'"
class="absolute top-2 right-2 bg-black/60 backdrop-blur-sm px-1.5 py-0.5 rounded text-[10px] uppercase font-bold text-white z-10 opacity-70 group-hover:opacity-100 transition-opacity">
@@ -368,17 +398,13 @@ const formatDate = (dateString: string) => {
</div>
</div>
<Dialog v-model:visible="isModalVisible" modal dismissableMask header="Asset View"
:style="{ width: '90vw', maxWidth: '800px' }" class="glass-panel rounded-2xl">
<div v-if="selectedAsset" class="flex flex-col items-center">
<img :src="selectedAsset.link ? API_URL + selectedAsset.link : (selectedAsset.url ? API_URL + selectedAsset.url : 'https://via.placeholder.com/800')"
:alt="selectedAsset.name" class="max-w-full max-h-[70vh] rounded-xl object-contain shadow-2xl" />
<div class="mt-6 text-center">
<h2 class="text-2xl font-bold mb-2">{{ selectedAsset.name }}</h2>
<p class="text-slate-400">{{ formatDate(selectedAsset.created_at) }}</p>
</div>
</div>
</Dialog>
<GenerationPreviewModal
v-model:visible="isImagePreviewVisible"
:preview-images="previewImages"
:initial-index="previewIndex"
:api-url="API_URL"
@liked="handleLiked"
/>
<!-- Create Album Dialog -->
<Dialog v-model:visible="showCreateDialog" modal header="Create New Album" :style="{ width: '500px' }"

View File

@@ -22,6 +22,8 @@ import Paginator from 'primevue/paginator'
import MultiSelect from 'primevue/multiselect'
import Dropdown from 'primevue/dropdown'
import GenerationPreviewModal from '../components/GenerationPreviewModal.vue'
const route = useRoute()
const router = useRouter()
const character = ref(null)
@@ -35,6 +37,51 @@ const historyFirst = ref(0)
const loading = ref(true)
const API_URL = import.meta.env.VITE_API_URL
// Preview Modal State
const isImagePreviewVisible = ref(false)
const previewIndex = ref(0)
const previewImages = ref([])
const openImagePreview = (images, index = 0) => {
previewImages.value = images
previewIndex.value = index
isImagePreviewVisible.value = true
}
const handleLiked = ({ id, is_liked }) => {
// Update local state in history
historyGenerations.value.forEach(gen => {
if (gen.result_list?.includes(id)) {
if (!gen.liked_assets) gen.liked_assets = []
if (is_liked) {
if (!gen.liked_assets.includes(id)) gen.liked_assets.push(id)
} else {
gen.liked_assets = gen.liked_assets.filter(aid => aid !== id)
}
}
})
// Update in character assets
characterAssets.value.forEach(asset => {
if (asset.id === id) {
asset.is_liked = is_liked
}
})
// Update in generatedResult if it's the one liked
if (generatedResult.value) {
if (generatedResult.value.type === 'assets') {
const found = generatedResult.value.assets.find(a => a.id === id)
if (found) {
if (!generatedResult.value.liked_assets) generatedResult.value.liked_assets = []
if (is_liked) {
if (!generatedResult.value.liked_assets.includes(id)) generatedResult.value.liked_assets.push(id)
} else {
generatedResult.value.liked_assets = generatedResult.value.liked_assets.filter(aid => aid !== id)
}
}
}
}
}
const selectedEnvironment = ref(null)
const isEnvModalVisible = ref(false)
const isEnvAssetPickerVisible = ref(false)
@@ -282,8 +329,14 @@ const openModal = (asset) => {
toggleBulkSelection(asset.id)
return
}
selectedAsset.value = asset
isModalVisible.value = true
const idx = characterAssets.value.findIndex(a => a.id === asset.id)
const images = characterAssets.value.map(a => ({
url: API_URL + a.url,
assetId: a.id,
is_liked: a.is_liked || a.liked,
gen: a.generation_id ? { id: a.generation_id, prompt: a.prompt } : null
}))
openImagePreview(images, idx >= 0 ? idx : 0)
}
const toggleBulkSelection = (id) => {
@@ -1267,7 +1320,7 @@ const handleGenerate = async () => {
:class="gen.children.length > 2 ? 'grid-cols-4' : 'grid-cols-2'">
<div v-for="child in gen.children" :key="child.id"
class="relative aspect-[9/16] rounded-md overflow-hidden bg-black/30 border border-white/5 group/child"
@click.stop="restoreGeneration(child)">
@click.stop="openImagePreview([{ url: API_URL + '/assets/' + child.result_list[0], assetId: child.result_list[0], is_liked: child.liked_assets?.includes(child.result_list[0]), gen: child }])">
<img v-if="child.result_list && child.result_list.length > 0"
:src="API_URL + '/assets/' + child.result_list[0] + '?thumbnail=true'"
@@ -1300,10 +1353,11 @@ const handleGenerate = async () => {
<div v-else class="flex gap-3 w-full">
<div class="w-12 h-12 rounded bg-black/40 border border-white/10 flex-shrink-0 mt-0.5 relative z-0"
@mouseenter="gen.result_list && gen.result_list[0] ? onThumbnailEnter($event, API_URL + '/assets/' + gen.result_list[0] + '?thumbnail=true') : null"
@mouseleave="onThumbnailLeave">
@mouseleave="onThumbnailLeave"
@click.stop="openImagePreview([{ url: API_URL + '/assets/' + gen.result_list[0], assetId: gen.result_list[0], is_liked: gen.liked_assets?.includes(gen.result_list[0]), gen: gen }])">
<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 rounded opacity-100" />
class="w-full h-full object-cover rounded opacity-100 cursor-pointer" />
<div v-else
class="w-full h-full flex items-center justify-center text-slate-700 overflow-hidden rounded">
<i class="pi pi-image text-lg" />
@@ -1559,18 +1613,16 @@ const handleGenerate = async () => {
Character not found.
</div>
<Dialog v-model:visible="isModalVisible" modal dismissableMask header="Asset View"
:style="{ width: '90vw', maxWidth: '800px' }" class="glass-panel rounded-2xl">
<div v-if="selectedAsset" class="flex flex-col items-center">
<img :src="selectedAsset.link ? API_URL + selectedAsset.link : (selectedAsset.url ? API_URL + selectedAsset.url : 'https://via.placeholder.com/800')"
:alt="selectedAsset.name"
class="max-w-full max-h-[70vh] rounded-xl object-contain shadow-2xl" />
<div class="mt-6 text-center">
<h2 class="text-2xl font-bold mb-2">{{ selectedAsset.name }}</h2>
<p class="text-slate-400">{{ selectedAsset.type }}</p>
</div>
</div>
</Dialog>
<GenerationPreviewModal
v-model:visible="isImagePreviewVisible"
:preview-images="previewImages"
:initial-index="previewIndex"
:api-url="API_URL"
@reuse-prompt="reusePrompt"
@reuse-asset="reuseAsset"
@use-result-as-asset="useResultAsReference"
@liked="handleLiked"
/>
<!-- Asset Selection Modal (Global) -->
<Dialog v-model:visible="isAssetSelectionVisible" modal header="Select Reference Assets"
:style="{ width: '80vw', maxWidth: '1000px' }" class="glass-panel rounded-2xl">

View File

@@ -592,6 +592,8 @@ const allPreviewImages = computed(() => {
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
@@ -602,6 +604,25 @@ const allPreviewImages = computed(() => {
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
@@ -896,6 +917,12 @@ const confirmAddToAlbum = async () => {
:src="API_URL + '/assets/' + child.result_list[0] + '?thumbnail=true'"
class="w-full h-full object-cover" />
<!-- Child: Liked badge -->
<div v-if="child.is_liked"
class="absolute top-1 right-1 z-20 w-4 h-4 rounded-full bg-pink-500 shadow-lg flex items-center justify-center border border-pink-400">
<i class="pi pi-heart-fill text-white text-[6px]"></i>
</div>
<!-- Child: processing -->
<div v-else-if="['processing', 'starting', 'running'].includes(child.status)"
class="w-full h-full flex flex-col items-center justify-center relative overflow-hidden bg-slate-800/50">
@@ -942,6 +969,10 @@ const confirmAddToAlbum = async () => {
<!-- Top right: edit, delete -->
<div class="flex justify-end items-start gap-0.5">
<Button :icon="child.is_liked ? 'pi pi-heart-fill' : 'pi pi-heart'" size="small"
class="!w-5 !h-5 !rounded-full !border-none !text-[8px] transition-colors"
:class="child.is_liked ? '!bg-pink-500 !text-white' : '!bg-white/20 !text-white hover:!bg-pink-500'"
@click.stop="toggleLike(child)" />
<Button icon="pi pi-pencil" size="small"
class="!w-5 !h-5 !rounded-full !bg-white/20 !border-none !text-white !text-[8px] hover:!bg-violet-500"
@click.stop="useResultAsAsset(child)" />
@@ -1000,6 +1031,12 @@ const confirmAddToAlbum = async () => {
class="w-full h-full object-cover cursor-pointer"
@click.stop="isSelectMode ? toggleImageSelection(item.result_list[0]) : openImagePreview(API_URL + '/assets/' + item.result_list[0])" />
<!-- Liked badge for single item -->
<div v-if="item.is_liked"
class="absolute top-2 right-2 z-20 w-6 h-6 rounded-full bg-pink-500 shadow-lg flex items-center justify-center border border-pink-400">
<i class="pi pi-heart-fill text-white text-[10px]"></i>
</div>
<!-- FAILED: error display -->
<div v-else-if="item.status === 'failed'"
class="w-full h-full flex flex-col items-center justify-between p-3 text-center bg-red-500/10 border border-red-500/20 relative group">
@@ -1064,6 +1101,10 @@ const confirmAddToAlbum = async () => {
<div
class="flex justify-end items-start translate-y-[-10px] group-hover:translate-y-0 transition-transform duration-200 w-full z-10">
<div class="flex gap-1">
<Button :icon="item.is_liked ? 'pi pi-heart-fill' : 'pi pi-heart'"
class="!w-6 !h-6 !rounded-full !border-none !text-[10px] transition-colors"
:class="item.is_liked ? '!bg-pink-500 !text-white' : '!bg-white/20 !text-white hover:!bg-pink-500'"
@click.stop="toggleLike(item)" />
<Button v-if="item.result_list && item.result_list.length > 0"
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"
@@ -1370,6 +1411,7 @@ const confirmAddToAlbum = async () => {
@reuse-prompt="reusePrompt"
@reuse-asset="reuseAsset"
@use-result-as-asset="useResultAsAsset"
@liked="handleLiked"
/>
<Dialog v-model:visible="isAssetPickerVisible" modal header="Select Assets"

View File

@@ -674,6 +674,11 @@ const groupedGenerations = computed(() => {
return result
})
const getChildByAssetId = (group, assetId) => {
if (!group.isGroup) return group;
return group.children.find(c => c.result_list?.includes(assetId)) || group;
}
const hasActiveGenerations = computed(() => {
return generations.value.some(g => ['processing', 'starting', 'running'].includes(g.status))
})
@@ -688,7 +693,8 @@ const allGalleryImages = computed(() => {
assetId,
url: API_URL + '/assets/' + assetId,
thumbnailUrl: API_URL + '/assets/' + assetId + '?thumbnail=true',
gen
gen,
is_liked: gen.liked_assets?.includes(assetId)
})
}
}
@@ -889,6 +895,25 @@ async function confirmAddToPlan() {
}
}
const handleLiked = ({ id, is_liked }) => {
// Update local state in generations
generations.value.forEach(gen => {
if (gen.id === id) {
gen.is_liked = is_liked
}
})
}
const toggleLike = async (gen) => {
if (!gen || !gen.id) return
try {
const response = await dataService.toggleLike(gen.id)
handleLiked({ id: gen.id, is_liked: response.is_liked })
} catch (e) {
console.error('Failed to toggle like', e)
}
}
// Exit select mode when switching to feed
watch(viewMode, (v) => {
if (v !== 'gallery') {
@@ -994,6 +1019,10 @@ watch(viewMode, (v) => {
</div>
<div
class="flex gap-1 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity">
<Button :icon="gen.is_liked ? 'pi pi-heart-fill' : 'pi pi-heart'" text rounded size="small"
class="!w-7 !h-7 !p-0"
:class="gen.is_liked ? '!text-pink-500' : '!text-slate-400 hover:!text-pink-500'"
v-tooltip.top="gen.is_liked ? 'Unlike' : 'Like'" @click="toggleLike(gen)" />
<Button icon="pi pi-copy" text rounded size="small"
class="!w-7 !h-7 !text-slate-400 hover:!text-white"
v-tooltip.top="'Reuse Prompt'" @click="reusePrompt(gen)" />
@@ -1016,19 +1045,32 @@ watch(viewMode, (v) => {
class="relative group/img cursor-pointer aspect-[4/3]">
<img :src="API_URL + '/assets/' + res + '?thumbnail=true'"
class="w-full h-full object-cover"
@click="openImagePreview(gen.result_list.map(r => ({ url: API_URL + '/assets/' + r, gen: gen })), resIdx)" />
@click="openImagePreview(gen.result_list.map(r => ({ url: API_URL + '/assets/' + r, gen: getChildByAssetId(gen, r), assetId: r, is_liked: getChildByAssetId(gen, r).is_liked })), resIdx)" />
<!-- Liked indicator -->
<div v-if="getChildByAssetId(gen, res).is_liked"
class="absolute top-1.5 left-1.5 w-5 h-5 rounded-full bg-pink-500 shadow-lg flex items-center justify-center border border-pink-400 z-10">
<i class="pi pi-heart-fill text-white text-[8px]"></i>
</div>
<!-- Per-image hover overlay -->
<div
class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover/img:opacity-100 transition-opacity duration-200 pointer-events-none">
</div>
<div
class="absolute bottom-1 right-1 flex gap-1 opacity-0 group-hover/img:opacity-100 transition-opacity duration-200">
<button @click.stop="toggleLike(getChildByAssetId(gen, res))"
class="w-6 h-6 rounded-md backdrop-blur-sm flex items-center justify-center text-white transition-all hover:scale-110 shadow-lg"
:class="getChildByAssetId(gen, res).is_liked ? 'bg-pink-500 hover:bg-pink-400' : 'bg-slate-700/80 hover:bg-pink-500'"
v-tooltip.top="getChildByAssetId(gen, res).is_liked ? 'Unlike' : 'Like'">
<i :class="getChildByAssetId(gen, res).is_liked ? 'pi pi-heart-fill' : 'pi pi-heart'" style="font-size: 10px"></i>
</button>
<button @click.stop="setAsReference(res)"
class="w-6 h-6 rounded-md bg-violet-600/80 hover:bg-violet-500 backdrop-blur-sm flex items-center justify-center text-white transition-all hover:scale-110 shadow-lg"
v-tooltip.top="'Use as Reference'">
<i class="pi pi-pencil" style="font-size: 10px"></i>
</button>
<button @click.stop="deleteAssetFromGeneration(gen, res)"
<button @click.stop="deleteAssetFromGeneration(getChildByAssetId(gen, res), res)"
class="w-6 h-6 rounded-md bg-red-600/80 hover:bg-red-500 backdrop-blur-sm flex items-center justify-center text-white transition-all hover:scale-110 shadow-lg"
v-tooltip.top="'Remove Image'">
<i class="pi pi-trash" style="font-size: 10px"></i>
@@ -1099,6 +1141,12 @@ watch(viewMode, (v) => {
<img :src="img.thumbnailUrl" class="w-full h-full object-cover" />
<!-- Liked Badge -->
<div v-if="img.is_liked"
class="absolute top-2 right-2 z-10 w-6 h-6 rounded-full bg-pink-500 shadow-lg flex items-center justify-center border border-pink-400">
<i class="pi pi-heart-fill text-white text-[10px]"></i>
</div>
<!-- Selection checkmark (always visible in select mode) -->
<div v-if="isSelectMode"
class="absolute top-2 left-2 w-7 h-7 rounded-full flex items-center justify-center transition-all shadow-lg z-10"
@@ -1111,6 +1159,11 @@ watch(viewMode, (v) => {
class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-end p-3">
<p class="text-xs text-white line-clamp-2 mb-2">{{ img.gen.prompt }}</p>
<div class="flex gap-2 justify-end">
<Button :icon="img.gen.is_liked ? 'pi pi-heart-fill' : 'pi pi-heart'" rounded text size="small"
class="!text-white transition-colors"
:class="img.gen.is_liked ? '!text-pink-500' : 'hover:!text-pink-500 hover:!bg-white/20'"
v-tooltip.top="img.gen.is_liked ? 'Unlike' : 'Like'"
@click.stop="toggleLike(img.gen)" />
<Button icon="pi pi-pencil" rounded text size="small"
class="!text-white hover:!bg-white/20" v-tooltip.top="'Use as Reference'"
@click.stop="setAsReference(img.assetId)" />
@@ -1425,6 +1478,7 @@ watch(viewMode, (v) => {
@reuse-prompt="reusePrompt"
@reuse-asset="reuseAssets"
@use-result-as-asset="useResultAsAsset"
@liked="handleLiked"
/>
<!-- Add to Content Plan Dialog -->

View File

@@ -338,11 +338,34 @@ const handleImprovePrompt = async () => {
const isImagePreviewVisible = ref(false)
const previewImage = ref(null)
const openImagePreview = (url, name = 'Image Preview', createdAt = null, gen = null) => {
previewImage.value = { url, name, createdAt, gen }
const openImagePreview = (url, name = 'Image Preview', createdAt = null, gen = null, assetId = null, isLiked = false) => {
previewImage.value = { url, name, createdAt, gen, assetId, is_liked: isLiked }
isImagePreviewVisible.value = true
}
const handleLiked = ({ id, is_liked }) => {
// Update local state in history
historyGenerations.value.forEach(gen => {
if (gen.result_list?.includes(id)) {
if (!gen.liked_assets) gen.liked_assets = []
if (is_liked) {
if (!gen.liked_assets.includes(id)) gen.liked_assets.push(id)
} else {
gen.liked_assets = gen.liked_assets.filter(aid => aid !== id)
}
}
})
// Also update generatedResult if it's the one liked
if (generatedResult.value && generatedResult.value.id === id || generatedResult.value?.result_list?.includes(id)) {
if (!generatedResult.value.liked_assets) generatedResult.value.liked_assets = []
if (is_liked) {
if (!generatedResult.value.liked_assets.includes(id)) generatedResult.value.liked_assets.push(id)
} else {
generatedResult.value.liked_assets = generatedResult.value.liked_assets.filter(aid => aid !== id)
}
}
}
const undoImprovePrompt = () => {
if (previousPrompt.value) {
const temp = prompt.value
@@ -596,7 +619,7 @@ onMounted(() => {
v-if="generatedResult.type === 'assets' && generatedResult.assets && generatedResult.assets.length > 0">
<!-- Displaying the first asset as main preview -->
<img :src="API_URL + '/assets/' + generatedResult.assets[0].id"
@click="openImagePreview(API_URL + '/assets/' + generatedResult.assets[0].id, 'Generated Result', new Date().toISOString(), generatedResult)"
@click="openImagePreview(API_URL + '/assets/' + generatedResult.assets[0].id, 'Generated Result', new Date().toISOString(), generatedResult, generatedResult.assets[0].id, generatedResult.liked_assets?.includes(generatedResult.assets[0].id))"
class="w-full h-full object-contain cursor-pointer hover:scale-[1.01] transition-transform duration-300" />
</template>
<template v-else>
@@ -657,13 +680,20 @@ onMounted(() => {
<div class="flex-1 overflow-y-auto pr-2 custom-scrollbar flex flex-col gap-2">
<div v-for="gen in historyGenerations" :key="gen.id"
class="glass-panel p-2 rounded-lg border border-white/5 flex flex-col gap-2 hover:bg-white/10 transition-colors group">
class="glass-panel p-2 rounded-lg border border-white/5 flex flex-col gap-2 hover:bg-white/10 transition-colors group relative">
<!-- Liked badge on history item -->
<div v-if="gen.liked_assets?.length > 0"
class="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-pink-500 shadow-lg flex items-center justify-center border border-pink-400 z-10">
<i class="pi pi-heart-fill text-white text-[8px]"></i>
</div>
<div class="flex gap-3 items-start cursor-pointer" @click="restoreGeneration(gen)">
<div
class="w-12 h-12 rounded bg-black/40 border border-white/10 overflow-hidden flex-shrink-0 mt-0.5">
class="w-12 h-12 rounded bg-black/40 border border-white/10 overflow-hidden flex-shrink-0 mt-0.5 relative group/hist">
<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" />
class="w-full h-full object-cover"
@click.stop="openImagePreview(API_URL + '/assets/' + gen.result_list[0], 'History Entry', gen.created_at, gen, gen.result_list[0], gen.liked_assets?.includes(gen.result_list[0]))" />
</div>
<div class="flex-1 min-w-0">
<p class="text-xs text-slate-300 truncate font-medium">{{ gen.prompt }}</p>
@@ -757,6 +787,7 @@ onMounted(() => {
@reuse-prompt="reusePrompt"
@reuse-asset="reuseAsset"
@use-result-as-asset="useResultAsReference"
@liked="handleLiked"
/>
</template>