Compare commits

16 Commits

Author SHA1 Message Date
xds
7d7cd25040 likes 2026-02-26 11:27:01 +03:00
xds
dea0916f6c likes 2026-02-24 17:32:48 +03:00
xds
122c5a7cbc likes 2026-02-24 12:47:19 +03:00
xds
a1d37ac517 likes 2026-02-24 12:04:17 +03:00
xds
1a7295aa77 fixes 2026-02-20 15:58:43 +03:00
xds
ccd7f8a2df fixes 2026-02-20 13:33:04 +03:00
xds
4136f42e70 fixes 2026-02-20 13:10:50 +03:00
xds
b0ce251914 fixes 2026-02-20 10:29:11 +03:00
xds
489fd14903 + env 2026-02-20 02:02:40 +03:00
xds
741857de92 + env 2026-02-19 21:25:48 +03:00
xds
6de5ded2fa fixes 2026-02-18 17:09:32 +03:00
xds
a6faa89686 fixes 2026-02-18 16:37:40 +03:00
xds
27da4c042e fixes 2026-02-18 16:34:40 +03:00
xds
0cc5150f9c fixes 2026-02-18 16:34:11 +03:00
xds
f8adcf33d3 feat: Implement Web Share API for mobile image sharing and standardize prompt textarea heights across views. 2026-02-17 18:17:00 +03:00
674cbb8f16 Merge pull request 'feat: Implement content planning and post management with a new service and calendar view.' (#3) from posts into main
Reviewed-on: #3
2026-02-17 12:55:21 +00:00
24 changed files with 3128 additions and 949 deletions

3
.gitignore vendored
View File

@@ -37,3 +37,6 @@ __screenshots__/
# Vite
*.timestamp-*-*.mjs
package-lock.json

1
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -58,6 +58,12 @@
font-weight: normal;
}
input,
textarea,
select {
font-size: 16px;
}
body {
min-height: 100vh;
color: var(--color-text);

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -0,0 +1,181 @@
<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'])
const isTemporarilyUnblurred = ref(false)
const isBlurred = computed(() => {
return 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) {
// If blurred, click might just unblur or do nothing?
// Let's let the button handle unblur, and click opens preview if unblurred?
// Or maybe click unblurs? Let's stick to button for unblur to be explicit.
// But if user clicks image, maybe show preview anyway?
// Usually blurred images shouldn't be previewed full size unless unblurred.
// Let's allow preview, but maybe preview also needs to handle blur?
// For now, let's just open preview. The preview modal might need its own blur logic or just show it.
// Let's assume preview shows it.
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="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>

View File

@@ -0,0 +1,350 @@
<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">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>

View File

@@ -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,22 @@ export const aiService = {
linked_assets: linkedAssets
})
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
}
}

View File

@@ -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()

View File

@@ -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 });
}
};

View 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 } })
};

View File

@@ -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
};
});

View File

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

View File

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

View File

