feat: Implement multi-select and download functionality for gallery images and add npm install to the deployment script.
This commit is contained in:
@@ -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/
|
||||
"
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user