Compare commits
19 Commits
674cbb8f16
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a737c38c53 | |||
| 2ed2ee2937 | |||
| 4f9807cfe7 | |||
| f89548b363 | |||
| 7d7cd25040 | |||
| dea0916f6c | |||
| 122c5a7cbc | |||
| a1d37ac517 | |||
| 1a7295aa77 | |||
| ccd7f8a2df | |||
| 4136f42e70 | |||
| b0ce251914 | |||
| 489fd14903 | |||
| 741857de92 | |||
| 6de5ded2fa | |||
| a6faa89686 | |||
| 27da4c042e | |||
| 0cc5150f9c | |||
| f8adcf33d3 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -37,3 +37,6 @@ __screenshots__/
|
||||
|
||||
# Vite
|
||||
*.timestamp-*-*.mjs
|
||||
|
||||
|
||||
package-lock.json
|
||||
1
package-lock.json
generated
1
package-lock.json
generated
@@ -23,6 +23,7 @@
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vite": "^7.3.1",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vite": "^7.3.1",
|
||||
|
||||
@@ -58,6 +58,12 @@
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--color-text);
|
||||
|
||||
@@ -218,19 +218,27 @@ h1, h2, h3, h4, h5, h6 {
|
||||
|
||||
/* --- Textarea / Inputs --- */
|
||||
.p-textarea,
|
||||
.p-inputtext {
|
||||
.p-inputtext,
|
||||
.p-dropdown,
|
||||
.p-multiselect,
|
||||
.p-autocomplete,
|
||||
.p-inputnumber input {
|
||||
width: 100%;
|
||||
background: rgba(15, 23, 42, 0.6) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||
border-radius: 8px !important;
|
||||
padding: 0.5rem !important;
|
||||
color: white !important;
|
||||
font-size: 0.8125rem !important;
|
||||
font-size: 1rem !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
.p-textarea:focus,
|
||||
.p-inputtext:focus {
|
||||
.p-inputtext:focus,
|
||||
.p-dropdown:focus,
|
||||
.p-multiselect:focus,
|
||||
.p-autocomplete:focus,
|
||||
.p-inputnumber input:focus {
|
||||
outline: none !important;
|
||||
border-color: #8b5cf6 !important;
|
||||
box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.1) !important;
|
||||
@@ -274,3 +282,59 @@ h1, h2, h3, h4, h5, h6 {
|
||||
background: rgba(255,255,255,0.1) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
/* --- ConfirmDialog --- */
|
||||
.p-confirmdialog {
|
||||
background: #1e293b !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||
border-radius: 1rem !important;
|
||||
}
|
||||
|
||||
.p-confirmdialog .p-dialog-header {
|
||||
background: transparent !important;
|
||||
color: white !important;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05) !important;
|
||||
}
|
||||
|
||||
.p-confirmdialog .p-dialog-content {
|
||||
background: transparent !important;
|
||||
color: #f8fafc !important;
|
||||
padding: 2rem !important;
|
||||
}
|
||||
|
||||
.p-confirmdialog .p-dialog-footer {
|
||||
background: transparent !important;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05) !important;
|
||||
padding: 1rem !important;
|
||||
display: flex !important;
|
||||
justify-content: flex-end !important;
|
||||
gap: 0.5rem !important;
|
||||
}
|
||||
|
||||
/* --- Specific Button Styles --- */
|
||||
.p-button-danger {
|
||||
background: #ef4444 !important;
|
||||
border-color: #ef4444 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.p-button-danger:hover {
|
||||
background: #dc2626 !important;
|
||||
border-color: #dc2626 !important;
|
||||
}
|
||||
|
||||
.p-button-secondary {
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
border-color: transparent !important;
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
|
||||
.p-button-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.15) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.p-button-text {
|
||||
background: transparent !important;
|
||||
border-color: transparent !important;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useProjectsStore } from '@/stores/projectsStore'
|
||||
import { aiService } from '@/services/aiService'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
|
||||
@@ -13,6 +14,17 @@ const projectsStore = useProjectsStore()
|
||||
const { projects, currentProject } = storeToRefs(projectsStore)
|
||||
|
||||
const selectedProject = ref(null)
|
||||
const usageCost = ref(0)
|
||||
|
||||
const fetchUsage = async () => {
|
||||
try {
|
||||
// Fetch current context usage (user or project depending on header)
|
||||
const report = await aiService.getUsageReport()
|
||||
usageCost.value = report.summary?.total_cost || 0
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch sidebar usage", e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Ensure we have projects
|
||||
@@ -23,6 +35,7 @@ onMounted(async () => {
|
||||
if (currentProject.value) {
|
||||
selectedProject.value = currentProject.value.id
|
||||
}
|
||||
fetchUsage()
|
||||
})
|
||||
|
||||
// Watch for external changes (like selecting from the list view)
|
||||
@@ -91,52 +104,48 @@ const navItems = computed(() => {
|
||||
<div class="contents">
|
||||
<!-- Sidebar (Desktop -> Top Bar) -->
|
||||
<nav
|
||||
class="hidden md:flex glass-panel w-[calc(100%-2rem)] mx-4 mt-4 mb-2 flex-row items-center px-8 py-3 rounded-2xl z-40 border border-white/5 bg-slate-900/50 backdrop-blur-md shrink-0 justify-between">
|
||||
class="hidden md:flex glass-panel w-[calc(100%-2rem)] mx-4 mt-2 mb-1 flex-row items-center px-4 py-1.5 rounded-xl z-40 border border-white/5 bg-slate-900/50 backdrop-blur-md shrink-0 justify-between">
|
||||
|
||||
<!-- Logo -->
|
||||
<img src="/web-app-manifest-512x512.png" alt="Logo"
|
||||
class="w-10 h-10 rounded-xl shadow-lg shadow-violet-500/20 shrink-0" />
|
||||
class="w-7 h-7 rounded-lg shadow-lg shadow-violet-500/20 shrink-0" />
|
||||
|
||||
<!-- Project Switcher -->
|
||||
<div class="hidden lg:block ml-4 relative">
|
||||
<div class="hidden lg:block ml-2 relative">
|
||||
<button @click="isProjectMenuOpen = !isProjectMenuOpen"
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-white/5 transition-colors text-slate-400 hover:text-slate-200">
|
||||
<i v-if="selectedProject" class="pi pi-folder text-violet-400"></i>
|
||||
<i v-else class="pi pi-user"></i>
|
||||
class="flex items-center gap-1.5 px-2 py-1 rounded-lg hover:bg-white/5 transition-colors text-slate-400 hover:text-slate-200 text-xs">
|
||||
<i v-if="selectedProject" class="pi pi-folder text-violet-400 text-[10px]"></i>
|
||||
<i v-else class="pi pi-user text-[10px]"></i>
|
||||
|
||||
<span class="max-w-[150px] truncate font-medium">
|
||||
{{ selectedProject ? getProjectName(selectedProject) : 'Personal Workspace' }}
|
||||
<span class="max-w-[120px] truncate font-medium">
|
||||
{{ selectedProject ? getProjectName(selectedProject) : 'Personal' }}
|
||||
</span>
|
||||
|
||||
<i class="pi pi-chevron-down text-xs ml-1 opacity-50"></i>
|
||||
<i class="pi pi-chevron-down text-[8px] opacity-50"></i>
|
||||
</button>
|
||||
|
||||
<!-- Custom Dropdown Menu -->
|
||||
<div v-if="isProjectMenuOpen"
|
||||
class="absolute top-full left-0 mt-2 w-56 bg-slate-900 border border-white/10 shadow-xl rounded-xl overflow-hidden z-50 py-1">
|
||||
class="absolute top-full left-0 mt-1 w-48 bg-slate-900 border border-white/10 shadow-xl rounded-lg overflow-hidden z-50 py-1">
|
||||
|
||||
<!-- Personal Workspace Option -->
|
||||
<div @click="selectProject(null)"
|
||||
class="flex items-center gap-3 px-4 py-3 hover:bg-white/5 cursor-pointer transition-colors"
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-white/5 cursor-pointer transition-colors text-xs"
|
||||
:class="{ 'text-violet-400 bg-white/5': !selectedProject, 'text-slate-300': selectedProject }">
|
||||
<i class="pi pi-user"></i>
|
||||
<i class="pi pi-user text-[10px]"></i>
|
||||
<span class="font-medium">Personal Workspace</span>
|
||||
<i v-if="!selectedProject" class="pi pi-check ml-auto text-sm"></i>
|
||||
<i v-if="!selectedProject" class="pi pi-check ml-auto text-[10px]"></i>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-white/5 my-1"></div>
|
||||
|
||||
<!-- Project Options -->
|
||||
<div v-for="project in projects" :key="project.id" @click="selectProject(project.id)"
|
||||
class="flex items-center gap-3 px-4 py-3 hover:bg-white/5 cursor-pointer transition-colors"
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-white/5 cursor-pointer transition-colors text-xs"
|
||||
:class="{ 'text-violet-400 bg-white/5': selectedProject === project.id, 'text-slate-300': selectedProject !== project.id }">
|
||||
<i class="pi pi-folder"></i>
|
||||
<i class="pi pi-folder text-[10px]"></i>
|
||||
<span class="truncate">{{ project.name }}</span>
|
||||
<i v-if="selectedProject === project.id" class="pi pi-check ml-auto text-sm"></i>
|
||||
</div>
|
||||
|
||||
<div v-if="projects.length === 0" class="px-4 py-3 text-slate-500 text-sm font-italic">
|
||||
No projects found
|
||||
<i v-if="selectedProject === project.id" class="pi pi-check ml-auto text-[10px]"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -146,27 +155,33 @@ const navItems = computed(() => {
|
||||
</div>
|
||||
|
||||
<!-- Nav Items -->
|
||||
<div class="flex flex-row gap-2 items-center justify-center flex-1 mx-8">
|
||||
<div class="flex flex-row gap-1 items-center justify-center flex-1 mx-4">
|
||||
<div v-for="item in navItems" :key="item.path" :class="[
|
||||
'px-4 py-2 flex items-center gap-2 rounded-xl cursor-pointer transition-all duration-300',
|
||||
'px-3 py-1 flex items-center gap-1.5 rounded-lg cursor-pointer transition-all duration-300',
|
||||
isActive(item.path)
|
||||
? 'bg-white/10 text-slate-50 shadow-inner'
|
||||
: 'text-slate-400 hover:bg-white/5 hover:text-slate-50'
|
||||
]" @click="router.push(item.path)" v-tooltip.bottom="item.tooltip">
|
||||
<span class="text-xl">{{ item.icon }}</span>
|
||||
<span class="text-sm font-medium hidden lg:block">{{ item.tooltip }}</span>
|
||||
<span class="text-base">{{ item.icon }}</span>
|
||||
<span class="text-[11px] font-medium hidden lg:block">{{ item.tooltip }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Actions -->
|
||||
<div class="flex items-center gap-4 shrink-0">
|
||||
<div class="flex items-center gap-3 shrink-0">
|
||||
<!-- Usage Stat -->
|
||||
<div v-if="usageCost > 0" class="hidden xl:flex flex-col items-end mr-1">
|
||||
<span class="text-[8px] font-bold text-slate-500 uppercase tracking-tighter">Usage</span>
|
||||
<span class="text-xs font-bold text-violet-400">${{ usageCost.toFixed(2) }}</span>
|
||||
</div>
|
||||
|
||||
<div @click="handleLogout"
|
||||
class="w-10 h-10 rounded-xl bg-red-500/10 text-red-400 flex items-center justify-center cursor-pointer hover:bg-red-500/20 transition-all font-bold"
|
||||
class="w-7 h-7 rounded-lg bg-red-500/10 text-red-400 flex items-center justify-center cursor-pointer hover:bg-red-500/20 transition-all font-bold"
|
||||
v-tooltip.bottom="'Logout'">
|
||||
<i class="pi pi-power-off"></i>
|
||||
<i class="pi pi-power-off text-xs"></i>
|
||||
</div>
|
||||
<!-- Profile Avatar Placeholder -->
|
||||
<div class="w-10 h-10 rounded-full bg-slate-800 border-2 border-violet-600 flex items-center justify-center font-bold text-slate-50 cursor-pointer hover:scale-105 transition-all"
|
||||
<div class="w-7 h-7 rounded-full bg-slate-800 border-2 border-violet-600 flex items-center justify-center font-bold text-slate-50 cursor-pointer hover:scale-105 transition-all text-xs"
|
||||
title="Profile">
|
||||
U
|
||||
</div>
|
||||
@@ -175,14 +190,14 @@ const navItems = computed(() => {
|
||||
|
||||
<!-- Mobile Bottom Nav -->
|
||||
<nav
|
||||
class="md:hidden fixed bottom-0 left-0 right-0 h-16 bg-slate-900/90 backdrop-blur-xl border-t border-white/10 z-50 flex justify-around items-center px-2">
|
||||
class="md:hidden fixed bottom-0 left-0 right-0 h-16 pb-4 bg-slate-900/90 backdrop-blur-xl border-t border-white/10 z-50 flex justify-around items-center px-2">
|
||||
<div v-for="item in navItems" :key="item.path" :class="[
|
||||
'flex flex-col items-center gap-1 p-2 rounded-xl transition-all',
|
||||
'flex flex-col items-center p-1.5 rounded-lg transition-all',
|
||||
isActive(item.path)
|
||||
? 'text-white bg-white/10 relative top-[-10px] shadow-lg shadow-violet-500/20 border border-violet-500/30'
|
||||
? 'text-white bg-white/10 shadow-lg shadow-violet-500/20 border border-violet-500/30'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
]" @click="router.push(item.path)">
|
||||
<span class="text-xl">{{ item.icon }}</span>
|
||||
<span class="text-lg">{{ item.icon }}</span>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
177
src/components/GenerationImage.vue
Normal file
177
src/components/GenerationImage.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
const props = defineProps({
|
||||
generation: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
apiUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
isSelectMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showNsfwGlobal: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
activeOverlayId: {
|
||||
type: [String, Number],
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['toggle-select', 'open-preview', 'toggle-like', 'delete', 'reuse-prompt', 'reuse-asset', 'use-result', 'toggle-overlay', 'mark-nsfw'])
|
||||
|
||||
const isTemporarilyUnblurred = ref(false)
|
||||
|
||||
const isBlurred = computed(() => {
|
||||
return (props.generation.is_nsfw || props.generation.nsfw) && !props.showNsfwGlobal && !isTemporarilyUnblurred.value
|
||||
})
|
||||
|
||||
const toggleBlur = () => {
|
||||
isTemporarilyUnblurred.value = !isTemporarilyUnblurred.value
|
||||
}
|
||||
|
||||
const handleImageClick = (e) => {
|
||||
if (props.isSelectMode) {
|
||||
emit('toggle-select', props.generation.result_list[0])
|
||||
} else {
|
||||
if (isBlurred.value) {
|
||||
emit('open-preview', props.apiUrl + '/assets/' + props.generation.result_list[0])
|
||||
} else {
|
||||
emit('open-preview', props.apiUrl + '/assets/' + props.generation.result_list[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleOverlayClick = () => {
|
||||
emit('toggle-overlay', props.generation.id)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full h-full relative group" @click="handleOverlayClick">
|
||||
|
||||
<!-- Image -->
|
||||
<div class="w-full h-full overflow-hidden relative">
|
||||
<img v-if="generation.result_list && generation.result_list.length > 0"
|
||||
:src="apiUrl + '/assets/' + generation.result_list[0] + '?thumbnail=true'"
|
||||
class="w-full h-full object-cover transition-all duration-300"
|
||||
:class="{ 'blur-xl scale-110': isBlurred, 'cursor-pointer': !isSelectMode }"
|
||||
@click.stop="handleImageClick"
|
||||
/>
|
||||
|
||||
<!-- NSFW Badge / Unblur Button -->
|
||||
<div v-if="isBlurred" class="absolute inset-0 flex flex-col items-center justify-center z-20 pointer-events-none">
|
||||
<div class="bg-black/60 backdrop-blur-md px-3 py-1.5 rounded-full border border-white/10 flex items-center gap-2 pointer-events-auto cursor-pointer hover:bg-black/80 transition-colors" @click.stop="toggleBlur">
|
||||
<i class="pi pi-eye-slash text-red-400 text-xs"></i>
|
||||
<span class="text-[10px] font-bold text-red-400 uppercase tracking-wider">NSFW</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liked badge -->
|
||||
<div v-if="generation.is_liked && !isBlurred"
|
||||
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 state -->
|
||||
<div v-if="generation.status === 'failed'"
|
||||
class="absolute inset-0 flex flex-col items-center justify-between p-3 text-center bg-red-500/10 border border-red-500/20">
|
||||
<div class="w-full flex justify-end">
|
||||
<Button icon="pi pi-trash" class="!w-6 !h-6 !rounded-full !bg-red-500/20 !border-none !text-red-400 text-[10px] hover:!bg-red-500 hover:!text-white z-10"
|
||||
@click.stop="emit('delete', generation)" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center flex-1">
|
||||
<i class="pi pi-times-circle text-red-500 text-2xl mb-1"></i>
|
||||
<span class="text-[10px] font-bold text-red-400 uppercase tracking-wide">Failed</span>
|
||||
<span v-if="generation.failed_reason" class="text-[8px] text-red-300/70 mt-1 line-clamp-3 leading-tight"
|
||||
v-tooltip.top="generation.failed_reason">{{ generation.failed_reason }}</span>
|
||||
</div>
|
||||
<div class="w-full flex gap-1 z-10">
|
||||
<Button icon="pi pi-comment" class="!w-6 !h-6 flex-1 !bg-white/10 !border-white/10 !text-slate-200 text-[10px] hover:!bg-white/20"
|
||||
@click.stop="emit('reuse-prompt', generation)" />
|
||||
<Button icon="pi pi-images" class="!w-6 !h-6 flex-1 !bg-white/10 !border-white/10 !text-slate-200 text-[10px] hover:!bg-white/20"
|
||||
@click.stop="emit('reuse-asset', generation)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PROCESSING state -->
|
||||
<div v-else-if="['processing', 'starting', 'running'].includes(generation.status)"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center bg-slate-800/50 border border-violet-500/20">
|
||||
<div class="absolute inset-0 bg-gradient-to-tr from-violet-500/5 via-violet-500/10 to-cyan-500/5 animate-pulse"></div>
|
||||
<i class="pi pi-spin pi-spinner text-violet-500 text-xl mb-2 relative z-10"></i>
|
||||
<span class="text-[10px] text-violet-300/70 relative z-10 capitalize">{{ generation.status }}...</span>
|
||||
</div>
|
||||
|
||||
<!-- HOVER OVERLAY (Success & Not Blurred) -->
|
||||
<div v-if="generation.result_list && generation.result_list.length > 0 && !isBlurred"
|
||||
class="absolute inset-0 bg-black/60 transition-opacity duration-200 flex flex-col justify-between p-2 z-10"
|
||||
:class="{ 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto': activeOverlayId !== generation.id, 'opacity-100 pointer-events-auto': activeOverlayId === generation.id }">
|
||||
|
||||
<!-- Top Right -->
|
||||
<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="generation.is_liked ? 'pi pi-heart-fill' : 'pi pi-heart'"
|
||||
class="!w-6 !h-6 !rounded-full !border-none !text-[10px] transition-colors"
|
||||
:class="generation.is_liked ? '!bg-pink-500 !text-white' : '!bg-white/20 !text-white hover:!bg-pink-500'"
|
||||
@click.stop="emit('toggle-like', generation)" />
|
||||
<Button icon="pi pi-pencil"
|
||||
class="!w-6 !h-6 !rounded-full !bg-white/20 !border-none !text-white text-[10px] hover:!bg-violet-500"
|
||||
@click.stop="emit('use-result', generation)" />
|
||||
<Button :icon="(generation.is_nsfw || generation.nsfw) ? 'pi pi-eye' : 'pi pi-eye-slash'"
|
||||
class="!w-6 !h-6 !rounded-full !bg-white/20 !border-none !text-white text-[10px] hover:!bg-red-500 hover:!text-white"
|
||||
@click.stop="emit('mark-nsfw', generation)"
|
||||
v-tooltip.bottom="(generation.is_nsfw || generation.nsfw) ? 'Unmark NSFW' : 'Mark NSFW'" />
|
||||
<Button icon="pi pi-trash"
|
||||
class="!w-6 !h-6 !rounded-full !bg-red-500/20 !border-none !text-red-400 text-[10px] hover:!bg-red-500 hover:!text-white"
|
||||
@click.stop="emit('delete', generation)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center -->
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none z-0">
|
||||
<div class="flex flex-col items-center gap-0.5 mb-2 pointer-events-none">
|
||||
<span class="text-[10px] font-bold text-slate-300 font-mono tracking-wider">{{ generation.cost }} $</span>
|
||||
<span v-if="generation.execution_time_seconds" class="text-[8px] text-slate-500 font-mono">{{ generation.execution_time_seconds.toFixed(1) }}s</span>
|
||||
</div>
|
||||
<Button icon="pi pi-eye" rounded text
|
||||
class="!bg-black/50 !text-white !w-12 !h-12 !rounded-full hover:!bg-black/70 hover:!scale-110 transition-all pointer-events-auto !border-2 !border-white/20"
|
||||
@click.stop="emit('open-preview', apiUrl + '/assets/' + generation.result_list[0])" />
|
||||
</div>
|
||||
|
||||
<!-- Bottom -->
|
||||
<div class="translate-y-[10px] group-hover:translate-y-0 transition-transform duration-200 z-10">
|
||||
<div class="flex gap-1 mb-1">
|
||||
<Button icon="pi pi-comment" class="!w-6 !h-6 flex-1 !bg-white/10 !border-white/10 !text-slate-200 text-[10px] hover:!bg-white/20"
|
||||
@click.stop="emit('reuse-prompt', generation)" />
|
||||
<Button icon="pi pi-images" class="!w-6 !h-6 flex-1 !bg-white/10 !border-white/10 !text-slate-200 text-[10px] hover:!bg-white/20"
|
||||
@click.stop="emit('reuse-asset', generation)" />
|
||||
</div>
|
||||
<p class="text-[10px] text-white/70 line-clamp-1 leading-tight">{{ generation.prompt }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Select mode checkbox overlay -->
|
||||
<div v-if="isSelectMode && generation.result_list && generation.result_list.length > 0"
|
||||
class="absolute inset-0 z-30 cursor-pointer"
|
||||
@click.stop="emit('toggle-select', generation.result_list[0])">
|
||||
<div class="absolute top-2 left-2 w-6 h-6 rounded-lg flex items-center justify-center transition-all"
|
||||
:class="isSelected ? 'bg-violet-600 text-white' : 'bg-black/40 border border-white/30 text-transparent hover:border-white/60'">
|
||||
<i class="pi pi-check" style="font-size: 12px"></i>
|
||||
</div>
|
||||
<div v-if="isSelected" class="absolute inset-0 bg-violet-600/20 pointer-events-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
362
src/components/GenerationPreviewModal.vue
Normal file
362
src/components/GenerationPreviewModal.vue
Normal file
@@ -0,0 +1,362 @@
|
||||
<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'
|
||||
import { dataService } from '../services/dataService'
|
||||
|
||||
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', '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) {
|
||||
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" />
|
||||
|
||||
<!-- 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">
|
||||
{{ 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="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" />
|
||||
|
||||
<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">Model</span>
|
||||
<span class="text-xs text-violet-400 font-bold truncate" :title="previewImage.gen.model">{{ previewImage.gen.model || 'N/A' }}</span>
|
||||
</div>
|
||||
<div v-if="previewImage.gen.seed !== undefined && previewImage.gen.seed !== null" class="flex flex-col gap-0.5">
|
||||
<span class="text-[10px] text-slate-500 uppercase">Seed</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-xs text-slate-200 font-mono">{{ previewImage.gen.seed }}</span>
|
||||
<Button icon="pi pi-copy" @click="copyToClipboard(previewImage.gen.seed.toString())"
|
||||
text class="!p-0 !w-3 !h-3 !text-slate-500 hover:!text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
@@ -52,9 +52,10 @@ export const aiService = {
|
||||
},
|
||||
|
||||
// Get generations history
|
||||
async getGenerations(limit, offset, characterId) {
|
||||
async getGenerations(limit, offset, characterId, onlyLiked = false) {
|
||||
const params = { limit, offset }
|
||||
if (characterId) params.character_id = characterId
|
||||
if (onlyLiked) params.only_liked = true
|
||||
const response = await api.get('/generations', { params })
|
||||
return response.data
|
||||
},
|
||||
@@ -66,5 +67,30 @@ export const aiService = {
|
||||
linked_assets: linkedAssets
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Mark generation as NSFW
|
||||
async markGenerationNsfw(generationId, isNsfw = true) {
|
||||
const response = await api.post(`/generations/${generationId}/nsfw`, {
|
||||
is_nsfw: isNsfw
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get usage statistics (runs, tokens, cost)
|
||||
async getUsageReport(breakdown = null, projectId = null) {
|
||||
const params = {}
|
||||
if (breakdown) params.breakdown = breakdown
|
||||
|
||||
const config = { params, headers: {} }
|
||||
if (projectId) {
|
||||
config.headers['X-Project-ID'] = projectId
|
||||
} else if (projectId === false) {
|
||||
// Explicitly ignore current active project header
|
||||
config.headers['X-Project-ID'] = ''
|
||||
}
|
||||
|
||||
const response = await api.get('/generations/usage', config)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,25 @@ export const dataService = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
getAssetMetadata: async (id) => {
|
||||
const response = await api.head(`/assets/${id}`)
|
||||
return {
|
||||
content_type: response.headers['content-type'],
|
||||
content_length: response.headers['content-length']
|
||||
}
|
||||
},
|
||||
|
||||
downloadAsset: async (id) => {
|
||||
// Return full response to access headers
|
||||
const response = await api.get(`/assets/${id}`, {
|
||||
responseType: 'blob',
|
||||
headers: {
|
||||
'Range': 'bytes=0-' // Explicitly request full content to play nice with backend streaming
|
||||
}
|
||||
});
|
||||
return response;
|
||||
},
|
||||
|
||||
getCharacters: async () => {
|
||||
// Spec says /api/characters/ (with trailing slash) but usually client shouldn't matter too much if config is good,
|
||||
// but let's follow spec if strictly needed. Axios usually handles this.
|
||||
@@ -35,12 +54,14 @@ export const dataService = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
getAssetsByCharacterId: async (charId, limit, offset) => {
|
||||
const response = await api.get(`/characters/${charId}/assets`, { params: { limit, offset } })
|
||||
getAssetsByCharacterId: async (charId, limit, offset, type) => {
|
||||
const params = { limit, offset }
|
||||
if (type && type !== 'all') params.type = type
|
||||
const response = await api.get(`/characters/${charId}/assets`, { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
uploadAsset: async (file, linkedCharId) => {
|
||||
uploadAsset: async (file, linkedCharId, onProgress) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (linkedCharId) formData.append('linked_char_id', linkedCharId)
|
||||
@@ -48,6 +69,12 @@ export const dataService = {
|
||||
const response = await api.post('/assets/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
||||
onProgress(percentCompleted)
|
||||
}
|
||||
}
|
||||
})
|
||||
return response.data
|
||||
@@ -58,11 +85,38 @@ 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
|
||||
},
|
||||
|
||||
// Environments
|
||||
getEnvironments: async (characterId) => {
|
||||
if (!characterId) return []
|
||||
const response = await api.get(`/environments/character/${characterId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
createEnvironment: async (envData) => {
|
||||
const response = await api.post('/environments/', envData)
|
||||
return response.data
|
||||
},
|
||||
|
||||
updateEnvironment: async (id, envData) => {
|
||||
const response = await api.put(`/environments/${id}`, envData)
|
||||
return response.data
|
||||
},
|
||||
|
||||
deleteEnvironment: async (id) => {
|
||||
const response = await api.delete(`/environments/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
generatePromptFromImage: async (files, prompt) => {
|
||||
const formData = new FormData()
|
||||
|
||||
|
||||
@@ -8,5 +8,9 @@ export const ideaService = {
|
||||
deleteIdea: (id) => api.delete(`/ideas/${id}`),
|
||||
addGenerationToIdea: (ideaId, generationId) => api.post(`/ideas/${ideaId}/generations/${generationId}`),
|
||||
removeGenerationFromIdea: (ideaId, generationId) => api.delete(`/ideas/${ideaId}/generations/${generationId}`),
|
||||
getIdeaGenerations: (ideaId, limit = 10, offset = 0) => api.get(`/ideas/${ideaId}/generations`, { params: { limit, offset } })
|
||||
getIdeaGenerations: (ideaId, limit = 10, offset = 0, onlyLiked = false) => {
|
||||
const params = { limit, offset };
|
||||
if (onlyLiked) params.only_liked = true;
|
||||
return api.get(`/ideas/${ideaId}/generations`, { params });
|
||||
}
|
||||
};
|
||||
|
||||
10
src/services/inspirationService.js
Normal file
10
src/services/inspirationService.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import api from './api';
|
||||
|
||||
export const inspirationService = {
|
||||
getInspirations: (limit = 20, offset = 0) => api.get('/inspirations', { params: { limit, offset } }),
|
||||
getInspiration: (id) => api.get(`/inspirations/${id}`),
|
||||
createInspiration: (data) => api.post('/inspirations', data),
|
||||
updateInspiration: (id, data) => api.put(`/inspirations/${id}`, data),
|
||||
deleteInspiration: (id) => api.delete(`/inspirations/${id}`),
|
||||
completeInspiration: (id, is_completed = true) => api.patch(`/inspirations/${id}/complete`, null, { params: { is_completed } })
|
||||
};
|
||||
@@ -1,10 +1,13 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { ideaService } from '../services/ideaService';
|
||||
import { inspirationService } from '../services/inspirationService';
|
||||
|
||||
export const useIdeaStore = defineStore('ideas', () => {
|
||||
const ideas = ref([]);
|
||||
const inspirations = ref([]);
|
||||
const currentIdea = ref(null);
|
||||
const currentInspiration = ref(null); // New state
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const totalIdeas = ref(0);
|
||||
@@ -61,6 +64,25 @@ export const useIdeaStore = defineStore('ideas', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// New action to fetch a single inspiration
|
||||
async function fetchInspiration(id) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
currentInspiration.value = null;
|
||||
try {
|
||||
const response = await inspirationService.getInspiration(id);
|
||||
currentInspiration.value = response.data;
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
console.error('Error fetching inspiration:', err);
|
||||
error.value = err.response?.data?.detail || 'Failed to fetch inspiration';
|
||||
return null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function updateIdea(id, data) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
@@ -133,9 +155,9 @@ export const useIdeaStore = defineStore('ideas', () => {
|
||||
}
|
||||
|
||||
// Assuming getIdeaGenerations is separate from getIdea
|
||||
async function fetchIdeaGenerations(ideaId, limit = 100, offset = 0) {
|
||||
async function fetchIdeaGenerations(ideaId, limit = 100, offset = 0, onlyLiked = false) {
|
||||
try {
|
||||
const response = await ideaService.getIdeaGenerations(ideaId, limit, offset);
|
||||
const response = await ideaService.getIdeaGenerations(ideaId, limit, offset, onlyLiked);
|
||||
return response;
|
||||
} catch (err) {
|
||||
console.error('Error fetching idea generations:', err);
|
||||
@@ -143,9 +165,85 @@ export const useIdeaStore = defineStore('ideas', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Inspirations ---
|
||||
async function fetchInspirations(limit = 20, offset = 0) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await inspirationService.getInspirations(limit, offset);
|
||||
inspirations.value = response.data.inspirations || response.data;
|
||||
} catch (err) {
|
||||
console.error('Error fetching inspirations:', err);
|
||||
error.value = err.response?.data?.detail || 'Failed to fetch inspirations';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createInspiration(data) {
|
||||
loading.value = true;
|
||||
try {
|
||||
await inspirationService.createInspiration(data);
|
||||
await fetchInspirations();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error creating inspiration:', err);
|
||||
error.value = err.response?.data?.detail || 'Failed to create inspiration';
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateInspiration(id, data) {
|
||||
loading.value = true;
|
||||
try {
|
||||
await inspirationService.updateInspiration(id, data);
|
||||
await fetchInspirations();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error updating inspiration:', err);
|
||||
error.value = err.response?.data?.detail || 'Failed to update inspiration';
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteInspiration(id) {
|
||||
loading.value = true;
|
||||
try {
|
||||
await inspirationService.deleteInspiration(id);
|
||||
await fetchInspirations();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error deleting inspiration:', err);
|
||||
error.value = err.response?.data?.detail || 'Failed to delete inspiration';
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function completeInspiration(id) {
|
||||
loading.value = true;
|
||||
try {
|
||||
await inspirationService.completeInspiration(id);
|
||||
await fetchInspirations();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error completing inspiration:', err);
|
||||
error.value = err.response?.data?.detail || 'Failed to complete inspiration';
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ideas,
|
||||
inspirations,
|
||||
currentIdea,
|
||||
currentInspiration,
|
||||
loading,
|
||||
error,
|
||||
totalIdeas,
|
||||
@@ -156,6 +254,12 @@ export const useIdeaStore = defineStore('ideas', () => {
|
||||
deleteIdea,
|
||||
addGenerationToIdea,
|
||||
removeGenerationFromIdea,
|
||||
fetchIdeaGenerations
|
||||
fetchIdeaGenerations,
|
||||
fetchInspirations,
|
||||
createInspiration,
|
||||
updateInspiration,
|
||||
deleteInspiration,
|
||||
completeInspiration,
|
||||
fetchInspiration
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import {computed, nextTick, onMounted, ref, watch} from 'vue'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import {dataService} from '../services/dataService'
|
||||
import {aiService} from '../services/aiService'
|
||||
@@ -7,9 +7,6 @@ import Button from 'primevue/button'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import Tag from 'primevue/tag'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import FileUpload from 'primevue/fileupload'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import ProgressBar from 'primevue/progressbar'
|
||||
import Message from 'primevue/message'
|
||||
@@ -22,10 +19,13 @@ import TabPanels from 'primevue/tabpanels'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import Paginator from 'primevue/paginator'
|
||||
|
||||
import GenerationPreviewModal from '../components/GenerationPreviewModal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const character = ref(null)
|
||||
const characterAssets = ref([])
|
||||
const environments = ref([])
|
||||
const assetsTotalRecords = ref(0)
|
||||
const historyGenerations = ref([])
|
||||
const historyTotal = ref(0)
|
||||
@@ -34,6 +34,287 @@ 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)
|
||||
const isDeletingEnv = ref(false)
|
||||
const envForm = ref({
|
||||
name: '',
|
||||
asset_ids: [],
|
||||
assets_list: []
|
||||
})
|
||||
const editingEnvId = ref(null)
|
||||
|
||||
// --- Env Asset Picker State ---
|
||||
const envModalAssets = ref([])
|
||||
const envAssetPickerTab = ref('all') // 'all', 'uploaded', 'generated'
|
||||
const envModalFirst = ref(0)
|
||||
const envModalRows = ref(20)
|
||||
const envModalTotal = ref(0)
|
||||
const isEnvModalLoading = ref(false)
|
||||
const envAssetScrollContainer = ref(null)
|
||||
const envAssetScrollSentinel = ref(null)
|
||||
const envAssetPickerFileInput = ref(null)
|
||||
const envUploadProgress = ref(0)
|
||||
const isEnvUploading = ref(false)
|
||||
const envCurrentEnvAssets = ref([])
|
||||
let envAssetObserver = null
|
||||
|
||||
const envSelectedAssets = computed(() => {
|
||||
// We check against all known assets, picker assets and current environment assets
|
||||
return [...characterAssets.value, ...envModalAssets.value, ...envCurrentEnvAssets.value]
|
||||
.filter((a, index, self) => self.findIndex(t => (t.id || t._id) === (a.id || a._id)) === index) // Unique
|
||||
.filter(a => envForm.value.asset_ids.includes(a.id) || (a._id && envForm.value.asset_ids.includes(a._id)))
|
||||
})
|
||||
|
||||
const loadEnvModalAssets = async (isNewTab = false) => {
|
||||
if (isEnvModalLoading.value) return
|
||||
|
||||
if (isNewTab) {
|
||||
envModalFirst.value = 0
|
||||
envModalAssets.value = []
|
||||
} else {
|
||||
// Increment offset for pagination
|
||||
envModalFirst.value += envModalRows.value
|
||||
}
|
||||
|
||||
if (envModalTotal.value > 0 && envModalFirst.value >= envModalTotal.value && !isNewTab) {
|
||||
return
|
||||
}
|
||||
|
||||
isEnvModalLoading.value = true
|
||||
try {
|
||||
const response = await dataService.getAssetsByCharacterId(
|
||||
route.params.id,
|
||||
envModalRows.value,
|
||||
envModalFirst.value,
|
||||
envAssetPickerTab.value
|
||||
)
|
||||
|
||||
if (response && response.assets) {
|
||||
envModalAssets.value = [...envModalAssets.value, ...response.assets]
|
||||
envModalTotal.value = response.total_count || 0
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load env modal assets', e)
|
||||
// Rollback offset on failure if not first page
|
||||
if (!isNewTab) envModalFirst.value -= envModalRows.value
|
||||
} finally {
|
||||
isEnvModalLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnvAssetInfiniteScroll = (entries) => {
|
||||
if (entries[0].isIntersecting && !isEnvModalLoading.value && (envModalTotal.value === 0 || envModalAssets.value.length < envModalTotal.value)) {
|
||||
loadEnvModalAssets()
|
||||
}
|
||||
}
|
||||
|
||||
watch(isEnvAssetPickerVisible, (visible) => {
|
||||
if (visible) {
|
||||
loadEnvModalAssets(true)
|
||||
nextTick(() => {
|
||||
if (envAssetObserver) envAssetObserver.disconnect()
|
||||
|
||||
envAssetObserver = new IntersectionObserver(handleEnvAssetInfiniteScroll, {
|
||||
root: envAssetScrollContainer.value,
|
||||
rootMargin: '100px',
|
||||
threshold: 0.1
|
||||
})
|
||||
|
||||
if (envAssetScrollSentinel.value) {
|
||||
envAssetObserver.observe(envAssetScrollSentinel.value)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
if (envAssetObserver) envAssetObserver.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
watch(envAssetPickerTab, () => {
|
||||
loadEnvModalAssets(true)
|
||||
})
|
||||
|
||||
const toggleEnvAssetSelection = (id) => {
|
||||
const idx = envForm.value.asset_ids.indexOf(id)
|
||||
if (idx > -1) {
|
||||
envForm.value.asset_ids.splice(idx, 1)
|
||||
const listIdx = envForm.value.assets_list.indexOf(id)
|
||||
if (listIdx > -1) envForm.value.assets_list.splice(listIdx, 1)
|
||||
} else {
|
||||
envForm.value.asset_ids.push(id)
|
||||
envForm.value.assets_list.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
const triggerEnvAssetUpload = () => {
|
||||
if (envAssetPickerFileInput.value) envAssetPickerFileInput.value.click()
|
||||
}
|
||||
|
||||
const handleEnvAssetUpload = async (event) => {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
isEnvUploading.value = true
|
||||
envUploadProgress.value = 0
|
||||
try {
|
||||
const response = await dataService.uploadAsset(file, route.params.id, (progress) => {
|
||||
envUploadProgress.value = progress
|
||||
})
|
||||
// Switch to uploaded tab to see the new asset
|
||||
envAssetPickerTab.value = 'uploaded'
|
||||
await loadEnvModalAssets(true)
|
||||
|
||||
// Auto-select the newly uploaded asset
|
||||
if (response && response.id) {
|
||||
if (!envForm.value.asset_ids.includes(response.id)) {
|
||||
envForm.value.asset_ids.push(response.id)
|
||||
envForm.value.assets_list.push(response.id)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to upload asset in environment modal', e)
|
||||
} finally {
|
||||
isEnvUploading.value = false
|
||||
envUploadProgress.value = 0
|
||||
if (event.target) event.target.value = '' // Clear input
|
||||
}
|
||||
}
|
||||
|
||||
const removeEnvAsset = (id) => {
|
||||
const idx = envForm.value.asset_ids.indexOf(id)
|
||||
if (idx > -1) {
|
||||
envForm.value.asset_ids.splice(idx, 1)
|
||||
const listIdx = envForm.value.assets_list.indexOf(id)
|
||||
if (listIdx > -1) envForm.value.assets_list.splice(listIdx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const loadEnvironments = async () => {
|
||||
try {
|
||||
const response = await dataService.getEnvironments(route.params.id)
|
||||
environments.value = Array.isArray(response) ? response : (response.environments || [])
|
||||
} catch (e) {
|
||||
console.error('Failed to load environments', e)
|
||||
}
|
||||
}
|
||||
|
||||
const openEnvModal = async (env = null) => {
|
||||
envCurrentEnvAssets.value = []
|
||||
if (env) {
|
||||
editingEnvId.value = env.id || env._id
|
||||
const initialAssets = [...(env.asset_ids || [])]
|
||||
envForm.value = {
|
||||
name: env.name,
|
||||
asset_ids: initialAssets,
|
||||
assets_list: [...initialAssets]
|
||||
}
|
||||
|
||||
// Fetch current environment assets if not already in memory
|
||||
if (initialAssets.length > 0) {
|
||||
const missingIds = initialAssets.filter(id =>
|
||||
!characterAssets.value.find(a => (a.id || a._id) === id) &&
|
||||
!envModalAssets.value.find(a => (a.id || a._id) === id)
|
||||
)
|
||||
|
||||
if (missingIds.length > 0) {
|
||||
try {
|
||||
const fetchedAssets = await Promise.all(
|
||||
missingIds.map(id => dataService.getAsset(id))
|
||||
)
|
||||
envCurrentEnvAssets.value = fetchedAssets.filter(a => !!a)
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch missing env assets', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
editingEnvId.value = null
|
||||
envForm.value = {
|
||||
name: '',
|
||||
asset_ids: [],
|
||||
assets_list: []
|
||||
}
|
||||
}
|
||||
isEnvModalVisible.value = true
|
||||
}
|
||||
|
||||
const saveEnvironment = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
...envForm.value,
|
||||
character_id: route.params.id
|
||||
}
|
||||
console.log('Saving environment with payload:', payload)
|
||||
if (editingEnvId.value) {
|
||||
await dataService.updateEnvironment(editingEnvId.value, payload)
|
||||
} else {
|
||||
await dataService.createEnvironment(payload)
|
||||
}
|
||||
isEnvModalVisible.value = false
|
||||
editingEnvId.value = null
|
||||
await loadEnvironments()
|
||||
} catch (e) {
|
||||
console.error('Failed to save environment', e)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteEnvironment = async (id) => {
|
||||
if (!confirm('Are you sure you want to delete this environment?')) return
|
||||
try {
|
||||
await dataService.deleteEnvironment(id)
|
||||
if (selectedEnvironment.value?.id === id || selectedEnvironment.value?._id === id) selectedEnvironment.value = null
|
||||
await loadEnvironments()
|
||||
} catch (e) {
|
||||
console.error('Failed to delete environment', e)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedAsset = ref(null)
|
||||
const isModalVisible = ref(false)
|
||||
const activeTab = ref("0")
|
||||
@@ -45,8 +326,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) => {
|
||||
@@ -132,10 +419,11 @@ const loadData = async () => {
|
||||
loading.value = true
|
||||
const charId = route.params.id
|
||||
try {
|
||||
const [char, assetsResponse, historyResponse] = await Promise.all([
|
||||
const [char, assetsResponse, historyResponse, envsResponse] = await Promise.all([
|
||||
dataService.getCharacterById(charId),
|
||||
dataService.getAssetsByCharacterId(charId, assetsRows.value, assetsFirst.value),
|
||||
aiService.getGenerations(historyRows.value, historyFirst.value, charId)
|
||||
aiService.getGenerations(historyRows.value, historyFirst.value, charId),
|
||||
dataService.getEnvironments(charId)
|
||||
])
|
||||
character.value = char
|
||||
|
||||
@@ -147,6 +435,8 @@ const loadData = async () => {
|
||||
assetsTotalRecords.value = characterAssets.value.length
|
||||
}
|
||||
|
||||
environments.value = Array.isArray(envsResponse) ? envsResponse : (envsResponse.environments || [])
|
||||
|
||||
if (historyResponse && historyResponse.generations) {
|
||||
historyGenerations.value = historyResponse.generations
|
||||
historyTotal.value = historyResponse.total_count || 0
|
||||
@@ -226,17 +516,7 @@ const prompt = ref('')
|
||||
const isGenerating = ref(false)
|
||||
const generationStatus = ref('')
|
||||
const generationProgress = ref(0)
|
||||
const sendToTelegram = ref(false)
|
||||
const useProfileImage = ref(true)
|
||||
const telegramId = ref(localStorage.getItem('telegram_id') || '')
|
||||
const isTelegramIdSaved = ref(!!localStorage.getItem('telegram_id'))
|
||||
|
||||
const saveTelegramId = () => {
|
||||
if (telegramId.value) {
|
||||
localStorage.setItem('telegram_id', telegramId.value)
|
||||
isTelegramIdSaved.value = true
|
||||
}
|
||||
}
|
||||
const generationSuccess = ref(false)
|
||||
const generationError = ref(null)
|
||||
const generatedResult = ref(null)
|
||||
@@ -249,6 +529,12 @@ const previousPrompt = ref('')
|
||||
const isUploading = ref(false)
|
||||
const fileInput = ref(null)
|
||||
|
||||
const model = ref({ key: 'gemini-3-pro-image-preview', value: 'Pro' })
|
||||
const modelOptions = ref([
|
||||
{ key: 'gemini-3.1-flash-image-preview', value: '2' },
|
||||
{ key: 'gemini-3-pro-image-preview', value: 'Pro' }
|
||||
])
|
||||
|
||||
const selectedAssets = ref([])
|
||||
const toggleAssetSelection = (asset) => {
|
||||
const index = selectedAssets.value.findIndex(a => a.id === asset.id)
|
||||
@@ -268,9 +554,6 @@ const quality = ref({
|
||||
value: '2K'
|
||||
})
|
||||
const qualityOptions = ref([{
|
||||
key: 'ONEK',
|
||||
value: '1K'
|
||||
}, {
|
||||
key: 'TWOK',
|
||||
value: '2K'
|
||||
}, {
|
||||
@@ -279,10 +562,16 @@ const qualityOptions = ref([{
|
||||
}])
|
||||
const aspectRatio = ref({ key: "NINESIXTEEN", value: "9:16" })
|
||||
const aspectRatioOptions = ref([
|
||||
{ key: "NINESIXTEEN", value: "9:16" },
|
||||
{ key: "FOURTHREE", value: "4:3" },
|
||||
{ key: "ONEONE", value: "1:1" },
|
||||
{ key: "TWOTHREE", value: "2:3" },
|
||||
{ key: "THREETWO", value: "3:2" },
|
||||
{ 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)
|
||||
@@ -433,6 +722,10 @@ const restoreGeneration = async (gen) => {
|
||||
// 1. Set prompt
|
||||
prompt.value = gen.prompt
|
||||
|
||||
// 1.1 Set Model
|
||||
const foundModel = modelOptions.value.find(opt => opt.key === gen.model)
|
||||
if (foundModel) model.value = foundModel
|
||||
|
||||
// 2. Set Quality
|
||||
const foundQuality = qualityOptions.value.find(opt => opt.key === gen.quality)
|
||||
if (foundQuality) quality.value = foundQuality
|
||||
@@ -491,6 +784,15 @@ const undoImprovePrompt = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const pastePrompt = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText()
|
||||
if (text) prompt.value = text
|
||||
} catch (err) {
|
||||
console.error('Failed to read clipboard', err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Reuse Logic ---
|
||||
|
||||
const reusePrompt = (gen) => {
|
||||
@@ -539,6 +841,29 @@ const useResultAsReference = (gen) => {
|
||||
}
|
||||
}
|
||||
|
||||
const markNsfw = async (gen) => {
|
||||
// Determine new state (toggle)
|
||||
const currentNsfw = gen.is_nsfw || gen.nsfw || false
|
||||
const newNsfw = !currentNsfw
|
||||
|
||||
try {
|
||||
await aiService.markGenerationNsfw(gen.id, newNsfw)
|
||||
|
||||
// Update local state
|
||||
gen.is_nsfw = newNsfw
|
||||
// Also update legacy property if present to keep UI consistent
|
||||
if (gen.nsfw !== undefined) gen.nsfw = newNsfw
|
||||
|
||||
// If this is the currently displayed result, update it too
|
||||
if (generatedResult.value && generatedResult.value.assets && generatedResult.value.assets.some(a => gen.result_list.includes(a.id))) {
|
||||
generatedResult.value.is_nsfw = newNsfw
|
||||
if (generatedResult.value.nsfw !== undefined) generatedResult.value.nsfw = newNsfw
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle NSFW', e)
|
||||
}
|
||||
}
|
||||
|
||||
const triggerFileUpload = () => {
|
||||
if (fileInput.value) fileInput.value.click()
|
||||
}
|
||||
@@ -570,24 +895,14 @@ const handleGenerate = async () => {
|
||||
generatedResult.value = null
|
||||
|
||||
try {
|
||||
if (sendToTelegram.value && !telegramId.value) {
|
||||
alert("Please enter your Telegram ID")
|
||||
isGenerating.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (telegramId.value && telegramId.value !== localStorage.getItem('telegram_id')) {
|
||||
localStorage.setItem('telegram_id', telegramId.value)
|
||||
isTelegramIdSaved.value = true
|
||||
}
|
||||
|
||||
const payload = {
|
||||
model: model.value.key,
|
||||
linked_character_id: character.value?.id,
|
||||
environment_id: selectedEnvironment.value?.id || selectedEnvironment.value?._id || null,
|
||||
aspect_ratio: aspectRatio.value.key,
|
||||
quality: quality.value.key,
|
||||
prompt: prompt.value,
|
||||
assets_list: selectedAssets.value.map(a => a.id),
|
||||
telegram_id: sendToTelegram.value ? telegramId.value : null,
|
||||
use_profile_image: useProfileImage.value,
|
||||
count: generationCount.value
|
||||
}
|
||||
@@ -695,6 +1010,12 @@ const handleGenerate = async () => {
|
||||
<span>Assets ({{ assetsTotalRecords }})</span>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab value="envs">
|
||||
<div class="!flex !flex-row !gap-1">
|
||||
<i class="pi pi-map-marker text-[10px]" />
|
||||
<span>Environments ({{ environments.length }})</span>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab value="2" class="hidden">
|
||||
<div class="!flex !flex-row !gap-1">
|
||||
<i class="pi pi-history text-[10px]" />
|
||||
@@ -712,6 +1033,21 @@ const handleGenerate = async () => {
|
||||
<h2 class="text-sm font-bold m-0">Settings</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label
|
||||
class="text-slate-400 text-[9px] font-semibold uppercase tracking-wider">Model</label>
|
||||
<div
|
||||
class="!flex !w-full !justify-between items-center justify-center !bg-slate-900/50 !p-1 gap-1 !rounded-lg !border !border-white/10">
|
||||
<div v-for="option in modelOptions" :key="option.key"
|
||||
@click="model = option"
|
||||
class="w-full items-center justify-center justify-items-center !text-center hover:bg-white/5 hover:text-white p-1 hover:rounded-lg cursor-pointer"
|
||||
:class="model.key === option.key ? 'bg-white/10 text-white rounded-lg shadow-sm' : 'text-slate-500'">
|
||||
<span class="text-white w-full text-center text-[8px]">{{ option.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label
|
||||
class="text-slate-400 text-[9px] font-semibold uppercase tracking-wider">Quality</label>
|
||||
@@ -719,12 +1055,11 @@ const handleGenerate = async () => {
|
||||
class="!flex !w-full !justify-between items-center justify-center !bg-slate-900/50 !p-1 gap-1 !rounded-lg !border !border-white/10">
|
||||
<div v-for="option in qualityOptions" :key="option.key"
|
||||
@click="quality = option"
|
||||
class="w-full items-center justify-center justify-items-center !text-center hover:bg-white/5 hover:text-white p-1 hover:rounded-lg"
|
||||
:class="quality.key === option.key ? 'bg-white/9 text-white rounded-lg' : ''">
|
||||
<span class="text-white w-full text-center">{{ option.value }}</span>
|
||||
class="w-full items-center justify-center justify-items-center !text-center hover:bg-white/5 hover:text-white p-1 hover:rounded-lg cursor-pointer"
|
||||
:class="quality.key === option.key ? 'bg-white/10 text-white rounded-lg' : ''">
|
||||
<span class="text-white w-full text-center text-[8px]">{{ option.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
@@ -734,12 +1069,12 @@ const handleGenerate = async () => {
|
||||
class="!flex !w-full !justify-between items-center justify-center !bg-slate-900/50 !p-1 gap-1 !rounded-lg !border !border-white/10">
|
||||
<div v-for="option in aspectRatioOptions" :key="option.key"
|
||||
@click="aspectRatio = option"
|
||||
class="w-full items-center justify-center justify-items-center !text-center hover:bg-white/5 hover:text-white p-1 hover:rounded-lg"
|
||||
:class="aspectRatio.key === option.key ? 'bg-white/9 text-white rounded-lg' : ''">
|
||||
<span class="text-white w-full text-center">{{ option.value }}</span>
|
||||
class="w-full items-center justify-center justify-items-center !text-center hover:bg-white/5 hover:text-white p-1 hover:rounded-lg cursor-pointer"
|
||||
:class="aspectRatio.key === option.key ? 'bg-white/10 text-white rounded-lg' : ''">
|
||||
<span class="text-white w-full text-center text-[8px]">{{ option.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
@@ -755,28 +1090,6 @@ const handleGenerate = async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label
|
||||
class="text-slate-400 text-[9px] font-semibold uppercase tracking-wider">Description</label>
|
||||
|
||||
<div class="relative w-full">
|
||||
<Textarea v-model="prompt" rows="3" autoResize placeholder="Describe..."
|
||||
class="w-full bg-slate-900 border-white/10 text-white rounded-lg p-2 focus:border-violet-500 transition-all text-xs pr-10" />
|
||||
|
||||
<div class="absolute top-1.5 right-1.5 flex gap-1">
|
||||
<Button v-if="previousPrompt" icon="pi pi-undo"
|
||||
class="!p-1 !w-6 !h-6 !text-[10px] bg-slate-800 hover:bg-slate-700 border-white/10 text-slate-300"
|
||||
@click="undoImprovePrompt" v-tooltip.top="'Rollback'" />
|
||||
|
||||
<Button icon="pi pi-sparkles" :loading="isImprovingPrompt"
|
||||
:disabled="prompt.length <= 10"
|
||||
class="!p-1 !w-6 !h-6 !text-[10px] bg-violet-600/20 hover:bg-violet-600/30 border-violet-500/30 text-violet-400 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="handleImprovePrompt"
|
||||
v-tooltip.top="prompt.length <= 10 ? 'Enter at least 10 characters' : 'Improve prompt'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assets Selection -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex justify-between items-center">
|
||||
@@ -805,22 +1118,50 @@ const handleGenerate = async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Environment Selection -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex justify-between items-center">
|
||||
<label class="text-slate-400 text-[9px] font-semibold uppercase tracking-wider">Environment</label>
|
||||
<Button v-if="selectedEnvironment" icon="pi pi-times" @click="selectedEnvironment = null" text size="small"
|
||||
class="!p-0 !h-4 !w-4 !text-[8px] text-slate-500 hover:text-white" v-tooltip.top="'Clear'" />
|
||||
</div>
|
||||
<div v-if="environments.length > 0" class="flex gap-2 overflow-x-auto pb-1 custom-scrollbar no-scrollbar">
|
||||
<div v-for="env in environments" :key="env.id || env._id"
|
||||
@click="selectedEnvironment = env"
|
||||
class="flex-shrink-0 flex items-center gap-2 px-2 py-1.5 rounded-lg border-2 transition-all cursor-pointer group bg-slate-900/30"
|
||||
:class="[
|
||||
(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id))
|
||||
? 'border-violet-500 bg-violet-500/10 shadow-[0_0_15px_rgba(124,58,237,0.1)]'
|
||||
: 'border-white/5 hover:border-white/20'
|
||||
]"
|
||||
>
|
||||
<div class="w-6 h-6 rounded overflow-hidden flex-shrink-0 bg-slate-800 flex items-center justify-center border border-white/5">
|
||||
<img v-if="env.asset_ids?.length > 0"
|
||||
:src="API_URL + '/assets/' + env.asset_ids[0] + '?thumbnail=true'"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<i v-else class="pi pi-map-marker text-[10px]"
|
||||
:class="(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id)) ? 'text-violet-400' : 'text-slate-500'"
|
||||
></i>
|
||||
</div>
|
||||
<span class="text-[10px] whitespace-nowrap pr-1"
|
||||
:class="(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id)) ? 'text-violet-300 font-bold' : 'text-slate-400 group-hover:text-slate-200'"
|
||||
>
|
||||
{{ env.name }}
|
||||
</span>
|
||||
<i v-if="(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id))"
|
||||
class="pi pi-check text-violet-400 text-[8px]"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="py-2 text-center border border-dashed border-white/5 rounded-lg">
|
||||
<p class="text-[8px] text-slate-600 uppercase m-0">No environments</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Removed Ref Assets section if characterAssets is empty, but it's already conditional -->
|
||||
|
||||
<div class="flex flex-col gap-1.5 mt-auto pt-1.5 border-t border-white/5">
|
||||
<div class="flex flex-col gap-2 mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox v-model="sendToTelegram" :binary="true"
|
||||
inputId="tg-check-char" />
|
||||
<label for="tg-check-char"
|
||||
class="text-[10px] text-slate-400 cursor-pointer select-none">Send
|
||||
result to Telegram</label>
|
||||
</div>
|
||||
<div v-if="sendToTelegram && !isTelegramIdSaved"
|
||||
class="animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
<InputText v-model="telegramId" placeholder="Enter Telegram ID"
|
||||
class="w-full !text-[10px] !py-1" @blur="saveTelegramId" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<Checkbox v-model="useProfileImage" :binary="true"
|
||||
inputId="profile-img-check" />
|
||||
@@ -987,7 +1328,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'"
|
||||
@@ -1020,10 +1361,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" />
|
||||
@@ -1047,6 +1389,7 @@ const handleGenerate = async () => {
|
||||
<span class="capitalize"
|
||||
:class="gen.status === 'done' ? 'text-green-500' : (gen.status === 'failed' ? 'text-red-500' : 'text-amber-500')">{{
|
||||
gen.status }}</span>
|
||||
<Tag v-if="gen.is_nsfw || gen.nsfw" value="NSFW" severity="danger" class="!text-[8px] !py-0 !px-1" />
|
||||
<i v-if="gen.failed_reason"
|
||||
v-tooltip.right="gen.failed_reason"
|
||||
class="pi pi-exclamation-circle text-red-500"
|
||||
@@ -1083,6 +1426,11 @@ const handleGenerate = async () => {
|
||||
:disabled="gen.status !== 'done' || gen.result_list.length == 0"
|
||||
@click.stop="useResultAsReference(gen)"
|
||||
v-tooltip.bottom="'Use result as reference'" />
|
||||
<Button :icon="(gen.is_nsfw || gen.nsfw) ? 'pi pi-eye' : 'pi pi-eye-slash'"
|
||||
label="NSFW" size="small" text
|
||||
class="!text-[10px] !py-0.5 !px-2 text-slate-400 hover:bg-white/5 flex-1"
|
||||
@click.stop="markNsfw(gen)"
|
||||
v-tooltip.bottom="(gen.is_nsfw || gen.nsfw) ? 'Unmark NSFW' : 'Mark NSFW'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1206,6 +1554,57 @@ const handleGenerate = async () => {
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value="envs">
|
||||
<div class="glass-panel p-8 rounded-3xl border border-white/5 bg-white/5">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold m-0">Environments ({{ environments.length }})</h2>
|
||||
<Button label="Create Environment" icon="pi pi-plus" @click="openEnvModal()"
|
||||
class="!py-2 !px-4 !text-sm font-bold bg-violet-600 hover:bg-violet-700 border-none text-white rounded-xl transition-all shadow-lg shadow-violet-500/20" />
|
||||
</div>
|
||||
|
||||
<div v-if="environments.length === 0"
|
||||
class="text-center py-16 text-slate-400 bg-white/[0.02] rounded-2xl">
|
||||
<i class="pi pi-map-marker text-4xl mb-4 opacity-20"></i>
|
||||
<p>No environments defined for this character.</p>
|
||||
<p class="text-xs opacity-60">Environments allow grouping assets into spaces like "Bedroom" or "Kitchen".</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div v-for="env in environments" :key="env.id || env._id"
|
||||
class="glass-panel rounded-2xl overflow-hidden border border-white/5 hover:border-violet-500/30 transition-all duration-300 group bg-white/[0.02]">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-white mb-1">{{ env.name }}</h3>
|
||||
<p class="text-xs text-slate-400">{{ env.asset_ids?.length || 0 }} assets linked</p>
|
||||
</div>
|
||||
<div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button icon="pi pi-pencil" text rounded size="small"
|
||||
class="!text-slate-400 hover:!text-violet-400 hover:!bg-violet-500/10"
|
||||
@click="openEnvModal(env)" />
|
||||
<Button icon="pi pi-trash" text rounded size="small"
|
||||
class="!text-slate-400 hover:!text-red-400 hover:!bg-red-500/10"
|
||||
@click="deleteEnvironment(env.id || env._id)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div v-for="assetId in env.asset_ids?.slice(0, 4)" :key="assetId"
|
||||
class="w-10 h-10 rounded-lg overflow-hidden border border-white/10 bg-black/20">
|
||||
<img :src="API_URL + '/assets/' + assetId + '?thumbnail=true'"
|
||||
class="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div v-if="env.asset_ids?.length > 4"
|
||||
class="w-10 h-10 rounded-lg border border-dashed border-white/10 flex items-center justify-center text-[10px] text-slate-500">
|
||||
+{{ env.asset_ids.length - 4 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -1228,18 +1627,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">
|
||||
@@ -1275,6 +1672,120 @@ const handleGenerate = async () => {
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
<!-- Environment Modal -->
|
||||
<Dialog v-model:visible="isEnvModalVisible" modal :header="editingEnvId ? 'Edit Environment' : 'Create Environment'"
|
||||
:style="{ width: '500px' }" class="glass-panel rounded-2xl">
|
||||
<div class="flex flex-col gap-6 p-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-bold text-slate-400 uppercase">Environment Name</label>
|
||||
<InputText v-model="envForm.name" placeholder="e.g. Bedroom, Living Room..." class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<label class="text-xs font-bold text-slate-400 uppercase">Linked Assets ({{ envForm.asset_ids.length }})</label>
|
||||
<Button label="Add Asset" icon="pi pi-plus" size="small" text
|
||||
class="!text-[10px] !py-0.5 !px-1.5 text-violet-400 hover:bg-violet-500/10"
|
||||
@click="isEnvAssetPickerVisible = true" />
|
||||
</div>
|
||||
|
||||
<div v-if="envSelectedAssets.length > 0" class="flex flex-wrap gap-2 p-3 bg-slate-900/50 rounded-xl border border-white/5">
|
||||
<div v-for="asset in envSelectedAssets" :key="asset.id || asset._id"
|
||||
class="relative w-12 h-12 rounded overflow-hidden border border-violet-500/50 group">
|
||||
<img :src="API_URL + asset.url + '?thumbnail=true'"
|
||||
class="w-full h-full object-cover" />
|
||||
<div @click="removeEnvAsset(asset.id || asset._id)"
|
||||
class="absolute inset-0 bg-black/60 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer">
|
||||
<i class="pi pi-times text-white text-[10px]"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else
|
||||
class="text-center py-6 text-xs text-slate-500 border border-dashed border-white/10 rounded-xl">
|
||||
No assets selected for this environment
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 mt-4">
|
||||
<Button label="Cancel" @click="isEnvModalVisible = false" text class="text-slate-400" />
|
||||
<Button :label="editingEnvId ? 'Update' : 'Create'" @click="saveEnvironment"
|
||||
class="bg-violet-600 hover:bg-violet-700 border-none px-6" />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<!-- Environment Asset Selection Modal (Character-specific) -->
|
||||
<Dialog v-model:visible="isEnvAssetPickerVisible" modal header="Select Character Assets for Environment"
|
||||
:style="{ width: '80vw', maxWidth: '800px' }" class="glass-panel rounded-2xl">
|
||||
<div class="flex flex-col h-[70vh]">
|
||||
<!-- Tabs & Upload -->
|
||||
<div class="flex border-b border-white/5 mb-4 px-2 items-center">
|
||||
<div class="flex flex-1">
|
||||
<button v-for="tab in ['all', 'uploaded', 'generated']" :key="tab" @click="envAssetPickerTab = tab"
|
||||
class="px-4 py-3 text-xs font-medium border-b-2 transition-colors capitalize"
|
||||
:class="envAssetPickerTab === tab ? 'border-violet-500 text-violet-400' : 'border-transparent text-slate-400 hover:text-slate-200'">
|
||||
{{ tab }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="file" ref="envAssetPickerFileInput" @change="handleEnvAssetUpload" class="hidden"
|
||||
accept="image/*" />
|
||||
<Button :label="isEnvModalLoading && envAssetPickerTab === 'uploaded' ? 'Uploading...' : 'Upload'"
|
||||
icon="pi pi-upload" size="small" text
|
||||
class="!text-[10px] !py-1 !px-2 text-violet-400 hover:bg-violet-500/10"
|
||||
@click="triggerEnvAssetUpload" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Progress -->
|
||||
<div v-if="isEnvUploading" class="px-2 mb-4 animate-in fade-in slide-in-from-top-1">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<span class="text-[10px] text-violet-400 font-bold uppercase tracking-wider">Uploading Asset...</span>
|
||||
<span class="text-[10px] text-slate-500 font-mono">{{ envUploadProgress }}%</span>
|
||||
</div>
|
||||
<ProgressBar :value="envUploadProgress" style="height: 4px; width: 100%"
|
||||
:showValue="false" class="rounded-full overflow-hidden !bg-slate-800" :pt="{
|
||||
value: { class: '!bg-gradient-to-r !from-violet-600 !to-cyan-500 !transition-all !duration-300' }
|
||||
}" />
|
||||
</div>
|
||||
|
||||
<div ref="envAssetScrollContainer" class="flex-1 overflow-y-auto p-1 text-slate-100 custom-scrollbar">
|
||||
<div v-if="envModalAssets.length > 0" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
<div v-for="asset in envModalAssets" :key="asset.id || asset._id" @click="toggleEnvAssetSelection(asset.id || asset._id)"
|
||||
class="aspect-square rounded-xl overflow-hidden cursor-pointer relative border transition-all"
|
||||
:class="(envForm.asset_ids.includes(asset.id) || (asset._id && envForm.asset_ids.includes(asset._id))) ? 'border-violet-500 ring-2 ring-violet-500/20' : 'border-white/10 hover:border-white/30'">
|
||||
<img :src="API_URL + asset.url + '?thumbnail=true'"
|
||||
class="w-full h-full object-cover" />
|
||||
<div v-if="envForm.asset_ids.includes(asset.id) || (asset._id && envForm.asset_ids.includes(asset._id))"
|
||||
class="absolute inset-0 bg-violet-600/30 flex items-center justify-center">
|
||||
<div class="bg-violet-600 rounded-full p-1 shadow-lg">
|
||||
<i class="pi pi-check text-white text-xs font-bold"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 right-0 p-1 bg-black/60 backdrop-blur-sm">
|
||||
<p class="text-[9px] text-white truncate">{{ asset.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!isEnvModalLoading" class="flex flex-col items-center justify-center py-20 text-slate-500 gap-3">
|
||||
<i class="pi pi-images text-4xl opacity-20"></i>
|
||||
<p>No assets found for this character.</p>
|
||||
</div>
|
||||
|
||||
<!-- Infinite Scroll Sentinel -->
|
||||
<div ref="envAssetScrollSentinel" class="w-full h-12 flex items-center justify-center mt-4">
|
||||
<i v-if="isEnvModalLoading" class="pi pi-spin pi-spinner text-violet-500 text-xl"></i>
|
||||
<span v-else-if="envModalAssets.length > 0 && envModalAssets.length >= envModalTotal" class="text-[10px] text-slate-600 italic">
|
||||
All assets loaded
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-4 border-t border-white/10 flex justify-between items-center text-slate-100">
|
||||
<span class="text-sm text-slate-400">{{ envForm.asset_ids.length }} selected</span>
|
||||
<Button label="Done" @click="isEnvAssetPickerVisible = false" class="!px-6" />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -519,7 +519,7 @@ onBeforeUnmount(() => {
|
||||
<header
|
||||
class="h-16 border-b border-white/5 flex items-center justify-between px-6 bg-slate-900/80 backdrop-blur z-20 shrink-0">
|
||||
<div class="flex items-center gap-4">
|
||||
<h1 class="text-lg font-bold text-slate-200">📅 Content plan</h1>
|
||||
<h1 class="text-lg font-bold text-slate-200 !m-0">📅 Content plan</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
|
||||
import {computed, onBeforeUnmount, onMounted, ref, watch} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
import {dataService} from '../services/dataService'
|
||||
import {aiService} from '../services/aiService'
|
||||
@@ -11,15 +11,15 @@ import Dialog from 'primevue/dialog'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import DatePicker from 'primevue/datepicker'
|
||||
import MultiSelect from 'primevue/multiselect'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import ProgressBar from 'primevue/progressbar'
|
||||
import Message from 'primevue/message'
|
||||
import Tag from 'primevue/tag'
|
||||
import InputSwitch from 'primevue/inputswitch'
|
||||
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import {useAlbumStore} from '../stores/albums'
|
||||
import {useToast} from 'primevue/usetoast'
|
||||
import Toast from 'primevue/toast'
|
||||
import GenerationPreviewModal from '../components/GenerationPreviewModal.vue'
|
||||
import GenerationImage from '../components/GenerationImage.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const API_URL = import.meta.env.VITE_API_URL
|
||||
@@ -138,6 +138,8 @@ const confirmAddToPlan = async () => {
|
||||
// --- State ---
|
||||
const prompt = ref('')
|
||||
const selectedCharacter = ref(null)
|
||||
const environments = ref([])
|
||||
const selectedEnvironment = ref(null)
|
||||
const selectedAssets = ref([])
|
||||
// Album Picker State
|
||||
const isAlbumPickerVisible = ref(false)
|
||||
@@ -149,15 +151,50 @@ const assetPickerTab = ref('all') // 'all', 'uploaded', 'generated'
|
||||
const modalAssets = ref([])
|
||||
const isModalLoading = ref(false)
|
||||
const tempSelectedAssets = ref([])
|
||||
const model = ref({ key: 'gemini-3-pro-image-preview', value: 'Pro' })
|
||||
const modelOptions = ref([
|
||||
{ key: 'gemini-3.1-flash-image-preview', value: '2' },
|
||||
{ key: 'gemini-3-pro-image-preview', value: 'Pro' }
|
||||
])
|
||||
const quality = ref({ key: 'TWOK', value: '2K' })
|
||||
const aspectRatio = ref({ key: "NINESIXTEEN", value: "9:16" })
|
||||
const aspectRatio = ref('NINESIXTEEN') // Default to Video (9:16)
|
||||
const generationCount = ref(1)
|
||||
const sendToTelegram = ref(false)
|
||||
const telegramId = ref('')
|
||||
const isTelegramIdSaved = ref(false)
|
||||
const useProfileImage = ref(true)
|
||||
const useEnvironment = ref(false)
|
||||
const isImprovingPrompt = ref(false)
|
||||
const previousPrompt = ref('')
|
||||
let _savedEnvironmentId = null
|
||||
|
||||
// NSFW Toggle
|
||||
const showNsfwGlobal = ref(false)
|
||||
|
||||
watch(showNsfwGlobal, (val) => {
|
||||
localStorage.setItem('show_nsfw_global', val)
|
||||
})
|
||||
|
||||
const loadEnvironments = async (charId) => {
|
||||
if (!charId) {
|
||||
environments.value = []
|
||||
selectedEnvironment.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await dataService.getEnvironments(charId)
|
||||
environments.value = Array.isArray(response) ? response : (response.environments || [])
|
||||
|
||||
if (_savedEnvironmentId) {
|
||||
selectedEnvironment.value = environments.value.find(e => (e.id === _savedEnvironmentId || e._id === _savedEnvironmentId)) || null
|
||||
_savedEnvironmentId = null
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load environments', e)
|
||||
environments.value = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedCharacter, (newChar) => {
|
||||
loadEnvironments(newChar?.id || newChar?._id)
|
||||
})
|
||||
|
||||
const characters = ref([])
|
||||
const allAssets = ref([])
|
||||
@@ -166,23 +203,22 @@ const historyTotal = ref(0)
|
||||
const historyRows = ref(50)
|
||||
const historyFirst = ref(0)
|
||||
|
||||
const isSettingsVisible = ref(false)
|
||||
const isSettingsVisible = ref(localStorage.getItem('flexible_gen_settings_visible') !== 'false')
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
watch(isSettingsVisible, (val) => {
|
||||
localStorage.setItem('flexible_gen_settings_visible', val)
|
||||
})
|
||||
|
||||
const activeOverlayId = ref(null) // For mobile tap-to-show overlay
|
||||
const filterCharacter = ref(null) // Character filter for gallery
|
||||
const onlyLiked = ref(false)
|
||||
|
||||
// Options
|
||||
const qualityOptions = ref([
|
||||
{ key: 'ONEK', value: '1K' },
|
||||
{ key: 'TWOK', value: '2K' },
|
||||
{ key: 'FOURK', value: '4K' }
|
||||
])
|
||||
const aspectRatioOptions = ref([
|
||||
{ key: "NINESIXTEEN", value: "9:16" },
|
||||
{ key: "FOURTHREE", value: "4:3" },
|
||||
{ key: "THREEFOUR", value: "3:4" },
|
||||
{ key: "SIXTEENNINE", value: "16:9" }
|
||||
])
|
||||
|
||||
// --- Persistence ---
|
||||
const STORAGE_KEY = 'flexible_gen_settings'
|
||||
@@ -190,22 +226,17 @@ const STORAGE_KEY = 'flexible_gen_settings'
|
||||
const saveSettings = () => {
|
||||
const settings = {
|
||||
prompt: prompt.value,
|
||||
selectedCharacterId: selectedCharacter.value?.id,
|
||||
model: model.value,
|
||||
selectedCharacterId: selectedCharacter.value?.id || selectedCharacter.value?._id,
|
||||
selectedEnvironmentId: selectedEnvironment.value?.id || selectedEnvironment.value?._id,
|
||||
selectedAssetIds: selectedAssets.value.map(a => a.id),
|
||||
quality: quality.value,
|
||||
aspectRatio: aspectRatio.value,
|
||||
sendToTelegram: sendToTelegram.value,
|
||||
telegramId: telegramId.value,
|
||||
useProfileImage: useProfileImage.value,
|
||||
useEnvironment: useEnvironment.value,
|
||||
generationCount: generationCount.value
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
|
||||
|
||||
// Also save Telegram ID separately as it's used elsewhere
|
||||
if (telegramId.value) {
|
||||
localStorage.setItem('telegram_id', telegramId.value)
|
||||
isTelegramIdSaved.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const restoreSettings = () => {
|
||||
@@ -217,13 +248,20 @@ const restoreSettings = () => {
|
||||
|
||||
// We need characters and assets loaded to fully restore objects
|
||||
// For now, we'll store IDs and restore in loadData
|
||||
if (settings.model) model.value = settings.model
|
||||
if (settings.quality) quality.value = settings.quality
|
||||
if (settings.aspectRatio) aspectRatio.value = settings.aspectRatio
|
||||
sendToTelegram.value = settings.sendToTelegram || false
|
||||
telegramId.value = settings.telegramId || localStorage.getItem('telegram_id') || ''
|
||||
if (telegramId.value) isTelegramIdSaved.value = true
|
||||
if (settings.aspectRatio) {
|
||||
// Handle legacy object format if present
|
||||
if (typeof settings.aspectRatio === 'object' && settings.aspectRatio.key) {
|
||||
aspectRatio.value = settings.aspectRatio.key
|
||||
} else {
|
||||
aspectRatio.value = settings.aspectRatio
|
||||
}
|
||||
}
|
||||
if (settings.useProfileImage !== undefined) useProfileImage.value = settings.useProfileImage
|
||||
if (settings.useEnvironment !== undefined) useEnvironment.value = settings.useEnvironment
|
||||
if (settings.generationCount) generationCount.value = Math.min(settings.generationCount, 4)
|
||||
if (settings.selectedEnvironmentId) _savedEnvironmentId = settings.selectedEnvironmentId
|
||||
|
||||
return settings // Return to use in loadData
|
||||
} catch (e) {
|
||||
@@ -234,7 +272,7 @@ const restoreSettings = () => {
|
||||
}
|
||||
|
||||
// Watchers for auto-save
|
||||
watch([prompt, selectedCharacter, selectedAssets, quality, aspectRatio, sendToTelegram, telegramId, useProfileImage, generationCount], () => {
|
||||
watch([prompt, selectedCharacter, selectedEnvironment, selectedAssets, quality, aspectRatio, useProfileImage, useEnvironment, generationCount], () => {
|
||||
saveSettings()
|
||||
}, { deep: true })
|
||||
|
||||
@@ -246,6 +284,13 @@ watch(filterCharacter, async () => {
|
||||
await refreshHistory()
|
||||
})
|
||||
|
||||
watch(onlyLiked, async () => {
|
||||
historyGenerations.value = []
|
||||
historyTotal.value = 0
|
||||
historyFirst.value = 0
|
||||
await refreshHistory()
|
||||
})
|
||||
|
||||
|
||||
// --- Data Loading ---
|
||||
const loadData = async () => {
|
||||
@@ -253,7 +298,7 @@ const loadData = async () => {
|
||||
const [charsRes, assetsRes, historyRes] = await Promise.all([
|
||||
dataService.getCharacters(), // Assuming this exists and returns list
|
||||
dataService.getAssets(100, 0, 'all'), // Load a batch of assets
|
||||
aiService.getGenerations(historyRows.value, historyFirst.value, filterCharacter.value?.id)
|
||||
aiService.getGenerations(historyRows.value, historyFirst.value, filterCharacter.value?.id || filterCharacter.value?._id)
|
||||
])
|
||||
|
||||
// Characters
|
||||
@@ -305,7 +350,7 @@ const loadData = async () => {
|
||||
const savedSettings = restoreSettings()
|
||||
if (savedSettings) {
|
||||
if (savedSettings.selectedCharacterId) {
|
||||
selectedCharacter.value = characters.value.find(c => c.id === savedSettings.selectedCharacterId) || null
|
||||
selectedCharacter.value = characters.value.find(c => (c.id === savedSettings.selectedCharacterId || c._id === savedSettings.selectedCharacterId)) || null
|
||||
}
|
||||
if (savedSettings.selectedAssetIds && savedSettings.selectedAssetIds.length > 0) {
|
||||
// Determine which assets to select.
|
||||
@@ -323,7 +368,7 @@ const loadData = async () => {
|
||||
|
||||
const refreshHistory = async () => {
|
||||
try {
|
||||
const response = await aiService.getGenerations(historyRows.value, 0, filterCharacter.value?.id)
|
||||
const response = await aiService.getGenerations(historyRows.value, 0, filterCharacter.value?.id || filterCharacter.value?._id, onlyLiked.value)
|
||||
if (response && response.generations) {
|
||||
// Update existing items and add new ones at the top
|
||||
const newGenerations = []
|
||||
@@ -359,11 +404,6 @@ const refreshHistory = async () => {
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.value.trim()) return
|
||||
|
||||
if (sendToTelegram.value && !telegramId.value) {
|
||||
alert("Please enter your Telegram ID")
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
|
||||
// Close settings to show gallery/progress (optional preference)
|
||||
@@ -371,12 +411,13 @@ const handleGenerate = async () => {
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
aspect_ratio: aspectRatio.value.key,
|
||||
model: model.value.key,
|
||||
aspect_ratio: aspectRatio.value, // Now a string
|
||||
quality: quality.value.key,
|
||||
prompt: prompt.value,
|
||||
assets_list: selectedAssets.value.map(a => a.id),
|
||||
linked_character_id: selectedCharacter.value?.id || null,
|
||||
telegram_id: sendToTelegram.value ? telegramId.value : null,
|
||||
linked_character_id: selectedCharacter.value?.id || selectedCharacter.value?._id || null,
|
||||
environment_id: (selectedCharacter.value && useEnvironment.value) ? (selectedEnvironment.value?.id || selectedEnvironment.value?._id || null) : null,
|
||||
use_profile_image: selectedCharacter.value ? useProfileImage.value : false,
|
||||
count: generationCount.value
|
||||
}
|
||||
@@ -483,7 +524,7 @@ const loadMoreHistory = async () => {
|
||||
|
||||
try {
|
||||
const nextOffset = historyGenerations.value.length
|
||||
const response = await aiService.getGenerations(historyRows.value, nextOffset, filterCharacter.value?.id)
|
||||
const response = await aiService.getGenerations(historyRows.value, nextOffset, filterCharacter.value?.id, onlyLiked.value)
|
||||
|
||||
if (response && response.generations) {
|
||||
const newGenerations = response.generations.filter(gen =>
|
||||
@@ -501,11 +542,14 @@ const loadMoreHistory = async () => {
|
||||
|
||||
// --- Initial Load ---
|
||||
onMounted(() => {
|
||||
// Reset NSFW on page load
|
||||
showNsfwGlobal.value = false
|
||||
localStorage.removeItem('show_nsfw_global')
|
||||
|
||||
loadData().then(() => {
|
||||
// slight delay to allow DOM render
|
||||
setTimeout(setupInfiniteScroll, 500)
|
||||
})
|
||||
isSettingsVisible.value = true
|
||||
})
|
||||
|
||||
// --- Sidebar Logic (Duplicated for now) ---
|
||||
@@ -551,8 +595,11 @@ 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
|
||||
prompt: gen.prompt,
|
||||
gen: gen
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -560,49 +607,31 @@ 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) => {
|
||||
// Find index of this image in the flat list
|
||||
const idx = allPreviewImages.value.findIndex(img => img.url === url)
|
||||
previewIndex.value = idx >= 0 ? idx : 0
|
||||
previewImage.value = allPreviewImages.value[previewIndex.value] || { url }
|
||||
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) => {
|
||||
if (gen.prompt) {
|
||||
prompt.value = gen.prompt
|
||||
@@ -671,6 +700,15 @@ const clearPrompt = () => {
|
||||
previousPrompt.value = ''
|
||||
}
|
||||
|
||||
const pastePrompt = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText()
|
||||
if (text) prompt.value = text
|
||||
} catch (err) {
|
||||
console.error('Failed to read clipboard', err)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteGeneration = async (gen) => {
|
||||
if (!gen) return
|
||||
try {
|
||||
@@ -701,6 +739,27 @@ const toggleMobileOverlay = (id) => {
|
||||
}
|
||||
}
|
||||
|
||||
const markNsfw = async (gen) => {
|
||||
// if (!confirm('Are you sure you want to mark this generation as NSFW?')) return
|
||||
const currentNsfw = gen.is_nsfw || gen.nsfw || false
|
||||
const newNsfw = !currentNsfw
|
||||
|
||||
try {
|
||||
await aiService.markGenerationNsfw(gen.id, newNsfw)
|
||||
gen.is_nsfw = newNsfw
|
||||
if (gen.nsfw !== undefined) gen.nsfw = newNsfw
|
||||
|
||||
if (gen.isGroup && gen.children) {
|
||||
gen.children.forEach(c => {
|
||||
c.is_nsfw = newNsfw
|
||||
if (c.nsfw !== undefined) c.nsfw = newNsfw
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle NSFW', e)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Asset Picker Logic ---
|
||||
|
||||
const loadModalAssets = async () => {
|
||||
@@ -808,48 +867,62 @@ const confirmAddToAlbum = async () => {
|
||||
<div class="flex flex-col h-full font-sans">
|
||||
<main class="flex-1 relative flex flex-col h-full overflow-hidden">
|
||||
<header
|
||||
class="p-4 flex justify-between items-center z-10 border-b border-white/5 bg-slate-900/80 backdrop-blur-sm">
|
||||
<div class="flex items-center gap-3">
|
||||
class="p-2 px-4 flex justify-between items-center z-10 border-b border-white/5 bg-slate-900/80 backdrop-blur-sm">
|
||||
<div class="flex flex-row !items-center !justify-items-center !justify-center gap-2">
|
||||
<h1
|
||||
class="text-xl font-bold bg-gradient-to-r from-white to-slate-400 bg-clip-text text-transparent m-0">
|
||||
class="text-base font-bold bg-gradient-to-r from-white to-slate-400 bg-clip-text text-transparent !m-0">
|
||||
Gallery</h1>
|
||||
|
||||
<span class="text-xs text-slate-500 border-l border-white/10 pl-3">History</span>
|
||||
<span class="text-[10px] text-slate-500 border-l border-white/10 pl-2">History</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Dropdown v-model="filterCharacter" :options="characters" optionLabel="name"
|
||||
placeholder="All Characters" showClear
|
||||
class="!w-48 !bg-slate-800/60 !border-white/10 !text-white !rounded-xl !text-sm" :pt="{
|
||||
root: { class: '!bg-slate-800/60 !h-8' },
|
||||
input: { class: '!text-white !text-xs !py-1 !px-2' },
|
||||
trigger: { class: '!text-slate-400 !w-6' },
|
||||
class="!w-40 !bg-slate-800/60 !border-white/10 !text-white !rounded-lg !text-[8px]" :pt="{
|
||||
root: { class: '!bg-slate-800/60 !h-7' },
|
||||
input: { class: '!text-white !text-[8px] !py-0.5 !px-2' },
|
||||
trigger: { class: '!text-slate-400 !w-5' },
|
||||
panel: { class: '!bg-slate-800 !border-white/10' },
|
||||
item: { class: '!text-slate-300 hover:!bg-white/10 hover:!text-white !text-xs !py-1.5' },
|
||||
clearIcon: { class: '!text-slate-400 hover:!text-white' }
|
||||
item: { class: '!text-slate-300 hover:!bg-white/10 hover:!text-white !text-[8px] !py-1' },
|
||||
clearIcon: { class: '!text-slate-400 hover:!text-white !text-[8px]' }
|
||||
}">
|
||||
<template #value="slotProps">
|
||||
<div v-if="slotProps.value" class="flex items-center gap-1.5">
|
||||
<div class="flex flex-row">
|
||||
<div v-if="slotProps.value" class="flex items-center gap-1">
|
||||
<img v-if="slotProps.value.avatar_image" :src="API_URL + slotProps.value.avatar_image"
|
||||
class="w-5 h-5 rounded-full object-cover" />
|
||||
<span class="text-xs">{{ slotProps.value.name }}</span>
|
||||
class="w-4 h-4 rounded-full object-cover" />
|
||||
<span class="text-[12px] ">{{ slotProps.value.name }}</span>
|
||||
</div>
|
||||
<span v-else class="text-slate-400 text-[12px] items-center">{{ slotProps.placeholder }}</span>
|
||||
</div>
|
||||
<span v-else class="text-xs text-slate-400">{{ slotProps.placeholder }}</span>
|
||||
</template>
|
||||
<template #option="slotProps">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<img v-if="slotProps.option.avatar_image" :src="API_URL + slotProps.option.avatar_image"
|
||||
class="w-6 h-6 rounded-full object-cover" />
|
||||
<span>{{ slotProps.option.name }}</span>
|
||||
class="w-5 h-5 rounded-full object-cover" />
|
||||
<span class="text-sm">{{ slotProps.option.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<Button :icon="onlyLiked ? 'pi pi-heart-fill' : 'pi pi-heart'"
|
||||
@click="onlyLiked = !onlyLiked" rounded text
|
||||
class="!w-7 !h-7 !p-0"
|
||||
:class="onlyLiked ? '!text-pink-500 !bg-pink-500/10' : '!text-slate-400 hover:!bg-white/10'"
|
||||
v-tooltip.bottom="onlyLiked ? 'Show all' : 'Show liked only'" />
|
||||
|
||||
<Button :icon="showNsfwGlobal ? 'pi pi-eye' : 'pi pi-eye-slash'"
|
||||
@click="showNsfwGlobal = !showNsfwGlobal" rounded text
|
||||
class="!w-7 !h-7 !p-0"
|
||||
:class="showNsfwGlobal ? '!text-red-400 !bg-red-500/10' : '!text-slate-400 hover:!bg-white/10'"
|
||||
v-tooltip.bottom="showNsfwGlobal ? 'Hide NSFW' : 'Show NSFW'" />
|
||||
|
||||
<Button icon="pi pi-refresh" @click="refreshHistory" rounded text
|
||||
class="!text-slate-400 hover:!bg-white/10 !w-8 !h-8 md:hidden" />
|
||||
class="!text-slate-400 hover:!bg-white/10 !w-7 !h-7 !p-0 md:hidden" />
|
||||
<Button :icon="isSelectMode ? 'pi pi-times' : 'pi pi-check-square'" @click="toggleSelectMode"
|
||||
rounded text class="!w-8 !h-8"
|
||||
rounded text class="!w-7 !h-7 !p-0"
|
||||
:class="isSelectMode ? '!text-violet-400 !bg-violet-500/20' : '!text-slate-400 hover:!bg-white/10'" />
|
||||
<Button icon="pi pi-cog" @click="isSettingsVisible = true" rounded text
|
||||
class="!text-slate-400 hover:!bg-white/10 !w-8 !h-8" v-if="!isSettingsVisible" />
|
||||
class="!text-slate-400 hover:!bg-white/10 !w-7 !h-7 !p-0" v-if="!isSettingsVisible" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -873,101 +946,25 @@ const confirmAddToAlbum = async () => {
|
||||
<div class="w-full h-full grid gap-0.5"
|
||||
:class="item.children.length <= 2 ? 'grid-cols-1' : 'grid-cols-2'">
|
||||
<div v-for="child in item.children" :key="child.id"
|
||||
class="relative group overflow-hidden" @click="toggleMobileOverlay(child.id)">
|
||||
class="relative group overflow-hidden">
|
||||
|
||||
<!-- Child: has result -->
|
||||
<img v-if="child.result_list && child.result_list.length > 0"
|
||||
:src="API_URL + '/assets/' + child.result_list[0] + '?thumbnail=true'"
|
||||
class="w-full h-full object-cover" />
|
||||
|
||||
<!-- 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">
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-tr from-violet-500/5 via-violet-500/10 to-cyan-500/5 animate-pulse">
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/5 to-transparent">
|
||||
</div>
|
||||
<i class="pi pi-spin pi-spinner text-violet-500 text-sm relative z-10"></i>
|
||||
</div>
|
||||
|
||||
<!-- Child: FAILED -->
|
||||
<div v-else-if="child.status === 'failed'"
|
||||
class="w-full h-full flex flex-col items-center justify-center bg-red-500/10 relative group p-2 text-center"
|
||||
v-tooltip.bottom="item.children.length > 1 ? (child.failed_reason || 'Generation failed') : null">
|
||||
<i class="pi pi-times-circle text-red-500 text-lg mb-0.5"></i>
|
||||
<span
|
||||
class="text-[8px] font-bold text-red-400 uppercase tracking-wide leading-tight">Failed</span>
|
||||
|
||||
<!-- Show error text if only 1 child -->
|
||||
<span v-if="item.children.length === 1 && child.failed_reason"
|
||||
class="text-[8px] text-red-300/70 mt-1 line-clamp-3 leading-tight">
|
||||
{{ child.failed_reason }}
|
||||
</span>
|
||||
|
||||
<!-- Delete Persistent for failed child -->
|
||||
<div class="absolute top-0 right-0 p-1 z-10">
|
||||
<Button icon="pi pi-trash" size="small"
|
||||
class="!w-5 !h-5 !rounded-full !bg-red-500/20 !border-none !text-red-400 !text-[8px] hover:!bg-red-500 hover:!text-white pointer-events-auto"
|
||||
@click.stop="deleteGeneration(child)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Child: other -->
|
||||
<div v-else class="w-full h-full flex items-center justify-center bg-slate-800">
|
||||
<i class="pi pi-image text-lg opacity-20 text-slate-600"></i>
|
||||
</div>
|
||||
|
||||
<!-- SUCCESS overlay per child (hover) -->
|
||||
<div v-if="child.result_list && child.result_list.length > 0"
|
||||
class="absolute inset-0 bg-black/60 transition-opacity duration-200 flex flex-col justify-between p-1 z-10"
|
||||
:class="{ 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto': activeOverlayId !== child.id, 'opacity-100 pointer-events-auto': activeOverlayId === child.id }">
|
||||
|
||||
<!-- Top right: edit, delete -->
|
||||
<div class="flex justify-end items-start gap-0.5">
|
||||
<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)" />
|
||||
<Button icon="pi pi-trash" size="small"
|
||||
class="!w-5 !h-5 !rounded-full !bg-red-500/20 !border-none !text-red-400 !text-[8px] hover:!bg-red-500 hover:!text-white"
|
||||
@click.stop="deleteGeneration(child)" />
|
||||
</div>
|
||||
|
||||
<!-- Center: view button + cost -->
|
||||
<div
|
||||
class="flex-1 flex flex-col items-center justify-center pointer-events-none">
|
||||
<span class="text-[8px] font-bold text-slate-300 font-mono mb-1">{{
|
||||
child.cost }} $</span>
|
||||
<Button icon="pi pi-eye" rounded text
|
||||
class="!bg-black/50 !text-white !w-8 !h-8 !rounded-full hover:!bg-black/70 hover:!scale-110 transition-all pointer-events-auto !border !border-white/20"
|
||||
@click.stop="openImagePreview(API_URL + '/assets/' + child.result_list[0])" />
|
||||
</div>
|
||||
|
||||
<!-- Bottom: reuse prompt + reuse assets -->
|
||||
<div class="flex gap-0.5">
|
||||
<Button icon="pi pi-comment" size="small"
|
||||
class="!w-5 !h-5 flex-1 !bg-white/10 !border-white/10 !text-slate-200 !text-[8px] hover:!bg-white/20"
|
||||
@click.stop="reusePrompt(child)" />
|
||||
<Button icon="pi pi-images" size="small"
|
||||
class="!w-5 !h-5 flex-1 !bg-white/10 !border-white/10 !text-slate-200 !text-[8px] hover:!bg-white/20"
|
||||
@click.stop="reuseAsset(child)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selection overlay for group children -->
|
||||
<div v-if="isSelectMode" class="absolute inset-0 z-30 pointer-events-none">
|
||||
<div class="absolute top-1 left-1 z-30 flex flex-col gap-0.5 pointer-events-auto">
|
||||
<template v-for="child in item.children" :key="'chk-'+child.id">
|
||||
<div v-if="child.result_list && child.result_list.length > 0"
|
||||
@click.stop="toggleImageSelection(child.result_list[0])"
|
||||
class="w-5 h-5 rounded-md flex items-center justify-center cursor-pointer transition-all"
|
||||
:class="selectedAssetIds.has(child.result_list[0]) ? 'bg-violet-600 text-white' : 'bg-black/40 border border-white/30 text-transparent'">
|
||||
<i class="pi pi-check" style="font-size: 10px"></i>
|
||||
</div>
|
||||
</template>
|
||||
<GenerationImage
|
||||
:generation="child"
|
||||
:api-url="API_URL"
|
||||
:is-select-mode="isSelectMode"
|
||||
:is-selected="selectedAssetIds.has(child.result_list?.[0])"
|
||||
:show-nsfw-global="showNsfwGlobal"
|
||||
:active-overlay-id="activeOverlayId"
|
||||
@toggle-select="toggleImageSelection"
|
||||
@open-preview="openImagePreview"
|
||||
@toggle-like="toggleLike"
|
||||
@delete="deleteGeneration"
|
||||
@reuse-prompt="reusePrompt"
|
||||
@reuse-asset="reuseAsset"
|
||||
@use-result="useResultAsAsset"
|
||||
@toggle-overlay="toggleMobileOverlay"
|
||||
@mark-nsfw="markNsfw"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -976,134 +973,23 @@ const confirmAddToAlbum = async () => {
|
||||
<!-- SINGLE GENERATION (full slot) -->
|
||||
<!-- ============================================ -->
|
||||
<template v-else>
|
||||
<div class="w-full h-full relative group" @click="toggleMobileOverlay(item.id)">
|
||||
|
||||
<!-- SUCCESS: image -->
|
||||
<img v-if="item.result_list && item.result_list.length > 0"
|
||||
:src="API_URL + '/assets/' + item.result_list[0] + '?thumbnail=true'"
|
||||
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])" />
|
||||
|
||||
<!-- 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">
|
||||
|
||||
<!-- Top Right: Delete (Persistent) -->
|
||||
<div class="w-full flex justify-end">
|
||||
<Button icon="pi pi-trash" v-tooltip.right="'Delete'"
|
||||
class="!w-6 !h-6 !rounded-full !bg-red-500/20 !border-none !text-red-400 text-[10px] hover:!bg-red-500 hover:!text-white z-10"
|
||||
@click.stop="deleteGeneration(item)" />
|
||||
</div>
|
||||
|
||||
<!-- Center: Error Info -->
|
||||
<div class="flex flex-col items-center justify-center flex-1">
|
||||
<i class="pi pi-times-circle text-red-500 text-2xl mb-1"></i>
|
||||
<span
|
||||
class="text-[10px] font-bold text-red-400 uppercase tracking-wide">Failed</span>
|
||||
<span v-if="item.failed_reason"
|
||||
class="text-[8px] text-red-300/70 mt-1 line-clamp-3 leading-tight"
|
||||
v-tooltip.top="item.failed_reason">{{ item.failed_reason }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Bottom: Reuse Buttons (Persistent) -->
|
||||
<div class="w-full flex gap-1 z-10">
|
||||
<Button icon="pi pi-comment" v-tooltip.bottom="'Reuse Prompt'"
|
||||
class="!w-6 !h-6 flex-1 !bg-white/10 !border-white/10 !text-slate-200 text-[10px] hover:!bg-white/20"
|
||||
@click.stop="reusePrompt(item)" />
|
||||
<Button icon="pi pi-images" v-tooltip.bottom="'Reuse Assets'"
|
||||
class="!w-6 !h-6 flex-1 !bg-white/10 !border-white/10 !text-slate-200 text-[10px] hover:!bg-white/20"
|
||||
@click.stop="reuseAsset(item)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PROCESSING -->
|
||||
<div v-else-if="['processing', 'starting', 'running'].includes(item.status)"
|
||||
class="w-full h-full flex flex-col items-center justify-center relative overflow-hidden bg-slate-800/50 border border-violet-500/20">
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-tr from-violet-500/5 via-violet-500/10 to-cyan-500/5 animate-pulse">
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/5 to-transparent">
|
||||
</div>
|
||||
<i class="pi pi-spin pi-spinner text-violet-500 text-xl mb-2 relative z-10"></i>
|
||||
<span class="text-[10px] text-violet-300/70 relative z-10 capitalize">{{ item.status
|
||||
}}...</span>
|
||||
<span v-if="item.progress"
|
||||
class="text-[9px] text-violet-400/60 font-mono mt-1 relative z-10">{{
|
||||
item.progress }}%</span>
|
||||
</div>
|
||||
|
||||
<!-- EMPTY -->
|
||||
<div v-else
|
||||
class="w-full h-full flex items-center justify-center text-slate-600 bg-slate-800">
|
||||
<i class="pi pi-image text-4xl opacity-20"></i>
|
||||
</div>
|
||||
|
||||
<!-- HOVER OVERLAY (for successful single gen state only) -->
|
||||
<div v-if="item.result_list && item.result_list.length > 0"
|
||||
class="absolute inset-0 bg-black/60 transition-opacity duration-200 flex flex-col justify-between p-2 z-10"
|
||||
:class="{ 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto': activeOverlayId !== item.id, 'opacity-100 pointer-events-auto': activeOverlayId === item.id }">
|
||||
|
||||
<!-- Top Right: buttons -->
|
||||
<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 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"
|
||||
@click.stop="useResultAsAsset(item)" />
|
||||
<Button icon="pi pi-trash" v-tooltip.left="'Delete'"
|
||||
class="!w-6 !h-6 !rounded-full !bg-red-500/20 !border-none !text-red-400 text-[10px] hover:!bg-red-500 hover:!text-white"
|
||||
@click.stop="deleteGeneration(item)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center: View button + Cost/Time -->
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none z-0">
|
||||
<!-- Cost & Time -->
|
||||
<div class="flex flex-col items-center gap-0.5 mb-2 pointer-events-none">
|
||||
<span
|
||||
class="text-[10px] font-bold text-slate-300 font-mono tracking-wider">{{
|
||||
item.cost }} $</span>
|
||||
<span v-if="item.execution_time_seconds"
|
||||
class="text-[8px] text-slate-500 font-mono">{{
|
||||
item.execution_time_seconds.toFixed(1) }}s</span>
|
||||
</div>
|
||||
<!-- View Button -->
|
||||
<Button icon="pi pi-eye" rounded text
|
||||
class="!bg-black/50 !text-white !w-12 !h-12 !rounded-full hover:!bg-black/70 hover:!scale-110 transition-all pointer-events-auto !border-2 !border-white/20"
|
||||
@click.stop="openImagePreview(API_URL + '/assets/' + item.result_list[0])" />
|
||||
</div>
|
||||
|
||||
<!-- Bottom: reuse buttons -->
|
||||
<div
|
||||
class="translate-y-[10px] group-hover:translate-y-0 transition-transform duration-200 z-10">
|
||||
<div class="flex gap-1 mb-1">
|
||||
<Button icon="pi pi-comment" v-tooltip.bottom="'Reuse Prompt'"
|
||||
class="!w-6 !h-6 flex-1 !bg-white/10 !border-white/10 !text-slate-200 text-[10px] hover:!bg-white/20"
|
||||
@click.stop="reusePrompt(item)" />
|
||||
<Button icon="pi pi-images" v-tooltip.bottom="'Reuse Assets'"
|
||||
class="!w-6 !h-6 flex-1 !bg-white/10 !border-white/10 !text-slate-200 text-[10px] hover:!bg-white/20"
|
||||
@click.stop="reuseAsset(item)" />
|
||||
</div>
|
||||
<p class="text-[10px] text-white/70 line-clamp-1 leading-tight">{{ item.prompt
|
||||
}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Select mode checkbox overlay -->
|
||||
<div v-if="isSelectMode && item.result_list && item.result_list.length > 0"
|
||||
class="absolute inset-0 z-20 cursor-pointer"
|
||||
@click.stop="toggleImageSelection(item.result_list[0])">
|
||||
<div class="absolute top-2 left-2 w-6 h-6 rounded-lg flex items-center justify-center transition-all"
|
||||
:class="selectedAssetIds.has(item.result_list[0]) ? 'bg-violet-600 text-white' : 'bg-black/40 border border-white/30 text-transparent hover:border-white/60'">
|
||||
<i class="pi pi-check" style="font-size: 12px"></i>
|
||||
</div>
|
||||
<div v-if="selectedAssetIds.has(item.result_list[0])"
|
||||
class="absolute inset-0 bg-violet-600/20 pointer-events-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
<GenerationImage
|
||||
:generation="item"
|
||||
:api-url="API_URL"
|
||||
:is-select-mode="isSelectMode"
|
||||
:is-selected="selectedAssetIds.has(item.result_list?.[0])"
|
||||
:show-nsfw-global="showNsfwGlobal"
|
||||
:active-overlay-id="activeOverlayId"
|
||||
@toggle-select="toggleImageSelection"
|
||||
@open-preview="openImagePreview"
|
||||
@toggle-like="toggleLike"
|
||||
@delete="deleteGeneration"
|
||||
@reuse-prompt="reusePrompt"
|
||||
@reuse-asset="reuseAsset"
|
||||
@use-result="useResultAsAsset"
|
||||
@toggle-overlay="toggleMobileOverlay"
|
||||
@mark-nsfw="markNsfw"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1144,17 +1030,20 @@ const confirmAddToAlbum = async () => {
|
||||
:disabled="!prompt || prompt.length <= 10"
|
||||
class="!py-0.5 !px-2 !text-[10px] !h-6 !bg-violet-600/20 hover:!bg-violet-600/30 !border-violet-500/30 !text-violet-400 disabled:opacity-50"
|
||||
@click="handleImprovePrompt" />
|
||||
<Button icon="pi pi-clipboard" label="Paste"
|
||||
class="!py-0.5 !px-2 !text-[10px] !h-6 !bg-slate-800 hover:!bg-slate-700 !border-white/10 !text-slate-400"
|
||||
@click="pastePrompt" />
|
||||
<Button icon="pi pi-times" label="Clear"
|
||||
class="!py-0.5 !px-2 !text-[10px] !h-6 !bg-slate-800 hover:!bg-slate-700 !border-white/10 !text-slate-400"
|
||||
@click="clearPrompt" />
|
||||
</div>
|
||||
</div>
|
||||
<Textarea v-model="prompt" rows="3" autoResize
|
||||
placeholder="Describe what you want to create..."
|
||||
class="w-full bg-slate-800 !text-[16px] border-white/10 text-white rounded-xl p-3 focus:border-violet-500 focus:ring-1 focus:ring-violet-500/50 transition-all resize-none shadow-inner" />
|
||||
<Textarea v-model="prompt" rows="2" placeholder="Describe what you want to create..."
|
||||
class="w-full bg-slate-800 !h-28 !text-[16px] border-white/10 text-white rounded-xl p-3 focus:border-violet-500 focus:ring-1 focus:ring-violet-500/50 transition-all resize-none shadow-inner" />
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Character & Assets Row -->
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex-1 flex flex-col gap-2">
|
||||
<label class="text-xs font-bold text-slate-400 uppercase tracking-wider">Character
|
||||
@@ -1188,7 +1077,8 @@ const confirmAddToAlbum = async () => {
|
||||
</Dropdown>
|
||||
|
||||
<div v-if="selectedCharacter"
|
||||
class="flex items-center gap-2 mt-2 px-1 animate-in fade-in slide-in-from-top-1">
|
||||
class="flex flex-wrap items-center gap-x-4 gap-y-2 mt-2 px-1 animate-in fade-in slide-in-from-top-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox v-model="useProfileImage" :binary="true" inputId="use-profile-img"
|
||||
class="!border-white/20" :pt="{
|
||||
box: ({ props, state }) => ({
|
||||
@@ -1196,9 +1086,19 @@ const confirmAddToAlbum = async () => {
|
||||
})
|
||||
}" />
|
||||
<label for="use-profile-img"
|
||||
class="text-xs text-slate-300 cursor-pointer select-none">Use
|
||||
Character
|
||||
Photo</label>
|
||||
class="text-xs text-slate-300 cursor-pointer select-none">Use Photo</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox v-model="useEnvironment" :binary="true" inputId="use-env"
|
||||
class="!border-white/20" :pt="{
|
||||
box: ({ props, state }) => ({
|
||||
class: ['!bg-slate-800 !border-white/20', { '!bg-violet-600 !border-violet-600': props.modelValue }]
|
||||
})
|
||||
}" />
|
||||
<label for="use-env"
|
||||
class="text-xs text-slate-300 cursor-pointer select-none">Use Environment</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1223,22 +1123,85 @@ const confirmAddToAlbum = async () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Environment Row (Below) -->
|
||||
<div v-if="selectedCharacter && useEnvironment" class="flex flex-col gap-2 animate-in fade-in slide-in-from-top-1 mt-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<label class="text-xs font-bold text-slate-400 uppercase tracking-wider">Environment</label>
|
||||
<Button v-if="selectedEnvironment" icon="pi pi-times" @click="selectedEnvironment = null" text size="small"
|
||||
class="!p-0 !h-5 !w-5 !text-[10px] text-slate-500 hover:text-white" />
|
||||
</div>
|
||||
|
||||
<div v-if="environments.length > 0" class="flex gap-2 overflow-x-auto pb-2 custom-scrollbar no-scrollbar">
|
||||
<div v-for="env in environments" :key="env.id || env._id"
|
||||
@click="selectedEnvironment = env"
|
||||
class="flex-shrink-0 flex items-center gap-3 px-3 py-2 rounded-xl border-2 transition-all cursor-pointer group bg-slate-800/40"
|
||||
:class="[
|
||||
(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id))
|
||||
? 'border-violet-500 bg-violet-500/10 shadow-[0_0_20px_rgba(124,58,237,0.15)]'
|
||||
: 'border-white/5 hover:border-white/20'
|
||||
]"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-lg overflow-hidden flex-shrink-0 bg-slate-900 flex items-center justify-center border border-white/5">
|
||||
<img v-if="env.asset_ids?.length > 0"
|
||||
:src="API_URL + '/assets/' + env.asset_ids[0] + '?thumbnail=true'"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<i v-else class="pi pi-map-marker text-xs"
|
||||
:class="(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id)) ? 'text-violet-400' : 'text-slate-500'"
|
||||
></i>
|
||||
</div>
|
||||
<span class="text-sm whitespace-nowrap pr-1"
|
||||
:class="(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id)) ? 'text-violet-300 font-bold' : 'text-slate-400 group-hover:text-slate-200'"
|
||||
>
|
||||
{{ env.name }}
|
||||
</span>
|
||||
<i v-if="(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id))"
|
||||
class="pi pi-check text-violet-400 text-xs"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="py-4 px-4 bg-slate-800/50 border border-white/5 rounded-2xl text-center">
|
||||
<p class="text-xs text-slate-600 uppercase m-0">No environments for this character</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full lg:w-80 flex flex-col gap-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-bold text-slate-400 uppercase tracking-wider">Quality</label>
|
||||
<Dropdown v-model="quality" :options="qualityOptions" optionLabel="value"
|
||||
class="w-full !bg-slate-800 !border-white/10 !text-white !rounded-xl"
|
||||
:pt="{ input: { class: '!text-white' }, trigger: { class: '!text-slate-400' }, panel: { class: '!bg-slate-800 !border-white/10' }, item: { class: '!text-slate-300 hover:!bg-white/10 hover:!text-white' } }" />
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Model</label>
|
||||
<div class="flex bg-slate-800 p-1 rounded-xl border border-white/10 h-[34px]">
|
||||
<div v-for="option in modelOptions" :key="option.key" @click="model = option"
|
||||
class="flex-1 flex items-center justify-center cursor-pointer rounded-md text-[10px] font-bold transition-all"
|
||||
:class="model.key === option.key ? 'bg-white/10 text-white shadow-sm' : 'text-slate-500 hover:text-slate-300'">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Quality</label>
|
||||
<div class="flex bg-slate-800 p-1 rounded-xl border border-white/10 h-[34px]">
|
||||
<div v-for="option in qualityOptions" :key="option.key" @click="quality = option"
|
||||
class="flex-1 flex items-center justify-center cursor-pointer rounded-md text-[10px] font-bold transition-all"
|
||||
:class="quality.key === option.key ? 'bg-white/10 text-white shadow-sm' : 'text-slate-500 hover:text-slate-300'">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Format</label>
|
||||
<div class="flex bg-slate-800 rounded-xl border border-white/10 h-[34px] p-1">
|
||||
<button @click="aspectRatio = 'THREEFOUR'"
|
||||
class="flex-1 text-[10px] font-bold transition-all flex items-center justify-center rounded-md"
|
||||
:class="aspectRatio === 'THREEFOUR' ? 'bg-white/10 text-white shadow-sm' : 'text-slate-500 hover:text-white hover:bg-white/5'">
|
||||
<i class="pi pi-image"></i>
|
||||
</button>
|
||||
<button @click="aspectRatio = 'NINESIXTEEN'"
|
||||
class="flex-1 text-[10px] font-bold transition-all flex items-center justify-center rounded-md"
|
||||
:class="aspectRatio === 'NINESIXTEEN' ? 'bg-white/10 text-white shadow-sm' : 'text-slate-500 hover:text-white hover:bg-white/5'">
|
||||
<i class="pi pi-video"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-bold text-slate-400 uppercase tracking-wider">Aspect
|
||||
Ratio</label>
|
||||
<Dropdown v-model="aspectRatio" :options="aspectRatioOptions" optionLabel="value"
|
||||
class="w-full !bg-slate-800 !border-white/10 !text-white !rounded-xl"
|
||||
:pt="{ input: { class: '!text-white' }, trigger: { class: '!text-slate-400' }, panel: { class: '!bg-slate-800 !border-white/10' }, item: { class: '!text-slate-300 hover:!bg-white/10 hover:!text-white' } }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1256,18 +1219,6 @@ const confirmAddToAlbum = async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 bg-slate-800/50 p-3 rounded-xl border border-white/5">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox v-model="sendToTelegram" :binary="true" inputId="tg-check" />
|
||||
<label for="tg-check" class="text-xs text-slate-300 cursor-pointer">Send to
|
||||
Telegram</label>
|
||||
</div>
|
||||
<div v-if="sendToTelegram" class="animate-in fade-in slide-in-from-top-1">
|
||||
<InputText v-model="telegramId" placeholder="Telegram ID"
|
||||
class="w-full !text-xs !bg-slate-900 !border-white/10 !text-white !py-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto">
|
||||
<Button :label="isSubmitting ? 'Starting...' : 'Generate'"
|
||||
:icon="isSubmitting ? 'pi pi-spin pi-spinner' : 'pi pi-sparkles'"
|
||||
@@ -1291,44 +1242,16 @@ const confirmAddToAlbum = async () => {
|
||||
|
||||
|
||||
|
||||
<Dialog v-model:visible="isImagePreviewVisible" modal dismissableMask
|
||||
:style="{ width: '95vw', maxWidth: '1100px', 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.self="isImagePreviewVisible = false">
|
||||
|
||||
<!-- Previous Button -->
|
||||
<Button v-if="allPreviewImages.length > 1" icon="pi pi-chevron-left" @click.stop="navigatePreview(-1)"
|
||||
rounded text
|
||||
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>
|
||||
<GenerationPreviewModal
|
||||
v-model:visible="isImagePreviewVisible"
|
||||
:preview-images="allPreviewImages"
|
||||
:initial-index="previewIndex"
|
||||
:api-url="API_URL"
|
||||
@reuse-prompt="reusePrompt"
|
||||
@reuse-asset="reuseAsset"
|
||||
@use-result-as-asset="useResultAsAsset"
|
||||
@liked="handleLiked"
|
||||
/>
|
||||
|
||||
<Dialog v-model:visible="isAssetPickerVisible" modal header="Select Assets"
|
||||
:style="{ width: '80vw', maxWidth: '900px' }"
|
||||
|
||||
@@ -26,55 +26,130 @@ import FileUpload from 'primevue/fileupload'
|
||||
import ConfirmDialog from 'primevue/confirmdialog'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import DatePicker from 'primevue/datepicker'
|
||||
import Tag from 'primevue/tag'
|
||||
import InputSwitch from 'primevue/inputswitch'
|
||||
import GenerationPreviewModal from '../components/GenerationPreviewModal.vue'
|
||||
import GenerationImage from '../components/GenerationImage.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const ideaStore = useIdeaStore()
|
||||
const confirm = useConfirm()
|
||||
const toast = useToast()
|
||||
const { currentIdea, loading, error } = storeToRefs(ideaStore)
|
||||
const { currentIdea, currentInspiration, loading, error } = storeToRefs(ideaStore)
|
||||
const generations = ref([])
|
||||
const inspirationContentType = ref('')
|
||||
|
||||
// --- Idea Name Editing ---
|
||||
const isEditingName = ref(false)
|
||||
const editableName = ref('')
|
||||
|
||||
const toggleEditName = () => {
|
||||
if (!currentIdea.value) return
|
||||
editableName.value = currentIdea.value.name
|
||||
isEditingName.value = true
|
||||
nextTick(() => {
|
||||
const input = document.querySelector('.idea-name-input input')
|
||||
if (input) input.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const saveName = async () => {
|
||||
if (!editableName.value.trim() || editableName.value === currentIdea.value.name) {
|
||||
isEditingName.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await ideaStore.updateIdea(currentIdea.value.id, {
|
||||
name: editableName.value.trim()
|
||||
})
|
||||
if (success) {
|
||||
toast.add({ severity: 'success', summary: 'Success', detail: 'Idea renamed', life: 2000 })
|
||||
}
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to rename idea', life: 3000 })
|
||||
} finally {
|
||||
isEditingName.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const prompt = ref('')
|
||||
const negativePrompt = ref('')
|
||||
const selectedModel = ref('flux-schnell')
|
||||
const model = ref({ key: 'gemini-3-pro-image-preview', value: 'Pro' })
|
||||
const modelOptions = ref([
|
||||
{ key: 'gemini-3.1-flash-image-preview', value: '2' },
|
||||
{ key: 'gemini-3-pro-image-preview', value: 'Pro' }
|
||||
])
|
||||
const selectedModel = ref('flux-schnell') // Keep legacy if needed elsewhere, but we will use 'model' for generation
|
||||
|
||||
// Character & Assets (declared early for settings persistence)
|
||||
const characters = ref([])
|
||||
const selectedCharacter = ref(null)
|
||||
const environments = ref([])
|
||||
const selectedEnvironment = ref(null)
|
||||
const selectedAssets = ref([])
|
||||
const showAssetPicker = ref(false) // Deprecated, using isAssetPickerVisible
|
||||
|
||||
// --- Load saved settings from localStorage ---
|
||||
const SETTINGS_KEY = 'idea-gen-settings'
|
||||
const quality = ref({ key: 'TWOK', value: '2K' })
|
||||
const aspectRatio = ref({ key: 'NINESIXTEEN', value: '9:16' })
|
||||
const aspectRatio = ref('NINESIXTEEN') // Default to Video (9:16)
|
||||
const imageCount = ref(1)
|
||||
const sendToTelegram = ref(false)
|
||||
const telegramId = ref('')
|
||||
const useProfileImage = ref(true)
|
||||
const useEnvironment = ref(false)
|
||||
const isImprovingPrompt = ref(false)
|
||||
const previousPrompt = ref('')
|
||||
let _savedCharacterId = null
|
||||
let _savedEnvironmentId = null
|
||||
|
||||
// NSFW Toggle
|
||||
const showNsfwGlobal = ref(localStorage.getItem('show_nsfw_global') === 'true')
|
||||
|
||||
watch(showNsfwGlobal, (val) => {
|
||||
localStorage.setItem('show_nsfw_global', val)
|
||||
})
|
||||
|
||||
const loadEnvironments = async (charId) => {
|
||||
if (!charId) {
|
||||
environments.value = []
|
||||
selectedEnvironment.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await dataService.getEnvironments(charId)
|
||||
environments.value = Array.isArray(response) ? response : (response.environments || [])
|
||||
|
||||
if (_savedEnvironmentId) {
|
||||
selectedEnvironment.value = environments.value.find(e => (e.id === _savedEnvironmentId || e._id === _savedEnvironmentId)) || null
|
||||
_savedEnvironmentId = null
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load environments', e)
|
||||
environments.value = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedCharacter, (newChar) => {
|
||||
loadEnvironments(newChar?.id || newChar?._id)
|
||||
})
|
||||
|
||||
// --- Persist settings to localStorage on change ---
|
||||
const saveSettings = () => {
|
||||
const settings = {
|
||||
prompt: prompt.value,
|
||||
model: model.value,
|
||||
quality: quality.value,
|
||||
aspectRatio: aspectRatio.value,
|
||||
imageCount: imageCount.value,
|
||||
selectedModel: selectedModel.value,
|
||||
sendToTelegram: sendToTelegram.value,
|
||||
telegramId: telegramId.value,
|
||||
useProfileImage: useProfileImage.value,
|
||||
selectedCharacterId: selectedCharacter.value?.id || null,
|
||||
useEnvironment: useEnvironment.value,
|
||||
selectedCharacterId: selectedCharacter.value?.id || selectedCharacter.value?._id || null,
|
||||
selectedEnvironmentId: selectedEnvironment.value?.id || selectedEnvironment.value?._id || null,
|
||||
selectedAssetIds: selectedAssets.value.map(a => a.id),
|
||||
}
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings))
|
||||
if (telegramId.value) {
|
||||
localStorage.setItem('telegram_id', telegramId.value)
|
||||
}
|
||||
}
|
||||
|
||||
const restoreSettings = () => {
|
||||
@@ -82,15 +157,23 @@ const restoreSettings = () => {
|
||||
if (!stored) return
|
||||
try {
|
||||
const s = JSON.parse(stored)
|
||||
if (s.model) model.value = s.model
|
||||
if (s.prompt) prompt.value = s.prompt
|
||||
if (s.quality) quality.value = s.quality
|
||||
if (s.aspectRatio) aspectRatio.value = s.aspectRatio
|
||||
if (s.aspectRatio) {
|
||||
// Handle legacy object format if present
|
||||
if (typeof s.aspectRatio === 'object' && s.aspectRatio.key) {
|
||||
aspectRatio.value = s.aspectRatio.key
|
||||
} else {
|
||||
aspectRatio.value = s.aspectRatio
|
||||
}
|
||||
}
|
||||
if (s.imageCount) imageCount.value = Math.min(s.imageCount, 4)
|
||||
if (s.selectedModel) selectedModel.value = s.selectedModel
|
||||
sendToTelegram.value = s.sendToTelegram || false
|
||||
telegramId.value = s.telegramId || localStorage.getItem('telegram_id') || ''
|
||||
if (s.useProfileImage !== undefined) useProfileImage.value = s.useProfileImage
|
||||
if (s.useEnvironment !== undefined) useEnvironment.value = s.useEnvironment
|
||||
_savedCharacterId = s.selectedCharacterId || null
|
||||
_savedEnvironmentId = s.selectedEnvironmentId || null
|
||||
if (s.selectedAssetIds && s.selectedAssetIds.length > 0) {
|
||||
selectedAssets.value = s.selectedAssetIds.map(id => ({
|
||||
id,
|
||||
@@ -104,25 +187,23 @@ const restoreSettings = () => {
|
||||
}
|
||||
restoreSettings()
|
||||
|
||||
watch([prompt, quality, aspectRatio, imageCount, selectedModel, sendToTelegram, telegramId, useProfileImage, selectedCharacter, selectedAssets], saveSettings, { deep: true })
|
||||
watch([prompt, quality, aspectRatio, imageCount, model, useProfileImage, useEnvironment, selectedCharacter, selectedEnvironment, selectedAssets], saveSettings, { deep: true })
|
||||
|
||||
const viewMode = ref('feed') // 'feed' or 'gallery'
|
||||
const onlyLiked = ref(false)
|
||||
const isSubmitting = ref(false)
|
||||
const isSettingsVisible = ref(true)
|
||||
const isSettingsVisible = ref(localStorage.getItem('idea_detail_settings_visible') !== 'false')
|
||||
|
||||
watch(isSettingsVisible, (val) => {
|
||||
localStorage.setItem('idea_detail_settings_visible', val)
|
||||
})
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL
|
||||
|
||||
const qualityOptions = ref([
|
||||
{ key: 'ONEK', value: '1K' },
|
||||
{ key: 'TWOK', value: '2K' },
|
||||
{ key: 'FOURK', value: '4K' }
|
||||
])
|
||||
const aspectRatioOptions = ref([
|
||||
{ key: "NINESIXTEEN", value: "9:16" },
|
||||
{ key: "FOURTHREE", value: "4:3" },
|
||||
{ key: "THREEFOUR", value: "3:4" },
|
||||
{ key: "SIXTEENNINE", value: "16:9" }
|
||||
])
|
||||
|
||||
// Removed duplicate characters ref
|
||||
const loadingGenerations = ref(false) // Added this ref based on usage in fetchGenerations
|
||||
@@ -139,6 +220,11 @@ onMounted(async () => {
|
||||
])
|
||||
console.log('Fetched idea:', currentIdea.value)
|
||||
if (currentIdea.value) {
|
||||
// Check for inspiration
|
||||
if (currentIdea.value.inspiration_id) {
|
||||
ideaStore.fetchInspiration(currentIdea.value.inspiration_id)
|
||||
}
|
||||
|
||||
// Check for autostart query param
|
||||
if (route.query.autostart === 'true') {
|
||||
// Slight delay to ensure everything is reactive and mounted
|
||||
@@ -165,7 +251,7 @@ const loadCharacters = async () => {
|
||||
characters.value = await dataService.getCharacters()
|
||||
// Restore saved character selection after characters are loaded
|
||||
if (_savedCharacterId && characters.value.length > 0) {
|
||||
const found = characters.value.find(c => c.id === _savedCharacterId)
|
||||
const found = characters.value.find(c => (c.id === _savedCharacterId || c._id === _savedCharacterId))
|
||||
if (found) selectedCharacter.value = found
|
||||
_savedCharacterId = null
|
||||
}
|
||||
@@ -177,7 +263,7 @@ const loadCharacters = async () => {
|
||||
const fetchGenerations = async (ideaId) => {
|
||||
loadingGenerations.value = true
|
||||
try {
|
||||
const response = await ideaStore.fetchIdeaGenerations(ideaId, 100)
|
||||
const response = await ideaStore.fetchIdeaGenerations(ideaId, 100, 0, onlyLiked.value)
|
||||
let loadedGens = []
|
||||
if (response.data && response.data.generations) {
|
||||
loadedGens = response.data.generations
|
||||
@@ -217,12 +303,13 @@ const handleGenerate = async () => {
|
||||
try {
|
||||
// Construct Payload
|
||||
const payload = {
|
||||
model: model.value.key,
|
||||
prompt: prompt.value,
|
||||
aspect_ratio: aspectRatio.value.key,
|
||||
aspect_ratio: aspectRatio.value, // Now a string
|
||||
quality: quality.value.key,
|
||||
assets_list: selectedAssets.value.map(a => a.id),
|
||||
linked_character_id: selectedCharacter.value?.id || null,
|
||||
telegram_id: sendToTelegram.value ? telegramId.value : null,
|
||||
linked_character_id: selectedCharacter.value?.id || selectedCharacter.value?._id || null,
|
||||
environment_id: (selectedCharacter.value && useEnvironment.value) ? (selectedEnvironment.value?.id || selectedEnvironment.value?._id || null) : null,
|
||||
use_profile_image: selectedCharacter.value ? useProfileImage.value : false,
|
||||
count: imageCount.value,
|
||||
idea_id: currentIdea.value.id
|
||||
@@ -413,6 +500,15 @@ const clearPrompt = () => {
|
||||
previousPrompt.value = ''
|
||||
}
|
||||
|
||||
const pastePrompt = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText()
|
||||
if (text) prompt.value = text
|
||||
} catch (err) {
|
||||
console.error('Failed to read clipboard', err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Asset Picker Logic ---
|
||||
const isAssetPickerVisible = ref(false)
|
||||
@@ -514,29 +610,32 @@ const useResultAsAsset = (gen) => {
|
||||
const isImagePreviewVisible = ref(false)
|
||||
const previewImages = ref([])
|
||||
const previewIndex = ref(0)
|
||||
|
||||
const openImagePreview = (imageList, startIdx = 0) => {
|
||||
// imageList should now be [{url, gen}]
|
||||
previewImages.value = imageList
|
||||
previewIndex.value = startIdx
|
||||
isImagePreviewVisible.value = true
|
||||
}
|
||||
const prevPreview = () => {
|
||||
if (previewIndex.value > 0) previewIndex.value--
|
||||
}
|
||||
const nextPreview = () => {
|
||||
if (previewIndex.value < previewImages.value.length - 1) previewIndex.value++
|
||||
|
||||
// --- Inspiration Logic ---
|
||||
const showInspirationDialog = ref(false)
|
||||
|
||||
const openInspirationDialog = () => {
|
||||
showInspirationDialog.value = true
|
||||
}
|
||||
|
||||
// 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(currentInspiration, async (newVal) => {
|
||||
if (newVal && newVal.asset_id) {
|
||||
try {
|
||||
const meta = await dataService.getAssetMetadata(newVal.asset_id)
|
||||
inspirationContentType.value = meta.content_type
|
||||
} catch (e) {
|
||||
// console.warn('Failed to get asset metadata', e)
|
||||
inspirationContentType.value = 'image/jpeg'
|
||||
}
|
||||
watch(isImagePreviewVisible, (visible) => {
|
||||
if (visible) window.addEventListener('keydown', handlePreviewKeydown)
|
||||
else window.removeEventListener('keydown', handlePreviewKeydown)
|
||||
})
|
||||
onUnmounted(() => window.removeEventListener('keydown', handlePreviewKeydown))
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// --- Computeds ---
|
||||
const groupedGenerations = computed(() => {
|
||||
@@ -602,6 +701,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))
|
||||
})
|
||||
@@ -616,7 +720,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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -629,7 +734,10 @@ const deleteIdea = () => {
|
||||
message: 'Delete this entire idea session? All generations will remain in your history.',
|
||||
header: 'Delete Idea',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Delete',
|
||||
rejectLabel: 'Cancel',
|
||||
acceptClass: 'p-button-danger',
|
||||
rejectClass: 'p-button-secondary p-button-text',
|
||||
accept: async () => {
|
||||
await ideaStore.deleteIdea(currentIdea.value.id)
|
||||
router.push('/ideas')
|
||||
@@ -674,16 +782,47 @@ const downloadSelected = async () => {
|
||||
const projectId = localStorage.getItem('active_project_id')
|
||||
if (projectId) headers['X-Project-ID'] = projectId
|
||||
|
||||
// Multi-file ZIP (if > 1)
|
||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) || navigator.maxTouchPoints > 1
|
||||
const canShare = isMobile && navigator.canShare && navigator.share
|
||||
|
||||
if (canShare) {
|
||||
// Attempt to fetch all files and share
|
||||
try {
|
||||
const files = []
|
||||
for (const assetId of ids) {
|
||||
const url = API_URL + '/assets/' + assetId
|
||||
const resp = await fetch(url, { headers })
|
||||
const blob = await resp.blob()
|
||||
const mime = blob.type || 'image/png'
|
||||
const ext = mime.split('/')[1] || 'png'
|
||||
files.push(new File([blob], `image-${assetId}.${ext}`, { type: mime }))
|
||||
}
|
||||
|
||||
if (navigator.canShare({ files })) {
|
||||
await navigator.share({ files })
|
||||
toast.add({ severity: 'success', summary: 'Shared', detail: `${files.length} images shared`, life: 2000 })
|
||||
return // Success, exit
|
||||
}
|
||||
} catch (shareError) {
|
||||
console.warn('Share failed or canceled, falling back to zip if multiple', shareError)
|
||||
// Fallthrough to zip/single download logic if share fails (e.g. user cancelled or limit reached)
|
||||
// actually if user canceled, we probably shouldn't zip. But hard to distinguish.
|
||||
// If it was a real error, zip might be better.
|
||||
if (shareError.name !== 'AbortError' && ids.length > 1) {
|
||||
toast.add({ severity: 'info', summary: 'Sharing failed', detail: 'Creating zip archive instead...', life: 2000 })
|
||||
} else {
|
||||
return // Stop if just cancelled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: ZIP for multiple, Direct for single (if sharing skipped/failed)
|
||||
if (ids.length > 1) {
|
||||
const zip = new JSZip()
|
||||
// Sanitize idea name for filename (fallback to 'session' if no name)
|
||||
const safeName = (currentIdea.value?.name || 'session').replace(/[^a-z0-9_\- ]/gi, '').trim().replace(/\s+/g, '_').toLowerCase()
|
||||
const folderName = `${safeName}_assets`
|
||||
|
||||
let successCount = 0
|
||||
|
||||
// Sequential fetch to avoid overwhelming network but could be parallel
|
||||
await Promise.all(ids.map(async (assetId) => {
|
||||
try {
|
||||
const url = API_URL + '/assets/' + assetId
|
||||
@@ -715,18 +854,13 @@ const downloadSelected = async () => {
|
||||
toast.add({ severity: 'success', summary: 'Archived', detail: `${successCount} images saved to zip`, life: 3000 })
|
||||
|
||||
} else {
|
||||
// Single File
|
||||
// Single File Download (Desktop or Mobile Fallback)
|
||||
const assetId = ids[0]
|
||||
const url = API_URL + '/assets/' + assetId
|
||||
const resp = await fetch(url, { headers })
|
||||
const blob = await resp.blob()
|
||||
|
||||
// Use share sheet only on mobile, direct download on desktop
|
||||
const file = new File([blob], assetId + '.png', { type: blob.type || 'image/png' })
|
||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) || navigator.maxTouchPoints > 1
|
||||
if (isMobile && navigator.canShare && navigator.canShare({ files: [file] })) {
|
||||
await navigator.share({ files: [file] })
|
||||
} else {
|
||||
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(file)
|
||||
a.download = file.name
|
||||
@@ -734,7 +868,7 @@ const downloadSelected = async () => {
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(a.href)
|
||||
}
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Downloaded', detail: `Image saved`, life: 2000 })
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -788,6 +922,31 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
watch(onlyLiked, (newVal) => {
|
||||
if (currentIdea.value) {
|
||||
fetchGenerations(currentIdea.value.id)
|
||||
}
|
||||
})
|
||||
|
||||
// Exit select mode when switching to feed
|
||||
watch(viewMode, (v) => {
|
||||
if (v !== 'gallery') {
|
||||
@@ -796,6 +955,26 @@ watch(viewMode, (v) => {
|
||||
}
|
||||
})
|
||||
|
||||
const markNsfw = async (gen) => {
|
||||
// if (!confirm('Are you sure you want to mark this generation as NSFW?')) return
|
||||
const currentNsfw = gen.is_nsfw || gen.nsfw || false
|
||||
const newNsfw = !currentNsfw
|
||||
|
||||
try {
|
||||
await aiService.markGenerationNsfw(gen.id, newNsfw)
|
||||
gen.is_nsfw = newNsfw
|
||||
if (gen.nsfw !== undefined) gen.nsfw = newNsfw
|
||||
|
||||
if (gen.isGroup && gen.children) {
|
||||
gen.children.forEach(c => {
|
||||
c.is_nsfw = newNsfw
|
||||
if (c.nsfw !== undefined) c.nsfw = newNsfw
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle NSFW', e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -806,35 +985,57 @@ watch(viewMode, (v) => {
|
||||
<main class="flex-1 flex flex-col min-w-0 bg-slate-950/50 relative">
|
||||
<!-- Header -->
|
||||
<header
|
||||
class="h-16 border-b border-white/5 flex items-center justify-between px-6 bg-slate-900/80 backdrop-blur z-20">
|
||||
<div class="flex items-center gap-4">
|
||||
class="h-10 border-b border-white/5 flex items-center justify-between px-4 bg-slate-900/80 backdrop-blur z-20">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-lg font-bold text-slate-200 truncate max-w-[200px] md:max-w-md">{{
|
||||
currentIdea?.name || 'Loading...' }}</h1>
|
||||
<div class="flex items-center gap-2 items-center">
|
||||
<div v-if="isEditingName" class="flex items-center gap-2">
|
||||
<InputText v-model="editableName"
|
||||
class="idea-name-input !bg-slate-800 !border-violet-500/50 !text-white !py-0.5 !h-7 !text-[16px] !font-bold"
|
||||
@keyup.enter="saveName"
|
||||
@blur="saveName"
|
||||
/>
|
||||
</div>
|
||||
<h1 v-else class="text-sm font-bold !m-0 text-slate-200 truncate max-w-[150px] md:max-w-md cursor-pointer hover:text-violet-400 transition-colors"
|
||||
@click="toggleEditName">
|
||||
{{ currentIdea?.name || 'Loading...' }}
|
||||
<i class="pi pi-pencil text-[8px] ml-1 opacity-50"></i>
|
||||
</h1>
|
||||
<span
|
||||
class="px-2 py-0.5 rounded-full bg-slate-800 text-[10px] text-slate-400 border border-white/5">Idea
|
||||
Session</span>
|
||||
class="px-1.5 py-0.5 rounded-full bg-slate-800 text-[8px] text-slate-500 border border-white/5">Idea</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- View Toggle -->
|
||||
<div class="flex bg-slate-800 rounded-lg p-1 border border-white/5">
|
||||
<div class="flex bg-slate-800 rounded-lg p-0.5 border border-white/5">
|
||||
<button @click="viewMode = 'feed'"
|
||||
class="px-3 py-1.5 rounded-md text-xs font-medium transition-all"
|
||||
class="px-2 py-1 rounded-md text-[10px] font-medium transition-all items-center flex"
|
||||
:class="viewMode === 'feed' ? 'bg-violet-600 text-white shadow-lg' : 'text-slate-400 hover:text-slate-200'">
|
||||
<i class="pi pi-list mr-1"></i> Feed
|
||||
</button>
|
||||
<button @click="viewMode = 'gallery'"
|
||||
class="px-3 py-1.5 rounded-md text-xs font-medium transition-all"
|
||||
class="px-2 py-1 rounded-md text-[10px] font-medium transition-all flex items-center"
|
||||
:class="viewMode === 'gallery' ? 'bg-violet-600 text-white shadow-lg' : 'text-slate-400 hover:text-slate-200'">
|
||||
<i class="pi pi-th-large mr-1"></i> Gallery
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button icon="pi pi-trash" text rounded severity="danger" v-tooltip.bottom="'Delete Idea'"
|
||||
<Button v-if="currentInspiration" icon="pi pi-lightbulb" text rounded size="small"
|
||||
class="!w-7 !h-7 !text-yellow-400 hover:!bg-yellow-400/10"
|
||||
v-tooltip.bottom="'View Inspiration'"
|
||||
@click="openInspirationDialog" />
|
||||
|
||||
<Button :icon="onlyLiked ? 'pi pi-heart-fill' : 'pi pi-heart'"
|
||||
@click="onlyLiked = !onlyLiked" rounded text
|
||||
class="!w-7 !h-7 !p-0"
|
||||
:class="onlyLiked ? '!text-pink-500 !bg-pink-500/10' : '!text-slate-400 hover:!bg-white/10'"
|
||||
v-tooltip.bottom="onlyLiked ? 'Show all' : 'Show liked only'" />
|
||||
|
||||
<Button icon="pi pi-trash" text rounded severity="danger" size="small"
|
||||
class="!w-7 !h-7"
|
||||
v-tooltip.bottom="'Delete Idea'"
|
||||
@click="deleteIdea" />
|
||||
</div>
|
||||
</header>
|
||||
@@ -882,6 +1083,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)" />
|
||||
@@ -889,6 +1094,11 @@ watch(viewMode, (v) => {
|
||||
text rounded size="small"
|
||||
class="!w-7 !h-7 !text-slate-400 hover:!text-violet-400"
|
||||
v-tooltip.top="'Reuse Assets'" @click="reuseAssets(gen)" />
|
||||
<Button :icon="(gen.is_nsfw || gen.nsfw) ? 'pi pi-eye' : 'pi pi-eye-slash'"
|
||||
text rounded size="small"
|
||||
class="!w-7 !h-7 !text-slate-400 hover:!text-red-400"
|
||||
v-tooltip.top="(gen.is_nsfw || gen.nsfw) ? 'Unmark NSFW' : 'Mark NSFW'"
|
||||
@click="markNsfw(gen)" />
|
||||
<Button icon="pi pi-trash" text rounded size="small"
|
||||
class="!w-7 !h-7 !text-slate-400 hover:!text-red-400"
|
||||
v-tooltip.top="'Delete'" @click="deleteGeneration(gen)" />
|
||||
@@ -904,19 +1114,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 => API_URL + '/assets/' + r), 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>
|
||||
@@ -983,10 +1206,16 @@ watch(viewMode, (v) => {
|
||||
<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)">
|
||||
@click="isSelectMode ? toggleImageSelection(img.assetId) : openImagePreview(allGalleryImages, idx)">
|
||||
|
||||
<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"
|
||||
@@ -999,11 +1228,21 @@ 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)" />
|
||||
<Button icon="pi pi-refresh" rounded text size="small"
|
||||
class="!text-white hover:!bg-white/20" @click.stop="reusePrompt(img.gen)" />
|
||||
<Button :icon="(img.gen.is_nsfw || img.gen.nsfw) ? 'pi pi-eye' : 'pi pi-eye-slash'"
|
||||
rounded text size="small"
|
||||
class="!text-white hover:!bg-red-500/20 hover:!text-red-400"
|
||||
v-tooltip.top="(img.gen.is_nsfw || img.gen.nsfw) ? 'Unmark NSFW' : 'Mark NSFW'"
|
||||
@click.stop="markNsfw(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)" />
|
||||
@@ -1050,14 +1289,16 @@ watch(viewMode, (v) => {
|
||||
:disabled="!prompt || prompt.length <= 10"
|
||||
class="!py-0 !px-1.5 !text-[9px] !h-5 !bg-violet-600/20 hover:!bg-violet-600/30 !border-violet-500/30 !text-violet-400 disabled:opacity-50"
|
||||
@click="handleImprovePrompt" />
|
||||
<Button icon="pi pi-clipboard" label="Paste"
|
||||
class="!py-0 !px-1.5 !text-[9px] !h-5 !bg-slate-800 hover:!bg-slate-700 !border-white/10 !text-slate-400"
|
||||
@click="pastePrompt" />
|
||||
<Button icon="pi pi-times" label="Clear"
|
||||
class="!py-0 !px-1.5 !text-[9px] !h-5 !bg-slate-800 hover:!bg-slate-700 !border-white/10 !text-slate-400"
|
||||
@click="clearPrompt" />
|
||||
</div>
|
||||
</div>
|
||||
<Textarea v-model="prompt" rows="2" autoResize
|
||||
placeholder="Describe what you want to create..."
|
||||
class="w-full bg-slate-800 !text-sm border-white/10 text-white rounded-lg p-2 focus:border-violet-500 focus:ring-1 focus:ring-violet-500/50 transition-all resize-none shadow-inner" />
|
||||
<Textarea v-model="prompt" rows="2" placeholder="Describe what you want to create..."
|
||||
class="w-full !h-28 bg-slate-800 !text-[16px] border-white/10 text-white rounded-lg p-2 focus:border-violet-500 focus:ring-1 focus:ring-violet-500/50 transition-all resize-none shadow-inner" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-2">
|
||||
@@ -1093,7 +1334,8 @@ watch(viewMode, (v) => {
|
||||
</Dropdown>
|
||||
|
||||
<div v-if="selectedCharacter"
|
||||
class="flex items-center gap-2 mt-2 px-1 animate-in fade-in slide-in-from-top-1">
|
||||
class="flex flex-wrap items-center gap-x-4 gap-y-2 mt-2 px-1 animate-in fade-in slide-in-from-top-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox v-model="useProfileImage" :binary="true" inputId="idea-use-profile-img"
|
||||
class="!border-white/20" :pt="{
|
||||
box: ({ props, state }) => ({
|
||||
@@ -1101,9 +1343,19 @@ watch(viewMode, (v) => {
|
||||
})
|
||||
}" />
|
||||
<label for="idea-use-profile-img"
|
||||
class="text-xs text-slate-300 cursor-pointer select-none">Use
|
||||
Character
|
||||
Photo</label>
|
||||
class="text-xs text-slate-300 cursor-pointer select-none">Use Photo</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox v-model="useEnvironment" :binary="true" inputId="idea-use-env"
|
||||
class="!border-white/20" :pt="{
|
||||
box: ({ props, state }) => ({
|
||||
class: ['!bg-slate-800 !border-white/20', { '!bg-violet-600 !border-violet-600': props.modelValue }]
|
||||
})
|
||||
}" />
|
||||
<label for="idea-use-env"
|
||||
class="text-xs text-slate-300 cursor-pointer select-none">Use Environment</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1128,23 +1380,83 @@ watch(viewMode, (v) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Environment Row (Below) -->
|
||||
<div v-if="selectedCharacter && useEnvironment" class="flex-1 flex flex-col gap-1 animate-in fade-in slide-in-from-top-1 mt-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Environment</label>
|
||||
<Button v-if="selectedEnvironment" icon="pi pi-times" @click="selectedEnvironment = null" text size="small"
|
||||
class="!p-0 !h-4 !w-4 !text-[8px] text-slate-500 hover:text-white" />
|
||||
</div>
|
||||
|
||||
<div v-if="environments.length > 0" class="flex gap-2 overflow-x-auto pb-1 custom-scrollbar no-scrollbar">
|
||||
<div v-for="env in environments" :key="env.id || env._id"
|
||||
@click="selectedEnvironment = env"
|
||||
class="flex-shrink-0 flex items-center gap-2 px-2 py-1.5 rounded-lg border-2 transition-all cursor-pointer group bg-slate-800/40"
|
||||
:class="[
|
||||
(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id))
|
||||
? 'border-violet-500 bg-violet-500/10 shadow-[0_0_15px_rgba(124,58,237,0.1)]'
|
||||
: 'border-white/5 hover:border-white/20'
|
||||
]"
|
||||
>
|
||||
<div class="w-6 h-6 rounded overflow-hidden flex-shrink-0 bg-slate-900 flex items-center justify-center border border-white/5">
|
||||
<img v-if="env.asset_ids?.length > 0"
|
||||
:src="API_URL + '/assets/' + env.asset_ids[0] + '?thumbnail=true'"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<i v-else class="pi pi-map-marker text-[10px]"
|
||||
:class="(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id)) ? 'text-violet-400' : 'text-slate-500'"
|
||||
></i>
|
||||
</div>
|
||||
<span class="text-[10px] whitespace-nowrap pr-1"
|
||||
:class="(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id)) ? 'text-violet-300 font-bold' : 'text-slate-400 group-hover:text-slate-200'"
|
||||
>
|
||||
{{ env.name }}
|
||||
</span>
|
||||
<i v-if="(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id))"
|
||||
class="pi pi-check text-violet-400 text-[8px]"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="py-2 px-3 bg-slate-800/50 border border-white/5 rounded-xl text-center">
|
||||
<p class="text-[9px] text-slate-600 uppercase m-0">No environments</p>
|
||||
</div>
|
||||
</div> </div>
|
||||
|
||||
<div class="w-full lg:w-72 flex flex-col gap-2">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Model</label>
|
||||
<div class="flex bg-slate-800 p-1 rounded-xl border border-white/10">
|
||||
<div v-for="option in modelOptions" :key="option.key" @click="model = option"
|
||||
class="flex-1 text-center py-1 text-[10px] font-medium transition-all cursor-pointer rounded-md"
|
||||
:class="model.key === option.key ? 'bg-white/10 text-white shadow-sm' : 'text-slate-500 hover:text-slate-300'">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label
|
||||
class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Quality</label>
|
||||
<Dropdown v-model="quality" :options="qualityOptions" optionLabel="value"
|
||||
class="w-full !bg-slate-800 !border-white/10 !text-white !rounded-xl"
|
||||
:pt="{ input: { class: '!text-white' }, trigger: { class: '!text-slate-400' }, panel: { class: '!bg-slate-800 !border-white/10' }, item: { class: '!text-slate-300 hover:!bg-white/10 hover:!text-white' } }" />
|
||||
class="w-full !bg-slate-800 !border-white/10 !text-white !rounded-xl !h-[34px]"
|
||||
:pt="{ input: { class: '!text-white !text-[10px] !py-1 !px-2' }, trigger: { class: '!text-slate-400 !w-6' }, panel: { class: '!bg-slate-800 !border-white/10' }, item: { class: '!text-slate-300 hover:!bg-white/10 hover:!text-white !text-[10px] !py-1' } }" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label
|
||||
class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Ratio</label>
|
||||
<Dropdown v-model="aspectRatio" :options="aspectRatioOptions" optionLabel="value"
|
||||
class="w-full !bg-slate-800 !border-white/10 !text-white !rounded-xl"
|
||||
:pt="{ input: { class: '!text-white' }, trigger: { class: '!text-slate-400' }, panel: { class: '!bg-slate-800 !border-white/10' }, item: { class: '!text-slate-300 hover:!bg-white/10 hover:!text-white' } }" />
|
||||
class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Format</label>
|
||||
<div class="flex bg-slate-800 rounded-xl border border-white/10 overflow-hidden h-[34px]">
|
||||
<button @click="aspectRatio = 'THREEFOUR'"
|
||||
class="flex-1 text-[10px] font-bold transition-all flex items-center justify-center"
|
||||
:class="aspectRatio === 'THREEFOUR' ? 'bg-violet-600 text-white' : 'text-slate-400 hover:text-white hover:bg-white/5'">
|
||||
<i class="pi pi-image"></i>
|
||||
</button>
|
||||
<div class="w-px bg-white/10"></div>
|
||||
<button @click="aspectRatio = 'NINESIXTEEN'"
|
||||
class="flex-1 text-[10px] font-bold transition-all flex items-center justify-center"
|
||||
:class="aspectRatio === 'NINESIXTEEN' ? 'bg-violet-600 text-white' : 'text-slate-400 hover:text-white hover:bg-white/5'">
|
||||
<i class="pi pi-video"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1162,15 +1474,11 @@ watch(viewMode, (v) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NSFW Toggle -->
|
||||
<div class="flex flex-col gap-1 bg-slate-800/50 p-2 rounded-lg border border-white/5">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox v-model="sendToTelegram" :binary="true" inputId="idea-tg-check" />
|
||||
<label for="idea-tg-check" class="text-xs text-slate-300 cursor-pointer">Send to
|
||||
Telegram</label>
|
||||
</div>
|
||||
<div v-if="sendToTelegram" class="animate-in fade-in slide-in-from-top-1">
|
||||
<InputText v-model="telegramId" placeholder="Telegram ID"
|
||||
class="w-full !text-xs !bg-slate-900 !border-white/10 !text-white !py-1.5" />
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs text-slate-300 cursor-pointer">Show NSFW</label>
|
||||
<InputSwitch v-model="showNsfwGlobal" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1252,45 +1560,16 @@ watch(viewMode, (v) => {
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Preview Modal with Slider -->
|
||||
<Dialog v-model:visible="isImagePreviewVisible" modal dismissableMask
|
||||
:style="{ width: '90vw', maxWidth: '1200px', background: 'transparent', boxShadow: 'none' }"
|
||||
:pt="{ root: { class: '!bg-transparent !border-none !shadow-none' }, header: { class: '!hidden' }, content: { class: '!bg-transparent !p-0' } }"
|
||||
@keydown.left.prevent="prevPreview" @keydown.right.prevent="nextPreview">
|
||||
<div class="relative flex items-center justify-center h-[90vh]" @click="isImagePreviewVisible = false">
|
||||
<!-- Main image -->
|
||||
<img v-if="previewImages.length > 0" :src="previewImages[previewIndex]"
|
||||
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>
|
||||
<GenerationPreviewModal
|
||||
v-model:visible="isImagePreviewVisible"
|
||||
:preview-images="previewImages"
|
||||
:initial-index="previewIndex"
|
||||
:api-url="dataService.API_URL || API_URL"
|
||||
@reuse-prompt="reusePrompt"
|
||||
@reuse-asset="reuseAssets"
|
||||
@use-result-as-asset="useResultAsAsset"
|
||||
@liked="handleLiked"
|
||||
/>
|
||||
|
||||
<!-- Add to Content Plan Dialog -->
|
||||
<Dialog v-model:visible="showAddToPlanDialog" header="Add to content plan" modal :style="{ width: '420px' }"
|
||||
@@ -1321,6 +1600,44 @@ watch(viewMode, (v) => {
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<!-- Inspiration Dialog -->
|
||||
<Dialog v-model:visible="showInspirationDialog" modal header="Inspiration"
|
||||
:style="{ width: '90vw', maxWidth: '1000px', height: '90vh' }"
|
||||
:pt="{ root: { class: '!bg-slate-900 !border !border-white/10 flex flex-col' }, header: { class: '!bg-slate-900 !border-b !border-white/5 !text-white flex-shrink-0' }, content: { class: '!bg-slate-900 !p-4 flex-1 overflow-hidden flex flex-col' } }">
|
||||
<div v-if="currentInspiration" class="flex flex-col gap-4 h-full">
|
||||
<!-- Asset Viewer -->
|
||||
<div v-if="currentInspiration.asset_id" class="flex-1 min-h-0 rounded-xl overflow-hidden border border-white/10 bg-black/20 flex items-center justify-center">
|
||||
<video v-if="inspirationContentType && inspirationContentType.startsWith('video/')"
|
||||
:src="API_URL + '/assets/' + currentInspiration.asset_id"
|
||||
controls autoplay loop muted
|
||||
class="max-w-full max-h-full object-contain" />
|
||||
<img v-else
|
||||
:src="API_URL + '/assets/' + currentInspiration.asset_id"
|
||||
class="max-w-full max-h-full object-contain" />
|
||||
</div>
|
||||
|
||||
<!-- Caption -->
|
||||
<div v-if="currentInspiration.caption" class="flex flex-col gap-2 flex-shrink-0">
|
||||
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Caption</label>
|
||||
<div class="max-h-[150px] overflow-y-auto custom-scrollbar bg-slate-800/50 p-3 rounded-lg border border-white/5">
|
||||
<p class="text-sm text-slate-200 whitespace-pre-wrap">{{ currentInspiration.caption }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end mt-2 flex-shrink-0 gap-2">
|
||||
<Button label="Close" text @click="showInspirationDialog = false" class="!text-slate-400 hover:!text-white" />
|
||||
<a v-if="currentInspiration.source_url" :href="currentInspiration.source_url" target="_blank" rel="noopener noreferrer">
|
||||
<Button label="Go to Source" icon="pi pi-external-link"
|
||||
class="!bg-violet-600 !border-none hover:!bg-violet-500" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex justify-center py-8">
|
||||
<ProgressSpinner />
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useIdeaStore } from '../stores/ideas'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { dataService } from '../services/dataService' // Added import
|
||||
import { dataService } from '../services/dataService'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import InputText from 'primevue/inputtext'
|
||||
@@ -15,40 +15,240 @@ import Checkbox from 'primevue/checkbox'
|
||||
|
||||
const router = useRouter()
|
||||
const ideaStore = useIdeaStore()
|
||||
const { ideas, loading } = storeToRefs(ideaStore)
|
||||
const { ideas, loading, inspirations } = storeToRefs(ideaStore)
|
||||
|
||||
const showCreateDialog = ref(false)
|
||||
const newIdea = ref({ name: '', description: '' })
|
||||
const submitting = ref(false)
|
||||
const API_URL = import.meta.env.VITE_API_URL
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api'
|
||||
const isSettingsVisible = ref(localStorage.getItem('ideas_view_settings_visible') !== 'false')
|
||||
|
||||
watch(isSettingsVisible, (val) => {
|
||||
localStorage.setItem('ideas_view_settings_visible', val)
|
||||
})
|
||||
|
||||
// --- Rename Idea ---
|
||||
const showRenameDialog = ref(false)
|
||||
const ideaToRename = ref(null)
|
||||
const newName = ref('')
|
||||
const renaming = ref(false)
|
||||
|
||||
const openRenameDialog = (idea) => {
|
||||
ideaToRename.value = idea
|
||||
newName.value = idea.name
|
||||
showRenameDialog.value = true
|
||||
}
|
||||
|
||||
const handleRename = async () => {
|
||||
if (!newName.value.trim() || newName.value === ideaToRename.value.name) {
|
||||
showRenameDialog.value = false
|
||||
return
|
||||
}
|
||||
|
||||
renaming.value = true
|
||||
try {
|
||||
await ideaStore.updateIdea(ideaToRename.value.id, {
|
||||
name: newName.value.trim()
|
||||
})
|
||||
showRenameDialog.value = false
|
||||
} finally {
|
||||
renaming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Inspirations Logic ---
|
||||
const showInspirationDialog = ref(false)
|
||||
const newInspirationLink = ref('')
|
||||
const creatingInspiration = ref(false)
|
||||
const downloadingInspirations = ref(new Set())
|
||||
|
||||
// Preview Logic
|
||||
const showPreviewDialog = ref(false)
|
||||
const previewInspiration = ref(null)
|
||||
const previewAsset = ref(null)
|
||||
const loadingPreview = ref(false)
|
||||
|
||||
const openPreview = async (insp) => {
|
||||
if (!insp || !insp.asset_id) return
|
||||
previewInspiration.value = insp
|
||||
showPreviewDialog.value = true
|
||||
loadingPreview.value = true
|
||||
previewAsset.value = null
|
||||
|
||||
try {
|
||||
// Use HEAD request to get content-type without downloading body
|
||||
previewAsset.value = await dataService.getAssetMetadata(insp.asset_id)
|
||||
} catch (e) {
|
||||
console.error('Failed to load asset metadata', e)
|
||||
// Fallback: assume image if metadata fails, or let user try
|
||||
previewAsset.value = { content_type: 'image/jpeg' }
|
||||
} finally {
|
||||
loadingPreview.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getIdeaInspiration = (idea) => {
|
||||
if (idea.inspiration) return idea.inspiration
|
||||
if (idea.inspiration_id) {
|
||||
return inspirations.value.find(i => i.id === idea.inspiration_id)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const handleCreateInspiration = async () => {
|
||||
if (!newInspirationLink.value) return
|
||||
creatingInspiration.value = true
|
||||
try {
|
||||
await ideaStore.createInspiration({ source_url: newInspirationLink.value })
|
||||
showInspirationDialog.value = false
|
||||
newInspirationLink.value = ''
|
||||
} finally {
|
||||
creatingInspiration.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteInspiration = async (id) => {
|
||||
if (confirm('Are you sure you want to delete this inspiration?')) {
|
||||
await ideaStore.deleteInspiration(id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCompleteInspiration = async (id) => {
|
||||
await ideaStore.completeInspiration(id)
|
||||
}
|
||||
|
||||
const handleDownloadInspiration = async (insp) => {
|
||||
if (!insp.asset_id) return
|
||||
|
||||
downloadingInspirations.value.add(insp.id)
|
||||
try {
|
||||
// Use dataService which uses axios with correct headers (Auth, X-Project-ID)
|
||||
// Now returns full response object
|
||||
const response = await dataService.downloadAsset(insp.asset_id)
|
||||
const blob = response.data
|
||||
|
||||
// Determine extension
|
||||
let ext = 'jpg'
|
||||
if (blob.type === 'video/mp4') ext = 'mp4'
|
||||
else if (blob.type === 'image/png') ext = 'png'
|
||||
else if (blob.type === 'image/jpeg') ext = 'jpg'
|
||||
else if (blob.type === 'image/webp') ext = 'webp'
|
||||
|
||||
const fileName = `inspiration-${insp.id.substring(0, 8)}.${ext}`
|
||||
const file = new File([blob], fileName, { type: blob.type })
|
||||
|
||||
// Check if mobile device
|
||||
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
|
||||
|
||||
if (isMobile && navigator.canShare && navigator.canShare({ files: [file] })) {
|
||||
try {
|
||||
await navigator.share({
|
||||
files: [file],
|
||||
title: 'Inspiration',
|
||||
text: insp.caption || 'Check out this inspiration'
|
||||
})
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('Share failed', err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Desktop download (or fallback if share not supported)
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = fileName
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Download failed', e)
|
||||
// Try to extract a meaningful error message
|
||||
let msg = 'Failed to download asset'
|
||||
if (e.response) {
|
||||
msg += `: ${e.response.status} ${e.response.statusText}`
|
||||
} else if (e.message) {
|
||||
msg += `: ${e.message}`
|
||||
}
|
||||
alert(msg)
|
||||
} finally {
|
||||
downloadingInspirations.value.delete(insp.id)
|
||||
}
|
||||
}
|
||||
|
||||
const startIdeaFromInspiration = (inspiration) => {
|
||||
selectedInspiration.value = inspiration
|
||||
isSettingsVisible.value = true
|
||||
// Optionally scroll to settings or highlight it
|
||||
}
|
||||
|
||||
|
||||
// --- Generation Settings ---
|
||||
const prompt = ref('')
|
||||
const model = ref({ key: 'gemini-3-pro-image-preview', value: 'Pro' })
|
||||
const modelOptions = ref([
|
||||
{ key: 'gemini-3.1-flash-image-preview', value: '2' },
|
||||
{ key: 'gemini-3-pro-image-preview', value: 'Pro' }
|
||||
])
|
||||
const selectedModel = ref('flux-schnell')
|
||||
const quality = ref({ key: 'TWOK', value: '2K' })
|
||||
const aspectRatio = ref({ key: 'NINESIXTEEN', value: '9:16' })
|
||||
const imageCount = ref(1)
|
||||
const sendToTelegram = ref(false)
|
||||
const telegramId = ref('')
|
||||
const useProfileImage = ref(true)
|
||||
const useEnvironment = ref(false)
|
||||
const isSubmittingGen = ref(false)
|
||||
const selectedInspiration = ref(null) // New state for selected inspiration
|
||||
|
||||
// Character & Assets
|
||||
const characters = ref([])
|
||||
const selectedCharacter = ref(null)
|
||||
const environments = ref([])
|
||||
const selectedEnvironment = ref(null)
|
||||
const selectedAssets = ref([])
|
||||
let _savedCharacterId = null
|
||||
let _savedEnvironmentId = null
|
||||
|
||||
const loadEnvironments = async (charId) => {
|
||||
if (!charId) {
|
||||
environments.value = []
|
||||
selectedEnvironment.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await dataService.getEnvironments(charId)
|
||||
environments.value = Array.isArray(response) ? response : (response.environments || [])
|
||||
|
||||
if (_savedEnvironmentId) {
|
||||
selectedEnvironment.value = environments.value.find(e => (e.id === _savedEnvironmentId || e._id === _savedEnvironmentId)) || null
|
||||
_savedEnvironmentId = null
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load environments', e)
|
||||
environments.value = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedCharacter, (newChar) => {
|
||||
loadEnvironments(newChar?.id || newChar?._id)
|
||||
})
|
||||
|
||||
const qualityOptions = ref([
|
||||
{ key: 'ONEK', value: '1K' },
|
||||
{ key: 'TWOK', value: '2K' },
|
||||
{ key: 'FOURK', value: '4K' }
|
||||
])
|
||||
const aspectRatioOptions = ref([
|
||||
{ key: "NINESIXTEEN", value: "9:16" },
|
||||
{ key: "FOURTHREE", value: "4:3" },
|
||||
{ key: "ONEONE", value: "1:1" },
|
||||
{ key: "TWOTHREE", value: "2:3" },
|
||||
{ key: "THREETWO", value: "3:2" },
|
||||
{ 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
|
||||
@@ -58,15 +258,16 @@ const restoreSettings = () => {
|
||||
if (!stored) return
|
||||
try {
|
||||
const s = JSON.parse(stored)
|
||||
if (s.model) model.value = s.model
|
||||
if (s.prompt) prompt.value = s.prompt
|
||||
if (s.quality) quality.value = s.quality
|
||||
if (s.aspectRatio) aspectRatio.value = s.aspectRatio
|
||||
if (s.imageCount) imageCount.value = Math.min(s.imageCount, 4)
|
||||
if (s.selectedModel) selectedModel.value = s.selectedModel
|
||||
sendToTelegram.value = s.sendToTelegram || false
|
||||
telegramId.value = s.telegramId || localStorage.getItem('telegram_id') || ''
|
||||
if (s.useProfileImage !== undefined) useProfileImage.value = s.useProfileImage
|
||||
if (s.useEnvironment !== undefined) useEnvironment.value = s.useEnvironment
|
||||
_savedCharacterId = s.selectedCharacterId || null
|
||||
_savedEnvironmentId = s.selectedEnvironmentId || null
|
||||
if (s.selectedAssetIds && s.selectedAssetIds.length > 0) {
|
||||
selectedAssets.value = s.selectedAssetIds.map(id => ({
|
||||
id,
|
||||
@@ -74,6 +275,7 @@ const restoreSettings = () => {
|
||||
name: 'Asset ' + id.substring(0, 4)
|
||||
}))
|
||||
}
|
||||
// Note: We don't restore selectedInspiration to avoid stale state, or we could if needed.
|
||||
} catch (e) {
|
||||
console.error('Failed to restore settings', e)
|
||||
}
|
||||
@@ -82,26 +284,27 @@ const restoreSettings = () => {
|
||||
const saveSettings = () => {
|
||||
const settings = {
|
||||
prompt: prompt.value,
|
||||
model: model.value,
|
||||
quality: quality.value,
|
||||
aspectRatio: aspectRatio.value,
|
||||
imageCount: imageCount.value,
|
||||
selectedModel: selectedModel.value,
|
||||
sendToTelegram: sendToTelegram.value,
|
||||
telegramId: telegramId.value,
|
||||
useProfileImage: useProfileImage.value,
|
||||
useEnvironment: useEnvironment.value,
|
||||
selectedCharacterId: selectedCharacter.value?.id || null,
|
||||
selectedEnvironmentId: selectedEnvironment.value?.id || null,
|
||||
selectedAssetIds: selectedAssets.value.map(a => a.id),
|
||||
}
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings))
|
||||
if (telegramId.value) localStorage.setItem('telegram_id', telegramId.value)
|
||||
}
|
||||
|
||||
watch([prompt, quality, aspectRatio, imageCount, selectedModel, sendToTelegram, telegramId, useProfileImage, selectedCharacter, selectedAssets], saveSettings, { deep: true })
|
||||
watch([prompt, quality, aspectRatio, imageCount, model, useProfileImage, useEnvironment, selectedCharacter, selectedEnvironment, selectedAssets], saveSettings, { deep: true })
|
||||
|
||||
onMounted(async () => {
|
||||
restoreSettings()
|
||||
await Promise.all([
|
||||
ideaStore.fetchIdeas(),
|
||||
ideaStore.fetchInspirations(),
|
||||
loadCharacters()
|
||||
])
|
||||
})
|
||||
@@ -110,7 +313,7 @@ const loadCharacters = async () => {
|
||||
try {
|
||||
characters.value = await dataService.getCharacters()
|
||||
if (_savedCharacterId && characters.value.length > 0) {
|
||||
const found = characters.value.find(c => c.id === _savedCharacterId)
|
||||
const found = characters.value.find(c => (c.id === _savedCharacterId || c._id === _savedCharacterId))
|
||||
if (found) selectedCharacter.value = found
|
||||
_savedCharacterId = null
|
||||
}
|
||||
@@ -143,10 +346,17 @@ const handleGenerate = async () => {
|
||||
try {
|
||||
// 1. Create a random name idea
|
||||
const randomName = 'Session ' + new Date().toLocaleString()
|
||||
const newIdea = await ideaStore.createIdea({
|
||||
const ideaData = {
|
||||
name: randomName,
|
||||
description: 'Auto-generated from quick start'
|
||||
})
|
||||
}
|
||||
|
||||
// Pass inspiration ID if selected
|
||||
if (selectedInspiration.value) {
|
||||
ideaData.inspiration_id = selectedInspiration.value.id
|
||||
}
|
||||
|
||||
const newIdea = await ideaStore.createIdea(ideaData)
|
||||
|
||||
// 2. Save settings (already handled by watch, but ensure latest)
|
||||
saveSettings()
|
||||
@@ -154,6 +364,9 @@ const handleGenerate = async () => {
|
||||
// 3. Navigate with autostart param
|
||||
router.push({ path: `/ideas/${newIdea.id}`, query: { autostart: 'true' } })
|
||||
|
||||
// Clear inspiration after use
|
||||
selectedInspiration.value = null
|
||||
|
||||
} catch (e) {
|
||||
console.error('Failed to start session', e)
|
||||
} finally {
|
||||
@@ -161,6 +374,15 @@ const handleGenerate = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const pastePrompt = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText()
|
||||
if (text) prompt.value = text
|
||||
} catch (err) {
|
||||
console.error('Failed to read clipboard', err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Asset Picker Logic ---
|
||||
const isAssetPickerVisible = ref(false)
|
||||
const assetPickerTab = ref('all') // 'all', 'uploaded', 'generated'
|
||||
@@ -243,16 +465,18 @@ const handleAssetPickerUpload = async (event) => {
|
||||
<template>
|
||||
<div class="flex flex-col h-full font-sans relative">
|
||||
<!-- Content Area (Scrollable) -->
|
||||
<div class="flex-1 overflow-y-auto p-8 pb-48 custom-scrollbar">
|
||||
<div class="flex-1 overflow-y-auto p-4 md:p-6 pb-48 custom-scrollbar">
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-6">
|
||||
<!-- LEFT COLUMN: Ideas List -->
|
||||
<div class="flex-1 flex flex-col gap-4">
|
||||
<!-- Top Bar -->
|
||||
<header class="flex justify-between items-end mb-8 border-b border-white/5 pb-6">
|
||||
<div>
|
||||
<h1
|
||||
class="text-4xl font-bold m-0 bg-gradient-to-r from-violet-400 to-fuchsia-400 bg-clip-text text-transparent">
|
||||
<header class="flex justify-between items-center gap-0 border-b border-white/5 pb-2">
|
||||
<div class="flex flex-row items-center justify-between gap-2">
|
||||
<h1 class="text-lg font-bold !m-0 bg-gradient-to-r from-violet-400 to-fuchsia-400 bg-clip-text text-transparent">
|
||||
Ideas</h1>
|
||||
<p class="mt-2 mb-0 text-slate-400">Your creative sessions and experiments</p>
|
||||
<p class="mt-0.5 mb-0 text-[10px] text-slate-500">Your creative sessions</p>
|
||||
</div>
|
||||
<!-- REMOVED NEW IDEA BUTTON -->
|
||||
</header>
|
||||
|
||||
<!-- Loading State -->
|
||||
@@ -296,14 +520,27 @@ const handleAssetPickerUpload = async (event) => {
|
||||
|
||||
<!-- Details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3
|
||||
class="m-0 text-lg font-bold text-slate-200 group-hover:text-violet-300 transition-colors truncate">
|
||||
{{ idea.name }}</h3>
|
||||
<Button icon="pi pi-pencil" text rounded size="small"
|
||||
class="!w-6 !h-6 !text-slate-500 hover:!text-violet-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
@click.stop="openRenameDialog(idea)"
|
||||
/>
|
||||
</div>
|
||||
<p class="m-0 text-sm text-slate-500 truncate">{{ idea.description || 'No description' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Meta -->
|
||||
<div class="flex items-center gap-4 text-xs text-slate-500">
|
||||
<div v-if="getIdeaInspiration(idea)"
|
||||
class="flex items-center gap-1 bg-violet-500/20 border border-violet-500/30 px-2 py-1 rounded-md text-violet-300 hover:bg-violet-500/30 transition-colors cursor-pointer"
|
||||
@click.stop="openPreview(getIdeaInspiration(idea))">
|
||||
<i class="pi pi-bolt text-[10px]"></i>
|
||||
<span class="hidden sm:inline">Inspiration</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 bg-slate-900/50 px-2 py-1 rounded-md">
|
||||
<i class="pi pi-images text-[10px]"></i>
|
||||
<span>{{ idea.generation_ids?.length || 0 }}</span>
|
||||
@@ -315,13 +552,146 @@ const handleAssetPickerUpload = async (event) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Idea Dialog (Removed) -->
|
||||
<!-- RIGHT COLUMN: Inspirations -->
|
||||
<div class="w-full lg:w-80 flex flex-col gap-4">
|
||||
<header class="flex justify-between items-center gap-0 border-b border-white/5 pb-2">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h2 class="text-lg font-bold !m-0 text-slate-200">Inspirations</h2>
|
||||
</div>
|
||||
<Button icon="pi pi-plus" size="small" rounded text
|
||||
class="!w-8 !h-8 !text-violet-400 hover:!bg-violet-500/10"
|
||||
@click="showInspirationDialog = true" />
|
||||
</header>
|
||||
|
||||
<div v-if="inspirations.length === 0" class="flex flex-col items-center justify-center py-10 text-slate-500 bg-slate-900/20 rounded-xl border border-dashed border-white/5">
|
||||
<i class="pi pi-bolt text-2xl mb-2 opacity-50"></i>
|
||||
<p class="text-xs">No inspirations yet</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<div v-for="insp in inspirations" :key="insp.id"
|
||||
class="glass-panel rounded-xl p-3 flex flex-col gap-2 border border-white/5 hover:border-white/10 transition-all relative group"
|
||||
:class="{'opacity-50': insp.is_completed}">
|
||||
|
||||
<!-- Content Preview (Text Only) -->
|
||||
<div class="text-xs text-slate-300 break-all line-clamp-3 mb-1">
|
||||
{{ insp.caption || insp.source_url }}
|
||||
</div>
|
||||
|
||||
<!-- Source Link -->
|
||||
<div v-if="insp.source_url" class="flex items-center gap-1 mb-1">
|
||||
<a :href="insp.source_url" target="_blank" class="text-[10px] text-violet-400 hover:underline truncate max-w-full flex items-center gap-1">
|
||||
<i class="pi pi-external-link text-[8px]"></i>
|
||||
Source
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-1 mt-1 border-t border-white/5 pt-2">
|
||||
<Button v-if="insp.asset_id"
|
||||
icon="pi pi-eye"
|
||||
text rounded size="small"
|
||||
class="!w-7 !h-7 !text-slate-500 hover:!text-violet-400"
|
||||
@click="openPreview(insp)"
|
||||
v-tooltip="'View Content'" />
|
||||
|
||||
<Button v-if="insp.asset_id"
|
||||
:icon="downloadingInspirations.has(insp.id) ? 'pi pi-spin pi-spinner' : 'pi pi-download'"
|
||||
text rounded size="small"
|
||||
class="!w-7 !h-7 !text-slate-500 hover:!text-blue-400"
|
||||
@click="handleDownloadInspiration(insp)"
|
||||
v-tooltip="'Download'" />
|
||||
|
||||
<Button icon="pi pi-trash" text rounded size="small"
|
||||
class="!w-7 !h-7 !text-slate-500 hover:!text-red-400"
|
||||
@click="handleDeleteInspiration(insp.id)"
|
||||
v-tooltip="'Delete'" />
|
||||
|
||||
<Button v-if="!insp.is_completed" icon="pi pi-check" text rounded size="small"
|
||||
class="!w-7 !h-7 !text-slate-500 hover:!text-green-400"
|
||||
@click="handleCompleteInspiration(insp.id)"
|
||||
v-tooltip="'Mark Complete'" />
|
||||
|
||||
<Button v-if="!insp.is_completed" icon="pi pi-sparkles" text rounded size="small"
|
||||
class="!w-7 !h-7 !text-violet-400 hover:!bg-violet-500/20"
|
||||
@click="startIdeaFromInspiration(insp)"
|
||||
v-tooltip="'Create Idea'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rename Dialog -->
|
||||
<Dialog v-model:visible="showRenameDialog" header="Rename Idea" modal :style="{ width: '400px' }"
|
||||
:pt="{ root: { class: '!bg-slate-800 !border-white/10' }, header: { class: '!bg-slate-800' }, content: { class: '!bg-slate-800' } }">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">New Name</label>
|
||||
<InputText v-model="newName" class="w-full !bg-slate-700 !border-white/10 !text-white" autofocus @keyup.enter="handleRename" />
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 mt-2">
|
||||
<Button label="Cancel" text @click="showRenameDialog = false" class="!text-slate-400 hover:!text-white" />
|
||||
<Button label="Save" :loading="renaming" @click="handleRename" class="!bg-violet-600 !border-none hover:!bg-violet-500" />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<!-- Create Inspiration Dialog -->
|
||||
<Dialog v-model:visible="showInspirationDialog" header="Add Inspiration" modal :style="{ width: '400px' }"
|
||||
:pt="{ root: { class: '!bg-slate-800 !border-white/10' }, header: { class: '!bg-slate-800' }, content: { class: '!bg-slate-800' } }">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Instagram Link / Content</label>
|
||||
<InputText v-model="newInspirationLink" placeholder="https://instagram.com/..." class="w-full !bg-slate-700 !border-white/10 !text-white" autofocus @keyup.enter="handleCreateInspiration" />
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 mt-2">
|
||||
<Button label="Cancel" text @click="showInspirationDialog = false" class="!text-slate-400 hover:!text-white" />
|
||||
<Button label="Add" :loading="creatingInspiration" @click="handleCreateInspiration" class="!bg-violet-600 !border-none hover:!bg-violet-500" />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<!-- Preview Dialog -->
|
||||
<Dialog v-model:visible="showPreviewDialog" modal :header="previewInspiration?.caption || 'Preview'"
|
||||
:style="{ width: '90vw', maxWidth: '800px' }"
|
||||
:pt="{ root: { class: '!bg-slate-900 !border !border-white/10' }, header: { class: '!bg-slate-900 !border-b !border-white/5 !text-white' }, content: { class: '!bg-slate-900 !p-0' }, closeButton: { class: '!text-slate-400 hover:!text-white' } }">
|
||||
<div class="flex flex-col items-center justify-center p-4 min-h-[300px]">
|
||||
<div v-if="loadingPreview" class="flex flex-col items-center gap-2">
|
||||
<i class="pi pi-spin pi-spinner text-violet-500 text-2xl"></i>
|
||||
<span class="text-slate-400 text-sm">Loading content...</span>
|
||||
</div>
|
||||
<div v-else-if="previewAsset" class="w-full h-full flex items-center justify-center">
|
||||
<video v-if="previewAsset.content_type?.startsWith('video/')"
|
||||
:src="API_URL + '/assets/' + previewInspiration.asset_id"
|
||||
controls autoplay class="max-w-full max-h-[70vh] rounded-lg shadow-2xl">
|
||||
</video>
|
||||
<img v-else
|
||||
:src="API_URL + '/assets/' + previewInspiration.asset_id"
|
||||
class="max-w-full max-h-[70vh] rounded-lg shadow-2xl object-contain" />
|
||||
</div>
|
||||
<div v-else class="text-slate-500">
|
||||
Failed to load content
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<!-- SETTINGS PANEL (Bottom - Persistent) -->
|
||||
<div
|
||||
<div v-if="isSettingsVisible"
|
||||
class="absolute bottom-4 left-1/2 -translate-x-1/2 w-[95%] max-w-4xl glass-panel border border-white/10 bg-slate-900/95 backdrop-blur-xl px-4 py-3 z-[60] !rounded-[2rem] shadow-2xl flex flex-col gap-1 max-h-[60vh] overflow-y-auto">
|
||||
|
||||
<div class="w-full flex justify-center -mt-1 mb-1 cursor-pointer" @click="isSettingsVisible = false">
|
||||
<div class="w-12 h-1 bg-white/20 rounded-full hover:bg-white/40 transition-colors"></div>
|
||||
</div>
|
||||
|
||||
<!-- Selected Inspiration Indicator -->
|
||||
<div v-if="selectedInspiration" class="flex items-center gap-2 bg-violet-500/20 border border-violet-500/30 rounded-lg px-3 py-2 mb-2 animate-in fade-in slide-in-from-bottom-2">
|
||||
<i class="pi pi-bolt text-violet-400 text-sm"></i>
|
||||
<span class="text-xs text-violet-200 truncate flex-1">Using inspiration: {{ selectedInspiration.caption || selectedInspiration.source_url }}</span>
|
||||
<Button icon="pi pi-times" text rounded size="small" class="!w-5 !h-5 !text-violet-300 hover:!text-white" @click="selectedInspiration = null" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-3">
|
||||
<!-- LEFT COLUMN: Prompt + Character + Assets -->
|
||||
<div class="flex-1 flex flex-col gap-2">
|
||||
@@ -329,18 +699,24 @@ const handleAssetPickerUpload = async (event) => {
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex justify-between items-center">
|
||||
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Prompt</label>
|
||||
<!-- Optional: Add Clear/Improve buttons here if desired, keeping simple for now to match layout -->
|
||||
<div class="flex gap-1">
|
||||
<Button icon="pi pi-clipboard" label="Paste" size="small" text
|
||||
class="!text-slate-400 hover:!text-white hover:!bg-white/10 !py-0 !px-1.5 !text-[9px] !h-5"
|
||||
@click="pastePrompt" />
|
||||
<Button icon="pi pi-times" label="Clear" size="small" text
|
||||
class="!text-slate-400 hover:!text-white hover:!bg-white/10 !py-0 !px-1.5 !text-[9px] !h-5"
|
||||
@click="prompt = ''" />
|
||||
</div>
|
||||
<Textarea v-model="prompt" rows="2" autoResize
|
||||
</div>
|
||||
<Textarea v-model="prompt" rows="2"
|
||||
placeholder="Describe what you want to create... (Auto-starts new session)"
|
||||
class="w-full bg-slate-800 !text-sm border-white/10 text-white rounded-lg p-2 focus:border-violet-500 focus:ring-1 focus:ring-violet-500/50 transition-all resize-none shadow-inner" />
|
||||
class="w-full !h-28 bg-slate-800 !text-[16px] border-white/10 text-white rounded-lg p-2 focus:border-violet-500 focus:ring-1 focus:ring-violet-500/50 transition-all resize-none shadow-inner" />
|
||||
</div>
|
||||
|
||||
<!-- Character & Assets Row -->
|
||||
<div class="flex flex-col md:flex-row gap-2">
|
||||
<div class="flex-1 flex flex-col gap-1">
|
||||
<label
|
||||
class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Character</label>
|
||||
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Character</label>
|
||||
<Dropdown v-model="selectedCharacter" :options="characters" optionLabel="name"
|
||||
placeholder="Select Character" filter showClear
|
||||
class="w-full !bg-slate-800 !border-white/10 !text-white !rounded-xl" :pt="{
|
||||
@@ -370,7 +746,8 @@ const handleAssetPickerUpload = async (event) => {
|
||||
</Dropdown>
|
||||
|
||||
<div v-if="selectedCharacter"
|
||||
class="flex items-center gap-2 mt-2 px-1 animate-in fade-in slide-in-from-top-1">
|
||||
class="flex flex-wrap items-center gap-x-4 gap-y-2 mt-2 px-1 animate-in fade-in slide-in-from-top-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox v-model="useProfileImage" :binary="true" inputId="idea-use-profile-img"
|
||||
class="!border-white/20" :pt="{
|
||||
box: ({ props, state }) => ({
|
||||
@@ -378,9 +755,19 @@ const handleAssetPickerUpload = async (event) => {
|
||||
})
|
||||
}" />
|
||||
<label for="idea-use-profile-img"
|
||||
class="text-xs text-slate-300 cursor-pointer select-none">Use
|
||||
Character
|
||||
Photo</label>
|
||||
class="text-xs text-slate-300 cursor-pointer select-none">Use Photo</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox v-model="useEnvironment" :binary="true" inputId="idea-use-env"
|
||||
class="!border-white/20" :pt="{
|
||||
box: ({ props, state }) => ({
|
||||
class: ['!bg-slate-800 !border-white/20', { '!bg-violet-600 !border-violet-600': props.modelValue }]
|
||||
})
|
||||
}" />
|
||||
<label for="idea-use-env"
|
||||
class="text-xs text-slate-300 cursor-pointer select-none">Use Environment</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -403,22 +790,80 @@ const handleAssetPickerUpload = async (event) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Environment Row (Below) -->
|
||||
<div v-if="selectedCharacter && useEnvironment" class="flex flex-col gap-1 animate-in fade-in slide-in-from-top-1 mt-2"> <div class="flex justify-between items-center">
|
||||
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Environment</label>
|
||||
<Button v-if="selectedEnvironment" icon="pi pi-times" @click="selectedEnvironment = null" text size="small"
|
||||
class="!p-0 !h-4 !w-4 !text-[8px] text-slate-500 hover:text-white" />
|
||||
</div>
|
||||
|
||||
<div v-if="environments.length > 0" class="flex gap-2 overflow-x-auto pb-1 custom-scrollbar no-scrollbar">
|
||||
<div v-for="env in environments" :key="env.id || env._id"
|
||||
@click="selectedEnvironment = env"
|
||||
class="flex-shrink-0 flex items-center gap-2 px-2 py-1.5 rounded-lg border-2 transition-all cursor-pointer group bg-slate-800/40"
|
||||
:class="[
|
||||
(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id))
|
||||
? 'border-violet-500 bg-violet-500/10 shadow-[0_0_15px_rgba(124,58,237,0.1)]'
|
||||
: 'border-white/5 hover:border-white/20'
|
||||
]"
|
||||
>
|
||||
<div class="w-6 h-6 rounded overflow-hidden flex-shrink-0 bg-slate-900 flex items-center justify-center border border-white/5">
|
||||
<img v-if="env.asset_ids?.length > 0"
|
||||
:src="API_URL + '/assets/' + env.asset_ids[0] + '?thumbnail=true'"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<i v-else class="pi pi-map-marker text-[10px]"
|
||||
:class="(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id)) ? 'text-violet-400' : 'text-slate-500'"
|
||||
></i>
|
||||
</div>
|
||||
<span class="text-[10px] whitespace-nowrap pr-1"
|
||||
:class="(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id)) ? 'text-violet-300 font-bold' : 'text-slate-400 group-hover:text-slate-200'"
|
||||
>
|
||||
{{ env.name }}
|
||||
</span>
|
||||
<i v-if="(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id))"
|
||||
class="pi pi-check text-violet-400 text-[8px]"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="py-2 px-3 bg-slate-800/50 border border-white/5 rounded-xl text-center">
|
||||
<p class="text-[9px] text-slate-600 uppercase m-0">No environments</p>
|
||||
</div>
|
||||
</div> </div>
|
||||
|
||||
<!-- RIGHT COLUMN: Settings & Button -->
|
||||
<div class="w-full lg:w-72 flex flex-col gap-2">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Model</label>
|
||||
<div class="flex bg-slate-800 p-1 rounded-xl border border-white/10">
|
||||
<div v-for="option in modelOptions" :key="option.key" @click="model = option"
|
||||
class="flex-1 text-center py-1 text-[10px] font-medium transition-all cursor-pointer rounded-md"
|
||||
:class="model.key === option.key ? 'bg-white/10 text-white shadow-sm' : 'text-slate-500 hover:text-slate-300'">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Quality</label>
|
||||
<Dropdown v-model="quality" :options="qualityOptions" optionLabel="value"
|
||||
class="w-full !bg-slate-800 !border-white/10 !text-white !rounded-xl"
|
||||
:pt="{ input: { class: '!text-white' }, trigger: { class: '!text-slate-400' }, panel: { class: '!bg-slate-800 !border-white/10' }, item: { class: '!text-slate-300 hover:!bg-white/10 hover:!text-white' } }" />
|
||||
<div class="flex bg-slate-800 p-1 rounded-xl border border-white/10 h-[34px]">
|
||||
<div v-for="option in qualityOptions" :key="option.key" @click="quality = option"
|
||||
class="flex-1 flex items-center justify-center cursor-pointer rounded-md text-[10px] font-bold transition-all"
|
||||
:class="quality.key === option.key ? 'bg-white/10 text-white shadow-sm' : 'text-slate-500 hover:text-slate-300'">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Ratio</label>
|
||||
<Dropdown v-model="aspectRatio" :options="aspectRatioOptions" optionLabel="value"
|
||||
class="w-full !bg-slate-800 !border-white/10 !text-white !rounded-xl"
|
||||
:pt="{ input: { class: '!text-white' }, trigger: { class: '!text-slate-400' }, panel: { class: '!bg-slate-800 !border-white/10' }, item: { class: '!text-slate-300 hover:!bg-white/10 hover:!text-white' } }" />
|
||||
class="w-full !bg-slate-800 !border-white/10 !text-white !rounded-xl !h-[34px]"
|
||||
:pt="{
|
||||
input: { class: '!text-white !text-[10px] !py-1 !px-2 !font-bold' },
|
||||
trigger: { class: '!text-slate-400 !w-6' },
|
||||
panel: { class: '!bg-slate-800 !border-white/10' },
|
||||
item: { class: '!text-slate-300 hover:!bg-white/10 hover:!text-white !text-[10px] !py-1' }
|
||||
}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -436,19 +881,6 @@ const handleAssetPickerUpload = async (event) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Telegram (Copied from Detail View) -->
|
||||
<div class="flex flex-col gap-1 bg-slate-800/50 p-2 rounded-lg border border-white/5">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox v-model="sendToTelegram" :binary="true" inputId="idea-tg-check" />
|
||||
<label for="idea-tg-check" class="text-xs text-slate-300 cursor-pointer">Send to
|
||||
Telegram</label>
|
||||
</div>
|
||||
<div v-if="sendToTelegram" class="animate-in fade-in slide-in-from-top-1">
|
||||
<InputText v-model="telegramId" placeholder="Telegram ID"
|
||||
class="w-full !text-xs !bg-slate-900 !border-white/10 !text-white !py-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mt-auto">
|
||||
<Button :label="isSubmittingGen ? 'Starting...' : 'Generate New Session'"
|
||||
@@ -460,6 +892,13 @@ const handleAssetPickerUpload = async (event) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="!isSettingsVisible" class="absolute bottom-24 md:bottom-8 left-1/2 -translate-x-1/2 z-10">
|
||||
<Button label="Open Controls" icon="pi pi-chevron-up" @click="isSettingsVisible = true" rounded
|
||||
class="!bg-violet-600 !border-none !shadow-xl !font-bold shadow-violet-500/40 !px-6 !py-3" />
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<Dialog v-model:visible="isAssetPickerVisible" modal header="Select Assets"
|
||||
:style="{ width: '80vw', maxWidth: '900px' }"
|
||||
:pt="{ root: { class: '!bg-slate-900 !border !border-white/10' }, header: { class: '!bg-slate-900 !border-b !border-white/5 !text-white' }, content: { class: '!bg-slate-900 !p-0' }, footer: { class: '!bg-slate-900 !border-t !border-white/5 !p-4' }, closeButton: { class: '!text-slate-400 hover:!text-white' } }">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import {onMounted, ref} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
import {dataService} from '../services/dataService'
|
||||
import {aiService} from '../services/aiService'
|
||||
@@ -7,12 +7,13 @@ import Button from 'primevue/button'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import ProgressBar from 'primevue/progressbar'
|
||||
import Message from 'primevue/message'
|
||||
import Tag from 'primevue/tag'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Paginator from 'primevue/paginator'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Tag from 'primevue/tag'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import GenerationPreviewModal from '../components/GenerationPreviewModal.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const API_URL = import.meta.env.VITE_API_URL
|
||||
@@ -44,32 +45,33 @@ const assetsTotalRecords = ref(0)
|
||||
const assetsRows = ref(12)
|
||||
const assetsFirst = ref(0)
|
||||
const activeAssetFilter = ref('all')
|
||||
const sendToTelegram = ref(false)
|
||||
const telegramId = ref(localStorage.getItem('telegram_id') || '')
|
||||
const isTelegramIdSaved = ref(!!localStorage.getItem('telegram_id'))
|
||||
const isUploading = ref(false)
|
||||
const fileInput = ref(null)
|
||||
|
||||
const saveTelegramId = () => {
|
||||
if (telegramId.value) {
|
||||
localStorage.setItem('telegram_id', telegramId.value)
|
||||
isTelegramIdSaved.value = true
|
||||
}
|
||||
}
|
||||
const model = ref({ key: 'gemini-3-pro-image-preview', value: 'Pro' })
|
||||
const modelOptions = ref([
|
||||
{ key: 'gemini-3.1-flash-image-preview', value: '2' },
|
||||
{ key: 'gemini-3-pro-image-preview', value: 'Pro' }
|
||||
])
|
||||
|
||||
const quality = ref({ key: 'TWOK', value: '2K' })
|
||||
const qualityOptions = ref([
|
||||
{ key: 'ONEK', value: '1K' },
|
||||
{ key: 'TWOK', value: '2K' },
|
||||
{ key: 'FOURK', value: '4K' }
|
||||
])
|
||||
|
||||
const aspectRatio = ref({ key: "NINESIXTEEN", value: "9:16" })
|
||||
const aspectRatioOptions = ref([
|
||||
{ key: "NINESIXTEEN", value: "9:16" },
|
||||
{ key: "FOURTHREE", value: "4:3" },
|
||||
{ key: "ONEONE", value: "1:1" },
|
||||
{ key: "TWOTHREE", value: "2:3" },
|
||||
{ key: "THREETWO", value: "3:2" },
|
||||
{ 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 ---
|
||||
@@ -180,18 +182,6 @@ const onFileSelected = async (event) => {
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.value.trim()) return
|
||||
|
||||
// Validation for Telegram
|
||||
if (sendToTelegram.value && !telegramId.value) {
|
||||
alert("Please enter your Telegram ID")
|
||||
return
|
||||
}
|
||||
|
||||
// Save ID if provided
|
||||
if (telegramId.value && telegramId.value !== localStorage.getItem('telegram_id')) {
|
||||
localStorage.setItem('telegram_id', telegramId.value)
|
||||
isTelegramIdSaved.value = true
|
||||
}
|
||||
|
||||
isGenerating.value = true
|
||||
generationSuccess.value = false
|
||||
generationError.value = null
|
||||
@@ -201,12 +191,12 @@ const handleGenerate = async () => {
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
model: model.value.key,
|
||||
aspect_ratio: aspectRatio.value.key,
|
||||
quality: quality.value.key,
|
||||
prompt: prompt.value,
|
||||
assets_list: selectedAssets.value.map(a => a.id),
|
||||
linked_character_id: null, // Explicitly null for global generation
|
||||
telegram_id: sendToTelegram.value ? telegramId.value : null
|
||||
}
|
||||
|
||||
const response = await aiService.runGeneration(payload)
|
||||
@@ -288,6 +278,9 @@ const pollStatus = async (id) => {
|
||||
const restoreGeneration = async (gen) => {
|
||||
prompt.value = gen.prompt
|
||||
|
||||
const foundModel = modelOptions.value.find(opt => opt.key === gen.model)
|
||||
if (foundModel) model.value = foundModel
|
||||
|
||||
const foundQuality = qualityOptions.value.find(opt => opt.key === gen.quality)
|
||||
if (foundQuality) quality.value = foundQuality
|
||||
|
||||
@@ -295,23 +288,16 @@ const restoreGeneration = async (gen) => {
|
||||
if (foundAspect) aspectRatio.value = foundAspect
|
||||
|
||||
if (gen.status === 'done' && gen.result_list && gen.result_list.length > 0) {
|
||||
// We need to fetch details or just display the image
|
||||
// history list usually has the main image preview
|
||||
// Keep original gen object for preview and details
|
||||
generatedResult.value = {
|
||||
...gen,
|
||||
type: 'assets',
|
||||
// Mocking asset object structure from history usage in DetailView
|
||||
assets: gen.result_list.map(id => ({
|
||||
id,
|
||||
url: `/assets/${id}`, // This might need adjustment based on how API serves files
|
||||
// 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
|
||||
url: `/assets/${id}`,
|
||||
})),
|
||||
tech_prompt: gen.tech_prompt,
|
||||
execution_time: gen.execution_time_seconds,
|
||||
api_execution_time: gen.api_execution_time_seconds,
|
||||
token_usage: gen.token_usage
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -339,14 +325,32 @@ const handleImprovePrompt = async () => {
|
||||
const isImagePreviewVisible = ref(false)
|
||||
const previewImage = ref(null)
|
||||
|
||||
const openImagePreview = (url, name = 'Image Preview', createdAt = null) => {
|
||||
previewImage.value = { url, name, createdAt }
|
||||
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 formatDate = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
return new Date(dateString).toLocaleString()
|
||||
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 = () => {
|
||||
@@ -361,6 +365,15 @@ const clearPrompt = () => {
|
||||
prompt.value = ''
|
||||
}
|
||||
|
||||
const pastePrompt = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText()
|
||||
if (text) prompt.value = text
|
||||
} catch (err) {
|
||||
console.error('Failed to read clipboard', err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Reuse Logic ---
|
||||
|
||||
const reusePrompt = (gen) => {
|
||||
@@ -412,10 +425,6 @@ const useResultAsReference = (gen) => {
|
||||
|
||||
// --- Utils ---
|
||||
|
||||
const copyToClipboard = () => {
|
||||
// Implement if needed for prompt copying
|
||||
}
|
||||
|
||||
|
||||
|
||||
// --- Lifecycle ---
|
||||
@@ -439,13 +448,23 @@ onMounted(() => {
|
||||
<!-- Settings Card -->
|
||||
<div class="glass-panel p-6 rounded-2xl border border-white/5 bg-white/5 flex flex-col gap-6">
|
||||
|
||||
<!-- Quality & Aspect Ratio -->
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<!-- Settings Row: Model, Quality & Aspect Ratio -->
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-bold text-slate-400 uppercase tracking-wider">Model</label>
|
||||
<div class="flex bg-slate-900/50 p-1 rounded-lg border border-white/10 h-[34px]">
|
||||
<div v-for="option in modelOptions" :key="option.key" @click="model = option"
|
||||
class="flex-1 flex items-center justify-center cursor-pointer rounded-md text-[10px] font-bold transition-all"
|
||||
:class="model.key === option.key ? 'bg-white/10 text-white shadow-sm' : 'text-slate-500 hover:text-slate-300'">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-bold text-slate-400 uppercase tracking-wider">Quality</label>
|
||||
<div class="flex bg-slate-900/50 p-1 rounded-lg border border-white/10">
|
||||
<div class="flex bg-slate-900/50 p-1 rounded-lg border border-white/10 h-[34px]">
|
||||
<div v-for="option in qualityOptions" :key="option.key" @click="quality = option"
|
||||
class="flex-1 text-center py-1.5 cursor-pointer rounded-md text-xs font-medium transition-all"
|
||||
class="flex-1 flex items-center justify-center cursor-pointer rounded-md text-[10px] font-bold transition-all"
|
||||
:class="quality.key === option.key ? 'bg-white/10 text-white shadow-sm' : 'text-slate-500 hover:text-slate-300'">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
@@ -454,14 +473,14 @@ onMounted(() => {
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-bold text-slate-400 uppercase tracking-wider">Aspect
|
||||
Ratio</label>
|
||||
<div class="flex bg-slate-900/50 p-1 rounded-lg border border-white/10">
|
||||
<div v-for="option in aspectRatioOptions" :key="option.key"
|
||||
@click="aspectRatio = option"
|
||||
class="flex-1 text-center py-1.5 cursor-pointer rounded-md text-xs font-medium transition-all"
|
||||
:class="aspectRatio.key === option.key ? 'bg-white/10 text-white shadow-sm' : 'text-slate-500 hover:text-slate-300'">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
<Dropdown v-model="aspectRatio" :options="aspectRatioOptions" optionLabel="value"
|
||||
class="w-full !bg-slate-900/50 !border-white/10 !text-white !rounded-lg !h-[34px]"
|
||||
:pt="{
|
||||
input: { class: '!text-white !text-[10px] !py-1 !px-2 !font-bold' },
|
||||
trigger: { class: '!text-slate-400 !w-6' },
|
||||
panel: { class: '!bg-slate-900 !border-white/10' },
|
||||
item: { class: '!text-slate-300 hover:!bg-white/10 hover:!text-white !text-[10px] !py-1' }
|
||||
}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -478,6 +497,9 @@ onMounted(() => {
|
||||
:disabled="prompt.length <= 10" size="small"
|
||||
class="!py-0.5 !px-2 !text-[10px] bg-violet-600/20 hover:bg-violet-600/30 border-violet-500/30 text-violet-400 disabled:opacity-50"
|
||||
@click="handleImprovePrompt" />
|
||||
<Button icon="pi pi-clipboard" label="Paste" size="small"
|
||||
class="!py-0.5 !px-2 !text-[10px] bg-slate-800 hover:bg-slate-700 border-white/10 text-slate-400"
|
||||
@click="pastePrompt" />
|
||||
<Button icon="pi pi-times" label="Clear" size="small"
|
||||
class="!py-0.5 !px-2 !text-[10px] bg-slate-800 hover:bg-slate-700 border-white/10 text-slate-400"
|
||||
@click="clearPrompt" />
|
||||
@@ -526,21 +548,6 @@ onMounted(() => {
|
||||
|
||||
<!-- Generate Button -->
|
||||
<div class="mt-auto">
|
||||
<div class="flex flex-col gap-2 mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox v-model="sendToTelegram" :binary="true" inputId="tg-check-gen" />
|
||||
<label for="tg-check-gen"
|
||||
class="text-xs text-slate-400 cursor-pointer select-none">Send result to
|
||||
Telegram</label>
|
||||
</div>
|
||||
<div v-if="sendToTelegram && !isTelegramIdSaved"
|
||||
class="animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
<InputText v-model="telegramId" placeholder="Enter Telegram ID"
|
||||
class="w-full !text-xs !py-1.5" @blur="saveTelegramId" />
|
||||
<small class="text-[10px] text-slate-500 block mt-0.5">ID will be saved for future
|
||||
use</small>
|
||||
</div>
|
||||
</div>
|
||||
<Button :label="isGenerating ? 'Generating...' : 'Generate Image'"
|
||||
:icon="isGenerating ? 'pi pi-spin pi-spinner' : 'pi pi-magic'" :loading="isGenerating"
|
||||
@click="handleGenerate"
|
||||
@@ -594,7 +601,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())"
|
||||
@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>
|
||||
@@ -655,13 +662,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>
|
||||
@@ -747,18 +761,16 @@ onMounted(() => {
|
||||
</Dialog>
|
||||
|
||||
<!-- Image Preview Modal -->
|
||||
<Dialog v-model:visible="isImagePreviewVisible" modal dismissableMask
|
||||
:header="previewImage?.name || 'Image Preview'" :style="{ width: '90vw', maxWidth: '800px' }"
|
||||
class="glass-panel rounded-2xl">
|
||||
<div v-if="previewImage" class="flex flex-col items-center">
|
||||
<img :src="previewImage.url" :alt="previewImage.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">{{ previewImage.name }}</h2>
|
||||
<p v-if="previewImage.createdAt" class="text-slate-400">{{ formatDate(previewImage.createdAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
<GenerationPreviewModal
|
||||
v-model:visible="isImagePreviewVisible"
|
||||
:preview-images="[previewImage].filter(i => !!i)"
|
||||
:initial-index="0"
|
||||
:api-url="API_URL"
|
||||
@reuse-prompt="reusePrompt"
|
||||
@reuse-asset="reuseAsset"
|
||||
@use-result-as-asset="useResultAsReference"
|
||||
@liked="handleLiked"
|
||||
/>
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
@@ -55,6 +55,15 @@ const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(generatedPrompt.value)
|
||||
}
|
||||
|
||||
const pastePrompt = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText()
|
||||
if (text) userPrompt.value = text
|
||||
} catch (err) {
|
||||
console.error('Failed to read clipboard', err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
@@ -124,11 +133,14 @@ const copyToClipboard = () => {
|
||||
|
||||
<!-- Optional Prompt -->
|
||||
<div class="glass-panel p-1 rounded-2xl border border-white/5">
|
||||
<div class="p-4 border-b border-white/5">
|
||||
<div class="p-4 border-b border-white/5 flex justify-between items-center">
|
||||
<label class="text-sm font-bold text-slate-300 flex items-center gap-2">
|
||||
<i class="pi pi-align-left"></i> Additional Instructions <span
|
||||
class="text-slate-500 font-normal">(Optional)</span>
|
||||
</label>
|
||||
<Button icon="pi pi-clipboard" label="Paste" size="small" text
|
||||
class="!text-slate-400 hover:!text-white hover:!bg-white/10 !py-1"
|
||||
@click="pastePrompt" />
|
||||
</div>
|
||||
<textarea v-model="userPrompt"
|
||||
class="w-full bg-transparent border-none p-4 text-slate-100 focus:outline-none focus:ring-0 placeholder-slate-600 min-h-[100px] resize-none"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div class="container mx-auto p-4 animate-fade-in" v-if="project">
|
||||
<div class="h-full overflow-y-auto custom-scrollbar">
|
||||
<div v-if="project" class="container mx-auto p-4 md:p-8 animate-fade-in">
|
||||
<!-- Header -->
|
||||
<div class="glass-panel p-8 rounded-xl mb-8 relative overflow-hidden">
|
||||
<div class="absolute top-0 right-0 p-4 opacity-10">
|
||||
@@ -44,25 +45,24 @@
|
||||
<div v-if="isOwner" class="mb-6">
|
||||
<div class="flex gap-2">
|
||||
<InputText v-model="inviteUsername" placeholder="Username to add"
|
||||
class="w-full p-inputtext-sm" @keyup.enter="addMember" />
|
||||
class="w-full" @keyup.enter="addMember" />
|
||||
<Button label="Add" icon="pi pi-user-plus" size="small" @click="addMember"
|
||||
:loading="inviting" :disabled="!inviteUsername.trim()" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-for="memberId in project.members" :key="memberId"
|
||||
<div v-for="member in project.members" :key="member.id"
|
||||
class="flex items-center p-3 rounded-lg bg-slate-800/30 border border-slate-700/30">
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-white font-bold mr-3">
|
||||
<i class="pi pi-user"></i>
|
||||
</div>
|
||||
<div class="overflow-hidden">
|
||||
<p class="text-white font-medium truncate">{{ memberId === project.owner_id ? 'Owner' :
|
||||
'Member' }}</p>
|
||||
<p class="text-slate-500 text-xs truncate">ID: {{ memberId }}</p>
|
||||
<p class="text-white font-bold truncate">{{ member.username }}</p>
|
||||
<p class="text-slate-500 text-[10px] truncate font-mono">ID: {{ member.id }}</p>
|
||||
</div>
|
||||
<div class="ml-auto" v-if="project.owner_id === memberId">
|
||||
<div class="ml-auto" v-if="project.owner_id === member.id">
|
||||
<i class="pi pi-crown text-yellow-500" title="Owner"></i>
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,13 +70,67 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats/Activity Column (Placeholder) -->
|
||||
<!-- Stats/Activity Column -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="glass-panel p-6 rounded-xl h-full">
|
||||
<h2 class="text-xl font-bold text-white mb-6">Activity</h2>
|
||||
<div class="text-center py-12 text-slate-500">
|
||||
<div class="glass-panel p-6 rounded-xl h-full flex flex-col">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-bold text-white">Usage Statistics</h2>
|
||||
<Button icon="pi pi-refresh" text rounded @click="fetchUsage" :loading="loadingUsage" />
|
||||
</div>
|
||||
|
||||
<div v-if="usageReport" class="flex-1 flex flex-col gap-8">
|
||||
<!-- Summary Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="p-4 rounded-xl bg-slate-800/40 border border-white/5 text-center">
|
||||
<p class="text-slate-500 text-xs uppercase font-bold tracking-wider mb-1">Total Runs</p>
|
||||
<p class="text-2xl font-bold text-white">{{ usageReport.summary?.total_runs || 0 }}</p>
|
||||
</div>
|
||||
<div class="p-4 rounded-xl bg-slate-800/40 border border-white/5 text-center">
|
||||
<p class="text-slate-500 text-xs uppercase font-bold tracking-wider mb-1">Total Tokens</p>
|
||||
<p class="text-2xl font-bold text-white">{{ (usageReport.summary?.total_tokens || 0).toLocaleString() }}</p>
|
||||
</div>
|
||||
<div class="p-4 rounded-xl bg-violet-500/10 border border-violet-500/20 text-center">
|
||||
<p class="text-violet-400 text-xs uppercase font-bold tracking-wider mb-1">Total Spend</p>
|
||||
<p class="text-2xl font-bold text-violet-300">${{ (usageReport.summary?.total_cost || 0).toFixed(2) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Breakdown -->
|
||||
<div v-if="usageReport.by_user && usageReport.by_user.length > 0">
|
||||
<h3 class="text-sm font-bold text-slate-400 uppercase tracking-wider mb-4">Usage by Member</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div v-for="item in usageReport.by_user" :key="item.entity_id"
|
||||
class="flex items-center justify-between p-3 rounded-lg bg-slate-800/20 border border-white/5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-slate-700 flex items-center justify-center text-[10px] font-bold">
|
||||
{{ (getMemberUsername(item.entity_id) || '??').substring(0,2).toUpperCase() }}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm text-slate-300 font-bold truncate max-w-[150px]">{{ getMemberUsername(item.entity_id) || 'Unknown' }}</span>
|
||||
<span class="text-[9px] text-slate-500 font-mono">{{ item.entity_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-6 text-right">
|
||||
<div>
|
||||
<p class="text-[10px] text-slate-500 uppercase font-bold">Runs</p>
|
||||
<p class="text-sm text-white">{{ item.stats.total_runs }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] text-slate-500 uppercase font-bold">Cost</p>
|
||||
<p class="text-sm text-violet-300">${{ item.stats.total_cost.toFixed(2) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="loadingUsage" class="flex-1 flex items-center justify-center py-20">
|
||||
<i class="pi pi-spin pi-spinner text-4xl text-violet-500"></i>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-12 text-slate-500 flex-1 flex flex-col justify-center">
|
||||
<i class="pi pi-chart-line text-4xl mb-4 opacity-50"></i>
|
||||
<p>No recent activity registered.</p>
|
||||
<p>No usage data available for this project.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,6 +143,7 @@
|
||||
<p class="text-slate-400">Loading project...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -97,6 +152,7 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
import { useProjectsStore } from '@/stores/projectsStore';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { aiService } from '@/services/aiService';
|
||||
import Button from 'primevue/button';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import ConfirmDialog from 'primevue/confirmdialog';
|
||||
@@ -113,14 +169,36 @@ const project = computed(() => projectsStore.getProjectById(projectId));
|
||||
const isCurrentProject = computed(() => currentProject.value?.id === projectId);
|
||||
const isOwner = computed(() => authStore.user && project.value && authStore.user.id === project.value.owner_id);
|
||||
|
||||
const getMemberUsername = (memberId) => {
|
||||
if (!project.value || !project.value.members) return null;
|
||||
const member = project.value.members.find(m => m.id === memberId);
|
||||
return member ? member.username : null;
|
||||
};
|
||||
|
||||
const inviteUsername = ref('');
|
||||
const inviting = ref(false);
|
||||
|
||||
const usageReport = ref(null);
|
||||
const loadingUsage = ref(false);
|
||||
|
||||
const fetchUsage = async () => {
|
||||
loadingUsage.value = true;
|
||||
try {
|
||||
// Pass projectId from route params to ensure we get stats for THIS project
|
||||
usageReport.value = await aiService.getUsageReport("user", projectId);
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch usage report", e);
|
||||
} finally {
|
||||
loadingUsage.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
// Ensure projects are loaded
|
||||
if (projects.value.length === 0) {
|
||||
await projectsStore.fetchProjects();
|
||||
}
|
||||
fetchUsage();
|
||||
});
|
||||
|
||||
const selectProject = () => {
|
||||
@@ -164,3 +242,22 @@ const confirmDelete = () => {
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div class="container mx-auto p-4 animate-fade-in">
|
||||
<div class="h-full overflow-y-auto custom-scrollbar">
|
||||
<div class="container mx-auto p-4 md:p-8 animate-fade-in">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Projects</h1>
|
||||
@@ -39,10 +40,16 @@
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-between mt-4 border-t border-slate-700/50 pt-4">
|
||||
<div class="flex items-center text-slate-500 text-sm">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center text-slate-500 text-sm mb-1">
|
||||
<i class="pi pi-users mr-2"></i>
|
||||
<span>{{ project.members.length }} members</span>
|
||||
</div>
|
||||
<div v-if="projectUsage[project.id]" class="flex items-center text-violet-400 text-xs font-bold">
|
||||
<i class="pi pi-bolt mr-2"></i>
|
||||
<span>${{ projectUsage[project.id].toFixed(2) }} spent</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button v-if="currentProject?.id !== project.id" icon="pi pi-check" label="Select" size="small"
|
||||
severity="secondary" @click.stop="selectProject(project.id)" />
|
||||
@@ -69,12 +76,14 @@
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useProjectsStore } from '@/stores/projectsStore';
|
||||
import { aiService } from '@/services/aiService';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import Button from 'primevue/button';
|
||||
import Dialog from 'primevue/dialog';
|
||||
@@ -94,8 +103,29 @@ const newProject = ref({
|
||||
description: ''
|
||||
});
|
||||
|
||||
const projectUsage = ref({});
|
||||
|
||||
const fetchUsage = async () => {
|
||||
try {
|
||||
// Fetch usage with project breakdown, pass false to ignore current active project header
|
||||
const report = await aiService.getUsageReport("project", false);
|
||||
if (report && report.by_project) {
|
||||
const usageMap = {};
|
||||
report.by_project.forEach(item => {
|
||||
if (item.entity_id) {
|
||||
usageMap[item.entity_id] = item.stats.total_cost;
|
||||
}
|
||||
});
|
||||
projectUsage.value = usageMap;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch projects usage", e);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
projectsStore.fetchProjects();
|
||||
fetchUsage();
|
||||
});
|
||||
|
||||
const createProject = async () => {
|
||||
@@ -121,3 +151,22 @@ const goToProject = (id) => {
|
||||
router.push(`/projects/${id}`);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user