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:
@@ -52,11 +52,7 @@ const historyRows = ref(50)
|
||||
const historyFirst = ref(0)
|
||||
|
||||
const isSettingsVisible = ref(false)
|
||||
const isGenerating = ref(false)
|
||||
const generationStatus = ref('')
|
||||
const generationProgress = ref(0)
|
||||
const generationError = ref(null)
|
||||
const generatedResult = ref(null) // For immediate feedback if needed
|
||||
const isSubmitting = ref(false)
|
||||
const activeOverlayId = ref(null) // For mobile tap-to-show overlay
|
||||
|
||||
// Options
|
||||
@@ -148,6 +144,32 @@ const loadData = async () => {
|
||||
if (historyRes && historyRes.generations) {
|
||||
historyGenerations.value = historyRes.generations
|
||||
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 {
|
||||
historyGenerations.value = Array.isArray(historyRes) ? historyRes : []
|
||||
historyTotal.value = historyGenerations.value.length
|
||||
@@ -183,7 +205,12 @@ const refreshHistory = async () => {
|
||||
const existingIndex = historyGenerations.value.findIndex(g => g.id === gen.id)
|
||||
if (existingIndex !== -1) {
|
||||
// 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 {
|
||||
newGenerations.push(gen)
|
||||
}
|
||||
@@ -202,7 +229,6 @@ const refreshHistory = async () => {
|
||||
}
|
||||
|
||||
|
||||
|
||||
// --- Generation ---
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.value.trim()) return
|
||||
@@ -212,10 +238,7 @@ const handleGenerate = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
isGenerating.value = true
|
||||
generationError.value = null
|
||||
generationStatus.value = 'starting'
|
||||
generationProgress.value = 0
|
||||
isSubmitting.value = true
|
||||
|
||||
// Close settings to show gallery/progress (optional preference)
|
||||
// isSettingsVisible.value = false
|
||||
@@ -234,51 +257,123 @@ const handleGenerate = async () => {
|
||||
const response = await aiService.runGeneration(payload)
|
||||
|
||||
if (response && response.id) {
|
||||
pollStatus(response.id)
|
||||
} else {
|
||||
// Immediate result
|
||||
isGenerating.value = false
|
||||
loadHistory() // Refresh gallery
|
||||
// Create optimistic generation items
|
||||
// If response is the full generation object, use it.
|
||||
// If it's just { id: '...' }, create a placeholder.
|
||||
// aiService.runGeneration returns response.data.
|
||||
|
||||
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) {
|
||||
console.error('Generation failed', e)
|
||||
generationError.value = e.message || 'Generation failed'
|
||||
isGenerating.value = false
|
||||
// Ideally show a toast error here
|
||||
alert(e.message || 'Generation failed to start')
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const pollStatus = async (id) => {
|
||||
const pollGeneration = async (id) => {
|
||||
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 {
|
||||
const response = await aiService.getGenerationStatus(id)
|
||||
generationStatus.value = response.status
|
||||
generationProgress.value = response.progress || 0
|
||||
|
||||
if (response.status === 'done') {
|
||||
completed = true
|
||||
// Update the object in the list
|
||||
// We use Object.assign to keep the reactive reference valid
|
||||
Object.assign(gen, response)
|
||||
|
||||
// Refresh history to show new item without resetting list
|
||||
await refreshHistory()
|
||||
} else if (response.status === 'failed') {
|
||||
if (response.status === 'done' || response.status === 'failed') {
|
||||
completed = true
|
||||
generationError.value = response.failed_reason || 'Generation failed'
|
||||
throw new Error(generationError.value)
|
||||
} else {
|
||||
// Exponential backoff or fixed interval
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Polling failed', e)
|
||||
completed = true
|
||||
isGenerating.value = false
|
||||
console.error(`Polling failed for ${id}`, e)
|
||||
attempts++
|
||||
if (attempts > 3) {
|
||||
completed = true
|
||||
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 ---
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
loadData().then(() => {
|
||||
// slight delay to allow DOM render
|
||||
setTimeout(setupInfiniteScroll, 500)
|
||||
})
|
||||
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 ---
|
||||
const openAlbumPicker = (gen) => {
|
||||
generationToAdd.value = gen
|
||||
@@ -475,6 +598,7 @@ const confirmAddToAlbum = async () => {
|
||||
<h1
|
||||
class="text-xl 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>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -507,10 +631,24 @@ const confirmAddToAlbum = async () => {
|
||||
v-tooltip.top="gen.failed_reason">{{ gen.failed_reason }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="gen.status === 'processing' || gen.status === 'starting'"
|
||||
class="w-full h-full flex flex-col items-center justify-center bg-slate-800/50 border border-violet-500/20">
|
||||
<i class="pi pi-spin pi-spinner text-violet-500 text-xl mb-2"></i>
|
||||
<span class="text-[10px] text-violet-300/70">Creating...</span>
|
||||
<div v-else-if="['processing', 'starting', 'running'].includes(gen.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 group">
|
||||
<!-- Shimmer Background -->
|
||||
<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 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"
|
||||
@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">
|
||||
<Button v-if="gen.result_list && gen.result_list.length > 0" icon="pi pi-pencil"
|
||||
v-tooltip.left="'Edit (Use Result)'"
|
||||
@@ -561,11 +707,20 @@ const confirmAddToAlbum = async () => {
|
||||
</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">
|
||||
<i class="pi pi-images text-6xl mb-4"></i>
|
||||
<p class="text-xl">Your creations will appear here</p>
|
||||
</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 v-if="isSettingsVisible"
|
||||
@@ -692,20 +847,10 @@ const confirmAddToAlbum = async () => {
|
||||
</div>
|
||||
|
||||
<div class="mt-auto">
|
||||
<Button :label="isGenerating ? 'Generating...' : 'Generate'"
|
||||
:icon="isGenerating ? 'pi pi-spin pi-spinner' : 'pi pi-sparkles'"
|
||||
:loading="isGenerating" @click="handleGenerate"
|
||||
<Button :label="isSubmitting ? 'Starting...' : 'Generate'"
|
||||
:icon="isSubmitting ? 'pi pi-spin pi-spinner' : 'pi pi-sparkles'"
|
||||
: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" />
|
||||
|
||||
<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>
|
||||
@@ -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' } }">
|
||||
<div class="flex flex-col h-[70vh]">
|
||||
<!-- 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"
|
||||
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'">
|
||||
{{ tab }}
|
||||
</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>
|
||||
|
||||
<!-- Grid -->
|
||||
@@ -798,9 +950,8 @@ const confirmAddToAlbum = async () => {
|
||||
<div v-else-if="albumStore.albums.length === 0" class="text-center text-slate-400">
|
||||
No albums found. Create one first!
|
||||
</div>
|
||||
<Dropdown v-else v-model="selectedAlbumForAdd" :options="albumStore.albums" optionLabel="name" placeholder="Select an Album"
|
||||
class="w-full !bg-slate-800 !border-white/10 !text-white"
|
||||
:pt="{
|
||||
<Dropdown v-else v-model="selectedAlbumForAdd" :options="albumStore.albums" optionLabel="name"
|
||||
placeholder="Select an Album" class="w-full !bg-slate-800 !border-white/10 !text-white" :pt="{
|
||||
root: { class: '!bg-slate-800' },
|
||||
input: { class: '!text-white' },
|
||||
trigger: { class: '!text-slate-400' },
|
||||
@@ -810,7 +961,8 @@ const confirmAddToAlbum = async () => {
|
||||
</div>
|
||||
<template #footer>
|
||||
<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"
|
||||
class="!bg-violet-600 !border-none hover:!bg-violet-500" />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user