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 "
|
ssh root@31.59.58.220 "
|
||||||
cd /root/ai/ai-service-front &&
|
cd /root/ai/ai-service-front &&
|
||||||
git pull &&
|
git pull &&
|
||||||
|
npm install &&
|
||||||
npm run build &&
|
npm run build &&
|
||||||
cp -r dist/* /var/www/ai.luminic.space/
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -767,7 +833,27 @@ const deleteIdea = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- GALLERY VIEW -->
|
<!-- GALLERY VIEW -->
|
||||||
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
<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>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||||
<!-- Active generations (spinners) -->
|
<!-- 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))"
|
<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"
|
:key="'active-' + gen.id"
|
||||||
@@ -775,14 +861,36 @@ const deleteIdea = () => {
|
|||||||
<ProgressSpinner style="width: 30px; height: 30px" />
|
<ProgressSpinner style="width: 30px; height: 30px" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 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="deleteGeneration(gen)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- All result images -->
|
<!-- All result images -->
|
||||||
<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 border-white/5 hover:border-violet-500/50 transition-all cursor-pointer"
|
class="aspect-[2/3] relative rounded-xl overflow-hidden group bg-slate-800 border-2 transition-all cursor-pointer"
|
||||||
@click="openImagePreview(allGalleryImages.map(i => i.url), idx)">
|
: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" />
|
<img :src="img.thumbnailUrl" class="w-full h-full object-cover" />
|
||||||
|
|
||||||
<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"
|
||||||
|
: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">
|
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>
|
<p class="text-xs text-white line-clamp-2 mb-2">{{ img.gen.prompt }}</p>
|
||||||
<div class="flex gap-2 justify-end">
|
<div class="flex gap-2 justify-end">
|
||||||
@@ -799,6 +907,18 @@ const deleteIdea = () => {
|
|||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- SETTINGS PANEL (Bottom) -->
|
<!-- SETTINGS PANEL (Bottom) -->
|
||||||
@@ -948,11 +1068,9 @@ const deleteIdea = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-auto">
|
<div class="mt-auto">
|
||||||
<Button
|
<Button :label="isSubmitting ? 'Starting...' : 'Generate'"
|
||||||
:label="isSubmitting ? 'Starting...' : (hasActiveGenerations ? 'Wait for completion' : 'Generate')"
|
|
||||||
:icon="isSubmitting ? 'pi pi-spin pi-spinner' : 'pi pi-sparkles'"
|
:icon="isSubmitting ? 'pi pi-spin pi-spinner' : 'pi pi-sparkles'"
|
||||||
:loading="isSubmitting" :disabled="hasActiveGenerations || isSubmitting"
|
:loading="isSubmitting" :disabled="isSubmitting" @click="handleGenerate"
|
||||||
@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" />
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user