Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f042042
feat: Add Media Assets sidebar tab for file management
viva-jinyi Oct 17, 2025
c20ea26
refactor: Apply PR #6112 review feedback for Media Assets feature
viva-jinyi Oct 20, 2025
fffdae1
chore: unexpected export
viva-jinyi Oct 20, 2025
8765a10
feat: Improve media asset display with file format tags and filename …
viva-jinyi Oct 20, 2025
52b2129
feat: Add includePublic parameter to getAssetsByTag API
viva-jinyi Oct 22, 2025
aa3354a
fix: test code
viva-jinyi Oct 22, 2025
cddd8ea
refactor: useQueueStore
viva-jinyi Oct 22, 2025
ea7e910
refactor: Apply review feedback for media assets implementation
viva-jinyi Oct 23, 2025
02a1810
Extract AssetsSidebarTab template and improve UI structure (#6164)
viva-jinyi Oct 23, 2025
5c01e61
feat: Implement centralized AssetsStore for reactive assets updates
viva-jinyi Oct 24, 2025
38885b7
refactor: Apply formatUtil code review feedback and improve type safety
viva-jinyi Oct 24, 2025
fd953c6
[automated] Update test expectations
invalid-email-address Oct 24, 2025
4e2fc4a
feat: Auto-refresh assets on file upload
viva-jinyi Oct 24, 2025
9d28ec8
fix: Add AssetsStore update trigger to WidgetSelectDropdown uploads
viva-jinyi Oct 24, 2025
cb33c8f
refactor:
viva-jinyi Oct 27, 2025
9125459
fix: Prevent gallery index shift when new outputs are generated
viva-jinyi Oct 27, 2025
993f08f
refactor: delete unused export
viva-jinyi Oct 27, 2025
e0a0d9f
refactor: Simplify asset ID handling and remove UUID extraction, Acce…
viva-jinyi Oct 27, 2025
b778cde
feat: implement asset deletion functionality (#6203)
viva-jinyi Oct 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Extract AssetsSidebarTab template and improve UI structure (#6164)
## Summary
- Extract sidebar template into reusable AssetSidebarTemplate component
- Replace PrimeVue Tabs with TextButton for better visual consistency  
- Improve job detail view header layout with better spacing

## Changes
- Created `AssetSidebarTemplate.vue` as a reusable template component
- Replaced PrimeVue Tabs with TextButton components for tab navigation
- Added i18n translation key for "Back to all assets" button
- Improved spacing and layout in job detail view header
- Maintained all existing functionality while cleaning up template
structure

## Test Plan
- [ ] Verify tab switching between Imported and Generated tabs works
correctly
- [ ] Test job detail view displays properly with Job ID and execution
time
- [ ] Confirm "Back to all assets" button returns to main view
- [ ] Check that all existing media asset features remain functional
- [ ] Verify UI consistency with other sidebar tabs


[screen-capture.webm](https://github.com/user-attachments/assets/4ed192e1-a9f7-4fc1-a41e-f732741dd55d)
  • Loading branch information
viva-jinyi committed Oct 29, 2025
commit 02a1810c80e8f2e62e4b7edd33fbca4a940206d6
33 changes: 33 additions & 0 deletions src/components/sidebar/tabs/AssetSidebarTemplate.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<template>
<div
class="flex h-full flex-col bg-interface-panel-surface"
:class="props.class"
>
<div>
<div
v-if="slots.top"
class="flex min-h-12 items-center border-b border-interface-stroke px-4 py-2"
>
<slot name="top" />
</div>
<div v-if="slots.header" class="px-4">
<slot name="header" />
</div>
</div>
<!-- h-0 to force scrollpanel to grow -->
<ScrollPanel class="h-0 grow">
<slot name="body" />
</ScrollPanel>
</div>
</template>

<script setup lang="ts">
import ScrollPanel from 'primevue/scrollpanel'
import { useSlots } from 'vue'

const props = defineProps<{
class?: string
}>()

const slots = useSlots()
</script>
173 changes: 156 additions & 17 deletions src/components/sidebar/tabs/AssetsSidebarTab.vue
Original file line number Diff line number Diff line change
@@ -1,16 +1,48 @@
<template>
<SidebarTabTemplate :title="$t('sideToolbar.mediaAssets')">
<AssetsSidebarTemplate>
<template #top>
<span v-if="!isInFolderView" class="font-bold">
{{ $t('sideToolbar.mediaAssets') }}
</span>
<div v-else class="flex w-full items-center justify-between gap-2">
<div class="flex items-center gap-2">
<span class="font-bold">{{ $t('Job ID') }}:</span>
<span class="text-sm">{{ folderPromptId?.substring(0, 8) }}</span>
<button
class="m-0 cursor-pointer border-0 bg-transparent p-0 outline-0"
role="button"
@click="copyJobId"
>
<i class="mb-1 icon-[lucide--copy] text-sm"></i>
</button>
</div>
<div>
<span>{{ formattedExecutionTime }}</span>
</div>
</div>
</template>
<template #header>
<Tabs v-model:value="activeTab" class="w-full">
<TabList class="border-b border-neutral-300">
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
<Tab value="output">{{ $t('sideToolbar.labels.generated') }}</Tab>
</TabList>
</Tabs>
<!-- Job Detail View Header -->
<div v-if="isInFolderView" class="pt-4 pb-2">
<IconTextButton
:label="$t('sideToolbar.backToAssets')"
type="secondary"
@click="exitFolderView"
>
<template #icon>
<i class="icon-[lucide--arrow-left] size-4" />
</template>
</IconTextButton>
</div>
<!-- Normal Tab View -->
<TabList v-else v-model="activeTab" class="pt-4 pb-1">
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
<Tab value="output">{{ $t('sideToolbar.labels.generated') }}</Tab>
</TabList>
</template>
<template #body>
<VirtualGrid
v-if="mediaAssets.length"
v-if="displayAssets.length"
:items="mediaAssetsWithKey"
:grid-style="{
display: 'grid',
Expand All @@ -23,8 +55,11 @@
<MediaAssetCard
:asset="item"
:selected="selectedAsset?.id === item.id"
:show-output-count="shouldShowOutputCount(item)"
:output-count="getOutputCount(item)"
@click="handleAssetSelect(item)"
@zoom="handleZoomClick(item)"
@output-count-click="enterFolderView(item)"
/>
</template>
</VirtualGrid>
Expand All @@ -45,7 +80,7 @@
/>
</div>
</template>
</SidebarTabTemplate>
</AssetsSidebarTemplate>
<ResultGallery
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
Expand All @@ -54,23 +89,49 @@

<script setup lang="ts">
import ProgressSpinner from 'primevue/progressspinner'
import Tab from 'primevue/tab'
import TabList from 'primevue/tablist'
import Tabs from 'primevue/tabs'
import { useToast } from 'primevue/usetoast'
import { computed, ref, watch } from 'vue'

import IconTextButton from '@/components/button/IconTextButton.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import { t } from '@/i18n'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import { useMediaAssets } from '@/platform/assets/composables/useMediaAssets'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { ResultItemImpl } from '@/stores/queueStore'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'

import AssetsSidebarTemplate from './AssetSidebarTemplate.vue'

const activeTab = ref<'input' | 'output'>('input')
const selectedAsset = ref<AssetItem | null>(null)
const folderPromptId = ref<string | null>(null)
const folderExecutionTime = ref<number | undefined>(undefined)
const isInFolderView = computed(() => folderPromptId.value !== null)

const getOutputCount = (item: AssetItem): number => {
const count = item.user_metadata?.outputCount
return typeof count === 'number' && count > 0 ? count : 0
}

const shouldShowOutputCount = (item: AssetItem): boolean => {
if (activeTab.value !== 'output' || isInFolderView.value) {
return false
}
return getOutputCount(item) > 1
}

const formattedExecutionTime = computed(() => {
if (!folderExecutionTime.value) return ''
return formatDuration(folderExecutionTime.value * 1000)
})

const toast = useToast()

const inputAssets = useMediaAssets('input')
const outputAssets = useMediaAssets('output')
Expand All @@ -84,7 +145,9 @@ const mediaAssets = computed(() => currentAssets.value.media.value)

const galleryActiveIndex = ref(-1)
const galleryItems = computed(() => {
return mediaAssets.value.map((asset) => {
// Convert AssetItems to ResultItemImpl format for gallery
// Use displayAssets instead of mediaAssets to show correct items based on view mode
return displayAssets.value.map((asset) => {
const mediaType = getMediaTypeFromFilename(asset.name)
const resultItem = new ResultItemImpl({
filename: asset.name,
Expand All @@ -105,9 +168,23 @@ const galleryItems = computed(() => {
})
})

// Store folder view assets separately
const folderAssets = ref<AssetItem[]>([])

// Get display assets based on view mode
const displayAssets = computed(() => {
if (isInFolderView.value) {
// Show all assets from the folder view
return folderAssets.value
}

// Normal view: show grouped assets (already have outputCount from API)
return mediaAssets.value
})

// Add key property for VirtualGrid
const mediaAssetsWithKey = computed(() => {
return mediaAssets.value.map((asset) => ({
return displayAssets.value.map((asset) => ({
...asset,
key: asset.id
}))
Expand Down Expand Up @@ -138,9 +215,71 @@ const handleAssetSelect = (asset: AssetItem) => {
}

const handleZoomClick = (asset: AssetItem) => {
const index = mediaAssets.value.findIndex((a) => a.id === asset.id)
// Find the index of the clicked asset
const index = displayAssets.value.findIndex((a) => a.id === asset.id)
if (index !== -1) {
galleryActiveIndex.value = index
}
}

const enterFolderView = (asset: AssetItem) => {
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (!metadata) {
console.warn('Invalid output asset metadata')
return
}

const { promptId, allOutputs, executionTimeInSeconds } = metadata

if (!promptId || !Array.isArray(allOutputs) || allOutputs.length === 0) {
console.warn('Missing required folder view data')
return
}

folderPromptId.value = promptId
folderExecutionTime.value = executionTimeInSeconds

folderAssets.value = allOutputs.map((output) => ({
id: `${promptId}-${output.nodeId}-${output.filename}`,
name: output.filename,
size: 0,
created_at: asset.created_at,
tags: ['output'],
preview_url: output.url,
user_metadata: {
promptId,
nodeId: output.nodeId,
subfolder: output.subfolder,
executionTimeInSeconds,
workflow: metadata.workflow
}
}))
}

const exitFolderView = () => {
folderPromptId.value = null
folderExecutionTime.value = undefined
folderAssets.value = []
}

const copyJobId = async () => {
if (folderPromptId.value) {
try {
await navigator.clipboard.writeText(folderPromptId.value)
toast.add({
severity: 'success',
summary: t('mediaAsset.jobIdToast.copied'),
detail: t('mediaAsset.jobIdToast.jobIdCopied'),
life: 2000
})
} catch (error) {
toast.add({
severity: 'error',
summary: t('mediaAsset.jobIdToast.error'),
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed'),
life: 3000
})
}
}
}
</script>
48 changes: 48 additions & 0 deletions src/components/tab/Tab.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<template>
<button
:id="tabId"
:class="tabClasses"
role="tab"
:aria-selected="isActive"
:aria-controls="panelId"
:tabindex="0"
@click="handleClick"
>
<slot />
</button>
</template>

<script setup lang="ts">
import type { Ref } from 'vue'
import { computed, inject } from 'vue'

import { cn } from '@/utils/tailwindUtil'

const { value, panelId } = defineProps<{
value: string
panelId?: string
}>()

const currentValue = inject<Ref<string>>('tabs-value')
const updateValue = inject<(value: string) => void>('tabs-update')

const tabId = computed(() => `tab-${value}`)
const isActive = computed(() => currentValue?.value === value)

const tabClasses = computed(() => {
return cn(
// Base styles from TextButton
'flex items-center justify-center shrink-0',
'px-2.5 py-2 text-sm rounded-lg cursor-pointer transition-all duration-200',
'outline-hidden border-none',
// State styles with semantic tokens
isActive.value
? 'bg-interface-menu-component-surface-hovered text-text-primary text-bold'
: 'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface'
)
})

const handleClick = () => {
updateValue?.(value)
}
</script>
Loading