This commit is contained in:
xds
2026-02-19 21:25:48 +03:00
parent 6de5ded2fa
commit 741857de92
5 changed files with 807 additions and 157 deletions

View File

@@ -35,12 +35,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 +50,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
@@ -63,6 +71,28 @@ export const dataService = {
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

@@ -1,5 +1,5 @@
<script setup>
import {computed, onMounted, ref} from 'vue'
import {computed, onMounted, ref, watch, nextTick} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {dataService} from '../services/dataService'
import {aiService} from '../services/aiService'
@@ -19,11 +19,14 @@ 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'
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)
@@ -32,6 +35,197 @@ const historyFirst = ref(0)
const loading = ref(true)
const API_URL = import.meta.env.VITE_API_URL
const selectedEnvironment = ref(null)
const isEnvModalVisible = ref(false)
const isEnvAssetPickerVisible = ref(false)
const isDeletingEnv = ref(false)
const envForm = ref({
name: '',
asset_ids: []
})
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 envAssetPickerFileInput = ref(null)
const envUploadProgress = ref(0)
const isEnvUploading = ref(false)
let envAssetObserver = null
const envSelectedAssets = computed(() => {
// We check against all known assets or just the ones in picker
// Since picker is for the character, we can look into characterAssets too
return [...characterAssets.value, ...envModalAssets.value]
.filter((a, index, self) => self.findIndex(t => t.id === a.id) === index) // Unique
.filter(a => envForm.value.asset_ids.includes(a.id))
})
const loadEnvModalAssets = async (isNewTab = false) => {
if (isEnvModalLoading.value) return
isEnvModalLoading.value = true
if (isNewTab) {
envModalFirst.value = 0
envModalAssets.value = []
}
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)
} finally {
isEnvModalLoading.value = false
}
}
const handleEnvAssetInfiniteScroll = (entries) => {
if (entries[0].isIntersecting && !isEnvModalLoading.value && envModalAssets.value.length < envModalTotal.value) {
envModalFirst.value += envModalRows.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)
else envForm.value.asset_ids.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)
}
}
} 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 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 = (env = null) => {
if (env) {
editingEnvId.value = env.id || env._id
envForm.value = {
name: env.name,
asset_ids: env.asset_ids || []
}
} else {
editingEnvId.value = null
envForm.value = {
name: '',
asset_ids: []
}
}
isEnvModalVisible.value = true
}
const saveEnvironment = async () => {
try {
const payload = {
...envForm.value,
character_id: route.params.id
}
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")
@@ -130,10 +324,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
@@ -145,6 +340,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
@@ -590,6 +787,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,
@@ -702,6 +900,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]" />
@@ -762,32 +966,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-16" />
<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-clipboard"
class="!p-1 !w-6 !h-6 !text-[10px] bg-slate-800 hover:bg-slate-700 border-white/10 text-slate-400"
@click="pastePrompt" v-tooltip.top="'Paste'" />
<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">
@@ -816,6 +994,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">
@@ -1217,6 +1435,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>
@@ -1286,6 +1555,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"
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)"
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" @click="toggleEnvAssetSelection(asset.id)"
class="aspect-square rounded-xl overflow-hidden cursor-pointer relative border transition-all"
:class="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)"
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

@@ -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,34 @@ 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
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([])
@@ -195,13 +223,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))
@@ -228,7 +258,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) {
@@ -239,7 +271,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 })
@@ -258,7 +290,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
@@ -310,7 +342,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.
@@ -328,7 +360,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)
if (response && response.generations) {
// Update existing items and add new ones at the top
const newGenerations = []
@@ -380,7 +412,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: selectedEnvironment.value?.id || selectedEnvironment.value?._id || null,
telegram_id: sendToTelegram.value ? telegramId.value : null,
use_profile_image: selectedCharacter.value ? useProfileImage.value : false,
count: generationCount.value
@@ -1170,6 +1203,7 @@ const confirmAddToAlbum = async () => {
</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
@@ -1203,17 +1237,28 @@ 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">
<Checkbox v-model="useProfileImage" :binary="true" inputId="use-profile-img"
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-profile-img"
class="text-xs text-slate-300 cursor-pointer select-none">Use
Character
Photo</label>
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 }) => ({
class: ['!bg-slate-800 !border-white/20', { '!bg-violet-600 !border-violet-600': props.modelValue }]
})
}" />
<label for="use-profile-img"
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>
@@ -1238,6 +1283,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">

