diff --git a/src/components/sidebar/tabs/AssetSidebarTemplate.vue b/src/components/sidebar/tabs/AssetSidebarTemplate.vue new file mode 100644 index 0000000000..d387994253 --- /dev/null +++ b/src/components/sidebar/tabs/AssetSidebarTemplate.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/components/sidebar/tabs/AssetsSidebarTab.vue b/src/components/sidebar/tabs/AssetsSidebarTab.vue index 85c54651b7..2c5f1004d9 100644 --- a/src/components/sidebar/tabs/AssetsSidebarTab.vue +++ b/src/components/sidebar/tabs/AssetsSidebarTab.vue @@ -1,16 +1,48 @@ - + 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(null) +const folderPromptId = ref(null) +const folderExecutionTime = ref(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') @@ -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, @@ -105,9 +168,23 @@ const galleryItems = computed(() => { }) }) +// Store folder view assets separately +const folderAssets = ref([]) + +// 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 })) @@ -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 + }) + } + } +} diff --git a/src/components/tab/Tab.vue b/src/components/tab/Tab.vue new file mode 100644 index 0000000000..ecdc24e2a4 --- /dev/null +++ b/src/components/tab/Tab.vue @@ -0,0 +1,48 @@ + + + diff --git a/src/components/tab/TabList.stories.ts b/src/components/tab/TabList.stories.ts new file mode 100644 index 0000000000..96ae14cc4c --- /dev/null +++ b/src/components/tab/TabList.stories.ts @@ -0,0 +1,153 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { ref } from 'vue' + +import Tab from './Tab.vue' +import TabList from './TabList.vue' + +const meta: Meta = { + title: 'Components/Tab/TabList', + component: TabList, + tags: ['autodocs'], + argTypes: { + modelValue: { + control: 'text', + description: 'The currently selected tab value' + }, + 'onUpdate:modelValue': { action: 'update:modelValue' } + } +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: (args) => ({ + components: { TabList, Tab }, + setup() { + const activeTab = ref(args.modelValue || 'tab1') + return { activeTab } + }, + template: ` + + Tab 1 + Tab 2 + Tab 3 + +
+ Selected tab: {{ activeTab }} +
+ ` + }), + args: { + modelValue: 'tab1' + } +} + +export const ManyTabs: Story = { + render: () => ({ + components: { TabList, Tab }, + setup() { + const activeTab = ref('tab1') + return { activeTab } + }, + template: ` + + Dashboard + Analytics + Reports + Settings + Profile + +
+ Selected tab: {{ activeTab }} +
+ ` + }) +} + +export const WithIcons: Story = { + render: () => ({ + components: { TabList, Tab }, + setup() { + const activeTab = ref('home') + return { activeTab } + }, + template: ` + + + + Home + + + + Users + + + + Settings + + +
+ Selected tab: {{ activeTab }} +
+ ` + }) +} + +export const LongLabels: Story = { + render: () => ({ + components: { TabList, Tab }, + setup() { + const activeTab = ref('overview') + return { activeTab } + }, + template: ` + + Project Overview + Documentation & Guides + Deployment Settings + Monitoring & Analytics + +
+ Selected tab: {{ activeTab }} +
+ ` + }) +} + +export const Interactive: Story = { + render: () => ({ + components: { TabList, Tab }, + setup() { + const activeTab = ref('input') + const handleTabChange = (value: string) => { + console.log('Tab changed to:', value) + } + return { activeTab, handleTabChange } + }, + template: ` +
+
+

Example: Media Assets

+ + Imported + Generated + +
+ +
+
+

Showing imported assets...

+
+
+

Showing generated assets...

+
+
+ +
+ Current tab value: {{ activeTab }} +
+
+ ` + }) +} diff --git a/src/components/tab/TabList.vue b/src/components/tab/TabList.vue new file mode 100644 index 0000000000..5db691d96e --- /dev/null +++ b/src/components/tab/TabList.vue @@ -0,0 +1,21 @@ + + + diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 03b322de4a..728715b972 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -597,6 +597,7 @@ "templates": "Templates", "assets": "Assets", "mediaAssets": "Media Assets", + "backToAssets": "Back to all assets", "labels": { "queue": "Queue", "nodes": "Nodes", @@ -2072,6 +2073,14 @@ "loadingAsset": "Loading asset" } }, + "mediaAsset": { + "jobIdToast": { + "jobIdCopied": "Job ID copied to clipboard", + "jobIdCopyFailed": "Failed to copy Job ID", + "copied": "Copied", + "error": "Error" + } + }, "actionbar": { "dockToTop": "Dock to top" }, diff --git a/src/platform/assets/components/MediaAssetActions.vue b/src/platform/assets/components/MediaAssetActions.vue index 73144d4d22..74e29fb237 100644 --- a/src/platform/assets/components/MediaAssetActions.vue +++ b/src/platform/assets/components/MediaAssetActions.vue @@ -3,7 +3,7 @@ - + diff --git a/src/platform/assets/components/MediaAssetCard.vue b/src/platform/assets/components/MediaAssetCard.vue index 3b20567c9a..b2b1ea87c5 100644 --- a/src/platform/assets/components/MediaAssetCard.vue +++ b/src/platform/assets/components/MediaAssetCard.vue @@ -39,7 +39,7 @@ :asset="adaptedAsset" :context="{ type: assetType }" @view="handleZoomClick" - @download="actions.downloadAsset(asset.id)" + @download="actions.downloadAsset()" @play="actions.playAsset(asset.id)" @video-playing-state-changed="isVideoPlaying = $event" @video-controls-changed="showVideoControls = $event" @@ -51,6 +51,7 @@