feat: Implement multi-select and download functionality for gallery images and add npm install to the deployment script.

This commit is contained in:
xds
2026-02-16 17:37:41 +03:00
parent 073c94140f
commit fc9048fc94
2 changed files with 146 additions and 27 deletions

View File

@@ -2,6 +2,7 @@
ssh root@31.59.58.220 "
cd /root/ai/ai-service-front &&
git pull &&
npm install &&
npm run build &&
cp -r dist/* /var/www/ai.luminic.space/
"

View File

@@ -625,6 +625,72 @@ const deleteIdea = () => {
})
}
// --- Gallery Multi-Select & Download ---
const isSelectMode = ref(false)
const selectedAssetIds = ref(new Set())
const isDownloading = ref(false)
const toggleSelectMode = () => {
isSelectMode.value = !isSelectMode.value
if (!isSelectMode.value) selectedAssetIds.value = new Set()
}
const toggleImageSelection = (assetId) => {
const s = new Set(selectedAssetIds.value)
if (s.has(assetId)) s.delete(assetId)
else s.add(assetId)
selectedAssetIds.value = s
}
const selectAllGallery = () => {
if (selectedAssetIds.value.size === allGalleryImages.value.length) {
selectedAssetIds.value = new Set()
} else {
selectedAssetIds.value = new Set(allGalleryImages.value.map(i => i.assetId))
}
}
const downloadSelected = async () => {
const ids = [...selectedAssetIds.value]
if (ids.length === 0) return
isDownloading.value = true
try {
const user = JSON.parse(localStorage.getItem('user'))
const headers = {}
if (user && user.access_token) headers['Authorization'] = `Bearer ${user.access_token}`
else if (user && user.token) headers['Authorization'] = `${user.tokenType} ${user.token}`
const projectId = localStorage.getItem('active_project_id')
if (projectId) headers['X-Project-ID'] = projectId
for (const assetId of ids) {
const url = API_URL + '/assets/' + assetId
const resp = await fetch(url, { headers })
const blob = await resp.blob()
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = assetId + '.png'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(a.href)
}
toast.add({ severity: 'success', summary: 'Downloaded', detail: `${ids.length} image(s) saved`, life: 2000 })
} catch (e) {
console.error('Download failed', e)
toast.add({ severity: 'error', summary: 'Error', detail: 'Download failed', life: 3000 })
} finally {
isDownloading.value = false
}
}
// Exit select mode when switching to feed
watch(viewMode, (v) => {
if (v !== 'gallery') {
isSelectMode.value = false
selectedAssetIds.value = new Set()
}
})
</script>
<template>
@@ -767,36 +833,90 @@ const deleteIdea = () => {
</div>
<!-- GALLERY VIEW -->
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
<!-- Active generations (spinners) -->
<div v-for="gen in generations.filter(g => ['processing', 'starting', 'running'].includes(g.status) && (!g.result_list || g.result_list.length === 0))"
:key="'active-' + gen.id"
class="aspect-[2/3] relative rounded-xl overflow-hidden bg-slate-800 border border-white/5 flex items-center justify-center">
<ProgressSpinner style="width: 30px; height: 30px" />
<div v-else>
<!-- Gallery toolbar -->
<div class="flex items-center justify-between mb-4 gap-2">
<div class="flex items-center gap-2">
<button @click="toggleSelectMode"
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-all border"
:class="isSelectMode ? 'bg-violet-600 text-white border-violet-500' : 'bg-slate-800 text-slate-400 border-white/10 hover:text-white hover:border-white/20'">
<i class="pi pi-check-square mr-1"></i>
{{ isSelectMode ? 'Cancel' : 'Select' }}
</button>
<button v-if="isSelectMode" @click="selectAllGallery"
class="px-3 py-1.5 rounded-lg text-xs font-medium bg-slate-800 text-slate-400 border border-white/10 hover:text-white hover:border-white/20 transition-all">
{{ selectedAssetIds.size === allGalleryImages.length ? 'Deselect All' : 'Select All' }}
</button>
</div>
<span v-if="isSelectMode" class="text-xs text-slate-400">
{{ selectedAssetIds.size }} selected
</span>
</div>
<!-- All result images -->
<div v-for="(img, idx) in allGalleryImages" :key="img.assetId"
class="aspect-[2/3] relative rounded-xl overflow-hidden group bg-slate-800 border border-white/5 hover:border-violet-500/50 transition-all cursor-pointer"
@click="openImagePreview(allGalleryImages.map(i => i.url), idx)">
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
<!-- Active generations (spinners) -->
<div v-for="gen in generations.filter(g => ['processing', 'starting', 'running'].includes(g.status) && (!g.result_list || g.result_list.length === 0))"
:key="'active-' + gen.id"
class="aspect-[2/3] relative rounded-xl overflow-hidden bg-slate-800 border border-white/5 flex items-center justify-center">
<ProgressSpinner style="width: 30px; height: 30px" />
</div>
<img :src="img.thumbnailUrl" class="w-full h-full object-cover" />
<div
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="pi pi-pencil" rounded text size="small"
class="!text-white hover:!bg-white/20" v-tooltip.top="'Use as Reference'"
@click.stop="setAsReference(img.assetId)" />
<Button icon="pi pi-refresh" rounded text size="small"
class="!text-white hover:!bg-white/20" @click.stop="reusePrompt(img.gen)" />
<!-- Failed generations -->
<div v-for="gen in generations.filter(g => g.status === 'failed' && (!g.result_list || g.result_list.length === 0))"
:key="'failed-' + gen.id"
class="aspect-[2/3] relative rounded-xl overflow-hidden bg-red-950/30 border border-red-500/20 flex flex-col items-center justify-center gap-2 group">
<i class="pi pi-exclamation-triangle text-red-400 text-2xl"></i>
<span class="text-red-400 text-xs font-medium">Failed</span>
<p class="text-[10px] text-red-300/60 px-3 text-center line-clamp-2">{{ gen.prompt }}</p>
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button icon="pi pi-trash" rounded text size="small"
class="!text-red-400 hover:!bg-red-500/20"
@click.stop="deleteAssetFromGeneration(img.gen, img.assetId)" />
class="!text-red-400 hover:!bg-red-500/20" @click.stop="deleteGeneration(gen)" />
</div>
</div>
<!-- All result images -->
<div v-for="(img, idx) in allGalleryImages" :key="img.assetId"
class="aspect-[2/3] relative rounded-xl overflow-hidden group bg-slate-800 border-2 transition-all cursor-pointer"
:class="isSelectMode && selectedAssetIds.has(img.assetId) ? 'border-violet-500 ring-2 ring-violet-500/30' : 'border-white/5 hover:border-violet-500/50'"
@click="isSelectMode ? toggleImageSelection(img.assetId) : openImagePreview(allGalleryImages.map(i => i.url), idx)">
<img :src="img.thumbnailUrl" class="w-full h-full object-cover" />
<!-- Selection checkmark (always visible in select mode) -->
<div v-if="isSelectMode"
class="absolute top-2 left-2 w-7 h-7 rounded-full flex items-center justify-center transition-all shadow-lg z-10"
:class="selectedAssetIds.has(img.assetId) ? 'bg-violet-500' : 'bg-black/40 border border-white/30'">
<i v-if="selectedAssetIds.has(img.assetId)" class="pi pi-check text-white text-xs"></i>
</div>
<!-- Hover overlay (only in non-select mode) -->
<div v-if="!isSelectMode"
class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-end p-3">
<p class="text-xs text-white line-clamp-2 mb-2">{{ img.gen.prompt }}</p>
<div class="flex gap-2 justify-end">
<Button icon="pi pi-pencil" rounded text size="small"
class="!text-white hover:!bg-white/20" v-tooltip.top="'Use as Reference'"
@click.stop="setAsReference(img.assetId)" />
<Button icon="pi pi-refresh" rounded text size="small"
class="!text-white hover:!bg-white/20" @click.stop="reusePrompt(img.gen)" />
<Button icon="pi pi-trash" rounded text size="small"
class="!text-red-400 hover:!bg-red-500/20"
@click.stop="deleteAssetFromGeneration(img.gen, img.assetId)" />
</div>
</div>
</div>
</div>
<!-- Floating download bar -->
<transition name="fade">
<div v-if="isSelectMode && selectedAssetIds.size > 0"
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-[70] flex items-center gap-3 bg-slate-900/95 backdrop-blur-xl border border-white/10 rounded-full px-5 py-3 shadow-2xl">
<span class="text-sm text-white font-medium">{{ selectedAssetIds.size }} selected</span>
<Button :label="isDownloading ? 'Downloading...' : 'Download'" icon="pi pi-download"
:loading="isDownloading" @click="downloadSelected"
class="!bg-violet-600 !border-none !rounded-full !text-sm !font-bold hover:!bg-violet-500 !px-4 !py-2" />
</div>
</transition>
</div>
</div>
@@ -948,11 +1068,9 @@ const deleteIdea = () => {
</div>
<div class="mt-auto">
<Button
:label="isSubmitting ? 'Starting...' : (hasActiveGenerations ? 'Wait for completion' : 'Generate')"
<Button :label="isSubmitting ? 'Starting...' : 'Generate'"
:icon="isSubmitting ? 'pi pi-spin pi-spinner' : 'pi pi-sparkles'"
:loading="isSubmitting" :disabled="hasActiveGenerations || isSubmitting"
@click="handleGenerate"
:loading="isSubmitting" :disabled="isSubmitting" @click="handleGenerate"
class="w-full !py-2 !text-sm !font-bold !bg-gradient-to-r from-violet-600 to-cyan-500 !border-none !rounded-lg !shadow-lg !shadow-violet-500/20 hover:!scale-[1.02] transition-all disabled:opacity-50 disabled:cursor-not-allowed" />
</div>
</div>