diff --git a/locales/de.json b/locales/de.json index 1e7537be..a38461e4 100644 --- a/locales/de.json +++ b/locales/de.json @@ -1071,7 +1071,8 @@ "sendWorkflow": "Workflow an ComfyUI senden", "sent": "Workflow an ComfyUI gesendet", "sendFailed": "Fehler beim Senden des Workflows an ComfyUI", - "noWorkflow": "Kein eingebetteter Workflow in diesem Rezept gefunden" + "noWorkflow": "Kein eingebetteter Workflow in diesem Rezept gefunden", + "hasWorkflow": "[TODO: Translate] Contains a ComfyUI workflow" }, "status": { "ready": "Einsatzbereit", diff --git a/locales/en.json b/locales/en.json index c6a6463a..70de63db 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1071,7 +1071,8 @@ "sendWorkflow": "Send Workflow to ComfyUI", "sent": "Workflow sent to ComfyUI", "sendFailed": "Failed to send workflow to ComfyUI", - "noWorkflow": "No embedded workflow found in this recipe" + "noWorkflow": "No embedded workflow found in this recipe", + "hasWorkflow": "Contains a ComfyUI workflow" }, "status": { "ready": "Ready to use", diff --git a/locales/es.json b/locales/es.json index 25538593..94cc82c5 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1071,7 +1071,8 @@ "sendWorkflow": "Enviar workflow a ComfyUI", "sent": "Workflow enviado a ComfyUI", "sendFailed": "Error al enviar el workflow a ComfyUI", - "noWorkflow": "No se encontró ningún workflow integrado en esta receta" + "noWorkflow": "No se encontró ningún workflow integrado en esta receta", + "hasWorkflow": "[TODO: Translate] Contains a ComfyUI workflow" }, "status": { "ready": "Listo para usar", diff --git a/locales/fr.json b/locales/fr.json index 63ac3648..327698c5 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1071,7 +1071,8 @@ "sendWorkflow": "Envoyer le workflow vers ComfyUI", "sent": "Workflow envoyé vers ComfyUI", "sendFailed": "Échec de l'envoi du workflow vers ComfyUI", - "noWorkflow": "Aucun workflow intégré trouvé dans cette Recipe" + "noWorkflow": "Aucun workflow intégré trouvé dans cette Recipe", + "hasWorkflow": "[TODO: Translate] Contains a ComfyUI workflow" }, "status": { "ready": "Prêt à l'emploi", diff --git a/locales/he.json b/locales/he.json index 508f0c0c..fe9cf8b6 100644 --- a/locales/he.json +++ b/locales/he.json @@ -1071,7 +1071,8 @@ "sendWorkflow": "שלח workflow ל-ComfyUI", "sent": "ה-workflow נשלח ל-ComfyUI", "sendFailed": "שליחת ה-workflow ל-ComfyUI נכשלה", - "noWorkflow": "לא נמצא workflow מוטבע במתכון זה" + "noWorkflow": "לא נמצא workflow מוטבע במתכון זה", + "hasWorkflow": "[TODO: Translate] Contains a ComfyUI workflow" }, "status": { "ready": "מוכן לשימוש", diff --git a/locales/ja.json b/locales/ja.json index fd5a9198..20a88c87 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -1071,7 +1071,8 @@ "sendWorkflow": "ワークフローをComfyUIへ送信", "sent": "ワークフローをComfyUIへ送信しました", "sendFailed": "ワークフローをComfyUIへ送信できませんでした", - "noWorkflow": "このレシピに埋め込まれたワークフローが見つかりません" + "noWorkflow": "このレシピに埋め込まれたワークフローが見つかりません", + "hasWorkflow": "[TODO: Translate] Contains a ComfyUI workflow" }, "status": { "ready": "使用可能", diff --git a/locales/ko.json b/locales/ko.json index ec78be96..09846106 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -1071,7 +1071,8 @@ "sendWorkflow": "워크플로를 ComfyUI로 보내기", "sent": "워크플로를 ComfyUI로 보냈습니다", "sendFailed": "워크플로를 ComfyUI로 보내지 못했습니다", - "noWorkflow": "이 레시피에서 임베드된 워크플로를 찾을 수 없습니다" + "noWorkflow": "이 레시피에서 임베드된 워크플로를 찾을 수 없습니다", + "hasWorkflow": "[TODO: Translate] Contains a ComfyUI workflow" }, "status": { "ready": "바로 사용 가능", diff --git a/locales/ru.json b/locales/ru.json index 9a3f3d24..8935c725 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1071,7 +1071,8 @@ "sendWorkflow": "Отправить workflow в ComfyUI", "sent": "Workflow отправлен в ComfyUI", "sendFailed": "Не удалось отправить workflow в ComfyUI", - "noWorkflow": "В этом рецепте не найден встроенный workflow" + "noWorkflow": "В этом рецепте не найден встроенный workflow", + "hasWorkflow": "[TODO: Translate] Contains a ComfyUI workflow" }, "status": { "ready": "Готово к использованию", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 156216ba..995976ca 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1071,7 +1071,8 @@ "sendWorkflow": "发送工作流到 ComfyUI", "sent": "工作流已发送到 ComfyUI", "sendFailed": "发送工作流到 ComfyUI 失败", - "noWorkflow": "此配方中未找到内嵌工作流" + "noWorkflow": "此配方中未找到内嵌工作流", + "hasWorkflow": "[TODO: Translate] Contains a ComfyUI workflow" }, "status": { "ready": "可直接使用", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index b39fde18..c8cdff02 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -1071,7 +1071,8 @@ "sendWorkflow": "傳送工作流到 ComfyUI", "sent": "工作流已傳送到 ComfyUI", "sendFailed": "傳送工作流到 ComfyUI 失敗", - "noWorkflow": "此配方中未找到內嵌工作流" + "noWorkflow": "此配方中未找到內嵌工作流", + "hasWorkflow": "[TODO: Translate] Contains a ComfyUI workflow" }, "status": { "ready": "可直接使用", diff --git a/static/css/base.css b/static/css/base.css index f6b89e1c..8dc5f0f1 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -79,6 +79,9 @@ body { --badge-skip-refresh-bg: var(--color-skip-refresh-bg); --badge-skip-refresh-text: var(--color-skip-refresh-text); --badge-skip-refresh-glow: var(--color-skip-refresh-glow); + --badge-workflow-bg: var(--color-workflow-bg); + --badge-workflow-text: var(--color-workflow-text); + --badge-workflow-glow: var(--color-workflow-glow); } :root[data-theme="dark"] { @@ -104,6 +107,9 @@ body { --badge-skip-refresh-bg: var(--color-skip-refresh-bg); --badge-skip-refresh-text: var(--color-skip-refresh-text); --badge-skip-refresh-glow: var(--color-skip-refresh-glow); + --badge-workflow-bg: var(--color-workflow-bg); + --badge-workflow-text: var(--color-workflow-text); + --badge-workflow-glow: var(--color-workflow-glow); } html[data-theme="dark"] { diff --git a/static/css/components/card.css b/static/css/components/card.css index f5304355..1f526c8e 100644 --- a/static/css/components/card.css +++ b/static/css/components/card.css @@ -664,7 +664,8 @@ body.hide-card-version .hl-badge { .card-footer, .card-header, .model-name, -.base-model-label { +.base-model-label, +.workflow-badge { -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; @@ -887,3 +888,26 @@ body.hide-card-version .hl-badge { flex-shrink: 0; opacity: 0.9; } + +/* Recipe card capability badge: mirrors the model-card update/skip-refresh + badges (small square icon next to the base-model pill) but stays a neutral + attribute marker — no card-level highlight like .has-update. */ +.workflow-badge { + width: 18px; + height: 18px; + padding: 0; + border-radius: 4px; + background: var(--badge-workflow-bg); + color: var(--badge-workflow-text); + font-size: 0.7rem; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-shadow: 0 2px 6px var(--badge-workflow-glow); + border: 1px solid color-mix(in oklab, var(--badge-workflow-bg) 55%, transparent); +} + +.workflow-badge i { + line-height: 1; +} diff --git a/static/css/tokens/colors.css b/static/css/tokens/colors.css index 9dcc00a1..6310a347 100644 --- a/static/css/tokens/colors.css +++ b/static/css/tokens/colors.css @@ -44,6 +44,10 @@ --color-skip-refresh-bg: oklch(82% 0.12 var(--color-warning-h)); --color-skip-refresh-text: oklch(35% 0.02 var(--color-warning-h)); --color-skip-refresh-glow: oklch(82% 0.12 var(--color-warning-h) / 0.15); + + --color-workflow-bg: oklch(72% 0.18 var(--color-accent-h)); + --color-workflow-text: oklch(28% 0.03 var(--color-accent-h)); + --color-workflow-glow: oklch(72% 0.18 var(--color-accent-h) / 0.28); } :root { @@ -110,6 +114,10 @@ --color-info-text: oklch(98% 0.02 var(--color-info-h)); --color-info-glow: oklch(62% 0.18 var(--color-info-h) / 0.4); + --color-workflow-bg: oklch(62% 0.18 var(--color-accent-h)); + --color-workflow-text: oklch(98% 0.02 var(--color-accent-h)); + --color-workflow-glow: oklch(62% 0.18 var(--color-accent-h) / 0.4); + --color-error-bg: color-mix(in oklch, var(--color-error) 15%, transparent); --color-error-border: color-mix(in oklch, var(--color-error) 40%, transparent); diff --git a/static/js/components/RecipeCard.js b/static/js/components/RecipeCard.js index 0c46d2c1..2595defc 100644 --- a/static/js/components/RecipeCard.js +++ b/static/js/components/RecipeCard.js @@ -133,7 +133,13 @@ class RecipeCard { `` : ''} - ${baseModelDisplay} +
+ ${baseModelDisplay} + ${this.recipe.has_workflow === true ? ` + + + ` : ''} +
diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js index 68dbd664..661e4ed2 100644 --- a/static/js/components/RecipeModal.js +++ b/static/js/components/RecipeModal.js @@ -835,8 +835,8 @@ class RecipeModal { const workflowBtn = document.createElement('button'); workflowBtn.className = 'recipe-source-url-btn'; workflowBtn.id = 'sendWorkflowBtn'; - workflowBtn.title = 'Send Workflow to ComfyUI'; - workflowBtn.innerHTML = ' Send Workflow to ComfyUI'; + workflowBtn.title = translate('recipes.workflow.sendWorkflow', {}, 'Send Workflow to ComfyUI'); + workflowBtn.innerHTML = ` ${workflowBtn.title}`; workflowBtn.addEventListener('click', () => { this.sendWorkflowToComfyUI(); }); diff --git a/tests/frontend/components/recipeCard.workflowBadge.test.js b/tests/frontend/components/recipeCard.workflowBadge.test.js new file mode 100644 index 00000000..67a60eba --- /dev/null +++ b/tests/frontend/components/recipeCard.workflowBadge.test.js @@ -0,0 +1,133 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const { + RECIPE_CARD_MODULE, + UI_HELPERS_MODULE, + RECIPE_API_MODULE, + MODEL_CARD_MODULE, + MODAL_MANAGER_MODULE, + STATE_MODULE, + BULK_MANAGER_MODULE, + CONSTANTS_MODULE, + I18N_MODULE, + UNDO_HELPERS_MODULE, +} = vi.hoisted(() => ({ + RECIPE_CARD_MODULE: new URL('../../../static/js/components/RecipeCard.js', import.meta.url).pathname, + UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname, + RECIPE_API_MODULE: new URL('../../../static/js/api/recipeApi.js', import.meta.url).pathname, + MODEL_CARD_MODULE: new URL('../../../static/js/components/shared/ModelCard.js', import.meta.url).pathname, + MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname, + STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname, + BULK_MANAGER_MODULE: new URL('../../../static/js/managers/BulkManager.js', import.meta.url).pathname, + CONSTANTS_MODULE: new URL('../../../static/js/utils/constants.js', import.meta.url).pathname, + I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname, + UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname, +})); + +const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key)); + +vi.mock(UI_HELPERS_MODULE, () => ({ + showToast: vi.fn(), + showActionToast: vi.fn(), + copyToClipboard: vi.fn(), + sendLoraToWorkflow: vi.fn(), +})); + +vi.mock(RECIPE_API_MODULE, () => ({ + updateRecipeMetadata: vi.fn(), +})); + +vi.mock(MODEL_CARD_MODULE, () => ({ + configureModelCardVideo: vi.fn(), +})); + +vi.mock(MODAL_MANAGER_MODULE, () => ({ + modalManager: { + showModal: vi.fn(), + closeModal: vi.fn(), + }, +})); + +vi.mock(STATE_MODULE, () => ({ + state: { + global: { settings: {} }, + settings: {}, + virtualScroller: { removeItemByFilePath: vi.fn() }, + }, + getCurrentPageState: vi.fn(() => ({})), +})); + +vi.mock(BULK_MANAGER_MODULE, () => ({ + bulkManager: {}, +})); + +vi.mock(CONSTANTS_MODULE, () => ({ + NSFW_LEVELS: {}, + getBaseModelAbbreviation: vi.fn((label) => label), + getMatureBlurThreshold: vi.fn(() => 10), +})); + +vi.mock(I18N_MODULE, () => ({ + translate: translateMock, +})); + +vi.mock(UNDO_HELPERS_MODULE, () => ({ + handleUndoDelete: vi.fn(), +})); + +function buildRecipe(overrides = {}) { + return { + id: 'recipe-1', + file_path: '/recipes/r1.json', + title: 'Badge Recipe', + file_url: '/preview.png', + preview_nsfw_level: 0, + created_date: '2024-01-01', + base_model: 'SDXL', + loras: [], + ...overrides, + }; +} + +async function createCard(overrides) { + const { RecipeCard } = await import(RECIPE_CARD_MODULE); + return new RecipeCard(buildRecipe(overrides), vi.fn()); +} + +describe('RecipeCard workflow badge', () => { + beforeEach(() => { + translateMock.mockClear(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('renders the badge next to the base model label when the recipe embeds a workflow', async () => { + const card = await createCard({ has_workflow: true }); + + const badge = card.element.querySelector('.card-header-info .workflow-badge'); + expect(badge).not.toBeNull(); + expect(badge.querySelector('.fa-diagram-project')).not.toBeNull(); + expect(badge.title).toBe('Contains a ComfyUI workflow'); + expect(badge.previousElementSibling.classList.contains('base-model-label')).toBe(true); + }); + + it('omits the badge when has_workflow is false or missing', async () => { + const withoutFlag = await createCard({ has_workflow: false }); + expect(withoutFlag.element.querySelector('.workflow-badge')).toBeNull(); + + const missingFlag = await createCard({}); + expect(missingFlag.element.querySelector('.workflow-badge')).toBeNull(); + }); + + it('asks i18n for the recipes.workflow.hasWorkflow tooltip', async () => { + await createCard({ has_workflow: true }); + + expect(translateMock).toHaveBeenCalledWith( + 'recipes.workflow.hasWorkflow', + {}, + 'Contains a ComfyUI workflow' + ); + }); +});