Compare commits

..

1 Commits

Author SHA1 Message Date
Terry Jia
74f610165c fix mask editor icon missing 2025-10-28 14:11:54 -04:00
144 changed files with 1239 additions and 3286 deletions

View File

@@ -1,157 +0,0 @@
name: "Weekly Documentation Check"
description: "Automated weekly documentation accuracy check and update via Claude"
permissions:
contents: write
pull-requests: write
id-token: write
on:
schedule:
# Run every Monday at 9 AM UTC
- cron: '0 9 * * 1'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs:
docs-check:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0
ref: main
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies for analysis tools
run: |
# Check if packages are already available locally
if ! pnpm list typescript @vue/compiler-sfc >/dev/null 2>&1; then
echo "Installing TypeScript and Vue compiler globally..."
pnpm install -g typescript @vue/compiler-sfc
else
echo "TypeScript and Vue compiler already available locally"
fi
- name: Run Claude Documentation Review
uses: anthropics/claude-code-action@v1.0.6
with:
prompt: |
Is all documentation still 100% accurate?
INSTRUCTIONS:
1. Fact-check all documentation against the current codebase
2. Look for:
- Outdated API references
- Deprecated functions or components still documented
- Missing documentation for new features
- Incorrect code examples
- Broken internal references
- Configuration examples that no longer work
- Documentation that contradicts actual implementation
3. Update any inaccurate or outdated documentation
4. Add documentation for significant undocumented features
5. Ensure all code examples are valid and tested against current code
Focus on these key areas:
- docs/**/*.md (all documentation files)
- CLAUDE.md (project guidelines)
- README.md files throughout the repository
- .claude/commands/*.md (Claude command documentation)
Make changes directly to the documentation files as needed.
DO NOT modify any source code files unless absolutely necessary for documentation accuracy.
After making all changes, create a comprehensive PR message summary:
1. Write a detailed PR body to /tmp/pr-body-${{ github.run_id }}.md in markdown format
2. Include:
- ## Summary section with bullet points of what was changed
- ## Changes Made section with details organized by category
- ## Review Notes section with any important context
3. Be specific about which files were updated and why
4. If no changes were needed, write a brief message stating documentation is up to date
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
claude_args: "--max-turns 256 --allowedTools 'Bash(git status),Bash(git diff),Bash(git log),Bash(pnpm:*),Bash(npm:*),Bash(node:*),Bash(tsc:*),Bash(echo:*),Read,Write,Edit,Glob,Grep'"
continue-on-error: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Check for changes
id: check_changes
run: |
if git diff --quiet && git diff --cached --quiet; then
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "No documentation changes needed"
else
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "Documentation changes detected"
fi
- name: Create default PR body if not generated
if: steps.check_changes.outputs.has_changes == 'true'
run: |
if [ ! -f /tmp/pr-body-${{ github.run_id }}.md ]; then
cat > /tmp/pr-body-${{ github.run_id }}.md <<'EOF'
## Automated Documentation Review
This PR contains documentation updates identified by the weekly automated review.
### Review Process
- Automated fact-checking against current codebase
- Verification of code examples and API references
- Detection of outdated or missing documentation
### What was checked
- All markdown documentation in `docs/`
- Project guidelines in `CLAUDE.md`
- README files throughout the repository
- Claude command documentation in `.claude/commands/`
**Note**: This is an automated PR. Please review all changes carefully before merging.
🤖 Generated by weekly documentation check workflow
EOF
fi
- name: Configure Git
if: steps.check_changes.outputs.has_changes == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Commit changes
if: steps.check_changes.outputs.has_changes == 'true'
run: |
git checkout -b docs/weekly-update-${{ github.run_id }}
git add .
git commit -m "docs: weekly documentation accuracy update"
git push origin docs/weekly-update-${{ github.run_id }}
- name: Create Pull Request
if: steps.check_changes.outputs.has_changes == 'true'
run: |
PR_BODY=$(cat /tmp/pr-body-${{ github.run_id }}.md)
gh pr create \
--title "docs: Weekly Documentation Update" \
--body "$PR_BODY" \
--label "documentation,automated" \
--draft \
--base main \
--head docs/weekly-update-${{ github.run_id }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1657,8 +1657,7 @@ export const comfyPageFixture = base.extend<{
'Comfy.userId': userId,
// Set tutorial completed to true to avoid loading the tutorial workflow.
'Comfy.TutorialCompleted': true,
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize,
'Comfy.VueNodes.AutoScaleLayout': false
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize
})
} catch (e) {
console.error(e)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.31.1",
"version": "1.31.0",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -9,18 +9,29 @@
@config '../../tailwind.config.ts';
@media (prefers-color-scheme: dark) {
:root {
--fg-color: #fff;
--bg-color: #202020;
--content-bg: #4e4e4e;
--content-fg: #fff;
--content-hover-bg: #222;
--content-hover-fg: #fff;
}
}
@theme {
--text-xxs: 0.625rem;
--text-xxs--line-height: calc(1 / 0.625);
--text-xxxs: 0.5625rem;
--text-xxxs--line-height: calc(1 / 0.5625);
/* Spacing */
--spacing-xs: 8px;
/* Font Families */
--font-inter: 'Inter', sans-serif;
/* Palette Colors */
--color-charcoal-100: #55565e;
--color-charcoal-100: #171718;
--color-charcoal-200: #494a50;
--color-charcoal-300: #3c3d42;
--color-charcoal-400: #313235;
@@ -31,45 +42,43 @@
--color-neutral-550: #636363;
--color-ash-300: #bbbbbb;
--color-ash-500: #828282;
--color-ash-800: #444444;
--color-stone-100: #828282;
--color-stone-200: #444444;
--color-stone-300: #bbbbbb;
--color-ivory-100: #fdfbfa;
--color-ivory-200: #faf9f5;
--color-ivory-300: #f0eee6;
--color-smoke-100: #f3f3f3;
--color-smoke-200: #e9e9e9;
--color-smoke-300: #e1e1e1;
--color-smoke-400: #d9d9d9;
--color-smoke-500: #c5c5c5;
--color-smoke-600: #b4b4b4;
--color-smoke-700: #a0a0a0;
--color-smoke-800: #8a8a8a;
--color-gray-100: #f3f3f3;
--color-gray-200: #e9e9e9;
--color-gray-300: #e1e1e1;
--color-gray-400: #d9d9d9;
--color-gray-500: #c5c5c5;
--color-gray-600: #b4b4b4;
--color-gray-700: #a0a0a0;
--color-gray-800: #8a8a8a;
--color-sand-100: #e1ded5;
--color-sand-200: #d6cfc2;
--color-sand-300: #888682;
--color-pure-black: #000000;
--color-pure-white: #ffffff;
--color-slate-100: #9c9eab;
--color-slate-200: #9fa2bd;
--color-slate-300: #5b5e7d;
--color-electric-400: #f0ff41;
--color-sapphire-700: #172dd7;
--color-brand-yellow: var(--color-electric-400);
--color-brand-blue: var(--color-sapphire-700);
--color-azure-400: #31b9f4;
--color-azure-600: #0b8ce9;
--color-jade-400: #47e469;
--color-jade-600: #00cd72;
--color-gold-400: #fcbf64;
--color-gold-600: #fd9903;
--color-brand-yellow: #f0ff41;
--color-brand-blue: #172dd7;
--color-blue-100: #0b8ce9;
--color-blue-200: #31b9f4;
--color-success-100: #00cd72;
--color-success-200: #47e469;
--color-warning-100: #fd9903;
--color-warning-200: #fcbf64;
--color-danger-100: #c02323;
--color-danger-200: #d62952;
@@ -81,24 +90,26 @@
--color-error: #962a2a;
--color-comfy-menu-secondary: var(--comfy-menu-secondary-bg);
--text-xxxs: 0.5625rem;
--text-xxxs--line-height: calc(1 / 0.5625);
--color-blue-selection: rgb(from var(--color-azure-600) r g b / 0.3);
--color-node-hover-100: rgb(from var(--color-charcoal-800) r g b/ 0.15);
--color-node-hover-200: rgb(from var(--color-charcoal-800) r g b/ 0.1);
--color-modal-tag: rgb(from var(--color-smoke-400) r g b/ 0.4);
--color-blue-selection: rgb(from var(--color-blue-100) r g b / 0.3);
--color-node-hover-100: rgb(from var(--color-charcoal-100) r g b/ 0.15);
--color-node-hover-200: rgb(from var(--color-charcoal-100) r g b/ 0.1);
--color-modal-tag: rgb(from var(--color-gray-400) r g b/ 0.4);
--color-alpha-charcoal-600-30: color-mix(
in srgb,
var(--color-charcoal-600) 30%,
transparent
);
--color-alpha-ash-500-20: color-mix(
--color-alpha-stone-100-20: color-mix(
in srgb,
var(--color-ash-500) 20%,
var(--color-stone-100) 20%,
transparent
);
--color-alpha-smoke-500-50: color-mix(
--color-alpha-gray-500-50: color-mix(
in srgb,
var(--color-smoke-500) 50%,
var(--color-gray-500) 50%,
transparent
);
@@ -134,8 +145,8 @@
--content-hover-bg: #adadad;
--content-hover-fg: #000;
--button-surface: var(--color-white);
--button-surface-contrast: var(--color-black);
--button-surface: var(--color-pure-white);
--button-surface-contrast: var(--color-pure-black);
/* Code styling colors for help menu*/
--code-text-color: rgb(0 122 255 / 1);
@@ -146,36 +157,31 @@
--accent-primary: var(--color-charcoal-700);
--backdrop: var(--color-white);
--button-hover-surface: var(--color-smoke-200);
--button-active-surface: var(--color-smoke-400);
--button-icon: var(--color-smoke-600);
--button-hover-surface: var(--color-gray-200);
--button-active-surface: var(--color-gray-400);
--button-icon: var(--color-gray-600);
--dialog-surface: var(--color-neutral-200);
--interface-menu-component-surface-hovered: var(--color-smoke-200);
--interface-menu-component-surface-selected: var(--color-smoke-400);
--interface-menu-keybind-surface-default: var(--color-smoke-500);
--interface-panel-surface: var(--color-white);
--interface-stroke: var(--color-smoke-300);
--nav-background: var(--color-white);
--node-border: var(--color-smoke-300);
--node-component-border: var(--color-smoke-400);
--node-component-disabled: var(--color-alpha-ash-500-20);
--interface-menu-component-surface-hovered: var(--color-gray-200);
--interface-menu-component-surface-selected: var(--color-gray-400);
--interface-menu-keybind-surface-default: var(--color-gray-500);
--interface-panel-surface: var(--color-pure-white);
--interface-stroke: var(--color-gray-300);
--nav-background: var(--color-pure-white);
--node-border: var(--color-gray-300);
--node-component-border: var(--color-gray-400);
--node-component-disabled: var(--color-alpha-stone-100-20);
--node-component-executing: var(--color-blue-500);
--node-component-header: var(--fg-color);
--node-component-header-icon: var(--color-ash-800);
--node-component-header-icon: var(--color-stone-200);
--node-component-header-surface: var(--color-white);
--node-component-outline: var(--color-black);
--node-component-ring: rgb(from var(--color-smoke-500) r g b / 50%);
--node-component-ring: rgb(from var(--color-gray-500) r g b / 50%);
--node-component-slot-dot-outline-opacity-mult: 1;
--node-component-slot-dot-outline-opacity: 5%;
--node-component-slot-dot-outline: var(--color-black);
--node-component-slot-text: var(--color-ash-800);
--node-component-surface-highlight: var(--color-ash-500);
--node-component-surface-hovered: var(--color-smoke-200);
--node-component-slot-text: var(--color-stone-200);
--node-component-surface-highlight: var(--color-stone-100);
--node-component-surface-hovered: var(--color-gray-200);
--node-component-surface-selected: var(--color-charcoal-200);
--node-component-surface: var(--color-white);
--node-component-tooltip: var(--color-charcoal-700);
@@ -187,53 +193,40 @@
);
--node-component-widget-skeleton-surface: var(--color-zinc-300);
--node-divider: var(--color-sand-100);
--node-icon-disabled: var(--color-alpha-smoke-500-50);
--node-stroke: var(--color-smoke-400);
--node-icon-disabled: var(--color-alpha-gray-500-50);
--node-stroke: var(--color-gray-400);
--node-stroke-selected: var(--color-accent-primary);
--node-stroke-error: var(--color-error);
--node-stroke-executing: var(--color-azure-600);
--text-secondary: var(--color-ash-500);
--node-stroke-executing: var(--color-blue-100);
--text-secondary: var(--color-stone-100);
--text-primary: var(--color-charcoal-700);
--input-surface: rgb(0 0 0 / 0.15);
}
.dark-theme {
--fg-color: #fff;
--bg-color: #202020;
--content-bg: #4e4e4e;
--content-fg: #fff;
--content-hover-bg: #222;
--content-hover-fg: #fff;
--accent-primary: var(--color-white);
--accent-primary: var(--color-pure-white);
--backdrop: var(--color-neutral-900);
--button-surface: var(--color-charcoal-600);
--button-surface-contrast: var(--color-white);
--button-surface-contrast: var(--color-pure-white);
--button-hover-surface: var(--color-charcoal-600);
--button-active-surface: var(--color-charcoal-600);
--button-icon: var(--color-smoke-800);
--button-icon: var(--color-gray-800);
--dialog-surface: var(--color-neutral-700);
--interface-menu-component-surface-hovered: var(--color-charcoal-400);
--interface-menu-component-surface-selected: var(--color-charcoal-300);
--interface-menu-keybind-surface-default: var(--color-charcoal-200);
--interface-panel-surface: var(--color-charcoal-800);
--interface-panel-surface: var(--color-charcoal-100);
--interface-stroke: var(--color-charcoal-400);
--nav-background: var(--color-charcoal-800);
--nav-background: var(--color-charcoal-100);
--node-border: var(--color-charcoal-500);
--node-component-border: var(--color-ash-800);
--node-component-border: var(--color-stone-200);
--node-component-border-error: var(--color-danger-100);
--node-component-border-executing: var(--color-blue-500);
--node-component-border-selected: var(--color-charcoal-200);
--node-component-header-icon: var(--color-slate-300);
--node-component-header-surface: var(--color-charcoal-800);
--node-component-outline: var(--color-white);
--node-component-ring: rgb(var(--color-smoke-500) / 20%);
--node-component-ring: rgb(var(--color-gray-500) / 20%);
--node-component-slot-dot-outline-opacity: 10%;
--node-component-slot-dot-outline: var(--color-white);
--node-component-slot-text: var(--color-slate-200);
@@ -247,15 +240,13 @@
--node-component-widget-skeleton-surface: var(--color-zinc-800);
--node-component-disabled: var(--color-alpha-charcoal-600-30);
--node-divider: var(--color-charcoal-500);
--node-icon-disabled: var(--color-alpha-ash-500-20);
--node-stroke: var(--color-ash-800);
--node-stroke-selected: var(--color-white);
--node-icon-disabled: var(--color-alpha-stone-100-20);
--node-stroke: var(--color-stone-200);
--node-stroke-selected: var(--color-pure-white);
--node-stroke-error: var(--color-error);
--node-stroke-executing: var(--color-azure-600);
--node-stroke-executing: var(--color-blue-100);
--text-secondary: var(--color-slate-100);
--text-primary: var(--color-white);
--text-primary: var(--color-pure-white);
--input-surface: rgb(130 130 130 / 0.1);
}
@@ -339,6 +330,7 @@
}
}
/* ===================== Scrollbar Utilities (Tailwind) =====================
Usage: Add `scrollbar-custom` class to scrollable containers.
The scrollbar styling adapts to light/dark theme automatically.

View File

@@ -1,5 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 9.99996L11.9427 7.94263C11.6926 7.69267 11.3536 7.55225 11 7.55225C10.6464 7.55225 10.3074 7.69267 10.0573 7.94263L9 9M8 14H12.6667C13.403 14 14 13.403 14 12.6667V3.33333C14 2.59695 13.403 2 12.6667 2H3.33333C2.59695 2 2 2.59695 2 3.33333V8" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.51377 12.671L4.77612 14.3921C4.67222 14.6346 4.32853 14.6346 4.22463 14.3921L3.48699 12.671C3.45664 12.6002 3.40022 12.5437 3.32942 12.5134L1.60825 11.7757C1.36581 11.6718 1.36581 11.3282 1.60825 11.2243L3.32942 10.4866C3.40022 10.4563 3.45664 10.3998 3.48699 10.329L4.22463 8.60787C4.32853 8.36544 4.67222 8.36544 4.77612 8.60787L5.51377 10.329C5.54411 10.3998 5.60053 10.4563 5.67134 10.4866L7.39251 11.2243C7.63494 11.3282 7.63494 11.6718 7.39251 11.7757L5.67134 12.5134C5.60053 12.5437 5.54411 12.6002 5.51377 12.671Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M5 5H5.0001" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -474,68 +474,3 @@ export function formatDuration(milliseconds: number): string {
return parts.join(' ')
}
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'] as const
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi'] as const
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb'] as const
const MEDIA_TYPES = ['image', 'video', 'audio', '3D'] as const
type MediaType = (typeof MEDIA_TYPES)[number]
// Type guard helper for checking array membership
type ImageExtension = (typeof IMAGE_EXTENSIONS)[number]
type VideoExtension = (typeof VIDEO_EXTENSIONS)[number]
type AudioExtension = (typeof AUDIO_EXTENSIONS)[number]
type ThreeDExtension = (typeof THREE_D_EXTENSIONS)[number]
/**
* Truncates a filename while preserving the extension
* @param filename The filename to truncate
* @param maxLength Maximum length for the filename without extension
* @returns Truncated filename with extension preserved
*/
export function truncateFilename(
filename: string,
maxLength: number = 20
): string {
if (!filename || filename.length <= maxLength) {
return filename
}
const lastDotIndex = filename.lastIndexOf('.')
const nameWithoutExt =
lastDotIndex > -1 ? filename.substring(0, lastDotIndex) : filename
const extension = lastDotIndex > -1 ? filename.substring(lastDotIndex) : ''
// If the name without extension is short enough, return as is
if (nameWithoutExt.length <= maxLength) {
return filename
}
// Calculate how to split the truncation
const halfLength = Math.floor((maxLength - 3) / 2) // -3 for '...'
const start = nameWithoutExt.substring(0, halfLength)
const end = nameWithoutExt.substring(nameWithoutExt.length - halfLength)
return `${start}...${end}${extension}`
}
/**
* Determines the media type from a filename's extension (singular form)
* @param filename The filename to analyze
* @returns The media type: 'image', 'video', 'audio', or '3D'
*/
export function getMediaTypeFromFilename(filename: string): MediaType {
if (!filename) return 'image'
const ext = filename.split('.').pop()?.toLowerCase()
if (!ext) return 'image'
// Type-safe array includes check using type assertion
if (IMAGE_EXTENSIONS.includes(ext as ImageExtension)) return 'image'
if (VIDEO_EXTENSIONS.includes(ext as VideoExtension)) return 'video'
if (AUDIO_EXTENSIONS.includes(ext as AudioExtension)) return 'audio'
if (THREE_D_EXTENSIONS.includes(ext as ThreeDExtension)) return '3D'
return 'image'
}