@@ -1,15 +1,13 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { dataService } from '../services/dataService'
import { aiService } from '../services/aiService'
import {computed, onMounted, ref, watch, nextTick} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {dataService} from '../services/dataService'
import {aiService} from '../services/aiService'
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'
@@ -21,11 +19,16 @@ import Tab from 'primevue/tab'
import TabPanels from 'primevue/tabpanels'
import TabPanel from 'primevue/tabpanel'
import Paginator from 'primevue/paginator'
import MultiSelect from 'primevue/multiselect'
import Dropdown from 'primevue/dropdown'
import GenerationPreviewModal from '../components/GenerationPreviewModal.vue'
const route = useRoute()
const router = useRouter()
const character = ref(null)
const characterAssets = ref([])
const environments = ref([])
const assetsTotalRecords = ref(0)
const historyGenerations = ref([])
const historyTotal = ref(0)
@@ -34,6 +37,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 +329,14 @@ const openModal = (asset) => {
toggleBulkSelection(asset.id)
return
}
selectedAsset.value = asset
isModalVisible.value = true
const idx = characterAssets.value.findIndex(a => a.id === asset.id)
const images = characterAssets.value.map(a => ({
url: API_URL + a.url,
assetId: a.id,
is_liked: a.is_liked || a.liked,
gen: a.generation_id ? { id: a.generation_id, prompt: a.prompt } : null
}))
openImagePreview(images, idx >= 0 ? idx : 0)
}
const toggleBulkSelection = (id) => {
@@ -132,10 +422,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 +438,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
@@ -279,10 +572,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)
@@ -491,6 +790,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) => {
@@ -583,6 +891,7 @@ const handleGenerate = async () => {
const payload = {
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,
@@ -695,6 +1004,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]" />
@@ -755,28 +1070,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,6 +1098,46 @@ 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">
@@ -819,7 +1152,7 @@ const handleGenerate = async () => {
<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" />
class="w-full !text-[16px] !py-1" @blur="saveTelegramId" />
</div>
<div class="flex items-center gap-2 mt-1">
<Checkbox v-model="useProfileImage" :binary="true"
@@ -987,7 +1320,7 @@ const handleGenerate = async () => {
:class="gen.children.length > 2 ? 'grid-cols-4' : 'grid-cols-2'">
<div v-for="child in gen.children" :key="child.id"
class="relative aspect-[9/16] rounded-md overflow-hidden bg-black/30 border border-white/5 group/child"
@click.stop="restoreGeneration(child)">
@click.stop="openImagePreview([{ url: API_URL + '/assets/' + child.result_list[0], assetId: child.result_list[0], is_liked: child.liked_assets?.includes(child.result_list[0]), gen: child }])">
<img v-if="child.result_list && child.result_list.length > 0"
:src="API_URL + '/assets/' + child.result_list[0] + '?thumbnail=true'"
@@ -1020,10 +1353,11 @@ const handleGenerate = async () => {
<div v-else class="flex gap-3 w-full">
<div class="w-12 h-12 rounded bg-black/40 border border-white/10 flex-shrink-0 mt-0.5 relative z-0"
@mouseenter="gen.result_list && gen.result_list[0] ? onThumbnailEnter($event, API_URL + '/assets/' + gen.result_list[0] + '?thumbnail=true') : null"
@mouseleave="onThumbnailLeave">
@mouseleave="onThumbnailLeave"
@click.stop="openImagePreview([{ url: API_URL + '/assets/' + gen.result_list[0], assetId: gen.result_list[0], is_liked: gen.liked_assets?.includes(gen.result_list[0]), gen: gen }])">
<img v-if="gen.result_list && gen.result_list.length > 0"
:src="API_URL + '/assets/' + gen.result_list[0] + '?thumbnail=true'"
class="w-full h-full object-cover rounded opacity-100" />
class="w-full h-full object-cover rounded opacity-100 cursor-pointer" />
<div v-else
class="w-full h-full flex items-center justify-center text-slate-700 overflow-hidden rounded">
<i class="pi pi-image text-lg" />
@@ -1206,6 +1540,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 +1613,16 @@ const handleGenerate = async () => {
Character not found.
</div>
<Dialog v-model:visible="isModalVisible" modal dismissableMask header="Asset View"
:style="{ width: '90vw', maxWidth: '800px' }" class="glass-panel rounded-2xl">
<div v-if="selectedAsset" class="flex flex-col items-center">
<img :src="selectedAsset.link ? API_URL + selectedAsset.link : (selectedAsset.url ? API_URL + selectedAsset.url : 'https://via.placeholder.com/800')"
:alt="selectedAsset.name"
class="max-w-full max-h-[70vh] rounded-xl object-contain shadow-2xl" />
<div class="mt-6 text-center">
<h2 class="text-2xl font-bold mb-2">{{ selectedAsset.name }}</h2>
<p class="text-slate-400">{{ selectedAsset.type }}</p>
</div>
</div>
</Dialog>
<GenerationPreviewModal
v-model:visible="isImagePreviewVisible"
:preview-images="previewImages"
:initial-index="previewIndex"
:api-url="API_URL"
@reuse-prompt="reusePrompt"
@reuse-asset="reuseAsset"
@use-result-as-asset="useResultAsReference"
@liked="handleLiked"
/>
<!-- Asset Selection Modal (Global) -->
<Dialog v-model:visible="isAssetSelectionVisible" modal header="Select Reference Assets"
:style="{ width: '80vw', maxWidth: '1000px' }" class="glass-panel rounded-2xl">
@@ -1275,6 +1658,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>

View File

@@ -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">

View File

@@ -1,9 +1,9 @@
<script setup>
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
import { useRouter } from 'vue-router'
import { dataService } from '../services/dataService'
import { aiService } from '../services/aiService'
import { postService } from '../services/postService'
import {computed, onBeforeUnmount, onMounted, ref, watch} from 'vue'
import {useRouter} from 'vue-router'
import {dataService} from '../services/dataService'
import {aiService} from '../services/aiService'
import {postService} from '../services/postService'
import Button from 'primevue/button'
import Textarea from 'primevue/textarea'
import InputText from 'primevue/inputtext'
@@ -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 {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)
@@ -156,8 +158,41 @@ 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(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)
})
const characters = ref([])
const allAssets = ref([])
@@ -166,10 +201,16 @@ 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([
@@ -178,10 +219,16 @@ const qualityOptions = ref([
{ 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" }
])
// --- Persistence ---
@@ -190,13 +237,15 @@ const STORAGE_KEY = 'flexible_gen_settings'
const saveSettings = () => {
const settings = {
prompt: prompt.value,
selectedCharacterId: selectedCharacter.value?.id,
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))
@@ -223,7 +272,9 @@ const restoreSettings = () => {
telegramId.value = settings.telegramId || localStorage.getItem('telegram_id') || ''
if (telegramId.value) isTelegramIdSaved.value = true
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 +285,7 @@ const restoreSettings = () => {
}
// Watchers for auto-save
watch([prompt, selectedCharacter, selectedAssets, quality, aspectRatio, sendToTelegram, telegramId, useProfileImage, generationCount], () => {
watch([prompt, selectedCharacter, selectedEnvironment, selectedAssets, quality, aspectRatio, sendToTelegram, telegramId, useProfileImage, useEnvironment, generationCount], () => {
saveSettings()
}, { deep: true })
@@ -246,6 +297,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 +311,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 +363,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 +381,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 = []
@@ -375,7 +433,8 @@ const handleGenerate = async () => {
quality: quality.value.key,
prompt: prompt.value,
assets_list: selectedAssets.value.map(a => a.id),
linked_character_id: selectedCharacter.value?.id || 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,
telegram_id: sendToTelegram.value ? telegramId.value : null,
use_profile_image: selectedCharacter.value ? useProfileImage.value : false,
count: generationCount.value
@@ -483,7 +542,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 =>
@@ -505,7 +564,6 @@ onMounted(() => {
// slight delay to allow DOM render
setTimeout(setupInfiniteScroll, 500)
})
isSettingsVisible.value = true
})
// --- Sidebar Logic (Duplicated for now) ---
@@ -551,8 +609,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 +621,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 +714,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 {
@@ -808,48 +860,55 @@ 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="">{{ 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="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 +932,24 @@ 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"
/>
</div>
</div>
</template>
@@ -976,134 +958,22 @@ 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"
/>
</template>
</div>
</div>
@@ -1144,17 +1014,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 +1061,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 +1070,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,6 +1107,47 @@ 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">
@@ -1264,7 +1189,15 @@ const confirmAddToAlbum = async () => {
</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" />
class="w-full !text-[16px] !bg-slate-900 !border-white/10 !text-white !py-1.5" />
</div>
</div>
<!-- NSFW Toggle -->
<div class="flex flex-col gap-2 bg-slate-800/50 p-3 rounded-xl border border-white/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>
@@ -1291,44 +1224,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' }"

View File

@@ -26,14 +26,53 @@ 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('')
@@ -42,6 +81,8 @@ const selectedModel = ref('flux-schnell')
// 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
@@ -53,9 +94,42 @@ 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 = () => {
@@ -68,7 +142,9 @@ const saveSettings = () => {
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))
@@ -90,7 +166,9 @@ const restoreSettings = () => {
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,11 +182,16 @@ const restoreSettings = () => {
}
restoreSettings()
watch([prompt, quality, aspectRatio, imageCount, selectedModel, sendToTelegram, telegramId, useProfileImage, selectedCharacter, selectedAssets], saveSettings, { deep: true })
watch([prompt, quality, aspectRatio, imageCount, selectedModel, sendToTelegram, telegramId, 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
@@ -118,10 +201,16 @@ const qualityOptions = ref([
{ 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" }
])
// Removed duplicate characters ref
@@ -139,6 +228,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 +259,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 +271,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
@@ -221,7 +315,8 @@ const handleGenerate = async () => {
aspect_ratio: aspectRatio.value.key,
quality: quality.value.key,
assets_list: selectedAssets.value.map(a => a.id),
linked_character_id: selectedCharacter.value?.id || 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,
telegram_id: sendToTelegram.value ? telegramId.value : null,
use_profile_image: selectedCharacter.value ? useProfileImage.value : false,
count: imageCount.value,
@@ -413,6 +508,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 +618,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(isImagePreviewVisible, (visible) => {
if (visible) window.addEventListener('keydown', handlePreviewKeydown)
else window.removeEventListener('keydown', handlePreviewKeydown)
})
onUnmounted(() => window.removeEventListener('keydown', handlePreviewKeydown))
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'
}
}
}, { immediate: true })
// --- Computeds ---
const groupedGenerations = computed(() => {
@@ -602,6 +709,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 +728,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 +742,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 +790,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 +862,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 +876,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 +930,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') {
@@ -806,35 +973,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 +1071,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)" />
@@ -904,19 +1097,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 +1189,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,6 +1211,11 @@ watch(viewMode, (v) => {
class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-end p-3">
<p class="text-xs text-white line-clamp-2 mb-2">{{ img.gen.prompt }}</p>
<div class="flex gap-2 justify-end">
<Button :icon="img.gen.is_liked ? 'pi pi-heart-fill' : 'pi pi-heart'" rounded text size="small"
class="!text-white transition-colors"
:class="img.gen.is_liked ? '!text-pink-500' : 'hover:!text-pink-500 hover:!bg-white/20'"
v-tooltip.top="img.gen.is_liked ? 'Unlike' : 'Like'"
@click.stop="toggleLike(img.gen)" />
<Button icon="pi pi-pencil" rounded text size="small"
class="!text-white hover:!bg-white/20" v-tooltip.top="'Use as Reference'"
@click.stop="setAsReference(img.assetId)" />
@@ -1050,14 +1267,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 +1312,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 +1321,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,8 +1358,48 @@ 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.15)]'
: '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="flex flex-col gap-1">
@@ -1170,7 +1440,15 @@ watch(viewMode, (v) => {
</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" />
class="w-full !text-[16px] !bg-slate-900 !border-white/10 !text-white !py-1.5" />
</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 justify-between">
<label class="text-xs text-slate-300 cursor-pointer">Show NSFW</label>
<InputSwitch v-model="showNsfwGlobal" />
</div>
</div>
@@ -1252,45 +1530,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 +1570,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>

View File

@@ -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,12 +15,175 @@ 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('')
@@ -31,13 +194,42 @@ 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' },
@@ -45,10 +237,16 @@ const qualityOptions = ref([
{ 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
@@ -66,7 +264,9 @@ const restoreSettings = () => {
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 +274,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)
}
@@ -89,19 +290,22 @@ const saveSettings = () => {
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, selectedModel, sendToTelegram, telegramId, useProfileImage, useEnvironment, selectedCharacter, selectedEnvironment, selectedAssets], saveSettings, { deep: true })
onMounted(async () => {
restoreSettings()
await Promise.all([
ideaStore.fetchIdeas(),
ideaStore.fetchInspirations(),
loadCharacters()
])
})
@@ -110,7 +314,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 +347,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 +365,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 +375,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 +466,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 +521,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 +553,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 +700,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 +747,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 +756,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,8 +791,47 @@ 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">
@@ -445,7 +872,7 @@ const handleAssetPickerUpload = async (event) => {
</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" />
class="w-full !text-[16px] !bg-slate-900 !border-white/10 !text-white !py-1.5" />
</div>
</div>
@@ -460,6 +887,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' } }">

View File

@@ -1,18 +1,18 @@
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { dataService } from '../services/dataService'
import { aiService } from '../services/aiService'
import {onMounted, ref} from 'vue'
import {useRouter} from 'vue-router'
import {dataService} from '../services/dataService'
import {aiService} from '../services/aiService'
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 GenerationPreviewModal from '../components/GenerationPreviewModal.vue'
const router = useRouter()
const API_URL = import.meta.env.VITE_API_URL
@@ -66,10 +66,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" }
])
// --- Data Loading ---
@@ -295,23 +301,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 +338,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 +378,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 +438,6 @@ const useResultAsReference = (gen) => {
// --- Utils ---
const copyToClipboard = () => {
// Implement if needed for prompt copying
}
// --- Lifecycle ---
@@ -478,6 +500,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" />
@@ -536,7 +561,7 @@ onMounted(() => {
<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" />
class="w-full !text-[16px] !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>
@@ -594,7 +619,7 @@ onMounted(() => {
v-if="generatedResult.type === 'assets' && generatedResult.assets && generatedResult.assets.length > 0">
<!-- Displaying the first asset as main preview -->
<img :src="API_URL + '/assets/' + generatedResult.assets[0].id"
@click="openImagePreview(API_URL + '/assets/' + generatedResult.assets[0].id, 'Generated Result', new Date().toISOString())"
@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 +680,20 @@ onMounted(() => {
<div class="flex-1 overflow-y-auto pr-2 custom-scrollbar flex flex-col gap-2">
<div v-for="gen in historyGenerations" :key="gen.id"
class="glass-panel p-2 rounded-lg border border-white/5 flex flex-col gap-2 hover:bg-white/10 transition-colors group">
class="glass-panel p-2 rounded-lg border border-white/5 flex flex-col gap-2 hover:bg-white/10 transition-colors group relative">
<!-- Liked badge on history item -->
<div v-if="gen.liked_assets?.length > 0"
class="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-pink-500 shadow-lg flex items-center justify-center border border-pink-400 z-10">
<i class="pi pi-heart-fill text-white text-[8px]"></i>
</div>
<div class="flex gap-3 items-start cursor-pointer" @click="restoreGeneration(gen)">
<div
class="w-12 h-12 rounded bg-black/40 border border-white/10 overflow-hidden flex-shrink-0 mt-0.5">
class="w-12 h-12 rounded bg-black/40 border border-white/10 overflow-hidden flex-shrink-0 mt-0.5 relative group/hist">
<img v-if="gen.result_list && gen.result_list.length > 0"
:src="API_URL + '/assets/' + gen.result_list[0] + '?thumbnail=true'"
class="w-full h-full object-cover" />
class="w-full h-full object-cover"
@click.stop="openImagePreview(API_URL + '/assets/' + gen.result_list[0], 'History Entry', gen.created_at, gen, gen.result_list[0], gen.liked_assets?.includes(gen.result_list[0]))" />
</div>
<div class="flex-1 min-w-0">
<p class="text-xs text-slate-300 truncate font-medium">{{ gen.prompt }}</p>
@@ -747,18 +779,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>

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>