View File

@@ -76,6 +76,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
@@ -90,6 +92,31 @@ const useProfileImage = ref(true)
const isImprovingPrompt = ref(false)
const previousPrompt = 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)
})
// --- Persist settings to localStorage on change ---
const saveSettings = () => {
@@ -102,7 +129,8 @@ const saveSettings = () => {
sendToTelegram: sendToTelegram.value,
telegramId: telegramId.value,
useProfileImage: useProfileImage.value,
selectedCharacterId: selectedCharacter.value?.id || null,
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))
@@ -125,6 +153,7 @@ const restoreSettings = () => {
telegramId.value = s.telegramId || localStorage.getItem('telegram_id') || ''
if (s.useProfileImage !== undefined) useProfileImage.value = s.useProfileImage
_savedCharacterId = s.selectedCharacterId || null
_savedEnvironmentId = s.selectedEnvironmentId || null
if (s.selectedAssetIds && s.selectedAssetIds.length > 0) {
selectedAssets.value = s.selectedAssetIds.map(id => ({
id,
@@ -138,7 +167,7 @@ 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, selectedCharacter, selectedEnvironment, selectedAssets], saveSettings, { deep: true })
const viewMode = ref('feed') // 'feed' or 'gallery'
const isSubmitting = ref(false)
@@ -203,7 +232,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
}
@@ -259,7 +288,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: selectedEnvironment.value?.id || selectedEnvironment.value?._id || null,
telegram_id: sendToTelegram.value ? telegramId.value : null,
use_profile_image: selectedCharacter.value ? useProfileImage.value : false,
count: imageCount.value,
@@ -1145,75 +1175,115 @@ watch(viewMode, (v) => {
class="w-full !h-28 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" />
</div>
<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>
<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="{
root: { class: '!bg-slate-800' },
input: { class: '!text-white' },
trigger: { class: '!text-slate-400' },
panel: { class: '!bg-slate-800 !border-white/10' },
item: { class: '!text-slate-300 hover:!bg-white/10 hover:!text-white' }
}">
<template #value="slotProps">
<div v-if="slotProps.value" class="flex items-center gap-2">
<img v-if="slotProps.value.avatar_image"
:src="API_URL + slotProps.value.avatar_image"
class="w-6 h-6 rounded-full object-cover" />
<span>{{ slotProps.value.name }}</span>
</div>
<span v-else>{{ slotProps.placeholder }}</span>
</template>
<template #option="slotProps">
<div class="flex items-center gap-2">
<img v-if="slotProps.option.avatar_image"
:src="API_URL + slotProps.option.avatar_image"
class="w-8 h-8 rounded-full object-cover" />
<span>{{ slotProps.option.name }}</span>
</div>
</template>
</Dropdown>
<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>
<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="{
root: { class: '!bg-slate-800' },
input: { class: '!text-white' },
trigger: { class: '!text-slate-400' },
panel: { class: '!bg-slate-800 !border-white/10' },
item: { class: '!text-slate-300 hover:!bg-white/10 hover:!text-white' }
}">
<template #value="slotProps">
<div v-if="slotProps.value" class="flex items-center gap-2">
<img v-if="slotProps.value.avatar_image"
:src="API_URL + slotProps.value.avatar_image"
class="w-6 h-6 rounded-full object-cover" />
<span>{{ slotProps.value.name }}</span>
</div>
<span v-else>{{ slotProps.placeholder }}</span>
</template>
<template #option="slotProps">
<div class="flex items-center gap-2">
<img v-if="slotProps.option.avatar_image"
:src="API_URL + slotProps.option.avatar_image"
class="w-8 h-8 rounded-full object-cover" />
<span>{{ slotProps.option.name }}</span>
</div>
</template>
</Dropdown>
<div v-if="selectedCharacter"
class="flex items-center gap-2 mt-2 px-1 animate-in fade-in slide-in-from-top-1">
<Checkbox v-model="useProfileImage" :binary="true" inputId="idea-use-profile-img"
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-profile-img"
class="text-xs text-slate-300 cursor-pointer select-none">Use
Character
Photo</label>
</div>
</div>
<div v-if="selectedCharacter"
class="flex items-center gap-2 mt-2 px-1 animate-in fade-in slide-in-from-top-1">
<Checkbox v-model="useProfileImage" :binary="true" inputId="idea-use-profile-img"
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-profile-img"
class="text-xs text-slate-300 cursor-pointer select-none">Use
Character
Photo</label>
</div>
</div>
<div class="flex-1 flex flex-col gap-1">
<label
class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Assets</label>
<div @click="openAssetPicker"
class="w-full bg-slate-800 border border-white/10 rounded-lg p-2 min-h-[38px] cursor-pointer hover:bg-slate-700/50 transition-colors flex flex-wrap gap-1.5">
<span v-if="selectedAssets.length === 0"
class="text-slate-400 text-sm py-0.5">Select
Assets</span>
<div v-for="asset in selectedAssets" :key="asset.id"
class="px-2 py-1 bg-violet-600/30 border border-violet-500/30 text-violet-200 text-xs rounded-md flex items-center gap-2 animate-in fade-in zoom-in duration-200"
@click.stop>
<img v-if="asset.url" :src="API_URL + asset.url + '?thumbnail=true'"
class="w-4 h-4 rounded object-cover" />
<span class="truncate max-w-[100px]">{{ asset.name || 'Asset ' + (asset.id ?
asset.id.substring(0, 4) : '') }}</span>
<i class="pi pi-times cursor-pointer hover:text-white"
@click.stop="removeAsset(asset)"></i>
</div>
</div>
</div>
</div>
</div>
<div class="flex-1 flex flex-col gap-1">
<label
class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Assets</label>
<div @click="openAssetPicker"
class="w-full bg-slate-800 border border-white/10 rounded-lg p-2 min-h-[38px] cursor-pointer hover:bg-slate-700/50 transition-colors flex flex-wrap gap-1.5">
<span v-if="selectedAssets.length === 0"
class="text-slate-400 text-sm py-0.5">Select
Assets</span>
<div v-for="asset in selectedAssets" :key="asset.id"
class="px-2 py-1 bg-violet-600/30 border border-violet-500/30 text-violet-200 text-xs rounded-md flex items-center gap-2 animate-in fade-in zoom-in duration-200"
@click.stop>
<img v-if="asset.url" :src="API_URL + asset.url + '?thumbnail=true'"
class="w-4 h-4 rounded object-cover" />
<span class="truncate max-w-[100px]">{{ asset.name || 'Asset ' + (asset.id ?
asset.id.substring(0, 4) : '') }}</span>
<i class="pi pi-times cursor-pointer hover:text-white"
@click.stop="removeAsset(asset)"></i>
</div>
</div>
</div>
</div>
<!-- Environment Row (Below) -->
<div v-if="selectedCharacter" class="flex-1 flex flex-col gap-1 animate-in fade-in slide-in-from-top-1">
<div class="flex justify-between items-center">
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Environment</label>
<Button v-if="selectedEnvironment" icon="pi pi-times" @click="selectedEnvironment = null" text size="small"
class="!p-0 !h-4 !w-4 !text-[8px] text-slate-500 hover:text-white" />
</div>
<div v-if="environments.length > 0" class="flex gap-2 overflow-x-auto pb-1 custom-scrollbar no-scrollbar">
<div v-for="env in environments" :key="env.id || env._id"
@click="selectedEnvironment = env"
class="flex-shrink-0 flex items-center gap-2 px-2 py-1.5 rounded-lg border-2 transition-all cursor-pointer group bg-slate-800/40"
:class="[
(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id))
? 'border-violet-500 bg-violet-500/10 shadow-[0_0_15px_rgba(124,58,237,0.1)]'
: 'border-white/5 hover:border-white/20'
]"
>
<div class="w-6 h-6 rounded overflow-hidden flex-shrink-0 bg-slate-900 flex items-center justify-center border border-white/5">
<img v-if="env.asset_ids?.length > 0"
:src="API_URL + '/assets/' + env.asset_ids[0] + '?thumbnail=true'"
class="w-full h-full object-cover"
/>
<i v-else class="pi pi-map-marker text-[10px]"
:class="(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id)) ? 'text-violet-400' : 'text-slate-500'"
></i>
</div>
<span class="text-[10px] whitespace-nowrap pr-1"
:class="(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id)) ? 'text-violet-300 font-bold' : 'text-slate-400 group-hover:text-slate-200'"
>
{{ env.name }}
</span>
<i v-if="(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id))"
class="pi pi-check text-violet-400 text-[8px]"></i>
</div>
</div>
<div v-else class="py-2 px-3 bg-slate-800/50 border border-white/5 rounded-xl text-center">
<p class="text-[9px] text-slate-600 uppercase m-0">No environments</p>
</div>
</div> </div>
<div class="w-full lg:w-72 flex flex-col gap-2">
<div class="grid grid-cols-2 gap-2">