View File

@@ -14,7 +14,7 @@ import { cn } from '@comfyorg/tailwind-utils'
// Use with conditional classes (ternary)
<button
:class="cn('px-4 py-2', isActive ? 'bg-blue-500' : 'bg-smoke-500')"
:class="cn('px-4 py-2', isActive ? 'bg-blue-500' : 'bg-gray-500')"
/>
```

View File

@@ -54,7 +54,7 @@ const {
}>()
const topStyle = computed(() => {
const baseClasses = 'relative p-0 overflow-hidden'
const baseClasses = 'relative p-0'
const ratioClasses = {
square: 'aspect-square',

View File

@@ -3,14 +3,14 @@
<div class="flex items-center gap-2">
<div
class="preview-box flex h-16 w-16 items-center justify-center rounded border p-2"
:class="{ 'bg-smoke-100 dark-theme:bg-smoke-800': !modelValue }"
:class="{ 'bg-gray-100 dark-theme:bg-gray-800': !modelValue }"
>
<img
v-if="modelValue"
:src="modelValue"
class="max-h-full max-w-full object-contain"
/>
<i v-else class="pi pi-image text-xl text-smoke-400" />
<i v-else class="pi pi-image text-xl text-gray-400" />
</div>
<div class="flex flex-col gap-2">

View File

@@ -36,55 +36,53 @@
</template>
<template #contentFilter>
<div class="relative flex flex-wrap justify-between gap-2 px-6 pb-4">
<div class="flex flex-wrap gap-2">
<!-- Model Filter -->
<MultiSelect
v-model="selectedModelObjects"
v-model:search-query="modelSearchText"
class="w-[250px]"
:label="modelFilterLabel"
:options="modelOptions"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
>
<template #icon>
<i class="icon-[lucide--cpu]" />
</template>
</MultiSelect>
<div class="relative flex flex-wrap gap-2 px-6 pt-2 pb-4">
<!-- Model Filter -->
<MultiSelect
v-model="selectedModelObjects"
v-model:search-query="modelSearchText"
class="w-[250px]"
:label="modelFilterLabel"
:options="modelOptions"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
>
<template #icon>
<i class="icon-[lucide--cpu]" />
</template>
</MultiSelect>
<!-- Use Case Filter -->
<MultiSelect
v-model="selectedUseCaseObjects"
:label="useCaseFilterLabel"
:options="useCaseOptions"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
>
<template #icon>
<i class="icon-[lucide--target]" />
</template>
</MultiSelect>
<!-- Use Case Filter -->
<MultiSelect
v-model="selectedUseCaseObjects"
:label="useCaseFilterLabel"
:options="useCaseOptions"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
>
<template #icon>
<i class="icon-[lucide--target]" />
</template>
</MultiSelect>
<!-- License Filter -->
<MultiSelect
v-model="selectedLicenseObjects"
:label="licenseFilterLabel"
:options="licenseOptions"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
>
<template #icon>
<i class="icon-[lucide--file-text]" />
</template>
</MultiSelect>
</div>
<!-- License Filter -->
<MultiSelect
v-model="selectedLicenseObjects"
:label="licenseFilterLabel"
:options="licenseOptions"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
>
<template #icon>
<i class="icon-[lucide--file-text]" />
</template>
</MultiSelect>
<!-- Sort Options -->
<div>
<div class="absolute right-5">
<SingleSelect
v-model="sortBy"
:label="$t('templateWorkflows.sorting', 'Sort by')"

View File

@@ -50,7 +50,7 @@
<div class="font-semibold">
{{ data.params?.api_name || 'API' }}
</div>
<div class="text-sm text-smoke-400">
<div class="text-sm text-gray-400">
{{ $t('credits.model') }}: {{ data.params?.model || '-' }}
</div>
</div>

View File

@@ -78,7 +78,7 @@
<!-- Login Section -->
<div v-else class="flex flex-col gap-4">
<p class="text-smoke-600">
<p class="text-gray-600">
{{ $t('auth.login.title') }}
</p>

View File

@@ -148,6 +148,6 @@ watch(
</script>
<style>
.zoomInputContainer:focus-within {
border: 1px solid var(--color-white);
border: 1px solid var(--color-pure-white);
}
</style>

View File

@@ -7,10 +7,10 @@
}"
severity="secondary"
text
icon="pi pi-pencil"
icon-class="w-4 h-4"
@click="openMaskEditor"
>
<i-comfy:mask class="!h-4 !w-4" />
</Button>
/>
</template>
<script setup lang="ts">

View File

@@ -1,7 +1,7 @@
<template>
<div
v-if="option.type === 'divider'"
class="my-1 h-px bg-smoke-200 dark-theme:bg-zinc-700"
class="my-1 h-px bg-gray-200 dark-theme:bg-zinc-700"
/>
<div
v-else

View File

@@ -20,15 +20,15 @@
:key="subOption.label"
:class="
isColorSubmenu
? 'w-7 h-7 flex items-center justify-center hover:bg-smoke-100 dark-theme:hover:bg-zinc-700 rounded cursor-pointer'
: 'flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-smoke-100 dark-theme:hover:bg-zinc-700 rounded cursor-pointer'
? 'w-7 h-7 flex items-center justify-center hover:bg-gray-100 dark-theme:hover:bg-zinc-700 rounded cursor-pointer'
: 'flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-gray-100 dark-theme:hover:bg-zinc-700 rounded cursor-pointer'
"
:title="subOption.label"
@click="handleSubmenuClick(subOption)"
>
<div
v-if="subOption.color"
class="h-5 w-5 rounded-full border border-smoke-300 dark-theme:border-zinc-600"
class="h-5 w-5 rounded-full border border-gray-300 dark-theme:border-zinc-600"
:style="{ backgroundColor: subOption.color }"
/>
<template v-else-if="!subOption.color">

View File

@@ -1,5 +1,3 @@
<template>
<div
class="h-6 w-px self-center bg-smoke-300/10 dark-theme:bg-smoke-600/10"
/>
<div class="h-6 w-px self-center bg-gray-300/10 dark-theme:bg-gray-600/10" />
</template>

View File

@@ -13,7 +13,7 @@
>
<div class="mb-1 flex justify-end">
<div
class="max-w-[80%] rounded-xl bg-smoke-300 px-4 py-1 text-right dark-theme:bg-smoke-800"
class="max-w-[80%] rounded-xl bg-gray-300 px-4 py-1 text-right dark-theme:bg-gray-800"
>
<div class="text-[12px] break-words">{{ item.prompt }}</div>
</div>
@@ -26,7 +26,7 @@
"
text
rounded
class="h-4! w-4! p-1! text-smoke-400 transition hover:text-smoke-600 hover:dark-theme:text-smoke-200"
class="h-4! w-4! p-1! text-gray-400 transition hover:text-gray-600 hover:dark-theme:text-gray-200"
pt:icon:class="text-xs!"
:icon="editIndex === i ? 'pi pi-times' : 'pi pi-pencil'"
:aria-label="

View File

@@ -5,7 +5,7 @@
"
text
rounded
class="h-4! w-6! p-1! text-smoke-400 transition hover:text-smoke-600 hover:dark-theme:text-smoke-200"
class="h-4! w-6! p-1! text-gray-400 transition hover:text-gray-600 hover:dark-theme:text-gray-200"
pt:icon:class="text-xs!"
:icon="copied ? 'pi pi-check' : 'pi pi-copy'"
:aria-label="

View File

@@ -119,12 +119,12 @@ export const KeyboardNavigationDemo: Story = {
},
template: `
<div class="space-y-4 p-4">
<div class="bg-blue-50 dark-theme:bg-blue-900/20 border border-azure-400 dark-theme:border-blue-700 rounded-lg p-4">
<div class="bg-blue-50 dark-theme:bg-blue-900/20 border border-blue-200 dark-theme:border-blue-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-2">🎯 Keyboard Navigation Test</h3>
<p class="text-sm text-smoke-600 dark-theme:text-smoke-300 mb-4">
<p class="text-sm text-gray-600 dark-theme:text-gray-300 mb-4">
Use your keyboard to navigate this MultiSelect:
</p>
<ol class="text-sm text-smoke-600 list-decimal list-inside space-y-1">
<ol class="text-sm text-gray-600 list-decimal list-inside space-y-1">
<li><strong>Tab</strong> to focus the dropdown</li>
<li><strong>Enter/Space</strong> to open dropdown</li>
<li><strong>Arrow Up/Down</strong> to navigate options</li>
@@ -134,11 +134,11 @@ export const KeyboardNavigationDemo: Story = {
</div>
<div class="space-y-2">
<label class="block text-sm font-medium text-smoke-700">
<label class="block text-sm font-medium text-gray-700">
Select Frameworks (Keyboard Navigation Test)
</label>
<MultiSelect v-bind="args" class="w-80" />
<p class="text-xs text-smoke-500">
<p class="text-xs text-gray-500">
Selected: {{ selectedFrameworks.map(f => f.name).join(', ') || 'None' }}
</p>
</div>
@@ -186,10 +186,10 @@ export const ScreenReaderFriendly: Story = {
<div class="space-y-6 p-4">
<div class="bg-green-50 dark-theme:bg-green-900/20 border border-green-200 dark-theme:border-green-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-2">♿ Screen Reader Test</h3>
<p class="text-sm text-smoke-600 mb-2">
<p class="text-sm text-gray-600 mb-2">
These dropdowns have proper ARIA attributes and labels for screen readers:
</p>
<ul class="text-sm text-smoke-600 list-disc list-inside space-y-1">
<ul class="text-sm text-gray-600 list-disc list-inside space-y-1">
<li><code>role="combobox"</code> identifies as dropdown</li>
<li><code>aria-haspopup="listbox"</code> indicates popup type</li>
<li><code>aria-expanded</code> shows open/closed state</li>
@@ -200,7 +200,7 @@ export const ScreenReaderFriendly: Story = {
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="block text-sm font-medium text-smoke-700">
<label class="block text-sm font-medium text-gray-700">
Color Preferences
</label>
<MultiSelect
@@ -211,13 +211,13 @@ export const ScreenReaderFriendly: Story = {
:show-clear-button="true"
class="w-full"
/>
<p class="text-xs text-smoke-500" aria-live="polite">
<p class="text-xs text-gray-500" aria-live="polite">
{{ selectedColors.length }} color(s) selected
</p>
</div>
<div class="space-y-2">
<label class="block text-sm font-medium text-smoke-700">
<label class="block text-sm font-medium text-gray-700">
Size Preferences
</label>
<MultiSelect
@@ -228,7 +228,7 @@ export const ScreenReaderFriendly: Story = {
:show-search-box="true"
class="w-full"
/>
<p class="text-xs text-smoke-500" aria-live="polite">
<p class="text-xs text-gray-500" aria-live="polite">
{{ selectedSizes.length }} size(s) selected
</p>
</div>
@@ -259,25 +259,25 @@ export const FocusManagement: Story = {
<div class="space-y-4 p-4">
<div class="bg-purple-50 dark-theme:bg-purple-900/20 border border-purple-200 dark-theme:border-purple-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-2">🎯 Focus Management Test</h3>
<p class="text-sm text-smoke-600 dark-theme:text-smoke-300 mb-4">
<p class="text-sm text-gray-600 dark-theme:text-gray-300 mb-4">
Test focus behavior with multiple form elements:
</p>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-smoke-700 mb-1">
<label class="block text-sm font-medium text-gray-700 mb-1">
Before MultiSelect
</label>
<input
type="text"
placeholder="Previous field"
class="block w-64 px-3 py-2 border border-smoke-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
class="block w-64 px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-smoke-700 mb-1">
<label class="block text-sm font-medium text-gray-700 mb-1">
MultiSelect (Test Focus Ring)
</label>
<MultiSelect
@@ -290,13 +290,13 @@ export const FocusManagement: Story = {
</div>
<div>
<label class="block text-sm font-medium text-smoke-700 mb-1">
<label class="block text-sm font-medium text-gray-700 mb-1">
After MultiSelect
</label>
<input
type="text"
placeholder="Next field"
class="block w-64 px-3 py-2 border border-smoke-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
class="block w-64 px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
@@ -307,7 +307,7 @@ export const FocusManagement: Story = {
</button>
</div>
<div class="text-sm text-smoke-600 mt-4">
<div class="text-sm text-gray-600 mt-4">
<strong>Test:</strong> Tab through all elements and verify focus rings are visible and logical.
</div>
</div>
@@ -319,7 +319,7 @@ export const AccessibilityChecklist: Story = {
render: () => ({
template: `
<div class="max-w-4xl mx-auto p-6 space-y-6">
<div class="bg-gray-50 dark-theme:bg-zinc-800 border border-smoke-200 dark-theme:border-zinc-700 rounded-lg p-6">
<div class="bg-gray-50 dark-theme:bg-zinc-800 border border-gray-200 dark-theme:border-zinc-700 rounded-lg p-6">
<h2 class="text-2xl font-bold mb-4">♿ MultiSelect Accessibility Checklist</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -366,9 +366,9 @@ export const AccessibilityChecklist: Story = {
</div>
</div>
<div class="mt-6 p-4 bg-blue-50 dark-theme:bg-blue-900/20 border border-azure-400 dark-theme:border-blue-700 rounded-lg">
<div class="mt-6 p-4 bg-blue-50 dark-theme:bg-blue-900/20 border border-blue-200 dark-theme:border-blue-700 rounded-lg">
<h4 class="font-semibold mb-2">🎯 Quick Test</h4>
<p class="text-sm text-smoke-700 dark-theme:text-smoke-300">
<p class="text-sm text-gray-700 dark-theme:text-gray-300">
Close your eyes, use only the keyboard, and try to select multiple options from any dropdown above.
If you can successfully navigate and make selections, the accessibility implementation is working!
</p>

View File

@@ -62,7 +62,7 @@
<!-- Trigger value (keep text scale identical) -->
<template #value>
<span class="text-sm text-zinc-700 dark-theme:text-smoke-200">
<span class="text-sm text-zinc-700 dark-theme:text-gray-200">
{{ label }}
</span>
<span
@@ -242,7 +242,7 @@ const pt = computed(() => ({
)
},
listContainer: () => ({
style: { maxHeight: `min(${listMaxHeight}, 50vh)` },
style: { maxHeight: listMaxHeight },
class: 'scrollbar-custom'
}),
list: {

View File

@@ -101,12 +101,12 @@ export const KeyboardNavigationDemo: Story = {
},
template: `
<div class="space-y-6 p-4">
<div class="bg-blue-50 dark-theme:bg-blue-900/20 border border-azure-400 dark-theme:border-blue-700 rounded-lg p-4">
<div class="bg-blue-50 dark-theme:bg-blue-900/20 border border-blue-200 dark-theme:border-blue-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-2">🎯 Keyboard Navigation Test</h3>
<p class="text-sm text-smoke-600 dark-theme:text-smoke-300 mb-4">
<p class="text-sm text-gray-600 dark-theme:text-gray-300 mb-4">
Use your keyboard to navigate these SingleSelect dropdowns:
</p>
<ol class="text-sm text-smoke-600 dark-theme:text-smoke-300 list-decimal list-inside space-y-1">
<ol class="text-sm text-gray-600 dark-theme:text-gray-300 list-decimal list-inside space-y-1">
<li><strong>Tab</strong> to focus the dropdown</li>
<li><strong>Enter/Space</strong> to open dropdown</li>
<li><strong>Arrow Up/Down</strong> to navigate options</li>
@@ -117,7 +117,7 @@ export const KeyboardNavigationDemo: Story = {
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="block text-sm font-medium text-smoke-700 dark-theme:text-smoke-200">
<label class="block text-sm font-medium text-gray-700 dark-theme:text-gray-200">
Sort Order
</label>
<SingleSelect
@@ -126,13 +126,13 @@ export const KeyboardNavigationDemo: Story = {
label="Choose sort order"
class="w-full"
/>
<p class="text-xs text-smoke-500">
<p class="text-xs text-gray-500">
Selected: {{ selectedSort ? sortOptions.find(o => o.value === selectedSort)?.name : 'None' }}
</p>
</div>
<div class="space-y-2">
<label class="block text-sm font-medium text-smoke-700 dark-theme:text-smoke-200">
<label class="block text-sm font-medium text-gray-700 dark-theme:text-gray-200">
Task Priority (With Icon)
</label>
<SingleSelect
@@ -147,7 +147,7 @@ export const KeyboardNavigationDemo: Story = {
</svg>
</template>
</SingleSelect>
<p class="text-xs text-smoke-500">
<p class="text-xs text-gray-500">
Selected: {{ selectedPriority ? priorityOptions.find(o => o.value === selectedPriority)?.name : 'None' }}
</p>
</div>
@@ -191,10 +191,10 @@ export const ScreenReaderFriendly: Story = {
<div class="space-y-6 p-4">
<div class="bg-green-50 dark-theme:bg-green-900/20 border border-green-200 dark-theme:border-green-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-2">♿ Screen Reader Test</h3>
<p class="text-sm text-smoke-600 dark-theme:text-smoke-300 mb-2">
<p class="text-sm text-gray-600 dark-theme:text-gray-300 mb-2">
These dropdowns have proper ARIA attributes and labels for screen readers:
</p>
<ul class="text-sm text-smoke-600 dark-theme:text-smoke-300 list-disc list-inside space-y-1">
<ul class="text-sm text-gray-600 dark-theme:text-gray-300 list-disc list-inside space-y-1">
<li><code>role="combobox"</code> identifies as dropdown</li>
<li><code>aria-haspopup="listbox"</code> indicates popup type</li>
<li><code>aria-expanded</code> shows open/closed state</li>
@@ -205,7 +205,7 @@ export const ScreenReaderFriendly: Story = {
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="block text-sm font-medium text-smoke-700 dark-theme:text-smoke-200" id="language-label">
<label class="block text-sm font-medium text-gray-700 dark-theme:text-gray-200" id="language-label">
Preferred Language
</label>
<SingleSelect
@@ -215,13 +215,13 @@ export const ScreenReaderFriendly: Story = {
class="w-full"
aria-labelledby="language-label"
/>
<p class="text-xs text-smoke-500" aria-live="polite">
<p class="text-xs text-gray-500" aria-live="polite">
Current: {{ selectedLanguage ? languageOptions.find(o => o.value === selectedLanguage)?.name : 'None selected' }}
</p>
</div>
<div class="space-y-2">
<label class="block text-sm font-medium text-smoke-700 dark-theme:text-smoke-200" id="theme-label">
<label class="block text-sm font-medium text-gray-700 dark-theme:text-gray-200" id="theme-label">
Interface Theme
</label>
<SingleSelect
@@ -231,7 +231,7 @@ export const ScreenReaderFriendly: Story = {
class="w-full"
aria-labelledby="theme-label"
/>
<p class="text-xs text-smoke-500" aria-live="polite">
<p class="text-xs text-gray-500" aria-live="polite">
Current: {{ selectedTheme ? themeOptions.find(o => o.value === selectedTheme)?.name : 'No theme selected' }}
</p>
</div>
@@ -239,7 +239,7 @@ export const ScreenReaderFriendly: Story = {
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<h4 class="font-semibold mb-2">🎧 Screen Reader Testing Tips</h4>
<ul class="text-sm text-smoke-600 dark-theme:text-smoke-300 space-y-1">
<ul class="text-sm text-gray-600 dark-theme:text-gray-300 space-y-1">
<li>• Listen for role announcements when focusing</li>
<li>• Verify dropdown state changes are announced</li>
<li>• Check that selected values are spoken clearly</li>
@@ -299,7 +299,7 @@ export const FormIntegration: Story = {
<div class="max-w-2xl mx-auto p-6">
<div class="bg-purple-50 dark-theme:bg-purple-900/20 border border-purple-200 dark-theme:border-purple-700 rounded-lg p-4 mb-6">
<h3 class="text-lg font-semibold mb-2">📝 Form Integration Test</h3>
<p class="text-sm text-smoke-600 dark-theme:text-smoke-300">
<p class="text-sm text-gray-600 dark-theme:text-gray-300">
Test keyboard navigation through a complete form with SingleSelect components.
Tab order should be logical and all elements should be accessible.
</p>
@@ -307,19 +307,19 @@ export const FormIntegration: Story = {
<form @submit.prevent="handleSubmit" class="space-y-6">
<div>
<label class="block text-sm font-medium text-smoke-700 dark-theme:text-smoke-200 mb-1">
<label class="block text-sm font-medium text-gray-700 dark-theme:text-gray-200 mb-1">
Title *
</label>
<input
type="text"
required
placeholder="Enter a title"
class="block w-full px-3 py-2 border border-smoke-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
class="block w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-smoke-700 dark-theme:text-smoke-200 mb-1">
<label class="block text-sm font-medium text-gray-700 dark-theme:text-gray-200 mb-1">
Category *
</label>
<SingleSelect
@@ -332,7 +332,7 @@ export const FormIntegration: Story = {
</div>
<div>
<label class="block text-sm font-medium text-smoke-700 dark-theme:text-smoke-200 mb-1">
<label class="block text-sm font-medium text-gray-700 dark-theme:text-gray-200 mb-1">
Status
</label>
<SingleSelect
@@ -344,7 +344,7 @@ export const FormIntegration: Story = {
</div>
<div>
<label class="block text-sm font-medium text-smoke-700 dark-theme:text-smoke-200 mb-1">
<label class="block text-sm font-medium text-gray-700 dark-theme:text-gray-200 mb-1">
Assignee
</label>
<SingleSelect
@@ -356,13 +356,13 @@ export const FormIntegration: Story = {
</div>
<div>
<label class="block text-sm font-medium text-smoke-700 dark-theme:text-smoke-200 mb-1">
<label class="block text-sm font-medium text-gray-700 dark-theme:text-gray-200 mb-1">
Description
</label>
<textarea
rows="4"
placeholder="Enter description"
class="block w-full px-3 py-2 border border-smoke-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
class="block w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
@@ -375,16 +375,16 @@ export const FormIntegration: Story = {
</button>
<button
type="button"
class="px-4 py-2 bg-smoke-300 dark-theme:bg-smoke-600 text-smoke-700 dark-theme:text-smoke-200 rounded-md hover:bg-smoke-400 dark-theme:hover:bg-smoke-500 focus:ring-2 focus:ring-smoke-500 focus:ring-offset-2"
class="px-4 py-2 bg-gray-300 dark-theme:bg-gray-600 text-gray-700 dark-theme:text-gray-200 rounded-md hover:bg-gray-400 dark-theme:hover:bg-gray-500 focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
>
Cancel
</button>
</div>
</form>
<div class="mt-6 p-4 bg-gray-50 dark-theme:bg-zinc-800 border border-smoke-200 dark-theme:border-zinc-700 rounded-lg">
<div class="mt-6 p-4 bg-gray-50 dark-theme:bg-zinc-800 border border-gray-200 dark-theme:border-zinc-700 rounded-lg">
<h4 class="font-semibold mb-2">Current Form Data:</h4>
<pre class="text-xs text-smoke-600 dark-theme:text-smoke-300">{{ JSON.stringify(formData, null, 2) }}</pre>
<pre class="text-xs text-gray-600 dark-theme:text-gray-300">{{ JSON.stringify(formData, null, 2) }}</pre>
</div>
</div>
`
@@ -395,7 +395,7 @@ export const AccessibilityChecklist: Story = {
render: () => ({
template: `
<div class="max-w-4xl mx-auto p-6 space-y-6">
<div class="bg-gray-50 dark-theme:bg-zinc-800 border border-smoke-200 dark-theme:border-zinc-700 rounded-lg p-6">
<div class="bg-gray-50 dark-theme:bg-zinc-800 border border-gray-200 dark-theme:border-zinc-700 rounded-lg p-6">
<h2 class="text-2xl font-bold mb-4">♿ SingleSelect Accessibility Checklist</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -442,9 +442,9 @@ export const AccessibilityChecklist: Story = {
</div>
</div>
<div class="mt-6 p-4 bg-blue-50 dark-theme:bg-blue-900/20 border border-azure-400 dark-theme:border-blue-700 rounded-lg">
<div class="mt-6 p-4 bg-blue-50 dark-theme:bg-blue-900/20 border border-blue-200 dark-theme:border-blue-700 rounded-lg">
<h4 class="font-semibold mb-2">🎯 Quick Test</h4>
<p class="text-sm text-smoke-700 dark-theme:text-smoke-200">
<p class="text-sm text-gray-700 dark-theme:text-gray-200">
Close your eyes, use only the keyboard, and try to select different options from any dropdown above.
If you can successfully navigate and make selections, the accessibility implementation is working!
</p>
@@ -452,7 +452,7 @@ export const AccessibilityChecklist: Story = {
<div class="mt-4 p-4 bg-orange-50 border border-orange-200 rounded-lg">
<h4 class="font-semibold mb-2">⚡ Performance Note</h4>
<p class="text-sm text-smoke-700 dark-theme:text-smoke-200">
<p class="text-sm text-gray-700 dark-theme:text-gray-200">
These accessibility features are built into the component with minimal performance impact.
The ARIA attributes and keyboard handlers add less than 1KB to the bundle size.
</p>

View File

@@ -26,11 +26,11 @@
<slot name="icon" />
<span
v-if="slotProps.value !== null && slotProps.value !== undefined"
class="text-zinc-700 dark-theme:text-smoke-200"
class="text-zinc-700 dark-theme:text-gray-200"
>
{{ getLabel(slotProps.value) }}
</span>
<span v-else class="text-zinc-700 dark-theme:text-smoke-200">
<span v-else class="text-zinc-700 dark-theme:text-gray-200">
{{ label }}
</span>
</div>
@@ -158,7 +158,7 @@ const pt = computed(() => ({
)
},
listContainer: () => ({
style: `max-height: min(${listMaxHeight}, 50vh)`,
style: `max-height: ${listMaxHeight}`,
class: 'scrollbar-custom'
}),
list: {

View File

@@ -1,6 +1,6 @@
<template>
<div
class="pointer-events-auto absolute top-12 left-2 z-20 flex flex-col rounded-lg bg-smoke-700/30"
class="pointer-events-auto absolute top-12 left-2 z-20 flex flex-col rounded-lg bg-gray-700/30"
>
<div class="show-menu relative">
<Button class="p-button-rounded p-button-text" @click="toggleMenu">
@@ -16,7 +16,7 @@
v-for="category in availableCategories"
:key="category"
class="p-button-text flex w-full items-center justify-start"
:class="{ 'bg-smoke-600': activeCategory === category }"
:class="{ 'bg-gray-600': activeCategory === category }"
@click="selectCategory(category)"
>
<i :class="getCategoryIcon(category)" />
@@ -26,7 +26,7 @@
</div>
</div>
<div v-show="activeCategory" class="rounded-lg bg-smoke-700/30">
<div v-show="activeCategory" class="rounded-lg bg-gray-700/30">
<SceneControls
v-if="activeCategory === 'scene'"
ref="sceneControlsRef"

View File

@@ -1,5 +1,5 @@
<template>
<div class="relative rounded-lg bg-smoke-700/30">
<div class="relative rounded-lg bg-gray-700/30">
<div class="flex flex-col gap-2">
<Button
class="p-button-rounded p-button-text"

View File

@@ -1,5 +1,5 @@
<template>
<div class="relative rounded-lg bg-smoke-700/30">
<div class="relative rounded-lg bg-gray-700/30">
<div class="flex flex-col gap-2">
<Button class="p-button-rounded p-button-text" @click="openIn3DViewer">
<i

View File

@@ -1,33 +0,0 @@
<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>

View File

@@ -1,297 +0,0 @@
<template>
<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>
<!-- 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="displayAssets.length"
:items="mediaAssetsWithKey"
:grid-style="{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
padding: '0.5rem',
gap: '0.5rem'
}"
>
<template #item="{ item }">
<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)"
@asset-deleted="refreshAssets"
/>
</template>
</VirtualGrid>
<div v-else-if="loading">
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
</div>
<div v-else>
<NoResultsPlaceholder
icon="pi pi-info-circle"
:title="
$t(
activeTab === 'input'
? 'sideToolbar.noImportedFiles'
: 'sideToolbar.noGeneratedFiles'
)
"
:message="$t('sideToolbar.noFilesFoundMessage')"
/>
</div>
</template>
</AssetsSidebarTemplate>
<ResultGallery
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
</template>
<script setup lang="ts">
import ProgressSpinner from 'primevue/progressspinner'
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 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/media/useMediaAssets'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { ResultItemImpl } from '@/stores/queueStore'
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')
const currentAssets = computed(() =>
activeTab.value === 'input' ? inputAssets : outputAssets
)
const loading = computed(() => currentAssets.value.loading.value)
const error = computed(() => currentAssets.value.error.value)
const mediaAssets = computed(() => currentAssets.value.media.value)
const galleryActiveIndex = ref(-1)
const currentGalleryAssetId = ref<string | null>(null)
const folderAssets = ref<AssetItem[]>([])
const displayAssets = computed(() => {
if (isInFolderView.value) {
return folderAssets.value
}
return mediaAssets.value
})
watch(displayAssets, (newAssets) => {
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
const newIndex = newAssets.findIndex(
(asset) => asset.id === currentGalleryAssetId.value
)
if (newIndex !== -1) {
galleryActiveIndex.value = newIndex
}
}
})
watch(galleryActiveIndex, (index) => {
if (index === -1) {
currentGalleryAssetId.value = null
}
})
const galleryItems = computed(() => {
return displayAssets.value.map((asset) => {
const mediaType = getMediaTypeFromFilename(asset.name)
const resultItem = new ResultItemImpl({
filename: asset.name,
subfolder: '',
type: 'output',
nodeId: '0',
mediaType: mediaType === 'image' ? 'images' : mediaType
})
Object.defineProperty(resultItem, 'url', {
get() {
return asset.preview_url || ''
},
configurable: true
})
return resultItem
})
})
// Add key property for VirtualGrid
const mediaAssetsWithKey = computed(() => {
return displayAssets.value.map((asset) => ({
...asset,
key: asset.id
}))
})
const refreshAssets = async () => {
await currentAssets.value.fetchMediaList()
if (error.value) {
console.error('Failed to refresh assets:', error.value)
}
}
watch(
activeTab,
() => {
void refreshAssets()
},
{ immediate: true }
)
const handleAssetSelect = (asset: AssetItem) => {
if (selectedAsset.value?.id === asset.id) {
selectedAsset.value = null
} else {
selectedAsset.value = asset
}
}
const handleZoomClick = (asset: AssetItem) => {
currentGalleryAssetId.value = asset.id
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: `${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>

View File

@@ -1,48 +0,0 @@
<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>

View File

@@ -1,153 +0,0 @@
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<typeof TabList> = {
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<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { TabList, Tab },
setup() {
const activeTab = ref(args.modelValue || 'tab1')
return { activeTab }
},
template: `
<TabList v-model="activeTab">
<Tab value="tab1">Tab 1</Tab>
<Tab value="tab2">Tab 2</Tab>
<Tab value="tab3">Tab 3</Tab>
</TabList>
<div class="mt-4 p-4 border rounded">
Selected tab: {{ activeTab }}
</div>
`
}),
args: {
modelValue: 'tab1'
}
}
export const ManyTabs: Story = {
render: () => ({
components: { TabList, Tab },
setup() {
const activeTab = ref('tab1')
return { activeTab }
},
template: `
<TabList v-model="activeTab">
<Tab value="tab1">Dashboard</Tab>
<Tab value="tab2">Analytics</Tab>
<Tab value="tab3">Reports</Tab>
<Tab value="tab4">Settings</Tab>
<Tab value="tab5">Profile</Tab>
</TabList>
<div class="mt-4 p-4 border rounded">
Selected tab: {{ activeTab }}
</div>
`
})
}
export const WithIcons: Story = {
render: () => ({
components: { TabList, Tab },
setup() {
const activeTab = ref('home')
return { activeTab }
},
template: `
<TabList v-model="activeTab">
<Tab value="home">
<i class="pi pi-home mr-2"></i>
Home
</Tab>
<Tab value="users">
<i class="pi pi-users mr-2"></i>
Users
</Tab>
<Tab value="settings">
<i class="pi pi-cog mr-2"></i>
Settings
</Tab>
</TabList>
<div class="mt-4 p-4 border rounded">
Selected tab: {{ activeTab }}
</div>
`
})
}
export const LongLabels: Story = {
render: () => ({
components: { TabList, Tab },
setup() {
const activeTab = ref('overview')
return { activeTab }
},
template: `
<TabList v-model="activeTab">
<Tab value="overview">Project Overview</Tab>
<Tab value="documentation">Documentation & Guides</Tab>
<Tab value="deployment">Deployment Settings</Tab>
<Tab value="monitoring">Monitoring & Analytics</Tab>
</TabList>
<div class="mt-4 p-4 border rounded">
Selected tab: {{ activeTab }}
</div>
`
})
}
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: `
<div class="space-y-4">
<div>
<h3 class="text-sm font-semibold mb-2">Example: Media Assets</h3>
<TabList v-model="activeTab" @update:model-value="handleTabChange">
<Tab value="input">Imported</Tab>
<Tab value="output">Generated</Tab>
</TabList>
</div>
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded">
<div v-if="activeTab === 'input'">
<p>Showing imported assets...</p>
</div>
<div v-else-if="activeTab === 'output'">
<p>Showing generated assets...</p>
</div>
</div>
<div class="text-sm text-gray-600">
Current tab value: <code>{{ activeTab }}</code>
</div>
</div>
`
})
}

View File

@@ -1,17 +0,0 @@
<template>
<div role="tablist" class="flex w-full items-center gap-2 pb-1">
<slot />
</div>
</template>
<script setup lang="ts">
import { provide } from 'vue'
const modelValue = defineModel<string>({ required: true })
// Provide for child Tab components
provide('tabs-value', modelValue)
provide('tabs-update', (value: string) => {
modelValue.value = value
})
</script>

View File

@@ -69,22 +69,6 @@ vi.mock('@/services/dialogService', () => ({
}))
}))
// Mock the firebaseAuthStore
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
getAuthHeader: vi
.fn()
.mockResolvedValue({ Authorization: 'Bearer mock-token' })
}))
}))
// Mock the useSubscription composable
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: vi.fn(() => ({
isActiveSubscription: vi.fn().mockReturnValue(true)
}))
}))
// Mock UserAvatar component
vi.mock('@/components/common/UserAvatar.vue', () => ({
default: {

View File

@@ -67,11 +67,7 @@
</div>
<div class="flex items-center justify-between">
<UserCredit text-class="text-2xl" />
<Button
v-if="isActiveSubscription"
:label="$t('credits.topUp.topUp')"
@click="handleTopUp"
/>
<Button :label="$t('credits.topUp.topUp')" @click="handleTopUp" />
</div>
</div>
</div>
@@ -86,7 +82,6 @@ import UserAvatar from '@/components/common/UserAvatar.vue'
import UserCredit from '@/components/common/UserCredit.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useDialogService } from '@/services/dialogService'
const emit = defineEmits<{
@@ -97,7 +92,6 @@ const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useFirebaseAuthActions()
const dialogService = useDialogService()
const { isActiveSubscription } = useSubscription()
const handleOpenUserSettings = () => {
dialogService.showSettingsDialog('user')

View File

@@ -160,8 +160,8 @@ describe('TopbarBadge', () => {
'full'
)
expect(wrapper.find('.bg-gold-600').exists()).toBe(true)
expect(wrapper.find('.text-gold-600').exists()).toBe(true)
expect(wrapper.find('.bg-warning-100').exists()).toBe(true)
expect(wrapper.find('.text-warning-100').exists()).toBe(true)
})
it('uses default error icon for error variant', () => {

View File

@@ -162,7 +162,7 @@ const labelClasses = computed(() => {
case 'error':
return 'bg-danger-100 text-white'
case 'warning':
return 'bg-gold-600 text-black'
return 'bg-warning-100 text-black'
case 'info':
default:
return 'bg-white text-black'
@@ -174,10 +174,10 @@ const textClasses = computed(() => {
case 'error':
return 'text-danger-100'
case 'warning':
return 'text-gold-600'
return 'text-warning-100'
case 'info':
default:
return 'text-text-primary'
return 'text-slate-100'
}
})
@@ -205,7 +205,7 @@ const dotClasses = computed(() => {
case 'error':
return 'bg-danger-100'
case 'warning':
return 'bg-gold-600'
return 'bg-warning-100'
case 'info':
default:
return 'bg-slate-100'

View File

@@ -133,7 +133,7 @@ const toggleRightPanel = () => {
const layoutClasses = cn(
'base-widget-layout',
'rounded-2xl overflow-hidden relative',
'bg-gray-50 dark-theme:bg-smoke-800'
'bg-gray-50 dark-theme:bg-gray-800'
)
const rightPanelButtonClasses = computed(() => {
@@ -150,7 +150,7 @@ const closeButtonClasses = cn(
const mainContainerClasses = cn(
'flex-1 flex',
'bg-smoke-100 dark-theme:bg-neutral-900'
'bg-gray-100 dark-theme:bg-neutral-900'
)
const headerClasses = cn(

View File

@@ -3,8 +3,8 @@
class="flex cursor-pointer items-center gap-2 rounded-md px-4 py-3 text-sm transition-colors"
:class="
active
? 'bg-smoke-400 dark-theme:bg-charcoal-300 text-neutral'
: 'text-neutral hover:bg-smoke-100 dark-theme:hover:bg-charcoal-300'
? 'bg-gray-400 dark-theme:bg-charcoal-300 text-neutral'
: 'text-neutral hover:bg-gray-100 dark-theme:hover:bg-charcoal-300'
"
role="button"
@click="onClick"

View File

@@ -9,7 +9,6 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
import { app as comfyApp } from '@/scripts/app'
function useVueNodeLifecycleIndividual() {
@@ -78,7 +77,6 @@ function useVueNodeLifecycleIndividual() {
(enabled) => {
if (enabled) {
initializeNodeManager()
ensureCorrectLayoutScale()
} else {
disposeNodeManagerAndSyncs()
}

View File

@@ -5,7 +5,6 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { ResultItemType } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
const PASTED_IMAGE_EXPIRY_MS = 2000
@@ -38,13 +37,6 @@ const uploadFile = async (
}
const data = await resp.json()
// Update AssetsStore input assets when files are uploaded to input folder
if (formFields.type === 'input' || (!formFields.type && !isPasted)) {
const assetsStore = useAssetsStore()
await assetsStore.updateInputs()
}
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
}

View File

@@ -1,16 +0,0 @@
import { markRaw } from 'vue'
import AssetsSidebarTab from '@/components/sidebar/tabs/AssetsSidebarTab.vue'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useAssetsSidebarTab = (): SidebarTabExtension => {
return {
id: 'assets',
icon: 'icon-[comfy--image-ai-edit]',
title: 'sideToolbar.assets',
tooltip: 'sideToolbar.assets',
label: 'sideToolbar.labels.assets',
component: markRaw(AssetsSidebarTab),
type: 'vue'
}
}

View File

@@ -21,7 +21,6 @@ import {
import type { Point } from '@/lib/litegraph/src/litegraph'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { SUPPORT_URL } from '@/platform/support/config'
@@ -64,8 +63,6 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelectorDialog'
const { isActiveSubscription, showSubscriptionDialog } = useSubscription()
const moveSelectedNodesVersionAdded = '1.22.2'
export function useCoreCommands(): ComfyCommand[] {
@@ -456,11 +453,6 @@ export function useCoreCommands(): ComfyCommand[] {
versionAdded: '1.3.7',
category: 'essentials' as const,
function: async () => {
if (!isActiveSubscription.value) {
showSubscriptionDialog()
return
}
const batchCount = useQueueSettingsStore().batchCount
if (isCloud) {
@@ -477,11 +469,6 @@ export function useCoreCommands(): ComfyCommand[] {
versionAdded: '1.3.7',
category: 'essentials' as const,
function: async () => {
if (!isActiveSubscription.value) {
showSubscriptionDialog()
return
}
const batchCount = useQueueSettingsStore().batchCount
if (isCloud) {
@@ -497,11 +484,6 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Queue Selected Output Nodes',
versionAdded: '1.19.6',
function: async () => {
if (!isActiveSubscription.value) {
showSubscriptionDialog()
return
}
const batchCount = useQueueSettingsStore().batchCount
const selectedNodes = getSelectedNodes()
const selectedOutputNodes = filterOutputNodes(selectedNodes)

View File

@@ -255,7 +255,7 @@ onBeforeUnmount(() => {
{{ $t('subgraphStore.shown') }}
</div>
<a
class="cursor-pointer text-right text-[11px] font-normal text-azure-600"
class="cursor-pointer text-right text-[11px] font-normal text-blue-100"
@click.stop="hideAll"
>
{{ $t('subgraphStore.hideAll') }}</a
@@ -280,7 +280,7 @@ onBeforeUnmount(() => {
{{ $t('subgraphStore.hidden') }}
</div>
<a
class="cursor-pointer text-right text-[11px] font-normal text-azure-600"
class="cursor-pointer text-right text-[11px] font-normal text-blue-100"
@click.stop="showAll"
>
{{ $t('subgraphStore.showAll') }}</a

View File

@@ -599,9 +599,6 @@
"nodeLibrary": "Node Library",
"workflows": "Workflows",
"templates": "Templates",
"assets": "Assets",
"mediaAssets": "Media Assets",
"backToAssets": "Back to all assets",
"labels": {
"queue": "Queue",
"nodes": "Nodes",
@@ -609,15 +606,8 @@
"workflows": "Workflows",
"templates": "Templates",
"console": "Console",
"menu": "Menu",
"assets": "Assets",
"imported": "Imported",
"generated": "Generated"
"menu": "Menu"
},
"noFilesFound": "No files found",
"noImportedFiles": "No imported files found",
"noGeneratedFiles": "No generated files found",
"noFilesFoundMessage": "Upload files or generate content to see them here",
"browseTemplates": "Browse example templates",
"openWorkflow": "Open workflow in local file system",
"newBlankWorkflow": "Create a new blank workflow",
@@ -1247,7 +1237,6 @@
"operations": "operations",
"training": "training",
"lotus": "lotus",
"LTXV": "LTXV",
"Luma": "Luma",
"MiniMax": "MiniMax",
"model_specific": "model_specific",
@@ -1651,7 +1640,6 @@
"beta": "BETA",
"perMonth": "USD / month",
"renewsDate": "Renews {date}",
"expiresDate": "Expires {date}",
"manageSubscription": "Manage subscription",
"apiNodesBalance": "\"API Nodes\" Credit Balance",
"apiNodesDescription": "For running commercial/proprietary models",
@@ -1770,24 +1758,7 @@
"sortAZ": "A-Z",
"sortZA": "Z-A",
"sortRecent": "Recent",
"sortPopular": "Popular",
"ariaLabel": {
"assetCard": "{name} - {type} asset",
"loadingAsset": "Loading asset"
}
},
"mediaAsset": {
"deleteAssetTitle": "Delete this asset?",
"deleteAssetDescription": "This asset will be permanently removed.",
"assetDeletedSuccessfully": "Asset deleted successfully",
"deletingImportedFilesCloudOnly": "Deleting imported files is only supported in cloud version",
"failedToDeleteAsset": "Failed to delete asset",
"jobIdToast": {
"jobIdCopied": "Job ID copied to clipboard",
"jobIdCopyFailed": "Failed to copy Job ID",
"copied": "Copied",
"error": "Error"
}
"sortPopular": "Popular"
},
"actionbar": {
"dockToTop": "Dock to top"

View File

@@ -4946,70 +4946,6 @@
}
}
},
"LtxvApiImageToVideo": {
"display_name": "LTXV Image To Video",
"description": "Professional-quality videos with customizable duration and resolution based on start image.",
"inputs": {
"image": {
"name": "image",
"tooltip": "First frame to be used for the video."
},
"model": {
"name": "model"
},
"prompt": {
"name": "prompt"
},
"duration": {
"name": "duration"
},
"resolution": {
"name": "resolution"
},
"fps": {
"name": "fps"
},
"generate_audio": {
"name": "generate_audio",
"tooltip": "When true, the generated video will include AI-generated audio matching the scene."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"LtxvApiTextToVideo": {
"display_name": "LTXV Text To Video",
"description": "Professional-quality videos with customizable duration and resolution.",
"inputs": {
"model": {
"name": "model"
},
"prompt": {
"name": "prompt"
},
"duration": {
"name": "duration"
},
"resolution": {
"name": "resolution"
},
"fps": {
"name": "fps"
},
"generate_audio": {
"name": "generate_audio",
"tooltip": "When true, the generated video will include AI-generated audio matching the scene."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"LTXVConditioning": {
"display_name": "LTXVConditioning",
"inputs": {

View File

@@ -30,13 +30,13 @@ defineProps<{
function getBadgeColor(type: AssetBadge['type']): string {
switch (type) {
case 'type':
return 'bg-azure-600/90 dark-theme:bg-azure-600/80'
return 'bg-blue-100/90 dark-theme:bg-blue-100/80'
case 'base':
return 'bg-jade-600/90 dark-theme:bg-jade-600/80'
return 'bg-success-100/90 dark-theme:bg-success-100/80'
case 'size':
return 'bg-ash-500/90 dark-theme:bg-charcoal-700/80'
return 'bg-stone-100/90 dark-theme:bg-charcoal-700/80'
default:
return 'bg-ash-500/90 dark-theme:bg-charcoal-700/80'
return 'bg-stone-100/90 dark-theme:bg-charcoal-700/80'
}
}
</script>

View File

@@ -70,7 +70,7 @@ export const Default: Story = {
}
},
template: `
<div class="flex items-center justify-center min-h-screen bg-ash-800 dark-theme:bg-ash-800 p-4">
<div class="flex items-center justify-center min-h-screen bg-stone-200 dark-theme:bg-stone-200 p-4">
<AssetBrowserModal
:node-type="nodeType"
:input-name="inputName"
@@ -111,7 +111,7 @@ export const SingleAssetType: Story = {
return { ...args, onAssetSelect, onClose, assets: singleTypeAssets }
},
template: `
<div class="flex items-center justify-center min-h-screen bg-ash-800 dark-theme:bg-ash-800 p-4">
<div class="flex items-center justify-center min-h-screen bg-stone-200 dark-theme:bg-stone-200 p-4">
<AssetBrowserModal
:node-type="nodeType"
:input-name="inputName"
@@ -154,7 +154,7 @@ export const NoLeftPanel: Story = {
return { ...args, onAssetSelect, onClose, assets: mockAssets }
},
template: `
<div class="flex items-center justify-center min-h-screen bg-ash-800 dark-theme:bg-ash-800 p-4">
<div class="flex items-center justify-center min-h-screen bg-stone-200 dark-theme:bg-stone-200 p-4">
<AssetBrowserModal
:node-type="nodeType"
:input-name="inputName"

View File

@@ -33,7 +33,7 @@ const meta: Meta<typeof AssetCard> = {
decorators: [
() => ({
template:
'<div class="p-8 bg-gray-50 dark-theme:bg-smoke-900"><story /></div>'
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900"><story /></div>'
})
]
}
@@ -49,7 +49,7 @@ export const Interactive: Story = {
decorators: [
() => ({
template:
'<div class="p-8 bg-gray-50 dark-theme:bg-smoke-900 max-w-96"><story /></div>'
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900 max-w-96"><story /></div>'
})
],
parameters: {
@@ -70,7 +70,7 @@ export const NonInteractive: Story = {
decorators: [
() => ({
template:
'<div class="p-8 bg-gray-50 dark-theme:bg-smoke-900 max-w-96"><story /></div>'
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900 max-w-96"><story /></div>'
})
],
parameters: {
@@ -93,7 +93,7 @@ export const WithPreviewImage: Story = {
decorators: [
() => ({
template:
'<div class="p-8 bg-gray-50 dark-theme:bg-smoke-900 max-w-96"><story /></div>'
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900 max-w-96"><story /></div>'
})
],
parameters: {
@@ -115,7 +115,7 @@ export const FallbackGradient: Story = {
decorators: [
() => ({
template:
'<div class="p-8 bg-gray-50 dark-theme:bg-smoke-900 max-w-96"><story /></div>'
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900 max-w-96"><story /></div>'
})
],
parameters: {
@@ -203,7 +203,7 @@ export const EdgeCases: Story = {
return { edgeCases }
},
template: `
<div class="grid grid-cols-4 gap-6 p-8 bg-gray-50 dark-theme:bg-smoke-900">
<div class="grid grid-cols-4 gap-6 p-8 bg-gray-50 dark-theme:bg-gray-900">
<AssetCard
v-for="asset in edgeCases"
:key="asset.id"

View File

@@ -16,7 +16,7 @@
/>
<div
v-else
class="flex h-full w-full items-center justify-center bg-gradient-to-br from-smoke-400 via-smoke-800 to-charcoal-400"
class="flex h-full w-full items-center justify-center bg-gradient-to-br from-gray-400 via-gray-800 to-charcoal-400"
></div>
<AssetBadgeGroup :badges="asset.badges" />
</div>
@@ -40,7 +40,7 @@
:class="
cn(
'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]',
'text-ash-500',
'text-stone-100',
'dark-theme:text-slate-100'
)
"
@@ -51,7 +51,11 @@
</div>
<div
:class="
cn('flex gap-4 text-xs', 'text-stone-400', 'dark-theme:text-ash-300')
cn(
'flex gap-4 text-xs',
'text-stone-400',
'dark-theme:text-stone-300'
)
"
>
<span v-if="asset.stats.stars" class="flex items-center gap-1">
@@ -103,7 +107,7 @@ const cardClasses = computed(() => {
]
if (!props.interactive) {
return cn(...base, 'bg-smoke-100 dark-theme:bg-charcoal-800')
return cn(...base, 'bg-gray-100 dark-theme:bg-charcoal-800')
}
return cn(
@@ -111,10 +115,10 @@ const cardClasses = computed(() => {
'group',
'appearance-none bg-transparent p-0 m-0',
'font-inherit text-inherit outline-none cursor-pointer text-left',
'bg-smoke-100 dark-theme:bg-charcoal-800',
'hover:bg-smoke-200 dark-theme:hover:bg-charcoal-600',
'bg-gray-100 dark-theme:bg-charcoal-800',
'hover:bg-gray-200 dark-theme:hover:bg-charcoal-600',
'border-none',
'focus:outline-solid outline-azure-600 outline-4'
'focus:outline-solid outline-blue-100 outline-4'
)
})

View File

@@ -27,10 +27,10 @@ const meta: Meta<typeof AssetFilterBar> = {
() => ({
template: `
<div class="min-h-screen bg-white dark-theme:bg-charcoal-900">
<div class="bg-gray-50 dark-theme:bg-charcoal-800 border-b border-smoke-200 dark-theme:border-charcoal-600">
<div class="bg-gray-50 dark-theme:bg-charcoal-800 border-b border-gray-200 dark-theme:border-charcoal-600">
<story />
</div>
<div class="p-6 text-sm text-smoke-600 dark-theme:text-smoke-400">
<div class="p-6 text-sm text-gray-600 dark-theme:text-gray-400">
<p>Filter bar with proper chrome styling showing contextual background and borders.</p>
</div>
</div>
@@ -222,7 +222,7 @@ export const CategorySwitchingReactivity: Story = {
'px-4 py-2 rounded border',
selectedCategory === 'all'
? 'bg-blue-500 text-white border-blue-600'
: 'bg-white dark-theme:bg-charcoal-700 border-smoke-300 dark-theme:border-charcoal-600'
: 'bg-white dark-theme:bg-charcoal-700 border-gray-300 dark-theme:border-charcoal-600'
]"
>
All (.safetensors + .pt, sd15 + sdxl)
@@ -233,7 +233,7 @@ export const CategorySwitchingReactivity: Story = {
'px-4 py-2 rounded border',
selectedCategory === 'checkpoints'
? 'bg-blue-500 text-white border-blue-600'
: 'bg-white dark-theme:bg-charcoal-700 border-smoke-300 dark-theme:border-charcoal-600'
: 'bg-white dark-theme:bg-charcoal-700 border-gray-300 dark-theme:border-charcoal-600'
]"
>
Checkpoints (.safetensors, sd15 + sdxl)
@@ -244,7 +244,7 @@ export const CategorySwitchingReactivity: Story = {
'px-4 py-2 rounded border',
selectedCategory === 'loras'
? 'bg-blue-500 text-white border-blue-600'
: 'bg-white dark-theme:bg-charcoal-700 border-smoke-300 dark-theme:border-charcoal-600'
: 'bg-white dark-theme:bg-charcoal-700 border-gray-300 dark-theme:border-charcoal-600'
]"
>
LoRAs (.pt, sd15 only)

View File

@@ -23,7 +23,7 @@
:class="
cn(
'col-span-full flex flex-col items-center justify-center py-16',
'text-ash-300 dark-theme:text-ash-800'
'text-stone-300 dark-theme:text-stone-200'
)
"
>
@@ -42,7 +42,7 @@
<i
class="icon-[lucide--loader]"
:class="
cn('size-12 animate-spin', 'text-ash-300 dark-theme:text-ash-800')
cn('size-12 animate-spin', 'text-stone-300 dark-theme:text-stone-200')
"
/>
</div>

View File

@@ -1,6 +1,11 @@
<template>
<div class="flex flex-col items-center gap-1">
<MediaTitle :file-name="fileName" />
<h3
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
:title="asset.name"
>
{{ fileName }}
</h3>
<div class="flex items-center gap-2 text-xs text-zinc-400">
<span>{{ formatSize(asset.size) }}</span>
</div>
@@ -13,7 +18,6 @@ import { computed } from 'vue'
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
const { asset } = defineProps<{
asset: AssetMeta

View File

@@ -1,7 +1,7 @@
<template>
<div class="relative size-full overflow-hidden rounded">
<div class="relative h-full w-full overflow-hidden rounded">
<div
class="flex size-full flex-col items-center justify-center gap-2 bg-zinc-200 dark-theme:bg-zinc-700/50"
class="flex h-full w-full flex-col items-center justify-center gap-2 bg-zinc-200 dark-theme:bg-zinc-700/50"
>
<i
class="icon-[lucide--box] text-3xl text-zinc-600 dark-theme:text-zinc-200"

View File

@@ -1,6 +1,6 @@
<template>
<IconGroup>
<IconButton v-if="showDeleteButton" size="sm" @click="handleDelete">
<IconButton size="sm" @click="handleDelete">
<i class="icon-[lucide--trash-2] size-4" />
</IconButton>
<IconButton size="sm" @click="handleDownload">
@@ -12,23 +12,18 @@
@menu-closed="emit('menuStateChanged', false)"
>
<template #default="{ close }">
<MediaAssetMoreMenu
:close="close"
@inspect="emit('inspect')"
@asset-deleted="emit('asset-deleted')"
/>
<MediaAssetMoreMenu :close="close" />
</template>
</MoreButton>
</IconGroup>
</template>
<script setup lang="ts">
import { computed, inject } from 'vue'
import { inject } from 'vue'
import IconButton from '@/components/button/IconButton.vue'
import IconGroup from '@/components/button/IconGroup.vue'
import MoreButton from '@/components/button/MoreButton.vue'
import { isCloud } from '@/platform/distribution/types'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
@@ -36,35 +31,20 @@ import MediaAssetMoreMenu from './MediaAssetMoreMenu.vue'
const emit = defineEmits<{
menuStateChanged: [isOpen: boolean]
inspect: []
'asset-deleted': []
}>()
const { asset, context } = inject(MediaAssetKey)!
const { asset } = inject(MediaAssetKey)!
const actions = useMediaAssetActions()
const assetType = computed(() => {
return context?.value?.type || asset.value?.tags?.[0] || 'output'
})
const showDeleteButton = computed(() => {
return (
assetType.value === 'output' || (assetType.value === 'input' && isCloud)
)
})
const handleDelete = async () => {
if (!asset.value) return
const success = await actions.confirmDelete(asset.value)
if (success) {
emit('asset-deleted')
const handleDelete = () => {
if (asset.value) {
actions.deleteAsset(asset.value.id)
}
}
const handleDownload = () => {
if (asset.value) {
actions.downloadAsset()
actions.downloadAsset(asset.value.id)
}
}
</script>

View File

@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
import type { AssetItem } from '../schemas/assetSchema'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import MediaAssetCard from './MediaAssetCard.vue'
const meta: Meta<typeof MediaAssetCard> = {
@@ -28,6 +28,10 @@ const meta: Meta<typeof MediaAssetCard> = {
})
],
argTypes: {
context: {
control: 'select',
options: ['input', 'output']
},
loading: {
control: 'boolean'
}
@@ -49,20 +53,19 @@ const SAMPLE_MEDIA = {
audio: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3'
}
const sampleAsset: AssetItem = {
const sampleAsset: AssetMeta = {
id: 'asset-1',
name: 'sample-image.png',
kind: 'image',
duration: 3345,
size: 2048576,
created_at: new Date().toISOString(),
preview_url: SAMPLE_MEDIA.image1,
tags: ['input'],
user_metadata: {
duration: 3345,
dimensions: {
width: 1920,
height: 1080
}
}
created_at: Date.now().toString(),
src: SAMPLE_MEDIA.image1,
dimensions: {
width: 1920,
height: 1080
},
tags: []
}
export const ImageAsset: Story = {
@@ -72,6 +75,7 @@ export const ImageAsset: Story = {
})
],
args: {
context: { type: 'output', outputCount: 3 },
asset: sampleAsset,
loading: false
}
@@ -84,18 +88,19 @@ export const VideoAsset: Story = {
})
],
args: {
context: { type: 'input' },
asset: {
...sampleAsset,
id: 'asset-2',
name: 'Big_Buck_Bunny.mp4',
kind: 'video',
size: 10485760,
preview_url: SAMPLE_MEDIA.videoThumbnail,
user_metadata: {
duration: 13425,
dimensions: {
width: 1280,
height: 720
}
duration: 13425,
preview_url: SAMPLE_MEDIA.videoThumbnail, // Poster image
src: SAMPLE_MEDIA.video, // Actual video file
dimensions: {
width: 1280,
height: 720
}
}
}
@@ -108,15 +113,16 @@ export const Model3DAsset: Story = {
})
],
args: {
context: { type: 'input' },
asset: {
...sampleAsset,
id: 'asset-3',
name: 'Asset-3d-model.glb',
kind: '3D',
size: 7340032,
preview_url: '',
user_metadata: {
duration: 18023
}
src: '',
dimensions: undefined,
duration: 18023
}
}
}
@@ -128,15 +134,16 @@ export const AudioAsset: Story = {
})
],
args: {
context: { type: 'input' },
asset: {
...sampleAsset,
id: 'asset-4',
id: 'asset-3',
name: 'SoundHelix-Song.mp3',
kind: 'audio',
size: 5242880,
preview_url: SAMPLE_MEDIA.audio,
user_metadata: {
duration: 23180
}
src: SAMPLE_MEDIA.audio,
dimensions: undefined,
duration: 23180
}
}
}
@@ -148,6 +155,7 @@ export const LoadingState: Story = {
})
],
args: {
context: { type: 'input' },
asset: sampleAsset,
loading: true
}
@@ -160,6 +168,7 @@ export const LongFileName: Story = {
})
],
args: {
context: { type: 'input' },
asset: {
...sampleAsset,
name: 'very-long-file-name-that-should-be-truncated-in-the-ui-to-prevent-overflow.png'
@@ -174,6 +183,7 @@ export const SelectedState: Story = {
})
],
args: {
context: { type: 'output', outputCount: 2 },
asset: sampleAsset,
selected: true
}
@@ -186,20 +196,21 @@ export const WebMVideo: Story = {
})
],
args: {
context: { type: 'input' },
asset: {
id: 'asset-webm',
name: 'animated-clip.webm',
kind: 'video',
size: 3145728,
created_at: new Date().toISOString(),
preview_url: SAMPLE_MEDIA.image1,
tags: ['input'],
user_metadata: {
duration: 620,
dimensions: {
width: 640,
height: 360
}
}
created_at: Date.now().toString(),
preview_url: SAMPLE_MEDIA.image1, // Poster image
src: 'https://www.w3schools.com/html/movie.mp4', // Actual video
duration: 620,
dimensions: {
width: 640,
height: 360
},
tags: []
}
}
}
@@ -211,20 +222,20 @@ export const GifAnimation: Story = {
})
],
args: {
context: { type: 'input' },
asset: {
id: 'asset-gif',
name: 'animation.gif',
kind: 'image',
size: 1572864,
created_at: new Date().toISOString(),
preview_url: 'https://media.giphy.com/media/3o7aCTPPm4OHfRLSH6/giphy.gif',
tags: ['input'],
user_metadata: {
duration: 1345,
dimensions: {
width: 480,
height: 270
}
}
duration: 1345,
created_at: Date.now().toString(),
src: 'https://media.giphy.com/media/3o7aCTPPm4OHfRLSH6/giphy.gif',
dimensions: {
width: 480,
height: 270
},
tags: []
}
}
}
@@ -233,89 +244,83 @@ export const GridLayout: Story = {
render: () => ({
components: { MediaAssetCard },
setup() {
const assets: AssetItem[] = [
const assets: AssetMeta[] = [
{
id: 'grid-1',
name: 'image-file.jpg',
kind: 'image',
size: 2097152,
created_at: new Date().toISOString(),
preview_url: SAMPLE_MEDIA.image1,
tags: ['input'],
user_metadata: {
duration: 4500,
dimensions: { width: 1920, height: 1080 }
}
duration: 4500,
created_at: Date.now().toString(),
src: SAMPLE_MEDIA.image1,
dimensions: { width: 1920, height: 1080 },
tags: []
},
{
id: 'grid-2',
name: 'image-file.jpg',
kind: 'image',
size: 2097152,
created_at: new Date().toISOString(),
preview_url: SAMPLE_MEDIA.image2,
tags: ['input'],
user_metadata: {
duration: 4500,
dimensions: { width: 1920, height: 1080 }
}
duration: 4500,
created_at: Date.now().toString(),
src: SAMPLE_MEDIA.image2,
dimensions: { width: 1920, height: 1080 },
tags: []
},
{
id: 'grid-3',
name: 'video-file.mp4',
kind: 'video',
size: 10485760,
created_at: new Date().toISOString(),
preview_url: SAMPLE_MEDIA.videoThumbnail,
tags: ['input'],
user_metadata: {
duration: 13425,
dimensions: { width: 1280, height: 720 }
}
duration: 13425,
created_at: Date.now().toString(),
preview_url: SAMPLE_MEDIA.videoThumbnail, // Poster image
src: SAMPLE_MEDIA.video, // Actual video
dimensions: { width: 1280, height: 720 },
tags: []
},
{
id: 'grid-4',
name: 'audio-file.mp3',
kind: 'audio',
size: 5242880,
created_at: new Date().toISOString(),
preview_url: SAMPLE_MEDIA.audio,
tags: ['input'],
user_metadata: {
duration: 180
}
duration: 180,
created_at: Date.now().toString(),
src: SAMPLE_MEDIA.audio,
tags: []
},
{
id: 'grid-5',
name: 'animation.gif',
kind: 'image',
size: 3145728,
created_at: new Date().toISOString(),
preview_url:
'https://media.giphy.com/media/l0HlNaQ6gWfllcjDO/giphy.gif',
tags: ['input'],
user_metadata: {
duration: 1345,
dimensions: { width: 480, height: 360 }
}
duration: 1345,
created_at: Date.now().toString(),
src: 'https://media.giphy.com/media/l0HlNaQ6gWfllcjDO/giphy.gif',
dimensions: { width: 480, height: 360 },
tags: []
},
{
id: 'grid-6',
name: 'Asset-3d-model.glb',
kind: '3D',
size: 7340032,
preview_url: '',
created_at: new Date().toISOString(),
tags: ['input'],
user_metadata: {
duration: 18023
}
src: '',
dimensions: undefined,
duration: 18023,
created_at: Date.now().toString(),
tags: []
},
{
id: 'grid-7',
name: 'image-file.jpg',
kind: 'image',
size: 2097152,
created_at: new Date().toISOString(),
preview_url: SAMPLE_MEDIA.image3,
tags: ['input'],
user_metadata: {
duration: 4500,
dimensions: { width: 1920, height: 1080 }
}
duration: 4500,
created_at: Date.now().toString(),
src: SAMPLE_MEDIA.image3,
dimensions: { width: 1920, height: 1080 },
tags: []
}
]
return { assets }
@@ -325,6 +330,7 @@ export const GridLayout: Story = {
<MediaAssetCard
v-for="asset in assets"
:key="asset.id"
:context="{ type: Math.random() > 0.5 ? 'input' : 'output', outputCount: Math.floor(Math.random() * 5) }"
:asset="asset"
/>
</div>

View File

@@ -3,12 +3,7 @@
ref="cardContainerRef"
role="button"
:aria-label="
asset
? $t('assetBrowser.ariaLabel.assetCard', {
name: asset.name,
type: fileKind
})
: $t('assetBrowser.ariaLabel.loadingAsset')
asset ? `${asset.name} - ${asset.kind} asset` : 'Loading asset'
"
:tabindex="loading ? -1 : 0"
size="mini"
@@ -33,17 +28,16 @@
</template>
<!-- Content based on asset type -->
<template v-else-if="asset && adaptedAsset">
<template v-else-if="asset">
<component
:is="getTopComponent(fileKind)"
:asset="adaptedAsset"
:context="{ type: assetType }"
:is="getTopComponent(asset.kind)"
:asset="asset"
:context="context"
@view="handleZoomClick"
@download="actions.downloadAsset()"
@play="actions.playAsset(asset.id)"
@download="actions.downloadAsset(asset!.id)"
@play="actions.playAsset(asset!.id)"
@video-playing-state-changed="isVideoPlaying = $event"
@video-controls-changed="showVideoControls = $event"
@image-loaded="handleImageLoaded"
/>
</template>
@@ -51,8 +45,6 @@
<template v-if="showActionsOverlay" #top-left>
<MediaAssetActions
@menu-state-changed="isMenuOpen = $event"
@inspect="handleZoomClick"
@asset-deleted="handleAssetDelete"
@mouseenter="handleOverlayMouseEnter"
@mouseleave="handleOverlayMouseLeave"
/>
@@ -71,17 +63,13 @@
</template>
<!-- Duration/Format chips (bottom-left) - show on hover even when playing -->
<template v-if="showDurationChips || showFileFormatChip" #bottom-left>
<template v-if="showDurationChips" #bottom-left>
<div
class="flex flex-wrap items-center gap-1"
@mouseenter="handleOverlayMouseEnter"
@mouseleave="handleOverlayMouseLeave"
>
<SquareChip
v-if="formattedDuration"
variant="light"
:label="formattedDuration"
/>
<SquareChip variant="light" :label="formattedDuration" />
<SquareChip v-if="fileFormat" variant="light" :label="fileFormat" />
</div>
</template>
@@ -91,8 +79,8 @@
<IconTextButton
type="secondary"
size="sm"
:label="String(outputCount)"
@click.stop="handleOutputCountClick"
:label="context?.outputCount?.toString() ?? '0'"
@click.stop="actions.openMoreOutputs(asset?.id || '')"
@mouseenter="handleOverlayMouseEnter"
@mouseleave="handleOverlayMouseLeave"
>
@@ -119,11 +107,11 @@
</template>
<!-- Content based on asset type -->
<template v-else-if="asset && adaptedAsset">
<template v-else-if="asset">
<component
:is="getBottomComponent(fileKind)"
:asset="adaptedAsset"
:context="{ type: assetType }"
:is="getBottomComponent(asset.kind)"
:asset="asset"
:context="context"
/>
</template>
</CardBottom>
@@ -141,13 +129,16 @@ import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import { formatDuration } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
import { getAssetType } from '../composables/media/assetMappers'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import type { AssetItem } from '../schemas/assetSchema'
import type { MediaKind } from '../schemas/mediaAssetSchema'
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
import type {
AssetContext,
AssetMeta,
MediaKind
} from '../schemas/mediaAssetSchema'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaAssetActions from './MediaAssetActions.vue'
@@ -174,18 +165,11 @@ function getBottomComponent(kind: MediaKind) {
return mediaComponents.bottom[kind] || mediaComponents.bottom.image
}
const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
asset?: AssetItem
const { context, asset, loading, selected } = defineProps<{
context: AssetContext
asset?: AssetMeta
loading?: boolean
selected?: boolean
showOutputCount?: boolean
outputCount?: number
}>()
const emit = defineEmits<{
zoom: [asset: AssetItem]
'output-count-click': []
'asset-deleted': []
}>()
const cardContainerRef = ref<HTMLElement>()
@@ -195,44 +179,14 @@ const isMenuOpen = ref(false)
const showVideoControls = ref(false)
const isOverlayHovered = ref(false)
// Store actual image dimensions
const imageDimensions = ref<{ width: number; height: number } | undefined>()
const isHovered = useElementHover(cardContainerRef)
const actions = useMediaAssetActions()
// Get asset type from tags
const assetType = computed(() => {
return getAssetType(asset?.tags)
})
// Determine file type from extension
const fileKind = computed((): MediaKind => {
return getMediaTypeFromFilename(asset?.name || '') as MediaKind
})
// Adapt AssetItem to legacy AssetMeta format for existing components
const adaptedAsset = computed(() => {
if (!asset) return undefined
return {
id: asset.id,
name: asset.name,
kind: fileKind.value,
src: asset.preview_url || '',
size: asset.size,
tags: asset.tags || [],
created_at: asset.created_at,
duration: asset.user_metadata?.duration
? Number(asset.user_metadata.duration)
: undefined,
dimensions: imageDimensions.value
}
})
const galleryStore = useMediaAssetGalleryStore()
provide(MediaAssetKey, {
asset: toRef(() => adaptedAsset.value),
context: toRef(() => ({ type: assetType.value })),
asset: toRef(() => asset),
context: toRef(() => context),
isVideoPlaying,
showVideoControls
})
@@ -247,16 +201,8 @@ const containerClasses = computed(() =>
)
const formattedDuration = computed(() => {
// Check for execution time first (from history API)
const executionTime = asset?.user_metadata?.executionTimeInSeconds
if (executionTime !== undefined && executionTime !== null) {
return `${Number(executionTime).toFixed(2)}s`
}
// Fall back to duration for media files
const duration = asset?.user_metadata?.duration
if (!duration) return ''
return formatDuration(Number(duration))
if (!asset?.duration) return ''
return formatDuration(asset.duration)
})
const fileFormat = computed(() => {
@@ -266,10 +212,10 @@ const fileFormat = computed(() => {
})
const durationChipClasses = computed(() => {
if (fileKind.value === 'audio') {
if (asset?.kind === 'audio') {
return '-translate-y-11'
}
if (fileKind.value === 'video' && showVideoControls.value) {
if (asset?.kind === 'video' && showVideoControls.value) {
return '-translate-y-16'
}
return ''
@@ -292,29 +238,27 @@ const showActionsOverlay = computed(
const showZoomOverlay = computed(
() =>
showHoverActions.value &&
fileKind.value !== '3D' &&
asset?.kind !== '3D' &&
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
)
const showDurationChips = computed(
() =>
!loading &&
(asset?.user_metadata?.executionTimeInSeconds ||
asset?.user_metadata?.duration) &&
asset?.duration &&
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
)
const showFileFormatChip = computed(
const showOutputCount = computed(
() =>
!loading &&
!!asset &&
!!fileFormat.value &&
context?.outputCount &&
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
)
const handleCardClick = () => {
if (adaptedAsset.value) {
actions.selectAsset(adaptedAsset.value)
if (asset) {
actions.selectAsset(asset)
}
}
@@ -328,19 +272,7 @@ const handleOverlayMouseLeave = () => {
const handleZoomClick = () => {
if (asset) {
emit('zoom', asset)
galleryStore.openSingle(asset)
}
}
const handleImageLoaded = (width: number, height: number) => {
imageDimensions.value = { width, height }
}
const handleOutputCountClick = () => {
emit('output-count-click')
}
const handleAssetDelete = () => {
emit('asset-deleted')
}
</script>

View File

@@ -13,7 +13,6 @@
</IconTextButton>
<IconTextButton
v-if="showWorkflowOptions"
type="transparent"
class="dark-theme:text-white"
label="Add to current workflow"
@@ -35,7 +34,7 @@
</template>
</IconTextButton>
<MediaAssetButtonDivider v-if="showWorkflowOptions" />
<MediaAssetButtonDivider />
<IconTextButton
v-if="showWorkflowOptions"
@@ -61,10 +60,9 @@
</template>
</IconTextButton>
<MediaAssetButtonDivider v-if="showWorkflowOptions && showCopyJobId" />
<MediaAssetButtonDivider v-if="showWorkflowOptions" />
<IconTextButton
v-if="showCopyJobId"
type="transparent"
class="dark-theme:text-white"
label="Copy job ID"
@@ -75,10 +73,9 @@
</template>
</IconTextButton>
<MediaAssetButtonDivider v-if="showCopyJobId && showDeleteButton" />
<MediaAssetButtonDivider />
<IconTextButton
v-if="showDeleteButton"
type="transparent"
class="dark-theme:text-white"
label="Delete"
@@ -95,9 +92,9 @@
import { computed, inject } from 'vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import { isCloud } from '@/platform/distribution/types'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaAssetButtonDivider from './MediaAssetButtonDivider.vue'
@@ -105,36 +102,16 @@ const { close } = defineProps<{
close: () => void
}>()
const emit = defineEmits<{
inspect: []
'asset-deleted': []
}>()
const { asset, context } = inject(MediaAssetKey)!
const actions = useMediaAssetActions()
const galleryStore = useMediaAssetGalleryStore()
const assetType = computed(() => {
return asset.value?.tags?.[0] || context.value?.type || 'output'
})
const showWorkflowOptions = computed(() => assetType.value === 'output')
// Only show Copy Job ID for output assets (not for imported/input assets)
const showCopyJobId = computed(() => {
return assetType.value !== 'input'
})
// Delete button should be shown for:
// - All output files (can be deleted via history)
// - Input files only in cloud environment
const showDeleteButton = computed(() => {
return (
assetType.value === 'output' || (assetType.value === 'input' && isCloud)
)
})
const showWorkflowOptions = computed(() => context.value.type)
const handleInspect = () => {
emit('inspect')
if (asset.value) {
galleryStore.openSingle(asset.value)
}
close()
}
@@ -147,7 +124,7 @@ const handleAddToWorkflow = () => {
const handleDownload = () => {
if (asset.value) {
actions.downloadAsset()
actions.downloadAsset(asset.value.id)
}
close()
}
@@ -166,21 +143,17 @@ const handleExportWorkflow = () => {
close()
}
const handleCopyJobId = async () => {
const handleCopyJobId = () => {
if (asset.value) {
await actions.copyJobId()
actions.copyAssetUrl(asset.value.id)
}
close()
}
const handleDelete = async () => {
if (!asset.value) return
close() // Close the menu first
const success = await actions.confirmDelete(asset.value)
if (success) {
emit('asset-deleted')
const handleDelete = () => {
if (asset.value) {
actions.deleteAsset(asset.value.id)
}
close()
}
</script>

View File

@@ -1,6 +1,11 @@
<template>
<div class="flex flex-col items-center gap-1">
<MediaTitle :file-name="fileName" />
<h3
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
:title="asset.name"
>
{{ fileName }}
</h3>
<div class="flex items-center gap-2 text-xs text-zinc-400">
<span>{{ formatSize(asset.size) }}</span>
</div>
@@ -13,7 +18,6 @@ import { computed } from 'vue'
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
const { asset } = defineProps<{
asset: AssetMeta

View File

@@ -1,7 +1,7 @@
<template>
<div class="relative size-full overflow-hidden rounded">
<div class="relative h-full w-full overflow-hidden rounded">
<div
class="flex size-full flex-col items-center justify-center gap-2 bg-zinc-200 dark-theme:bg-zinc-700/50"
class="flex h-full w-full flex-col items-center justify-center gap-2 bg-zinc-200 dark-theme:bg-zinc-700/50"
>
<i
class="icon-[lucide--music] text-3xl text-zinc-600 dark-theme:text-zinc-200"

View File

@@ -1,10 +1,13 @@
<template>
<div class="flex flex-col items-center gap-1">
<MediaTitle :file-name="fileName" />
<h3
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
:title="asset.name"
>
{{ fileName }}
</h3>
<div class="flex items-center text-xs text-zinc-400">
<span v-if="asset.dimensions"
>{{ asset.dimensions?.width }}x{{ asset.dimensions?.height }}</span
>
<span>{{ asset.dimensions?.width }}x{{ asset.dimensions?.height }}</span>
</div>
</div>
</template>
@@ -15,7 +18,6 @@ import { computed } from 'vue'
import { getFilenameDetails } from '@/utils/formatUtil'
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
const { asset } = defineProps<{
asset: AssetMeta

View File

@@ -1,44 +1,27 @@
<template>
<div
class="relative size-full overflow-hidden rounded bg-zinc-200 dark-theme:bg-zinc-700/50"
>
<img
v-if="!error"
<div class="relative h-full w-full overflow-hidden rounded">
<LazyImage
v-if="asset.src"
:src="asset.src"
:alt="asset.name"
class="size-full object-contain"
:container-class="'aspect-square'"
:image-class="'w-full h-full object-cover'"
/>
<div
v-else
class="flex size-full items-center justify-center bg-zinc-200 dark-theme:bg-zinc-700/50"
class="flex h-full w-full items-center justify-center bg-zinc-200 dark-theme:bg-zinc-700/50"
>
<i class="pi pi-image text-3xl text-smoke-400" />
<i class="pi pi-image text-3xl text-gray-400" />
</div>
</div>
</template>
<script setup lang="ts">
import { useImage, whenever } from '@vueuse/core'
import LazyImage from '@/components/common/LazyImage.vue'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
const { asset } = defineProps<{
asset: AssetMeta
}>()
const emit = defineEmits<{
'image-loaded': [width: number, height: number]
}>()
const { state, error, isReady } = useImage({
src: asset.src ?? '',
alt: asset.name
})
whenever(
() =>
isReady.value && state.value?.naturalWidth && state.value?.naturalHeight,
() =>
emit('image-loaded', state.value!.naturalWidth, state.value!.naturalHeight)
)
</script>

View File

@@ -1,21 +0,0 @@
<template>
<h3
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
:title="fullName"
>
{{ displayName }}
</h3>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { truncateFilename } from '@/utils/formatUtil'
const props = defineProps<{
fileName: string
}>()
const fullName = computed(() => props.fileName)
const displayName = computed(() => truncateFilename(props.fileName))
</script>

View File

@@ -1,8 +1,13 @@
<template>
<div class="flex flex-col items-center gap-1">
<MediaTitle :file-name="fileName" />
<h3
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
:title="asset.name"
>
{{ fileName }}
</h3>
<div class="flex items-center text-xs text-zinc-400">
<span>{{ formatSize(asset.size) }}</span>
<span>{{ asset.dimensions?.width }}x{{ asset.dimensions?.height }}</span>
</div>
</div>
</template>
@@ -10,10 +15,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
import { getFilenameDetails } from '@/utils/formatUtil'
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
const { asset } = defineProps<{
asset: AssetMeta

View File

@@ -1,6 +1,6 @@
<template>
<div
class="relative size-full overflow-hidden rounded bg-black"
class="relative h-full w-full overflow-hidden rounded bg-black"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
>
@@ -9,7 +9,7 @@
:controls="shouldShowControls"
preload="none"
:poster="asset.preview_url"
class="relative size-full object-contain"
class="relative h-full w-full object-contain"
@click.stop
@play="onVideoPlay"
@pause="onVideoPause"

View File

@@ -1,29 +0,0 @@
import type { Ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
/**
* Interface for media assets providers
* Defines the common API for both cloud and internal file implementations
*/
export interface IAssetsProvider {
/** Current media assets */
media: Ref<AssetItem[]>
/** Loading state indicator */
loading: Ref<boolean>
/** Error state */
error: Ref<unknown>
/**
* Fetch list of media assets
* @returns Promise resolving to array of AssetItem
*/
fetchMediaList: () => Promise<AssetItem[]>
/**
* Refresh the media list (alias for fetchMediaList)
*/
refresh: () => Promise<AssetItem[]>
}

View File

@@ -1,73 +0,0 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetContext } from '@/platform/assets/schemas/mediaAssetSchema'
import { api } from '@/scripts/api'
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
/**
* Extract asset type from tags array
* @param tags The tags array from AssetItem
* @returns The asset type ('input' or 'output')
*/
export function getAssetType(tags?: string[]): AssetContext['type'] {
const tag = tags?.[0]
if (tag === 'output') return 'output'
return 'input'
}
/**
* Maps a TaskItemImpl output to an AssetItem format
* @param taskItem The task item containing execution data
* @param output The output from the task
* @param useDisplayName Whether to truncate the filename for display
* @returns AssetItem formatted object
*/
export function mapTaskOutputToAssetItem(
taskItem: TaskItemImpl,
output: ResultItemImpl
): AssetItem {
const metadata: OutputAssetMetadata = {
promptId: taskItem.promptId,
nodeId: output.nodeId,
subfolder: output.subfolder,
executionTimeInSeconds: taskItem.executionTimeInSeconds,
format: output.format,
workflow: taskItem.workflow
}
return {
id: taskItem.promptId,
name: output.filename,
size: 0,
created_at: taskItem.executionStartTimestamp
? new Date(taskItem.executionStartTimestamp).toISOString()
: new Date().toISOString(),
tags: ['output'],
preview_url: output.url,
user_metadata: metadata
}
}
/**
* Maps input directory file to AssetItem format
* @param filename The filename
* @param index File index for unique ID
* @param directory The directory type
* @returns AssetItem formatted object
*/
export function mapInputFileToAssetItem(
filename: string,
index: number,
directory: 'input' | 'output' = 'input'
): AssetItem {
return {
id: `${directory}-${index}-${filename}`,
name: filename,
size: 0,
created_at: new Date().toISOString(),
tags: [directory],
preview_url: api.apiURL(
`/view?filename=${encodeURIComponent(filename)}&type=${directory}`
)
}
}

View File

@@ -1,46 +0,0 @@
import { computed } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetsStore } from '@/stores/assetsStore'
/**
* Composable for fetching media assets from cloud environment
* Uses AssetsStore for centralized state management
*/
export function useAssetsApi(directory: 'input' | 'output') {
const assetsStore = useAssetsStore()
const media = computed(() =>
directory === 'input' ? assetsStore.inputAssets : assetsStore.historyAssets
)
const loading = computed(() =>
directory === 'input'
? assetsStore.inputLoading
: assetsStore.historyLoading
)
const error = computed(() =>
directory === 'input' ? assetsStore.inputError : assetsStore.historyError
)
const fetchMediaList = async (): Promise<AssetItem[]> => {
if (directory === 'input') {
await assetsStore.updateInputs()
return assetsStore.inputAssets
} else {
await assetsStore.updateHistory()
return assetsStore.historyAssets
}
}
const refresh = () => fetchMediaList()
return {
media,
loading,
error,
fetchMediaList,
refresh
}
}

View File

@@ -1,46 +0,0 @@
import { computed } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetsStore } from '@/stores/assetsStore'
/**
* Composable for fetching media assets from local environment
* Uses AssetsStore for centralized state management
*/
export function useInternalFilesApi(directory: 'input' | 'output') {
const assetsStore = useAssetsStore()
const media = computed(() =>
directory === 'input' ? assetsStore.inputAssets : assetsStore.historyAssets
)
const loading = computed(() =>
directory === 'input'
? assetsStore.inputLoading
: assetsStore.historyLoading
)
const error = computed(() =>
directory === 'input' ? assetsStore.inputError : assetsStore.historyError
)
const fetchMediaList = async (): Promise<AssetItem[]> => {
if (directory === 'input') {
await assetsStore.updateInputs()
return assetsStore.inputAssets
} else {
await assetsStore.updateHistory()
return assetsStore.historyAssets
}
}
const refresh = () => fetchMediaList()
return {
media,
loading,
error,
fetchMediaList,
refresh
}
}

View File

@@ -1,15 +0,0 @@
import { isCloud } from '@/platform/distribution/types'
import type { IAssetsProvider } from './IAssetsProvider'
import { useAssetsApi } from './useAssetsApi'
import { useInternalFilesApi } from './useInternalFilesApi'
/**
* Factory function that returns the appropriate media assets implementation
* based on the current distribution (cloud vs internal)
* @param directory - The directory to fetch assets from
* @returns IAssetsProvider implementation
*/
export function useMediaAssets(directory: 'input' | 'output'): IAssetsProvider {
return isCloud ? useAssetsApi(directory) : useInternalFilesApi(directory)
}

View File

@@ -91,12 +91,12 @@ const DialogDemoComponent = {
>
Change Current Model
</button>
<p class="text-sm text-smoke-600 mt-1">
<p class="text-sm text-gray-600 mt-1">
Opens with "realistic_vision_v5.safetensors" as current value
</p>
</div>
<div class="mt-8 p-4 bg-smoke-100 rounded">
<div class="mt-8 p-4 bg-gray-100 rounded">
<h4 class="font-semibold mb-2">Instructions:</h4>
<ul class="text-sm space-y-1">
<li>• Click any button to open the Asset Browser dialog</li>
@@ -154,9 +154,9 @@ export const Demo: Story = {
<DialogDemoComponent />
<!-- Code Example Section -->
<div class="p-8 border-t border-smoke-200 bg-gray-50">
<div class="p-8 border-t border-gray-200 bg-gray-50">
<h2 class="text-2xl font-bold mb-4">Code Example</h2>
<p class="text-smoke-600 mb-4">
<p class="text-gray-600 mb-4">
This is how you would use the composable in your component:
</p>
<div class="bg-white p-4 rounded-lg border shadow-sm">
@@ -182,7 +182,7 @@ export default {
}
}</code></pre>
</div>
<div class="mt-4 p-3 bg-blue-50 border border-azure-400 rounded">
<div class="mt-4 p-3 bg-blue-50 border border-blue-200 rounded">
<p class="text-sm text-blue-800">
<strong>💡 Try it:</strong> Use the interactive buttons above to see this code in action!
</p>

View File

@@ -1,191 +1,29 @@
/* eslint-disable no-console */
import { useToast } from 'primevue/usetoast'
import { inject } from 'vue'
import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue'
import { downloadFile } from '@/base/common/downloadUtil'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { getOutputAssetMetadata } from '../schemas/assetMetadataSchema'
import { useAssetsStore } from '@/stores/assetsStore'
import { useDialogStore } from '@/stores/dialogStore'
import type { AssetItem } from '../schemas/assetSchema'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import { assetService } from '../services/assetService'
export function useMediaAssetActions() {
const toast = useToast()
const dialogStore = useDialogStore()
const mediaContext = inject(MediaAssetKey, null)
const selectAsset = (asset: AssetMeta) => {
console.log('Asset selected:', asset)
}
const downloadAsset = () => {
const asset = mediaContext?.asset.value
if (!asset) return
try {
const assetType = asset.tags?.[0] || 'output'
const filename = asset.name
const downloadUrl = api.apiURL(
`/view?filename=${encodeURIComponent(filename)}&type=${assetType}`
)
downloadFile(downloadUrl, filename)
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('g.downloadStarted'),
life: 2000
})
} catch (error) {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadImage'),
life: 3000
})
}
const downloadAsset = (assetId: string) => {
console.log('Downloading asset:', assetId)
}
/**
* Show confirmation dialog and delete asset if confirmed
* @param asset The asset to delete
* @returns true if the asset was deleted, false otherwise
*/
const confirmDelete = async (asset: AssetItem): Promise<boolean> => {
const assetType = asset.tags?.[0] || 'output'
return new Promise((resolve) => {
dialogStore.showDialog({
key: 'delete-asset-confirmation',
title: t('mediaAsset.deleteAssetTitle'),
component: ConfirmationDialogContent,
props: {
message: t('mediaAsset.deleteAssetDescription'),
type: 'delete',
itemList: [asset.name],
onConfirm: async () => {
const success = await deleteAsset(asset, assetType)
resolve(success)
},
onCancel: () => {
resolve(false)
}
}
})
})
}
const deleteAsset = async (asset: AssetItem, assetType: string) => {
const assetsStore = useAssetsStore()
try {
if (assetType === 'output') {
// For output files, delete from history
const promptId =
asset.id || getOutputAssetMetadata(asset.user_metadata)?.promptId
if (!promptId) {
throw new Error('Unable to extract prompt ID from asset')
}
await api.deleteItem('history', promptId)
// Update history assets in store after deletion
await assetsStore.updateHistory()
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('mediaAsset.assetDeletedSuccessfully'),
life: 2000
})
return true
} else {
// For input files, only allow deletion in cloud environment
if (!isCloud) {
toast.add({
severity: 'warn',
summary: t('g.warning'),
detail: t('mediaAsset.deletingImportedFilesCloudOnly'),
life: 3000
})
return false
}
// In cloud environment, use the assets API to delete
await assetService.deleteAsset(asset.id)
// Update input assets in store after deletion
await assetsStore.updateInputs()
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('mediaAsset.assetDeletedSuccessfully'),
life: 2000
})
return true
}
} catch (error) {
console.error('Failed to delete asset:', error)
toast.add({
severity: 'error',
summary: t('g.error'),
detail:
error instanceof Error
? error.message
: t('mediaAsset.failedToDeleteAsset'),
life: 3000
})
return false
}
const deleteAsset = (assetId: string) => {
console.log('Deleting asset:', assetId)
}
const playAsset = (assetId: string) => {
console.log('Playing asset:', assetId)
}
const copyJobId = async () => {
const asset = mediaContext?.asset.value
if (!asset) return
const copyAssetUrl = (assetId: string) => {
console.log('Copy asset URL:', assetId)
}
// Get promptId from metadata instead of parsing the ID string
const metadata = getOutputAssetMetadata(asset.user_metadata)
const promptId = metadata?.promptId
if (!promptId) {
toast.add({
severity: 'warn',
summary: t('g.warning'),
detail: 'No job ID found for this asset',
life: 2000
})
return
}
try {
await navigator.clipboard.writeText(promptId)
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('mediaAsset.jobIdToast.jobIdCopied'),
life: 2000
})
} catch (error) {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed'),
life: 3000
})
}
const copyJobId = (jobId: string) => {
console.log('Copy job ID:', jobId)
}
const addWorkflow = (assetId: string) => {
@@ -207,9 +45,9 @@ export function useMediaAssetActions() {
return {
selectAsset,
downloadAsset,
confirmDelete,
deleteAsset,
playAsset,
copyAssetUrl,
copyJobId,
addWorkflow,
openWorkflow,

Some files were not shown because too many files have changed in this diff Show More