feat: Redesign sidebar to a horizontal top navigation bar and enhance assets view with asset upload, refined filters, and a new grid layout.

This commit is contained in:
xds
2026-02-09 01:52:20 +03:00
parent c73bffc9f4
commit 27337e0ccf
4 changed files with 341 additions and 138 deletions

View File

@@ -12,10 +12,10 @@ const route = useRoute()
</div> </div>
<!-- Main Layout (Sidebar + Content) --> <!-- Main Layout (Sidebar + Content) -->
<div v-else class="flex h-screen bg-slate-900 text-slate-100 font-sans overflow-hidden"> <div v-else class="flex flex-col h-screen bg-slate-900 text-slate-100 font-sans overflow-hidden">
<AppSidebar /> <AppSidebar />
<div class="flex-1 h-full overflow-hidden relative"> <div class="flex-1 w-full overflow-hidden relative">
<RouterView v-slot="{ Component }"> <RouterView v-slot="{ Component }">
<transition name="fade" mode="out-in"> <transition name="fade" mode="out-in">
<component :is="Component" /> <component :is="Component" />

View File

@@ -23,10 +23,9 @@ const isActive = (path) => {
const navItems = computed(() => { const navItems = computed(() => {
const items = [ const items = [
{ path: '/', icon: '🏠', tooltip: 'Home' }, { path: '/', icon: '🏠', tooltip: 'Home' },
{ path: '/assets', icon: '📂', tooltip: 'Assets' },
{ path: '/albums', icon: '🖼️', tooltip: 'Albums' },
// { path: '/generation', icon: '🎨', tooltip: 'Image Generation' },
{ path: '/flexible', icon: '🖌️', tooltip: 'Flexible Generation' }, { path: '/flexible', icon: '🖌️', tooltip: 'Flexible Generation' },
{ path: '/albums', icon: '🖼️', tooltip: 'Albums' },
{ path: '/assets', icon: '📂', tooltip: 'Assets' },
{ path: '/characters', icon: '👥', tooltip: 'Characters' }, { path: '/characters', icon: '👥', tooltip: 'Characters' },
{ path: '/image-to-prompt', icon: '✨', tooltip: 'Image to Prompt' } { path: '/image-to-prompt', icon: '✨', tooltip: 'Image to Prompt' }
] ]
@@ -41,31 +40,34 @@ const navItems = computed(() => {
<template> <template>
<div class="contents"> <div class="contents">
<!-- Sidebar (Desktop) --> <!-- Sidebar (Desktop -> Top Bar) -->
<nav <nav
class="hidden md:flex glass-panel w-20 m-4 flex-col items-center py-6 rounded-3xl z-40 border border-white/5 bg-slate-900/50 backdrop-blur-md h-[calc(100vh-2rem)]"> 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">
<div class="mb-12">
<!-- Logo -->
<div <div
class="w-10 h-10 bg-gradient-to-br from-violet-600 to-cyan-500 rounded-xl flex items-center justify-center font-bold text-white text-xl shadow-lg shadow-violet-500/20"> class="w-10 h-10 bg-gradient-to-br from-violet-600 to-cyan-500 rounded-xl flex items-center justify-center font-bold text-white text-xl shadow-lg shadow-violet-500/20 shrink-0">
AI AI
</div> </div>
</div>
<div class="flex-1 flex flex-col gap-6 w-full items-center"> <!-- Nav Items -->
<div class="flex flex-row gap-2 items-center justify-center flex-1 mx-8">
<div v-for="item in navItems" :key="item.path" :class="[ <div v-for="item in navItems" :key="item.path" :class="[
'w-12 h-12 flex items-center justify-center rounded-xl cursor-pointer transition-all duration-300', 'px-4 py-2 flex items-center gap-2 rounded-xl cursor-pointer transition-all duration-300',
isActive(item.path) isActive(item.path)
? 'bg-white/10 text-slate-50 shadow-inner' ? 'bg-white/10 text-slate-50 shadow-inner'
: 'text-slate-400 hover:bg-white/10 hover:text-slate-50' : 'text-slate-400 hover:bg-white/5 hover:text-slate-50'
]" @click="router.push(item.path)" v-tooltip.right="item.tooltip"> ]" @click="router.push(item.path)" v-tooltip.bottom="item.tooltip">
<span class="text-2xl">{{ item.icon }}</span> <span class="text-xl">{{ item.icon }}</span>
<span class="text-sm font-medium hidden lg:block">{{ item.tooltip }}</span>
</div> </div>
</div> </div>
<div class="mt-auto flex flex-col items-center gap-4"> <!-- Right Actions -->
<div class="flex items-center gap-4 shrink-0">
<div @click="handleLogout" <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-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"
v-tooltip.right="'Logout'"> v-tooltip.bottom="'Logout'">
<i class="pi pi-power-off"></i> <i class="pi pi-power-off"></i>
</div> </div>
<!-- Profile Avatar Placeholder --> <!-- Profile Avatar Placeholder -->

View File

@@ -25,8 +25,35 @@ const selectedAsset = ref<Asset | null>(null)
const isModalVisible = ref(false) const isModalVisible = ref(false)
const first = ref(0) const first = ref(0)
const rows = ref(12) const rows = ref(18)
const totalRecords = ref(0) const totalRecords = ref(0)
const fileInput = ref<HTMLInputElement | null>(null)
const triggerFileUpload = () => {
fileInput.value?.click()
}
const handleFileUpload = async (event: Event) => {
const target = event.target as HTMLInputElement
if (target.files && target.files[0]) {
const file = target.files[0]
try {
loading.value = true
await dataService.uploadAsset(file)
toast.add({ severity: 'success', summary: 'Success', detail: 'Asset uploaded successfully', life: 3000 })
// Reset to first page and reload
first.value = 0
activeFilter.value = 'uploaded' // Switch to uploaded view to see the new asset
loadAssets()
} catch (e) {
console.error('Failed to upload asset', e)
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to upload asset', life: 3000 })
} finally {
loading.value = false
target.value = '' // Reset input
}
}
}
const openModal = (asset: Asset) => { const openModal = (asset: Asset) => {
selectedAsset.value = asset selectedAsset.value = asset
@@ -114,92 +141,114 @@ const formatDate = (dateString: string) => {
<template> <template>
<div class="flex flex-col h-full p-8 overflow-y-auto w-full text-slate-100"> <div class="flex flex-col h-full p-8 overflow-y-auto w-full text-slate-100">
<!-- Main Content (Sidebar removed) --> <!-- Main Content (Sidebar removed) -->
<div class="flex-1 flex flex-col"> <div class="flex-1 flex flex-col overflow-hidden">
<!-- Top Bar --> <!-- Top Bar -->
<header class="flex justify-between items-end mb-8"> <header class="flex justify-between items-center mb-4 px-1">
<div> <div>
<h1 class="text-4xl font-bold m-0">Assets Library</h1> <h1
<p class="mt-2 mb-0 text-slate-400">Manage all your assets</p> class="text-xl font-bold bg-gradient-to-r from-white to-slate-400 bg-clip-text text-transparent m-0">
Assets Library</h1>
<p class="text-xs text-slate-500 mt-1">Manage all your assets</p>
</div> </div>
<div class="glass-panel p-2 flex gap-2 rounded-xl"> <div class="flex items-center gap-2">
<Button v-for="filter in ['all', 'image']" :key="filter" <input type="file" ref="fileInput" @change="handleFileUpload" class="hidden" accept="image/*" />
<Button label="Upload" icon="pi pi-upload" @click="triggerFileUpload"
class="!bg-violet-600 !border-none hover:!bg-violet-500 !text-xs !px-3 !py-1.5 !rounded-lg !font-medium shadow-lg shadow-violet-500/20" />
<div class="glass-panel p-1 flex gap-1 rounded-xl bg-slate-800/50 border border-white/5">
<Button v-for="filter in ['all', 'generated', 'uploaded']" :key="filter"
:label="filter.charAt(0).toUpperCase() + filter.slice(1)" :label="filter.charAt(0).toUpperCase() + filter.slice(1)"
:class="activeFilter === filter ? 'bg-white/10 text-slate-50' : 'bg-transparent text-slate-400'" :class="activeFilter === filter ? 'bg-white/10 text-slate-50 shadow-sm' : 'bg-transparent text-slate-400 hover:text-slate-200'"
class="px-4 py-2 rounded-lg font-medium transition-all duration-300 hover:text-slate-50" text class="px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200" text
@click="handleFilterChange(filter)" /> @click="handleFilterChange(filter)" />
</div> </div>
</div>
</header> </header>
<!-- Loading State --> <!-- Loading State -->
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 pb-8"> <div v-if="loading"
<div v-for="i in 8" :key="i" class="glass-panel rounded-2xl overflow-hidden"> class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-6 gap-2 md:gap-1 pb-4 overflow-y-auto custom-scrollbar">
<Skeleton height="180px" /> <Skeleton v-for="i in 12" :key="i" class="aspect-[9/16] !bg-slate-800 rounded-none sm:rounded-md" />
<div class="p-5">
<Skeleton class="mb-2" />
<Skeleton width="60%" />
</div>
</div>
</div> </div>
<!-- Assets Grid --> <!-- Assets Grid -->
<div v-else class="flex-1 flex flex-col"> <div v-else class="flex-1 overflow-y-auto custom-scrollbar pb-20">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-6 pb-8"> <div v-if="paginatedAssets.length > 0"
<div v-for="asset in paginatedAssets" :key="asset.id" @click="openModal(asset)" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-6 gap-2 md:gap-1">
class="glass-panel rounded-2xl overflow-hidden transition-all duration-300 cursor-pointer border border-white/5 hover:-translate-y-1 hover:border-white/20 hover:shadow-2xl"> <div v-for="asset in paginatedAssets" :key="asset.id"
<!-- Media Preview --> class="aspect-[9/16] relative group overflow-hidden bg-slate-800 transition-all duration-300 rounded-none sm:rounded-md border border-white/5 hover:border-white/20"
<div class="h-70 bg-black/30 relative overflow-hidden"> @click="openModal(asset)">
<!-- Image -->
<img :src="(API_URL + asset.url + '?thumbnail=true') || 'https://via.placeholder.com/300'" <img :src="(API_URL + asset.url + '?thumbnail=true') || 'https://via.placeholder.com/300'"
:alt="asset.name" :alt="asset.name"
class="w-full h-full object-cover transition-transform duration-500 hover:scale-105" /> class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" />
<div
class="absolute top-2.5 right-2.5 bg-black/60 backdrop-blur-sm px-3 py-1 rounded-full text-xs uppercase font-semibold text-white z-10"> <!-- Type Badge (Optional, maybe too cluttered for 9:16 tiles, but kept for now) -->
<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">
{{ asset.type }} {{ asset.type }}
</div> </div>
<div @click.stop="confirmDelete(asset)"
class="absolute top-2.5 left-2.5 w-8 h-8 rounded-full bg-black/60 backdrop-blur-sm flex items-center justify-center cursor-pointer hover:bg-red-500/80 transition-all z-10 group/delete"> <!-- Hover Overlay -->
<i <div
class="pi pi-trash text-white text-xs group-hover/delete:scale-110 transition-transform"></i> class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex flex-col justify-between p-3">
<!-- Top Actions -->
<div
class="flex justify-between items-start translate-y-[-10px] group-hover:translate-y-0 transition-transform duration-200">
<Button icon="pi pi-trash" v-tooltip.right="'Delete'"
class="!w-7 !h-7 !rounded-full !bg-red-500/20 !border-none !text-red-400 text-xs hover:!bg-red-500 hover:!text-white transition-colors"
@click.stop="confirmDelete(asset)" />
<span v-if="asset.linked_char_id"
class="text-[10px] bg-emerald-500/20 text-emerald-400 px-2 py-1 rounded-full border border-emerald-500/20"
v-tooltip.left="'Linked to Character'">
Linked
</span>
</div>
<!-- Center View Button -->
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<Button icon="pi pi-eye" rounded text
class="!bg-black/40 !text-white !w-10 !h-10 !rounded-full opacity-0 group-hover:opacity-100 hover:!bg-black/60 hover:!scale-110 transition-all duration-300 pointer-events-auto !border border-white/20"
@click.stop="openModal(asset)" />
</div>
<!-- Bottom Info -->
<div class="translate-y-[10px] group-hover:translate-y-0 transition-transform duration-200">
<p class="text-xs font-medium text-white line-clamp-1 mb-0.5">{{ asset.name }}</p>
<p class="text-[10px] text-slate-400">{{ formatDate(asset.created_at) }}</p>
</div>
</div>
</div> </div>
</div> </div>
<!-- Asset Info --> <div v-else class="flex flex-col items-center justify-center h-64 text-slate-500">
<div class="p-5"> <i class="pi pi-images text-4xl mb-3 opacity-50"></i>
<div class="flex justify-between items-start mb-2"> <p>No assets found</p>
<h3 <Button v-if="activeFilter !== 'all'" label="Clear Filters" text class="mt-2 !text-violet-400"
class="m-0 text-base font-semibold whitespace-nowrap overflow-hidden text-ellipsis flex-1 mr-2"> @click="handleFilterChange('all')" />
{{ asset.name }}
</h3>
</div>
<div class="flex justify-between items-center text-xs text-slate-400">
<span>{{ formatDate(asset.created_at) }}</span>
<span v-if="asset.linked_char_id"
class="bg-emerald-500/10 text-emerald-400 px-2 py-0.5 rounded">
🔗 Linked
</span>
</div>
</div>
</div>
</div> </div>
<!-- Pagination --> <!-- Pagination -->
<div v-if="totalRecords > rows" class="mt-auto py-6"> <div v-if="totalRecords > rows" class="mt-6 flex justify-center">
<Paginator :first="first" :rows="rows" :totalRecords="totalRecords" @page="onPage" :template="{ <Paginator :first="first" :rows="rows" :totalRecords="totalRecords" @page="onPage" :template="{
default: 'FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink' default: 'FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink'
}" class="!bg-transparent !border-none !p-0" :pt="{ }" class="!bg-transparent !border-none !p-0" :pt="{
root: { class: '!bg-transparent' }, root: { class: '!bg-transparent' },
pcPageButton: { pages: { class: 'gap-1' },
root: ({ context }) => ({ pageButton: ({ context }) => ({
class: [ class: [
'!min-w-[40px] !h-10 !rounded-xl !border-none !transition-all !duration-300 !font-bold', '!min-w-[32px] !h-8 !rounded-lg !border-none !transition-all !duration-200 !text-xs',
context.active ? '!bg-violet-600 !text-white !shadow-lg' : '!bg-white/5 !text-slate-400 hover:!bg-white/10 hover:!text-slate-50' context.active ? '!bg-violet-600 !text-white !shadow-lg !shadow-violet-500/30' : '!bg-white/5 !text-slate-400 hover:!bg-white/10 hover:!text-slate-200'
] ]
}) }),
}, firstPageButton: { root: { class: '!bg-white/5 !text-slate-400 !border-none !rounded-lg !min-w-[32px] !h-8 hover:!bg-white/10 hover:!text-slate-200 transition-all !mr-1' } },
pcFirstPageButton: { root: { class: '!bg-white/5 !text-slate-400 !border-none !rounded-xl !min-w-[40px] !h-10 hover:!bg-white/10 hover:!text-slate-50 transition-all' } }, previousPageButton: { root: { class: '!bg-white/5 !text-slate-400 !border-none !rounded-lg !min-w-[32px] !h-8 hover:!bg-white/10 hover:!text-slate-200 transition-all !mr-2' } },
pcPreviousPageButton: { root: { class: '!bg-white/5 !text-slate-400 !border-none !rounded-xl !min-w-[40px] !h-10 hover:!bg-white/10 hover:!text-slate-50 transition-all' } }, nextPageButton: { root: { class: '!bg-white/5 !text-slate-400 !border-none !rounded-lg !min-w-[32px] !h-8 hover:!bg-white/10 hover:!text-slate-200 transition-all !ml-2' } },
pcNextPageButton: { root: { class: '!bg-white/5 !text-slate-400 !border-none !rounded-xl !min-w-[40px] !h-10 hover:!bg-white/10 hover:!text-slate-50 transition-all' } }, lastPageButton: { root: { class: '!bg-white/5 !text-slate-400 !border-none !rounded-lg !min-w-[32px] !h-8 hover:!bg-white/10 hover:!text-slate-200 transition-all !ml-1' } }
pcLastPageButton: { root: { class: '!bg-white/5 !text-slate-400 !border-none !rounded-xl !min-w-[40px] !h-10 hover:!bg-white/10 hover:!text-slate-50 transition-all' } }
}" /> }" />
</div> </div>
</div> </div>

View File

@@ -52,11 +52,7 @@ const historyRows = ref(50)
const historyFirst = ref(0) const historyFirst = ref(0)
const isSettingsVisible = ref(false) const isSettingsVisible = ref(false)
const isGenerating = ref(false) const isSubmitting = ref(false)
const generationStatus = ref('')
const generationProgress = ref(0)
const generationError = ref(null)
const generatedResult = ref(null) // For immediate feedback if needed
const activeOverlayId = ref(null) // For mobile tap-to-show overlay const activeOverlayId = ref(null) // For mobile tap-to-show overlay
// Options // Options
@@ -148,6 +144,32 @@ const loadData = async () => {
if (historyRes && historyRes.generations) { if (historyRes && historyRes.generations) {
historyGenerations.value = historyRes.generations historyGenerations.value = historyRes.generations
historyTotal.value = historyRes.total_count || 0 historyTotal.value = historyRes.total_count || 0
// Resume polling for unfinished generations
// Resume polling for unfinished generations
const threeMinutesAgo = Date.now() - 3 * 60 * 1000
historyGenerations.value.forEach(gen => {
const status = gen.status ? gen.status.toLowerCase() : ''
const isActive = ['processing', 'starting', 'running'].includes(status)
let isRecent = true
if (gen.created_at) {
// Force UTC if missing timezone info (simple heuristic)
let timeStr = gen.created_at
if (timeStr.indexOf('Z') === -1 && timeStr.indexOf('+') === -1) {
timeStr += 'Z'
}
const createdTime = new Date(timeStr).getTime()
if (!isNaN(createdTime)) {
isRecent = createdTime > threeMinutesAgo
}
}
if (isActive && isRecent) {
pollGeneration(gen.id)
}
})
} else { } else {
historyGenerations.value = Array.isArray(historyRes) ? historyRes : [] historyGenerations.value = Array.isArray(historyRes) ? historyRes : []
historyTotal.value = historyGenerations.value.length historyTotal.value = historyGenerations.value.length
@@ -183,7 +205,12 @@ const refreshHistory = async () => {
const existingIndex = historyGenerations.value.findIndex(g => g.id === gen.id) const existingIndex = historyGenerations.value.findIndex(g => g.id === gen.id)
if (existingIndex !== -1) { if (existingIndex !== -1) {
// Update existing item in place to preserve state/reactivity // Update existing item in place to preserve state/reactivity
Object.assign(historyGenerations.value[existingIndex], gen) // Only update if not currently polling to avoid race conditions,
// or just update fields that might change
const existing = historyGenerations.value[existingIndex]
if (!['processing', 'starting', 'running'].includes(existing.status)) {
Object.assign(existing, gen)
}
} else { } else {
newGenerations.push(gen) newGenerations.push(gen)
} }
@@ -202,7 +229,6 @@ const refreshHistory = async () => {
} }
// --- Generation --- // --- Generation ---
const handleGenerate = async () => { const handleGenerate = async () => {
if (!prompt.value.trim()) return if (!prompt.value.trim()) return
@@ -212,10 +238,7 @@ const handleGenerate = async () => {
return return
} }
isGenerating.value = true isSubmitting.value = true
generationError.value = null
generationStatus.value = 'starting'
generationProgress.value = 0
// Close settings to show gallery/progress (optional preference) // Close settings to show gallery/progress (optional preference)
// isSettingsVisible.value = false // isSettingsVisible.value = false
@@ -234,51 +257,123 @@ const handleGenerate = async () => {
const response = await aiService.runGeneration(payload) const response = await aiService.runGeneration(payload)
if (response && response.id) { if (response && response.id) {
pollStatus(response.id) // Create optimistic generation items
} else { // If response is the full generation object, use it.
// Immediate result // If it's just { id: '...' }, create a placeholder.
isGenerating.value = false // aiService.runGeneration returns response.data.
loadHistory() // Refresh gallery
const newGen = {
id: response.id,
prompt: prompt.value,
status: 'starting',
created_at: new Date().toISOString(),
// Add other fields as necessary for display
}
// Add to history immediately
historyGenerations.value.unshift(newGen)
historyTotal.value++
// Start polling
pollGeneration(response.id)
// Clear prompt if desired, or keep for reuse
// prompt.value = ''
} }
} catch (e) { } catch (e) {
console.error('Generation failed', e) console.error('Generation failed', e)
generationError.value = e.message || 'Generation failed' // Ideally show a toast error here
isGenerating.value = false alert(e.message || 'Generation failed to start')
} finally {
isSubmitting.value = false
} }
} }
const pollStatus = async (id) => { const pollGeneration = async (id) => {
let completed = false let completed = false
while (!completed && isGenerating.value) { let attempts = 0
// Find the generation object in our list to update it specifically
const genIndex = historyGenerations.value.findIndex(g => g.id === id)
if (genIndex === -1) return // Should not happen if we just added it
const gen = historyGenerations.value[genIndex]
while (!completed) {
try { try {
const response = await aiService.getGenerationStatus(id) const response = await aiService.getGenerationStatus(id)
generationStatus.value = response.status
generationProgress.value = response.progress || 0
if (response.status === 'done') { // Update the object in the list
completed = true // We use Object.assign to keep the reactive reference valid
Object.assign(gen, response)
// Refresh history to show new item without resetting list if (response.status === 'done' || response.status === 'failed') {
await refreshHistory()
} else if (response.status === 'failed') {
completed = true completed = true
generationError.value = response.failed_reason || 'Generation failed'
throw new Error(generationError.value)
} else { } else {
// Exponential backoff or fixed interval
await new Promise(resolve => setTimeout(resolve, 2000)) await new Promise(resolve => setTimeout(resolve, 2000))
} }
} catch (e) { } catch (e) {
console.error('Polling failed', e) console.error(`Polling failed for ${id}`, e)
attempts++
if (attempts > 3) {
completed = true completed = true
isGenerating.value = false gen.status = 'failed'
gen.failed_reason = 'Polling connection lost'
}
await new Promise(resolve => setTimeout(resolve, 5000))
} }
} }
isGenerating.value = false }
// --- Infinite Scroll ---
const isHistoryLoading = ref(false)
const infiniteScrollTrigger = ref(null)
let observer = null
const setupInfiniteScroll = () => {
observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isHistoryLoading.value && historyGenerations.value.length < historyTotal.value) {
loadMoreHistory()
}
}, {
root: null,
rootMargin: '100px',
threshold: 0.1
})
if (infiniteScrollTrigger.value) {
observer.observe(infiniteScrollTrigger.value)
}
}
const loadMoreHistory = async () => {
if (isHistoryLoading.value) return
isHistoryLoading.value = true
try {
const nextOffset = historyGenerations.value.length
const response = await aiService.getGenerations(historyRows.value, nextOffset)
if (response && response.generations) {
const newGenerations = response.generations.filter(gen =>
!historyGenerations.value.some(existing => existing.id === gen.id)
)
historyGenerations.value.push(...newGenerations)
historyTotal.value = response.total_count || historyTotal.value
}
} catch (e) {
console.error('Failed to load more history', e)
} finally {
isHistoryLoading.value = false
}
} }
// --- Initial Load --- // --- Initial Load ---
onMounted(() => { onMounted(() => {
loadData() loadData().then(() => {
// slight delay to allow DOM render
setTimeout(setupInfiniteScroll, 500)
})
isSettingsVisible.value = true isSettingsVisible.value = true
}) })
@@ -444,6 +539,34 @@ watch(assetPickerTab, () => {
} }
}) })
// --- Asset Upload in Picker ---
const assetPickerFileInput = ref(null)
const triggerAssetPickerUpload = () => {
assetPickerFileInput.value?.click()
}
const handleAssetPickerUpload = async (event) => {
const target = event.target
if (target.files && target.files[0]) {
const file = target.files[0]
try {
isModalLoading.value = true
await dataService.uploadAsset(file)
// Switch tab to 'uploaded' and reload
assetPickerTab.value = 'uploaded'
loadModalAssets()
} catch (e) {
console.error('Failed to upload asset', e)
} finally {
isModalLoading.value = false
target.value = ''
}
}
}
// --- Album Picker Logic --- // --- Album Picker Logic ---
const openAlbumPicker = (gen) => { const openAlbumPicker = (gen) => {
generationToAdd.value = gen generationToAdd.value = gen
@@ -475,6 +598,7 @@ const confirmAddToAlbum = async () => {
<h1 <h1
class="text-xl font-bold bg-gradient-to-r from-white to-slate-400 bg-clip-text text-transparent m-0"> class="text-xl font-bold bg-gradient-to-r from-white to-slate-400 bg-clip-text text-transparent m-0">
Gallery</h1> Gallery</h1>
<span class="text-xs text-slate-500 border-l border-white/10 pl-3">History</span> <span class="text-xs text-slate-500 border-l border-white/10 pl-3">History</span>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -507,10 +631,24 @@ const confirmAddToAlbum = async () => {
v-tooltip.top="gen.failed_reason">{{ gen.failed_reason }}</span> v-tooltip.top="gen.failed_reason">{{ gen.failed_reason }}</span>
</div> </div>
<div v-else-if="gen.status === 'processing' || gen.status === 'starting'" <div v-else-if="['processing', 'starting', 'running'].includes(gen.status)"
class="w-full h-full flex flex-col items-center justify-center bg-slate-800/50 border border-violet-500/20"> class="w-full h-full flex flex-col items-center justify-center relative overflow-hidden bg-slate-800/50 border border-violet-500/20 group">
<i class="pi pi-spin pi-spinner text-violet-500 text-xl mb-2"></i> <!-- Shimmer Background -->
<span class="text-[10px] text-violet-300/70">Creating...</span> <div
class="absolute inset-0 bg-gradient-to-tr from-violet-500/5 via-violet-500/10 to-cyan-500/5 animate-pulse">
</div>
<!-- Moving Highlight -->
<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">{{ gen.status
}}...</span>
<span v-if="gen.progress"
class="text-[9px] text-violet-400/60 font-mono mt-1 relative z-10">{{
gen.progress }}%</span>
</div> </div>
<div v-else class="w-full h-full flex items-center justify-center text-slate-600 bg-slate-800"> <div v-else class="w-full h-full flex items-center justify-center text-slate-600 bg-slate-800">
@@ -526,6 +664,14 @@ const confirmAddToAlbum = async () => {
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" 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(gen)" /> @click.stop="deleteGeneration(gen)" />
<div class="flex flex-col items-center gap-0.5 pointer-events-none">
<span class="text-[10px] font-bold text-slate-300 font-mono tracking-wider">{{
gen.cost }} $</span>
<span v-if="gen.execution_time_seconds"
class="text-[8px] text-slate-500 font-mono">{{
gen.execution_time_seconds.toFixed(1) }}s</span>
</div>
<div class="flex gap-1"> <div class="flex gap-1">
<Button v-if="gen.result_list && gen.result_list.length > 0" icon="pi pi-pencil" <Button v-if="gen.result_list && gen.result_list.length > 0" icon="pi pi-pencil"
v-tooltip.left="'Edit (Use Result)'" v-tooltip.left="'Edit (Use Result)'"
@@ -561,11 +707,20 @@ const confirmAddToAlbum = async () => {
</div> </div>
</div> </div>
<div v-else-if="!isGenerating" <div v-else-if="!isSubmitting && historyGenerations.length === 0"
class="flex flex-col items-center justify-center h-full text-slate-600 opacity-50"> class="flex flex-col items-center justify-center h-full text-slate-600 opacity-50">
<i class="pi pi-images text-6xl mb-4"></i> <i class="pi pi-images text-6xl mb-4"></i>
<p class="text-xl">Your creations will appear here</p> <p class="text-xl">Your creations will appear here</p>
</div> </div>
<!-- Infinite Scroll Sentinel & Loading Indicator -->
<div ref="infiniteScrollTrigger" class="w-full py-4 flex justify-center items-center min-h-[50px]">
<i v-if="isHistoryLoading" class="pi pi-spin pi-spinner text-violet-500 text-2xl"></i>
<span v-else-if="historyGenerations.length > 0 && historyGenerations.length >= historyTotal"
class="text-slate-600 text-xs">
All items loaded
</span>
</div>
</div> </div>
<div v-if="isSettingsVisible" <div v-if="isSettingsVisible"
@@ -692,20 +847,10 @@ const confirmAddToAlbum = async () => {
</div> </div>
<div class="mt-auto"> <div class="mt-auto">
<Button :label="isGenerating ? 'Generating...' : 'Generate'" <Button :label="isSubmitting ? 'Starting...' : 'Generate'"
:icon="isGenerating ? 'pi pi-spin pi-spinner' : 'pi pi-sparkles'" :icon="isSubmitting ? 'pi pi-spin pi-spinner' : 'pi pi-sparkles'"
:loading="isGenerating" @click="handleGenerate" :loading="isSubmitting" @click="handleGenerate"
class="w-full !py-3 !text-base !font-bold !bg-gradient-to-r from-violet-600 to-cyan-500 !border-none !rounded-xl !shadow-lg !shadow-violet-500/20 hover:!scale-[1.02] transition-all" /> class="w-full !py-3 !text-base !font-bold !bg-gradient-to-r from-violet-600 to-cyan-500 !border-none !rounded-xl !shadow-lg !shadow-violet-500/20 hover:!scale-[1.02] transition-all" />
<div v-if="isGenerating" class="mt-2 text-center">
<ProgressBar :value="generationProgress" class="h-1 bg-slate-700"
:pt="{ value: { class: '!bg-violet-500' } }" :showValue="false" />
<span class="text-[10px] text-slate-500">{{ generationStatus }}</span>
</div>
<div v-if="generationError"
class="mt-2 text-center text-xs text-red-400 bg-red-500/10 p-2 rounded border border-red-500/20">
{{ generationError }}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -740,12 +885,19 @@ const confirmAddToAlbum = async () => {
: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' } }"> :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' } }">
<div class="flex flex-col h-[70vh]"> <div class="flex flex-col h-[70vh]">
<!-- Tabs --> <!-- Tabs -->
<div class="flex border-b border-white/5 px-4"> <div class="flex border-b border-white/5 px-4 items-center">
<button v-for="tab in ['all', 'uploaded', 'generated']" :key="tab" @click="assetPickerTab = tab" <button v-for="tab in ['all', 'uploaded', 'generated']" :key="tab" @click="assetPickerTab = tab"
class="px-4 py-3 text-sm font-medium border-b-2 transition-colors capitalize" class="px-4 py-3 text-sm font-medium border-b-2 transition-colors capitalize"
:class="assetPickerTab === tab ? 'border-violet-500 text-violet-400' : 'border-transparent text-slate-400 hover:text-slate-200'"> :class="assetPickerTab === tab ? 'border-violet-500 text-violet-400' : 'border-transparent text-slate-400 hover:text-slate-200'">
{{ tab }} {{ tab }}
</button> </button>
<!-- Upload Action -->
<div class="ml-auto flex items-center">
<input type="file" ref="assetPickerFileInput" @change="handleAssetPickerUpload" class="hidden"
accept="image/*" />
<Button icon="pi pi-upload" label="Upload" @click="triggerAssetPickerUpload"
class="!text-xs !bg-violet-600/20 !text-violet-300 hover:!bg-violet-600/40 !border-none !px-3 !py-1.5 !rounded-lg" />
</div>
</div> </div>
<!-- Grid --> <!-- Grid -->
@@ -798,9 +950,8 @@ const confirmAddToAlbum = async () => {
<div v-else-if="albumStore.albums.length === 0" class="text-center text-slate-400"> <div v-else-if="albumStore.albums.length === 0" class="text-center text-slate-400">
No albums found. Create one first! No albums found. Create one first!
</div> </div>
<Dropdown v-else v-model="selectedAlbumForAdd" :options="albumStore.albums" optionLabel="name" placeholder="Select an Album" <Dropdown v-else v-model="selectedAlbumForAdd" :options="albumStore.albums" optionLabel="name"
class="w-full !bg-slate-800 !border-white/10 !text-white" placeholder="Select an Album" class="w-full !bg-slate-800 !border-white/10 !text-white" :pt="{
:pt="{
root: { class: '!bg-slate-800' }, root: { class: '!bg-slate-800' },
input: { class: '!text-white' }, input: { class: '!text-white' },
trigger: { class: '!text-slate-400' }, trigger: { class: '!text-slate-400' },
@@ -810,7 +961,8 @@ const confirmAddToAlbum = async () => {
</div> </div>
<template #footer> <template #footer>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<Button label="Cancel" @click="isAlbumPickerVisible = false" class="!text-slate-300 hover:!bg-white/5" text /> <Button label="Cancel" @click="isAlbumPickerVisible = false"
class="!text-slate-300 hover:!bg-white/5" text />
<Button label="Add" @click="confirmAddToAlbum" :disabled="!selectedAlbumForAdd" <Button label="Add" @click="confirmAddToAlbum" :disabled="!selectedAlbumForAdd"
class="!bg-violet-600 !border-none hover:!bg-violet-500" /> class="!bg-violet-600 !border-none hover:!bg-violet-500" />
</div> </div>