View File

@@ -65,13 +65,41 @@ const imageCount = ref(1)
const sendToTelegram = ref(false)
const telegramId = ref('')
const useProfileImage = ref(true)
const useEnvironment = ref(false)
const isSubmittingGen = ref(false)
// 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' },
@@ -100,7 +128,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,
@@ -123,14 +153,16 @@ 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()
@@ -144,7 +176,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
}
@@ -411,8 +443,7 @@ const handleAssetPickerUpload = async (event) => {
<!-- 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="{
@@ -441,41 +472,91 @@ const handleAssetPickerUpload = async (event) => {
</template>
</Dropdown>
<div v-if="selectedCharacter"
class="flex items-center gap-2 mt-2 px-1 animate-in fade-in slide-in-from-top-1">
<Checkbox v-model="useProfileImage" :binary="true" inputId="idea-use-profile-img"
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-profile-img"
class="text-xs text-slate-300 cursor-pointer select-none">Use
Character
Photo</label>
</div>
<div v-if="selectedCharacter"
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 }) => ({
class: ['!bg-slate-800 !border-white/20', { '!bg-violet-600 !border-violet-600': props.modelValue }]
})
}" />
<label for="idea-use-profile-img"
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>
<div class="flex-1 flex flex-col gap-1">
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Assets</label>
<div @click="openAssetPicker"
class="w-full bg-slate-800 border border-white/10 rounded-lg p-2 min-h-[38px] cursor-pointer hover:bg-slate-700/50 transition-colors flex flex-wrap gap-1.5">
<span v-if="selectedAssets.length === 0" class="text-slate-400 text-sm py-0.5">Select
Assets</span>
<div v-for="asset in selectedAssets" :key="asset.id"
class="px-2 py-1 bg-violet-600/30 border border-violet-500/30 text-violet-200 text-xs rounded-md flex items-center gap-2 animate-in fade-in zoom-in duration-200"
@click.stop>
<img v-if="asset.url" :src="API_URL + asset.url + '?thumbnail=true'"
class="w-4 h-4 rounded object-cover" />
<span class="truncate max-w-[100px]">{{ asset.name || 'Asset ' + (asset.id ?
asset.id.substring(0, 4) : '') }}</span>
<i class="pi pi-times cursor-pointer hover:text-white"
@click.stop="removeAsset(asset)"></i>
</div>
</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 class="flex-1 flex flex-col gap-1">
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Assets</label>
<div @click="openAssetPicker"
class="w-full bg-slate-800 border border-white/10 rounded-lg p-2 min-h-[38px] cursor-pointer hover:bg-slate-700/50 transition-colors flex flex-wrap gap-1.5">
<span v-if="selectedAssets.length === 0" class="text-slate-400 text-sm py-0.5">Select
Assets</span>
<div v-for="asset in selectedAssets" :key="asset.id"
class="px-2 py-1 bg-violet-600/30 border border-violet-500/30 text-violet-200 text-xs rounded-md flex items-center gap-2 animate-in fade-in zoom-in duration-200"
@click.stop>
<img v-if="asset.url" :src="API_URL + asset.url + '?thumbnail=true'"
class="w-4 h-4 rounded object-cover" />
<span class="truncate max-w-[100px]">{{ asset.name || 'Asset ' + (asset.id ?
asset.id.substring(0, 4) : '') }}</span>
<i class="pi pi-times cursor-pointer hover:text-white"
@click.stop="removeAsset(asset)"></i>
<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>
</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">