Compare commits
42 Commits
feat/works
...
bl-remove-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d62e57fe2 | ||
|
|
258a1d5d75 | ||
|
|
55eb737024 | ||
|
|
3e61950237 | ||
|
|
7c67a253d0 | ||
|
|
a08ccb55c1 | ||
|
|
5c142275ad | ||
|
|
4a5e7c8bcb | ||
|
|
df93277802 | ||
|
|
db16cfbb2b | ||
|
|
92357445e4 | ||
|
|
93e7a4f9f9 | ||
|
|
8261e1d187 | ||
|
|
e4d2bc2b59 | ||
|
|
482159957e | ||
|
|
f08b0f44ef | ||
|
|
f1d1747582 | ||
|
|
d12c6d7814 | ||
|
|
2db246f494 | ||
|
|
66e6f24980 | ||
|
|
fcf2e0e639 | ||
|
|
f23c291f25 | ||
|
|
7b701ad07b | ||
|
|
9a6ead37cb | ||
|
|
7a1a2c1abb | ||
|
|
e503482c6f | ||
|
|
c7b5f47055 | ||
|
|
d9e5b07c73 | ||
|
|
73cfbd9833 | ||
|
|
9669100c14 | ||
|
|
ac825bdb87 | ||
|
|
327e37db04 | ||
|
|
47714c2740 | ||
|
|
f7ac0aa39e | ||
|
|
e83f33aeb2 | ||
|
|
b1dfbfaa09 | ||
|
|
e6ef99e92c | ||
|
|
f5a784e561 | ||
|
|
e8b45204f2 | ||
|
|
5df793b721 | ||
|
|
79d3b2c291 | ||
|
|
916c1248e3 |
@@ -122,7 +122,7 @@ echo " pnpm build - Build for production"
|
||||
echo " pnpm test:unit - Run unit tests"
|
||||
echo " pnpm typecheck - Run TypeScript checks"
|
||||
echo " pnpm lint - Run ESLint"
|
||||
echo " pnpm format - Format code with Prettier"
|
||||
echo " pnpm format - Format code with oxfmt"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Run 'pnpm dev' to start developing"
|
||||
|
||||
6
.github/workflows/ci-lint-format.yaml
vendored
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Run Stylelint with auto-fix
|
||||
run: pnpm stylelint:fix
|
||||
|
||||
- name: Run Prettier with auto-format
|
||||
- name: Run oxfmt with auto-format
|
||||
run: pnpm format
|
||||
|
||||
- name: Check for changes
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add .
|
||||
git commit -m "[automated] Apply ESLint and Prettier fixes"
|
||||
git commit -m "[automated] Apply ESLint and Oxfmt fixes"
|
||||
git push
|
||||
|
||||
- name: Final validation
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Prettier formatting'
|
||||
body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Oxfmt formatting'
|
||||
})
|
||||
|
||||
- name: Comment on PR about manual fix needed
|
||||
|
||||
18
.i18nrc.cjs
@@ -1,7 +1,7 @@
|
||||
// This file is intentionally kept in CommonJS format (.cjs)
|
||||
// to resolve compatibility issues with dependencies that require CommonJS.
|
||||
// Do not convert this file to ESModule format unless all dependencies support it.
|
||||
const { defineConfig } = require('@lobehub/i18n-cli');
|
||||
const { defineConfig } = require('@lobehub/i18n-cli')
|
||||
|
||||
module.exports = defineConfig({
|
||||
modelName: 'gpt-4.1',
|
||||
@@ -10,7 +10,19 @@ module.exports = defineConfig({
|
||||
entry: 'src/locales/en',
|
||||
entryLocale: 'en',
|
||||
output: 'src/locales',
|
||||
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR', 'fa'],
|
||||
outputLocales: [
|
||||
'zh',
|
||||
'zh-TW',
|
||||
'ru',
|
||||
'ja',
|
||||
'ko',
|
||||
'fr',
|
||||
'es',
|
||||
'ar',
|
||||
'tr',
|
||||
'pt-BR',
|
||||
'fa'
|
||||
],
|
||||
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face.
|
||||
'latent' is the short form of 'latent space'.
|
||||
'mask' is in the context of image processing.
|
||||
@@ -26,4 +38,4 @@ module.exports = defineConfig({
|
||||
- Use Arabic-Indic numerals (۰-۹) for numbers where appropriate.
|
||||
- Maintain consistency with terminology used in Persian software and design applications.
|
||||
`
|
||||
});
|
||||
})
|
||||
|
||||
20
.oxfmtrc.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 80,
|
||||
"ignorePatterns": [
|
||||
"packages/registry-types/src/comfyRegistryTypes.ts",
|
||||
"src/types/generatedManagerTypes.ts",
|
||||
"**/*.md",
|
||||
"**/*.json",
|
||||
"**/*.css",
|
||||
"**/*.yaml",
|
||||
"**/*.yml",
|
||||
"**/*.html",
|
||||
"**/*.svg",
|
||||
"**/*.xml"
|
||||
]
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
packages/registry-types/src/comfyRegistryTypes.ts
|
||||
src/types/generatedManagerTypes.ts
|
||||
11
.prettierrc
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 80,
|
||||
"importOrder": ["^@core/(.*)$", "<THIRD_PARTY_MODULES>", "^@/(.*)$", "^[./]"],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true,
|
||||
"plugins": ["@prettier/plugin-oxc", "@trivago/prettier-plugin-sort-imports"]
|
||||
}
|
||||
15
.vscode/extensions.json
vendored
@@ -1,25 +1,22 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"antfu.vite",
|
||||
"austenc.tailwind-docs",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"davidanson.vscode-markdownlint",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"donjayamanne.githistory",
|
||||
"eamodio.gitlens",
|
||||
"esbenp.prettier-vscode",
|
||||
"figma.figma-vscode-extension",
|
||||
"github.vscode-github-actions",
|
||||
"github.vscode-pull-request-github",
|
||||
"hbenl.vscode-test-explorer",
|
||||
"kisstkondoros.vscode-codemetrics",
|
||||
"lokalise.i18n-ally",
|
||||
"ms-playwright.playwright",
|
||||
"oxc.oxc-vscode",
|
||||
"sonarsource.sonarlint-vscode",
|
||||
"vitest.explorer",
|
||||
"vue.volar",
|
||||
"sonarsource.sonarlint-vscode",
|
||||
"deque-systems.vscode-axe-linter",
|
||||
"kisstkondoros.vscode-codemetrics",
|
||||
"donjayamanne.githistory",
|
||||
"wix.vscode-import-cost",
|
||||
"prograhammer.tslint-vue",
|
||||
"antfu.vite"
|
||||
"wix.vscode-import-cost"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -27,10 +27,10 @@ See @docs/guidance/*.md for file-type-specific conventions (auto-loaded by glob)
|
||||
- Build output: `dist/`
|
||||
- Configs
|
||||
- `vite.config.mts`
|
||||
- `vitest.config.ts`
|
||||
- `playwright.config.ts`
|
||||
- `eslint.config.ts`
|
||||
- `.prettierrc`
|
||||
- `.oxfmtrc.json`
|
||||
- `.oxlintrc.json`
|
||||
- etc.
|
||||
|
||||
## Monorepo Architecture
|
||||
@@ -46,7 +46,7 @@ The project uses **Nx** for build orchestration and task management
|
||||
- `pnpm test:unit`: Run Vitest unit tests
|
||||
- `pnpm test:browser`: Run Playwright E2E tests (`browser_tests/`)
|
||||
- `pnpm lint` / `pnpm lint:fix`: Lint (ESLint)
|
||||
- `pnpm format` / `pnpm format:check`: Prettier
|
||||
- `pnpm format` / `pnpm format:check`: oxfmt
|
||||
- `pnpm typecheck`: Vue TSC type checking
|
||||
- `pnpm storybook`: Start Storybook development server
|
||||
|
||||
@@ -72,7 +72,7 @@ The project uses **Nx** for build orchestration and task management
|
||||
- Composition API only
|
||||
- Tailwind 4 styling
|
||||
- Avoid `<style>` blocks
|
||||
- Style: (see `.prettierrc`)
|
||||
- Style: (see `.oxfmtrc.json`)
|
||||
- Indent 2 spaces
|
||||
- single quotes
|
||||
- no trailing semicolons
|
||||
|
||||
@@ -79,48 +79,15 @@ export class SubgraphSlotReference {
|
||||
|
||||
const node =
|
||||
type === 'input' ? currentGraph.inputNode : currentGraph.outputNode
|
||||
const slots =
|
||||
type === 'input' ? currentGraph.inputs : currentGraph.outputs
|
||||
|
||||
if (!node) {
|
||||
throw new Error(`No ${type} node found in subgraph`)
|
||||
}
|
||||
|
||||
// Calculate position for next available slot
|
||||
// const nextSlotIndex = slots?.length || 0
|
||||
// const slotHeight = 20
|
||||
// const slotY = node.pos[1] + 30 + nextSlotIndex * slotHeight
|
||||
|
||||
// Find last slot position
|
||||
const lastSlot = slots.at(-1)
|
||||
let slotX: number
|
||||
let slotY: number
|
||||
|
||||
if (lastSlot) {
|
||||
// If there are existing slots, position the new one below the last one
|
||||
const gapHeight = 20
|
||||
slotX = lastSlot.pos[0]
|
||||
slotY = lastSlot.pos[1] + gapHeight
|
||||
} else {
|
||||
// No existing slots - use slotAnchorX if available, otherwise calculate from node position
|
||||
if (currentGraph.slotAnchorX !== undefined) {
|
||||
// The actual slot X position seems to be slotAnchorX - 10
|
||||
slotX = currentGraph.slotAnchorX - 10
|
||||
} else {
|
||||
// Fallback: calculate from node edge
|
||||
slotX =
|
||||
type === 'input'
|
||||
? node.pos[0] + node.size[0] - 10 // Right edge for input node
|
||||
: node.pos[0] + 10 // Left edge for output node
|
||||
}
|
||||
// For Y position when no slots exist, use middle of node
|
||||
slotY = node.pos[1] + node.size[1] / 2
|
||||
}
|
||||
|
||||
// Convert from offset to canvas coordinates
|
||||
const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([
|
||||
slotX,
|
||||
slotY
|
||||
node.emptySlot.pos[0],
|
||||
node.emptySlot.pos[1]
|
||||
])
|
||||
return canvasPos
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
@@ -189,9 +189,7 @@ test.describe('Templates', () => {
|
||||
const templateGrid = comfyPage.page.locator(
|
||||
'[data-testid="template-workflows-content"]'
|
||||
)
|
||||
const nav = comfyPage.page
|
||||
.locator('header')
|
||||
.filter({ hasText: 'Templates' })
|
||||
const nav = comfyPage.page.locator('header', { hasText: 'Templates' })
|
||||
|
||||
await comfyPage.templates.waitForMinimumCardCount(1)
|
||||
await expect(templateGrid).toBeVisible()
|
||||
@@ -201,7 +199,8 @@ test.describe('Templates', () => {
|
||||
await comfyPage.page.setViewportSize(mobileSize)
|
||||
await comfyPage.templates.waitForMinimumCardCount(1)
|
||||
await expect(templateGrid).toBeVisible()
|
||||
await expect(nav).not.toBeVisible() // Nav should collapse at mobile size
|
||||
// Nav header is clipped by overflow-hidden parent at mobile size
|
||||
await expect(nav).not.toBeInViewport()
|
||||
|
||||
const tabletSize = { width: 1024, height: 800 }
|
||||
await comfyPage.page.setViewportSize(tabletSize)
|
||||
|
||||
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
@@ -76,6 +76,7 @@ function getModuleName(id: string): string {
|
||||
export function comfyAPIPlugin(isDev: boolean): Plugin {
|
||||
return {
|
||||
name: 'comfy-api-plugin',
|
||||
apply: 'build',
|
||||
transform(code: string, id: string) {
|
||||
if (isDev) return null
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
|
||||
@@ -30,6 +30,10 @@ describe('MyStore', () => {
|
||||
|
||||
**Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior.
|
||||
|
||||
## i18n in Component Tests
|
||||
|
||||
Use real `createI18n` with empty messages instead of mocking `vue-i18n`. See `SearchBox.test.ts` for example.
|
||||
|
||||
## Mock Patterns
|
||||
|
||||
### Reset all mocks at once
|
||||
|
||||
@@ -4,9 +4,7 @@ import pluginI18n from '@intlify/eslint-plugin-vue-i18n'
|
||||
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
|
||||
import { importX } from 'eslint-plugin-import-x'
|
||||
import oxlint from 'eslint-plugin-oxlint'
|
||||
// WORKAROUND: eslint-plugin-prettier causes segfault on Node.js 24 + Windows
|
||||
// See: https://github.com/nodejs/node/issues/58690
|
||||
// Prettier is still run separately in lint-staged, so this is safe to disable
|
||||
// eslint-config-prettier disables ESLint rules that conflict with formatters (oxfmt)
|
||||
import eslintConfigPrettier from 'eslint-config-prettier'
|
||||
import { configs as storybookConfigs } from 'eslint-plugin-storybook'
|
||||
import unusedImports from 'eslint-plugin-unused-imports'
|
||||
@@ -111,7 +109,7 @@ export default defineConfig([
|
||||
tseslintConfigs.recommended,
|
||||
// Difference in typecheck on CI vs Local
|
||||
pluginVue.configs['flat/recommended'],
|
||||
// Use eslint-config-prettier instead of eslint-plugin-prettier to avoid Node 24 segfault
|
||||
// Disables ESLint rules that conflict with formatters
|
||||
eslintConfigPrettier,
|
||||
// @ts-expect-error Type incompatibility between storybook plugin and ESLint config types
|
||||
storybookConfigs['flat/recommended'],
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import path from 'node:path'
|
||||
|
||||
export default {
|
||||
'tests-ui/**': () => 'echo "Files in tests-ui/ are deprecated. Colocate tests with source files." && exit 1',
|
||||
|
||||
'./**/*.js': (stagedFiles) => formatAndEslint(stagedFiles),
|
||||
|
||||
'./**/*.{ts,tsx,vue,mts}': (stagedFiles) => [
|
||||
...formatAndEslint(stagedFiles),
|
||||
'pnpm typecheck'
|
||||
]
|
||||
}
|
||||
|
||||
function formatAndEslint(fileNames) {
|
||||
// Convert absolute paths to relative paths for better ESLint resolution
|
||||
const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f))
|
||||
const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ')
|
||||
return [
|
||||
`pnpm exec prettier --cache --write ${joinedPaths}`,
|
||||
`pnpm exec oxlint --fix ${joinedPaths}`,
|
||||
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import path from 'node:path'
|
||||
|
||||
export default {
|
||||
'tests-ui/**': () =>
|
||||
'echo "Files in tests-ui/ are deprecated. Colocate tests with source files." && exit 1',
|
||||
|
||||
'./**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles),
|
||||
|
||||
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => [
|
||||
@@ -14,7 +17,7 @@ function formatAndEslint(fileNames: string[]) {
|
||||
const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f))
|
||||
const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ')
|
||||
return [
|
||||
`pnpm exec prettier --cache --write ${joinedPaths}`,
|
||||
`pnpm exec oxfmt --write ${joinedPaths}`,
|
||||
`pnpm exec oxlint --fix ${joinedPaths}`,
|
||||
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
|
||||
]
|
||||
|
||||
13
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.38.7",
|
||||
"version": "1.38.9",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -22,10 +22,8 @@
|
||||
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true nx serve",
|
||||
"dev": "nx serve",
|
||||
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
|
||||
"format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",
|
||||
"format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}' --cache",
|
||||
"format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --list-different",
|
||||
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache --list-different",
|
||||
"format:check": "oxfmt --check",
|
||||
"format": "oxfmt --write",
|
||||
"json-schema": "tsx scripts/generate-json-schema.ts",
|
||||
"knip:no-cache": "knip",
|
||||
"knip": "knip --cache",
|
||||
@@ -63,14 +61,12 @@
|
||||
"@nx/vite": "catalog:",
|
||||
"@pinia/testing": "catalog:",
|
||||
"@playwright/test": "catalog:",
|
||||
"@prettier/plugin-oxc": "catalog:",
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
"@storybook/addon-docs": "catalog:",
|
||||
"@storybook/addon-mcp": "catalog:",
|
||||
"@storybook/vue3": "catalog:",
|
||||
"@storybook/vue3-vite": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@trivago/prettier-plugin-sort-imports": "catalog:",
|
||||
"@types/fs-extra": "catalog:",
|
||||
"@types/jsdom": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
@@ -101,11 +97,11 @@
|
||||
"markdown-table": "catalog:",
|
||||
"mixpanel-browser": "catalog:",
|
||||
"nx": "catalog:",
|
||||
"oxfmt": "catalog:",
|
||||
"oxlint": "catalog:",
|
||||
"oxlint-tsgolint": "catalog:",
|
||||
"picocolors": "catalog:",
|
||||
"postcss-html": "catalog:",
|
||||
"prettier": "catalog:",
|
||||
"pretty-bytes": "catalog:",
|
||||
"rollup-plugin-visualizer": "catalog:",
|
||||
"storybook": "catalog:",
|
||||
@@ -173,6 +169,7 @@
|
||||
"firebase": "catalog:",
|
||||
"fuse.js": "^7.0.0",
|
||||
"glob": "^11.0.3",
|
||||
"jsonata": "catalog:",
|
||||
"jsondiffpatch": "^0.6.0",
|
||||
"loglevel": "^1.9.2",
|
||||
"marked": "^15.0.11",
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<clipPath id="hollow">
|
||||
<path
|
||||
d="M -50 50
|
||||
A 100 100, 0, 0, 1, 150 50
|
||||
A 100 100, 0, 0, 1, -50 50
|
||||
M 30 50
|
||||
A 20 20, 0, 0, 0, 70 50
|
||||
A 20 20, 0, 0, 0, 30 50"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clip-path="var(--shape)" stroke-width="4">
|
||||
<path d="M 50 0 A 50 50, 0, 0, 1, 50 100" fill="var(--type1, red)"/>
|
||||
<path d="M 50 100 A 50 50, 0, 0, 1, 50 0" fill="var(--type2, blue)"/>
|
||||
<path d="M50 0L50 100" stroke="var(--inner-stroke, black)"/>
|
||||
<path d="M50 2A48 48 0 0 1 50 98A48 48 0 0 1 50 2" fill="transparent" stroke="var(--outer-stroke, transparent)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 693 B |
@@ -1,20 +0,0 @@
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<clipPath id="hollow">
|
||||
<path
|
||||
d="M-50 50
|
||||
A100 100 0 0 1 150 50
|
||||
A100 100 0 0 1 -50 50
|
||||
M30 50
|
||||
A20 20 0 0 0 70 50
|
||||
A20 20 0 0 0 30 50"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clip-path="var(--shape)" stroke-width="4">
|
||||
<path d="M50 0A50 50 0 0 1 93 75L50 50" fill="var(--type1, red)"/>
|
||||
<path d="M93 75A50 50 0 0 1 7 75L50 50" fill="var(--type2, blue)"/>
|
||||
<path d="M7 75A50 50 0 0 1 50 0L50 50" fill="var(--type3, green)"/>
|
||||
<path d="M50 50L50 0M50 50L93 75M50 50L7 75" stroke="var(--inner-stroke, black)"/>
|
||||
<path d="M50 2A48 48 0 0 1 50 98A48 48 0 0 1 50 2" fill="transparent" stroke="var(--outer-stroke, transparent)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 763 B |
335
pnpm-lock.yaml
generated
@@ -48,9 +48,6 @@ catalogs:
|
||||
'@playwright/test':
|
||||
specifier: ^1.57.0
|
||||
version: 1.57.0
|
||||
'@prettier/plugin-oxc':
|
||||
specifier: ^0.1.3
|
||||
version: 0.1.3
|
||||
'@primeuix/forms':
|
||||
specifier: 0.0.2
|
||||
version: 0.0.2
|
||||
@@ -96,9 +93,6 @@ catalogs:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.12
|
||||
version: 4.1.12
|
||||
'@trivago/prettier-plugin-sort-imports':
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.2
|
||||
'@types/fs-extra':
|
||||
specifier: ^11.0.4
|
||||
version: 11.0.4
|
||||
@@ -192,6 +186,9 @@ catalogs:
|
||||
jsdom:
|
||||
specifier: ^27.4.0
|
||||
version: 27.4.0
|
||||
jsonata:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
knip:
|
||||
specifier: ^5.75.1
|
||||
version: 5.75.1
|
||||
@@ -207,6 +204,9 @@ catalogs:
|
||||
nx:
|
||||
specifier: 22.2.6
|
||||
version: 22.2.6
|
||||
oxfmt:
|
||||
specifier: ^0.26.0
|
||||
version: 0.26.0
|
||||
oxlint:
|
||||
specifier: ^1.33.0
|
||||
version: 1.33.0
|
||||
@@ -222,9 +222,6 @@ catalogs:
|
||||
postcss-html:
|
||||
specifier: ^1.8.0
|
||||
version: 1.8.0
|
||||
prettier:
|
||||
specifier: ^3.7.4
|
||||
version: 3.7.4
|
||||
pretty-bytes:
|
||||
specifier: ^7.1.0
|
||||
version: 7.1.0
|
||||
@@ -455,6 +452,9 @@ importers:
|
||||
glob:
|
||||
specifier: ^11.0.3
|
||||
version: 11.0.3
|
||||
jsonata:
|
||||
specifier: 'catalog:'
|
||||
version: 2.1.0
|
||||
jsondiffpatch:
|
||||
specifier: ^0.6.0
|
||||
version: 0.6.0
|
||||
@@ -540,9 +540,6 @@ importers:
|
||||
'@playwright/test':
|
||||
specifier: 'catalog:'
|
||||
version: 1.57.0
|
||||
'@prettier/plugin-oxc':
|
||||
specifier: 'catalog:'
|
||||
version: 0.1.3
|
||||
'@sentry/vite-plugin':
|
||||
specifier: 'catalog:'
|
||||
version: 4.6.0
|
||||
@@ -561,9 +558,6 @@ importers:
|
||||
'@tailwindcss/vite':
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.12(vite@8.0.0-beta.8(@types/node@24.10.4)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
|
||||
'@trivago/prettier-plugin-sort-imports':
|
||||
specifier: 'catalog:'
|
||||
version: 5.2.2(@vue/compiler-sfc@3.5.25)(prettier@3.7.4)
|
||||
'@types/fs-extra':
|
||||
specifier: 'catalog:'
|
||||
version: 11.0.4
|
||||
@@ -654,6 +648,9 @@ importers:
|
||||
nx:
|
||||
specifier: 'catalog:'
|
||||
version: 22.2.6
|
||||
oxfmt:
|
||||
specifier: 'catalog:'
|
||||
version: 0.26.0
|
||||
oxlint:
|
||||
specifier: 'catalog:'
|
||||
version: 1.33.0(oxlint-tsgolint@0.9.1)
|
||||
@@ -666,9 +663,6 @@ importers:
|
||||
postcss-html:
|
||||
specifier: 'catalog:'
|
||||
version: 1.8.0
|
||||
prettier:
|
||||
specifier: 'catalog:'
|
||||
version: 3.7.4
|
||||
pretty-bytes:
|
||||
specifier: 'catalog:'
|
||||
version: 7.1.0
|
||||
@@ -2517,95 +2511,6 @@ packages:
|
||||
'@one-ini/wasm@0.1.1':
|
||||
resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==}
|
||||
|
||||
'@oxc-parser/binding-android-arm64@0.99.0':
|
||||
resolution: {integrity: sha512-V4jhmKXgQQdRnm73F+r3ZY4pUEsijQeSraFeaCGng7abSNJGs76X6l82wHnmjLGFAeY00LWtjcELs7ZmbJ9+lA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@oxc-parser/binding-darwin-arm64@0.99.0':
|
||||
resolution: {integrity: sha512-Rp41nf9zD5FyLZciS9l1GfK8PhYqrD5kEGxyTOA2esTLeAy37rZxetG2E3xteEolAkeb2WDkVrlxPtibeAncMg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxc-parser/binding-darwin-x64@0.99.0':
|
||||
resolution: {integrity: sha512-WVonp40fPPxo5Gs0POTI57iEFv485TvNKOHMwZRhigwZRhZY2accEAkYIhei9eswF4HN5B44Wybkz7Gd1Qr/5Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxc-parser/binding-freebsd-x64@0.99.0':
|
||||
resolution: {integrity: sha512-H30bjOOttPmG54gAqu6+HzbLEzuNOYO2jZYrIq4At+NtLJwvNhXz28Hf5iEAFZIH/4hMpLkM4VN7uc+5UlNW3Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@oxc-parser/binding-linux-arm-gnueabihf@0.99.0':
|
||||
resolution: {integrity: sha512-0Z/Th0SYqzSRDPs6tk5lQdW0i73UCupnim3dgq2oW0//UdLonV/5wIZCArfKGC7w9y4h8TxgXpgtIyD1kKzzlQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxc-parser/binding-linux-arm-musleabihf@0.99.0':
|
||||
resolution: {integrity: sha512-xo0wqNd5bpbzQVNpAIFbHk1xa+SaS/FGBABCd942SRTnrpxl6GeDj/s1BFaGcTl8MlwlKVMwOcyKrw/2Kdfquw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxc-parser/binding-linux-arm64-gnu@0.99.0':
|
||||
resolution: {integrity: sha512-u26I6LKoLTPTd4Fcpr0aoAtjnGf5/ulMllo+QUiBhupgbVCAlaj4RyXH/mvcjcsl2bVBv9E/gYJZz2JjxQWXBA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@oxc-parser/binding-linux-arm64-musl@0.99.0':
|
||||
resolution: {integrity: sha512-qhftDo2D37SqCEl3ZTa367NqWSZNb1Ddp34CTmShLKFrnKdNiUn55RdokLnHtf1AL5ssaQlYDwBECX7XiBWOhw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@oxc-parser/binding-linux-riscv64-gnu@0.99.0':
|
||||
resolution: {integrity: sha512-zxn/xkf519f12FKkpL5XwJipsylfSSnm36h6c1zBDTz4fbIDMGyIhHfWfwM7uUmHo9Aqw1pLxFpY39Etv398+Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@oxc-parser/binding-linux-s390x-gnu@0.99.0':
|
||||
resolution: {integrity: sha512-Y1eSDKDS5E4IVC7Oxw+NbYAKRmJPMJTIjW+9xOWwteDHkFqpocKe0USxog+Q1uhzalD9M0p9eXWEWdGQCMDBMQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@oxc-parser/binding-linux-x64-gnu@0.99.0':
|
||||
resolution: {integrity: sha512-YVJMfk5cFWB8i2/nIrbk6n15bFkMHqWnMIWkVx7r2KwpTxHyFMfu2IpeVKo1ITDSmt5nBrGdLHD36QRlu2nDLg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@oxc-parser/binding-linux-x64-musl@0.99.0':
|
||||
resolution: {integrity: sha512-2+SDPrie5f90A1b9EirtVggOgsqtsYU5raZwkDYKyS1uvJzjqHCDhG/f4TwQxHmIc5YkczdQfwvN91lwmjsKYQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@oxc-parser/binding-wasm32-wasi@0.99.0':
|
||||
resolution: {integrity: sha512-DKA4j0QerUWSMADziLM5sAyM7V53Fj95CV9SjP77bPfEfT7MnvFKnneaRMqPK1cpzjAGiQF52OBUIKyk0dwOQA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@oxc-parser/binding-win32-arm64-msvc@0.99.0':
|
||||
resolution: {integrity: sha512-EaB3AvsxqdNUhh9FOoAxRZ2L4PCRwDlDb//QXItwyOJrX7XS+uGK9B1KEUV4FZ/7rDhHsWieLt5e07wl2Ti5AQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxc-parser/binding-win32-x64-msvc@0.99.0':
|
||||
resolution: {integrity: sha512-sJN1Q8h7ggFOyDn0zsHaXbP/MklAVUvhrbq0LA46Qum686P3SZQHjbATqJn9yaVEvaSKXCshgl0vQ1gWkGgpcQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@oxc-project/runtime@0.108.0':
|
||||
resolution: {integrity: sha512-J1cESY4anMO4i9KtCPmCfQAzAR00Uw4SWsDPFP10CIwDMugkh34UrTKByuYKuPaHy0XAk8LlJiZJq2OLMfbuIQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -2613,9 +2518,6 @@ packages:
|
||||
'@oxc-project/types@0.108.0':
|
||||
resolution: {integrity: sha512-7lf13b2IA/kZO6xgnIZA88sq3vwrxWk+2vxf6cc+omwYCRTiA5e63Beqf3fz/v8jEviChWWmFYBwzfSeyrsj7Q==}
|
||||
|
||||
'@oxc-project/types@0.99.0':
|
||||
resolution: {integrity: sha512-LLDEhXB7g1m5J+woRSgfKsFPS3LhR9xRhTeIoEBm5WrkwMxn6eZ0Ld0c0K5eHB57ChZX6I3uSmmLjZ8pcjlRcw==}
|
||||
|
||||
'@oxc-resolver/binding-android-arm-eabi@11.15.0':
|
||||
resolution: {integrity: sha512-Q+lWuFfq7whNelNJIP1dhXaVz4zO9Tu77GcQHyxDWh3MaCoO2Bisphgzmsh4ZoUe2zIchQh6OvQL99GlWHg9Tw==}
|
||||
cpu: [arm]
|
||||
@@ -2716,6 +2618,46 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@oxfmt/darwin-arm64@0.26.0':
|
||||
resolution: {integrity: sha512-AAGc+8CffkiWeVgtWf4dPfQwHEE5c/j/8NWH7VGVxxJRCZFdmWcqCXprvL2H6qZFewvDLrFbuSPRCqYCpYGaTQ==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxfmt/darwin-x64@0.26.0':
|
||||
resolution: {integrity: sha512-xFx5ijCTjw577wJvFlZEMmKDnp3HSCcbYdCsLRmC5i3TZZiDe9DEYh3P46uqhzj8BkEw1Vm1ZCWdl48aEYAzvQ==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxfmt/linux-arm64-gnu@0.26.0':
|
||||
resolution: {integrity: sha512-GubkQeQT5d3B/Jx/IiR7NMkSmXrCZcVI0BPh1i7mpFi8HgD1hQ/LbhiBKAMsMqs5bbugdQOgBEl8bOhe8JhW1g==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/linux-arm64-musl@0.26.0':
|
||||
resolution: {integrity: sha512-OEypUwK69bFPj+aa3/LYCnlIUPgoOLu//WNcriwpnWNmt47808Ht7RJSg+MNK8a7pSZHpXJ5/E6CRK/OTwFdaQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/linux-x64-gnu@0.26.0':
|
||||
resolution: {integrity: sha512-xO6iEW2bC6ZHyOTPmPWrg/nM6xgzyRPaS84rATy6F8d79wz69LdRdJ3l/PXlkqhi7XoxhvX4ExysA0Nf10ZZEQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/linux-x64-musl@0.26.0':
|
||||
resolution: {integrity: sha512-Z3KuZFC+MIuAyFCXBHY71kCsdRq1ulbsbzTe71v+hrEv7zVBn6yzql+/AZcgfIaKzWO9OXNuz5WWLWDmVALwow==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/win32-arm64@0.26.0':
|
||||
resolution: {integrity: sha512-3zRbqwVWK1mDhRhTknlQFpRFL9GhEB5GfU6U7wawnuEwpvi39q91kJ+SRJvJnhyPCARkjZBd1V8XnweN5IFd1g==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxfmt/win32-x64@0.26.0':
|
||||
resolution: {integrity: sha512-m8TfIljU22i9UEIkD+slGPifTFeaCwIUfxszN3E6ABWP1KQbtwSw9Ak0TdoikibvukF/dtbeyG3WW63jv9DnEg==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint-tsgolint/darwin-arm64@0.9.1':
|
||||
resolution: {integrity: sha512-vk+8kChWqN+F+QUOvp4/6jDTlDCzXPgYGkxdi6EOUSOmCP1ix0uYOlIi/ytH2imXmC8YfPgLR/1BhqbsuDKuew==}
|
||||
cpu: [arm64]
|
||||
@@ -2824,10 +2766,6 @@ packages:
|
||||
'@polka/url@1.0.0-next.29':
|
||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||
|
||||
'@prettier/plugin-oxc@0.1.3':
|
||||
resolution: {integrity: sha512-aABz3zIRilpWMekbt1FL1JVBQrQLR8L4Td2SRctECrWSsXGTNn/G1BqNSKCdbvQS1LWstAXfqcXzDki7GAAJyg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@primeuix/forms@0.0.2':
|
||||
resolution: {integrity: sha512-DpecPQd/Qf/kav4LKCaIeGuT3AkwhJzuHCkLANTVlN/zBvo8KIj3OZHsCkm0zlIMVVnaJdtx1ULNlRQdudef+A==}
|
||||
engines: {node: '>=12.11.0'}
|
||||
@@ -3571,22 +3509,6 @@ packages:
|
||||
'@tmcp/auth':
|
||||
optional: true
|
||||
|
||||
'@trivago/prettier-plugin-sort-imports@5.2.2':
|
||||
resolution: {integrity: sha512-fYDQA9e6yTNmA13TLVSA+WMQRc5Bn/c0EUBditUHNfMMxN7M82c38b1kEggVE3pLpZ0FwkwJkUEKMiOi52JXFA==}
|
||||
engines: {node: '>18.12'}
|
||||
peerDependencies:
|
||||
'@vue/compiler-sfc': 3.x
|
||||
prettier: 2.x - 3.x
|
||||
prettier-plugin-svelte: 3.x
|
||||
svelte: 4.x || 5.x
|
||||
peerDependenciesMeta:
|
||||
'@vue/compiler-sfc':
|
||||
optional: true
|
||||
prettier-plugin-svelte:
|
||||
optional: true
|
||||
svelte:
|
||||
optional: true
|
||||
|
||||
'@tweenjs/tween.js@23.1.3':
|
||||
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
|
||||
|
||||
@@ -6039,9 +5961,6 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
javascript-natural-sort@0.7.1:
|
||||
resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==}
|
||||
|
||||
jest-diff@30.2.0:
|
||||
resolution: {integrity: sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==}
|
||||
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
@@ -6132,6 +6051,10 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
jsonata@2.1.0:
|
||||
resolution: {integrity: sha512-OCzaRMK8HobtX8fp37uIVmL8CY1IGc/a6gLsDqz3quExFR09/U78HUzWYr7T31UEB6+Eu0/8dkVD5fFDOl9a8w==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
jsonc-eslint-parser@2.4.0:
|
||||
resolution: {integrity: sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
@@ -6889,13 +6812,14 @@ packages:
|
||||
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
oxc-parser@0.99.0:
|
||||
resolution: {integrity: sha512-MpS1lbd2vR0NZn1v0drpgu7RUFu3x9Rd0kxExObZc2+F+DIrV0BOMval/RO3BYGwssIOerII6iS8EbbpCCZQpQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
||||
oxc-resolver@11.15.0:
|
||||
resolution: {integrity: sha512-Hk2J8QMYwmIO9XTCUiOH00+Xk2/+aBxRUnhrSlANDyCnLYc32R1WSIq1sU2yEdlqd53FfMpPEpnBYIKQMzliJw==}
|
||||
|
||||
oxfmt@0.26.0:
|
||||
resolution: {integrity: sha512-UDD1wFNwfeorMm2ZY0xy1KRAAvJ5NjKBfbDmiMwGP7baEHTq65cYpC0aPP+BGHc8weXUbSZaK8MdGyvuRUvS4Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
oxlint-tsgolint@0.9.1:
|
||||
resolution: {integrity: sha512-w1lIvUDkkiAPFyo268SFGrdh1LQ3Lcs1XShES7I4X75TliQA0os5XJ5hNZ4lYsSevqcofgEtq4xq7rBumv69iQ==}
|
||||
hasBin: true
|
||||
@@ -7796,6 +7720,10 @@ packages:
|
||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tinypool@2.0.0:
|
||||
resolution: {integrity: sha512-/RX9RzeH2xU5ADE7n2Ykvmi9ED3FBGPAjw9u3zucrNNaEBIO0HPSYgL0NT7+3p147ojeSdaVu08F6hjpv31HJg==}
|
||||
engines: {node: ^20.0.0 || >=22.0.0}
|
||||
|
||||
tinyrainbow@2.0.0:
|
||||
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -10677,59 +10605,10 @@ snapshots:
|
||||
|
||||
'@one-ini/wasm@0.1.1': {}
|
||||
|
||||
'@oxc-parser/binding-android-arm64@0.99.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-darwin-arm64@0.99.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-darwin-x64@0.99.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-freebsd-x64@0.99.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-arm-gnueabihf@0.99.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-arm-musleabihf@0.99.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-arm64-gnu@0.99.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-arm64-musl@0.99.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-riscv64-gnu@0.99.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-s390x-gnu@0.99.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-x64-gnu@0.99.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-x64-musl@0.99.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-wasm32-wasi@0.99.0':
|
||||
dependencies:
|
||||
'@napi-rs/wasm-runtime': 1.1.1
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-win32-arm64-msvc@0.99.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-win32-x64-msvc@0.99.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-project/runtime@0.108.0': {}
|
||||
|
||||
'@oxc-project/types@0.108.0': {}
|
||||
|
||||
'@oxc-project/types@0.99.0': {}
|
||||
|
||||
'@oxc-resolver/binding-android-arm-eabi@11.15.0':
|
||||
optional: true
|
||||
|
||||
@@ -10792,6 +10671,30 @@ snapshots:
|
||||
'@oxc-resolver/binding-win32-x64-msvc@11.15.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/darwin-arm64@0.26.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/darwin-x64@0.26.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/linux-arm64-gnu@0.26.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/linux-arm64-musl@0.26.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/linux-x64-gnu@0.26.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/linux-x64-musl@0.26.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/win32-arm64@0.26.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/win32-x64@0.26.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/darwin-arm64@0.9.1':
|
||||
optional: true
|
||||
|
||||
@@ -10866,10 +10769,6 @@ snapshots:
|
||||
|
||||
'@polka/url@1.0.0-next.29': {}
|
||||
|
||||
'@prettier/plugin-oxc@0.1.3':
|
||||
dependencies:
|
||||
oxc-parser: 0.99.0
|
||||
|
||||
'@primeuix/forms@0.0.2':
|
||||
dependencies:
|
||||
'@primeuix/utils': 0.3.2
|
||||
@@ -11585,20 +11484,6 @@ snapshots:
|
||||
esm-env: 1.2.2
|
||||
tmcp: 1.19.0(typescript@5.9.3)
|
||||
|
||||
'@trivago/prettier-plugin-sort-imports@5.2.2(@vue/compiler-sfc@3.5.25)(prettier@3.7.4)':
|
||||
dependencies:
|
||||
'@babel/generator': 7.28.5
|
||||
'@babel/parser': 7.28.5
|
||||
'@babel/traverse': 7.28.5
|
||||
'@babel/types': 7.28.5
|
||||
javascript-natural-sort: 0.7.1
|
||||
lodash: 4.17.21
|
||||
prettier: 3.7.4
|
||||
optionalDependencies:
|
||||
'@vue/compiler-sfc': 3.5.25
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@tweenjs/tween.js@23.1.3': {}
|
||||
|
||||
'@tybys/wasm-util@0.10.1':
|
||||
@@ -14431,8 +14316,6 @@ snapshots:
|
||||
filelist: 1.0.4
|
||||
minimatch: 3.1.2
|
||||
|
||||
javascript-natural-sort@0.7.1: {}
|
||||
|
||||
jest-diff@30.2.0:
|
||||
dependencies:
|
||||
'@jest/diff-sequences': 30.0.1
|
||||
@@ -14530,6 +14413,8 @@ snapshots:
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
jsonata@2.1.0: {}
|
||||
|
||||
jsonc-eslint-parser@2.4.0:
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
@@ -15531,26 +15416,6 @@ snapshots:
|
||||
safe-push-apply: 1.0.0
|
||||
optional: true
|
||||
|
||||
oxc-parser@0.99.0:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.99.0
|
||||
optionalDependencies:
|
||||
'@oxc-parser/binding-android-arm64': 0.99.0
|
||||
'@oxc-parser/binding-darwin-arm64': 0.99.0
|
||||
'@oxc-parser/binding-darwin-x64': 0.99.0
|
||||
'@oxc-parser/binding-freebsd-x64': 0.99.0
|
||||
'@oxc-parser/binding-linux-arm-gnueabihf': 0.99.0
|
||||
'@oxc-parser/binding-linux-arm-musleabihf': 0.99.0
|
||||
'@oxc-parser/binding-linux-arm64-gnu': 0.99.0
|
||||
'@oxc-parser/binding-linux-arm64-musl': 0.99.0
|
||||
'@oxc-parser/binding-linux-riscv64-gnu': 0.99.0
|
||||
'@oxc-parser/binding-linux-s390x-gnu': 0.99.0
|
||||
'@oxc-parser/binding-linux-x64-gnu': 0.99.0
|
||||
'@oxc-parser/binding-linux-x64-musl': 0.99.0
|
||||
'@oxc-parser/binding-wasm32-wasi': 0.99.0
|
||||
'@oxc-parser/binding-win32-arm64-msvc': 0.99.0
|
||||
'@oxc-parser/binding-win32-x64-msvc': 0.99.0
|
||||
|
||||
oxc-resolver@11.15.0:
|
||||
optionalDependencies:
|
||||
'@oxc-resolver/binding-android-arm-eabi': 11.15.0
|
||||
@@ -15574,6 +15439,19 @@ snapshots:
|
||||
'@oxc-resolver/binding-win32-ia32-msvc': 11.15.0
|
||||
'@oxc-resolver/binding-win32-x64-msvc': 11.15.0
|
||||
|
||||
oxfmt@0.26.0:
|
||||
dependencies:
|
||||
tinypool: 2.0.0
|
||||
optionalDependencies:
|
||||
'@oxfmt/darwin-arm64': 0.26.0
|
||||
'@oxfmt/darwin-x64': 0.26.0
|
||||
'@oxfmt/linux-arm64-gnu': 0.26.0
|
||||
'@oxfmt/linux-arm64-musl': 0.26.0
|
||||
'@oxfmt/linux-x64-gnu': 0.26.0
|
||||
'@oxfmt/linux-x64-musl': 0.26.0
|
||||
'@oxfmt/win32-arm64': 0.26.0
|
||||
'@oxfmt/win32-x64': 0.26.0
|
||||
|
||||
oxlint-tsgolint@0.9.1:
|
||||
optionalDependencies:
|
||||
'@oxlint-tsgolint/darwin-arm64': 0.9.1
|
||||
@@ -15754,7 +15632,8 @@ snapshots:
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
prettier@3.7.4: {}
|
||||
prettier@3.7.4:
|
||||
optional: true
|
||||
|
||||
pretty-bytes@7.1.0: {}
|
||||
|
||||
@@ -16717,6 +16596,8 @@ snapshots:
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
|
||||
tinypool@2.0.0: {}
|
||||
|
||||
tinyrainbow@2.0.0: {}
|
||||
|
||||
tinyrainbow@3.0.3: {}
|
||||
|
||||
@@ -17,7 +17,6 @@ catalog:
|
||||
'@nx/vite': 22.2.6
|
||||
'@pinia/testing': ^1.0.3
|
||||
'@playwright/test': ^1.57.0
|
||||
'@prettier/plugin-oxc': ^0.1.3
|
||||
'@primeuix/forms': 0.0.2
|
||||
'@primeuix/styled': 0.3.2
|
||||
'@primeuix/utils': ^0.3.2
|
||||
@@ -33,7 +32,6 @@ catalog:
|
||||
'@storybook/vue3': ^10.1.9
|
||||
'@storybook/vue3-vite': ^10.1.9
|
||||
'@tailwindcss/vite': ^4.1.12
|
||||
'@trivago/prettier-plugin-sort-imports': ^5.2.0
|
||||
'@types/fs-extra': ^11.0.4
|
||||
'@types/jsdom': ^21.1.7
|
||||
'@types/node': ^24.1.0
|
||||
@@ -64,18 +62,19 @@ catalog:
|
||||
happy-dom: ^20.0.11
|
||||
husky: ^9.1.7
|
||||
jiti: 2.6.1
|
||||
jsonata: ^2.1.0
|
||||
jsdom: ^27.4.0
|
||||
knip: ^5.75.1
|
||||
lint-staged: ^16.2.7
|
||||
markdown-table: ^3.0.4
|
||||
mixpanel-browser: ^2.71.0
|
||||
nx: 22.2.6
|
||||
oxfmt: ^0.26.0
|
||||
oxlint: ^1.33.0
|
||||
oxlint-tsgolint: ^0.9.1
|
||||
picocolors: ^1.1.1
|
||||
pinia: ^3.0.4
|
||||
postcss-html: ^1.8.0
|
||||
prettier: ^3.7.4
|
||||
pretty-bytes: ^7.1.0
|
||||
primeicons: ^7.0.0
|
||||
primevue: ^4.2.5
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed } from 'vue'
|
||||
import { computed, nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import TopMenuSection from '@/components/TopMenuSection.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
import type {
|
||||
JobListItem,
|
||||
JobStatus
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
|
||||
const mockData = vi.hoisted(() => ({ isLoggedIn: false }))
|
||||
@@ -36,7 +42,9 @@ function createWrapper() {
|
||||
sideToolbar: {
|
||||
queueProgressOverlay: {
|
||||
viewJobHistory: 'View job history',
|
||||
expandCollapsedQueue: 'Expand collapsed queue'
|
||||
expandCollapsedQueue: 'Expand collapsed queue',
|
||||
activeJobsShort: '{count} active | {count} active',
|
||||
clearQueueTooltip: 'Clear queue'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,7 +58,12 @@ function createWrapper() {
|
||||
SubgraphBreadcrumb: true,
|
||||
QueueProgressOverlay: true,
|
||||
CurrentUserButton: true,
|
||||
LoginButton: true
|
||||
LoginButton: true,
|
||||
ContextMenu: {
|
||||
name: 'ContextMenu',
|
||||
props: ['model'],
|
||||
template: '<div />'
|
||||
}
|
||||
},
|
||||
directives: {
|
||||
tooltip: () => {}
|
||||
@@ -59,6 +72,19 @@ function createWrapper() {
|
||||
})
|
||||
}
|
||||
|
||||
function createJob(id: string, status: JobStatus): JobListItem {
|
||||
return {
|
||||
id,
|
||||
status,
|
||||
create_time: 0,
|
||||
priority: 0
|
||||
}
|
||||
}
|
||||
|
||||
function createTask(id: string, status: JobStatus): TaskItemImpl {
|
||||
return new TaskItemImpl(createJob(id, status))
|
||||
}
|
||||
|
||||
describe('TopMenuSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
@@ -100,4 +126,39 @@ describe('TopMenuSection', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the active jobs label with the current count', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const queueStore = useQueueStore()
|
||||
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
|
||||
queueStore.runningTasks = [
|
||||
createTask('running-1', 'in_progress'),
|
||||
createTask('running-2', 'in_progress')
|
||||
]
|
||||
|
||||
await nextTick()
|
||||
|
||||
const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
|
||||
expect(queueButton.text()).toContain('3 active')
|
||||
})
|
||||
|
||||
it('disables the clear queue context menu item when no queued jobs exist', () => {
|
||||
const wrapper = createWrapper()
|
||||
const menu = wrapper.findComponent({ name: 'ContextMenu' })
|
||||
const model = menu.props('model') as MenuItem[]
|
||||
expect(model[0]?.label).toBe('Clear queue')
|
||||
expect(model[0]?.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('enables the clear queue context menu item when queued jobs exist', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const queueStore = useQueueStore()
|
||||
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
|
||||
|
||||
await nextTick()
|
||||
|
||||
const menu = wrapper.findComponent({ name: 'ContextMenu' })
|
||||
const model = menu.props('model') as MenuItem[]
|
||||
expect(model[0]?.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="!workspaceStore.focusMode"
|
||||
class="ml-1 flex gap-x-0.5 pt-1"
|
||||
@mouseenter="isTopMenuHovered = true"
|
||||
@mouseleave="isTopMenuHovered = false"
|
||||
>
|
||||
<div v-if="!workspaceStore.focusMode" class="ml-1 flex gap-x-0.5 pt-1">
|
||||
<div class="min-w-0 flex-1">
|
||||
<SubgraphBreadcrumb />
|
||||
</div>
|
||||
@@ -44,21 +39,21 @@
|
||||
<Button
|
||||
v-tooltip.bottom="queueHistoryTooltipConfig"
|
||||
type="destructive"
|
||||
size="icon"
|
||||
size="md"
|
||||
:aria-pressed="isQueueOverlayExpanded"
|
||||
:aria-label="
|
||||
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
|
||||
"
|
||||
class="px-3"
|
||||
data-testid="queue-overlay-toggle"
|
||||
@click="toggleQueueOverlay"
|
||||
@contextmenu.stop.prevent="showQueueContextMenu"
|
||||
>
|
||||
<i class="icon-[lucide--history] size-4" />
|
||||
<span
|
||||
v-if="queuedCount > 0"
|
||||
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-base-foreground"
|
||||
>
|
||||
{{ queuedCount }}
|
||||
<span class="text-sm font-normal tabular-nums">
|
||||
{{ activeJobsLabel }}
|
||||
</span>
|
||||
<span class="sr-only">
|
||||
{{ t('sideToolbar.queueProgressOverlay.expandCollapsedQueue') }}
|
||||
</span>
|
||||
</Button>
|
||||
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn && !isIntegratedTabBar"
|
||||
class="shrink-0"
|
||||
@@ -76,16 +71,15 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<QueueProgressOverlay
|
||||
v-model:expanded="isQueueOverlayExpanded"
|
||||
:menu-hovered="isTopMenuHovered"
|
||||
/>
|
||||
<QueueProgressOverlay v-model:expanded="isQueueOverlayExpanded" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -103,6 +97,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
@@ -117,18 +112,26 @@ const rightSidePanelStore = useRightSidePanelStore()
|
||||
const managerState = useManagerState()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const isDesktop = isElectron()
|
||||
const { t } = useI18n()
|
||||
const { t, n } = useI18n()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const commandStore = useCommandStore()
|
||||
const queueStore = useQueueStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const queueUIStore = useQueueUIStore()
|
||||
const { activeJobsCount } = storeToRefs(queueStore)
|
||||
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
||||
const releaseStore = useReleaseStore()
|
||||
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
|
||||
const { shouldShowRedDot: shouldShowConflictRedDot } =
|
||||
useConflictAcknowledgment()
|
||||
const isTopMenuHovered = ref(false)
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const activeJobsLabel = computed(() => {
|
||||
const count = activeJobsCount.value
|
||||
return t(
|
||||
'sideToolbar.queueProgressOverlay.activeJobsShort',
|
||||
{ count: n(count) },
|
||||
count
|
||||
)
|
||||
})
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
)
|
||||
@@ -138,6 +141,18 @@ const queueHistoryTooltipConfig = computed(() =>
|
||||
const customNodesManagerTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.customNodesManager'))
|
||||
)
|
||||
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
const queueContextMenuItems = computed<MenuItem[]>(() => [
|
||||
{
|
||||
label: t('sideToolbar.queueProgressOverlay.clearQueueTooltip'),
|
||||
icon: 'icon-[lucide--list-x] text-destructive-background',
|
||||
class: '*:text-destructive-background',
|
||||
disabled: queueStore.pendingTasks.length === 0,
|
||||
command: () => {
|
||||
void handleClearQueue()
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
// Use either release red dot or conflict red dot
|
||||
const shouldShowRedDot = computed((): boolean => {
|
||||
@@ -164,6 +179,19 @@ const toggleQueueOverlay = () => {
|
||||
commandStore.execute('Comfy.Queue.ToggleOverlay')
|
||||
}
|
||||
|
||||
const showQueueContextMenu = (event: MouseEvent) => {
|
||||
queueContextMenu.value?.show(event)
|
||||
}
|
||||
|
||||
const handleClearQueue = async () => {
|
||||
const pendingPromptIds = queueStore.pendingTasks
|
||||
.map((task) => task.promptId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
executionStore.clearInitializationByPromptIds(pendingPromptIds)
|
||||
}
|
||||
|
||||
const openCustomNodeManager = async () => {
|
||||
try {
|
||||
await managerState.openManager({
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('EditableText', () => {
|
||||
isEditing: true
|
||||
})
|
||||
await wrapper.findComponent(InputText).setValue('New Text')
|
||||
await wrapper.findComponent(InputText).trigger('keyup.enter')
|
||||
await wrapper.findComponent(InputText).trigger('keydown.enter')
|
||||
// Blur event should have been triggered
|
||||
expect(wrapper.findComponent(InputText).element).not.toBe(
|
||||
document.activeElement
|
||||
@@ -79,7 +79,7 @@ describe('EditableText', () => {
|
||||
await wrapper.findComponent(InputText).setValue('Modified Text')
|
||||
|
||||
// Press escape
|
||||
await wrapper.findComponent(InputText).trigger('keyup.escape')
|
||||
await wrapper.findComponent(InputText).trigger('keydown.escape')
|
||||
|
||||
// Should emit cancel event
|
||||
expect(wrapper.emitted('cancel')).toBeTruthy()
|
||||
@@ -103,7 +103,7 @@ describe('EditableText', () => {
|
||||
await wrapper.findComponent(InputText).setValue('Modified Text')
|
||||
|
||||
// Press escape (which triggers blur internally)
|
||||
await wrapper.findComponent(InputText).trigger('keyup.escape')
|
||||
await wrapper.findComponent(InputText).trigger('keydown.escape')
|
||||
|
||||
// Manually trigger blur to simulate the blur that happens after escape
|
||||
await wrapper.findComponent(InputText).trigger('blur')
|
||||
@@ -120,7 +120,7 @@ describe('EditableText', () => {
|
||||
isEditing: true
|
||||
})
|
||||
await enterWrapper.findComponent(InputText).setValue('Saved Text')
|
||||
await enterWrapper.findComponent(InputText).trigger('keyup.enter')
|
||||
await enterWrapper.findComponent(InputText).trigger('keydown.enter')
|
||||
// Trigger blur that happens after enter
|
||||
await enterWrapper.findComponent(InputText).trigger('blur')
|
||||
expect(enterWrapper.emitted('edit')).toBeTruthy()
|
||||
@@ -133,7 +133,7 @@ describe('EditableText', () => {
|
||||
isEditing: true
|
||||
})
|
||||
await escapeWrapper.findComponent(InputText).setValue('Cancelled Text')
|
||||
await escapeWrapper.findComponent(InputText).trigger('keyup.escape')
|
||||
await escapeWrapper.findComponent(InputText).trigger('keydown.escape')
|
||||
expect(escapeWrapper.emitted('cancel')).toBeTruthy()
|
||||
expect(escapeWrapper.emitted('edit')).toBeFalsy()
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<span v-if="!isEditing">
|
||||
{{ modelValue }}
|
||||
</span>
|
||||
<!-- Avoid double triggering finishEditing event when keyup.enter is triggered -->
|
||||
<!-- Avoid double triggering finishEditing event when keydown.enter is triggered -->
|
||||
<InputText
|
||||
v-else
|
||||
ref="inputRef"
|
||||
@@ -18,8 +18,8 @@
|
||||
...inputAttrs
|
||||
}
|
||||
}"
|
||||
@keyup.enter.capture.stop="blurInputElement"
|
||||
@keyup.escape.stop="cancelEditing"
|
||||
@keydown.enter.capture.stop="blurInputElement"
|
||||
@keydown.escape.capture.stop="cancelEditing"
|
||||
@click.stop
|
||||
@contextmenu.stop
|
||||
@pointerdown.stop.capture
|
||||
|
||||
95
src/components/common/StatusBadge.stories.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import StatusBadge from './StatusBadge.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'Common/StatusBadge',
|
||||
component: StatusBadge,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
label: { control: 'text' },
|
||||
severity: {
|
||||
control: 'select',
|
||||
options: ['default', 'secondary', 'warn', 'danger', 'contrast']
|
||||
},
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['label', 'dot', 'circle']
|
||||
}
|
||||
},
|
||||
args: {
|
||||
label: 'Status',
|
||||
severity: 'default'
|
||||
}
|
||||
} satisfies Meta<typeof StatusBadge>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const Failed: Story = {
|
||||
args: {
|
||||
label: 'Failed',
|
||||
severity: 'danger'
|
||||
}
|
||||
}
|
||||
|
||||
export const Finished: Story = {
|
||||
args: {
|
||||
label: 'Finished',
|
||||
severity: 'contrast'
|
||||
}
|
||||
}
|
||||
|
||||
export const Dot: Story = {
|
||||
args: {
|
||||
label: undefined,
|
||||
variant: 'dot',
|
||||
severity: 'danger'
|
||||
}
|
||||
}
|
||||
|
||||
export const Circle: Story = {
|
||||
args: {
|
||||
label: '3',
|
||||
variant: 'circle'
|
||||
}
|
||||
}
|
||||
|
||||
export const AllSeverities: Story = {
|
||||
render: () => ({
|
||||
components: { StatusBadge },
|
||||
template: `
|
||||
<div class="flex items-center gap-2">
|
||||
<StatusBadge label="Default" severity="default" />
|
||||
<StatusBadge label="Secondary" severity="secondary" />
|
||||
<StatusBadge label="Warn" severity="warn" />
|
||||
<StatusBadge label="Danger" severity="danger" />
|
||||
<StatusBadge label="Contrast" severity="contrast" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { StatusBadge },
|
||||
template: `
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<StatusBadge label="Label" variant="label" />
|
||||
<span class="text-xs text-muted">label</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<StatusBadge variant="dot" severity="danger" />
|
||||
<span class="text-xs text-muted">dot</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<StatusBadge label="5" variant="circle" />
|
||||
<span class="text-xs text-muted">circle</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,30 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
type Severity = 'default' | 'secondary' | 'warn' | 'danger' | 'contrast'
|
||||
import { statusBadgeVariants } from './statusBadge.variants'
|
||||
import type { StatusBadgeVariants } from './statusBadge.variants'
|
||||
|
||||
const { label, severity = 'default' } = defineProps<{
|
||||
label: string
|
||||
severity?: Severity
|
||||
const {
|
||||
label,
|
||||
severity = 'default',
|
||||
variant
|
||||
} = defineProps<{
|
||||
label?: string | number
|
||||
severity?: StatusBadgeVariants['severity']
|
||||
variant?: StatusBadgeVariants['variant']
|
||||
}>()
|
||||
|
||||
function badgeClasses(sev: Severity): string {
|
||||
const baseClasses =
|
||||
'inline-flex h-3.5 items-center justify-center rounded-full px-1 text-xxxs font-semibold uppercase'
|
||||
|
||||
switch (sev) {
|
||||
case 'danger':
|
||||
return `${baseClasses} bg-destructive-background text-white`
|
||||
case 'contrast':
|
||||
return `${baseClasses} bg-base-foreground text-base-background`
|
||||
case 'warn':
|
||||
return `${baseClasses} bg-warning-background text-base-background`
|
||||
case 'secondary':
|
||||
return `${baseClasses} bg-secondary-background text-base-foreground`
|
||||
default:
|
||||
return `${baseClasses} bg-primary-background text-base-foreground`
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="badgeClasses(severity)">{{ label }}</span>
|
||||
<span
|
||||
:class="
|
||||
statusBadgeVariants({
|
||||
severity,
|
||||
variant: variant ?? (label == null ? 'dot' : 'label')
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
<template>
|
||||
<div ref="container" class="scroll-container">
|
||||
<div :style="{ height: `${(state.start / cols) * itemHeight}px` }" />
|
||||
<div :style="gridStyle">
|
||||
<div v-for="item in renderedItems" :key="item.key" data-virtual-grid-item>
|
||||
<div
|
||||
ref="container"
|
||||
class="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface)"
|
||||
>
|
||||
<div :style="topSpacerStyle" />
|
||||
<div :style="mergedGridStyle">
|
||||
<div
|
||||
v-for="item in renderedItems"
|
||||
:key="item.key"
|
||||
class="transition-[width] duration-150 ease-out"
|
||||
data-virtual-grid-item
|
||||
>
|
||||
<slot name="item" :item="item" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:style="{
|
||||
height: `${((items.length - state.end) / cols) * itemHeight}px`
|
||||
}"
|
||||
/>
|
||||
<div :style="bottomSpacerStyle" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -28,19 +32,22 @@ type GridState = {
|
||||
|
||||
const {
|
||||
items,
|
||||
gridStyle,
|
||||
bufferRows = 1,
|
||||
scrollThrottle = 64,
|
||||
resizeDebounce = 64,
|
||||
defaultItemHeight = 200,
|
||||
defaultItemWidth = 200
|
||||
defaultItemWidth = 200,
|
||||
maxColumns = Infinity
|
||||
} = defineProps<{
|
||||
items: (T & { key: string })[]
|
||||
gridStyle: Partial<CSSProperties>
|
||||
gridStyle: CSSProperties
|
||||
bufferRows?: number
|
||||
scrollThrottle?: number
|
||||
resizeDebounce?: number
|
||||
defaultItemHeight?: number
|
||||
defaultItemWidth?: number
|
||||
maxColumns?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -59,7 +66,18 @@ const { y: scrollY } = useScroll(container, {
|
||||
eventListenerOptions: { passive: true }
|
||||
})
|
||||
|
||||
const cols = computed(() => Math.floor(width.value / itemWidth.value) || 1)
|
||||
const cols = computed(() =>
|
||||
Math.min(Math.floor(width.value / itemWidth.value) || 1, maxColumns)
|
||||
)
|
||||
|
||||
const mergedGridStyle = computed<CSSProperties>(() => {
|
||||
if (maxColumns === Infinity) return gridStyle
|
||||
return {
|
||||
...gridStyle,
|
||||
gridTemplateColumns: `repeat(${maxColumns}, minmax(0, 1fr))`
|
||||
}
|
||||
})
|
||||
|
||||
const viewRows = computed(() => Math.ceil(height.value / itemHeight.value))
|
||||
const offsetRows = computed(() => Math.floor(scrollY.value / itemHeight.value))
|
||||
const isValidGrid = computed(() => height.value && width.value && items?.length)
|
||||
@@ -83,6 +101,16 @@ const renderedItems = computed(() =>
|
||||
isValidGrid.value ? items.slice(state.value.start, state.value.end) : []
|
||||
)
|
||||
|
||||
function rowsToHeight(rows: number): string {
|
||||
return `${(rows / cols.value) * itemHeight.value}px`
|
||||
}
|
||||
const topSpacerStyle = computed<CSSProperties>(() => ({
|
||||
height: rowsToHeight(state.value.start)
|
||||
}))
|
||||
const bottomSpacerStyle = computed<CSSProperties>(() => ({
|
||||
height: rowsToHeight(items.length - state.value.end)
|
||||
}))
|
||||
|
||||
whenever(
|
||||
() => state.value.isNearEnd,
|
||||
() => {
|
||||
@@ -109,15 +137,6 @@ const onResize = debounce(updateItemSize, resizeDebounce)
|
||||
watch([width, height], onResize, { flush: 'post' })
|
||||
whenever(() => items, updateItemSize, { flush: 'post' })
|
||||
onBeforeUnmount(() => {
|
||||
onResize.cancel() // Clear pending debounced calls
|
||||
onResize.cancel()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scroll-container {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--dialog-surface) transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
43
src/components/common/WorkspaceProfilePic.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex size-6 items-center justify-center rounded-md text-base font-semibold text-white"
|
||||
:style="{
|
||||
background: gradient,
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.2)'
|
||||
}"
|
||||
>
|
||||
{{ letter }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { workspaceName } = defineProps<{
|
||||
workspaceName: string
|
||||
}>()
|
||||
|
||||
const letter = computed(() => workspaceName?.charAt(0)?.toUpperCase() ?? '?')
|
||||
|
||||
const gradient = computed(() => {
|
||||
const seed = letter.value.charCodeAt(0)
|
||||
|
||||
function mulberry32(a: number) {
|
||||
return function () {
|
||||
let t = (a += 0x6d2b79f5)
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
const rand = mulberry32(seed)
|
||||
|
||||
const hue1 = Math.floor(rand() * 360)
|
||||
const hue2 = (hue1 + 40 + Math.floor(rand() * 80)) % 360
|
||||
const sat = 65 + Math.floor(rand() * 20)
|
||||
const light = 55 + Math.floor(rand() * 15)
|
||||
|
||||
return `linear-gradient(135deg, hsl(${hue1}, ${sat}%, ${light}%), hsl(${hue2}, ${sat}%, ${light}%))`
|
||||
})
|
||||
</script>
|
||||
26
src/components/common/statusBadge.variants.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const statusBadgeVariants = cva({
|
||||
base: 'inline-flex items-center justify-center rounded-full',
|
||||
variants: {
|
||||
severity: {
|
||||
default: 'bg-primary-background text-base-foreground',
|
||||
secondary: 'bg-secondary-background text-base-foreground',
|
||||
warn: 'bg-warning-background text-base-background',
|
||||
danger: 'bg-destructive-background text-white',
|
||||
contrast: 'bg-base-foreground text-base-background'
|
||||
},
|
||||
variant: {
|
||||
label: 'h-3.5 px-1 text-xxxs font-semibold uppercase',
|
||||
dot: 'size-2',
|
||||
circle: 'size-3.5 text-xxxs font-semibold'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
severity: 'default',
|
||||
variant: 'label'
|
||||
}
|
||||
})
|
||||
|
||||
export type StatusBadgeVariants = VariantProps<typeof statusBadgeVariants>
|
||||
@@ -4,7 +4,12 @@
|
||||
v-for="item in dialogStore.dialogStack"
|
||||
:key="item.key"
|
||||
v-model:visible="item.visible"
|
||||
class="global-dialog"
|
||||
:class="[
|
||||
'global-dialog',
|
||||
item.key === 'global-settings' && teamWorkspacesEnabled
|
||||
? 'settings-dialog-workspace'
|
||||
: ''
|
||||
]"
|
||||
v-bind="item.dialogComponentProps"
|
||||
:pt="item.dialogComponentProps.pt"
|
||||
:aria-labelledby="item.key"
|
||||
@@ -38,7 +43,15 @@
|
||||
<script setup lang="ts">
|
||||
import Dialog from 'primevue/dialog'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const teamWorkspacesEnabled = computed(
|
||||
() => isCloud && flags.teamWorkspacesEnabled
|
||||
)
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
</script>
|
||||
@@ -55,4 +68,27 @@ const dialogStore = useDialogStore()
|
||||
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
|
||||
@apply pt-0;
|
||||
}
|
||||
|
||||
/* Workspace mode: wider settings dialog */
|
||||
.settings-dialog-workspace {
|
||||
width: 100%;
|
||||
max-width: 1440px;
|
||||
}
|
||||
|
||||
.settings-dialog-workspace .p-dialog-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.manager-dialog {
|
||||
height: 80vh;
|
||||
max-width: 1724px;
|
||||
max-height: 1026px;
|
||||
}
|
||||
|
||||
@media (min-width: 3000px) {
|
||||
.manager-dialog {
|
||||
max-width: 2200px;
|
||||
max-height: 1320px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
11
src/components/dialog/content/setting/WorkspacePanel.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<TabPanel value="Workspace" class="h-full">
|
||||
<WorkspacePanelContent />
|
||||
</TabPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
|
||||
import WorkspacePanelContent from '@/components/dialog/content/setting/WorkspacePanelContent.vue'
|
||||
</script>
|
||||
163
src/components/dialog/content/setting/WorkspacePanelContent.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div class="flex h-full w-full flex-col">
|
||||
<div class="pb-8 flex items-center gap-4">
|
||||
<WorkspaceProfilePic
|
||||
class="size-12 !text-3xl"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
<h1 class="text-3xl text-base-foreground">
|
||||
{{ workspaceName }}
|
||||
</h1>
|
||||
</div>
|
||||
<Tabs :value="activeTab" @update:value="setActiveTab">
|
||||
<div class="flex w-full items-center">
|
||||
<TabList class="w-full">
|
||||
<Tab value="plan">{{ $t('workspacePanel.tabs.planCredits') }}</Tab>
|
||||
</TabList>
|
||||
|
||||
<template v-if="permissions.canAccessWorkspaceMenu">
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
@click="menu?.toggle($event)"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
<Menu ref="menu" :model="menuItems" :popup="true">
|
||||
<template #item="{ item }">
|
||||
<div
|
||||
v-tooltip="
|
||||
item.disabled && deleteTooltip
|
||||
? { value: deleteTooltip, showDelay: 0 }
|
||||
: null
|
||||
"
|
||||
:class="[
|
||||
'flex items-center gap-2 px-3 py-2',
|
||||
item.class,
|
||||
item.disabled ? 'pointer-events-auto' : ''
|
||||
]"
|
||||
@click="
|
||||
item.command?.({
|
||||
originalEvent: $event,
|
||||
item
|
||||
})
|
||||
"
|
||||
>
|
||||
<i :class="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Menu>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<TabPanels>
|
||||
<TabPanel value="plan">
|
||||
<SubscriptionPanelContent />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Menu from 'primevue/menu'
|
||||
import Tab from 'primevue/tab'
|
||||
import TabList from 'primevue/tablist'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import TabPanels from 'primevue/tabpanels'
|
||||
import Tabs from 'primevue/tabs'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import SubscriptionPanelContent from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const { defaultTab = 'plan' } = defineProps<{
|
||||
defaultTab?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
showLeaveWorkspaceDialog,
|
||||
showDeleteWorkspaceDialog,
|
||||
showEditWorkspaceDialog
|
||||
} = useDialogService()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { workspaceName, isWorkspaceSubscribed } = storeToRefs(workspaceStore)
|
||||
|
||||
const { activeTab, setActiveTab, permissions, uiConfig } = useWorkspaceUI()
|
||||
|
||||
const menu = ref<InstanceType<typeof Menu> | null>(null)
|
||||
|
||||
function handleLeaveWorkspace() {
|
||||
showLeaveWorkspaceDialog()
|
||||
}
|
||||
|
||||
function handleDeleteWorkspace() {
|
||||
showDeleteWorkspaceDialog()
|
||||
}
|
||||
|
||||
function handleEditWorkspace() {
|
||||
showEditWorkspaceDialog()
|
||||
}
|
||||
|
||||
// Disable delete when workspace has an active subscription (to prevent accidental deletion)
|
||||
// Use workspace's own subscription status, not the global isActiveSubscription
|
||||
const isDeleteDisabled = computed(
|
||||
() =>
|
||||
uiConfig.value.workspaceMenuAction === 'delete' &&
|
||||
isWorkspaceSubscribed.value
|
||||
)
|
||||
|
||||
const deleteTooltip = computed(() => {
|
||||
if (!isDeleteDisabled.value) return null
|
||||
const tooltipKey = uiConfig.value.workspaceMenuDisabledTooltip
|
||||
return tooltipKey ? t(tooltipKey) : null
|
||||
})
|
||||
|
||||
const menuItems = computed(() => {
|
||||
const items = []
|
||||
|
||||
// Add edit option for owners
|
||||
if (uiConfig.value.showEditWorkspaceMenuItem) {
|
||||
items.push({
|
||||
label: t('workspacePanel.menu.editWorkspace'),
|
||||
icon: 'pi pi-pencil',
|
||||
command: handleEditWorkspace
|
||||
})
|
||||
}
|
||||
|
||||
const action = uiConfig.value.workspaceMenuAction
|
||||
if (action === 'delete') {
|
||||
items.push({
|
||||
label: t('workspacePanel.menu.deleteWorkspace'),
|
||||
icon: 'pi pi-trash',
|
||||
class: isDeleteDisabled.value
|
||||
? 'text-danger/50 cursor-not-allowed'
|
||||
: 'text-danger',
|
||||
disabled: isDeleteDisabled.value,
|
||||
command: isDeleteDisabled.value ? undefined : handleDeleteWorkspace
|
||||
})
|
||||
} else if (action === 'leave') {
|
||||
items.push({
|
||||
label: t('workspacePanel.menu.leaveWorkspace'),
|
||||
icon: 'pi pi-sign-out',
|
||||
command: handleLeaveWorkspace
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
setActiveTab(defaultTab)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<WorkspaceProfilePic
|
||||
class="size-6 text-xs"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
|
||||
<span>{{ workspaceName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
const { workspaceName } = storeToRefs(useTeamWorkspaceStore())
|
||||
</script>
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('workspacePanel.createWorkspaceDialog.title') }}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex flex-col gap-4 px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.createWorkspaceDialog.message') }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-base-foreground">
|
||||
{{ $t('workspacePanel.createWorkspaceDialog.nameLabel') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="workspaceName"
|
||||
type="text"
|
||||
class="w-full rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-secondary-foreground"
|
||||
:placeholder="
|
||||
$t('workspacePanel.createWorkspaceDialog.namePlaceholder')
|
||||
"
|
||||
@keydown.enter="isValidName && onCreate()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:loading
|
||||
:disabled="!isValidName"
|
||||
@click="onCreate"
|
||||
>
|
||||
{{ $t('workspacePanel.createWorkspaceDialog.create') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { onConfirm } = defineProps<{
|
||||
onConfirm?: (name: string) => void | Promise<void>
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const toast = useToast()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const loading = ref(false)
|
||||
const workspaceName = ref('')
|
||||
|
||||
const isValidName = computed(() => {
|
||||
const name = workspaceName.value.trim()
|
||||
// Allow alphanumeric, spaces, hyphens, underscores (safe characters)
|
||||
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_]*$/
|
||||
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
|
||||
})
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'create-workspace' })
|
||||
}
|
||||
|
||||
async function onCreate() {
|
||||
if (!isValidName.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const name = workspaceName.value.trim()
|
||||
// Call optional callback if provided
|
||||
await onConfirm?.(name)
|
||||
dialogStore.closeDialog({ key: 'create-workspace' })
|
||||
// Create workspace and switch to it (triggers reload internally)
|
||||
await workspaceStore.createWorkspace(name)
|
||||
} catch (error) {
|
||||
console.error('[CreateWorkspaceDialog] Failed to create workspace:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.toast.failedToCreateWorkspace'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[360px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('workspacePanel.deleteDialog.title') }}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{
|
||||
workspaceName
|
||||
? $t('workspacePanel.deleteDialog.messageWithName', {
|
||||
name: workspaceName
|
||||
})
|
||||
: $t('workspacePanel.deleteDialog.message')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button variant="destructive" size="lg" :loading @click="onDelete">
|
||||
{{ $t('g.delete') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { workspaceId, workspaceName } = defineProps<{
|
||||
workspaceId?: string
|
||||
workspaceName?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const dialogStore = useDialogStore()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const loading = ref(false)
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'delete-workspace' })
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
loading.value = true
|
||||
try {
|
||||
// Delete workspace (uses workspaceId if provided, otherwise current workspace)
|
||||
await workspaceStore.deleteWorkspace(workspaceId)
|
||||
dialogStore.closeDialog({ key: 'delete-workspace' })
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error('[DeleteWorkspaceDialog] Failed to delete workspace:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.toast.failedToDeleteWorkspace'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('workspacePanel.editWorkspaceDialog.title') }}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex flex-col gap-4 px-4 py-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-base-foreground">
|
||||
{{ $t('workspacePanel.editWorkspaceDialog.nameLabel') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="newWorkspaceName"
|
||||
type="text"
|
||||
class="w-full rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-secondary-foreground"
|
||||
@keydown.enter="isValidName && onSave()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:loading
|
||||
:disabled="!isValidName"
|
||||
@click="onSave"
|
||||
>
|
||||
{{ $t('workspacePanel.editWorkspaceDialog.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const dialogStore = useDialogStore()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const loading = ref(false)
|
||||
const newWorkspaceName = ref(workspaceStore.workspaceName)
|
||||
|
||||
const isValidName = computed(() => {
|
||||
const name = newWorkspaceName.value.trim()
|
||||
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_]*$/
|
||||
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
|
||||
})
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'edit-workspace' })
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!isValidName.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await workspaceStore.updateWorkspaceName(newWorkspaceName.value.trim())
|
||||
dialogStore.closeDialog({ key: 'edit-workspace' })
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('workspacePanel.toast.workspaceUpdated.title'),
|
||||
detail: t('workspacePanel.toast.workspaceUpdated.message'),
|
||||
life: 5000
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[EditWorkspaceDialog] Failed to update workspace:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.toast.failedToUpdateWorkspace'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[360px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('workspacePanel.leaveDialog.title') }}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.leaveDialog.message') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button variant="destructive" size="lg" :loading @click="onLeave">
|
||||
{{ $t('workspacePanel.leaveDialog.leave') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const dialogStore = useDialogStore()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const loading = ref(false)
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'leave-workspace' })
|
||||
}
|
||||
|
||||
async function onLeave() {
|
||||
loading.value = true
|
||||
try {
|
||||
// leaveWorkspace() handles switching to personal workspace internally and reloads
|
||||
await workspaceStore.leaveWorkspace()
|
||||
dialogStore.closeDialog({ key: 'leave-workspace' })
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error('[LeaveWorkspaceDialog] Failed to leave workspace:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.toast.failedToLeaveWorkspace'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,125 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import QueueOverlayActive from './QueueOverlayActive.vue'
|
||||
import * as tooltipConfig from '@/composables/useTooltipConfig'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
sideToolbar: {
|
||||
queueProgressOverlay: {
|
||||
total: 'Total: {percent}',
|
||||
currentNode: 'Current node:',
|
||||
running: 'running',
|
||||
interruptAll: 'Interrupt all running jobs',
|
||||
queuedSuffix: 'queued',
|
||||
clearQueued: 'Clear queued',
|
||||
viewAllJobs: 'View all jobs',
|
||||
cancelJobTooltip: 'Cancel job',
|
||||
clearQueueTooltip: 'Clear queue'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const tooltipDirectiveStub = {
|
||||
mounted: vi.fn(),
|
||||
updated: vi.fn()
|
||||
}
|
||||
|
||||
const SELECTORS = {
|
||||
interruptAllButton: 'button[aria-label="Interrupt all running jobs"]',
|
||||
clearQueuedButton: 'button[aria-label="Clear queued"]',
|
||||
summaryRow: '.flex.items-center.gap-2',
|
||||
currentNodeRow: '.flex.items-center.gap-1.text-text-secondary'
|
||||
}
|
||||
|
||||
const COPY = {
|
||||
viewAllJobs: 'View all jobs'
|
||||
}
|
||||
|
||||
const mountComponent = (props: Record<string, unknown> = {}) =>
|
||||
mount(QueueOverlayActive, {
|
||||
props: {
|
||||
totalProgressStyle: { width: '65%' },
|
||||
currentNodeProgressStyle: { width: '40%' },
|
||||
totalPercentFormatted: '65%',
|
||||
currentNodePercentFormatted: '40%',
|
||||
currentNodeName: 'Sampler',
|
||||
runningCount: 1,
|
||||
queuedCount: 2,
|
||||
bottomRowClass: 'flex custom-bottom-row',
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: {
|
||||
tooltip: tooltipDirectiveStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('QueueOverlayActive', () => {
|
||||
it('renders progress metrics and emits actions when buttons clicked', async () => {
|
||||
const wrapper = mountComponent({ runningCount: 2, queuedCount: 3 })
|
||||
|
||||
const progressBars = wrapper.findAll('.absolute.inset-0')
|
||||
expect(progressBars[0].attributes('style')).toContain('width: 65%')
|
||||
expect(progressBars[1].attributes('style')).toContain('width: 40%')
|
||||
|
||||
const content = wrapper.text().replace(/\s+/g, ' ')
|
||||
expect(content).toContain('Total: 65%')
|
||||
|
||||
const [runningSection, queuedSection] = wrapper.findAll(
|
||||
SELECTORS.summaryRow
|
||||
)
|
||||
expect(runningSection.text()).toContain('2')
|
||||
expect(runningSection.text()).toContain('running')
|
||||
expect(queuedSection.text()).toContain('3')
|
||||
expect(queuedSection.text()).toContain('queued')
|
||||
|
||||
const currentNodeSection = wrapper.find(SELECTORS.currentNodeRow)
|
||||
expect(currentNodeSection.text()).toContain('Current node:')
|
||||
expect(currentNodeSection.text()).toContain('Sampler')
|
||||
expect(currentNodeSection.text()).toContain('40%')
|
||||
|
||||
const interruptButton = wrapper.get(SELECTORS.interruptAllButton)
|
||||
await interruptButton.trigger('click')
|
||||
expect(wrapper.emitted('interruptAll')).toHaveLength(1)
|
||||
|
||||
const clearQueuedButton = wrapper.get(SELECTORS.clearQueuedButton)
|
||||
await clearQueuedButton.trigger('click')
|
||||
expect(wrapper.emitted('clearQueued')).toHaveLength(1)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const viewAllButton = buttons.find((btn) =>
|
||||
btn.text().includes(COPY.viewAllJobs)
|
||||
)
|
||||
expect(viewAllButton).toBeDefined()
|
||||
await viewAllButton!.trigger('click')
|
||||
expect(wrapper.emitted('viewAllJobs')).toHaveLength(1)
|
||||
|
||||
expect(wrapper.find('.custom-bottom-row').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides action buttons when counts are zero', () => {
|
||||
const wrapper = mountComponent({ runningCount: 0, queuedCount: 0 })
|
||||
|
||||
expect(wrapper.find(SELECTORS.interruptAllButton).exists()).toBe(false)
|
||||
expect(wrapper.find(SELECTORS.clearQueuedButton).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('builds tooltip configs with translated strings', () => {
|
||||
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
|
||||
|
||||
mountComponent()
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('Cancel job')
|
||||
expect(spy).toHaveBeenCalledWith('Clear queue')
|
||||
})
|
||||
})
|
||||
@@ -1,124 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 p-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
class="relative h-2 w-full overflow-hidden rounded-full border border-interface-stroke bg-interface-panel-surface"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 h-full rounded-full transition-[width]"
|
||||
:style="totalProgressStyle"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 h-full rounded-full transition-[width]"
|
||||
:style="currentNodeProgressStyle"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-start justify-end gap-4 text-[12px] leading-none">
|
||||
<div class="flex items-center gap-1 text-text-primary opacity-90">
|
||||
<i18n-t keypath="sideToolbar.queueProgressOverlay.total">
|
||||
<template #percent>
|
||||
<span class="font-bold">{{ totalPercentFormatted }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-text-secondary">
|
||||
<span>{{ t('sideToolbar.queueProgressOverlay.currentNode') }}</span>
|
||||
<span class="inline-block max-w-[10rem] truncate">{{
|
||||
currentNodeName
|
||||
}}</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span>{{ currentNodePercentFormatted }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="bottomRowClass">
|
||||
<div class="flex items-center gap-4 text-[12px] text-text-primary">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="opacity-90">
|
||||
<span class="font-bold">{{ runningCount }}</span>
|
||||
<span class="ml-1">{{
|
||||
t('sideToolbar.queueProgressOverlay.running')
|
||||
}}</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="runningCount > 0"
|
||||
v-tooltip.top="cancelJobTooltip"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.interruptAll')"
|
||||
@click="$emit('interruptAll')"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--x] block size-4 leading-none text-text-primary"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="opacity-90">
|
||||
<span class="font-bold">{{ queuedCount }}</span>
|
||||
<span class="ml-1">{{
|
||||
t('sideToolbar.queueProgressOverlay.queuedSuffix')
|
||||
}}</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="queuedCount > 0"
|
||||
v-tooltip.top="clearQueueTooltip"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
|
||||
@click="$emit('clearQueued')"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--list-x] block size-4 leading-none text-text-primary"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
class="min-w-30 flex-1 px-2 py-0"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
@click="$emit('viewAllJobs')"
|
||||
>
|
||||
{{ t('sideToolbar.queueProgressOverlay.viewAllJobs') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
|
||||
defineProps<{
|
||||
totalProgressStyle: Record<string, string>
|
||||
currentNodeProgressStyle: Record<string, string>
|
||||
totalPercentFormatted: string
|
||||
currentNodePercentFormatted: string
|
||||
currentNodeName: string
|
||||
runningCount: number
|
||||
queuedCount: number
|
||||
bottomRowClass: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'interruptAll'): void
|
||||
(e: 'clearQueued'): void
|
||||
(e: 'viewAllJobs'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const cancelJobTooltip = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.cancelJobTooltip'))
|
||||
)
|
||||
const clearQueueTooltip = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.clearQueueTooltip'))
|
||||
)
|
||||
</script>
|
||||
@@ -1,15 +1,11 @@
|
||||
<template>
|
||||
<div
|
||||
v-show="isVisible"
|
||||
:class="['flex', 'justify-end', 'w-full', 'pointer-events-none']"
|
||||
v-if="isOverlayVisible"
|
||||
class="flex justify-end w-full pointer-events-none"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-auto flex w-[350px] min-w-[310px] max-h-[60vh] flex-col overflow-hidden rounded-lg border font-inter transition-colors duration-200 ease-in-out"
|
||||
:class="containerClass"
|
||||
@mouseenter="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
class="pointer-events-auto flex w-[350px] min-w-[310px] max-h-[60vh] flex-col overflow-hidden rounded-lg border border-interface-stroke bg-interface-panel-surface font-inter shadow-interface"
|
||||
>
|
||||
<!-- Expanded state -->
|
||||
<QueueOverlayExpanded
|
||||
v-if="isExpanded"
|
||||
v-model:selected-job-tab="selectedJobTab"
|
||||
@@ -29,22 +25,6 @@
|
||||
@delete-item="onDeleteItem"
|
||||
@view-item="inspectJobAsset"
|
||||
/>
|
||||
|
||||
<QueueOverlayActive
|
||||
v-else-if="hasActiveJob"
|
||||
:total-progress-style="totalProgressStyle"
|
||||
:current-node-progress-style="currentNodeProgressStyle"
|
||||
:total-percent-formatted="totalPercentFormatted"
|
||||
:current-node-percent-formatted="currentNodePercentFormatted"
|
||||
:current-node-name="currentNodeName"
|
||||
:running-count="runningCount"
|
||||
:queued-count="queuedCount"
|
||||
:bottom-row-class="bottomRowClass"
|
||||
@interrupt-all="interruptAll"
|
||||
@clear-queued="cancelQueuedWorkflows"
|
||||
@view-all-jobs="viewAllJobs"
|
||||
/>
|
||||
|
||||
<QueueOverlayEmpty
|
||||
v-else-if="completionSummary"
|
||||
:summary="completionSummary"
|
||||
@@ -63,7 +43,6 @@
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
|
||||
import QueueOverlayEmpty from '@/components/queue/QueueOverlayEmpty.vue'
|
||||
import QueueOverlayExpanded from '@/components/queue/QueueOverlayExpanded.vue'
|
||||
import QueueClearHistoryDialog from '@/components/queue/dialogs/QueueClearHistoryDialog.vue'
|
||||
@@ -71,7 +50,6 @@ import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import { useCompletionSummary } from '@/composables/queue/useCompletionSummary'
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { useResultGallery } from '@/composables/queue/useResultGallery'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
|
||||
@@ -84,17 +62,9 @@ import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
type OverlayState = 'hidden' | 'empty' | 'active' | 'expanded'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
expanded?: boolean
|
||||
menuHovered?: boolean
|
||||
}>(),
|
||||
{
|
||||
menuHovered: false
|
||||
}
|
||||
)
|
||||
const { expanded } = defineProps<{
|
||||
expanded?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:expanded', value: boolean): void
|
||||
@@ -110,20 +80,11 @@ const assetsStore = useAssetsStore()
|
||||
const assetSelectionStore = useAssetSelectionStore()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
const {
|
||||
totalPercentFormatted,
|
||||
currentNodePercentFormatted,
|
||||
totalProgressStyle,
|
||||
currentNodeProgressStyle
|
||||
} = useQueueProgress()
|
||||
const isHovered = ref(false)
|
||||
const isOverlayHovered = computed(() => isHovered.value || props.menuHovered)
|
||||
const internalExpanded = ref(false)
|
||||
const isExpanded = computed({
|
||||
get: () =>
|
||||
props.expanded === undefined ? internalExpanded.value : props.expanded,
|
||||
get: () => (expanded === undefined ? internalExpanded.value : expanded),
|
||||
set: (value) => {
|
||||
if (props.expanded === undefined) {
|
||||
if (expanded === undefined) {
|
||||
internalExpanded.value = value
|
||||
}
|
||||
emit('update:expanded', value)
|
||||
@@ -131,44 +92,15 @@ const isExpanded = computed({
|
||||
})
|
||||
|
||||
const { summary: completionSummary, clearSummary } = useCompletionSummary()
|
||||
const hasCompletionSummary = computed(() => completionSummary.value !== null)
|
||||
const isOverlayVisible = computed(
|
||||
() => isExpanded.value || completionSummary.value !== null
|
||||
)
|
||||
|
||||
const runningCount = computed(() => queueStore.runningTasks.length)
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const isExecuting = computed(() => !executionStore.isIdle)
|
||||
const hasActiveJob = computed(() => runningCount.value > 0 || isExecuting.value)
|
||||
const activeJobsCount = computed(() => runningCount.value + queuedCount.value)
|
||||
|
||||
const overlayState = computed<OverlayState>(() => {
|
||||
if (isExpanded.value) return 'expanded'
|
||||
if (hasActiveJob.value) return 'active'
|
||||
if (hasCompletionSummary.value) return 'empty'
|
||||
return 'hidden'
|
||||
})
|
||||
|
||||
const showBackground = computed(
|
||||
() =>
|
||||
overlayState.value === 'expanded' ||
|
||||
overlayState.value === 'empty' ||
|
||||
(overlayState.value === 'active' && isOverlayHovered.value)
|
||||
)
|
||||
|
||||
const isVisible = computed(() => overlayState.value !== 'hidden')
|
||||
|
||||
const containerClass = computed(() =>
|
||||
showBackground.value
|
||||
? 'border-interface-stroke bg-interface-panel-surface shadow-interface'
|
||||
: 'border-transparent bg-transparent shadow-none'
|
||||
)
|
||||
|
||||
const bottomRowClass = computed(
|
||||
() =>
|
||||
`flex items-center justify-end gap-4 transition-opacity duration-200 ease-in-out ${
|
||||
overlayState.value === 'active' && isOverlayHovered.value
|
||||
? 'opacity-100 pointer-events-auto'
|
||||
: 'opacity-0 pointer-events-none'
|
||||
}`
|
||||
)
|
||||
const headerTitle = computed(() =>
|
||||
hasActiveJob.value
|
||||
? `${activeJobsCount.value} ${t('sideToolbar.queueProgressOverlay.activeJobsSuffix')}`
|
||||
@@ -188,8 +120,7 @@ const {
|
||||
selectedSortMode,
|
||||
hasFailedJobs,
|
||||
filteredTasks,
|
||||
groupedJobItems,
|
||||
currentNodeName
|
||||
groupedJobItems
|
||||
} = useJobList()
|
||||
|
||||
const displayedJobGroups = computed(() => groupedJobItems.value)
|
||||
@@ -200,7 +131,13 @@ const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
|
||||
if (item.state === 'running' || item.state === 'initialization') {
|
||||
// Running/initializing jobs: interrupt execution
|
||||
await api.interrupt(promptId)
|
||||
// Cloud backend uses deleteItem, local uses interrupt
|
||||
if (isCloud) {
|
||||
await api.deleteItem('queue', promptId)
|
||||
} else {
|
||||
await api.interrupt(promptId)
|
||||
}
|
||||
executionStore.clearInitializationByPromptId(promptId)
|
||||
await queueStore.update()
|
||||
} else if (item.state === 'pending') {
|
||||
// Pending jobs: remove from queue
|
||||
@@ -220,20 +157,8 @@ const {
|
||||
onViewItem: openResultGallery
|
||||
} = useResultGallery(() => filteredTasks.value)
|
||||
|
||||
const setExpanded = (expanded: boolean) => {
|
||||
isExpanded.value = expanded
|
||||
}
|
||||
|
||||
const openExpandedFromEmpty = () => {
|
||||
setExpanded(true)
|
||||
}
|
||||
|
||||
const viewAllJobs = () => {
|
||||
setExpanded(true)
|
||||
}
|
||||
|
||||
const onSummaryClick = () => {
|
||||
openExpandedFromEmpty()
|
||||
isExpanded.value = true
|
||||
clearSummary()
|
||||
}
|
||||
|
||||
@@ -268,26 +193,15 @@ const inspectJobAsset = wrapWithErrorHandlingAsync(
|
||||
)
|
||||
|
||||
const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
})
|
||||
|
||||
const interruptAll = wrapWithErrorHandlingAsync(async () => {
|
||||
const tasks = queueStore.runningTasks
|
||||
const promptIds = tasks
|
||||
// Capture pending promptIds before clearing
|
||||
const pendingPromptIds = queueStore.pendingTasks
|
||||
.map((task) => task.promptId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
if (!promptIds.length) return
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
|
||||
// Cloud backend supports cancelling specific jobs via /queue delete,
|
||||
// while /interrupt always targets the "first" job. Use the targeted API
|
||||
// on cloud to ensure we cancel the workflow the user clicked.
|
||||
if (isCloud) {
|
||||
await Promise.all(promptIds.map((id) => api.deleteItem('queue', id)))
|
||||
return
|
||||
}
|
||||
|
||||
await Promise.all(promptIds.map((id) => api.interrupt(id)))
|
||||
// Clear initialization state for removed prompts
|
||||
executionStore.clearInitializationByPromptIds(pendingPromptIds)
|
||||
})
|
||||
|
||||
const showClearHistoryDialog = () => {
|
||||
|
||||
@@ -5,25 +5,32 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import TransitionCollapse from './TransitionCollapse.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
const {
|
||||
disabled,
|
||||
label,
|
||||
enableEmptyState,
|
||||
tooltip,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
disabled?: boolean
|
||||
label?: string
|
||||
enableEmptyState?: boolean
|
||||
tooltip?: string
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const isCollapse = defineModel<boolean>('collapse', { default: false })
|
||||
|
||||
const isExpanded = computed(() => !isCollapse.value && !props.disabled)
|
||||
const isExpanded = computed(() => !isCollapse.value && !disabled)
|
||||
|
||||
const tooltipConfig = computed(() => {
|
||||
if (!props.tooltip) return undefined
|
||||
return { value: props.tooltip, showDelay: 1000 }
|
||||
if (!tooltip) return undefined
|
||||
return { value: tooltip, showDelay: 1000 }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col bg-comfy-menu-bg">
|
||||
<div :class="cn('flex flex-col bg-comfy-menu-bg', className)">
|
||||
<div
|
||||
class="sticky top-0 z-10 flex items-center justify-between backdrop-blur-xl bg-inherit"
|
||||
>
|
||||
|
||||
@@ -25,13 +25,31 @@ const widgetsSectionDataList = computed((): NodeWidgetsListList => {
|
||||
return nodes.map((node) => {
|
||||
const { widgets = [] } = node
|
||||
const shownWidgets = widgets
|
||||
.filter((w) => !(w.options?.canvasOnly || w.options?.hidden))
|
||||
.filter(
|
||||
(w) =>
|
||||
!(w.options?.canvasOnly || w.options?.hidden || w.options?.advanced)
|
||||
)
|
||||
.map((widget) => ({ node, widget }))
|
||||
|
||||
return { widgets: shownWidgets, node }
|
||||
})
|
||||
})
|
||||
|
||||
const advancedWidgetsSectionDataList = computed((): NodeWidgetsListList => {
|
||||
return nodes
|
||||
.map((node) => {
|
||||
const { widgets = [] } = node
|
||||
const advancedWidgets = widgets
|
||||
.filter(
|
||||
(w) =>
|
||||
!(w.options?.canvasOnly || w.options?.hidden) && w.options?.advanced
|
||||
)
|
||||
.map((widget) => ({ node, widget }))
|
||||
return { widgets: advancedWidgets, node }
|
||||
})
|
||||
.filter(({ widgets }) => widgets.length > 0)
|
||||
})
|
||||
|
||||
const isMultipleNodesSelected = computed(
|
||||
() => widgetsSectionDataList.value.length > 1
|
||||
)
|
||||
@@ -56,6 +74,12 @@ const label = computed(() => {
|
||||
: t('rightSidePanel.inputsNone')
|
||||
: undefined // SectionWidgets display node titles by default
|
||||
})
|
||||
|
||||
const advancedLabel = computed(() => {
|
||||
return !mustShowNodeTitle && !isMultipleNodesSelected.value
|
||||
? t('rightSidePanel.advancedInputs')
|
||||
: undefined // SectionWidgets display node titles by default
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -93,4 +117,16 @@ const label = computed(() => {
|
||||
class="border-b border-interface-stroke"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<template v-if="advancedWidgetsSectionDataList.length > 0 && !isSearching">
|
||||
<SectionWidgets
|
||||
v-for="{ widgets, node } in advancedWidgetsSectionDataList"
|
||||
:key="`advanced-${node.id}`"
|
||||
:collapse="true"
|
||||
:node
|
||||
:label="advancedLabel"
|
||||
:widgets
|
||||
:show-locate-button="isMultipleNodesSelected"
|
||||
class="border-b border-interface-stroke"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -43,7 +43,7 @@ const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const isEditing = ref(false)
|
||||
|
||||
const widgetComponent = computed(() => {
|
||||
const component = getComponent(widget.type, widget.name)
|
||||
const component = getComponent(widget.type)
|
||||
return component || WidgetLegacy
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
:label="$t('menu.help')"
|
||||
:tooltip="$t('sideToolbar.helpCenter')"
|
||||
:icon-badge="shouldShowRedDot ? '•' : ''"
|
||||
badge-class="-top-1 -right-1 min-w-2 w-2 h-2 p-0 rounded-full text-[0px] bg-[#ff3b30]"
|
||||
:is-small="isSmall"
|
||||
@click="toggleHelpCenter"
|
||||
/>
|
||||
@@ -21,24 +22,3 @@ defineProps<{
|
||||
|
||||
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.p-badge) {
|
||||
background: #ff3b30;
|
||||
color: #ff3b30;
|
||||
min-width: 8px;
|
||||
height: 8px;
|
||||
padding: 0;
|
||||
border-radius: 9999px;
|
||||
font-size: 0;
|
||||
margin-top: 4px;
|
||||
margin-right: 4px;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.p-badge.p-badge-dot) {
|
||||
width: 8px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import OverlayBadge from 'primevue/overlaybadge'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
@@ -33,8 +32,7 @@ describe('SidebarIcon', () => {
|
||||
return mount(SidebarIcon, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n],
|
||||
directives: { tooltip: Tooltip },
|
||||
components: { OverlayBadge }
|
||||
directives: { tooltip: Tooltip }
|
||||
},
|
||||
props: { ...exampleProps, ...props },
|
||||
...options
|
||||
@@ -54,9 +52,9 @@ describe('SidebarIcon', () => {
|
||||
it('creates badge when iconBadge prop is set', () => {
|
||||
const badge = '2'
|
||||
const wrapper = mountSidebarIcon({ iconBadge: badge })
|
||||
const badgeEl = wrapper.findComponent(OverlayBadge)
|
||||
const badgeEl = wrapper.find('.sidebar-icon-badge')
|
||||
expect(badgeEl.exists()).toBe(true)
|
||||
expect(badgeEl.find('.p-badge').text()).toEqual(badge)
|
||||
expect(badgeEl.text()).toEqual(badge)
|
||||
})
|
||||
|
||||
it('shows tooltip on hover', async () => {
|
||||
|
||||
@@ -17,22 +17,28 @@
|
||||
>
|
||||
<div class="side-bar-button-content">
|
||||
<slot name="icon">
|
||||
<OverlayBadge v-if="shouldShowBadge" :value="overlayValue">
|
||||
<div class="sidebar-icon-wrapper relative">
|
||||
<i
|
||||
v-if="typeof icon === 'string'"
|
||||
:class="icon + ' side-bar-button-icon'"
|
||||
/>
|
||||
<component :is="icon" v-else class="side-bar-button-icon" />
|
||||
</OverlayBadge>
|
||||
<i
|
||||
v-else-if="typeof icon === 'string'"
|
||||
:class="icon + ' side-bar-button-icon'"
|
||||
/>
|
||||
<component
|
||||
:is="icon"
|
||||
v-else-if="typeof icon === 'object'"
|
||||
class="side-bar-button-icon"
|
||||
/>
|
||||
<component
|
||||
:is="icon"
|
||||
v-else-if="typeof icon === 'object'"
|
||||
class="side-bar-button-icon"
|
||||
/>
|
||||
<span
|
||||
v-if="shouldShowBadge"
|
||||
:class="
|
||||
cn(
|
||||
'sidebar-icon-badge absolute min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-base-foreground',
|
||||
badgeClass || '-top-1 -right-1'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ overlayValue }}
|
||||
</span>
|
||||
</div>
|
||||
</slot>
|
||||
<span v-if="label && !isSmall" class="side-bar-button-label">{{
|
||||
t(label)
|
||||
@@ -42,7 +48,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import OverlayBadge from 'primevue/overlaybadge'
|
||||
import { computed } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -57,6 +62,7 @@ const {
|
||||
tooltip = '',
|
||||
tooltipSuffix = '',
|
||||
iconBadge = '',
|
||||
badgeClass = '',
|
||||
label = '',
|
||||
isSmall = false
|
||||
} = defineProps<{
|
||||
@@ -65,6 +71,7 @@ const {
|
||||
tooltip?: string
|
||||
tooltipSuffix?: string
|
||||
iconBadge?: string | (() => string | null)
|
||||
badgeClass?: string
|
||||
label?: string
|
||||
isSmall?: boolean
|
||||
}>()
|
||||
|
||||
109
src/components/sidebar/tabs/AssetsSidebarGridView.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- Active Jobs Grid -->
|
||||
<div
|
||||
v-if="activeJobItems.length"
|
||||
class="grid max-h-[50%] scrollbar-custom overflow-y-auto"
|
||||
:style="gridStyle"
|
||||
>
|
||||
<ActiveJobCard v-for="job in activeJobItems" :key="job.id" :job="job" />
|
||||
</div>
|
||||
|
||||
<!-- Assets Header -->
|
||||
<div
|
||||
v-if="assets.length"
|
||||
:class="cn('px-2 2xl:px-4', activeJobItems.length && 'mt-2')"
|
||||
>
|
||||
<div
|
||||
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
assetType === 'input'
|
||||
? 'sideToolbar.importedAssetsHeader'
|
||||
: 'sideToolbar.generatedAssetsHeader'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assets Grid -->
|
||||
<VirtualGrid
|
||||
class="flex-1"
|
||||
:items="assetItems"
|
||||
:grid-style="gridStyle"
|
||||
@approach-end="emit('approach-end')"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<MediaAssetCard
|
||||
:asset="item.asset"
|
||||
:selected="isSelected(item.asset.id)"
|
||||
:show-output-count="showOutputCount(item.asset)"
|
||||
:output-count="getOutputCount(item.asset)"
|
||||
@click="emit('select-asset', item.asset)"
|
||||
@context-menu="emit('context-menu', $event, item.asset)"
|
||||
@zoom="emit('zoom', item.asset)"
|
||||
@output-count-click="emit('output-count-click', item.asset)"
|
||||
/>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import ActiveJobCard from '@/components/sidebar/tabs/assets/ActiveJobCard.vue'
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { isActiveJobState } from '@/utils/queueUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
assets,
|
||||
isSelected,
|
||||
assetType = 'output',
|
||||
showOutputCount,
|
||||
getOutputCount
|
||||
} = defineProps<{
|
||||
assets: AssetItem[]
|
||||
isSelected: (assetId: string) => boolean
|
||||
assetType?: 'input' | 'output'
|
||||
showOutputCount: (asset: AssetItem) => boolean
|
||||
getOutputCount: (asset: AssetItem) => number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-asset', asset: AssetItem): void
|
||||
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
|
||||
(e: 'approach-end'): void
|
||||
(e: 'zoom', asset: AssetItem): void
|
||||
(e: 'output-count-click', asset: AssetItem): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { jobItems } = useJobList()
|
||||
|
||||
type AssetGridItem = { key: string; asset: AssetItem }
|
||||
|
||||
const activeJobItems = computed(() =>
|
||||
jobItems.value.filter((item) => isActiveJobState(item.state))
|
||||
)
|
||||
|
||||
const assetItems = computed<AssetGridItem[]>(() =>
|
||||
assets.map((asset) => ({
|
||||
key: `asset-${asset.id}`,
|
||||
asset
|
||||
}))
|
||||
)
|
||||
|
||||
const gridStyle = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
padding: '0 0.5rem',
|
||||
gap: '0.5rem'
|
||||
}
|
||||
</script>
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex h-full flex-col">
|
||||
<div
|
||||
v-if="activeJobItems.length"
|
||||
class="flex max-h-[50%] flex-col gap-2 overflow-y-auto px-2"
|
||||
class="flex max-h-[50%] scrollbar-custom flex-col gap-2 overflow-y-auto px-2"
|
||||
>
|
||||
<AssetsListItem
|
||||
v-for="job in activeJobItems"
|
||||
@@ -44,9 +44,15 @@
|
||||
:class="cn('px-2', activeJobItems.length && 'mt-2')"
|
||||
>
|
||||
<div
|
||||
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
|
||||
class="flex items-center p-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
|
||||
>
|
||||
{{ t('sideToolbar.generatedAssetsHeader') }}
|
||||
{{
|
||||
t(
|
||||
assetType === 'input'
|
||||
? 'sideToolbar.importedAssetsHeader'
|
||||
: 'sideToolbar.generatedAssetsHeader'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -108,7 +114,7 @@ import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import { isActiveJobState } from '@/utils/queueUtil'
|
||||
import {
|
||||
formatDuration,
|
||||
formatSize,
|
||||
@@ -118,9 +124,14 @@ import {
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { assets, isSelected } = defineProps<{
|
||||
const {
|
||||
assets,
|
||||
isSelected,
|
||||
assetType = 'output'
|
||||
} = defineProps<{
|
||||
assets: AssetItem[]
|
||||
isSelected: (assetId: string) => boolean
|
||||
assetType?: 'input' | 'output'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -161,12 +172,6 @@ const listGridStyle = {
|
||||
gap: '0.5rem'
|
||||
}
|
||||
|
||||
function isActiveJobState(state: JobState): boolean {
|
||||
return (
|
||||
state === 'pending' || state === 'initialization' || state === 'running'
|
||||
)
|
||||
}
|
||||
|
||||
function getAssetPrimaryText(asset: AssetItem): string {
|
||||
return truncateFilename(asset.name)
|
||||
}
|
||||
|
||||
@@ -100,34 +100,24 @@
|
||||
v-if="isListView"
|
||||
:assets="displayAssets"
|
||||
:is-selected="isSelected"
|
||||
:asset-type="activeTab"
|
||||
@select-asset="handleAssetSelect"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
/>
|
||||
<VirtualGrid
|
||||
<AssetsSidebarGridView
|
||||
v-else
|
||||
:items="mediaAssetsWithKey"
|
||||
:grid-style="{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
padding: '0 0.5rem',
|
||||
gap: '0.5rem'
|
||||
}"
|
||||
:assets="displayAssets"
|
||||
:is-selected="isSelected"
|
||||
:asset-type="activeTab"
|
||||
:show-output-count="shouldShowOutputCount"
|
||||
:get-output-count="getOutputCount"
|
||||
@select-asset="handleAssetSelect"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<MediaAssetCard
|
||||
:asset="item"
|
||||
:selected="isSelected(item.id)"
|
||||
:show-output-count="shouldShowOutputCount(item)"
|
||||
:output-count="getOutputCount(item)"
|
||||
@click="handleAssetSelect(item)"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@zoom="handleZoomClick(item)"
|
||||
@output-count-click="enterFolderView(item)"
|
||||
/>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
@zoom="handleZoomClick"
|
||||
@output-count-click="enterFolderView"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
@@ -212,6 +202,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Divider from 'primevue/divider'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
@@ -219,15 +210,14 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
|
||||
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
|
||||
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import Tab from '@/components/tab/Tab.vue'
|
||||
import TabList from '@/components/tab/TabList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
|
||||
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
|
||||
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
|
||||
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
||||
@@ -243,6 +233,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { getJobDetail } from '@/services/jobOutputCache'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { ResultItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -256,6 +247,8 @@ interface JobOutputItem {
|
||||
const { t, n } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const queueStore = useQueueStore()
|
||||
const { activeJobsCount } = storeToRefs(queueStore)
|
||||
const executionStore = useExecutionStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const activeTab = ref<'input' | 'output'>('output')
|
||||
@@ -301,9 +294,6 @@ const formattedExecutionTime = computed(() => {
|
||||
})
|
||||
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const activeJobsCount = computed(
|
||||
() => queueStore.pendingTasks.length + queueStore.runningTasks.length
|
||||
)
|
||||
const activeJobsLabel = computed(() => {
|
||||
const count = activeJobsCount.value
|
||||
return t(
|
||||
@@ -404,14 +394,14 @@ const showLoadingState = computed(
|
||||
() =>
|
||||
loading.value &&
|
||||
displayAssets.value.length === 0 &&
|
||||
(!isListView.value || activeJobsCount.value === 0)
|
||||
activeJobsCount.value === 0
|
||||
)
|
||||
|
||||
const showEmptyState = computed(
|
||||
() =>
|
||||
!loading.value &&
|
||||
displayAssets.value.length === 0 &&
|
||||
(!isListView.value || activeJobsCount.value === 0)
|
||||
activeJobsCount.value === 0
|
||||
)
|
||||
|
||||
watch(displayAssets, (newAssets) => {
|
||||
@@ -453,14 +443,6 @@ const galleryItems = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 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) {
|
||||
@@ -510,7 +492,13 @@ const handleBulkDelete = async (assets: AssetItem[]) => {
|
||||
}
|
||||
|
||||
const handleClearQueue = async () => {
|
||||
const pendingPromptIds = queueStore.pendingTasks
|
||||
.map((task) => task.promptId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
|
||||
executionStore.clearInitializationByPromptIds(pendingPromptIds)
|
||||
}
|
||||
|
||||
const handleBulkAddToWorkflow = async (assets: AssetItem[]) => {
|
||||
|
||||
111
src/components/sidebar/tabs/assets/ActiveJobCard.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ActiveJobCard from './ActiveJobCard.vue'
|
||||
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
|
||||
vi.mock('@/composables/useProgressBarBackground', () => ({
|
||||
useProgressBarBackground: () => ({
|
||||
progressBarPrimaryClass: 'bg-blue-500',
|
||||
hasProgressPercent: (val: number | undefined) => typeof val === 'number',
|
||||
progressPercentStyle: (val: number) => ({ width: `${val}%` })
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
sideToolbar: {
|
||||
activeJobStatus: 'Active job: {status}'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const createJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
|
||||
id: 'test-job-1',
|
||||
title: 'Running...',
|
||||
meta: 'Step 5/10',
|
||||
state: 'running',
|
||||
progressTotalPercent: 50,
|
||||
progressCurrentPercent: 75,
|
||||
...overrides
|
||||
})
|
||||
|
||||
const mountComponent = (job: JobListItem) =>
|
||||
mount(ActiveJobCard, {
|
||||
props: { job },
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
describe('ActiveJobCard', () => {
|
||||
it('displays percentage and progress bar when job is running', () => {
|
||||
const wrapper = mountComponent(
|
||||
createJob({ state: 'running', progressTotalPercent: 65 })
|
||||
)
|
||||
|
||||
expect(wrapper.text()).toContain('65%')
|
||||
const progressBar = wrapper.find('.bg-blue-500')
|
||||
expect(progressBar.exists()).toBe(true)
|
||||
expect(progressBar.attributes('style')).toContain('width: 65%')
|
||||
})
|
||||
|
||||
it('displays status text when job is pending', () => {
|
||||
const wrapper = mountComponent(
|
||||
createJob({
|
||||
state: 'pending',
|
||||
title: 'In queue...',
|
||||
progressTotalPercent: undefined
|
||||
})
|
||||
)
|
||||
|
||||
expect(wrapper.text()).toContain('In queue...')
|
||||
const progressBar = wrapper.find('.bg-blue-500')
|
||||
expect(progressBar.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows spinner for pending state', () => {
|
||||
const wrapper = mountComponent(createJob({ state: 'pending' }))
|
||||
|
||||
const spinner = wrapper.find('.icon-\\[lucide--loader-circle\\]')
|
||||
expect(spinner.exists()).toBe(true)
|
||||
expect(spinner.classes()).toContain('animate-spin')
|
||||
})
|
||||
|
||||
it('shows error icon for failed state', () => {
|
||||
const wrapper = mountComponent(
|
||||
createJob({ state: 'failed', title: 'Failed' })
|
||||
)
|
||||
|
||||
const errorIcon = wrapper.find('.icon-\\[lucide--circle-alert\\]')
|
||||
expect(errorIcon.exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Failed')
|
||||
})
|
||||
|
||||
it('shows preview image when running with iconImageUrl', () => {
|
||||
const wrapper = mountComponent(
|
||||
createJob({
|
||||
state: 'running',
|
||||
iconImageUrl: 'https://example.com/preview.jpg'
|
||||
})
|
||||
)
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.exists()).toBe(true)
|
||||
expect(img.attributes('src')).toBe('https://example.com/preview.jpg')
|
||||
})
|
||||
|
||||
it('has proper accessibility attributes', () => {
|
||||
const wrapper = mountComponent(createJob({ title: 'Generating...' }))
|
||||
|
||||
const container = wrapper.find('[role="status"]')
|
||||
expect(container.exists()).toBe(true)
|
||||
expect(container.attributes('aria-label')).toBe('Active job: Generating...')
|
||||
})
|
||||
})
|
||||
85
src/components/sidebar/tabs/assets/ActiveJobCard.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div
|
||||
role="status"
|
||||
:aria-label="t('sideToolbar.activeJobStatus', { status: statusText })"
|
||||
class="flex flex-col gap-2 p-2 rounded-lg"
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<div class="relative aspect-square overflow-hidden rounded-lg">
|
||||
<!-- Running state with preview image -->
|
||||
<img
|
||||
v-if="isRunning && job.iconImageUrl"
|
||||
:src="job.iconImageUrl"
|
||||
:alt="statusText"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<!-- Placeholder for queued/failed states or running without preview -->
|
||||
<div
|
||||
v-else
|
||||
class="absolute inset-0 flex items-center justify-center bg-modal-card-placeholder-background"
|
||||
>
|
||||
<!-- Spinner for queued/initialization states -->
|
||||
<i
|
||||
v-if="isQueued"
|
||||
class="icon-[lucide--loader-circle] size-8 animate-spin text-muted-foreground"
|
||||
/>
|
||||
<!-- Error icon for failed state -->
|
||||
<i
|
||||
v-else-if="isFailed"
|
||||
class="icon-[lucide--circle-alert] size-8 text-red-500"
|
||||
/>
|
||||
<!-- Spinner for running without preview -->
|
||||
<i
|
||||
v-else
|
||||
class="icon-[lucide--loader-circle] size-8 animate-spin text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer: Progress bar or status text -->
|
||||
<div class="flex gap-1.5 items-center h-5">
|
||||
<!-- Running state: percentage + progress bar -->
|
||||
<template v-if="isRunning && hasProgressPercent(progressPercent)">
|
||||
<span class="shrink-0 text-sm text-muted-foreground">
|
||||
{{ Math.round(progressPercent ?? 0) }}%
|
||||
</span>
|
||||
<div class="flex-1 relative h-1 rounded-sm bg-secondary-background">
|
||||
<div
|
||||
:class="progressBarPrimaryClass"
|
||||
:style="progressPercentStyle(progressPercent)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Non-running states: status text only -->
|
||||
<template v-else>
|
||||
<div class="w-full truncate text-center text-sm text-muted-foreground">
|
||||
{{ statusText }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
|
||||
|
||||
const { job } = defineProps<{ job: JobListItem }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { progressBarPrimaryClass, hasProgressPercent, progressPercentStyle } =
|
||||
useProgressBarBackground()
|
||||
|
||||
const statusText = computed(() => job.title)
|
||||
const progressPercent = computed(() => job.progressTotalPercent)
|
||||
|
||||
const isQueued = computed(
|
||||
() => job.state === 'pending' || job.state === 'initialization'
|
||||
)
|
||||
const isRunning = computed(() => job.state === 'running')
|
||||
const isFailed = computed(() => job.state === 'failed')
|
||||
</script>
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- A button that shows current authenticated user's avatar -->
|
||||
<!-- A button that shows workspace icon (Cloud) or user avatar -->
|
||||
<template>
|
||||
<div>
|
||||
<Button
|
||||
@@ -16,7 +16,16 @@
|
||||
)
|
||||
"
|
||||
>
|
||||
<UserAvatar :photo-url="photoURL" :class="compact && 'size-full'" />
|
||||
<WorkspaceProfilePic
|
||||
v-if="showWorkspaceIcon"
|
||||
:workspace-name="workspaceName"
|
||||
:class="compact && 'size-full'"
|
||||
/>
|
||||
<UserAvatar
|
||||
v-else
|
||||
:photo-url="photoURL"
|
||||
:class="compact && 'size-full'"
|
||||
/>
|
||||
|
||||
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-3 px-1" />
|
||||
</div>
|
||||
@@ -27,38 +36,65 @@
|
||||
:show-arrow="false"
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'rounded-lg'
|
||||
class: 'rounded-lg w-80'
|
||||
}
|
||||
}"
|
||||
>
|
||||
<CurrentUserPopover @close="closePopover" />
|
||||
<!-- Workspace mode: workspace-aware popover -->
|
||||
<CurrentUserPopoverWorkspace
|
||||
v-if="teamWorkspacesEnabled"
|
||||
@close="closePopover"
|
||||
/>
|
||||
<!-- Legacy mode: original popover -->
|
||||
<CurrentUserPopover v-else @close="closePopover" />
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, defineAsyncComponent, ref } from 'vue'
|
||||
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import CurrentUserPopover from './CurrentUserPopover.vue'
|
||||
|
||||
const CurrentUserPopoverWorkspace = defineAsyncComponent(
|
||||
() => import('./CurrentUserPopoverWorkspace.vue')
|
||||
)
|
||||
|
||||
const { showArrow = true, compact = false } = defineProps<{
|
||||
showArrow?: boolean
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const teamWorkspacesEnabled = computed(() => flags.teamWorkspacesEnabled)
|
||||
|
||||
const { isLoggedIn, userPhotoUrl } = useCurrentUser()
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
const photoURL = computed<string | undefined>(
|
||||
() => userPhotoUrl.value ?? undefined
|
||||
)
|
||||
|
||||
const showWorkspaceIcon = computed(() => isCloud && teamWorkspacesEnabled.value)
|
||||
|
||||
const workspaceName = computed(() => {
|
||||
if (!showWorkspaceIcon.value) return ''
|
||||
const { workspaceName } = storeToRefs(useTeamWorkspaceStore())
|
||||
return workspaceName.value
|
||||
})
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
|
||||
const closePopover = () => {
|
||||
popover.value?.hide()
|
||||
}
|
||||
|
||||
337
src/components/topbar/CurrentUserPopoverWorkspace.vue
Normal file
@@ -0,0 +1,337 @@
|
||||
<!-- A popover that shows current user information and actions -->
|
||||
<template>
|
||||
<div
|
||||
class="current-user-popover w-80 -m-3 p-2 rounded-lg border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- User Info Section -->
|
||||
<div class="flex flex-col items-center px-0 py-3 mb-4">
|
||||
<UserAvatar
|
||||
class="mb-1"
|
||||
:photo-url="userPhotoUrl"
|
||||
:pt:icon:class="{
|
||||
'text-2xl!': !userPhotoUrl
|
||||
}"
|
||||
size="large"
|
||||
/>
|
||||
|
||||
<!-- User Details -->
|
||||
<h3 class="my-0 mb-1 truncate text-base font-bold text-base-foreground">
|
||||
{{ userDisplayName || $t('g.user') }}
|
||||
</h3>
|
||||
<p v-if="userEmail" class="my-0 truncate text-sm text-muted">
|
||||
{{ userEmail }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Workspace Selector -->
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between rounded-lg px-4 py-2 hover:bg-secondary-background-hover"
|
||||
@click="toggleWorkspaceSwitcher"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<WorkspaceProfilePic
|
||||
class="size-6 shrink-0 text-xs"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
<span class="truncate text-sm text-base-foreground">{{
|
||||
workspaceName
|
||||
}}</span>
|
||||
<div
|
||||
v-if="workspaceTierName"
|
||||
class="shrink-0 rounded bg-secondary-background-hover px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{{ workspaceTierName }}
|
||||
</div>
|
||||
<span v-else class="shrink-0 text-xs text-muted-foreground">
|
||||
{{ $t('workspaceSwitcher.subscribe') }}
|
||||
</span>
|
||||
</div>
|
||||
<i class="pi pi-chevron-down shrink-0 text-sm text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<Popover
|
||||
ref="workspaceSwitcherPopover"
|
||||
append-to="body"
|
||||
:pt="{
|
||||
content: {
|
||||
class: 'p-0'
|
||||
}
|
||||
}"
|
||||
>
|
||||
<WorkspaceSwitcherPopover
|
||||
@select="workspaceSwitcherPopover?.hide()"
|
||||
@create="handleCreateWorkspace"
|
||||
/>
|
||||
</Popover>
|
||||
|
||||
<!-- Credits Section (PERSONAL and OWNER only) -->
|
||||
<template v-if="showCreditsSection">
|
||||
<div class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="4rem"
|
||||
height="1.25rem"
|
||||
class="w-full"
|
||||
/>
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
displayedCredits
|
||||
}}</span>
|
||||
<i
|
||||
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
|
||||
class="icon-[lucide--circle-help] mr-auto cursor-help text-base text-muted-foreground"
|
||||
/>
|
||||
<!-- Subscribed: Show Add Credits button -->
|
||||
<Button
|
||||
v-if="isActiveSubscription && isWorkspaceSubscribed"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="text-base-foreground"
|
||||
data-testid="add-credits-button"
|
||||
@click="handleTopUp"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
</Button>
|
||||
<!-- Unsubscribed: Show Subscribe button (disabled until billing is ready) -->
|
||||
<SubscribeButton
|
||||
v-else
|
||||
disabled
|
||||
:fluid="false"
|
||||
:label="$t('workspaceSwitcher.subscribe')"
|
||||
size="sm"
|
||||
variant="gradient"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider class="mx-0 my-2" />
|
||||
</template>
|
||||
|
||||
<!-- Plans & Pricing (PERSONAL and OWNER only) -->
|
||||
<div
|
||||
v-if="showPlansAndPricing"
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="plans-pricing-menu-item"
|
||||
@click="handleOpenPlansAndPricing"
|
||||
>
|
||||
<i class="icon-[lucide--receipt-text] text-sm text-muted-foreground" />
|
||||
<span class="flex-1 text-sm text-base-foreground">{{
|
||||
$t('subscription.plansAndPricing')
|
||||
}}</span>
|
||||
<span
|
||||
v-if="canUpgrade"
|
||||
class="rounded-full bg-base-foreground px-1.5 py-0.5 text-xs font-bold text-base-background"
|
||||
>
|
||||
{{ $t('subscription.upgrade') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Manage Plan (PERSONAL and OWNER, only if subscribed) -->
|
||||
<div
|
||||
v-if="showManagePlan"
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="manage-plan-menu-item"
|
||||
@click="handleOpenPlanAndCreditsSettings"
|
||||
>
|
||||
<i class="icon-[lucide--file-text] text-sm text-muted-foreground" />
|
||||
<span class="flex-1 text-sm text-base-foreground">{{
|
||||
$t('subscription.managePlan')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Partner Nodes Pricing (always shown) -->
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="partner-nodes-menu-item"
|
||||
@click="handleOpenPartnerNodesInfo"
|
||||
>
|
||||
<i class="icon-[lucide--tag] text-sm text-muted-foreground" />
|
||||
<span class="flex-1 text-sm text-base-foreground">{{
|
||||
$t('subscription.partnerNodesCredits')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<Divider class="mx-0 my-2" />
|
||||
|
||||
<!-- Workspace Settings (always shown) -->
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="workspace-settings-menu-item"
|
||||
@click="handleOpenWorkspaceSettings"
|
||||
>
|
||||
<i class="icon-[lucide--users] text-sm text-muted-foreground" />
|
||||
<span class="flex-1 text-sm text-base-foreground">{{
|
||||
$t('userSettings.workspaceSettings')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Account Settings (always shown) -->
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="user-settings-menu-item"
|
||||
@click="handleOpenUserSettings"
|
||||
>
|
||||
<i class="icon-[lucide--settings-2] text-sm text-muted-foreground" />
|
||||
<span class="flex-1 text-sm text-base-foreground">{{
|
||||
$t('userSettings.accountSettings')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<Divider class="mx-0 my-2" />
|
||||
|
||||
<!-- Logout (always shown) -->
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="logout-menu-item"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<i class="icon-[lucide--log-out] text-sm text-muted-foreground" />
|
||||
<span class="flex-1 text-sm text-base-foreground">{{
|
||||
$t('auth.signOut.signOut')
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Divider from 'primevue/divider'
|
||||
import Popover from 'primevue/popover'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import WorkspaceSwitcherPopover from '@/components/topbar/WorkspaceSwitcherPopover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const {
|
||||
workspaceName,
|
||||
isInPersonalWorkspace: isPersonalWorkspace,
|
||||
isWorkspaceSubscribed,
|
||||
subscriptionPlan
|
||||
} = storeToRefs(workspaceStore)
|
||||
const { workspaceRole } = useWorkspaceUI()
|
||||
const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
|
||||
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
||||
useCurrentUser()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const dialogService = useDialogService()
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
const { totalCredits, isLoadingBalance } = useSubscriptionCredits()
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
const { t } = useI18n()
|
||||
|
||||
const displayedCredits = computed(() =>
|
||||
isWorkspaceSubscribed.value ? totalCredits.value : '0'
|
||||
)
|
||||
|
||||
// Workspace subscription tier name (not user tier)
|
||||
const workspaceTierName = computed(() => {
|
||||
if (!isWorkspaceSubscribed.value) return null
|
||||
if (!subscriptionPlan.value) return null
|
||||
// Convert plan to display name
|
||||
if (subscriptionPlan.value === 'PRO_MONTHLY')
|
||||
return t('subscription.tiers.pro.name')
|
||||
if (subscriptionPlan.value === 'PRO_YEARLY')
|
||||
return t('subscription.tierNameYearly', {
|
||||
name: t('subscription.tiers.pro.name')
|
||||
})
|
||||
return null
|
||||
})
|
||||
|
||||
const canUpgrade = computed(() => {
|
||||
// PRO is currently the only/highest tier, so no upgrades available
|
||||
// This will need updating when additional tiers are added
|
||||
return false
|
||||
})
|
||||
|
||||
const showPlansAndPricing = computed(
|
||||
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
|
||||
)
|
||||
const showManagePlan = computed(
|
||||
() => showPlansAndPricing.value && isActiveSubscription.value
|
||||
)
|
||||
const showCreditsSection = computed(
|
||||
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
|
||||
)
|
||||
|
||||
const handleOpenUserSettings = () => {
|
||||
dialogService.showSettingsDialog('user')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleOpenWorkspaceSettings = () => {
|
||||
dialogService.showSettingsDialog('workspace')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleOpenPlansAndPricing = () => {
|
||||
subscriptionDialog.show()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleOpenPlanAndCreditsSettings = () => {
|
||||
if (isCloud) {
|
||||
dialogService.showSettingsDialog('workspace')
|
||||
} else {
|
||||
dialogService.showSettingsDialog('credits')
|
||||
}
|
||||
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleTopUp = () => {
|
||||
// Track purchase credits entry from avatar popover
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked()
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleOpenPartnerNodesInfo = () => {
|
||||
window.open(
|
||||
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
|
||||
'_blank'
|
||||
)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await handleSignOut()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleCreateWorkspace = () => {
|
||||
workspaceSwitcherPopover.value?.hide()
|
||||
dialogService.showCreateWorkspaceDialog()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const toggleWorkspaceSwitcher = (event: MouseEvent) => {
|
||||
workspaceSwitcherPopover.value?.toggle(event)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void authActions.fetchBalance()
|
||||
})
|
||||
</script>
|
||||
166
src/components/topbar/WorkspaceSwitcherPopover.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div class="flex w-80 flex-col overflow-hidden rounded-lg">
|
||||
<div class="flex flex-col overflow-y-auto">
|
||||
<!-- Loading state -->
|
||||
<div v-if="isFetchingWorkspaces" class="flex flex-col gap-2 p-2">
|
||||
<div
|
||||
v-for="i in 2"
|
||||
:key="i"
|
||||
class="flex h-[54px] animate-pulse items-center gap-2 rounded px-2 py-4"
|
||||
>
|
||||
<div class="size-8 rounded-full bg-secondary-background" />
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<div class="h-4 w-24 rounded bg-secondary-background" />
|
||||
<div class="h-3 w-16 rounded bg-secondary-background" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workspace list -->
|
||||
<template v-else>
|
||||
<template v-for="workspace in availableWorkspaces" :key="workspace.id">
|
||||
<div class="border-b border-border-default p-2">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'group flex h-[54px] w-full items-center gap-2 rounded px-2 py-4',
|
||||
'hover:bg-secondary-background-hover',
|
||||
isCurrentWorkspace(workspace) && 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
>
|
||||
<button
|
||||
class="flex flex-1 cursor-pointer items-center gap-2 border-none bg-transparent p-0"
|
||||
@click="handleSelectWorkspace(workspace)"
|
||||
>
|
||||
<WorkspaceProfilePic
|
||||
class="size-8 text-sm"
|
||||
:workspace-name="workspace.name"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col items-start gap-1">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ workspace.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="workspace.type !== 'personal'"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
{{ getRoleLabel(workspace.role) }}
|
||||
</span>
|
||||
</div>
|
||||
<i
|
||||
v-if="isCurrentWorkspace(workspace)"
|
||||
class="pi pi-check text-sm text-base-foreground"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- <Divider class="mx-0 my-0" /> -->
|
||||
|
||||
<!-- Create workspace button -->
|
||||
<div class="px-2 py-2">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex h-12 w-full items-center gap-2 rounded px-2 py-2',
|
||||
canCreateWorkspace
|
||||
? 'cursor-pointer hover:bg-secondary-background-hover'
|
||||
: 'cursor-default'
|
||||
)
|
||||
"
|
||||
@click="canCreateWorkspace && handleCreateWorkspace()"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex size-8 items-center justify-center rounded-full bg-secondary-background',
|
||||
!canCreateWorkspace && 'opacity-50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="pi pi-plus text-sm text-muted-foreground" />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span
|
||||
v-if="canCreateWorkspace"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('workspaceSwitcher.createWorkspace') }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-muted-foreground">
|
||||
{{ $t('workspaceSwitcher.maxWorkspacesReached') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
|
||||
import type {
|
||||
WorkspaceRole,
|
||||
WorkspaceType
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface AvailableWorkspace {
|
||||
id: string
|
||||
name: string
|
||||
type: WorkspaceType
|
||||
role: WorkspaceRole
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [workspace: AvailableWorkspace]
|
||||
create: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { switchWithConfirmation } = useWorkspaceSwitch()
|
||||
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { workspaceId, workspaces, canCreateWorkspace, isFetchingWorkspaces } =
|
||||
storeToRefs(workspaceStore)
|
||||
|
||||
const availableWorkspaces = computed<AvailableWorkspace[]>(() =>
|
||||
workspaces.value.map((w) => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
type: w.type,
|
||||
role: w.role
|
||||
}))
|
||||
)
|
||||
|
||||
function isCurrentWorkspace(workspace: AvailableWorkspace): boolean {
|
||||
return workspace.id === workspaceId.value
|
||||
}
|
||||
|
||||
function getRoleLabel(role: AvailableWorkspace['role']): string {
|
||||
if (role === 'owner') return t('workspaceSwitcher.roleOwner')
|
||||
if (role === 'member') return t('workspaceSwitcher.roleMember')
|
||||
return ''
|
||||
}
|
||||
|
||||
async function handleSelectWorkspace(workspace: AvailableWorkspace) {
|
||||
const success = await switchWithConfirmation(workspace.id)
|
||||
if (success) {
|
||||
emit('select', workspace)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateWorkspace() {
|
||||
emit('create')
|
||||
}
|
||||
</script>
|
||||
19
src/components/ui/AGENTS.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# UI Component Guidelines
|
||||
|
||||
## Adding New Components
|
||||
|
||||
```bash
|
||||
pnpm dlx shadcn-vue@latest add <component-name> --yes
|
||||
```
|
||||
|
||||
After adding, create `ComponentName.stories.ts` with Default, Disabled, and variant stories.
|
||||
|
||||
## Reka UI Wrapper Components
|
||||
|
||||
- Use reactive props destructuring with rest: `const { class: className, ...restProps } = defineProps<Props>()`
|
||||
- Use `useForwardProps(restProps)` for prop forwarding, or `computed()` if adding defaults
|
||||
- Import siblings directly (`./Component.vue`), not from barrel (`'.'`)
|
||||
- Use `cn()` for class merging with `className`
|
||||
- Use Iconify icons: `<i class="icon-[lucide--check]" />`
|
||||
- Use design tokens: `bg-secondary-background`, `text-muted-foreground`, `border-border-default`
|
||||
- Tailwind 4 CSS variables use parentheses: `h-(--my-var)` not `h-[--my-var]`
|
||||
261
src/components/ui/select/Select.stories.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Select from './Select.vue'
|
||||
import SelectContent from './SelectContent.vue'
|
||||
import SelectGroup from './SelectGroup.vue'
|
||||
import SelectItem from './SelectItem.vue'
|
||||
import SelectLabel from './SelectLabel.vue'
|
||||
import SelectSeparator from './SelectSeparator.vue'
|
||||
import SelectTrigger from './SelectTrigger.vue'
|
||||
import SelectValue from './SelectValue.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Select',
|
||||
component: Select,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
modelValue: {
|
||||
control: 'text',
|
||||
description: 'Selected value'
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'When true, disables the select'
|
||||
},
|
||||
'onUpdate:modelValue': { action: 'update:modelValue' }
|
||||
}
|
||||
} satisfies Meta<typeof Select>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
},
|
||||
setup() {
|
||||
const value = ref(args.modelValue || '')
|
||||
return { value, args }
|
||||
},
|
||||
template: `
|
||||
<Select v-model="value" :disabled="args.disabled">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Select a fruit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
<SelectItem value="cherry">Cherry</SelectItem>
|
||||
<SelectItem value="grape">Grape</SelectItem>
|
||||
<SelectItem value="orange">Orange</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div class="mt-4 text-sm text-muted-foreground">
|
||||
Selected: {{ value || 'None' }}
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
export const WithPlaceholder: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
},
|
||||
setup() {
|
||||
const value = ref('')
|
||||
return { value, args }
|
||||
},
|
||||
template: `
|
||||
<Select v-model="value" :disabled="args.disabled">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Choose an option..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="option1">Option 1</SelectItem>
|
||||
<SelectItem value="option2">Option 2</SelectItem>
|
||||
<SelectItem value="option3">Option 3</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
},
|
||||
setup() {
|
||||
const value = ref('apple')
|
||||
return { value, args }
|
||||
},
|
||||
template: `
|
||||
<Select v-model="value" disabled>
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Select a fruit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
<SelectItem value="cherry">Cherry</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithGroups: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
},
|
||||
setup() {
|
||||
const value = ref('')
|
||||
return { value, args }
|
||||
},
|
||||
template: `
|
||||
<Select v-model="value" :disabled="args.disabled">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Select a model type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Checkpoints</SelectLabel>
|
||||
<SelectItem value="sd15">SD 1.5</SelectItem>
|
||||
<SelectItem value="sdxl">SDXL</SelectItem>
|
||||
<SelectItem value="flux">Flux</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>LoRAs</SelectLabel>
|
||||
<SelectItem value="lora-style">Style LoRA</SelectItem>
|
||||
<SelectItem value="lora-character">Character LoRA</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Other</SelectLabel>
|
||||
<SelectItem value="vae">VAE</SelectItem>
|
||||
<SelectItem value="embedding">Embedding</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div class="mt-4 text-sm text-muted-foreground">
|
||||
Selected: {{ value || 'None' }}
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
export const Scrollable: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
},
|
||||
setup() {
|
||||
const value = ref('')
|
||||
const items = Array.from({ length: 20 }, (_, i) => ({
|
||||
value: `item-${i + 1}`,
|
||||
label: `Option ${i + 1}`
|
||||
}))
|
||||
return { value, items, args }
|
||||
},
|
||||
template: `
|
||||
<Select v-model="value" :disabled="args.disabled">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Select an option" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="item in items"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomWidth: Story = {
|
||||
render: (args) => ({
|
||||
components: {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
},
|
||||
setup() {
|
||||
const value = ref('')
|
||||
return { value, args }
|
||||
},
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<Select v-model="value" :disabled="args.disabled">
|
||||
<SelectTrigger class="w-32">
|
||||
<SelectValue placeholder="Small" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="a">A</SelectItem>
|
||||
<SelectItem value="b">B</SelectItem>
|
||||
<SelectItem value="c">C</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select v-model="value" :disabled="args.disabled">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="Full width select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="option1">Option 1</SelectItem>
|
||||
<SelectItem value="option2">Option 2</SelectItem>
|
||||
<SelectItem value="option3">Option 3</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
16
src/components/ui/select/Select.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectRootEmits, SelectRootProps } from 'reka-ui'
|
||||
import { SelectRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
// eslint-disable-next-line vue/no-unused-properties
|
||||
const props = defineProps<SelectRootProps>()
|
||||
const emits = defineEmits<SelectRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</SelectRoot>
|
||||
</template>
|
||||
73
src/components/ui/select/SelectContent.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectContentEmits, SelectContentProps } from 'reka-ui'
|
||||
import {
|
||||
SelectContent,
|
||||
SelectPortal,
|
||||
SelectViewport,
|
||||
useForwardPropsEmits
|
||||
} from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import SelectScrollDownButton from './SelectScrollDownButton.vue'
|
||||
import SelectScrollUpButton from './SelectScrollUpButton.vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const {
|
||||
position = 'popper',
|
||||
class: className,
|
||||
...restProps
|
||||
} = defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<SelectContentEmits>()
|
||||
|
||||
const delegatedProps = computed(() => ({
|
||||
position,
|
||||
...restProps
|
||||
}))
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectPortal>
|
||||
<SelectContent
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
:class="
|
||||
cn(
|
||||
'relative z-3000 max-h-96 min-w-32 overflow-hidden',
|
||||
'mt-2 rounded-lg p-2',
|
||||
'bg-base-background text-base-foreground',
|
||||
'border border-solid border-border-default',
|
||||
'shadow-md',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectViewport
|
||||
:class="
|
||||
cn(
|
||||
'scrollbar-custom flex flex-col gap-0',
|
||||
position === 'popper' &&
|
||||
'h-(--reka-select-trigger-height) w-full min-w-(--reka-select-trigger-width)'
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</SelectViewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</template>
|
||||
17
src/components/ui/select/SelectGroup.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectGroupProps } from 'reka-ui'
|
||||
import { SelectGroup } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
SelectGroupProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectGroup :class="cn('w-full', className)" v-bind="restProps">
|
||||
<slot />
|
||||
</SelectGroup>
|
||||
</template>
|
||||
37
src/components/ui/select/SelectItem.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectItemProps } from 'reka-ui'
|
||||
import { SelectItem, SelectItemIndicator, SelectItemText } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
SelectItemProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectItem
|
||||
v-bind="restProps"
|
||||
:class="
|
||||
cn(
|
||||
'relative flex w-full cursor-pointer select-none items-center justify-between',
|
||||
'gap-3 rounded px-2 py-3 text-sm outline-none',
|
||||
'hover:bg-secondary-background-hover',
|
||||
'focus:bg-secondary-background-hover',
|
||||
'data-[state=checked]:bg-secondary-background-selected',
|
||||
'data-[state=checked]:hover:bg-secondary-background-selected',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<SelectItemText class="truncate">
|
||||
<slot />
|
||||
</SelectItemText>
|
||||
|
||||
<SelectItemIndicator class="flex shrink-0 items-center justify-center">
|
||||
<i class="icon-[lucide--check] text-base-foreground" aria-hidden="true" />
|
||||
</SelectItemIndicator>
|
||||
</SelectItem>
|
||||
</template>
|
||||
25
src/components/ui/select/SelectLabel.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectLabelProps } from 'reka-ui'
|
||||
import { SelectLabel } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
SelectLabelProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectLabel
|
||||
v-bind="restProps"
|
||||
:class="
|
||||
cn(
|
||||
'px-3 py-2 text-xs uppercase tracking-wide text-muted-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</SelectLabel>
|
||||
</template>
|
||||
27
src/components/ui/select/SelectScrollDownButton.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectScrollDownButtonProps } from 'reka-ui'
|
||||
import { SelectScrollDownButton } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
SelectScrollDownButtonProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectScrollDownButton
|
||||
v-bind="restProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex cursor-default items-center justify-center py-1 text-muted-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot>
|
||||
<i class="icon-[lucide--chevron-down]" />
|
||||
</slot>
|
||||
</SelectScrollDownButton>
|
||||
</template>
|
||||
27
src/components/ui/select/SelectScrollUpButton.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectScrollUpButtonProps } from 'reka-ui'
|
||||
import { SelectScrollUpButton } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
SelectScrollUpButtonProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectScrollUpButton
|
||||
v-bind="restProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex cursor-default items-center justify-center py-1 text-muted-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot>
|
||||
<i class="icon-[lucide--chevron-up]" />
|
||||
</slot>
|
||||
</SelectScrollUpButton>
|
||||
</template>
|
||||
18
src/components/ui/select/SelectSeparator.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectSeparatorProps } from 'reka-ui'
|
||||
import { SelectSeparator } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
SelectSeparatorProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectSeparator
|
||||
v-bind="restProps"
|
||||
:class="cn('-mx-1 my-1 h-px bg-border-default', className)"
|
||||
/>
|
||||
</template>
|
||||
36
src/components/ui/select/SelectTrigger.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectTriggerProps } from 'reka-ui'
|
||||
import { SelectIcon, SelectTrigger } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restProps } = defineProps<
|
||||
SelectTriggerProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectTrigger
|
||||
v-bind="restProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-10 w-full cursor-pointer select-none items-center justify-between',
|
||||
'rounded-lg px-4 py-2 text-sm',
|
||||
'bg-secondary-background text-base-foreground',
|
||||
'border-[2.5px] border-solid border-transparent',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'focus:border-node-component-border focus:outline-none',
|
||||
'data-[placeholder]:text-muted-foreground',
|
||||
'disabled:cursor-not-allowed disabled:opacity-60',
|
||||
'[&>span]:truncate',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<SelectIcon as-child>
|
||||
<i class="icon-[lucide--chevron-down] shrink-0 text-muted-foreground" />
|
||||
</SelectIcon>
|
||||
</SelectTrigger>
|
||||
</template>
|
||||
12
src/components/ui/select/SelectValue.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectValueProps } from 'reka-ui'
|
||||
import { SelectValue } from 'reka-ui'
|
||||
|
||||
const props = defineProps<SelectValueProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectValue v-bind="props">
|
||||
<slot />
|
||||
</SelectValue>
|
||||
</template>
|
||||
@@ -23,6 +23,11 @@ const showInput = computed(() => isEditing.value || isEmpty)
|
||||
const { forwardRef, currentElement } = useForwardExpose()
|
||||
const registerFocus = inject(tagsInputFocusKey, undefined)
|
||||
|
||||
function handleEscape() {
|
||||
currentElement.value?.blur()
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
registerFocus?.(() => currentElement.value?.focus())
|
||||
})
|
||||
@@ -44,5 +49,6 @@ onUnmounted(() => {
|
||||
className
|
||||
)
|
||||
"
|
||||
@keydown.escape.stop="handleEscape"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,100 +1,128 @@
|
||||
<template>
|
||||
<div class="base-widget-layout rounded-2xl overflow-hidden relative">
|
||||
<Button
|
||||
v-show="!isRightPanelOpen && hasRightPanel"
|
||||
size="lg"
|
||||
:class="
|
||||
cn('absolute top-4 right-18 z-10', 'transition-opacity duration-200', {
|
||||
'opacity-0 pointer-events-none': isRightPanelOpen || !hasRightPanel
|
||||
})
|
||||
"
|
||||
@click="toggleRightPanel"
|
||||
<div
|
||||
class="base-widget-layout rounded-2xl overflow-hidden relative"
|
||||
@keydown.esc.capture="handleEscape"
|
||||
>
|
||||
<div
|
||||
class="grid h-full w-full transition-[grid-template-columns] duration-300 ease-out"
|
||||
:style="gridStyle"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right]" />
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
class="absolute top-4 right-6 z-10 transition-opacity duration-200 w-10"
|
||||
@click="closeDialog"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</Button>
|
||||
<div class="flex h-full w-full">
|
||||
<Transition name="slide-panel">
|
||||
<nav
|
||||
v-if="$slots.leftPanel && showLeftPanel"
|
||||
:class="[
|
||||
PANEL_SIZES.width,
|
||||
PANEL_SIZES.minWidth,
|
||||
PANEL_SIZES.maxWidth
|
||||
]"
|
||||
>
|
||||
<slot name="leftPanel"></slot>
|
||||
</nav>
|
||||
</Transition>
|
||||
|
||||
<div class="flex-1 flex bg-base-background">
|
||||
<div class="flex h-full w-full flex-col">
|
||||
<header
|
||||
v-if="$slots.header"
|
||||
class="w-full h-18 px-6 flex items-center justify-between gap-2"
|
||||
>
|
||||
<div class="flex flex-1 shrink-0 gap-2">
|
||||
<Button v-if="!notMobile" size="icon" @click="toggleLeftPanel">
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
showLeftPanel
|
||||
? 'icon-[lucide--panel-left]'
|
||||
: 'icon-[lucide--panel-left-close]'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
<slot name="header"></slot>
|
||||
</div>
|
||||
<slot name="header-right-area"></slot>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex justify-end gap-2 w-0',
|
||||
hasRightPanel && !isRightPanelOpen ? 'min-w-22' : 'min-w-10'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Button
|
||||
v-if="isRightPanelOpen && hasRightPanel"
|
||||
size="lg"
|
||||
@click="toggleRightPanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right-close]" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex min-h-0 flex-1 flex-col">
|
||||
<!-- Fallback title bar when no leftPanel is provided -->
|
||||
<slot name="contentFilter"></slot>
|
||||
<h2
|
||||
v-if="!$slots.leftPanel"
|
||||
class="text-xxl m-0 px-6 pt-2 pb-6 capitalize"
|
||||
>
|
||||
{{ contentTitle }}
|
||||
</h2>
|
||||
<div
|
||||
class="min-h-0 flex-1 px-6 pt-0 pb-10 overflow-y-auto scrollbar-custom"
|
||||
>
|
||||
<slot name="content"></slot>
|
||||
</div>
|
||||
</main>
|
||||
<nav
|
||||
class="h-full overflow-hidden"
|
||||
:inert="!showLeftPanel"
|
||||
:aria-hidden="!showLeftPanel"
|
||||
>
|
||||
<div v-if="hasLeftPanel" class="h-full min-w-40 max-w-56">
|
||||
<slot name="leftPanel" />
|
||||
</div>
|
||||
<aside
|
||||
v-if="hasRightPanel && isRightPanelOpen"
|
||||
class="w-1/4 min-w-40 max-w-80 pt-16 pb-8"
|
||||
</nav>
|
||||
|
||||
<div class="flex flex-col bg-base-background overflow-hidden">
|
||||
<header
|
||||
v-if="$slots.header"
|
||||
class="w-full h-18 px-6 flex items-center justify-between gap-2"
|
||||
>
|
||||
<slot name="rightPanel"></slot>
|
||||
</aside>
|
||||
<div class="flex flex-1 shrink-0 gap-2">
|
||||
<Button
|
||||
v-if="!notMobile"
|
||||
size="icon"
|
||||
:aria-label="
|
||||
showLeftPanel ? t('g.hideLeftPanel') : t('g.showLeftPanel')
|
||||
"
|
||||
@click="toggleLeftPanel"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
showLeftPanel
|
||||
? 'icon-[lucide--panel-left]'
|
||||
: 'icon-[lucide--panel-left-close]'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<slot name="header-right-area" />
|
||||
<template v-if="!isRightPanelOpen">
|
||||
<Button
|
||||
v-if="hasRightPanel"
|
||||
size="lg"
|
||||
class="w-10 p-0"
|
||||
:aria-label="t('g.showRightPanel')"
|
||||
@click="toggleRightPanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
class="w-10"
|
||||
:aria-label="t('g.closeDialog')"
|
||||
@click="closeDialog"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</Button>
|
||||
</template>
|
||||
</header>
|
||||
|
||||
<main class="flex min-h-0 flex-1 flex-col">
|
||||
<slot name="contentFilter" />
|
||||
<h2
|
||||
v-if="!hasLeftPanel"
|
||||
class="text-xxl m-0 px-6 pt-2 pb-6 capitalize"
|
||||
>
|
||||
{{ contentTitle }}
|
||||
</h2>
|
||||
<div
|
||||
class="min-h-0 flex-1 px-6 pt-0 pb-10 overflow-y-auto scrollbar-custom"
|
||||
>
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<aside
|
||||
v-if="hasRightPanel"
|
||||
class="overflow-hidden"
|
||||
:inert="!isRightPanelOpen"
|
||||
:aria-hidden="!isRightPanelOpen"
|
||||
>
|
||||
<div
|
||||
class="min-w-72 w-72 flex flex-col bg-modal-panel-background h-full"
|
||||
>
|
||||
<header
|
||||
data-component-id="RightPanelHeader"
|
||||
class="flex h-18 shrink-0 items-center gap-2 px-6"
|
||||
>
|
||||
<h2 v-if="rightPanelTitle" class="flex-1 text-base font-semibold">
|
||||
{{ rightPanelTitle }}
|
||||
</h2>
|
||||
<div v-else class="flex-1">
|
||||
<slot name="rightPanelHeaderTitle" />
|
||||
</div>
|
||||
<slot name="rightPanelHeaderActions" />
|
||||
<Button
|
||||
size="lg"
|
||||
class="w-10 p-0"
|
||||
:aria-label="t('g.hideRightPanel')"
|
||||
@click="toggleRightPanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right-close] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
class="w-10 p-0"
|
||||
:aria-label="t('g.closeDialog')"
|
||||
@click="closeDialog"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</Button>
|
||||
</header>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<slot name="rightPanel" />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -102,27 +130,29 @@
|
||||
<script setup lang="ts">
|
||||
import { useBreakpoints } from '@vueuse/core'
|
||||
import { computed, inject, ref, useSlots, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { contentTitle } = defineProps<{
|
||||
const { t } = useI18n()
|
||||
|
||||
const { contentTitle, rightPanelTitle } = defineProps<{
|
||||
contentTitle: string
|
||||
rightPanelTitle?: string
|
||||
}>()
|
||||
|
||||
const isRightPanelOpen = defineModel<boolean>('rightPanelOpen', {
|
||||
default: false
|
||||
})
|
||||
|
||||
const BREAKPOINTS = { md: 880 }
|
||||
const PANEL_SIZES = {
|
||||
width: 'w-1/3',
|
||||
minWidth: 'min-w-40',
|
||||
maxWidth: 'max-w-56'
|
||||
}
|
||||
|
||||
const slots = useSlots()
|
||||
const hasLeftPanel = computed(() => !!slots.leftPanel)
|
||||
const hasRightPanel = computed(() => !!slots.rightPanel)
|
||||
|
||||
const BREAKPOINTS = { md: 880 }
|
||||
|
||||
const closeDialog = inject(OnCloseKey, () => {})
|
||||
|
||||
const breakpoints = useBreakpoints(BREAKPOINTS)
|
||||
@@ -131,8 +161,6 @@ const notMobile = breakpoints.greater('md')
|
||||
const isLeftPanelOpen = ref<boolean>(true)
|
||||
const mobileMenuOpen = ref<boolean>(false)
|
||||
|
||||
const hasRightPanel = computed(() => !!slots.rightPanel)
|
||||
|
||||
watch(notMobile, (isDesktop) => {
|
||||
if (!isDesktop) {
|
||||
mobileMenuOpen.value = false
|
||||
@@ -146,6 +174,12 @@ const showLeftPanel = computed(() => {
|
||||
return shouldShow
|
||||
})
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
gridTemplateColumns: hasRightPanel.value
|
||||
? `${hasLeftPanel.value && showLeftPanel.value ? '14rem' : '0rem'} 1fr ${isRightPanelOpen.value ? '18rem' : '0rem'}`
|
||||
: `${hasLeftPanel.value && showLeftPanel.value ? '14rem' : '0rem'} 1fr`
|
||||
}))
|
||||
|
||||
const toggleLeftPanel = () => {
|
||||
if (notMobile.value) {
|
||||
isLeftPanelOpen.value = !isLeftPanelOpen.value
|
||||
@@ -157,6 +191,23 @@ const toggleLeftPanel = () => {
|
||||
const toggleRightPanel = () => {
|
||||
isRightPanelOpen.value = !isRightPanelOpen.value
|
||||
}
|
||||
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
if (
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (isRightPanelOpen.value) {
|
||||
event.stopPropagation()
|
||||
isRightPanelOpen.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.base-widget-layout {
|
||||
@@ -171,28 +222,4 @@ const toggleRightPanel = () => {
|
||||
max-width: 1724px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fade transition for buttons */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Slide transition for left panel */
|
||||
.slide-panel-enter-active,
|
||||
.slide-panel-leave-active {
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: transform;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.slide-panel-enter-from,
|
||||
.slide-panel-leave-to {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
disabled: !isOverflowing,
|
||||
pt: { text: { class: 'whitespace-nowrap' } }
|
||||
}"
|
||||
class="flex cursor-pointer items-start gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
|
||||
class="flex cursor-pointer items-center-safe gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
|
||||
:class="
|
||||
active
|
||||
? 'bg-interface-menu-component-surface-selected'
|
||||
@@ -15,25 +15,32 @@
|
||||
@mouseenter="checkOverflow"
|
||||
@click="onClick"
|
||||
>
|
||||
<div v-if="icon" class="pt-0.5">
|
||||
<NavIcon :icon="icon" />
|
||||
</div>
|
||||
<NavIcon v-if="icon" :icon="icon" />
|
||||
<i v-else class="text-neutral icon-[lucide--folder] text-xs shrink-0" />
|
||||
<span ref="textRef" class="min-w-0 truncate">
|
||||
<slot></slot>
|
||||
<slot />
|
||||
</span>
|
||||
<StatusBadge
|
||||
v-if="badge !== undefined"
|
||||
:label="String(badge)"
|
||||
severity="contrast"
|
||||
variant="circle"
|
||||
class="ml-auto"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import type { NavItemData } from '@/types/navTypes'
|
||||
|
||||
import NavIcon from './NavIcon.vue'
|
||||
|
||||
const { icon, active, onClick } = defineProps<{
|
||||
const { icon, badge, active, onClick } = defineProps<{
|
||||
icon: NavItemData['icon']
|
||||
badge?: NavItemData['badge']
|
||||
active?: boolean
|
||||
onClick: () => void
|
||||
}>()
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
v-for="subItem in item.items"
|
||||
:key="subItem.id"
|
||||
:icon="subItem.icon"
|
||||
:badge="subItem.badge"
|
||||
:active="activeItem === subItem.id"
|
||||
@click="activeItem = subItem.id"
|
||||
>
|
||||
@@ -32,6 +33,7 @@
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<NavItem
|
||||
:icon="item.icon"
|
||||
:badge="item.badge"
|
||||
:active="activeItem === item.id"
|
||||
@click="activeItem = item.id"
|
||||
>
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface VueNodeData {
|
||||
outputs?: INodeOutputSlot[]
|
||||
resizable?: boolean
|
||||
shape?: number
|
||||
showAdvanced?: boolean
|
||||
subgraphId?: string | null
|
||||
titleMode?: TitleMode
|
||||
widgets?: SafeWidgetData[]
|
||||
@@ -314,7 +315,8 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
|
||||
color: node.color || undefined,
|
||||
bgcolor: node.bgcolor || undefined,
|
||||
resizable: node.resizable,
|
||||
shape: node.shape
|
||||
shape: node.shape,
|
||||
showAdvanced: node.showAdvanced
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,6 +400,9 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
|
||||
const initializeVueNodeLayout = () => {
|
||||
// Check if the node was removed mid-sequence
|
||||
if (!nodeRefs.has(id)) return
|
||||
|
||||
// Extract actual positions after configure() has potentially updated them
|
||||
const nodePosition = { x: node.pos[0], y: node.pos[1] }
|
||||
const nodeSize = { width: node.size[0], height: node.size[1] }
|
||||
@@ -427,7 +432,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
} else {
|
||||
// Not during workflow loading - initialize layout immediately
|
||||
// This handles individual node additions during normal operation
|
||||
initializeVueNodeLayout()
|
||||
requestAnimationFrame(initializeVueNodeLayout)
|
||||
}
|
||||
|
||||
// Call original callback if provided
|
||||
@@ -565,6 +570,13 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
? propertyEvent.newValue
|
||||
: undefined
|
||||
})
|
||||
break
|
||||
case 'showAdvanced':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
showAdvanced: Boolean(propertyEvent.newValue)
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -73,6 +73,14 @@ export const useNodeBadge = () => {
|
||||
onMounted(() => {
|
||||
const nodePricing = useNodePricing()
|
||||
|
||||
watch(
|
||||
() => nodePricing.pricingRevision.value,
|
||||
() => {
|
||||
if (!showApiPricingBadge.value) return
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
)
|
||||
|
||||
extensionStore.registerExtension({
|
||||
name: 'Comfy.NodeBadge',
|
||||
nodeCreated(node: LGraphNode) {
|
||||
@@ -111,17 +119,16 @@ export const useNodeBadge = () => {
|
||||
node.badges.push(() => badge.value)
|
||||
|
||||
if (node.constructor.nodeData?.api_node && showApiPricingBadge.value) {
|
||||
// Get the pricing function to determine if this node has dynamic pricing
|
||||
// JSONata rules are dynamic if they depend on any widgets/inputs/input_groups
|
||||
const pricingConfig = nodePricing.getNodePricingConfig(node)
|
||||
const hasDynamicPricing =
|
||||
typeof pricingConfig?.displayPrice === 'function'
|
||||
|
||||
let creditsBadge
|
||||
const createBadge = () => {
|
||||
const price = nodePricing.getNodeDisplayPrice(node)
|
||||
return priceBadge.getCreditsBadge(price)
|
||||
}
|
||||
!!pricingConfig &&
|
||||
((pricingConfig.depends_on?.widgets?.length ?? 0) > 0 ||
|
||||
(pricingConfig.depends_on?.inputs?.length ?? 0) > 0 ||
|
||||
(pricingConfig.depends_on?.input_groups?.length ?? 0) > 0)
|
||||
|
||||
// Keep the existing widget-watch wiring ONLY to trigger redraws on widget change.
|
||||
// (We no longer rely on it to hold the current badge value.)
|
||||
if (hasDynamicPricing) {
|
||||
// For dynamic pricing nodes, use computed that watches widget changes
|
||||
const relevantWidgetNames = nodePricing.getRelevantWidgetNames(
|
||||
@@ -133,13 +140,63 @@ export const useNodeBadge = () => {
|
||||
triggerCanvasRedraw: true
|
||||
})
|
||||
|
||||
creditsBadge = computedWithWidgetWatch(createBadge)
|
||||
} else {
|
||||
// For static pricing nodes, use regular computed
|
||||
creditsBadge = computed(createBadge)
|
||||
// Ensure watchers are installed; ignore the returned value.
|
||||
// (This call is what registers the widget listeners in most implementations.)
|
||||
computedWithWidgetWatch(() => 0)
|
||||
|
||||
// Hook into connection changes to trigger price recalculation
|
||||
// This handles both connect and disconnect in VueNodes mode
|
||||
const relevantInputs = pricingConfig?.depends_on?.inputs ?? []
|
||||
const inputGroupPrefixes =
|
||||
pricingConfig?.depends_on?.input_groups ?? []
|
||||
const hasRelevantInputs =
|
||||
relevantInputs.length > 0 || inputGroupPrefixes.length > 0
|
||||
|
||||
if (hasRelevantInputs) {
|
||||
const originalOnConnectionsChange = node.onConnectionsChange
|
||||
node.onConnectionsChange = function (
|
||||
type,
|
||||
slotIndex,
|
||||
isConnected,
|
||||
link,
|
||||
ioSlot
|
||||
) {
|
||||
originalOnConnectionsChange?.call(
|
||||
this,
|
||||
type,
|
||||
slotIndex,
|
||||
isConnected,
|
||||
link,
|
||||
ioSlot
|
||||
)
|
||||
// Only trigger if this input affects pricing
|
||||
const inputName = ioSlot?.name
|
||||
if (!inputName) return
|
||||
const isRelevantInput =
|
||||
relevantInputs.includes(inputName) ||
|
||||
inputGroupPrefixes.some((prefix) =>
|
||||
inputName.startsWith(prefix + '.')
|
||||
)
|
||||
if (isRelevantInput) {
|
||||
nodePricing.triggerPriceRecalculation(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
node.badges.push(() => creditsBadge.value)
|
||||
let lastLabel = nodePricing.getNodeDisplayPrice(node)
|
||||
let lastBadge = priceBadge.getCreditsBadge(lastLabel)
|
||||
|
||||
const creditsBadgeGetter: () => LGraphBadge = () => {
|
||||
const label = nodePricing.getNodeDisplayPrice(node)
|
||||
if (label !== lastLabel) {
|
||||
lastLabel = label
|
||||
lastBadge = priceBadge.getCreditsBadge(label)
|
||||
}
|
||||
return lastBadge
|
||||
}
|
||||
|
||||
node.badges.push(creditsBadgeGetter)
|
||||
}
|
||||
},
|
||||
init() {
|
||||
|
||||
@@ -305,24 +305,40 @@ describe('useJobList', () => {
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('sorts all tasks by priority descending', async () => {
|
||||
it('sorts all tasks by create time', async () => {
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({ promptId: 'p', queueIndex: 1, mockState: 'pending' })
|
||||
createTask({
|
||||
promptId: 'p',
|
||||
queueIndex: 1,
|
||||
mockState: 'pending',
|
||||
createTime: 3000
|
||||
})
|
||||
]
|
||||
queueStoreMock.runningTasks = [
|
||||
createTask({ promptId: 'r', queueIndex: 5, mockState: 'running' })
|
||||
createTask({
|
||||
promptId: 'r',
|
||||
queueIndex: 5,
|
||||
mockState: 'running',
|
||||
createTime: 2000
|
||||
})
|
||||
]
|
||||
queueStoreMock.historyTasks = [
|
||||
createTask({ promptId: 'h', queueIndex: 3, mockState: 'completed' })
|
||||
createTask({
|
||||
promptId: 'h',
|
||||
queueIndex: 3,
|
||||
mockState: 'completed',
|
||||
createTime: 1000,
|
||||
executionEndTimestamp: 5000
|
||||
})
|
||||
]
|
||||
|
||||
const { allTasksSorted } = initComposable()
|
||||
await flush()
|
||||
|
||||
expect(allTasksSorted.value.map((task) => task.promptId)).toEqual([
|
||||
'p',
|
||||
'r',
|
||||
'h',
|
||||
'p'
|
||||
'h'
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { orderBy } from 'es-toolkit/array'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -197,13 +198,15 @@ export function useJobList() {
|
||||
const selectedWorkflowFilter = ref<'all' | 'current'>('all')
|
||||
const selectedSortMode = ref<JobSortMode>('mostRecent')
|
||||
|
||||
const mostRecentTimestamp = (task: TaskItemImpl) => task.createTime ?? 0
|
||||
|
||||
const allTasksSorted = computed<TaskItemImpl[]>(() => {
|
||||
const all = [
|
||||
...queueStore.pendingTasks,
|
||||
...queueStore.runningTasks,
|
||||
...queueStore.historyTasks
|
||||
]
|
||||
return all.sort((a, b) => b.queueIndex - a.queueIndex)
|
||||
return orderBy(all, [mostRecentTimestamp], ['desc'])
|
||||
})
|
||||
|
||||
const tasksWithJobState = computed<TaskWithState[]>(() =>
|
||||
|
||||