mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-21 15:24:09 +00:00
merge main into rh-test
This commit is contained in:
@@ -17,10 +17,12 @@ import { computed, onMounted } from 'vue'
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import config from '@/config'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
|
||||
import { electronAPI, isElectron } from './utils/envUtil'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const conflictDetection = useConflictDetection()
|
||||
const isLoading = computed<boolean>(() => workspaceStore.spinner)
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
workspaceStore.shiftDown = e.shiftKey
|
||||
@@ -47,5 +49,9 @@ onMounted(() => {
|
||||
if (isElectron()) {
|
||||
document.addEventListener('contextmenu', showContextMenu)
|
||||
}
|
||||
|
||||
// Initialize conflict detection in background
|
||||
// This runs async and doesn't block UI setup
|
||||
void conflictDetection.initializeConflictDetection()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,640 +1 @@
|
||||
@layer primevue, tailwind-utilities;
|
||||
|
||||
@layer tailwind-utilities {
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
}
|
||||
|
||||
:root {
|
||||
--fg-color: #000;
|
||||
--bg-color: #fff;
|
||||
--comfy-menu-bg: #353535;
|
||||
--comfy-menu-secondary-bg: #292929;
|
||||
--comfy-topbar-height: 2.5rem;
|
||||
--comfy-input-bg: #222;
|
||||
--input-text: #ddd;
|
||||
--descrip-text: #999;
|
||||
--drag-text: #ccc;
|
||||
--error-text: #ff4444;
|
||||
--border-color: #4e4e4e;
|
||||
--tr-even-bg-color: #222;
|
||||
--tr-odd-bg-color: #353535;
|
||||
--primary-bg: #236692;
|
||||
--primary-fg: #ffffff;
|
||||
--primary-hover-bg: #3485bb;
|
||||
--primary-hover-fg: #ffffff;
|
||||
--content-bg: #e0e0e0;
|
||||
--content-fg: #000;
|
||||
--content-hover-bg: #adadad;
|
||||
--content-hover-fg: #000;
|
||||
|
||||
/* Code styling colors for help menu*/
|
||||
--code-text-color: rgba(0, 122, 255, 1);
|
||||
--code-bg-color: rgba(96, 165, 250, 0.2);
|
||||
--code-block-bg-color: rgba(60, 60, 60, 0.12);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--fg-color: #fff;
|
||||
--bg-color: #202020;
|
||||
--content-bg: #4e4e4e;
|
||||
--content-fg: #fff;
|
||||
--content-hover-bg: #222;
|
||||
--content-hover-fg: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: var(--bg-color) var(--bg-img);
|
||||
color: var(--fg-color);
|
||||
min-height: -webkit-fill-available;
|
||||
max-height: -webkit-fill-available;
|
||||
min-width: -webkit-fill-available;
|
||||
max-width: -webkit-fill-available;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.comfy-multiline-input {
|
||||
background-color: var(--comfy-input-bg);
|
||||
color: var(--input-text);
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 2px;
|
||||
resize: none;
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
font-size: var(--comfy-textarea-font-size);
|
||||
}
|
||||
|
||||
.comfy-markdown {
|
||||
/* We assign the textarea and the Tiptap editor to the same CSS grid area to stack them on top of one another. */
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.comfy-markdown > textarea {
|
||||
grid-area: 1 / 1 / 2 / 2;
|
||||
}
|
||||
|
||||
.comfy-markdown .tiptap {
|
||||
grid-area: 1 / 1 / 2 / 2;
|
||||
background-color: var(--comfy-input-bg);
|
||||
color: var(--input-text);
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
resize: none;
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
font-size: var(--comfy-textarea-font-size);
|
||||
height: 100%;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.comfy-markdown.editing .tiptap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.comfy-markdown .tiptap :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.comfy-markdown .tiptap :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.comfy-markdown .tiptap blockquote {
|
||||
border-left: medium solid;
|
||||
margin-left: 1em;
|
||||
padding-left: 0.5em;
|
||||
}
|
||||
|
||||
.comfy-markdown .tiptap pre {
|
||||
border: thin dotted;
|
||||
border-radius: 0.5em;
|
||||
margin: 0.5em;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.comfy-markdown .tiptap table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.comfy-markdown .tiptap th {
|
||||
text-align: left;
|
||||
background: var(--comfy-menu-bg);
|
||||
}
|
||||
|
||||
.comfy-markdown .tiptap th,
|
||||
.comfy-markdown .tiptap td {
|
||||
padding: 0.5em;
|
||||
border: thin solid;
|
||||
}
|
||||
|
||||
.comfy-modal {
|
||||
display: none; /* Hidden by default */
|
||||
position: fixed; /* Stay in place */
|
||||
z-index: 100; /* Sit on top */
|
||||
padding: 30px 30px 10px 30px;
|
||||
background-color: var(--comfy-menu-bg); /* Modal background */
|
||||
color: var(--error-text);
|
||||
box-shadow: 0 0 20px #888888;
|
||||
border-radius: 10px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
max-width: 80vw;
|
||||
max-height: 80vh;
|
||||
transform: translate(-50%, -50%);
|
||||
overflow: hidden;
|
||||
justify-content: center;
|
||||
font-family: monospace;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.comfy-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.comfy-modal p {
|
||||
overflow: auto;
|
||||
white-space: pre-line; /* This will respect line breaks */
|
||||
margin-bottom: 20px; /* Add some margin between the text and the close button*/
|
||||
}
|
||||
|
||||
.comfy-modal select,
|
||||
.comfy-modal input[type='button'],
|
||||
.comfy-modal input[type='checkbox'] {
|
||||
margin: 3px 3px 3px 4px;
|
||||
}
|
||||
|
||||
.comfy-menu {
|
||||
font-size: 15px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
z-index: 999;
|
||||
width: 190px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: var(--descrip-text);
|
||||
background-color: var(--comfy-menu-bg);
|
||||
font-family: sans-serif;
|
||||
padding: 10px;
|
||||
border-radius: 0 8px 8px 8px;
|
||||
box-shadow: 3px 3px 8px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.comfy-menu-header {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.comfy-menu-actions {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.comfy-menu .comfy-menu-actions button {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
padding: 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.comfy-menu .comfy-menu-actions .comfy-settings-btn {
|
||||
font-size: 0.6em;
|
||||
}
|
||||
|
||||
button.comfy-close-menu-btn {
|
||||
font-size: 1em;
|
||||
line-height: 12px;
|
||||
color: #ccc;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
.comfy-menu-queue-size {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.comfy-menu button,
|
||||
.comfy-modal button {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.comfy-menu-btns {
|
||||
margin-bottom: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.comfy-menu-btns button {
|
||||
font-size: 10px;
|
||||
width: 50%;
|
||||
color: var(--descrip-text) !important;
|
||||
}
|
||||
|
||||
.comfy-menu > button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.comfy-btn,
|
||||
.comfy-menu > button,
|
||||
.comfy-menu-btns button,
|
||||
.comfy-menu .comfy-list button,
|
||||
.comfy-modal button {
|
||||
color: var(--input-text);
|
||||
background-color: var(--comfy-input-bg);
|
||||
border-radius: 8px;
|
||||
border-color: var(--border-color);
|
||||
border-style: solid;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.comfy-btn:hover:not(:disabled),
|
||||
.comfy-menu > button:hover,
|
||||
.comfy-menu-btns button:hover,
|
||||
.comfy-menu .comfy-list button:hover,
|
||||
.comfy-modal button:hover,
|
||||
.comfy-menu-actions button:hover {
|
||||
filter: brightness(1.2);
|
||||
will-change: transform;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
span.drag-handle {
|
||||
width: 10px;
|
||||
height: 20px;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
line-height: 5px;
|
||||
padding: 3px 4px;
|
||||
cursor: move;
|
||||
vertical-align: middle;
|
||||
margin-top: -0.4em;
|
||||
margin-left: -0.2em;
|
||||
font-size: 12px;
|
||||
font-family: sans-serif;
|
||||
letter-spacing: 2px;
|
||||
color: var(--drag-text);
|
||||
text-shadow: 1px 0 1px black;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
span.drag-handle::after {
|
||||
content: '.. .. ..';
|
||||
}
|
||||
|
||||
.comfy-queue-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.comfy-list {
|
||||
color: var(--descrip-text);
|
||||
background-color: var(--comfy-menu-bg);
|
||||
margin-bottom: 10px;
|
||||
border-color: var(--border-color);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.comfy-list-items {
|
||||
overflow-y: scroll;
|
||||
max-height: 100px;
|
||||
min-height: 25px;
|
||||
background-color: var(--comfy-input-bg);
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.comfy-list h4 {
|
||||
min-width: 160px;
|
||||
margin: 0;
|
||||
padding: 3px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.comfy-list-items button {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.comfy-list-actions {
|
||||
margin: 5px;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.comfy-list-actions button {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button.comfy-queue-btn {
|
||||
margin: 6px 0 !important;
|
||||
}
|
||||
|
||||
.comfy-modal.comfy-settings,
|
||||
.comfy-modal.comfy-manage-templates {
|
||||
text-align: center;
|
||||
font-family: sans-serif;
|
||||
color: var(--descrip-text);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.comfy-modal.comfy-settings input[type='range'] {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.comfy-modal.comfy-settings input[type='range'] + input[type='number'] {
|
||||
width: 3.5em;
|
||||
}
|
||||
|
||||
.comfy-modal input,
|
||||
.comfy-modal select {
|
||||
color: var(--input-text);
|
||||
background-color: var(--comfy-input-bg);
|
||||
border-radius: 8px;
|
||||
border-color: var(--border-color);
|
||||
border-style: solid;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.comfy-tooltip-indicator {
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dashed;
|
||||
}
|
||||
|
||||
@media only screen and (max-height: 850px) {
|
||||
.comfy-menu {
|
||||
top: 0 !important;
|
||||
bottom: 0 !important;
|
||||
left: auto !important;
|
||||
right: 0 !important;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.comfy-menu span.drag-handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.comfy-menu-queue-size {
|
||||
flex: unset;
|
||||
}
|
||||
|
||||
.comfy-menu-header {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.comfy-menu-actions {
|
||||
gap: 10px;
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Input popup */
|
||||
|
||||
.graphdialog {
|
||||
min-height: 1em;
|
||||
background-color: var(--comfy-menu-bg);
|
||||
z-index: 41; /* z-index is set to 41 here in order to appear over selection-overlay-container which should have a z-index of 40 */
|
||||
}
|
||||
|
||||
.graphdialog .name {
|
||||
font-size: 14px;
|
||||
font-family: sans-serif;
|
||||
color: var(--descrip-text);
|
||||
}
|
||||
|
||||
.graphdialog button {
|
||||
margin-top: unset;
|
||||
vertical-align: unset;
|
||||
height: 1.6em;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.graphdialog input,
|
||||
.graphdialog textarea,
|
||||
.graphdialog select {
|
||||
background-color: var(--comfy-input-bg);
|
||||
border: 2px solid;
|
||||
border-color: var(--border-color);
|
||||
color: var(--input-text);
|
||||
border-radius: 12px 0 0 12px;
|
||||
}
|
||||
|
||||
/* Dialogs */
|
||||
|
||||
dialog {
|
||||
box-shadow: 0 0 20px #888888;
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.comfy-dialog.comfyui-dialog.comfy-modal {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.comfy-dialog.comfy-modal {
|
||||
font-family: Arial, sans-serif;
|
||||
border-color: var(--bg-color);
|
||||
box-shadow: none;
|
||||
border: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.comfy-dialog .comfy-modal-content {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.comfy-dialog .comfy-modal-content h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.comfy-dialog .comfy-modal-content > p {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.comfy-dialog .comfy-modal-content > .comfyui-button {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Context menu */
|
||||
|
||||
.litegraph .dialog {
|
||||
z-index: 1;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.litegraph .litemenu-entry.has_submenu {
|
||||
position: relative;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.litemenu-entry.has_submenu::after {
|
||||
content: '>';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 2px;
|
||||
}
|
||||
|
||||
.litegraph.litecontextmenu,
|
||||
.litegraph.litecontextmenu.dark {
|
||||
z-index: 9999 !important;
|
||||
background-color: var(--comfy-menu-bg) !important;
|
||||
}
|
||||
|
||||
.litegraph.litecontextmenu
|
||||
.litemenu-entry:hover:not(.disabled):not(.separator) {
|
||||
background-color: var(--comfy-menu-hover-bg, var(--border-color)) !important;
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.litegraph.litecontextmenu .litemenu-entry.submenu,
|
||||
.litegraph.litecontextmenu.dark .litemenu-entry.submenu {
|
||||
background-color: var(--comfy-menu-bg) !important;
|
||||
color: var(--input-text);
|
||||
}
|
||||
|
||||
.litegraph.litecontextmenu input {
|
||||
background-color: var(--comfy-input-bg) !important;
|
||||
color: var(--input-text) !important;
|
||||
}
|
||||
|
||||
.comfy-context-menu-filter {
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #999;
|
||||
margin: 0 0 5px 5px;
|
||||
width: calc(100% - 10px);
|
||||
}
|
||||
|
||||
.comfy-img-preview {
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.comfy-img-preview img {
|
||||
object-fit: contain;
|
||||
width: var(--comfy-img-preview-width);
|
||||
height: var(--comfy-img-preview-height);
|
||||
}
|
||||
|
||||
.comfy-img-preview video {
|
||||
pointer-events: auto;
|
||||
object-fit: contain;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.comfy-missing-nodes li button {
|
||||
font-size: 12px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
/* Search box */
|
||||
|
||||
.litegraph.litesearchbox {
|
||||
z-index: 9999 !important;
|
||||
background-color: var(--comfy-menu-bg) !important;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.litegraph.litesearchbox input,
|
||||
.litegraph.litesearchbox select {
|
||||
background-color: var(--comfy-input-bg) !important;
|
||||
color: var(--input-text);
|
||||
}
|
||||
|
||||
.litegraph.lite-search-item {
|
||||
color: var(--input-text);
|
||||
background-color: var(--comfy-input-bg);
|
||||
filter: brightness(80%);
|
||||
will-change: transform;
|
||||
padding-left: 0.2em;
|
||||
}
|
||||
|
||||
.litegraph.lite-search-item.generic_type {
|
||||
color: var(--input-text);
|
||||
filter: brightness(50%);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
audio.comfy-audio.empty-audio-widget {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#vue-app {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Set auto complete panel's width as it is not accessible within vue-root */
|
||||
.p-autocomplete-overlay {
|
||||
max-width: 25vw;
|
||||
}
|
||||
|
||||
.p-tree-node-content {
|
||||
padding: var(--comfy-tree-explorer-item-padding) !important;
|
||||
}
|
||||
|
||||
/* Load3d styles */
|
||||
.comfy-load-3d,
|
||||
.comfy-load-3d-animation,
|
||||
.comfy-preview-3d,
|
||||
.comfy-preview-3d-animation{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: transparent;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.comfy-load-3d canvas,
|
||||
.comfy-load-3d-animation canvas,
|
||||
.comfy-preview-3d canvas,
|
||||
.comfy-preview-3d-animation canvas,
|
||||
.comfy-load-3d-viewer canvas{
|
||||
display: flex;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* End of Load3d styles */
|
||||
|
||||
/* [Desktop] Electron window specific styles */
|
||||
.app-drag {
|
||||
app-region: drag;
|
||||
}
|
||||
|
||||
.no-drag {
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.window-actions-spacer {
|
||||
width: calc(100vw - env(titlebar-area-width, 100vw));
|
||||
}
|
||||
/* End of [Desktop] Electron window specific styles */
|
||||
@import '@comfyorg/design-system/css/style.css';
|
||||
@@ -1,331 +0,0 @@
|
||||
# ComfyUI Icons Guide
|
||||
|
||||
ComfyUI supports three types of icons that can be used throughout the interface. All icons are automatically imported - no manual imports needed!
|
||||
|
||||
## Quick Start - Code Examples
|
||||
|
||||
### 1. PrimeIcons
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Basic usage -->
|
||||
<i class="pi pi-plus" />
|
||||
<i class="pi pi-cog" />
|
||||
<i class="pi pi-check text-green-500" />
|
||||
|
||||
<!-- In PrimeVue components -->
|
||||
<button icon="pi pi-save" label="Save" />
|
||||
<button icon="pi pi-times" severity="danger" />
|
||||
</template>
|
||||
```
|
||||
|
||||
[Browse all PrimeIcons →](https://primevue.org/icons/#list)
|
||||
|
||||
### 2. Iconify Icons (Recommended)
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Primary icon set: Lucide -->
|
||||
<i-lucide:download />
|
||||
<i-lucide:settings />
|
||||
<i-lucide:workflow class="text-2xl" />
|
||||
|
||||
<!-- Other popular icon sets -->
|
||||
<i-mdi:folder-open />
|
||||
<!-- Material Design Icons -->
|
||||
<i-heroicons:document-text />
|
||||
<!-- Heroicons -->
|
||||
<i-tabler:brand-github />
|
||||
<!-- Tabler Icons -->
|
||||
<i-carbon:cloud-upload />
|
||||
<!-- Carbon Icons -->
|
||||
|
||||
<!-- With styling -->
|
||||
<i-lucide:save class="w-6 h-6 text-blue-500" />
|
||||
</template>
|
||||
```
|
||||
|
||||
[Browse 200,000+ icons →](https://icon-sets.iconify.design/)
|
||||
|
||||
### 3. Custom Icons
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Your custom SVG icons from src/assets/icons/custom/ -->
|
||||
<i-comfy:workflow />
|
||||
<i-comfy:node-tree />
|
||||
<i-comfy:my-custom-icon class="text-xl" />
|
||||
|
||||
<!-- In PrimeVue button -->
|
||||
<Button severity="secondary">
|
||||
<template #icon>
|
||||
<i-comfy:workflow />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Icon Usage Patterns
|
||||
|
||||
### In Buttons
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- PrimeIcon in button (simple) -->
|
||||
<Button icon="pi pi-check" label="Confirm" />
|
||||
|
||||
<!-- Iconify/Custom in button (template) -->
|
||||
<Button>
|
||||
<template #icon>
|
||||
<i-lucide:save />
|
||||
</template>
|
||||
Save File
|
||||
</Button>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Conditional Icons
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<i-lucide:eye v-if="isVisible" />
|
||||
<i-lucide:eye-off v-else />
|
||||
|
||||
<!-- Or with ternary -->
|
||||
<component :is="isLocked ? 'i-lucide:lock' : 'i-lucide:lock-open'" />
|
||||
</template>
|
||||
```
|
||||
|
||||
### With Tooltips
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<i-lucide:info
|
||||
v-tooltip="'Click for more information'"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Using Iconify Icons
|
||||
|
||||
### Finding Icons
|
||||
|
||||
1. Visit [Iconify Icon Sets](https://icon-sets.iconify.design/)
|
||||
2. Search or browse collections
|
||||
3. Click on any icon to get its name
|
||||
4. Use with `i-[collection]:[icon-name]` format
|
||||
|
||||
### Popular Collections
|
||||
|
||||
- **Lucide** (`i-lucide:`) - Our primary icon set, clean and consistent
|
||||
- **Material Design Icons** (`i-mdi:`) - Comprehensive Material Design icons
|
||||
- **Heroicons** (`i-heroicons:`) - Beautiful hand-crafted SVG icons
|
||||
- **Tabler** (`i-tabler:`) - 3000+ free SVG icons
|
||||
- **Carbon** (`i-carbon:`) - IBM's design system icons
|
||||
|
||||
## Adding Custom Icons
|
||||
|
||||
### 1. Add Your SVG
|
||||
|
||||
Place your SVG file in `src/assets/icons/custom/`:
|
||||
|
||||
```
|
||||
src/assets/icons/custom/
|
||||
├── workflow-duplicate.svg
|
||||
├── node-preview.svg
|
||||
└── your-icon.svg
|
||||
```
|
||||
|
||||
### 2. SVG Format Requirements
|
||||
|
||||
```xml
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Use currentColor for theme compatibility -->
|
||||
<path fill="currentColor" d="..." />
|
||||
</svg>
|
||||
```
|
||||
|
||||
**Important:**
|
||||
- Use `viewBox` for proper scaling (24x24 is standard)
|
||||
- Don't include `width` or `height` attributes
|
||||
- Use `currentColor` for theme-aware icons
|
||||
- Keep SVGs optimized and simple
|
||||
|
||||
### 3. Use Immediately
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<i-comfy:your-icon />
|
||||
</template>
|
||||
```
|
||||
|
||||
No imports needed - icons are auto-discovered!
|
||||
|
||||
## Icon Guidelines
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Files**: `kebab-case.svg` (workflow-icon.svg)
|
||||
- **Usage**: `<i-comfy:workflow-icon />`
|
||||
|
||||
### Size & Styling
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Size with Tailwind classes -->
|
||||
<i-lucide:plus class="w-4 h-4" />
|
||||
<!-- 16px -->
|
||||
<i-lucide:plus class="w-6 h-6" />
|
||||
<!-- 24px (default) -->
|
||||
<i-lucide:plus class="w-8 h-8" />
|
||||
<!-- 32px -->
|
||||
|
||||
<!-- Or text size -->
|
||||
<i-lucide:plus class="text-sm" />
|
||||
<i-lucide:plus class="text-2xl" />
|
||||
|
||||
<!-- Colors -->
|
||||
<i-lucide:check class="text-green-500" />
|
||||
<i-lucide:x class="text-red-500" />
|
||||
</template>
|
||||
```
|
||||
|
||||
### Theme Compatibility
|
||||
|
||||
Always use `currentColor` in SVGs for automatic theme adaptation:
|
||||
|
||||
```xml
|
||||
<!-- ✅ Good: Adapts to light/dark theme -->
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="..." />
|
||||
</svg>
|
||||
|
||||
<!-- ❌ Bad: Fixed colors -->
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path fill="#000000" d="..." />
|
||||
</svg>
|
||||
```
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### From PrimeIcons to Iconify/Custom
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Before -->
|
||||
<Button icon="pi pi-download" />
|
||||
|
||||
<!-- After -->
|
||||
<Button>
|
||||
<template #icon>
|
||||
<i-lucide:download />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
```
|
||||
|
||||
### From Inline SVG to Custom Icon
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Before: Inline SVG -->
|
||||
<svg class="w-6 h-6" viewBox="0 0 24 24">
|
||||
<path d="..." />
|
||||
</svg>
|
||||
|
||||
<!-- After: Save as custom/my-icon.svg and use -->
|
||||
<i-comfy:my-icon class="w-6 h-6" />
|
||||
</template>
|
||||
```
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Auto-Import System
|
||||
|
||||
Icons are automatically imported using `unplugin-icons` - no manual imports needed! Just use the icon component directly.
|
||||
|
||||
### Configuration
|
||||
|
||||
The icon system has two layers:
|
||||
|
||||
1. **Build-time Processing** (`build/customIconCollection.ts`):
|
||||
- Scans `src/assets/icons/custom/` for SVG files
|
||||
- Validates SVG content and structure
|
||||
- Creates Iconify collection for Tailwind CSS
|
||||
- Provides error handling for malformed files
|
||||
|
||||
2. **Vite Runtime** (`vite.config.mts`):
|
||||
- Enables direct SVG import as Vue components
|
||||
- Supports dynamic icon loading
|
||||
|
||||
```typescript
|
||||
// Build script creates Iconify collection
|
||||
export const iconCollection: IconifyCollection = {
|
||||
prefix: 'comfy',
|
||||
icons: {
|
||||
'workflow': { body: '<svg>...</svg>' },
|
||||
'node': { body: '<svg>...</svg>' }
|
||||
}
|
||||
}
|
||||
|
||||
// Vite configuration for component-based usage
|
||||
Icons({
|
||||
compiler: 'vue3',
|
||||
customCollections: {
|
||||
comfy: FileSystemIconLoader('src/assets/icons/custom')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### TypeScript Support
|
||||
|
||||
Icons are fully typed. If TypeScript doesn't recognize a new custom icon:
|
||||
|
||||
1. Restart the dev server
|
||||
2. Ensure the SVG file is valid
|
||||
3. Check filename follows kebab-case
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Icon Not Showing
|
||||
1. **Check filename**: Must be kebab-case without special characters
|
||||
2. **Restart dev server**: Required after adding new icons
|
||||
3. **Verify SVG**: Ensure it's valid SVG syntax (build script validates automatically)
|
||||
4. **Check console**: Look for Vue component resolution errors
|
||||
5. **Build script errors**: Check console during build - malformed SVGs are logged but don't break builds
|
||||
|
||||
### Icon Wrong Color
|
||||
- Replace hardcoded colors with `currentColor`
|
||||
- Use `stroke="currentColor"` for outlines
|
||||
- Use `fill="currentColor"` for filled shapes
|
||||
|
||||
### Icon Wrong Size
|
||||
- Remove `width` and `height` from SVG
|
||||
- Ensure `viewBox` is present
|
||||
- Use CSS classes for sizing: `class="w-6 h-6"`
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Optimize SVGs**: Use tools like [SVGO](https://jakearchibald.github.io/svgomg/) to minimize file size
|
||||
2. **Consistent viewBox**: Stick to 24x24 or 16x16 for consistency
|
||||
3. **Semantic names**: Use descriptive names like `workflow-duplicate` not `icon1`
|
||||
4. **Theme support**: Always use `currentColor` for adaptable icons
|
||||
5. **Test both themes**: Verify icons look good in light and dark modes
|
||||
|
||||
## Adding Icon Collections
|
||||
|
||||
To add an entire icon set from npm:
|
||||
|
||||
1. Install the icon package
|
||||
2. Configure in `vite.config.mts`
|
||||
3. Use with the appropriate prefix
|
||||
|
||||
See the [unplugin-icons documentation](https://github.com/unplugin/unplugin-icons) for details.
|
||||
|
||||
## Resources
|
||||
|
||||
- [PrimeIcons List](https://primevue.org/icons/#list)
|
||||
- [Iconify Icon Browser](https://icon-sets.iconify.design/)
|
||||
- [Lucide Icons](https://lucide.dev/icons/)
|
||||
- [unplugin-icons docs](https://github.com/unplugin/unplugin-icons)
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"><g stroke="currentColor" stroke-linecap="round" stroke-width="1.3" clip-path="url(#a)"><path d="m4.998 13.909.557-2.225a1.112 1.112 0 0 0-1.08-1.382H2.32c-.51 0-.955.347-1.079.842L.684 13.37a1.112 1.112 0 0 0 1.079 1.382h2.156c.51 0 .956-.347 1.08-.842ZM6.11 7.234l.557-2.224a1.112 1.112 0 0 0-1.08-1.383H3.433c-.51 0-.956.348-1.08.843l-.556 2.225a1.112 1.112 0 0 0 1.08 1.382h2.156c.51 0 .955-.347 1.079-.843ZM11.673 13.909l.556-2.225a1.112 1.112 0 0 0-1.08-1.382H8.994c-.51 0-.955.347-1.079.842l-.556 2.225a1.112 1.112 0 0 0 1.08 1.382h2.156c.51 0 .955-.347 1.079-.842ZM13.141 5.816l-.784 1.83a.334.334 0 0 1-.614 0l-.785-1.83a.333.333 0 0 0-.175-.176l-1.831-.784a.334.334 0 0 1 0-.614l1.831-.785a.333.333 0 0 0 .175-.175l.785-1.831a.334.334 0 0 1 .614 0l.784 1.831a.334.334 0 0 0 .176.175l1.83.785c.27.116.27.498 0 .614l-1.83.784a.334.334 0 0 0-.176.176Z"/></g><defs><clipPath id="a"><path fill="currentColor" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
10
src/assets/icons/custom/dark-info.svg
Normal file
10
src/assets/icons/custom/dark-info.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<g clip-path="url(#clip0_1416_62)">
|
||||
<path d="M7.99998 10.6667V8.00004M7.99998 5.33337H8.00665M14.6666 8.00004C14.6666 11.6819 11.6819 14.6667 7.99998 14.6667C4.31808 14.6667 1.33331 11.6819 1.33331 8.00004C1.33331 4.31814 4.31808 1.33337 7.99998 1.33337C11.6819 1.33337 14.6666 4.31814 14.6666 8.00004Z" stroke="#171718" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1416_62">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 623 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"><path stroke="currentColor" stroke-linecap="round" stroke-width="1.3" d="m11.2667 10.45-.0842.3156c-.143.5363-.6286.9094-1.18362.9094H4.6945c-.80486 0-1.39102-.7629-1.18364-1.5406l1.30667-4.90002c.143-.53625.62865-.90937 1.18364-.90937h5.46393c.7243 0 1.2518.68663 1.0652 1.38652l-.0301.11275M15.35 8.00001h-4.9m-6.73748 0H.65002"/></svg>
|
||||
|
Before Width: | Height: | Size: 411 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"><g stroke="currentColor" stroke-linecap="round" stroke-width="1.3" clip-path="url(#a)"><path d="m14.6685 5.7416.6425-2.57c.2028-.811-.4106-1.5967-1.2466-1.5967H2.5782a1.285 1.285 0 0 0-1.2467.9733l-.6425 2.57c-.2027.8111.4107 1.5968 1.2467 1.5968h11.4861a1.285 1.285 0 0 0 1.2467-.9734Zm0 7.7102.6425-2.5701c.2028-.811-.4106-1.5967-1.2466-1.5967h-5.061a1.285 1.285 0 0 0-1.2467.9734l-.6425 2.5701c-.2028.811.4106 1.5966 1.2466 1.5966h5.061a1.285 1.285 0 0 0 1.2467-.9733Zm-10.2802 0 .6425-2.5701c.2027-.811-.4107-1.5967-1.2467-1.5967H2.5782a1.285 1.285 0 0 0-1.2467.9734L.689 12.8285c-.2027.811.4107 1.5966 1.2467 1.5966h1.206a1.285 1.285 0 0 0 1.2466-.9733Z"/></g><defs><clipPath id="a"><path fill="currentColor" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 830 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"><path stroke="currentColor" stroke-linecap="round" stroke-width="1.3" d="M9.18613 3.09999H6.81377M9.18613 12.9H7.55288c-3.08678 0-5.35171-2.99581-4.60305-6.08843l.3054-1.26158M14.7486 2.1721l-.5931 2.45c-.132.54533-.6065.92789-1.1508.92789h-2.2993c-.77173 0-1.33797-.74895-1.1508-1.5221l.5931-2.45c.132-.54533.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.74896 1.1508 1.52211Zm-8.3033 0-.59309 2.45c-.13201.54533-.60646.92789-1.15076.92789H2.4021c-.7717 0-1.33793-.74895-1.15077-1.5221l.59309-2.45c.13201-.54533.60647-.9279 1.15077-.9279h2.29935c.77169 0 1.33792.74896 1.15076 1.52211Zm8.3033 9.8-.5931 2.45c-.132.5453-.6065.9279-1.1508.9279h-2.2993c-.77173 0-1.33797-.749-1.1508-1.5221l.5931-2.45c.132-.5453.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.7489 1.1508 1.5221Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 857 B |
98
src/base/common/async.ts
Normal file
98
src/base/common/async.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Cross-browser async utilities for scheduling tasks during browser idle time
|
||||
* with proper fallbacks for browsers that don't support requestIdleCallback.
|
||||
*
|
||||
* Implementation based on:
|
||||
* https://github.com/microsoft/vscode/blob/main/src/vs/base/common/async.ts
|
||||
*/
|
||||
|
||||
interface IdleDeadline {
|
||||
didTimeout: boolean
|
||||
timeRemaining(): number
|
||||
}
|
||||
|
||||
interface IDisposable {
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal implementation function that handles the actual scheduling logic.
|
||||
* Uses feature detection to determine whether to use native requestIdleCallback
|
||||
* or fall back to setTimeout-based implementation.
|
||||
*/
|
||||
let _runWhenIdle: (
|
||||
targetWindow: any,
|
||||
callback: (idle: IdleDeadline) => void,
|
||||
timeout?: number
|
||||
) => IDisposable
|
||||
|
||||
/**
|
||||
* Execute the callback during the next browser idle period.
|
||||
* Falls back to setTimeout-based scheduling in browsers without native support.
|
||||
*/
|
||||
export let runWhenGlobalIdle: (
|
||||
callback: (idle: IdleDeadline) => void,
|
||||
timeout?: number
|
||||
) => IDisposable
|
||||
|
||||
// Self-invoking function to set up the idle callback implementation
|
||||
;(function () {
|
||||
const safeGlobal: any = globalThis
|
||||
|
||||
if (
|
||||
typeof safeGlobal.requestIdleCallback !== 'function' ||
|
||||
typeof safeGlobal.cancelIdleCallback !== 'function'
|
||||
) {
|
||||
// Fallback implementation for browsers without native support (e.g., Safari)
|
||||
_runWhenIdle = (_targetWindow, runner, _timeout?) => {
|
||||
setTimeout(() => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
// Simulate IdleDeadline - give 15ms window (one frame at ~64fps)
|
||||
const end = Date.now() + 15
|
||||
const deadline: IdleDeadline = {
|
||||
didTimeout: true,
|
||||
timeRemaining() {
|
||||
return Math.max(0, end - Date.now())
|
||||
}
|
||||
}
|
||||
|
||||
runner(Object.freeze(deadline))
|
||||
})
|
||||
|
||||
let disposed = false
|
||||
return {
|
||||
dispose() {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
disposed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Native requestIdleCallback implementation
|
||||
_runWhenIdle = (targetWindow: typeof safeGlobal, runner, timeout?) => {
|
||||
const handle: number = targetWindow.requestIdleCallback(
|
||||
runner,
|
||||
typeof timeout === 'number' ? { timeout } : undefined
|
||||
)
|
||||
|
||||
let disposed = false
|
||||
return {
|
||||
dispose() {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
disposed = true
|
||||
targetWindow.cancelIdleCallback(handle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runWhenGlobalIdle = (runner, timeout) =>
|
||||
_runWhenIdle(globalThis, runner, timeout)
|
||||
})()
|
||||
41
src/base/common/downloadUtil.ts
Normal file
41
src/base/common/downloadUtil.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Utility functions for downloading files
|
||||
*/
|
||||
|
||||
// Constants
|
||||
const DEFAULT_DOWNLOAD_FILENAME = 'download.png'
|
||||
|
||||
/**
|
||||
* Download a file from a URL by creating a temporary anchor element
|
||||
* @param url - The URL of the file to download (must be a valid URL string)
|
||||
* @param filename - Optional filename override (will use URL filename or default if not provided)
|
||||
* @throws {Error} If the URL is invalid or empty
|
||||
*/
|
||||
export const downloadFile = (url: string, filename?: string): void => {
|
||||
if (!url || typeof url !== 'string' || url.trim().length === 0) {
|
||||
throw new Error('Invalid URL provided for download')
|
||||
}
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download =
|
||||
filename || extractFilenameFromUrl(url) || DEFAULT_DOWNLOAD_FILENAME
|
||||
|
||||
// Trigger download
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract filename from a URL's query parameters
|
||||
* @param url - The URL to extract filename from
|
||||
* @returns The extracted filename or null if not found
|
||||
*/
|
||||
const extractFilenameFromUrl = (url: string): string | null => {
|
||||
try {
|
||||
const urlObj = new URL(url, window.location.origin)
|
||||
return urlObj.searchParams.get('filename')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ import Splitter from 'primevue/splitter'
|
||||
import SplitterPanel from 'primevue/splitterpanel'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
@@ -79,6 +79,8 @@ const sidebarStateKey = computed(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
:deep(.p-splitter-gutter) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -21,10 +21,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { CSSProperties, computed, watchEffect } from 'vue'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { computed, watchEffect } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { showNativeSystemMenu } from '@/utils/envUtil'
|
||||
|
||||
@@ -56,7 +57,9 @@ const positionCSS = computed<CSSProperties>(() =>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
.comfy-menu-hamburger {
|
||||
@apply fixed z-[9999] flex flex-row;
|
||||
@apply fixed z-9999 flex flex-row;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -37,8 +37,8 @@ import { storeToRefs } from 'pinia'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
const queueSettingsStore = useQueueSettingsStore()
|
||||
const { batchCount } = storeToRefs(queueSettingsStore)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
:class="{ 'is-dragging': isDragging, 'is-docked': isDocked }"
|
||||
>
|
||||
<div ref="panelRef" class="actionbar-content flex items-center select-none">
|
||||
<span ref="dragHandleRef" class="drag-handle cursor-move mr-2 p-0!" />
|
||||
<span ref="dragHandleRef" class="drag-handle cursor-move mr-2" />
|
||||
<ComfyQueueButton />
|
||||
</div>
|
||||
</Panel>
|
||||
@@ -22,9 +22,10 @@ import {
|
||||
} from '@vueuse/core'
|
||||
import { clamp } from 'es-toolkit/compat'
|
||||
import Panel from 'primevue/panel'
|
||||
import { Ref, computed, inject, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, inject, nextTick, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
import ComfyQueueButton from './ComfyQueueButton.vue'
|
||||
|
||||
@@ -37,7 +38,7 @@ const visible = computed(() => position.value !== 'Disabled')
|
||||
const topMenuRef = inject<Ref<HTMLDivElement | null>>('topMenuRef')
|
||||
const panelRef = ref<HTMLElement | null>(null)
|
||||
const dragHandleRef = ref<HTMLElement | null>(null)
|
||||
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', false)
|
||||
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
|
||||
const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
|
||||
x: 0,
|
||||
y: 0
|
||||
@@ -228,6 +229,8 @@ watch([isDragging, isOverlappingWithTopMenu], ([dragging, overlapping]) => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.actionbar {
|
||||
pointer-events: all;
|
||||
position: fixed;
|
||||
|
||||
@@ -40,8 +40,8 @@
|
||||
</div>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
<!-- h-0 to force the div to flex-grow -->
|
||||
<div class="flex-grow h-0">
|
||||
<!-- h-0 to force the div to grow -->
|
||||
<div class="grow h-0">
|
||||
<ExtensionSlot
|
||||
v-if="
|
||||
bottomPanelStore.bottomPanelVisible &&
|
||||
|
||||
@@ -18,13 +18,13 @@
|
||||
:key="command.id"
|
||||
class="shortcut-item flex justify-between items-center py-2 rounded hover:bg-surface-100 dark-theme:hover:bg-surface-700 transition-colors duration-200"
|
||||
>
|
||||
<div class="shortcut-info flex-grow pr-4">
|
||||
<div class="shortcut-info grow pr-4">
|
||||
<div class="shortcut-name text-sm font-medium">
|
||||
{{ t(`commands.${normalizeI18nKey(command.id)}.label`) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="keybinding-display flex-shrink-0">
|
||||
<div class="keybinding-display shrink-0">
|
||||
<div
|
||||
class="keybinding-combo flex gap-1"
|
||||
:aria-label="`Keyboard shortcut: ${command.keybinding!.combo.getKeySequences().join(' + ')}`"
|
||||
|
||||
@@ -1,15 +1,43 @@
|
||||
<template>
|
||||
<div ref="rootEl" class="relative overflow-hidden h-full w-full bg-black">
|
||||
<div
|
||||
ref="rootEl"
|
||||
class="relative overflow-hidden h-full w-full bg-neutral-900"
|
||||
>
|
||||
<div class="p-terminal rounded-none h-full w-full p-2">
|
||||
<div ref="terminalEl" class="h-full terminal-host" />
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip.left="{
|
||||
value: tooltipText,
|
||||
showDelay: 300
|
||||
}"
|
||||
icon="pi pi-copy"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
:class="
|
||||
cn('absolute top-2 right-8 transition-opacity', {
|
||||
'opacity-0 pointer-events-none select-none': !isHovered
|
||||
})
|
||||
"
|
||||
:aria-label="tooltipText"
|
||||
@click="handleCopy"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Ref, onUnmounted, ref } from 'vue'
|
||||
import { useElementHover, useEventListener } from '@vueuse/core'
|
||||
import type { IDisposable } from '@xterm/xterm'
|
||||
import Button from 'primevue/button'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
|
||||
import { electronAPI, isElectron } from '@/utils/envUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: [ReturnType<typeof useTerminal>, Ref<HTMLElement | undefined>]
|
||||
@@ -17,18 +45,69 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
const terminalEl = ref<HTMLElement | undefined>()
|
||||
const rootEl = ref<HTMLElement | undefined>()
|
||||
emit('created', useTerminal(terminalEl), rootEl)
|
||||
const hasSelection = ref(false)
|
||||
|
||||
onUnmounted(() => emit('unmounted'))
|
||||
const isHovered = useElementHover(rootEl)
|
||||
|
||||
const terminalData = useTerminal(terminalEl)
|
||||
emit('created', terminalData, ref(rootEl))
|
||||
|
||||
const { terminal } = terminalData
|
||||
let selectionDisposable: IDisposable | undefined
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
return hasSelection.value
|
||||
? t('serverStart.copySelectionTooltip')
|
||||
: t('serverStart.copyAllTooltip')
|
||||
})
|
||||
|
||||
const handleCopy = async () => {
|
||||
const existingSelection = terminal.getSelection()
|
||||
const shouldSelectAll = !existingSelection
|
||||
if (shouldSelectAll) terminal.selectAll()
|
||||
|
||||
const selectedText = shouldSelectAll
|
||||
? terminal.getSelection()
|
||||
: existingSelection
|
||||
|
||||
if (selectedText) {
|
||||
await navigator.clipboard.writeText(selectedText)
|
||||
|
||||
if (shouldSelectAll) {
|
||||
terminal.clearSelection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const showContextMenu = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
electronAPI()?.showContextMenu({ type: 'text' })
|
||||
}
|
||||
|
||||
if (isElectron()) {
|
||||
useEventListener(terminalEl, 'contextmenu', showContextMenu)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
selectionDisposable = terminal.onSelectionChange(() => {
|
||||
hasSelection.value = terminal.hasSelection()
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
selectionDisposable?.dispose()
|
||||
emit('unmounted')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
:deep(.p-terminal) .xterm {
|
||||
overflow-x: auto;
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
:deep(.p-terminal) .xterm-screen {
|
||||
background-color: black;
|
||||
overflow-y: hidden;
|
||||
@apply bg-neutral-900 overflow-hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { IDisposable } from '@xterm/xterm'
|
||||
import { Ref, onMounted, onUnmounted } from 'vue'
|
||||
import type { IDisposable } from '@xterm/xterm'
|
||||
import type { Ref } from 'vue'
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
|
||||
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
|
||||
@@ -15,10 +15,11 @@
|
||||
import { until } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { Ref, onMounted, onUnmounted, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
|
||||
import { LogEntry, LogsWsMessage, TerminalSize } from '@/schemas/apiSchema'
|
||||
import type { LogEntry, LogsWsMessage, TerminalSize } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
|
||||
@@ -38,9 +38,10 @@ import { computed, onUpdated, ref, watch } from 'vue'
|
||||
|
||||
import SubgraphBreadcrumbItem from '@/components/breadcrumb/SubgraphBreadcrumbItem.vue'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { forEachSubgraphNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
const MIN_WIDTH = 28
|
||||
@@ -52,6 +53,9 @@ const workflowStore = useWorkflowStore()
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
|
||||
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
|
||||
const isBlueprint = computed(() =>
|
||||
useSubgraphStore().isSubgraphBlueprint(workflowStore.activeWorkflow)
|
||||
)
|
||||
const collapseTabs = ref(false)
|
||||
const overflowingTabs = ref(false)
|
||||
|
||||
@@ -89,6 +93,7 @@ const home = computed(() => ({
|
||||
label: workflowName.value,
|
||||
icon: 'pi pi-home',
|
||||
key: 'root',
|
||||
isBlueprint: isBlueprint.value,
|
||||
command: () => {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
@@ -156,6 +161,8 @@ onUpdated(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.subgraph-breadcrumb:not(:empty) {
|
||||
flex: auto;
|
||||
flex-shrink: 10000;
|
||||
@@ -196,6 +203,8 @@ onUpdated(() => {
|
||||
</style>
|
||||
|
||||
<style>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.subgraph-breadcrumb-collapse .p-breadcrumb-list {
|
||||
.p-breadcrumb-item,
|
||||
.p-breadcrumb-separator {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
@click="handleClick"
|
||||
>
|
||||
<span class="p-breadcrumb-item-label">{{ item.label }}</span>
|
||||
<Tag v-if="item.isBlueprint" :value="'Blueprint'" severity="primary" />
|
||||
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
|
||||
</a>
|
||||
<Menu
|
||||
@@ -36,7 +37,7 @@
|
||||
v-if="isEditing"
|
||||
ref="itemInputRef"
|
||||
v-model="itemLabel"
|
||||
class="fixed z-[10000] text-[.8rem] px-2 py-2"
|
||||
class="fixed z-10000 text-[.8rem] px-2 py-2"
|
||||
@blur="inputBlur(true)"
|
||||
@click.stop
|
||||
@keydown.enter="inputBlur(true)"
|
||||
@@ -46,16 +47,21 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Menu, { MenuState } from 'primevue/menu'
|
||||
import type { MenuState } from 'primevue/menu'
|
||||
import Menu from 'primevue/menu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import Tag from 'primevue/tag'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import {
|
||||
ComfyWorkflow,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
|
||||
interface Props {
|
||||
@@ -107,6 +113,7 @@ const rename = async (
|
||||
}
|
||||
}
|
||||
|
||||
const isRoot = props.item.key === 'root'
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
return [
|
||||
{
|
||||
@@ -120,7 +127,27 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
command: async () => {
|
||||
await workflowService.duplicateWorkflow(workflowStore.activeWorkflow!)
|
||||
},
|
||||
visible: props.item.key === 'root'
|
||||
visible: isRoot && !props.item.isBlueprint
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
label: t('menuLabels.Save'),
|
||||
icon: 'pi pi-save',
|
||||
command: async () => {
|
||||
await useCommandStore().execute('Comfy.SaveWorkflow')
|
||||
},
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
label: t('menuLabels.Save As'),
|
||||
icon: 'pi pi-save',
|
||||
command: async () => {
|
||||
await useCommandStore().execute('Comfy.SaveWorkflowAs')
|
||||
},
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
separator: true
|
||||
@@ -134,15 +161,29 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
visible: props.item.key === 'root'
|
||||
visible: props.item.key === 'root' && props.item.isBlueprint
|
||||
},
|
||||
{
|
||||
label: t('breadcrumbsMenu.deleteWorkflow'),
|
||||
label: t('subgraphStore.publish'),
|
||||
icon: 'pi pi-copy',
|
||||
command: async () => {
|
||||
await workflowService.saveWorkflowAs(workflowStore.activeWorkflow!)
|
||||
},
|
||||
visible: props.item.key === 'root' && props.item.isBlueprint
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
label: props.item.isBlueprint
|
||||
? t('breadcrumbsMenu.deleteBlueprint')
|
||||
: t('breadcrumbsMenu.deleteWorkflow'),
|
||||
icon: 'pi pi-times',
|
||||
command: async () => {
|
||||
await workflowService.deleteWorkflow(workflowStore.activeWorkflow!)
|
||||
},
|
||||
visible: props.item.key === 'root'
|
||||
visible: isRoot
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -190,6 +231,8 @@ const inputBlur = async (doRename: boolean) => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.p-breadcrumb-item-link,
|
||||
.p-breadcrumb-item-icon {
|
||||
@apply select-none;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { Bell, Download, Heart, Settings, Trophy, X } from 'lucide-vue-next'
|
||||
|
||||
import IconButton from './IconButton.vue'
|
||||
|
||||
@@ -16,6 +15,14 @@ const meta: Meta<typeof IconButton> = {
|
||||
control: { type: 'select' },
|
||||
options: ['primary', 'secondary', 'transparent']
|
||||
},
|
||||
border: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle border attribute'
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle disable status'
|
||||
},
|
||||
onClick: { action: 'clicked' }
|
||||
}
|
||||
}
|
||||
@@ -25,13 +32,13 @@ type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Primary: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconButton, Trophy },
|
||||
components: { IconButton },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconButton v-bind="args">
|
||||
<Trophy :size="16" />
|
||||
<i class="icon-[lucide--trophy] size-4" />
|
||||
</IconButton>
|
||||
`
|
||||
}),
|
||||
@@ -43,13 +50,13 @@ export const Primary: Story = {
|
||||
|
||||
export const Secondary: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconButton, Settings },
|
||||
components: { IconButton },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconButton v-bind="args">
|
||||
<Settings :size="16" />
|
||||
<i class="icon-[lucide--settings] size-4" />
|
||||
</IconButton>
|
||||
`
|
||||
}),
|
||||
@@ -61,13 +68,13 @@ export const Secondary: Story = {
|
||||
|
||||
export const Transparent: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconButton, X },
|
||||
components: { IconButton },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconButton v-bind="args">
|
||||
<X :size="16" />
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</IconButton>
|
||||
`
|
||||
}),
|
||||
@@ -79,13 +86,13 @@ export const Transparent: Story = {
|
||||
|
||||
export const Small: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconButton, Bell },
|
||||
components: { IconButton },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconButton v-bind="args">
|
||||
<Bell :size="12" />
|
||||
<i class="icon-[lucide--bell] size-3" />
|
||||
</IconButton>
|
||||
`
|
||||
}),
|
||||
@@ -97,42 +104,42 @@ export const Small: Story = {
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { IconButton, Trophy, Settings, X, Bell, Heart, Download },
|
||||
components: { IconButton },
|
||||
template: `
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconButton type="primary" size="sm" @click="() => {}">
|
||||
<Trophy :size="12" />
|
||||
<i class="icon-[lucide--trophy] size-3" />
|
||||
</IconButton>
|
||||
<IconButton type="primary" size="md" @click="() => {}">
|
||||
<Trophy :size="16" />
|
||||
<i class="icon-[lucide--trophy] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconButton type="secondary" size="sm" @click="() => {}">
|
||||
<Settings :size="12" />
|
||||
<i class="icon-[lucide--settings] size-3" />
|
||||
</IconButton>
|
||||
<IconButton type="secondary" size="md" @click="() => {}">
|
||||
<Settings :size="16" />
|
||||
<i class="icon-[lucide--settings] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconButton type="transparent" size="sm" @click="() => {}">
|
||||
<X :size="12" />
|
||||
<i class="icon-[lucide--x] size-3" />
|
||||
</IconButton>
|
||||
<IconButton type="transparent" size="md" @click="() => {}">
|
||||
<X :size="16" />
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconButton type="primary" size="md" @click="() => {}">
|
||||
<Bell :size="16" />
|
||||
<i class="icon-[lucide--bell] size-4" />
|
||||
</IconButton>
|
||||
<IconButton type="secondary" size="md" @click="() => {}">
|
||||
<Heart :size="16" />
|
||||
<i class="icon-[lucide--heart] size-4" />
|
||||
</IconButton>
|
||||
<IconButton type="transparent" size="md" @click="() => {}">
|
||||
<Download :size="16" />
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<Button unstyled :class="buttonStyle" @click="onClick">
|
||||
<Button
|
||||
v-bind="$attrs"
|
||||
unstyled
|
||||
:class="buttonStyle"
|
||||
:disabled="disabled"
|
||||
@click="onClick"
|
||||
>
|
||||
<slot></slot>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -11,17 +17,25 @@ import { computed } from 'vue'
|
||||
import type { BaseButtonProps } from '@/types/buttonTypes'
|
||||
import {
|
||||
getBaseButtonClasses,
|
||||
getBorderButtonTypeClasses,
|
||||
getButtonTypeClasses,
|
||||
getIconButtonSizeClasses
|
||||
} from '@/types/buttonTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface IconButtonProps extends BaseButtonProps {
|
||||
onClick: (event: Event) => void
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const {
|
||||
size = 'md',
|
||||
type = 'secondary',
|
||||
border = false,
|
||||
disabled = false,
|
||||
class: className,
|
||||
onClick
|
||||
} = defineProps<IconButtonProps>()
|
||||
@@ -29,10 +43,10 @@ const {
|
||||
const buttonStyle = computed(() => {
|
||||
const baseClasses = `${getBaseButtonClasses()} p-0`
|
||||
const sizeClasses = getIconButtonSizeClasses(size)
|
||||
const typeClasses = getButtonTypeClasses(type)
|
||||
const typeClasses = border
|
||||
? getBorderButtonTypeClasses(type)
|
||||
: getButtonTypeClasses(type)
|
||||
|
||||
return [baseClasses, sizeClasses, typeClasses, className]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
return cn(baseClasses, sizeClasses, typeClasses, className)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { Download, ExternalLink, Heart } from 'lucide-vue-next'
|
||||
|
||||
import IconButton from './IconButton.vue'
|
||||
import IconGroup from './IconGroup.vue'
|
||||
@@ -17,17 +16,17 @@ type Story = StoryObj<typeof IconGroup>
|
||||
|
||||
export const Basic: Story = {
|
||||
render: () => ({
|
||||
components: { IconGroup, IconButton, Download, ExternalLink, Heart },
|
||||
components: { IconGroup, IconButton },
|
||||
template: `
|
||||
<IconGroup>
|
||||
<IconButton @click="console.log('Hello World!!')">
|
||||
<Heart :size="16" />
|
||||
<i class="icon-[lucide--heart] size-4" />
|
||||
</IconButton>
|
||||
<IconButton @click="console.log('Hello World!!')">
|
||||
<Download :size="16" />
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</IconButton>
|
||||
<IconButton @click="console.log('Hello World!!')">
|
||||
<ExternalLink :size="16" />
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
</IconButton>
|
||||
</IconGroup>
|
||||
`
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex justify-center items-center flex-shrink-0 outline-none border-none p-0 bg-white text-neutral-950 dark-theme:bg-zinc-700 dark-theme:text-white rounded-lg cursor-pointer"
|
||||
>
|
||||
<div :class="iconGroupClasses">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const iconGroupClasses = cn(
|
||||
'flex justify-center items-center shrink-0',
|
||||
'outline-hidden border-none p-0 rounded-lg',
|
||||
'bg-white dark-theme:bg-zinc-700',
|
||||
'text-neutral-950 dark-theme:text-white',
|
||||
'cursor-pointer'
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Package,
|
||||
Save,
|
||||
Settings,
|
||||
Trash2,
|
||||
X
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
import IconTextButton from './IconTextButton.vue'
|
||||
|
||||
@@ -28,6 +18,14 @@ const meta: Meta<typeof IconTextButton> = {
|
||||
control: { type: 'select' },
|
||||
options: ['primary', 'secondary', 'transparent']
|
||||
},
|
||||
border: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle border attribute'
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle disable status'
|
||||
},
|
||||
iconPosition: {
|
||||
control: { type: 'select' },
|
||||
options: ['left', 'right']
|
||||
@@ -41,14 +39,14 @@ type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Primary: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconTextButton, Package },
|
||||
components: { IconTextButton },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconTextButton v-bind="args">
|
||||
<template #icon>
|
||||
<Package :size="16" />
|
||||
<i class="icon-[lucide--package] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
`
|
||||
@@ -62,14 +60,14 @@ export const Primary: Story = {
|
||||
|
||||
export const Secondary: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconTextButton, Settings },
|
||||
components: { IconTextButton },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconTextButton v-bind="args">
|
||||
<template #icon>
|
||||
<Settings :size="16" />
|
||||
<i class="icon-[lucide--settings] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
`
|
||||
@@ -83,14 +81,14 @@ export const Secondary: Story = {
|
||||
|
||||
export const Transparent: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconTextButton, X },
|
||||
components: { IconTextButton },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconTextButton v-bind="args">
|
||||
<template #icon>
|
||||
<X :size="16" />
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
`
|
||||
@@ -104,14 +102,14 @@ export const Transparent: Story = {
|
||||
|
||||
export const WithIconRight: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconTextButton, ChevronRight },
|
||||
components: { IconTextButton },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconTextButton v-bind="args">
|
||||
<template #icon>
|
||||
<ChevronRight :size="16" />
|
||||
<i class="icon-[lucide--chevron-right] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
`
|
||||
@@ -126,14 +124,14 @@ export const WithIconRight: Story = {
|
||||
|
||||
export const Small: Story = {
|
||||
render: (args) => ({
|
||||
components: { IconTextButton, Save },
|
||||
components: { IconTextButton },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<IconTextButton v-bind="args">
|
||||
<template #icon>
|
||||
<Save :size="12" />
|
||||
<i class="icon-[lucide--save] size-3" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
`
|
||||
@@ -148,66 +146,60 @@ export const Small: Story = {
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: {
|
||||
IconTextButton,
|
||||
Download,
|
||||
Settings,
|
||||
Trash2,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Save
|
||||
IconTextButton
|
||||
},
|
||||
template: `
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconTextButton label="Download" type="primary" size="sm" @click="() => {}">
|
||||
<template #icon>
|
||||
<Download :size="12" />
|
||||
<i class="icon-[lucide--download] size-3" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton label="Download" type="primary" size="md" @click="() => {}">
|
||||
<template #icon>
|
||||
<Download :size="16" />
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconTextButton label="Settings" type="secondary" size="sm" @click="() => {}">
|
||||
<template #icon>
|
||||
<Settings :size="12" />
|
||||
<i class="icon-[lucide--settings] size-3" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton label="Settings" type="secondary" size="md" @click="() => {}">
|
||||
<template #icon>
|
||||
<Settings :size="16" />
|
||||
<i class="icon-[lucide--settings] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconTextButton label="Delete" type="transparent" size="sm" @click="() => {}">
|
||||
<template #icon>
|
||||
<Trash2 :size="12" />
|
||||
<i class="icon-[lucide--trash-2] size-3" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton label="Delete" type="transparent" size="md" @click="() => {}">
|
||||
<template #icon>
|
||||
<Trash2 :size="16" />
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconTextButton label="Next" type="primary" size="md" iconPosition="right" @click="() => {}">
|
||||
<template #icon>
|
||||
<ChevronRight :size="16" />
|
||||
<i class="icon-[lucide--chevron-right] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton label="Previous" type="secondary" size="md" @click="() => {}">
|
||||
<template #icon>
|
||||
<ChevronLeft :size="16" />
|
||||
<i class="icon-[lucide--chevron-left] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton label="Save File" type="primary" size="md" @click="() => {}">
|
||||
<template #icon>
|
||||
<Save :size="16" />
|
||||
<i class="icon-[lucide--save] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<Button unstyled :class="buttonStyle" @click="onClick">
|
||||
<Button
|
||||
v-bind="$attrs"
|
||||
unstyled
|
||||
:class="buttonStyle"
|
||||
:disabled="disabled"
|
||||
@click="onClick"
|
||||
>
|
||||
<slot v-if="iconPosition !== 'right'" name="icon"></slot>
|
||||
<span>{{ label }}</span>
|
||||
<slot v-if="iconPosition === 'right'" name="icon"></slot>
|
||||
@@ -13,9 +19,15 @@ import { computed } from 'vue'
|
||||
import type { BaseButtonProps } from '@/types/buttonTypes'
|
||||
import {
|
||||
getBaseButtonClasses,
|
||||
getBorderButtonTypeClasses,
|
||||
getButtonSizeClasses,
|
||||
getButtonTypeClasses
|
||||
} from '@/types/buttonTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
interface IconTextButtonProps extends BaseButtonProps {
|
||||
iconPosition?: 'left' | 'right'
|
||||
@@ -26,6 +38,8 @@ interface IconTextButtonProps extends BaseButtonProps {
|
||||
const {
|
||||
size = 'md',
|
||||
type = 'primary',
|
||||
border = false,
|
||||
disabled = false,
|
||||
class: className,
|
||||
iconPosition = 'left',
|
||||
label,
|
||||
@@ -33,12 +47,12 @@ const {
|
||||
} = defineProps<IconTextButtonProps>()
|
||||
|
||||
const buttonStyle = computed(() => {
|
||||
const baseClasses = `${getBaseButtonClasses()} !justify-start gap-2`
|
||||
const baseClasses = `${getBaseButtonClasses()} justify-start! gap-2`
|
||||
const sizeClasses = getButtonSizeClasses(size)
|
||||
const typeClasses = getButtonTypeClasses(type)
|
||||
const typeClasses = border
|
||||
? getBorderButtonTypeClasses(type)
|
||||
: getButtonTypeClasses(type)
|
||||
|
||||
return [baseClasses, sizeClasses, typeClasses, className]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
return cn(baseClasses, sizeClasses, typeClasses, className)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { Download, ScrollText } from 'lucide-vue-next'
|
||||
|
||||
import IconTextButton from './IconTextButton.vue'
|
||||
import MoreButton from './MoreButton.vue'
|
||||
@@ -18,28 +17,28 @@ type Story = StoryObj<typeof MoreButton>
|
||||
|
||||
export const Basic: Story = {
|
||||
render: () => ({
|
||||
components: { MoreButton, IconTextButton, Download, ScrollText },
|
||||
components: { MoreButton, IconTextButton },
|
||||
template: `
|
||||
<div style="height: 200px; display: flex; align-items: center; justify-content: center;">
|
||||
<MoreButton>
|
||||
<template #default="{ close }">
|
||||
<IconTextButton
|
||||
type="secondary"
|
||||
type="transparent"
|
||||
label="Settings"
|
||||
@click="() => { close() }"
|
||||
>
|
||||
<template #icon>
|
||||
<Download />
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<IconTextButton
|
||||
type="primary"
|
||||
type="transparent"
|
||||
label="Profile"
|
||||
@click="() => { close() }"
|
||||
>
|
||||
<template #icon>
|
||||
<ScrollText />
|
||||
<i class="icon-[lucide--scroll-text] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</template>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
unstyled
|
||||
:pt="pt"
|
||||
>
|
||||
<div class="flex flex-col gap-1 p-2 min-w-40">
|
||||
<div class="flex flex-col gap-2 p-2 min-w-40">
|
||||
<slot :close="hide" />
|
||||
</div>
|
||||
</Popover>
|
||||
@@ -25,6 +25,8 @@
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import IconButton from './IconButton.vue'
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover>>()
|
||||
@@ -39,13 +41,16 @@ const hide = () => {
|
||||
|
||||
const pt = computed(() => ({
|
||||
root: {
|
||||
class: 'absolute z-50'
|
||||
class: cn('absolute z-50')
|
||||
},
|
||||
content: {
|
||||
class: [
|
||||
'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg',
|
||||
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700'
|
||||
]
|
||||
class: cn(
|
||||
'mt-2 rounded-lg',
|
||||
'bg-white dark-theme:bg-zinc-800',
|
||||
'text-neutral dark-theme:text-white',
|
||||
'shadow-lg',
|
||||
'border border-zinc-200 dark-theme:border-zinc-700'
|
||||
)
|
||||
}
|
||||
}))
|
||||
</script>
|
||||
|
||||
@@ -16,6 +16,14 @@ const meta: Meta<typeof TextButton> = {
|
||||
options: ['sm', 'md'],
|
||||
defaultValue: 'md'
|
||||
},
|
||||
border: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle border attribute'
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle disable status'
|
||||
},
|
||||
type: {
|
||||
control: { type: 'select' },
|
||||
options: ['primary', 'secondary', 'transparent'],
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<Button unstyled :class="buttonStyle" role="button" @click="onClick">
|
||||
<Button
|
||||
v-bind="$attrs"
|
||||
unstyled
|
||||
:class="buttonStyle"
|
||||
:disabled="disabled"
|
||||
@click="onClick"
|
||||
>
|
||||
<span>{{ label }}</span>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -11,18 +17,26 @@ import { computed } from 'vue'
|
||||
import type { BaseButtonProps } from '@/types/buttonTypes'
|
||||
import {
|
||||
getBaseButtonClasses,
|
||||
getBorderButtonTypeClasses,
|
||||
getButtonSizeClasses,
|
||||
getButtonTypeClasses
|
||||
} from '@/types/buttonTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface TextButtonProps extends BaseButtonProps {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const {
|
||||
size = 'md',
|
||||
type = 'primary',
|
||||
border = false,
|
||||
disabled = false,
|
||||
class: className,
|
||||
label,
|
||||
onClick
|
||||
@@ -31,10 +45,10 @@ const {
|
||||
const buttonStyle = computed(() => {
|
||||
const baseClasses = getBaseButtonClasses()
|
||||
const sizeClasses = getButtonSizeClasses(size)
|
||||
const typeClasses = getButtonTypeClasses(type)
|
||||
const typeClasses = border
|
||||
? getBorderButtonTypeClasses(type)
|
||||
: getButtonTypeClasses(type)
|
||||
|
||||
return [baseClasses, sizeClasses, typeClasses, className]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
return cn(baseClasses, sizeClasses, typeClasses, className)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import {
|
||||
Download,
|
||||
Folder,
|
||||
Heart,
|
||||
Info,
|
||||
MoreVertical,
|
||||
Star,
|
||||
Upload
|
||||
} from 'lucide-vue-next'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import IconButton from '../button/IconButton.vue'
|
||||
@@ -58,14 +49,6 @@ const meta: Meta<CardStoryArgs> = {
|
||||
options: ['square', 'portrait', 'tallPortrait'],
|
||||
description: 'Card container aspect ratio'
|
||||
},
|
||||
maxWidth: {
|
||||
control: { type: 'range', min: 200, max: 600, step: 10 },
|
||||
description: 'Maximum width in pixels'
|
||||
},
|
||||
minWidth: {
|
||||
control: { type: 'range', min: 150, max: 400, step: 10 },
|
||||
description: 'Minimum width in pixels'
|
||||
},
|
||||
topRatio: {
|
||||
control: 'select',
|
||||
options: ['square', 'landscape'],
|
||||
@@ -149,14 +132,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
IconButton,
|
||||
SquareChip,
|
||||
Info,
|
||||
Folder,
|
||||
Heart,
|
||||
Download,
|
||||
Star,
|
||||
Upload,
|
||||
MoreVertical
|
||||
SquareChip
|
||||
},
|
||||
setup() {
|
||||
const favorited = ref(false)
|
||||
@@ -171,11 +147,10 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="p-4 min-h-screen bg-zinc-50 dark-theme:bg-zinc-900">
|
||||
<div class="min-h-screen">
|
||||
<CardContainer
|
||||
:ratio="args.containerRatio"
|
||||
:max-width="args.maxWidth"
|
||||
:min-width="args.minWidth"
|
||||
class="max-w-[320px] mx-auto"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop :ratio="args.topRatio">
|
||||
@@ -202,14 +177,14 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
class="!bg-white/90 !text-neutral-900"
|
||||
@click="() => console.log('Info clicked')"
|
||||
>
|
||||
<Info :size="16" />
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
class="!bg-white/90"
|
||||
:class="favorited ? '!text-red-500' : '!text-neutral-900'"
|
||||
@click="toggleFavorite"
|
||||
>
|
||||
<Heart :size="16" :fill="favorited ? 'currentColor' : 'none'" />
|
||||
<i class="icon-[lucide--heart] size-4" :class="favorited ? 'fill-current' : ''" />
|
||||
</IconButton>
|
||||
</template>
|
||||
|
||||
@@ -222,7 +197,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
<SquareChip v-if="args.showFileSize" :label="args.fileSize" />
|
||||
<SquareChip v-for="tag in args.tags" :key="tag" :label="tag">
|
||||
<template v-if="tag === 'LoRA'" #icon>
|
||||
<Folder :size="12" />
|
||||
<i class="icon-[lucide--folder] size-3" />
|
||||
</template>
|
||||
</SquareChip>
|
||||
</template>
|
||||
@@ -230,7 +205,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<CardBottom class="p-3">
|
||||
<CardBottom class="p-3 bg-neutral-100">
|
||||
<CardTitle v-if="args.showTitle">{{ args.title }}</CardTitle>
|
||||
<CardDescription v-if="args.showDescription">{{ args.description }}</CardDescription>
|
||||
</CardBottom>
|
||||
@@ -244,8 +219,6 @@ export const Default: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'portrait',
|
||||
maxWidth: 300,
|
||||
minWidth: 200,
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: true,
|
||||
@@ -271,8 +244,6 @@ export const SquareCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'square',
|
||||
maxWidth: 400,
|
||||
minWidth: 250,
|
||||
topRatio: 'landscape',
|
||||
showTopLeft: false,
|
||||
showTopRight: true,
|
||||
@@ -298,8 +269,6 @@ export const TallPortraitCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'tallPortrait',
|
||||
maxWidth: 280,
|
||||
minWidth: 180,
|
||||
topRatio: 'square',
|
||||
showTopLeft: true,
|
||||
showTopRight: true,
|
||||
@@ -325,8 +294,6 @@ export const ImageCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'portrait',
|
||||
maxWidth: 350,
|
||||
minWidth: 220,
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: true,
|
||||
@@ -351,8 +318,6 @@ export const MinimalCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'square',
|
||||
maxWidth: 300,
|
||||
minWidth: 200,
|
||||
topRatio: 'landscape',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
@@ -377,8 +342,6 @@ export const FullFeaturedCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'tallPortrait',
|
||||
maxWidth: 320,
|
||||
minWidth: 240,
|
||||
topRatio: 'square',
|
||||
showTopLeft: true,
|
||||
showTopRight: true,
|
||||
@@ -392,274 +355,10 @@ export const FullFeaturedCard: Story = {
|
||||
backgroundColor: '#ef4444',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: ['Bundle', 'Premium', 'SDXL'],
|
||||
tags: ['Bundle', 'SDXL'],
|
||||
showFileSize: true,
|
||||
fileSize: '5.4 GB',
|
||||
showFileType: true,
|
||||
fileType: 'pack'
|
||||
}
|
||||
}
|
||||
|
||||
export const GridOfCards: Story = {
|
||||
render: () => ({
|
||||
components: {
|
||||
CardContainer,
|
||||
CardTop,
|
||||
CardBottom,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
IconButton,
|
||||
SquareChip,
|
||||
Info,
|
||||
Folder,
|
||||
Heart,
|
||||
Download
|
||||
},
|
||||
setup() {
|
||||
const cards = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: 'Realistic Vision',
|
||||
description: 'Photorealistic model for portraits',
|
||||
color: 'from-blue-400 to-blue-600',
|
||||
ratio: 'portrait' as const,
|
||||
tags: ['SD 1.5'],
|
||||
size: '2.1 GB'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'DreamShaper XL',
|
||||
description: 'Artistic style model with enhanced details',
|
||||
color: 'from-purple-400 to-pink-600',
|
||||
ratio: 'portrait' as const,
|
||||
tags: ['SDXL'],
|
||||
size: '6.5 GB'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Anime LoRA',
|
||||
description: 'Character style LoRA',
|
||||
color: 'from-green-400 to-teal-600',
|
||||
ratio: 'portrait' as const,
|
||||
tags: ['LoRA'],
|
||||
size: '144 MB'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'VAE Model',
|
||||
description: 'Enhanced color VAE',
|
||||
color: 'from-orange-400 to-red-600',
|
||||
ratio: 'portrait' as const,
|
||||
tags: ['VAE'],
|
||||
size: '335 MB'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'Workflow Bundle',
|
||||
description: 'Complete workflow setup',
|
||||
color: 'from-indigo-400 to-blue-600',
|
||||
ratio: 'portrait' as const,
|
||||
tags: ['Workflow'],
|
||||
size: '45 KB'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'Embedding Pack',
|
||||
description: 'Negative embeddings collection',
|
||||
color: 'from-yellow-400 to-orange-600',
|
||||
ratio: 'portrait' as const,
|
||||
tags: ['Embedding'],
|
||||
size: '2.3 MB'
|
||||
}
|
||||
])
|
||||
|
||||
return { cards }
|
||||
},
|
||||
template: `
|
||||
<div class="p-4 min-h-screen bg-zinc-50 dark-theme:bg-zinc-900">
|
||||
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Model Gallery</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
|
||||
<CardContainer
|
||||
v-for="card in cards"
|
||||
:key="card.id"
|
||||
:ratio="card.ratio"
|
||||
:max-width="300"
|
||||
:min-width="180"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="square">
|
||||
<template #default>
|
||||
<div
|
||||
class="w-full h-full bg-gray-600"
|
||||
:class="card.color"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<template #top-right>
|
||||
<IconButton
|
||||
class="!bg-white/90 !text-neutral-900"
|
||||
@click="() => console.log('Info:', card.title)"
|
||||
>
|
||||
<Info :size="16" />
|
||||
</IconButton>
|
||||
</template>
|
||||
|
||||
<template #bottom-right>
|
||||
<SquareChip
|
||||
v-for="tag in card.tags"
|
||||
:key="tag"
|
||||
:label="tag"
|
||||
>
|
||||
<template v-if="tag === 'LoRA'" #icon>
|
||||
<Folder :size="12" />
|
||||
</template>
|
||||
</SquareChip>
|
||||
<SquareChip :label="card.size" />
|
||||
</template>
|
||||
</CardTop>
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<CardBottom class="p-3">
|
||||
<CardTitle>{{ card.title }}</CardTitle>
|
||||
<CardDescription>{{ card.description }}</CardDescription>
|
||||
</CardBottom>
|
||||
</template>
|
||||
</CardContainer>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const ResponsiveGrid: Story = {
|
||||
render: () => ({
|
||||
components: {
|
||||
CardContainer,
|
||||
CardTop,
|
||||
CardBottom,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
SquareChip
|
||||
},
|
||||
setup() {
|
||||
const generateCards = (
|
||||
count: number,
|
||||
ratio: 'square' | 'portrait' | 'tallPortrait'
|
||||
) => {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: i + 1,
|
||||
title: `Model ${i + 1}`,
|
||||
description: `Description for model ${i + 1}`,
|
||||
ratio,
|
||||
color: `hsl(${(i * 60) % 360}, 70%, 60%)`
|
||||
}))
|
||||
}
|
||||
|
||||
const squareCards = ref(generateCards(4, 'square'))
|
||||
const portraitCards = ref(generateCards(6, 'portrait'))
|
||||
const tallCards = ref(generateCards(5, 'tallPortrait'))
|
||||
|
||||
return {
|
||||
squareCards,
|
||||
portraitCards,
|
||||
tallCards
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="p-4 space-y-8 min-h-screen bg-zinc-50 dark-theme:bg-zinc-900">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Square Cards (1:1)</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<CardContainer
|
||||
v-for="card in squareCards"
|
||||
:key="card.id"
|
||||
:ratio="card.ratio"
|
||||
:max-width="400"
|
||||
:min-width="200"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="landscape">
|
||||
<div
|
||||
class="w-full h-full"
|
||||
:style="{ backgroundColor: card.color }"
|
||||
></div>
|
||||
</CardTop>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<CardBottom class="p-3">
|
||||
<CardTitle>{{ card.title }}</CardTitle>
|
||||
<CardDescription>{{ card.description }}</CardDescription>
|
||||
</CardBottom>
|
||||
</template>
|
||||
</CardContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Portrait Cards (2:3)</h3>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
<CardContainer
|
||||
v-for="card in portraitCards"
|
||||
:key="card.id"
|
||||
:ratio="card.ratio"
|
||||
:max-width="280"
|
||||
:min-width="160"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="square">
|
||||
<div
|
||||
class="w-full h-full"
|
||||
:style="{ backgroundColor: card.color }"
|
||||
></div>
|
||||
</CardTop>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<CardBottom class="p-2">
|
||||
<CardTitle>{{ card.title }}</CardTitle>
|
||||
</CardBottom>
|
||||
</template>
|
||||
</CardContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Tall Portrait Cards (2:4)</h3>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
<CardContainer
|
||||
v-for="card in tallCards"
|
||||
:key="card.id"
|
||||
:ratio="card.ratio"
|
||||
:max-width="260"
|
||||
:min-width="150"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="square">
|
||||
<template #default>
|
||||
<div
|
||||
class="w-full h-full"
|
||||
:style="{ backgroundColor: card.color }"
|
||||
></div>
|
||||
</template>
|
||||
<template #bottom-right>
|
||||
<SquareChip :label="'#' + card.id" />
|
||||
</template>
|
||||
</CardTop>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<CardBottom class="p-3">
|
||||
<CardTitle>{{ card.title }}</CardTitle>
|
||||
<CardDescription>{{ card.description }}</CardDescription>
|
||||
</CardBottom>
|
||||
</template>
|
||||
</CardContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
actions: { disable: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
<template>
|
||||
<div class="flex-1 w-full h-full">
|
||||
<div :class="containerClasses">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { fullHeight = true } = defineProps<{
|
||||
fullHeight?: boolean
|
||||
}>()
|
||||
|
||||
const containerClasses = computed(() =>
|
||||
cn('flex-1 w-full', fullHeight && 'h-full')
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div :class="containerClasses" :style="containerStyle">
|
||||
<div :class="containerClasses">
|
||||
<slot name="top"></slot>
|
||||
<slot name="bottom"></slot>
|
||||
</div>
|
||||
@@ -8,35 +8,26 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const {
|
||||
ratio = 'square',
|
||||
maxWidth,
|
||||
minWidth
|
||||
} = defineProps<{
|
||||
maxWidth?: number
|
||||
minWidth?: number
|
||||
ratio?: 'square' | 'portrait' | 'tallPortrait'
|
||||
const { ratio = 'square', type } = defineProps<{
|
||||
ratio?: 'smallSquare' | 'square' | 'portrait' | 'tallPortrait'
|
||||
type?: string
|
||||
}>()
|
||||
|
||||
const containerClasses = computed(() => {
|
||||
const baseClasses =
|
||||
'flex flex-col bg-white dark-theme:bg-zinc-800 rounded-lg shadow-sm border border-zinc-200 dark-theme:border-zinc-700 overflow-hidden'
|
||||
'cursor-pointer flex flex-col bg-white dark-theme:bg-zinc-800 rounded-lg shadow-sm border border-zinc-200 dark-theme:border-zinc-700 overflow-hidden'
|
||||
|
||||
if (type === 'workflow-template-card') {
|
||||
return `cursor-pointer p-2 flex flex-col hover:bg-white dark-theme:hover:bg-zinc-800 rounded-lg transition-background duration-200 ease-in-out`
|
||||
}
|
||||
|
||||
const ratioClasses = {
|
||||
square: 'aspect-[256/308]',
|
||||
portrait: 'aspect-[256/325]',
|
||||
tallPortrait: 'aspect-[256/353]'
|
||||
smallSquare: 'aspect-240/311',
|
||||
square: 'aspect-256/308',
|
||||
portrait: 'aspect-256/325',
|
||||
tallPortrait: 'aspect-256/353'
|
||||
}
|
||||
|
||||
return `${baseClasses} ${ratioClasses[ratio]}`
|
||||
})
|
||||
|
||||
const containerStyle = computed(() =>
|
||||
maxWidth || minWidth
|
||||
? {
|
||||
maxWidth: `${maxWidth}px`,
|
||||
minWidth: `${minWidth}px`
|
||||
}
|
||||
: {}
|
||||
)
|
||||
</script>
|
||||
|
||||
69
src/components/card/CardGridList.stories.ts
Normal file
69
src/components/card/CardGridList.stories.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import { createGridStyle } from '@/utils/gridUtil'
|
||||
|
||||
import CardBottom from './CardBottom.vue'
|
||||
import CardContainer from './CardContainer.vue'
|
||||
import CardTop from './CardTop.vue'
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Components/Card/CardGridList',
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
minWidth: {
|
||||
control: 'text',
|
||||
description: 'Minimum width for each grid item'
|
||||
},
|
||||
maxWidth: {
|
||||
control: 'text',
|
||||
description: 'Maximum width for each grid item'
|
||||
},
|
||||
padding: {
|
||||
control: 'text',
|
||||
description: 'Padding around the grid'
|
||||
},
|
||||
gap: {
|
||||
control: 'text',
|
||||
description: 'Gap between grid items'
|
||||
},
|
||||
columns: {
|
||||
control: 'number',
|
||||
description: 'Fixed number of columns (overrides auto-fill)'
|
||||
}
|
||||
},
|
||||
args: {
|
||||
minWidth: '15rem',
|
||||
maxWidth: '1fr',
|
||||
padding: '0rem',
|
||||
gap: '1rem'
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { CardContainer, CardTop, CardBottom },
|
||||
setup() {
|
||||
const gridStyle = createGridStyle(args)
|
||||
return { gridStyle }
|
||||
},
|
||||
template: `
|
||||
<div :style="gridStyle">
|
||||
<CardContainer v-for="i in 12" :key="i" ratio="square">
|
||||
<template #top>
|
||||
<CardTop ratio="landscape">
|
||||
<template #default>
|
||||
<div class="w-full h-full bg-blue-500"></div>
|
||||
</template>
|
||||
</CardTop>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<CardBottom class="bg-neutral-200"></CardBottom>
|
||||
</template>
|
||||
</CardContainer>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -2,26 +2,40 @@
|
||||
<div :class="topStyle">
|
||||
<slot class="absolute top-0 left-0 w-full h-full"></slot>
|
||||
|
||||
<div class="absolute top-2 left-2 flex gap-2">
|
||||
<div
|
||||
v-if="slots['top-left']"
|
||||
class="absolute top-2 left-2 flex gap-2 flex-wrap justify-start"
|
||||
>
|
||||
<slot name="top-left"></slot>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-2 right-2 flex gap-2">
|
||||
<div
|
||||
v-if="slots['top-right']"
|
||||
class="absolute top-2 right-2 flex gap-2 flex-wrap justify-end"
|
||||
>
|
||||
<slot name="top-right"></slot>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-2 left-2 flex gap-2">
|
||||
<div
|
||||
v-if="slots['bottom-left']"
|
||||
class="absolute bottom-2 left-2 flex gap-2 flex-wrap justify-start"
|
||||
>
|
||||
<slot name="bottom-left"></slot>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-2 right-2 flex gap-2">
|
||||
<div
|
||||
v-if="slots['bottom-right']"
|
||||
class="absolute bottom-2 right-2 flex gap-2 flex-wrap justify-end"
|
||||
>
|
||||
<slot name="bottom-right"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, useSlots } from 'vue'
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const { ratio = 'square' } = defineProps<{
|
||||
ratio?: 'square' | 'landscape'
|
||||
@@ -31,8 +45,8 @@ const topStyle = computed(() => {
|
||||
const baseClasses = 'relative p-0'
|
||||
|
||||
const ratioClasses = {
|
||||
square: 'aspect-[1/1]',
|
||||
landscape: 'aspect-[48/27]'
|
||||
square: 'aspect-square',
|
||||
landscape: 'aspect-48/27'
|
||||
}
|
||||
|
||||
return `${baseClasses} ${ratioClasses[ratio]}`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex justify-center items-center gap-1 flex-shrink-0 py-1 px-2 text-xs bg-[#D9D9D966]/40 rounded font-bold text-white/90"
|
||||
class="inline-flex justify-center items-center gap-1 shrink-0 py-1 px-2 text-xs bg-[#D9D9D966]/40 rounded font-bold text-white/90"
|
||||
>
|
||||
<slot name="icon" class="text-xs text-white/90"></slot>
|
||||
<span>{{ label }}</span>
|
||||
|
||||
@@ -36,8 +36,8 @@ import Button from 'primevue/button'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
|
||||
const modelValue = defineModel<string>()
|
||||
|
||||
|
||||
131
src/components/common/DotSpinner.vue
Normal file
131
src/components/common/DotSpinner.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex items-center justify-center"
|
||||
:style="{ width: size + 'px', height: size + 'px' }"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
class="animate-spin"
|
||||
:style="{ animationDuration: duration }"
|
||||
>
|
||||
<g clip-path="url(#clip0_776_9582)">
|
||||
<!-- Top dot -->
|
||||
<path
|
||||
class="dot-animation"
|
||||
style="animation-delay: 0s"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M7 2.21053C7.61042 2.21053 8.10526 1.71568 8.10526 1.10526C8.10526 0.494843 7.61042 0 7 0C6.38958 0 5.89474 0.494843 5.89474 1.10526C5.89474 1.71568 6.38958 2.21053 7 2.21053Z"
|
||||
:fill="color"
|
||||
/>
|
||||
<!-- Left dot -->
|
||||
<path
|
||||
class="dot-animation"
|
||||
style="animation-delay: 0.25s"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M2.21053 7C2.21053 7.61042 1.71568 8.10526 1.10526 8.10526C0.494843 8.10526 0 7.61042 0 7C0 6.38958 0.494843 5.89474 1.10526 5.89474C1.71568 5.89474 2.21053 6.38958 2.21053 7Z"
|
||||
:fill="color"
|
||||
/>
|
||||
<!-- Right dot -->
|
||||
<path
|
||||
class="dot-animation"
|
||||
style="animation-delay: 0.5s"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M14 7C14 7.61042 13.5052 8.10526 12.8947 8.10526C12.2843 8.10526 11.7895 7.61042 11.7895 7C11.7895 6.38958 12.2843 5.89474 12.8947 5.89474C13.5052 5.89474 14 6.38958 14 7Z"
|
||||
:fill="color"
|
||||
/>
|
||||
<!-- Bottom dot -->
|
||||
<path
|
||||
class="dot-animation"
|
||||
style="animation-delay: 0.75s"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M8.10526 12.8947C8.10526 13.5052 7.61041 14 6.99999 14C6.38957 14 5.89473 13.5052 5.89473 12.8947C5.89473 12.2843 6.38957 11.7895 6.99999 11.7895C7.61041 11.7895 8.10526 12.2843 8.10526 12.8947Z"
|
||||
:fill="color"
|
||||
/>
|
||||
<!-- Top-left dot -->
|
||||
<path
|
||||
class="dot-animation"
|
||||
style="animation-delay: 0.125s"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M2.05039 3.61349C2.48203 4.04513 3.18184 4.04513 3.61347 3.61349C4.0451 3.18186 4.0451 2.48205 3.61347 2.05042C3.18184 1.61878 2.48203 1.61878 2.05039 2.05042C1.61876 2.48205 1.61876 3.18186 2.05039 3.61349Z"
|
||||
:fill="color"
|
||||
/>
|
||||
<!-- Bottom-right dot -->
|
||||
<path
|
||||
class="dot-animation"
|
||||
style="animation-delay: 0.625s"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M11.9496 11.9496C11.518 12.3812 10.8182 12.3812 10.3865 11.9496C9.9549 11.5179 9.9549 10.8181 10.3865 10.3865C10.8182 9.95485 11.518 9.95485 11.9496 10.3865C12.3812 10.8181 12.3812 11.5179 11.9496 11.9496Z"
|
||||
:fill="color"
|
||||
/>
|
||||
<!-- Bottom-left dot -->
|
||||
<path
|
||||
class="dot-animation"
|
||||
style="animation-delay: 0.875s"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M2.05039 11.9496C2.48203 12.3812 3.18184 12.3812 3.61347 11.9496C4.0451 11.5179 4.0451 10.8181 3.61347 10.3865C3.18184 9.95485 2.48203 9.95485 2.05039 10.3865C1.61876 10.8181 1.61876 11.5179 2.05039 11.9496Z"
|
||||
:fill="color"
|
||||
/>
|
||||
<!-- Top-right dot -->
|
||||
<path
|
||||
class="dot-animation"
|
||||
style="animation-delay: 0.375s"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M11.9496 3.61349C11.518 4.04513 10.8182 4.04513 10.3865 3.61349C9.9549 3.18186 9.9549 2.48205 10.3865 2.05042C10.8182 1.61878 11.518 1.61878 11.9496 2.05042C12.3812 2.48205 12.3812 3.18186 11.9496 3.61349Z"
|
||||
:fill="color"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_776_9582">
|
||||
<rect width="14" height="14" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
|
||||
const { size = 24, duration = '2s' } = defineProps<{
|
||||
size?: number
|
||||
duration?: string
|
||||
}>()
|
||||
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
|
||||
const color = computed(() =>
|
||||
colorPaletteStore.completedActivePalette.light_theme ? '#2C2B30' : '#D4D4D4'
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dot-animation {
|
||||
animation: dot-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes dot-pulse {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
40% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,71 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import EditableText from './EditableText.vue'
|
||||
|
||||
describe('EditableText', () => {
|
||||
beforeAll(() => {
|
||||
// Create a Vue app instance for PrimeVue
|
||||
const app = createApp({})
|
||||
app.use(PrimeVue)
|
||||
})
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const mountComponent = (props, options = {}) => {
|
||||
return mount(EditableText, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { InputText }
|
||||
},
|
||||
props,
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
it('renders span with modelValue when not editing', () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'Test Text',
|
||||
isEditing: false
|
||||
})
|
||||
expect(wrapper.find('span').text()).toBe('Test Text')
|
||||
expect(wrapper.findComponent(InputText).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders input with modelValue when editing', () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'Test Text',
|
||||
isEditing: true
|
||||
})
|
||||
expect(wrapper.find('span').exists()).toBe(false)
|
||||
expect(wrapper.findComponent(InputText).props()['modelValue']).toBe(
|
||||
'Test Text'
|
||||
)
|
||||
})
|
||||
|
||||
it('emits edit event when input is submitted', async () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'Test Text',
|
||||
isEditing: true
|
||||
})
|
||||
await wrapper.findComponent(InputText).setValue('New Text')
|
||||
await wrapper.findComponent(InputText).trigger('keyup.enter')
|
||||
// Blur event should have been triggered
|
||||
expect(wrapper.findComponent(InputText).element).not.toBe(
|
||||
document.activeElement
|
||||
)
|
||||
})
|
||||
|
||||
it('finishes editing on blur', async () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'Test Text',
|
||||
isEditing: true
|
||||
})
|
||||
await wrapper.findComponent(InputText).trigger('blur')
|
||||
expect(wrapper.emitted('edit')).toBeTruthy()
|
||||
// @ts-expect-error fixme ts strict error
|
||||
expect(wrapper.emitted('edit')[0]).toEqual(['Test Text'])
|
||||
})
|
||||
})
|
||||
140
src/components/common/EditableText.test.ts
Normal file
140
src/components/common/EditableText.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import EditableText from './EditableText.vue'
|
||||
|
||||
describe('EditableText', () => {
|
||||
beforeAll(() => {
|
||||
// Create a Vue app instance for PrimeVue
|
||||
const app = createApp({})
|
||||
app.use(PrimeVue)
|
||||
})
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const mountComponent = (props, options = {}) => {
|
||||
return mount(EditableText, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { InputText }
|
||||
},
|
||||
props,
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
it('renders span with modelValue when not editing', () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'Test Text',
|
||||
isEditing: false
|
||||
})
|
||||
expect(wrapper.find('span').text()).toBe('Test Text')
|
||||
expect(wrapper.findComponent(InputText).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders input with modelValue when editing', () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'Test Text',
|
||||
isEditing: true
|
||||
})
|
||||
expect(wrapper.find('span').exists()).toBe(false)
|
||||
expect(wrapper.findComponent(InputText).props()['modelValue']).toBe(
|
||||
'Test Text'
|
||||
)
|
||||
})
|
||||
|
||||
it('emits edit event when input is submitted', async () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'Test Text',
|
||||
isEditing: true
|
||||
})
|
||||
await wrapper.findComponent(InputText).setValue('New Text')
|
||||
await wrapper.findComponent(InputText).trigger('keyup.enter')
|
||||
// Blur event should have been triggered
|
||||
expect(wrapper.findComponent(InputText).element).not.toBe(
|
||||
document.activeElement
|
||||
)
|
||||
})
|
||||
|
||||
it('finishes editing on blur', async () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'Test Text',
|
||||
isEditing: true
|
||||
})
|
||||
await wrapper.findComponent(InputText).trigger('blur')
|
||||
expect(wrapper.emitted('edit')).toBeTruthy()
|
||||
// @ts-expect-error fixme ts strict error
|
||||
expect(wrapper.emitted('edit')[0]).toEqual(['Test Text'])
|
||||
})
|
||||
|
||||
it('cancels editing on escape key', async () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'Original Text',
|
||||
isEditing: true
|
||||
})
|
||||
|
||||
// Change the input value
|
||||
await wrapper.findComponent(InputText).setValue('Modified Text')
|
||||
|
||||
// Press escape
|
||||
await wrapper.findComponent(InputText).trigger('keyup.escape')
|
||||
|
||||
// Should emit cancel event
|
||||
expect(wrapper.emitted('cancel')).toBeTruthy()
|
||||
|
||||
// Should NOT emit edit event
|
||||
expect(wrapper.emitted('edit')).toBeFalsy()
|
||||
|
||||
// Input value should be reset to original
|
||||
expect(wrapper.findComponent(InputText).props()['modelValue']).toBe(
|
||||
'Original Text'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not save changes when escape is pressed and blur occurs', async () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'Original Text',
|
||||
isEditing: true
|
||||
})
|
||||
|
||||
// Change the input value
|
||||
await wrapper.findComponent(InputText).setValue('Modified Text')
|
||||
|
||||
// Press escape (which triggers blur internally)
|
||||
await wrapper.findComponent(InputText).trigger('keyup.escape')
|
||||
|
||||
// Manually trigger blur to simulate the blur that happens after escape
|
||||
await wrapper.findComponent(InputText).trigger('blur')
|
||||
|
||||
// Should emit cancel but not edit
|
||||
expect(wrapper.emitted('cancel')).toBeTruthy()
|
||||
expect(wrapper.emitted('edit')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('saves changes on enter but not on escape', async () => {
|
||||
// Test Enter key saves changes
|
||||
const enterWrapper = mountComponent({
|
||||
modelValue: 'Original Text',
|
||||
isEditing: true
|
||||
})
|
||||
await enterWrapper.findComponent(InputText).setValue('Saved Text')
|
||||
await enterWrapper.findComponent(InputText).trigger('keyup.enter')
|
||||
// Trigger blur that happens after enter
|
||||
await enterWrapper.findComponent(InputText).trigger('blur')
|
||||
expect(enterWrapper.emitted('edit')).toBeTruthy()
|
||||
// @ts-expect-error fixme ts strict error
|
||||
expect(enterWrapper.emitted('edit')[0]).toEqual(['Saved Text'])
|
||||
|
||||
// Test Escape key cancels changes with a fresh wrapper
|
||||
const escapeWrapper = mountComponent({
|
||||
modelValue: 'Original Text',
|
||||
isEditing: true
|
||||
})
|
||||
await escapeWrapper.findComponent(InputText).setValue('Cancelled Text')
|
||||
await escapeWrapper.findComponent(InputText).trigger('keyup.escape')
|
||||
expect(escapeWrapper.emitted('cancel')).toBeTruthy()
|
||||
expect(escapeWrapper.emitted('edit')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
@@ -7,17 +7,19 @@
|
||||
<InputText
|
||||
v-else
|
||||
ref="inputRef"
|
||||
v-model:modelValue="inputValue"
|
||||
v-model:model-value="inputValue"
|
||||
v-focus
|
||||
type="text"
|
||||
size="small"
|
||||
fluid
|
||||
:pt="{
|
||||
root: {
|
||||
onBlur: finishEditing
|
||||
onBlur: finishEditing,
|
||||
...inputAttrs
|
||||
}
|
||||
}"
|
||||
@keyup.enter="blurInputElement"
|
||||
@keyup.escape="cancelEditing"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
@@ -27,21 +29,41 @@
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
|
||||
const { modelValue, isEditing = false } = defineProps<{
|
||||
const {
|
||||
modelValue,
|
||||
isEditing = false,
|
||||
inputAttrs = {}
|
||||
} = defineProps<{
|
||||
modelValue: string
|
||||
isEditing?: boolean
|
||||
inputAttrs?: Record<string, any>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'edit'])
|
||||
const emit = defineEmits(['update:modelValue', 'edit', 'cancel'])
|
||||
const inputValue = ref<string>(modelValue)
|
||||
const inputRef = ref<InstanceType<typeof InputText> | undefined>()
|
||||
const isCanceling = ref(false)
|
||||
|
||||
const blurInputElement = () => {
|
||||
// @ts-expect-error - $el is an internal property of the InputText component
|
||||
inputRef.value?.$el.blur()
|
||||
}
|
||||
const finishEditing = () => {
|
||||
emit('edit', inputValue.value)
|
||||
// Don't save if we're canceling
|
||||
if (!isCanceling.value) {
|
||||
emit('edit', inputValue.value)
|
||||
}
|
||||
isCanceling.value = false
|
||||
}
|
||||
const cancelEditing = () => {
|
||||
// Set canceling flag to prevent blur from saving
|
||||
isCanceling.value = true
|
||||
// Reset to original value
|
||||
inputValue.value = modelValue
|
||||
// Emit cancel event
|
||||
emit('cancel')
|
||||
// Blur the input to exit edit mode
|
||||
blurInputElement()
|
||||
}
|
||||
watch(
|
||||
() => isEditing,
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount } from 'vue'
|
||||
|
||||
import { CustomExtension, VueExtension } from '@/types/extensionTypes'
|
||||
import type { CustomExtension, VueExtension } from '@/types/extensionTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
extension: VueExtension | CustomExtension
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="flex gap-2 items-center">
|
||||
<div
|
||||
class="preview-box border rounded p-2 w-16 h-16 flex items-center justify-center"
|
||||
:class="{ 'bg-gray-100 dark:bg-gray-800': !modelValue }"
|
||||
:class="{ 'bg-gray-100 dark-theme:bg-gray-800': !modelValue }"
|
||||
>
|
||||
<img
|
||||
v-if="modelValue"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- A generalized form item for rendering in a form. -->
|
||||
<template>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div class="form-label flex flex-grow items-center">
|
||||
<div class="form-label flex grow items-center">
|
||||
<span
|
||||
:id="`${props.id}-label`"
|
||||
class="text-muted"
|
||||
@@ -21,7 +21,7 @@
|
||||
<component
|
||||
:is="markRaw(getFormComponent(props.item))"
|
||||
:id="props.id"
|
||||
v-model:modelValue="formValue"
|
||||
v-model:model-value="formValue"
|
||||
:aria-labelledby="`${props.id}-label`"
|
||||
v-bind="getFormAttrs(props.item)"
|
||||
/>
|
||||
@@ -40,10 +40,11 @@ import BackgroundImageUpload from '@/components/common/BackgroundImageUpload.vue
|
||||
import CustomFormValue from '@/components/common/CustomFormValue.vue'
|
||||
import FormColorPicker from '@/components/common/FormColorPicker.vue'
|
||||
import FormImageUpload from '@/components/common/FormImageUpload.vue'
|
||||
import FormRadioGroup from '@/components/common/FormRadioGroup.vue'
|
||||
import InputKnob from '@/components/common/InputKnob.vue'
|
||||
import InputSlider from '@/components/common/InputSlider.vue'
|
||||
import UrlInput from '@/components/common/UrlInput.vue'
|
||||
import { FormItem } from '@/types/settingTypes'
|
||||
import type { FormItem } from '@/platform/settings/types'
|
||||
|
||||
const formValue = defineModel<any>('formValue')
|
||||
const props = defineProps<{
|
||||
@@ -66,6 +67,7 @@ function getFormAttrs(item: FormItem) {
|
||||
}
|
||||
switch (item.type) {
|
||||
case 'combo':
|
||||
case 'radio':
|
||||
attrs['options'] =
|
||||
typeof item.options === 'function'
|
||||
? // @ts-expect-error: Audit and deprecate usage of legacy options type:
|
||||
@@ -97,6 +99,8 @@ function getFormComponent(item: FormItem): Component {
|
||||
return InputKnob
|
||||
case 'combo':
|
||||
return Select
|
||||
case 'radio':
|
||||
return FormRadioGroup
|
||||
case 'image':
|
||||
return FormImageUpload
|
||||
case 'color':
|
||||
@@ -112,6 +116,8 @@ function getFormComponent(item: FormItem): Component {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.form-input :deep(.input-slider) .p-inputnumber input,
|
||||
.form-input :deep(.input-slider) .slider-part {
|
||||
@apply w-20;
|
||||
|
||||
245
src/components/common/FormRadioGroup.test.ts
Normal file
245
src/components/common/FormRadioGroup.test.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import RadioButton from 'primevue/radiobutton'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import type { SettingOption } from '@/platform/settings/types'
|
||||
|
||||
import FormRadioGroup from './FormRadioGroup.vue'
|
||||
|
||||
describe('FormRadioGroup', () => {
|
||||
beforeAll(() => {
|
||||
const app = createApp({})
|
||||
app.use(PrimeVue)
|
||||
})
|
||||
|
||||
const mountComponent = (props: any, options = {}) => {
|
||||
return mount(FormRadioGroup, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { RadioButton }
|
||||
},
|
||||
props,
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
describe('normalizedOptions computed property', () => {
|
||||
it('handles string array options', () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'option1',
|
||||
options: ['option1', 'option2', 'option3'],
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(3)
|
||||
|
||||
expect(radioButtons[0].props('value')).toBe('option1')
|
||||
expect(radioButtons[1].props('value')).toBe('option2')
|
||||
expect(radioButtons[2].props('value')).toBe('option3')
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
expect(labels[0].text()).toBe('option1')
|
||||
expect(labels[1].text()).toBe('option2')
|
||||
expect(labels[2].text()).toBe('option3')
|
||||
})
|
||||
|
||||
it('handles SettingOption array', () => {
|
||||
const options: SettingOption[] = [
|
||||
{ text: 'Small', value: 'sm' },
|
||||
{ text: 'Medium', value: 'md' },
|
||||
{ text: 'Large', value: 'lg' }
|
||||
]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'md',
|
||||
options,
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(3)
|
||||
|
||||
expect(radioButtons[0].props('value')).toBe('sm')
|
||||
expect(radioButtons[1].props('value')).toBe('md')
|
||||
expect(radioButtons[2].props('value')).toBe('lg')
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
expect(labels[0].text()).toBe('Small')
|
||||
expect(labels[1].text()).toBe('Medium')
|
||||
expect(labels[2].text()).toBe('Large')
|
||||
})
|
||||
|
||||
it('handles SettingOption with undefined value (uses text as value)', () => {
|
||||
const options: SettingOption[] = [
|
||||
{ text: 'Option A', value: undefined },
|
||||
{ text: 'Option B' }
|
||||
]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'Option A',
|
||||
options,
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
|
||||
expect(radioButtons[0].props('value')).toBe('Option A')
|
||||
expect(radioButtons[1].props('value')).toBe('Option B')
|
||||
})
|
||||
|
||||
it('handles custom object with optionLabel and optionValue', () => {
|
||||
const options = [
|
||||
{ name: 'First Option', id: 1 },
|
||||
{ name: 'Second Option', id: 2 },
|
||||
{ name: 'Third Option', id: 3 }
|
||||
]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 2,
|
||||
options,
|
||||
optionLabel: 'name',
|
||||
optionValue: 'id',
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(3)
|
||||
|
||||
expect(radioButtons[0].props('value')).toBe(1)
|
||||
expect(radioButtons[1].props('value')).toBe(2)
|
||||
expect(radioButtons[2].props('value')).toBe(3)
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
expect(labels[0].text()).toBe('First Option')
|
||||
expect(labels[1].text()).toBe('Second Option')
|
||||
expect(labels[2].text()).toBe('Third Option')
|
||||
})
|
||||
|
||||
it('handles mixed array with strings and SettingOptions', () => {
|
||||
const options: (string | SettingOption)[] = [
|
||||
'Simple String',
|
||||
{ text: 'Complex Option', value: 'complex' },
|
||||
'Another String'
|
||||
]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'complex',
|
||||
options,
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(3)
|
||||
|
||||
expect(radioButtons[0].props('value')).toBe('Simple String')
|
||||
expect(radioButtons[1].props('value')).toBe('complex')
|
||||
expect(radioButtons[2].props('value')).toBe('Another String')
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
expect(labels[0].text()).toBe('Simple String')
|
||||
expect(labels[1].text()).toBe('Complex Option')
|
||||
expect(labels[2].text()).toBe('Another String')
|
||||
})
|
||||
|
||||
it('handles empty options array', () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: null,
|
||||
options: [],
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('handles undefined options gracefully', () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: null,
|
||||
options: undefined,
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('handles object with missing properties gracefully', () => {
|
||||
const options = [
|
||||
{ label: 'Option 1', val: 'opt1' },
|
||||
{ text: 'Option 2', value: 'opt2' }
|
||||
]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'opt1',
|
||||
options,
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(2)
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
expect(labels[0].text()).toBe('Unknown')
|
||||
expect(labels[1].text()).toBe('Option 2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('component functionality', () => {
|
||||
it('sets correct input-id and name attributes', () => {
|
||||
const options = ['A', 'B']
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'A',
|
||||
options,
|
||||
id: 'my-radio-group'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
|
||||
expect(radioButtons[0].props('inputId')).toBe('my-radio-group-A')
|
||||
expect(radioButtons[0].props('name')).toBe('my-radio-group')
|
||||
expect(radioButtons[1].props('inputId')).toBe('my-radio-group-B')
|
||||
expect(radioButtons[1].props('name')).toBe('my-radio-group')
|
||||
})
|
||||
|
||||
it('associates labels with radio buttons correctly', () => {
|
||||
const options = ['Yes', 'No']
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'Yes',
|
||||
options,
|
||||
id: 'confirm-radio'
|
||||
})
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
|
||||
expect(labels[0].attributes('for')).toBe('confirm-radio-Yes')
|
||||
expect(labels[1].attributes('for')).toBe('confirm-radio-No')
|
||||
})
|
||||
|
||||
it('sets aria-describedby attribute correctly', () => {
|
||||
const options: SettingOption[] = [
|
||||
{ text: 'Option 1', value: 'opt1' },
|
||||
{ text: 'Option 2', value: 'opt2' }
|
||||
]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'opt1',
|
||||
options,
|
||||
id: 'test-radio'
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
|
||||
expect(radioButtons[0].attributes('aria-describedby')).toBe(
|
||||
'Option 1-label'
|
||||
)
|
||||
expect(radioButtons[1].attributes('aria-describedby')).toBe(
|
||||
'Option 2-label'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
62
src/components/common/FormRadioGroup.vue
Normal file
62
src/components/common/FormRadioGroup.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="flex flex-row gap-4">
|
||||
<div
|
||||
v-for="option in normalizedOptions"
|
||||
:key="option.value"
|
||||
class="flex items-center"
|
||||
>
|
||||
<RadioButton
|
||||
:input-id="`${id}-${option.value}`"
|
||||
:name="id"
|
||||
:value="option.value"
|
||||
:model-value="modelValue"
|
||||
:aria-describedby="`${option.text}-label`"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
<label :for="`${id}-${option.value}`" class="ml-2 cursor-pointer">
|
||||
{{ option.text }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import RadioButton from 'primevue/radiobutton'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SettingOption } from '@/platform/settings/types'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: any
|
||||
options: (SettingOption | string)[]
|
||||
optionLabel?: string
|
||||
optionValue?: string
|
||||
id?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: any]
|
||||
}>()
|
||||
|
||||
const normalizedOptions = computed<SettingOption[]>(() => {
|
||||
if (!props.options) return []
|
||||
|
||||
return props.options.map((option) => {
|
||||
if (typeof option === 'string') {
|
||||
return { text: option, value: option }
|
||||
}
|
||||
|
||||
if ('text' in option) {
|
||||
return {
|
||||
text: option.text,
|
||||
value: option.value ?? option.text
|
||||
}
|
||||
}
|
||||
// Handle optionLabel/optionValue
|
||||
return {
|
||||
text: option[props.optionLabel || 'text'] || 'Unknown',
|
||||
value: option[props.optionValue || 'value']
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -10,7 +10,7 @@
|
||||
class="absolute inset-0"
|
||||
/>
|
||||
<img
|
||||
v-show="isImageLoaded"
|
||||
v-if="cachedSrc"
|
||||
ref="imageRef"
|
||||
:src="cachedSrc"
|
||||
:alt="alt"
|
||||
@@ -24,7 +24,13 @@
|
||||
v-if="hasError"
|
||||
class="absolute inset-0 flex items-center justify-center bg-surface-50 dark-theme:bg-surface-800 text-muted"
|
||||
>
|
||||
<i class="pi pi-image text-2xl" />
|
||||
<img
|
||||
src="/assets/images/default-template.png"
|
||||
:alt="alt"
|
||||
draggable="false"
|
||||
:class="imageClass"
|
||||
:style="imageStyle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -77,8 +83,8 @@ const shouldLoad = computed(() => isIntersecting.value)
|
||||
|
||||
watch(
|
||||
shouldLoad,
|
||||
async (shouldLoad) => {
|
||||
if (shouldLoad && src && !cachedSrc.value && !hasError.value) {
|
||||
async (shouldLoadVal) => {
|
||||
if (shouldLoadVal && src && !cachedSrc.value && !hasError.value) {
|
||||
try {
|
||||
const cachedMedia = await getCachedMedia(src)
|
||||
if (cachedMedia.error) {
|
||||
@@ -93,7 +99,7 @@ watch(
|
||||
console.warn('Failed to load cached media:', error)
|
||||
cachedSrc.value = src
|
||||
}
|
||||
} else if (!shouldLoad) {
|
||||
} else if (!shouldLoadVal) {
|
||||
if (cachedSrc.value?.startsWith('blob:')) {
|
||||
releaseUrl(src)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
import Button from 'primevue/button'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
|
||||
import { PrimeVueSeverity } from '@/types/primeVueTypes'
|
||||
import type { PrimeVueSeverity } from '@/types/primeVueTypes'
|
||||
|
||||
const {
|
||||
disabled,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
class="search-box-input w-full"
|
||||
:model-value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:autofocus="autofocus"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<InputIcon v-if="!modelValue" :class="icon" />
|
||||
@@ -57,7 +58,8 @@ const {
|
||||
icon = 'pi pi-search',
|
||||
debounceTime = 300,
|
||||
filterIcon,
|
||||
filters = []
|
||||
filters = [],
|
||||
autofocus = false
|
||||
} = defineProps<{
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
@@ -65,6 +67,7 @@ const {
|
||||
debounceTime?: number
|
||||
filterIcon?: string
|
||||
filters?: TFilter[]
|
||||
autofocus?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -91,6 +94,8 @@ const clearSearch = () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.p-inputtext) {
|
||||
--p-form-field-padding-x: 0.625rem;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ defineEmits(['remove'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.i-badge) {
|
||||
@apply bg-green-500 text-white;
|
||||
}
|
||||
|
||||
71
src/components/common/StartupDisplay.vue
Normal file
71
src/components/common/StartupDisplay.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div :class="wrapperClass">
|
||||
<div class="grid grid-rows-2 gap-8">
|
||||
<!-- Top container: Logo -->
|
||||
<div class="flex items-end justify-center">
|
||||
<img
|
||||
src="/assets/images/comfy-brand-mark.svg"
|
||||
:alt="t('g.logoAlt')"
|
||||
class="w-60"
|
||||
/>
|
||||
</div>
|
||||
<!-- Bottom container: Progress and text -->
|
||||
<div class="flex flex-col items-center justify-center gap-4">
|
||||
<ProgressBar
|
||||
v-if="!hideProgress"
|
||||
:mode="progressMode"
|
||||
:value="progressPercentage ?? 0"
|
||||
:show-value="false"
|
||||
class="w-90 h-2 mt-8"
|
||||
:pt="{ value: { class: 'bg-brand-yellow' } }"
|
||||
/>
|
||||
<h1 v-if="title" class="font-inter font-bold text-3xl text-neutral-300">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p v-if="statusText" class="text-lg text-neutral-400">
|
||||
{{ statusText }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ProgressBar from 'primevue/progressbar'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/** Props for the StartupDisplay component */
|
||||
interface StartupDisplayProps {
|
||||
/** Progress: 0-100 for determinate, undefined for indeterminate */
|
||||
progressPercentage?: number
|
||||
/** Main title text */
|
||||
title?: string
|
||||
/** Status text shown below the title */
|
||||
statusText?: string
|
||||
/** Hide the progress bar */
|
||||
hideProgress?: boolean
|
||||
/** Use full screen wrapper (default: true) */
|
||||
fullScreen?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
progressPercentage,
|
||||
title,
|
||||
statusText,
|
||||
hideProgress = false,
|
||||
fullScreen = true
|
||||
} = defineProps<StartupDisplayProps>()
|
||||
|
||||
const progressMode = computed(() =>
|
||||
progressPercentage === undefined ? 'indeterminate' : 'determinate'
|
||||
)
|
||||
|
||||
const wrapperClass = computed(() =>
|
||||
fullScreen
|
||||
? 'flex items-center justify-center min-h-screen'
|
||||
: 'flex items-center justify-center'
|
||||
)
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<span v-if="position === 'left'" class="mr-2 shrink-0">{{ text }}</span>
|
||||
<Divider :align="align" :type="type" :layout="layout" class="flex-grow" />
|
||||
<Divider :align="align" :type="type" :layout="layout" class="grow" />
|
||||
<span v-if="position === 'right'" class="ml-2 shrink-0">{{ text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<Tree
|
||||
v-model:expandedKeys="expandedKeys"
|
||||
v-model:selectionKeys="selectionKeys"
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
v-model:selection-keys="selectionKeys"
|
||||
class="tree-explorer py-0 px-2 2xl:px-4"
|
||||
:class="props.class"
|
||||
:value="renderedRoot.children"
|
||||
|
||||
@@ -9,10 +9,8 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
|
||||
import {
|
||||
InjectKeyHandleEditLabelFunction,
|
||||
RenderedTreeExplorerNode
|
||||
} from '@/types/treeExplorerTypes'
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
import { InjectKeyHandleEditLabelFunction } from '@/types/treeExplorerTypes'
|
||||
|
||||
// Create a mock i18n instance
|
||||
const i18n = createI18n({
|
||||
757
src/components/custom/widget/WorkflowTemplateSelectorDialog.vue
Normal file
757
src/components/custom/widget/WorkflowTemplateSelectorDialog.vue
Normal file
@@ -0,0 +1,757 @@
|
||||
<template>
|
||||
<BaseModalLayout
|
||||
:content-title="$t('templateWorkflows.title', 'Workflow Templates')"
|
||||
class="workflow-template-selector-dialog"
|
||||
>
|
||||
<template #leftPanel>
|
||||
<LeftSidePanel v-model="selectedNavItem" :nav-items="navItems">
|
||||
<template #header-icon>
|
||||
<i class="icon-[comfy--template]" />
|
||||
</template>
|
||||
<template #header-title>
|
||||
<span class="text-neutral text-base">{{
|
||||
$t('sideToolbar.templates', 'Templates')
|
||||
}}</span>
|
||||
</template>
|
||||
</LeftSidePanel>
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<SearchBox v-model="searchQuery" class="max-w-[384px]" />
|
||||
</template>
|
||||
|
||||
<template #header-right-area>
|
||||
<div class="flex gap-2">
|
||||
<IconTextButton
|
||||
v-if="filteredCount !== totalCount"
|
||||
type="secondary"
|
||||
:label="$t('templateWorkflows.resetFilters', 'Clear Filters')"
|
||||
@click="resetFilters"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:filter-x />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #contentFilter>
|
||||
<div class="relative px-6 pt-2 pb-4 flex gap-2 flex-wrap">
|
||||
<!-- Model Filter -->
|
||||
<MultiSelect
|
||||
v-model="selectedModelObjects"
|
||||
v-model:search-query="modelSearchText"
|
||||
class="w-[250px]"
|
||||
:label="modelFilterLabel"
|
||||
:options="modelOptions"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:show-clear-button="true"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:cpu />
|
||||
</template>
|
||||
</MultiSelect>
|
||||
|
||||
<!-- Use Case Filter -->
|
||||
<MultiSelect
|
||||
v-model="selectedUseCaseObjects"
|
||||
:label="useCaseFilterLabel"
|
||||
:options="useCaseOptions"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:show-clear-button="true"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:target />
|
||||
</template>
|
||||
</MultiSelect>
|
||||
|
||||
<!-- License Filter -->
|
||||
<MultiSelect
|
||||
v-model="selectedLicenseObjects"
|
||||
:label="licenseFilterLabel"
|
||||
:options="licenseOptions"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:show-clear-button="true"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:file-text />
|
||||
</template>
|
||||
</MultiSelect>
|
||||
|
||||
<!-- Sort Options -->
|
||||
<div class="absolute right-5">
|
||||
<SingleSelect
|
||||
v-model="sortBy"
|
||||
:label="$t('templateWorkflows.sorting', 'Sort by')"
|
||||
:options="sortOptions"
|
||||
class="min-w-[270px]"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:arrow-up-down />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isLoading"
|
||||
class="px-6 pt-4 pb-2 text-2xl font-semibold text-neutral"
|
||||
>
|
||||
<span>
|
||||
{{ pageTitle }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<!-- No Results State (only show when loaded and no results) -->
|
||||
<div
|
||||
v-if="!isLoading && filteredTemplates.length === 0"
|
||||
class="flex flex-col items-center justify-center h-64 text-neutral-500"
|
||||
>
|
||||
<i-lucide:search class="w-12 h-12 mb-4 opacity-50" />
|
||||
<p class="text-lg mb-2">
|
||||
{{ $t('templateWorkflows.noResults', 'No templates found') }}
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
{{
|
||||
$t(
|
||||
'templateWorkflows.noResultsHint',
|
||||
'Try adjusting your search or filters'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<!-- Title -->
|
||||
<span
|
||||
v-if="isLoading"
|
||||
class="inline-block h-8 w-48 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse"
|
||||
></span>
|
||||
|
||||
<!-- Template Cards Grid -->
|
||||
<div
|
||||
:key="templateListKey"
|
||||
:style="gridStyle"
|
||||
data-testid="template-workflows-content"
|
||||
>
|
||||
<!-- Loading Skeletons (show while loading initial data) -->
|
||||
<CardContainer
|
||||
v-for="n in isLoading ? 12 : 0"
|
||||
:key="`initial-skeleton-${n}`"
|
||||
ratio="smallSquare"
|
||||
type="workflow-template-card"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="landscape">
|
||||
<template #default>
|
||||
<div
|
||||
class="w-full h-full bg-neutral-200 dark-theme:bg-neutral-700 animate-pulse"
|
||||
></div>
|
||||
</template>
|
||||
</CardTop>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<CardBottom>
|
||||
<div class="px-4 py-3">
|
||||
<div
|
||||
class="h-6 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse mb-2"
|
||||
></div>
|
||||
<div
|
||||
class="h-4 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse"
|
||||
></div>
|
||||
</div>
|
||||
</CardBottom>
|
||||
</template>
|
||||
</CardContainer>
|
||||
|
||||
<!-- Actual Template Cards -->
|
||||
<CardContainer
|
||||
v-for="template in isLoading ? [] : displayTemplates"
|
||||
:key="template.name"
|
||||
ref="cardRefs"
|
||||
v-memo="[template.name, hoveredTemplate === template.name]"
|
||||
ratio="smallSquare"
|
||||
type="workflow-template-card"
|
||||
:data-testid="`template-workflow-${template.name}`"
|
||||
@mouseenter="hoveredTemplate = template.name"
|
||||
@mouseleave="hoveredTemplate = null"
|
||||
@click="onLoadWorkflow(template)"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="square">
|
||||
<template #default>
|
||||
<!-- Template Thumbnail -->
|
||||
<div
|
||||
class="w-full h-full relative rounded-lg overflow-hidden"
|
||||
>
|
||||
<template v-if="template.mediaType === 'audio'">
|
||||
<AudioThumbnail :src="getBaseThumbnailSrc(template)" />
|
||||
</template>
|
||||
<template
|
||||
v-else-if="template.thumbnailVariant === 'compareSlider'"
|
||||
>
|
||||
<CompareSliderThumbnail
|
||||
:base-image-src="getBaseThumbnailSrc(template)"
|
||||
:overlay-image-src="getOverlayThumbnailSrc(template)"
|
||||
:alt="
|
||||
getTemplateTitle(
|
||||
template,
|
||||
getEffectiveSourceModule(template)
|
||||
)
|
||||
"
|
||||
:is-hovered="hoveredTemplate === template.name"
|
||||
:is-video="
|
||||
template.mediaType === 'video' ||
|
||||
template.mediaSubtype === 'webp'
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template
|
||||
v-else-if="template.thumbnailVariant === 'hoverDissolve'"
|
||||
>
|
||||
<HoverDissolveThumbnail
|
||||
:base-image-src="getBaseThumbnailSrc(template)"
|
||||
:overlay-image-src="getOverlayThumbnailSrc(template)"
|
||||
:alt="
|
||||
getTemplateTitle(
|
||||
template,
|
||||
getEffectiveSourceModule(template)
|
||||
)
|
||||
"
|
||||
:is-hovered="hoveredTemplate === template.name"
|
||||
:is-video="
|
||||
template.mediaType === 'video' ||
|
||||
template.mediaSubtype === 'webp'
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<DefaultThumbnail
|
||||
:src="getBaseThumbnailSrc(template)"
|
||||
:alt="
|
||||
getTemplateTitle(
|
||||
template,
|
||||
getEffectiveSourceModule(template)
|
||||
)
|
||||
"
|
||||
:is-hovered="hoveredTemplate === template.name"
|
||||
:is-video="
|
||||
template.mediaType === 'video' ||
|
||||
template.mediaSubtype === 'webp'
|
||||
"
|
||||
:hover-zoom="
|
||||
template.thumbnailVariant === 'zoomHover' ? 16 : 5
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<ProgressSpinner
|
||||
v-if="loadingTemplate === template.name"
|
||||
class="absolute inset-0 z-10 w-12 h-12 m-auto"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #bottom-right>
|
||||
<template v-if="template.tags && template.tags.length > 0">
|
||||
<SquareChip
|
||||
v-for="tag in template.tags"
|
||||
:key="tag"
|
||||
:label="tag"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</CardTop>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<CardBottom>
|
||||
<div class="flex flex-col gap-2 pt-3">
|
||||
<h3
|
||||
class="line-clamp-1 text-sm m-0"
|
||||
:title="
|
||||
getTemplateTitle(
|
||||
template,
|
||||
getEffectiveSourceModule(template)
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
getTemplateTitle(
|
||||
template,
|
||||
getEffectiveSourceModule(template)
|
||||
)
|
||||
}}
|
||||
</h3>
|
||||
<div class="flex justify-between gap-2">
|
||||
<div class="flex-1">
|
||||
<p
|
||||
class="line-clamp-2 text-sm text-muted m-0"
|
||||
:title="getTemplateDescription(template)"
|
||||
>
|
||||
{{ getTemplateDescription(template) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="template.tutorialUrl"
|
||||
class="flex flex-col-reverse justify-center"
|
||||
>
|
||||
<IconButton
|
||||
v-if="hoveredTemplate === template.name"
|
||||
v-tooltip.bottom="$t('g.seeTutorial')"
|
||||
v-bind="$attrs"
|
||||
type="primary"
|
||||
size="sm"
|
||||
@click.stop="openTutorial(template)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBottom>
|
||||
</template>
|
||||
</CardContainer>
|
||||
|
||||
<!-- Loading More Skeletons -->
|
||||
<CardContainer
|
||||
v-for="n in isLoadingMore ? 6 : 0"
|
||||
:key="`skeleton-${n}`"
|
||||
ratio="smallSquare"
|
||||
type="workflow-template-card"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="square">
|
||||
<template #default>
|
||||
<div
|
||||
class="w-full h-full bg-neutral-200 dark-theme:bg-neutral-700 animate-pulse"
|
||||
></div>
|
||||
</template>
|
||||
</CardTop>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<CardBottom>
|
||||
<div class="px-4 py-3">
|
||||
<div
|
||||
class="h-6 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse mb-2"
|
||||
></div>
|
||||
<div
|
||||
class="h-4 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse"
|
||||
></div>
|
||||
</div>
|
||||
</CardBottom>
|
||||
</template>
|
||||
</CardContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load More Trigger -->
|
||||
<div
|
||||
v-if="!isLoading && hasMoreTemplates"
|
||||
ref="loadTrigger"
|
||||
class="w-full h-4 flex justify-center items-center mt-4"
|
||||
>
|
||||
<div v-if="isLoadingMore" class="text-sm text-muted">
|
||||
{{ $t('templateWorkflows.loadingMore', 'Loading more...') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Summary -->
|
||||
<div
|
||||
v-if="!isLoading"
|
||||
class="mt-6 px-6 text-sm text-neutral-600 dark-theme:text-neutral-400"
|
||||
>
|
||||
{{
|
||||
$t('templateWorkflows.resultsCount', {
|
||||
count: filteredCount,
|
||||
total: totalCount
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
</BaseModalLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, onBeforeUnmount, provide, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SearchBox from '@/components/input/SearchBox.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
|
||||
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
|
||||
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'
|
||||
import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import { useLazyPagination } from '@/composables/useLazyPagination'
|
||||
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
|
||||
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
|
||||
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
import { createGridStyle } from '@/utils/gridUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { onClose } = defineProps<{
|
||||
onClose: () => void
|
||||
}>()
|
||||
|
||||
provide(OnCloseKey, onClose)
|
||||
|
||||
// Workflow templates store and composable
|
||||
const workflowTemplatesStore = useWorkflowTemplatesStore()
|
||||
const {
|
||||
loadTemplates,
|
||||
loadWorkflowTemplate,
|
||||
getTemplateThumbnailUrl,
|
||||
getTemplateTitle,
|
||||
getTemplateDescription
|
||||
} = useTemplateWorkflows()
|
||||
|
||||
const getEffectiveSourceModule = (template: TemplateInfo) =>
|
||||
template.sourceModule || 'default'
|
||||
|
||||
const getBaseThumbnailSrc = (template: TemplateInfo) => {
|
||||
const sm = getEffectiveSourceModule(template)
|
||||
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '1' : '')
|
||||
}
|
||||
|
||||
const getOverlayThumbnailSrc = (template: TemplateInfo) => {
|
||||
const sm = getEffectiveSourceModule(template)
|
||||
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '2' : '')
|
||||
}
|
||||
|
||||
// Open tutorial in new tab
|
||||
const openTutorial = (template: TemplateInfo) => {
|
||||
if (template.tutorialUrl) {
|
||||
window.open(template.tutorialUrl, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
// Get navigation items from the store, with skeleton items while loading
|
||||
const navItems = computed<(NavItemData | NavGroupData)[]>(() => {
|
||||
// Show skeleton navigation items while loading
|
||||
if (isLoading.value) {
|
||||
return [
|
||||
{
|
||||
id: 'skeleton-all',
|
||||
label: 'All Templates',
|
||||
icon: 'icon-[lucide--layout-grid]'
|
||||
},
|
||||
{
|
||||
id: 'skeleton-basics',
|
||||
label: 'Basics',
|
||||
icon: 'icon-[lucide--graduation-cap]'
|
||||
},
|
||||
{
|
||||
title: 'Generation Type',
|
||||
items: [
|
||||
{ id: 'skeleton-1', label: '...', icon: 'icon-[lucide--loader-2]' },
|
||||
{ id: 'skeleton-2', label: '...', icon: 'icon-[lucide--loader-2]' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Closed Source Models',
|
||||
items: [
|
||||
{ id: 'skeleton-3', label: '...', icon: 'icon-[lucide--loader-2]' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
return workflowTemplatesStore.navGroupedTemplates
|
||||
})
|
||||
|
||||
const gridStyle = computed(() => createGridStyle())
|
||||
|
||||
// Get enhanced templates for better filtering
|
||||
const allTemplates = computed(() => {
|
||||
return workflowTemplatesStore.enhancedTemplates
|
||||
})
|
||||
|
||||
// Filter templates based on selected navigation item
|
||||
const navigationFilteredTemplates = computed(() => {
|
||||
if (!selectedNavItem.value) {
|
||||
return allTemplates.value
|
||||
}
|
||||
|
||||
return workflowTemplatesStore.filterTemplatesByCategory(selectedNavItem.value)
|
||||
})
|
||||
|
||||
// Template filtering
|
||||
const {
|
||||
searchQuery,
|
||||
selectedModels,
|
||||
selectedUseCases,
|
||||
selectedLicenses,
|
||||
sortBy,
|
||||
filteredTemplates,
|
||||
availableModels,
|
||||
availableUseCases,
|
||||
availableLicenses,
|
||||
filteredCount,
|
||||
totalCount,
|
||||
resetFilters
|
||||
} = useTemplateFiltering(navigationFilteredTemplates)
|
||||
|
||||
// Convert between string array and object array for MultiSelect component
|
||||
const selectedModelObjects = computed({
|
||||
get() {
|
||||
return selectedModels.value.map((model) => ({ name: model, value: model }))
|
||||
},
|
||||
set(value: { name: string; value: string }[]) {
|
||||
selectedModels.value = value.map((item) => item.value)
|
||||
}
|
||||
})
|
||||
|
||||
const selectedUseCaseObjects = computed({
|
||||
get() {
|
||||
return selectedUseCases.value.map((useCase) => ({
|
||||
name: useCase,
|
||||
value: useCase
|
||||
}))
|
||||
},
|
||||
set(value: { name: string; value: string }[]) {
|
||||
selectedUseCases.value = value.map((item) => item.value)
|
||||
}
|
||||
})
|
||||
|
||||
const selectedLicenseObjects = computed({
|
||||
get() {
|
||||
return selectedLicenses.value.map((license) => ({
|
||||
name: license,
|
||||
value: license
|
||||
}))
|
||||
},
|
||||
set(value: { name: string; value: string }[]) {
|
||||
selectedLicenses.value = value.map((item) => item.value)
|
||||
}
|
||||
})
|
||||
|
||||
// Loading states
|
||||
const loadingTemplate = ref<string | null>(null)
|
||||
const hoveredTemplate = ref<string | null>(null)
|
||||
const cardRefs = ref<HTMLElement[]>([])
|
||||
|
||||
// Force re-render key for templates when sorting changes
|
||||
const templateListKey = ref(0)
|
||||
|
||||
// Navigation
|
||||
const selectedNavItem = ref<string | null>('all')
|
||||
|
||||
// Search text for model filter
|
||||
const modelSearchText = ref<string>('')
|
||||
|
||||
// Filter options
|
||||
const modelOptions = computed(() =>
|
||||
availableModels.value.map((model) => ({
|
||||
name: model,
|
||||
value: model
|
||||
}))
|
||||
)
|
||||
|
||||
const useCaseOptions = computed(() =>
|
||||
availableUseCases.value.map((useCase) => ({
|
||||
name: useCase,
|
||||
value: useCase
|
||||
}))
|
||||
)
|
||||
|
||||
const licenseOptions = computed(() =>
|
||||
availableLicenses.value.map((license) => ({
|
||||
name: license,
|
||||
value: license
|
||||
}))
|
||||
)
|
||||
|
||||
// Filter labels
|
||||
const modelFilterLabel = computed(() => {
|
||||
if (selectedModelObjects.value.length === 0) {
|
||||
return t('templateWorkflows.modelFilter', 'Model Filter')
|
||||
} else if (selectedModelObjects.value.length === 1) {
|
||||
return selectedModelObjects.value[0].name
|
||||
} else {
|
||||
return t('templateWorkflows.modelsSelected', {
|
||||
count: selectedModelObjects.value.length
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const useCaseFilterLabel = computed(() => {
|
||||
if (selectedUseCaseObjects.value.length === 0) {
|
||||
return t('templateWorkflows.useCaseFilter', 'Use Case')
|
||||
} else if (selectedUseCaseObjects.value.length === 1) {
|
||||
return selectedUseCaseObjects.value[0].name
|
||||
} else {
|
||||
return t('templateWorkflows.useCasesSelected', {
|
||||
count: selectedUseCaseObjects.value.length
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const licenseFilterLabel = computed(() => {
|
||||
if (selectedLicenseObjects.value.length === 0) {
|
||||
return t('templateWorkflows.licenseFilter', 'License')
|
||||
} else if (selectedLicenseObjects.value.length === 1) {
|
||||
return selectedLicenseObjects.value[0].name
|
||||
} else {
|
||||
return t('templateWorkflows.licensesSelected', {
|
||||
count: selectedLicenseObjects.value.length
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Sort options
|
||||
const sortOptions = computed(() => [
|
||||
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
|
||||
{
|
||||
name: t('templateWorkflows.sort.default', 'Default'),
|
||||
value: 'default'
|
||||
},
|
||||
{
|
||||
name: t(
|
||||
'templateWorkflows.sort.vramLowToHigh',
|
||||
'VRAM Utilization (Low to High)'
|
||||
),
|
||||
value: 'vram-low-to-high'
|
||||
},
|
||||
{
|
||||
name: t(
|
||||
'templateWorkflows.sort.modelSizeLowToHigh',
|
||||
'Model Size (Low to High)'
|
||||
),
|
||||
value: 'model-size-low-to-high'
|
||||
},
|
||||
{
|
||||
name: t('templateWorkflows.sort.alphabetical', 'Alphabetical (A-Z)'),
|
||||
value: 'alphabetical'
|
||||
}
|
||||
])
|
||||
|
||||
// Lazy pagination setup
|
||||
const loadTrigger = ref<HTMLElement | null>(null)
|
||||
const shouldUsePagination = computed(() => !searchQuery.value.trim())
|
||||
|
||||
const {
|
||||
paginatedItems: paginatedTemplates,
|
||||
isLoading: isLoadingMore,
|
||||
hasMoreItems: hasMoreTemplates,
|
||||
loadNextPage,
|
||||
reset: resetPagination
|
||||
} = useLazyPagination(filteredTemplates, { itemsPerPage: 24 }) // Load 24 items per page
|
||||
|
||||
// Display templates (all when searching, paginated when not)
|
||||
const displayTemplates = computed(() => {
|
||||
return shouldUsePagination.value
|
||||
? paginatedTemplates.value
|
||||
: filteredTemplates.value
|
||||
})
|
||||
|
||||
// Set up intersection observer for lazy loading
|
||||
useIntersectionObserver(loadTrigger, () => {
|
||||
if (
|
||||
shouldUsePagination.value &&
|
||||
hasMoreTemplates.value &&
|
||||
!isLoadingMore.value
|
||||
) {
|
||||
void loadNextPage()
|
||||
}
|
||||
})
|
||||
|
||||
// Reset pagination when filters change
|
||||
watch(
|
||||
[
|
||||
searchQuery,
|
||||
selectedNavItem,
|
||||
sortBy,
|
||||
selectedModels,
|
||||
selectedUseCases,
|
||||
selectedLicenses
|
||||
],
|
||||
() => {
|
||||
resetPagination()
|
||||
// Clear loading state and force re-render of template list
|
||||
loadingTemplate.value = null
|
||||
templateListKey.value++
|
||||
}
|
||||
)
|
||||
|
||||
// Methods
|
||||
const onLoadWorkflow = async (template: any) => {
|
||||
loadingTemplate.value = template.name
|
||||
try {
|
||||
await loadWorkflowTemplate(
|
||||
template.name,
|
||||
getEffectiveSourceModule(template)
|
||||
)
|
||||
onClose()
|
||||
} finally {
|
||||
loadingTemplate.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
const navItem = navItems.value.find((item) =>
|
||||
'id' in item
|
||||
? item.id === selectedNavItem.value
|
||||
: item.items?.some((sub) => sub.id === selectedNavItem.value)
|
||||
)
|
||||
|
||||
if (!navItem) {
|
||||
return t('templateWorkflows.allTemplates', 'All Templates')
|
||||
}
|
||||
|
||||
return 'id' in navItem
|
||||
? navItem.label
|
||||
: navItem.items?.find((i) => i.id === selectedNavItem.value)?.label ||
|
||||
t('templateWorkflows.allTemplates', 'All Templates')
|
||||
})
|
||||
|
||||
// Initialize templates loading with useAsyncState
|
||||
const { isLoading } = useAsyncState(
|
||||
async () => {
|
||||
// Run both operations in parallel for better performance
|
||||
await Promise.all([
|
||||
loadTemplates(),
|
||||
workflowTemplatesStore.loadWorkflowTemplates()
|
||||
])
|
||||
return true
|
||||
},
|
||||
false, // initial state
|
||||
{
|
||||
immediate: true // Start loading immediately
|
||||
}
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cardRefs.value = [] // Release DOM refs
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Ensure the workflow template selector dialog fits within provided dialog */
|
||||
.workflow-template-selector-dialog.base-widget-layout {
|
||||
width: 100% !important;
|
||||
max-width: 1400px;
|
||||
height: 100% !important;
|
||||
aspect-ratio: auto !important;
|
||||
}
|
||||
|
||||
@media (min-width: 1600px) {
|
||||
.workflow-template-selector-dialog.base-widget-layout {
|
||||
max-width: 1600px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -29,7 +29,7 @@
|
||||
/>
|
||||
|
||||
<template v-if="item.footerComponent" #footer>
|
||||
<component :is="item.footerComponent" />
|
||||
<component :is="item.footerComponent" v-bind="item.footerProps" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -43,6 +43,8 @@ const dialogStore = useDialogStore()
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.global-dialog .p-dialog-header {
|
||||
@apply p-2 2xl:p-[var(--p-dialog-header-padding)];
|
||||
@apply pb-0;
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- Prompt user that the workflow contains API nodes that needs login to run -->
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 max-w-96 h-110 p-2">
|
||||
<div class="text-2xl font-medium mb-2">
|
||||
|
||||
@@ -16,6 +16,21 @@
|
||||
{{ hint }}
|
||||
</Message>
|
||||
<div class="flex gap-4 justify-end">
|
||||
<div
|
||||
v-if="type === 'overwriteBlueprint'"
|
||||
class="flex gap-4 justify-start"
|
||||
>
|
||||
<Checkbox
|
||||
v-model="doNotAskAgain"
|
||||
class="flex gap-4 justify-start"
|
||||
input-id="doNotAskAgain"
|
||||
binary
|
||||
/>
|
||||
<label for="doNotAskAgain" severity="secondary">{{
|
||||
t('missingModelsDialog.doNotAskAgain')
|
||||
}}</label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
:label="$t('g.cancel')"
|
||||
icon="pi pi-undo"
|
||||
@@ -38,7 +53,7 @@
|
||||
@click="onConfirm"
|
||||
/>
|
||||
<Button
|
||||
v-else-if="type === 'overwrite'"
|
||||
v-else-if="type === 'overwrite' || type === 'overwriteBlueprint'"
|
||||
:label="$t('g.overwrite')"
|
||||
severity="warn"
|
||||
icon="pi pi-save"
|
||||
@@ -74,8 +89,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import Message from 'primevue/message'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { ConfirmationDialogType } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
@@ -87,14 +106,20 @@ const props = defineProps<{
|
||||
hint?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const onCancel = () => useDialogStore().closeDialog()
|
||||
|
||||
const doNotAskAgain = ref(false)
|
||||
|
||||
const onDeny = () => {
|
||||
props.onConfirm(false)
|
||||
useDialogStore().closeDialog()
|
||||
}
|
||||
|
||||
const onConfirm = () => {
|
||||
if (props.type === 'overwriteBlueprint' && doNotAskAgain.value)
|
||||
void useSettingStore().set('Comfy.Workflow.WarnBlueprintOverwrite', false)
|
||||
props.onConfirm(true)
|
||||
useDialogStore().closeDialog()
|
||||
}
|
||||
|
||||
@@ -21,16 +21,9 @@
|
||||
@click="showReport"
|
||||
/>
|
||||
<Button
|
||||
v-show="!sendReportOpen"
|
||||
text
|
||||
:label="$t('issueReport.helpFix')"
|
||||
@click="showSendReport"
|
||||
/>
|
||||
<Button
|
||||
v-if="authStore.currentUser"
|
||||
v-show="!reportOpen"
|
||||
text
|
||||
:label="$t('issueReport.contactSupportTitle')"
|
||||
:label="$t('issueReport.helpFix')"
|
||||
@click="showContactSupport"
|
||||
/>
|
||||
</div>
|
||||
@@ -41,16 +34,6 @@
|
||||
</ScrollPanel>
|
||||
<Divider />
|
||||
</template>
|
||||
<ReportIssuePanel
|
||||
v-if="sendReportOpen"
|
||||
:title="$t('issueReport.submitErrorReport')"
|
||||
:error-type="error.reportType ?? 'unknownError'"
|
||||
:extra-fields="[stackTraceField]"
|
||||
:tags="{
|
||||
exceptionMessage: error.exceptionMessage,
|
||||
nodeType: error.nodeType ?? 'UNKNOWN'
|
||||
}"
|
||||
/>
|
||||
<div class="flex gap-4 justify-end">
|
||||
<FindIssueButton
|
||||
:error-message="error.exceptionMessage"
|
||||
@@ -81,18 +64,12 @@ import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import type { ReportField } from '@/types/issueReportTypes'
|
||||
import {
|
||||
type ErrorReportData,
|
||||
generateErrorReport
|
||||
} from '@/utils/errorReportUtil'
|
||||
|
||||
import ReportIssuePanel from './error/ReportIssuePanel.vue'
|
||||
|
||||
const authStore = useFirebaseAuthStore()
|
||||
|
||||
const { error } = defineProps<{
|
||||
error: Omit<ErrorReportData, 'workflow' | 'systemStats' | 'serverLogs'> & {
|
||||
/**
|
||||
@@ -114,10 +91,6 @@ const reportOpen = ref(false)
|
||||
const showReport = () => {
|
||||
reportOpen.value = true
|
||||
}
|
||||
const sendReportOpen = ref(false)
|
||||
const showSendReport = () => {
|
||||
sendReportOpen.value = true
|
||||
}
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
@@ -126,22 +99,13 @@ const title = computed<string>(
|
||||
() => error.nodeType ?? error.exceptionType ?? t('errorDialog.defaultTitle')
|
||||
)
|
||||
|
||||
const stackTraceField = computed<ReportField>(() => {
|
||||
return {
|
||||
label: t('issueReport.stackTrace'),
|
||||
value: 'StackTrace',
|
||||
optIn: true,
|
||||
getData: () => error.traceback
|
||||
}
|
||||
})
|
||||
|
||||
const showContactSupport = async () => {
|
||||
await useCommandStore().execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!systemStatsStore.systemStats) {
|
||||
await systemStatsStore.fetchSystemStats()
|
||||
await systemStatsStore.refetchSystemStats()
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<template>
|
||||
<div class="p-2 h-full" aria-labelledby="issue-report-title">
|
||||
<Panel
|
||||
:pt="{
|
||||
root: 'border-none',
|
||||
content: 'p-0'
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<header class="flex flex-col items-center w-full">
|
||||
<h2 id="issue-report-title" class="text-4xl">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<span v-if="subtitle" class="text-muted mt-0">{{ subtitle }}</span>
|
||||
</header>
|
||||
</template>
|
||||
<ReportIssuePanel v-bind="panelProps" :pt="{ root: 'border-none' }" />
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Panel from 'primevue/panel'
|
||||
|
||||
import ReportIssuePanel from '@/components/dialog/content/error/ReportIssuePanel.vue'
|
||||
import type { IssueReportPanelProps } from '@/types/issueReportTypes'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
subtitle?: string
|
||||
panelProps: IssueReportPanelProps
|
||||
}>()
|
||||
</script>
|
||||
@@ -2,8 +2,8 @@
|
||||
<NoResultsPlaceholder
|
||||
class="pb-0"
|
||||
icon="pi pi-exclamation-circle"
|
||||
title="Some Nodes Are Missing"
|
||||
message="When loading the graph, the following node types were not found"
|
||||
:title="$t('loadWorkflowWarning.missingNodesTitle')"
|
||||
:message="$t('loadWorkflowWarning.missingNodesDescription')"
|
||||
/>
|
||||
<MissingCoreNodesMessage :missing-core-nodes="missingCoreNodes" />
|
||||
<ListBox
|
||||
@@ -31,12 +31,20 @@
|
||||
</div>
|
||||
</template>
|
||||
</ListBox>
|
||||
<div v-if="isManagerInstalled" class="flex justify-end py-3">
|
||||
<div v-if="showManagerButtons" class="flex justify-end py-3">
|
||||
<PackInstallButton
|
||||
:disabled="isLoading || !!error || missingNodePacks.length === 0"
|
||||
v-if="showInstallAllButton"
|
||||
size="md"
|
||||
:disabled="
|
||||
isLoading || !!error || missingNodePacks.length === 0 || isInstalling
|
||||
"
|
||||
:is-loading="isLoading"
|
||||
:node-packs="missingNodePacks"
|
||||
variant="black"
|
||||
:label="$t('manager.installAllMissingNodes')"
|
||||
:label="
|
||||
isLoading
|
||||
? $t('manager.gettingInfo')
|
||||
: $t('manager.installAllMissingNodes')
|
||||
"
|
||||
/>
|
||||
<Button label="Open Manager" size="small" outlined @click="openManager" />
|
||||
</div>
|
||||
@@ -45,35 +53,36 @@
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import ListBox from 'primevue/listbox'
|
||||
import { computed } from 'vue'
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
|
||||
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
|
||||
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import { ManagerTab } from '@/types/comfyManagerTypes'
|
||||
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
missingNodeTypes: MissingNodeType[]
|
||||
}>()
|
||||
|
||||
const aboutPanelStore = useAboutPanelStore()
|
||||
|
||||
// Get missing node packs from workflow with loading and error states
|
||||
const { missingNodePacks, isLoading, error, missingCoreNodes } =
|
||||
useMissingNodes()
|
||||
|
||||
// Determines if ComfyUI-Manager is installed by checking for its badge in the about panel
|
||||
// This allows us to conditionally show the Manager button only when the extension is available
|
||||
// TODO: Remove this check when Manager functionality is fully migrated into core
|
||||
const isManagerInstalled = computed(() => {
|
||||
return aboutPanelStore.badges.some(
|
||||
(badge) =>
|
||||
badge.label.includes('ComfyUI-Manager') ||
|
||||
badge.url.includes('ComfyUI-Manager')
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const managerState = useManagerState()
|
||||
|
||||
// Check if any of the missing packs are currently being installed
|
||||
const isInstalling = computed(() => {
|
||||
if (!missingNodePacks.value?.length) return false
|
||||
return missingNodePacks.value.some((pack) =>
|
||||
comfyManagerStore.isPackInstalling(pack.id)
|
||||
)
|
||||
})
|
||||
|
||||
@@ -98,11 +107,51 @@ const uniqueNodes = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const openManager = () => {
|
||||
useDialogService().showManagerDialog({
|
||||
initialTab: ManagerTab.Missing
|
||||
// Show manager buttons unless manager is disabled
|
||||
const showManagerButtons = computed(() => {
|
||||
return managerState.shouldShowManagerButtons.value
|
||||
})
|
||||
|
||||
// Only show Install All button for NEW_UI (new manager with v4 support)
|
||||
const showInstallAllButton = computed(() => {
|
||||
return managerState.shouldShowInstallButton.value
|
||||
})
|
||||
|
||||
const openManager = async () => {
|
||||
await managerState.openManager({
|
||||
initialTab: ManagerTab.Missing,
|
||||
showToastOnLegacyError: true
|
||||
})
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
// Computed to check if all missing nodes have been installed
|
||||
const allMissingNodesInstalled = computed(() => {
|
||||
return (
|
||||
!isLoading.value &&
|
||||
!isInstalling.value &&
|
||||
missingNodePacks.value?.length === 0
|
||||
)
|
||||
})
|
||||
// Watch for completion and close dialog
|
||||
watch(allMissingNodesInstalled, async (allInstalled) => {
|
||||
if (allInstalled && showInstallAllButton.value) {
|
||||
// Use nextTick to ensure state updates are complete
|
||||
await nextTick()
|
||||
|
||||
dialogStore.closeDialog({ key: 'global-load-workflow-warning' })
|
||||
|
||||
// Show success toast
|
||||
useToastStore().add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('manager.allMissingNodesInstalled'),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -42,13 +42,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { whenever } from '@vueuse/core'
|
||||
import Message from 'primevue/message'
|
||||
import { computed, ref } from 'vue'
|
||||
import { compare } from 'semver'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import { compareVersions } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
missingCoreNodes: Record<string, LGraphNode[]>
|
||||
@@ -60,25 +59,16 @@ const hasMissingCoreNodes = computed(() => {
|
||||
return Object.keys(props.missingCoreNodes).length > 0
|
||||
})
|
||||
|
||||
const currentComfyUIVersion = ref<string | null>(null)
|
||||
whenever(
|
||||
hasMissingCoreNodes,
|
||||
async () => {
|
||||
if (!systemStatsStore.systemStats) {
|
||||
await systemStatsStore.fetchSystemStats()
|
||||
}
|
||||
currentComfyUIVersion.value =
|
||||
systemStatsStore.systemStats?.system?.comfyui_version ?? null
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
)
|
||||
// Use computed for reactive version tracking
|
||||
const currentComfyUIVersion = computed<string | null>(() => {
|
||||
if (!hasMissingCoreNodes.value) return null
|
||||
return systemStatsStore.systemStats?.system?.comfyui_version ?? null
|
||||
})
|
||||
|
||||
const sortedMissingCoreNodes = computed(() => {
|
||||
return Object.entries(props.missingCoreNodes).sort(([a], [b]) => {
|
||||
// Sort by version in descending order (newest first)
|
||||
return compareVersions(b, a) // Reversed for descending order
|
||||
return compare(b, a) // Reversed for descending order
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import ElectronFileDownload from '@/components/common/ElectronFileDownload.vue'
|
||||
import FileDownload from '@/components/common/FileDownload.vue'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
|
||||
// TODO: Read this from server internal API rather than hardcoding here
|
||||
|
||||
@@ -152,7 +152,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
|
||||
import { SignInData, SignUpData } from '@/schemas/signInSchema'
|
||||
import type { SignInData, SignUpData } from '@/schemas/signInSchema'
|
||||
import { translateAuthError } from '@/utils/authErrorTranslation'
|
||||
import { isInChina } from '@/utils/networkUtil'
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Form, FormSubmitEvent } from '@primevue/forms'
|
||||
import type { FormSubmitEvent } from '@primevue/forms'
|
||||
import { Form } from '@primevue/forms'
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import Button from 'primevue/button'
|
||||
import { ref } from 'vue'
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
import { Form } from '@primevue/forms'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMesages from '@/locales/en/main.json'
|
||||
import { IssueReportPanelProps } from '@/types/issueReportTypes'
|
||||
|
||||
import ReportIssuePanel from './ReportIssuePanel.vue'
|
||||
|
||||
const DEFAULT_FIELDS = ['Workflow', 'Logs', 'Settings', 'SystemStats']
|
||||
const CUSTOM_FIELDS = [
|
||||
{
|
||||
label: 'Custom Field',
|
||||
value: 'CustomField',
|
||||
optIn: true,
|
||||
getData: () => 'mock data'
|
||||
}
|
||||
]
|
||||
|
||||
async function getSubmittedContext() {
|
||||
const { captureMessage } = (await import('@sentry/core')) as any
|
||||
return captureMessage.mock.calls[0][1]
|
||||
}
|
||||
|
||||
async function submitForm(wrapper: any) {
|
||||
await wrapper.findComponent(Form).trigger('submit')
|
||||
return getSubmittedContext()
|
||||
}
|
||||
|
||||
async function findAndUpdateCheckbox(
|
||||
wrapper: any,
|
||||
value: string,
|
||||
checked = true
|
||||
) {
|
||||
const checkbox = wrapper
|
||||
.findAllComponents(Checkbox)
|
||||
.find((c: any) => c.props('value') === value)
|
||||
if (!checkbox) throw new Error(`Checkbox with value "${value}" not found`)
|
||||
|
||||
await checkbox.vm.$emit('update:modelValue', checked)
|
||||
return checkbox
|
||||
}
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: enMesages
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: vi.fn(() => ({
|
||||
add: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
getLogs: vi.fn().mockResolvedValue('mock logs'),
|
||||
getSystemStats: vi.fn().mockResolvedValue('mock stats'),
|
||||
getSettings: vi.fn().mockResolvedValue('mock settings'),
|
||||
fetchApi: vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue({}),
|
||||
text: vi.fn().mockResolvedValue('')
|
||||
}),
|
||||
apiURL: vi.fn().mockReturnValue('https://test.com')
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
graph: {
|
||||
asSerialisable: vi.fn().mockReturnValue({})
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@sentry/core', () => ({
|
||||
captureMessage: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@primevue/forms', () => ({
|
||||
Form: {
|
||||
name: 'Form',
|
||||
template:
|
||||
'<form @submit.prevent="onSubmit"><slot :values="formValues" /></form>',
|
||||
props: ['resolver'],
|
||||
data() {
|
||||
return {
|
||||
formValues: {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onSubmit() {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
this.$emit('submit', {
|
||||
valid: true,
|
||||
// @ts-expect-error fixme ts strict error
|
||||
values: this.formValues
|
||||
})
|
||||
},
|
||||
updateFieldValue(name: string, value: any) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
this.formValues[name] = value
|
||||
}
|
||||
}
|
||||
},
|
||||
FormField: {
|
||||
name: 'FormField',
|
||||
template:
|
||||
'<div><slot :modelValue="modelValue" @update:modelValue="updateValue" /></div>',
|
||||
props: ['name'],
|
||||
data() {
|
||||
return {
|
||||
modelValue: ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
updateValue(value) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
this.modelValue = value
|
||||
// @ts-expect-error fixme ts strict error
|
||||
let parent = this.$parent
|
||||
while (parent && parent.$options.name !== 'Form') {
|
||||
parent = parent.$parent
|
||||
}
|
||||
if (parent) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
parent.updateFieldValue(this.name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: () => ({
|
||||
currentUser: {
|
||||
email: 'test@example.com'
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
describe('ReportIssuePanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
})
|
||||
|
||||
const mountComponent = (props: IssueReportPanelProps, options = {}): any => {
|
||||
return mount(ReportIssuePanel, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n, createPinia()],
|
||||
directives: { tooltip: Tooltip }
|
||||
},
|
||||
props,
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
it('renders the panel with all required components', () => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
expect(wrapper.find('.p-panel').exists()).toBe(true)
|
||||
expect(wrapper.findAllComponents(Checkbox).length).toBe(6)
|
||||
expect(wrapper.findComponent(InputText).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(Textarea).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('updates selection when checkboxes are selected', async () => {
|
||||
const wrapper = mountComponent({
|
||||
errorType: 'Test Error'
|
||||
})
|
||||
|
||||
const checkboxes = wrapper.findAllComponents(Checkbox)
|
||||
|
||||
for (const field of DEFAULT_FIELDS) {
|
||||
const checkbox = checkboxes.find(
|
||||
// @ts-expect-error fixme ts strict error
|
||||
(checkbox) => checkbox.props('value') === field
|
||||
)
|
||||
expect(checkbox).toBeDefined()
|
||||
|
||||
await checkbox?.vm.$emit('update:modelValue', [field])
|
||||
expect(wrapper.vm.selection).toContain(field)
|
||||
}
|
||||
})
|
||||
|
||||
it('updates contactInfo when input is changed', async () => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
const input = wrapper.findComponent(InputText)
|
||||
|
||||
await input.vm.$emit('update:modelValue', 'test@example.com')
|
||||
const context = await submitForm(wrapper)
|
||||
expect(context.user.email).toBe('test@example.com')
|
||||
})
|
||||
|
||||
it('updates additional details when textarea is changed', async () => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
const textarea = wrapper.findComponent(Textarea)
|
||||
|
||||
await textarea.vm.$emit('update:modelValue', 'This is a test detail.')
|
||||
const context = await submitForm(wrapper)
|
||||
expect(context.extra.details).toBe('This is a test detail.')
|
||||
})
|
||||
|
||||
it('set contact preferences back to false if email is removed', async () => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
const input = wrapper.findComponent(InputText)
|
||||
|
||||
// Set a valid email, enabling the contact preferences to be changed
|
||||
await input.vm.$emit('update:modelValue', 'name@example.com')
|
||||
|
||||
// Enable both contact preferences
|
||||
for (const pref of ['followUp', 'notifyOnResolution']) {
|
||||
await findAndUpdateCheckbox(wrapper, pref)
|
||||
}
|
||||
|
||||
// Change the email back to empty
|
||||
await input.vm.$emit('update:modelValue', '')
|
||||
const context = await submitForm(wrapper)
|
||||
|
||||
// Check that the contact preferences are back to false automatically
|
||||
expect(context.tags.followUp).toBe(false)
|
||||
expect(context.tags.notifyOnResolution).toBe(false)
|
||||
})
|
||||
|
||||
it('renders with overridden default fields', () => {
|
||||
const wrapper = mountComponent({
|
||||
errorType: 'Test Error',
|
||||
defaultFields: ['Settings']
|
||||
})
|
||||
|
||||
// Filter out the contact preferences checkboxes
|
||||
const fieldCheckboxes = wrapper.findAllComponents(Checkbox).filter(
|
||||
// @ts-expect-error fixme ts strict error
|
||||
(checkbox) =>
|
||||
!['followUp', 'notifyOnResolution'].includes(checkbox.props('value'))
|
||||
)
|
||||
expect(fieldCheckboxes.length).toBe(1)
|
||||
expect(fieldCheckboxes.at(0)?.props('value')).toBe('Settings')
|
||||
})
|
||||
|
||||
it('renders additional fields when extraFields prop is provided', () => {
|
||||
const wrapper = mountComponent({
|
||||
errorType: 'Test Error',
|
||||
extraFields: CUSTOM_FIELDS
|
||||
})
|
||||
const customCheckbox = wrapper
|
||||
.findAllComponents(Checkbox)
|
||||
// @ts-expect-error fixme ts strict error
|
||||
.find((checkbox) => checkbox.props('value') === 'CustomField')
|
||||
expect(customCheckbox).toBeDefined()
|
||||
})
|
||||
|
||||
it('allows custom fields to be selected', async () => {
|
||||
const wrapper = mountComponent({
|
||||
errorType: 'Test Error',
|
||||
extraFields: CUSTOM_FIELDS
|
||||
})
|
||||
|
||||
await findAndUpdateCheckbox(wrapper, 'CustomField')
|
||||
const context = await submitForm(wrapper)
|
||||
expect(context.extra.CustomField).toBe('mock data')
|
||||
})
|
||||
|
||||
it('does not submit unchecked fields', async () => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
const textarea = wrapper.findComponent(Textarea)
|
||||
|
||||
// Set details but don't check any field checkboxes
|
||||
await textarea.vm.$emit(
|
||||
'update:modelValue',
|
||||
'Report with only text but no fields selected'
|
||||
)
|
||||
const context = await submitForm(wrapper)
|
||||
|
||||
// Verify none of the optional fields were included
|
||||
for (const field of DEFAULT_FIELDS) {
|
||||
expect(context.extra[field]).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
checkbox: 'Logs',
|
||||
apiMethod: 'getLogs',
|
||||
expectedKey: 'Logs',
|
||||
mockValue: 'mock logs'
|
||||
},
|
||||
{
|
||||
checkbox: 'SystemStats',
|
||||
apiMethod: 'getSystemStats',
|
||||
expectedKey: 'SystemStats',
|
||||
mockValue: 'mock stats'
|
||||
},
|
||||
{
|
||||
checkbox: 'Settings',
|
||||
apiMethod: 'getSettings',
|
||||
expectedKey: 'Settings',
|
||||
mockValue: 'mock settings'
|
||||
}
|
||||
])(
|
||||
'submits $checkbox data when checkbox is selected',
|
||||
async ({ checkbox, apiMethod, expectedKey, mockValue }) => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
|
||||
const { api } = (await import('@/scripts/api')) as any
|
||||
vi.spyOn(api, apiMethod).mockResolvedValue(mockValue)
|
||||
|
||||
await findAndUpdateCheckbox(wrapper, checkbox)
|
||||
const context = await submitForm(wrapper)
|
||||
expect(context.extra[expectedKey]).toBe(mockValue)
|
||||
}
|
||||
)
|
||||
|
||||
it('submits workflow when the Workflow checkbox is selected', async () => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
|
||||
const { app } = (await import('@/scripts/app')) as any
|
||||
const mockWorkflow = { nodes: [], edges: [] }
|
||||
vi.spyOn(app.graph, 'asSerialisable').mockReturnValue(mockWorkflow)
|
||||
|
||||
await findAndUpdateCheckbox(wrapper, 'Workflow')
|
||||
const context = await submitForm(wrapper)
|
||||
|
||||
expect(context.extra.Workflow).toEqual(mockWorkflow)
|
||||
})
|
||||
})
|
||||
@@ -1,348 +0,0 @@
|
||||
<template>
|
||||
<Form
|
||||
v-slot="$form"
|
||||
:resolver="zodResolver(issueReportSchema)"
|
||||
@submit="submit"
|
||||
>
|
||||
<Panel :pt="$attrs.pt as any">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-bold">{{ title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-4">
|
||||
<Button
|
||||
v-tooltip="!submitted ? $t('g.reportIssueTooltip') : undefined"
|
||||
:label="submitted ? $t('g.reportSent') : $t('g.reportIssue')"
|
||||
:severity="submitted ? 'secondary' : 'primary'"
|
||||
:icon="submitted ? 'pi pi-check' : 'pi pi-send'"
|
||||
:disabled="submitted"
|
||||
type="submit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div class="p-4 mt-2 border border-round surface-border shadow-1">
|
||||
<div class="flex flex-col gap-6">
|
||||
<FormField
|
||||
v-slot="$field"
|
||||
name="contactInfo"
|
||||
:initial-value="authStore.currentUser?.email"
|
||||
>
|
||||
<div class="self-stretch inline-flex justify-start items-center">
|
||||
<label for="contactInfo" class="pb-2 pt-0 opacity-80">{{
|
||||
$t('issueReport.email')
|
||||
}}</label>
|
||||
</div>
|
||||
<InputText
|
||||
id="contactInfo"
|
||||
v-bind="$field"
|
||||
class="w-full"
|
||||
:placeholder="$t('issueReport.provideEmail')"
|
||||
/>
|
||||
<Message
|
||||
v-if="$field?.error && $field.touched && $field.value !== ''"
|
||||
severity="error"
|
||||
size="small"
|
||||
variant="simple"
|
||||
>
|
||||
{{ t('issueReport.validation.invalidEmail') }}
|
||||
</Message>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="$field" name="helpType">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
class="self-stretch inline-flex justify-start items-center gap-2.5"
|
||||
>
|
||||
<label for="helpType" class="pb-2 pt-0 opacity-80">{{
|
||||
$t('issueReport.whatDoYouNeedHelpWith')
|
||||
}}</label>
|
||||
</div>
|
||||
<Dropdown
|
||||
v-bind="$field"
|
||||
v-model="$field.value"
|
||||
:options="helpTypes"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:placeholder="$t('issueReport.selectIssue')"
|
||||
class="w-full"
|
||||
/>
|
||||
<Message
|
||||
v-if="$field?.error"
|
||||
severity="error"
|
||||
size="small"
|
||||
variant="simple"
|
||||
>
|
||||
{{ t('issueReport.validation.selectIssueType') }}
|
||||
</Message>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
class="self-stretch inline-flex justify-start items-center gap-2.5"
|
||||
>
|
||||
<span class="pb-2 pt-0 opacity-80">{{
|
||||
$t('issueReport.whatCanWeInclude')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex flex-row gap-3">
|
||||
<div v-for="field in fields" :key="field.value">
|
||||
<FormField
|
||||
v-if="field.optIn"
|
||||
v-slot="$field"
|
||||
:name="field.value"
|
||||
class="flex space-x-1"
|
||||
>
|
||||
<Checkbox
|
||||
v-bind="$field"
|
||||
v-model="selection"
|
||||
:input-id="field.value"
|
||||
:value="field.value"
|
||||
/>
|
||||
<label :for="field.value">{{ field.label }}</label>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<FormField v-slot="$field" name="details">
|
||||
<div
|
||||
class="self-stretch inline-flex justify-start items-center gap-2.5"
|
||||
>
|
||||
<label for="details" class="pb-2 pt-0 opacity-80">{{
|
||||
$t('issueReport.describeTheProblem')
|
||||
}}</label>
|
||||
</div>
|
||||
<Textarea
|
||||
v-bind="$field"
|
||||
id="details"
|
||||
class="w-full"
|
||||
rows="5"
|
||||
:placeholder="$t('issueReport.provideAdditionalDetails')"
|
||||
:aria-label="$t('issueReport.provideAdditionalDetails')"
|
||||
/>
|
||||
<Message
|
||||
v-if="$field?.error && $field.touched"
|
||||
severity="error"
|
||||
size="small"
|
||||
variant="simple"
|
||||
>
|
||||
{{
|
||||
$field.value
|
||||
? t('issueReport.validation.maxLength')
|
||||
: t('issueReport.validation.descriptionRequired')
|
||||
}}
|
||||
</Message>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 mt-2">
|
||||
<div v-for="checkbox in contactCheckboxes" :key="checkbox.value">
|
||||
<FormField
|
||||
v-slot="$field"
|
||||
:name="checkbox.value"
|
||||
class="flex space-x-1"
|
||||
>
|
||||
<Checkbox
|
||||
v-bind="$field"
|
||||
v-model="contactPrefs"
|
||||
:input-id="checkbox.value"
|
||||
:value="checkbox.value"
|
||||
:disabled="
|
||||
$form.contactInfo?.error || !$form.contactInfo?.value
|
||||
"
|
||||
/>
|
||||
<label :for="checkbox.value">{{ checkbox.label }}</label>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</Form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Form, FormField, type FormSubmitEvent } from '@primevue/forms'
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import type { CaptureContext, User } from '@sentry/core'
|
||||
import { captureMessage } from '@sentry/core'
|
||||
import _ from 'es-toolkit/compat'
|
||||
import { cloneDeep } from 'es-toolkit/compat'
|
||||
import Button from 'primevue/button'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import Panel from 'primevue/panel'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
type IssueReportFormData,
|
||||
issueReportSchema
|
||||
} from '@/schemas/issueReportSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import type {
|
||||
DefaultField,
|
||||
IssueReportPanelProps,
|
||||
ReportField
|
||||
} from '@/types/issueReportTypes'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { generateUUID } from '@/utils/formatUtil'
|
||||
|
||||
const DEFAULT_ISSUE_NAME = 'User reported issue'
|
||||
|
||||
const props = defineProps<IssueReportPanelProps>()
|
||||
const { defaultFields = ['Workflow', 'Logs', 'SystemStats', 'Settings'] } =
|
||||
props
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
|
||||
const selection = ref<string[]>([])
|
||||
const contactPrefs = ref<string[]>([])
|
||||
const submitted = ref(false)
|
||||
|
||||
const contactCheckboxes = [
|
||||
{ label: t('issueReport.contactFollowUp'), value: 'followUp' },
|
||||
{ label: t('issueReport.notifyResolve'), value: 'notifyOnResolution' }
|
||||
]
|
||||
|
||||
const helpTypes = [
|
||||
{
|
||||
label: t('issueReport.helpTypes.billingPayments'),
|
||||
value: 'billingPayments'
|
||||
},
|
||||
{
|
||||
label: t('issueReport.helpTypes.loginAccessIssues'),
|
||||
value: 'loginAccessIssues'
|
||||
},
|
||||
{ label: t('issueReport.helpTypes.giveFeedback'), value: 'giveFeedback' },
|
||||
{ label: t('issueReport.helpTypes.bugReport'), value: 'bugReport' },
|
||||
{ label: t('issueReport.helpTypes.somethingElse'), value: 'somethingElse' }
|
||||
]
|
||||
|
||||
const defaultFieldsConfig: ReportField[] = [
|
||||
{
|
||||
label: t('issueReport.systemStats'),
|
||||
value: 'SystemStats',
|
||||
getData: () => api.getSystemStats(),
|
||||
optIn: true
|
||||
},
|
||||
{
|
||||
label: t('g.workflow'),
|
||||
value: 'Workflow',
|
||||
getData: () => cloneDeep(app.graph.asSerialisable()),
|
||||
optIn: true
|
||||
},
|
||||
{
|
||||
label: t('g.logs'),
|
||||
value: 'Logs',
|
||||
getData: () => api.getLogs(),
|
||||
optIn: true
|
||||
},
|
||||
{
|
||||
label: t('g.settings'),
|
||||
value: 'Settings',
|
||||
getData: () => api.getSettings(),
|
||||
optIn: true
|
||||
}
|
||||
]
|
||||
|
||||
const fields = computed(() => [
|
||||
...defaultFieldsConfig.filter(({ value }) =>
|
||||
defaultFields.includes(value as DefaultField)
|
||||
),
|
||||
...(props.extraFields ?? [])
|
||||
])
|
||||
|
||||
const createUser = (formData: IssueReportFormData): User => ({
|
||||
email: formData.contactInfo || undefined
|
||||
})
|
||||
|
||||
const createExtraData = async (formData: IssueReportFormData) => {
|
||||
const result: Record<string, unknown> = {}
|
||||
const isChecked = (fieldValue: string) => formData[fieldValue]
|
||||
|
||||
await Promise.all(
|
||||
fields.value
|
||||
.filter((field) => !field.optIn || isChecked(field.value))
|
||||
.map(async (field) => {
|
||||
try {
|
||||
result[field.value] = await field.getData()
|
||||
} catch (error) {
|
||||
console.error(`Failed to collect ${field.value}:`, error)
|
||||
result[field.value] = { error: String(error) }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const createCaptureContext = async (
|
||||
formData: IssueReportFormData
|
||||
): Promise<CaptureContext> => {
|
||||
return {
|
||||
user: createUser(formData),
|
||||
level: 'error',
|
||||
tags: {
|
||||
errorType: props.errorType,
|
||||
helpType: formData.helpType,
|
||||
followUp: formData.contactInfo ? formData.followUp : false,
|
||||
notifyOnResolution: formData.contactInfo
|
||||
? formData.notifyOnResolution
|
||||
: false,
|
||||
isElectron: isElectron(),
|
||||
..._.mapValues(props.tags, (tag) => _.trim(tag).replace(/[\n\r\t]/g, ' '))
|
||||
},
|
||||
extra: {
|
||||
details: formData.details,
|
||||
...(await createExtraData(formData))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const generateUniqueTicketId = (type: string) => `${type}-${generateUUID()}`
|
||||
|
||||
const submit = async (event: FormSubmitEvent) => {
|
||||
if (event.valid) {
|
||||
try {
|
||||
const captureContext = await createCaptureContext(event.values)
|
||||
|
||||
// If it's billing or access issue, generate unique id to be used by customer service ticketing
|
||||
const isValidContactInfo = event.values.contactInfo?.length
|
||||
const isCustomerServiceIssue =
|
||||
isValidContactInfo &&
|
||||
['billingPayments', 'loginAccessIssues'].includes(
|
||||
event.values.helpType || ''
|
||||
)
|
||||
const issueName = isCustomerServiceIssue
|
||||
? `ticket-${generateUniqueTicketId(event.values.helpType || '')}`
|
||||
: DEFAULT_ISSUE_NAME
|
||||
captureMessage(issueName, captureContext)
|
||||
submitted.value = true
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.reportSent'),
|
||||
life: 3000
|
||||
})
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,94 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="inline-flex items-center gap-1 rounded-2xl text-xs cursor-pointer px-2 py-1"
|
||||
:class="{ 'bg-gray-100 dark-theme:bg-neutral-700': fill }"
|
||||
aria-haspopup="true"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="toggleVersionSelector"
|
||||
@keydown.enter="toggleVersionSelector"
|
||||
@keydown.space="toggleVersionSelector"
|
||||
>
|
||||
<i
|
||||
v-if="isUpdateAvailable"
|
||||
class="pi pi-arrow-circle-up text-blue-600"
|
||||
style="font-size: 8px"
|
||||
/>
|
||||
<span>{{ installedVersion }}</span>
|
||||
<i class="pi pi-chevron-right" style="font-size: 8px" />
|
||||
</div>
|
||||
|
||||
<Popover
|
||||
ref="popoverRef"
|
||||
:pt="{
|
||||
content: { class: 'px-0' }
|
||||
}"
|
||||
>
|
||||
<PackVersionSelectorPopover
|
||||
:installed-version="installedVersion"
|
||||
:node-pack="nodePack"
|
||||
@cancel="closeVersionSelector"
|
||||
@submit="closeVersionSelector"
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import PackVersionSelectorPopover from '@/components/dialog/content/manager/PackVersionSelectorPopover.vue'
|
||||
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { SelectedVersion } from '@/types/comfyManagerTypes'
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
import { isSemVer } from '@/utils/formatUtil'
|
||||
|
||||
const TRUNCATED_HASH_LENGTH = 7
|
||||
|
||||
const {
|
||||
nodePack,
|
||||
isSelected,
|
||||
fill = true
|
||||
} = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
isSelected: boolean
|
||||
fill?: boolean
|
||||
}>()
|
||||
|
||||
const { isUpdateAvailable } = usePackUpdateStatus(nodePack)
|
||||
const popoverRef = ref()
|
||||
|
||||
const managerStore = useComfyManagerStore()
|
||||
|
||||
const installedVersion = computed(() => {
|
||||
if (!nodePack.id) return SelectedVersion.NIGHTLY
|
||||
const version =
|
||||
managerStore.installedPacks[nodePack.id]?.ver ??
|
||||
nodePack.latest_version?.version ??
|
||||
SelectedVersion.NIGHTLY
|
||||
|
||||
// If Git hash, truncate to 7 characters
|
||||
return isSemVer(version) ? version : version.slice(0, TRUNCATED_HASH_LENGTH)
|
||||
})
|
||||
|
||||
const toggleVersionSelector = (event: Event) => {
|
||||
popoverRef.value.toggle(event)
|
||||
}
|
||||
|
||||
const closeVersionSelector = () => {
|
||||
popoverRef.value.hide()
|
||||
}
|
||||
|
||||
// If the card is unselected, automatically close the version selector popover
|
||||
watch(
|
||||
() => isSelected,
|
||||
(isSelected, wasSelected) => {
|
||||
if (wasSelected && !isSelected) {
|
||||
closeVersionSelector()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -1,331 +0,0 @@
|
||||
import { VueWrapper, mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import Button from 'primevue/button'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Listbox from 'primevue/listbox'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import { SelectedVersion } from '@/types/comfyManagerTypes'
|
||||
|
||||
import PackVersionSelectorPopover from './PackVersionSelectorPopover.vue'
|
||||
|
||||
// Default mock versions for reference
|
||||
const defaultMockVersions = [
|
||||
{ version: '1.0.0', createdAt: '2023-01-01' },
|
||||
{ version: '0.9.0', createdAt: '2022-12-01' },
|
||||
{ version: '0.8.0', createdAt: '2022-11-01' }
|
||||
]
|
||||
|
||||
const mockNodePack = {
|
||||
id: 'test-pack',
|
||||
name: 'Test Pack',
|
||||
latest_version: { version: '1.0.0' },
|
||||
repository: 'https://github.com/user/repo'
|
||||
}
|
||||
|
||||
// Create mock functions
|
||||
const mockGetPackVersions = vi.fn()
|
||||
const mockInstallPack = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
// Mock the registry service
|
||||
vi.mock('@/services/comfyRegistryService', () => ({
|
||||
useComfyRegistryService: vi.fn(() => ({
|
||||
getPackVersions: mockGetPackVersions
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the manager store
|
||||
vi.mock('@/stores/comfyManagerStore', () => ({
|
||||
useComfyManagerStore: vi.fn(() => ({
|
||||
installPack: {
|
||||
call: mockInstallPack,
|
||||
clear: vi.fn()
|
||||
},
|
||||
isPackInstalled: vi.fn(() => false),
|
||||
getInstalledPackVersion: vi.fn(() => undefined)
|
||||
}))
|
||||
}))
|
||||
|
||||
const waitForPromises = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 16))
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('PackVersionSelectorPopover', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetPackVersions.mockReset()
|
||||
mockInstallPack.mockReset().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
const mountComponent = ({
|
||||
props = {}
|
||||
}: Record<string, any> = {}): VueWrapper => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
return mount(PackVersionSelectorPopover, {
|
||||
props: {
|
||||
nodePack: mockNodePack,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [PrimeVue, createPinia(), i18n],
|
||||
components: {
|
||||
Listbox
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('fetches versions on mount', async () => {
|
||||
// Set up the mock for this specific test
|
||||
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
|
||||
|
||||
mountComponent()
|
||||
await waitForPromises()
|
||||
|
||||
expect(mockGetPackVersions).toHaveBeenCalledWith(mockNodePack.id)
|
||||
})
|
||||
|
||||
it('shows loading state while fetching versions', async () => {
|
||||
// Delay the promise resolution
|
||||
mockGetPackVersions.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(() => resolve(defaultMockVersions), 1000)
|
||||
)
|
||||
)
|
||||
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.text()).toContain('Loading versions...')
|
||||
})
|
||||
|
||||
it('displays special options and version options in the listbox', async () => {
|
||||
// Set up the mock for this specific test
|
||||
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await waitForPromises()
|
||||
|
||||
const listbox = wrapper.findComponent(Listbox)
|
||||
expect(listbox.exists()).toBe(true)
|
||||
|
||||
const options = listbox.props('options')!
|
||||
// Check that we have both special options and version options
|
||||
expect(options.length).toBe(defaultMockVersions.length + 2) // 2 special options + version options
|
||||
|
||||
// Check that special options exist
|
||||
expect(options.some((o) => o.value === SelectedVersion.NIGHTLY)).toBe(true)
|
||||
expect(options.some((o) => o.value === SelectedVersion.LATEST)).toBe(true)
|
||||
|
||||
// Check that version options exist
|
||||
expect(options.some((o) => o.value === '1.0.0')).toBe(true)
|
||||
expect(options.some((o) => o.value === '0.9.0')).toBe(true)
|
||||
expect(options.some((o) => o.value === '0.8.0')).toBe(true)
|
||||
})
|
||||
|
||||
it('emits cancel event when cancel button is clicked', async () => {
|
||||
// Set up the mock for this specific test
|
||||
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await waitForPromises()
|
||||
|
||||
const cancelButton = wrapper.findAllComponents(Button)[0]
|
||||
await cancelButton.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('cancel')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('calls installPack and emits submit when install button is clicked', async () => {
|
||||
// Set up the mock for this specific test
|
||||
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await waitForPromises()
|
||||
|
||||
// Set the selected version
|
||||
await wrapper.findComponent(Listbox).setValue('0.9.0')
|
||||
|
||||
const installButton = wrapper.findAllComponents(Button)[1]
|
||||
await installButton.trigger('click')
|
||||
|
||||
// Check that installPack was called with the correct parameters
|
||||
expect(mockInstallPack).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: mockNodePack.id,
|
||||
repository: mockNodePack.repository,
|
||||
version: '0.9.0',
|
||||
selected_version: '0.9.0'
|
||||
})
|
||||
)
|
||||
|
||||
// Check that submit was emitted
|
||||
expect(wrapper.emitted('submit')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('is reactive to nodePack prop changes', async () => {
|
||||
// Set up the mock for the initial fetch
|
||||
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await waitForPromises()
|
||||
|
||||
// Set up the mock for the second fetch after prop change
|
||||
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
|
||||
|
||||
// Update the nodePack prop
|
||||
const newNodePack = { ...mockNodePack, id: 'new-test-pack' }
|
||||
await wrapper.setProps({ nodePack: newNodePack })
|
||||
await waitForPromises()
|
||||
|
||||
// Should fetch versions for the new nodePack
|
||||
expect(mockGetPackVersions).toHaveBeenCalledWith(newNodePack.id)
|
||||
})
|
||||
|
||||
describe('nodePack.id changes', () => {
|
||||
it('re-fetches versions when nodePack.id changes', async () => {
|
||||
// Set up the mock for the initial fetch
|
||||
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await waitForPromises()
|
||||
|
||||
// Verify initial fetch
|
||||
expect(mockGetPackVersions).toHaveBeenCalledTimes(1)
|
||||
expect(mockGetPackVersions).toHaveBeenCalledWith(mockNodePack.id)
|
||||
|
||||
// Set up the mock for the second fetch
|
||||
const newVersions = [
|
||||
{ version: '2.0.0', createdAt: '2023-06-01' },
|
||||
{ version: '1.9.0', createdAt: '2023-05-01' }
|
||||
]
|
||||
mockGetPackVersions.mockResolvedValueOnce(newVersions)
|
||||
|
||||
// Update the nodePack with a new ID
|
||||
const newNodePack = {
|
||||
...mockNodePack,
|
||||
id: 'different-pack',
|
||||
name: 'Different Pack'
|
||||
}
|
||||
await wrapper.setProps({ nodePack: newNodePack })
|
||||
await waitForPromises()
|
||||
|
||||
// Should fetch versions for the new nodePack
|
||||
expect(mockGetPackVersions).toHaveBeenCalledTimes(2)
|
||||
expect(mockGetPackVersions).toHaveBeenLastCalledWith(newNodePack.id)
|
||||
|
||||
// Check that new versions are displayed
|
||||
const listbox = wrapper.findComponent(Listbox)
|
||||
const options = listbox.props('options')!
|
||||
expect(options.some((o) => o.value === '2.0.0')).toBe(true)
|
||||
expect(options.some((o) => o.value === '1.9.0')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not re-fetch when nodePack changes but id remains the same', async () => {
|
||||
// Set up the mock for the initial fetch
|
||||
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await waitForPromises()
|
||||
|
||||
// Verify initial fetch
|
||||
expect(mockGetPackVersions).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Update the nodePack with same ID but different properties
|
||||
const updatedNodePack = {
|
||||
...mockNodePack,
|
||||
name: 'Updated Test Pack',
|
||||
description: 'New description'
|
||||
}
|
||||
await wrapper.setProps({ nodePack: updatedNodePack })
|
||||
await waitForPromises()
|
||||
|
||||
// Should NOT fetch versions again
|
||||
expect(mockGetPackVersions).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('maintains selected version when switching to a new pack', async () => {
|
||||
// Set up the mock for the initial fetch
|
||||
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await waitForPromises()
|
||||
|
||||
// Select a specific version
|
||||
const listbox = wrapper.findComponent(Listbox)
|
||||
await listbox.setValue('0.9.0')
|
||||
expect(listbox.props('modelValue')).toBe('0.9.0')
|
||||
|
||||
// Set up the mock for the second fetch
|
||||
mockGetPackVersions.mockResolvedValueOnce([
|
||||
{ version: '3.0.0', createdAt: '2023-07-01' },
|
||||
{ version: '0.9.0', createdAt: '2023-04-01' }
|
||||
])
|
||||
|
||||
// Update to a new pack that also has version 0.9.0
|
||||
const newNodePack = {
|
||||
id: 'another-pack',
|
||||
name: 'Another Pack',
|
||||
latest_version: { version: '3.0.0' }
|
||||
}
|
||||
await wrapper.setProps({ nodePack: newNodePack })
|
||||
await waitForPromises()
|
||||
|
||||
// Selected version should remain the same if available
|
||||
expect(listbox.props('modelValue')).toBe('0.9.0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Unclaimed GitHub packs handling', () => {
|
||||
it('falls back to nightly when no versions exist', async () => {
|
||||
// Set up the mock to return versions
|
||||
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
|
||||
|
||||
const packWithRepo = {
|
||||
...mockNodePack,
|
||||
latest_version: undefined
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({
|
||||
props: {
|
||||
nodePack: packWithRepo
|
||||
}
|
||||
})
|
||||
|
||||
await waitForPromises()
|
||||
const listbox = wrapper.findComponent(Listbox)
|
||||
expect(listbox.exists()).toBe(true)
|
||||
expect(listbox.props('modelValue')).toBe(SelectedVersion.NIGHTLY)
|
||||
})
|
||||
|
||||
it('defaults to nightly when publisher name is "Unclaimed"', async () => {
|
||||
// Set up the mock to return versions
|
||||
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
|
||||
|
||||
const unclaimedNodePack = {
|
||||
...mockNodePack,
|
||||
publisher: { name: 'Unclaimed' }
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({
|
||||
props: {
|
||||
nodePack: unclaimedNodePack
|
||||
}
|
||||
})
|
||||
|
||||
await waitForPromises()
|
||||
const listbox = wrapper.findComponent(Listbox)
|
||||
expect(listbox.exists()).toBe(true)
|
||||
expect(listbox.props('modelValue')).toBe(SelectedVersion.NIGHTLY)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,187 +0,0 @@
|
||||
<template>
|
||||
<div class="w-64 mt-2">
|
||||
<span class="pl-3 text-muted text-md font-semibold opacity-70">
|
||||
{{ $t('manager.selectVersion') }}
|
||||
</span>
|
||||
<div
|
||||
v-if="isLoadingVersions || isQueueing"
|
||||
class="text-center text-muted py-4 flex flex-col items-center"
|
||||
>
|
||||
<ProgressSpinner class="w-8 h-8 mb-2" />
|
||||
{{ $t('manager.loadingVersions') }}
|
||||
</div>
|
||||
<div v-else-if="versionOptions.length === 0" class="py-2">
|
||||
<NoResultsPlaceholder
|
||||
:title="$t('g.noResultsFound')"
|
||||
:message="$t('manager.tryAgainLater')"
|
||||
icon="pi pi-exclamation-circle"
|
||||
class="p-0"
|
||||
/>
|
||||
</div>
|
||||
<Listbox
|
||||
v-else
|
||||
v-model="selectedVersion"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:options="versionOptions"
|
||||
:highlight-on-select="false"
|
||||
class="my-3 w-full max-h-[50vh] border-none shadow-none"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<div class="flex justify-between items-center w-full p-1">
|
||||
<span>{{ slotProps.option.label }}</span>
|
||||
<i
|
||||
v-if="selectedVersion === slotProps.option.value"
|
||||
class="pi pi-check text-highlight"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Listbox>
|
||||
<ContentDivider class="my-2" />
|
||||
<div class="flex justify-end gap-2 p-1 px-3">
|
||||
<Button
|
||||
text
|
||||
severity="secondary"
|
||||
:label="$t('g.cancel')"
|
||||
:disabled="isQueueing"
|
||||
@click="emit('cancel')"
|
||||
/>
|
||||
<Button
|
||||
severity="secondary"
|
||||
:label="$t('g.install')"
|
||||
class="py-3 px-4 dark-theme:bg-unset bg-black/80 dark-theme:text-unset text-neutral-100 rounded-lg"
|
||||
:disabled="isQueueing"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { whenever } from '@vueuse/core'
|
||||
import Button from 'primevue/button'
|
||||
import Listbox from 'primevue/listbox'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import { useComfyRegistryService } from '@/services/comfyRegistryService'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import {
|
||||
ManagerChannel,
|
||||
ManagerDatabaseSource,
|
||||
SelectedVersion
|
||||
} from '@/types/comfyManagerTypes'
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
import { isSemVer } from '@/utils/formatUtil'
|
||||
|
||||
const { nodePack } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
cancel: []
|
||||
submit: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const registryService = useComfyRegistryService()
|
||||
const managerStore = useComfyManagerStore()
|
||||
|
||||
const isQueueing = ref(false)
|
||||
|
||||
const selectedVersion = ref<string>(SelectedVersion.LATEST)
|
||||
onMounted(() => {
|
||||
const initialVersion = getInitialSelectedVersion() ?? SelectedVersion.LATEST
|
||||
selectedVersion.value =
|
||||
// Use NIGHTLY when version is a Git hash
|
||||
isSemVer(initialVersion) ? initialVersion : SelectedVersion.NIGHTLY
|
||||
})
|
||||
|
||||
const getInitialSelectedVersion = () => {
|
||||
if (!nodePack.id) return
|
||||
|
||||
// If unclaimed, set selected version to nightly
|
||||
if (nodePack.publisher?.name === 'Unclaimed') return SelectedVersion.NIGHTLY
|
||||
|
||||
// If node pack is installed, set selected version to the installed version
|
||||
if (managerStore.isPackInstalled(nodePack.id))
|
||||
return managerStore.getInstalledPackVersion(nodePack.id)
|
||||
|
||||
// If node pack is not installed, set selected version to latest
|
||||
return nodePack.latest_version?.version
|
||||
}
|
||||
|
||||
const fetchVersions = async () => {
|
||||
if (!nodePack?.id) return []
|
||||
return (await registryService.getPackVersions(nodePack.id)) || []
|
||||
}
|
||||
|
||||
const versionOptions = ref<
|
||||
{
|
||||
value: string
|
||||
label: string
|
||||
}[]
|
||||
>([])
|
||||
|
||||
const isLoadingVersions = ref(false)
|
||||
|
||||
const onNodePackChange = async () => {
|
||||
isLoadingVersions.value = true
|
||||
|
||||
// Fetch versions from the registry
|
||||
const versions = await fetchVersions()
|
||||
const availableVersionOptions = versions
|
||||
.map((version) => ({
|
||||
value: version.version ?? '',
|
||||
label: version.version ?? ''
|
||||
}))
|
||||
.filter((option) => option.value)
|
||||
|
||||
// Add Latest option
|
||||
const defaultVersions = [
|
||||
{
|
||||
value: SelectedVersion.LATEST,
|
||||
label: t('manager.latestVersion')
|
||||
}
|
||||
]
|
||||
|
||||
// Add Nightly option if there is a non-empty `repository` field
|
||||
if (nodePack.repository?.length) {
|
||||
defaultVersions.push({
|
||||
value: SelectedVersion.NIGHTLY,
|
||||
label: t('manager.nightlyVersion')
|
||||
})
|
||||
}
|
||||
|
||||
versionOptions.value = [...defaultVersions, ...availableVersionOptions]
|
||||
isLoadingVersions.value = false
|
||||
}
|
||||
|
||||
whenever(
|
||||
() => nodePack.id,
|
||||
(nodePackId, oldNodePackId) => {
|
||||
if (nodePackId !== oldNodePackId) {
|
||||
void onNodePackChange()
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
isQueueing.value = true
|
||||
await managerStore.installPack.call({
|
||||
id: nodePack.id,
|
||||
repository: nodePack.repository ?? '',
|
||||
channel: ManagerChannel.DEFAULT,
|
||||
mode: ManagerDatabaseSource.CACHE,
|
||||
version: selectedVersion.value,
|
||||
selected_version: selectedVersion.value
|
||||
})
|
||||
|
||||
isQueueing.value = false
|
||||
emit('submit')
|
||||
}
|
||||
</script>
|
||||
@@ -1,53 +0,0 @@
|
||||
<template>
|
||||
<Button
|
||||
outlined
|
||||
class="!m-0 p-0 rounded-lg text-gray-900 dark-theme:text-gray-50"
|
||||
:class="[
|
||||
variant === 'black'
|
||||
? 'bg-neutral-900 text-white border-neutral-900'
|
||||
: 'border-neutral-700',
|
||||
fullWidth ? 'w-full' : 'w-min-content'
|
||||
]"
|
||||
:disabled="loading"
|
||||
v-bind="$attrs"
|
||||
@click="onClick"
|
||||
>
|
||||
<span class="py-2 px-3 whitespace-nowrap">
|
||||
<template v-if="loading">
|
||||
{{ loadingMessage ?? $t('g.loading') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ label }}
|
||||
</template>
|
||||
</span>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
|
||||
const {
|
||||
label,
|
||||
loadingMessage,
|
||||
fullWidth = false,
|
||||
variant = 'default'
|
||||
} = defineProps<{
|
||||
label: string
|
||||
loading?: boolean
|
||||
loadingMessage?: string
|
||||
fullWidth?: boolean
|
||||
variant?: 'default' | 'black'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
action: []
|
||||
}>()
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const onClick = (): void => {
|
||||
emit('action')
|
||||
}
|
||||
</script>
|
||||
@@ -1,76 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<ToggleSwitch
|
||||
:model-value="isEnabled"
|
||||
:disabled="isLoading"
|
||||
aria-label="Enable or disable pack"
|
||||
@update:model-value="onToggle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import {
|
||||
InstallPackParams,
|
||||
ManagerChannel,
|
||||
SelectedVersion
|
||||
} from '@/types/comfyManagerTypes'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
const TOGGLE_DEBOUNCE_MS = 256
|
||||
|
||||
const { nodePack } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
}>()
|
||||
|
||||
const { isPackEnabled, enablePack, disablePack, installedPacks } =
|
||||
useComfyManagerStore()
|
||||
|
||||
const isLoading = ref(false)
|
||||
|
||||
const isEnabled = computed(() => isPackEnabled(nodePack.id))
|
||||
const version = computed(() => {
|
||||
const id = nodePack.id
|
||||
if (!id) return SelectedVersion.NIGHTLY
|
||||
return (
|
||||
installedPacks[id]?.ver ??
|
||||
nodePack.latest_version?.version ??
|
||||
SelectedVersion.NIGHTLY
|
||||
)
|
||||
})
|
||||
|
||||
const handleEnable = () =>
|
||||
enablePack.call({
|
||||
id: nodePack.id,
|
||||
version: version.value,
|
||||
selected_version: version.value,
|
||||
repository: nodePack.repository ?? '',
|
||||
channel: ManagerChannel.DEFAULT,
|
||||
mode: 'default' as InstallPackParams['mode']
|
||||
})
|
||||
|
||||
const handleDisable = () =>
|
||||
disablePack({
|
||||
id: nodePack.id,
|
||||
version: version.value
|
||||
})
|
||||
|
||||
const handleToggle = async (enable: boolean) => {
|
||||
if (isLoading.value) return
|
||||
|
||||
isLoading.value = true
|
||||
if (enable) {
|
||||
await handleEnable()
|
||||
} else {
|
||||
handleDisable()
|
||||
}
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
const onToggle = debounce(handleToggle, TOGGLE_DEBOUNCE_MS, { trailing: true })
|
||||
</script>
|
||||
@@ -1,78 +0,0 @@
|
||||
<template>
|
||||
<PackActionButton
|
||||
v-bind="$attrs"
|
||||
:label="
|
||||
label ??
|
||||
(nodePacks.length > 1 ? $t('manager.installSelected') : $t('g.install'))
|
||||
"
|
||||
:severity="variant === 'black' ? undefined : 'secondary'"
|
||||
:variant="variant"
|
||||
:loading="isInstalling"
|
||||
:loading-message="$t('g.installing')"
|
||||
@action="installAllPacks"
|
||||
@click="onClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject, ref } from 'vue'
|
||||
|
||||
import PackActionButton from '@/components/dialog/content/manager/button/PackActionButton.vue'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import {
|
||||
IsInstallingKey,
|
||||
ManagerChannel,
|
||||
ManagerDatabaseSource,
|
||||
SelectedVersion
|
||||
} from '@/types/comfyManagerTypes'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
type NodePack = components['schemas']['Node']
|
||||
|
||||
const { nodePacks, variant, label } = defineProps<{
|
||||
nodePacks: NodePack[]
|
||||
variant?: 'default' | 'black'
|
||||
label?: string
|
||||
}>()
|
||||
|
||||
const isInstalling = inject(IsInstallingKey, ref(false))
|
||||
|
||||
const onClick = (): void => {
|
||||
isInstalling.value = true
|
||||
}
|
||||
|
||||
const managerStore = useComfyManagerStore()
|
||||
|
||||
const createPayload = (installItem: NodePack) => {
|
||||
const isUnclaimedPack = installItem.publisher?.name === 'Unclaimed'
|
||||
const versionToInstall = isUnclaimedPack
|
||||
? SelectedVersion.NIGHTLY
|
||||
: installItem.latest_version?.version ?? SelectedVersion.LATEST
|
||||
|
||||
return {
|
||||
id: installItem.id,
|
||||
repository: installItem.repository ?? '',
|
||||
channel: ManagerChannel.DEV,
|
||||
mode: ManagerDatabaseSource.CACHE,
|
||||
selected_version: versionToInstall,
|
||||
version: versionToInstall
|
||||
}
|
||||
}
|
||||
|
||||
const installPack = (item: NodePack) =>
|
||||
managerStore.installPack.call(createPayload(item))
|
||||
|
||||
const installAllPacks = async () => {
|
||||
if (!nodePacks?.length) return
|
||||
|
||||
isInstalling.value = true
|
||||
|
||||
const uninstalledPacks = nodePacks.filter(
|
||||
(pack) => !managerStore.isPackInstalled(pack.id)
|
||||
)
|
||||
if (!uninstalledPacks.length) return
|
||||
|
||||
await Promise.all(uninstalledPacks.map(installPack))
|
||||
managerStore.installPack.clear()
|
||||
}
|
||||
</script>
|
||||
@@ -1,43 +0,0 @@
|
||||
<template>
|
||||
<PackActionButton
|
||||
v-bind="$attrs"
|
||||
:label="
|
||||
nodePacks.length > 1
|
||||
? $t('manager.uninstallSelected')
|
||||
: $t('manager.uninstall')
|
||||
"
|
||||
severity="danger"
|
||||
:loading-message="$t('manager.uninstalling')"
|
||||
@action="uninstallItems"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PackActionButton from '@/components/dialog/content/manager/button/PackActionButton.vue'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import type { ManagerPackInfo } from '@/types/comfyManagerTypes'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
type NodePack = components['schemas']['Node']
|
||||
|
||||
const { nodePacks } = defineProps<{
|
||||
nodePacks: NodePack[]
|
||||
}>()
|
||||
|
||||
const managerStore = useComfyManagerStore()
|
||||
|
||||
const createPayload = (uninstallItem: NodePack): ManagerPackInfo => {
|
||||
return {
|
||||
id: uninstallItem.id,
|
||||
version: uninstallItem.latest_version?.version
|
||||
}
|
||||
}
|
||||
|
||||
const uninstallPack = (item: NodePack) =>
|
||||
managerStore.uninstallPack(createPayload(item))
|
||||
|
||||
const uninstallItems = async () => {
|
||||
if (!nodePacks?.length) return
|
||||
await Promise.all(nodePacks.map(uninstallPack))
|
||||
}
|
||||
</script>
|
||||
@@ -1,144 +0,0 @@
|
||||
<template>
|
||||
<template v-if="nodePack">
|
||||
<div class="flex flex-col h-full z-40 overflow-hidden relative">
|
||||
<div class="top-0 z-10 px-6 pt-6 w-full">
|
||||
<InfoPanelHeader :node-packs="[nodePack]" />
|
||||
</div>
|
||||
<div
|
||||
ref="scrollContainer"
|
||||
class="p-6 pt-2 overflow-y-auto flex-1 text-sm hidden-scrollbar"
|
||||
>
|
||||
<div class="mb-6">
|
||||
<MetadataRow
|
||||
v-if="isPackInstalled(nodePack.id)"
|
||||
:label="t('manager.filter.enabled')"
|
||||
class="flex"
|
||||
style="align-items: center"
|
||||
>
|
||||
<PackEnableToggle :node-pack="nodePack" />
|
||||
</MetadataRow>
|
||||
<MetadataRow
|
||||
v-for="item in infoItems"
|
||||
v-show="item.value !== undefined && item.value !== null"
|
||||
:key="item.key"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
<MetadataRow :label="t('g.status')">
|
||||
<PackStatusMessage
|
||||
:status-type="
|
||||
nodePack.status as components['schemas']['NodeVersionStatus']
|
||||
"
|
||||
/>
|
||||
</MetadataRow>
|
||||
<MetadataRow :label="t('manager.version')">
|
||||
<PackVersionBadge :node-pack="nodePack" :is-selected="true" />
|
||||
</MetadataRow>
|
||||
</div>
|
||||
<div class="mb-6 overflow-hidden">
|
||||
<InfoTabs :node-pack="nodePack" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="pt-4 px-8 flex-1 overflow-hidden text-sm">
|
||||
{{ $t('manager.infoPanelEmpty') }}
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useScroll, whenever } from '@vueuse/core'
|
||||
import { computed, provide, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PackStatusMessage from '@/components/dialog/content/manager/PackStatusMessage.vue'
|
||||
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
|
||||
import PackEnableToggle from '@/components/dialog/content/manager/button/PackEnableToggle.vue'
|
||||
import InfoPanelHeader from '@/components/dialog/content/manager/infoPanel/InfoPanelHeader.vue'
|
||||
import InfoTabs from '@/components/dialog/content/manager/infoPanel/InfoTabs.vue'
|
||||
import MetadataRow from '@/components/dialog/content/manager/infoPanel/MetadataRow.vue'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { IsInstallingKey } from '@/types/comfyManagerTypes'
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
interface InfoItem {
|
||||
key: string
|
||||
label: string
|
||||
value: string | number | undefined
|
||||
}
|
||||
|
||||
const { nodePack } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
}>()
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
const managerStore = useComfyManagerStore()
|
||||
const isInstalled = computed(() => managerStore.isPackInstalled(nodePack.id))
|
||||
const isInstalling = ref(false)
|
||||
provide(IsInstallingKey, isInstalling)
|
||||
whenever(isInstalled, () => {
|
||||
isInstalling.value = false
|
||||
})
|
||||
|
||||
const { isPackInstalled } = useComfyManagerStore()
|
||||
|
||||
const { t, d, n } = useI18n()
|
||||
|
||||
const infoItems = computed<InfoItem[]>(() => [
|
||||
{
|
||||
key: 'publisher',
|
||||
label: t('manager.createdBy'),
|
||||
value: nodePack.publisher?.name ?? nodePack.publisher?.id
|
||||
},
|
||||
{
|
||||
key: 'downloads',
|
||||
label: t('manager.downloads'),
|
||||
value: nodePack.downloads ? n(nodePack.downloads) : undefined
|
||||
},
|
||||
{
|
||||
key: 'lastUpdated',
|
||||
label: t('manager.lastUpdated'),
|
||||
value: nodePack.latest_version?.createdAt
|
||||
? d(nodePack.latest_version.createdAt, {
|
||||
dateStyle: 'medium'
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
])
|
||||
|
||||
const { y } = useScroll(scrollContainer, {
|
||||
eventListenerOptions: {
|
||||
passive: true
|
||||
}
|
||||
})
|
||||
const onNodePackChange = () => {
|
||||
y.value = 0
|
||||
}
|
||||
|
||||
whenever(
|
||||
() => nodePack.id,
|
||||
(nodePackId, oldNodePackId) => {
|
||||
if (nodePackId !== oldNodePackId) {
|
||||
onNodePackChange()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
<style scoped>
|
||||
.hidden-scrollbar {
|
||||
/* Firefox */
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,59 +0,0 @@
|
||||
<template>
|
||||
<div v-if="nodePacks?.length" class="flex flex-col items-center mb-6">
|
||||
<slot name="thumbnail">
|
||||
<PackIcon :node-pack="nodePacks[0]" width="24" height="24" />
|
||||
</slot>
|
||||
<h2
|
||||
class="text-2xl font-bold text-center mt-4 mb-2"
|
||||
style="word-break: break-all"
|
||||
>
|
||||
<slot name="title">
|
||||
{{ nodePacks[0].name }}
|
||||
</slot>
|
||||
</h2>
|
||||
<div class="mt-2 mb-4 w-full max-w-xs flex justify-center">
|
||||
<slot name="install-button">
|
||||
<PackUninstallButton
|
||||
v-if="isAllInstalled"
|
||||
v-bind="$attrs"
|
||||
:node-packs="nodePacks"
|
||||
/>
|
||||
<PackInstallButton v-else v-bind="$attrs" :node-packs="nodePacks" />
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex flex-col items-center mb-6">
|
||||
<NoResultsPlaceholder
|
||||
:message="$t('manager.status.unknown')"
|
||||
:title="$t('manager.tryAgainLater')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
|
||||
import PackUninstallButton from '@/components/dialog/content/manager/button/PackUninstallButton.vue'
|
||||
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
const { nodePacks } = defineProps<{
|
||||
nodePacks: components['schemas']['Node'][]
|
||||
}>()
|
||||
|
||||
const managerStore = useComfyManagerStore()
|
||||
|
||||
const isAllInstalled = ref(false)
|
||||
watch(
|
||||
[() => nodePacks, () => managerStore.installedPacks],
|
||||
() => {
|
||||
isAllInstalled.value = nodePacks.every((nodePack) =>
|
||||
managerStore.isPackInstalled(nodePack.id)
|
||||
)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -1,81 +0,0 @@
|
||||
<template>
|
||||
<div v-if="nodePacks?.length" class="flex flex-col h-full">
|
||||
<div class="p-6 flex-1 overflow-auto">
|
||||
<InfoPanelHeader :node-packs>
|
||||
<template #thumbnail>
|
||||
<PackIconStacked :node-packs="nodePacks" />
|
||||
</template>
|
||||
<template #title>
|
||||
{{ nodePacks.length }}
|
||||
{{ $t('manager.packsSelected') }}
|
||||
</template>
|
||||
<template #install-button>
|
||||
<PackInstallButton :full-width="true" :node-packs="nodePacks" />
|
||||
</template>
|
||||
</InfoPanelHeader>
|
||||
<div class="mb-6">
|
||||
<MetadataRow :label="$t('g.status')">
|
||||
<PackStatusMessage status-type="NodeVersionStatusActive" />
|
||||
</MetadataRow>
|
||||
<MetadataRow
|
||||
:label="$t('manager.totalNodes')"
|
||||
:value="totalNodesCount"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mt-4 mx-8 flex-1 overflow-hidden text-sm">
|
||||
{{ $t('manager.infoPanelEmpty') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { computed, onUnmounted } from 'vue'
|
||||
|
||||
import PackStatusMessage from '@/components/dialog/content/manager/PackStatusMessage.vue'
|
||||
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
|
||||
import InfoPanelHeader from '@/components/dialog/content/manager/infoPanel/InfoPanelHeader.vue'
|
||||
import MetadataRow from '@/components/dialog/content/manager/infoPanel/MetadataRow.vue'
|
||||
import PackIconStacked from '@/components/dialog/content/manager/packIcon/PackIconStacked.vue'
|
||||
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
const { nodePacks } = defineProps<{
|
||||
nodePacks: components['schemas']['Node'][]
|
||||
}>()
|
||||
|
||||
const { getNodeDefs } = useComfyRegistryStore()
|
||||
|
||||
const getPackNodes = async (pack: components['schemas']['Node']) => {
|
||||
if (!pack.latest_version?.version) return []
|
||||
const nodeDefs = await getNodeDefs.call({
|
||||
packId: pack.id,
|
||||
version: pack.latest_version?.version,
|
||||
// Fetch all nodes.
|
||||
// TODO: Render all nodes previews and handle pagination.
|
||||
// For determining length, use the `totalNumberOfPages` field of response
|
||||
limit: 8192
|
||||
})
|
||||
return nodeDefs?.comfy_nodes ?? []
|
||||
}
|
||||
|
||||
const { state: allNodeDefs } = useAsyncState(
|
||||
() => Promise.all(nodePacks.map(getPackNodes)),
|
||||
[],
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
)
|
||||
|
||||
const totalNodesCount = computed(() =>
|
||||
allNodeDefs.value.reduce(
|
||||
(total, nodeDefs) => total + (nodeDefs?.length || 0),
|
||||
0
|
||||
)
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
getNodeDefs.cancel()
|
||||
})
|
||||
</script>
|
||||
@@ -1,47 +0,0 @@
|
||||
<template>
|
||||
<div class="overflow-hidden">
|
||||
<Tabs :value="activeTab">
|
||||
<TabList>
|
||||
<Tab value="description">
|
||||
{{ $t('g.description') }}
|
||||
</Tab>
|
||||
<Tab value="nodes">
|
||||
{{ $t('g.nodes') }}
|
||||
</Tab>
|
||||
</TabList>
|
||||
<TabPanels class="overflow-auto">
|
||||
<TabPanel value="description">
|
||||
<DescriptionTabPanel :node-pack="nodePack" />
|
||||
</TabPanel>
|
||||
<TabPanel value="nodes">
|
||||
<NodesTabPanel :node-pack="nodePack" :node-names="nodeNames" />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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, ref } from 'vue'
|
||||
|
||||
import DescriptionTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/DescriptionTabPanel.vue'
|
||||
import NodesTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/NodesTabPanel.vue'
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
const { nodePack } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
}>()
|
||||
|
||||
const nodeNames = computed(() => {
|
||||
// @ts-expect-error comfy_nodes is an Algolia-specific field
|
||||
const { comfy_nodes } = nodePack
|
||||
return comfy_nodes ?? []
|
||||
})
|
||||
|
||||
const activeTab = ref('description')
|
||||
</script>
|
||||
@@ -1,165 +0,0 @@
|
||||
<template>
|
||||
<Card
|
||||
class="w-full h-full inline-flex flex-col justify-between items-start overflow-hidden rounded-lg shadow-elevation-3 dark-theme:bg-dark-elevation-2 transition-all duration-200"
|
||||
:class="{
|
||||
'selected-card': isSelected,
|
||||
'opacity-60': isDisabled
|
||||
}"
|
||||
:pt="{
|
||||
body: { class: 'p-0 flex flex-col w-full h-full rounded-lg gap-0' },
|
||||
content: { class: 'flex-1 flex flex-col rounded-lg min-h-0' },
|
||||
title: { class: 'w-full h-full rounded-t-lg cursor-pointer' },
|
||||
footer: {
|
||||
class: 'p-0 m-0 flex flex-col gap-0',
|
||||
style: {
|
||||
borderTop: isLightTheme ? '1px solid #f4f4f4' : '1px solid #2C2C2C'
|
||||
}
|
||||
}
|
||||
}"
|
||||
>
|
||||
<template #title>
|
||||
<PackBanner :node-pack="nodePack" />
|
||||
</template>
|
||||
<template #content>
|
||||
<template v-if="isInstalling">
|
||||
<div
|
||||
class="self-stretch inline-flex flex-col justify-center items-center gap-2 h-full"
|
||||
>
|
||||
<ProgressSpinner />
|
||||
<div
|
||||
class="self-stretch text-center justify-start text-sm font-medium leading-none"
|
||||
>
|
||||
{{ $t('g.installing') }}...
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="pt-4 px-4 pb-3 w-full h-full">
|
||||
<div class="flex flex-col gap-y-1 w-full h-full">
|
||||
<span
|
||||
class="text-sm font-bold truncate overflow-hidden text-ellipsis"
|
||||
>
|
||||
{{ nodePack.name }}
|
||||
</span>
|
||||
<p
|
||||
v-if="nodePack.description"
|
||||
class="flex-1 text-muted text-xs font-medium break-words overflow-hidden min-h-12 line-clamp-3 my-0 leading-4 mb-1 overflow-hidden"
|
||||
>
|
||||
{{ nodePack.description }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-y-2">
|
||||
<div class="flex-1 flex items-center gap-2">
|
||||
<div v-if="nodesCount" class="p-2 pl-0 text-xs">
|
||||
{{ nodesCount }} {{ $t('g.nodes') }}
|
||||
</div>
|
||||
<PackVersionBadge
|
||||
:node-pack="nodePack"
|
||||
:is-selected="isSelected"
|
||||
:fill="false"
|
||||
/>
|
||||
<div
|
||||
v-if="formattedLatestVersionDate"
|
||||
class="px-2 py-1 flex justify-center items-center gap-1 text-xs text-muted font-medium"
|
||||
>
|
||||
{{ formattedLatestVersionDate }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<span
|
||||
v-if="publisherName"
|
||||
class="text-xs text-muted font-medium leading-3 max-w-40 truncate"
|
||||
>
|
||||
{{ publisherName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template #footer>
|
||||
<PackCardFooter :node-pack="nodePack" />
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { whenever } from '@vueuse/core'
|
||||
import Card from 'primevue/card'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, provide, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
|
||||
import PackBanner from '@/components/dialog/content/manager/packBanner/PackBanner.vue'
|
||||
import PackCardFooter from '@/components/dialog/content/manager/packCard/PackCardFooter.vue'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import {
|
||||
IsInstallingKey,
|
||||
type MergedNodePack,
|
||||
type RegistryPack,
|
||||
isMergedNodePack
|
||||
} from '@/types/comfyManagerTypes'
|
||||
|
||||
const { nodePack, isSelected = false } = defineProps<{
|
||||
nodePack: MergedNodePack | RegistryPack
|
||||
isSelected?: boolean
|
||||
}>()
|
||||
|
||||
const { d } = useI18n()
|
||||
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const isLightTheme = computed(
|
||||
() => colorPaletteStore.completedActivePalette.light_theme
|
||||
)
|
||||
|
||||
const isInstalling = ref(false)
|
||||
provide(IsInstallingKey, isInstalling)
|
||||
|
||||
const { isPackInstalled, isPackEnabled } = useComfyManagerStore()
|
||||
|
||||
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
|
||||
const isDisabled = computed(
|
||||
() => isInstalled.value && !isPackEnabled(nodePack?.id)
|
||||
)
|
||||
|
||||
whenever(isInstalled, () => (isInstalling.value = false))
|
||||
|
||||
const nodesCount = computed(() =>
|
||||
isMergedNodePack(nodePack) ? nodePack.comfy_nodes?.length : undefined
|
||||
)
|
||||
const publisherName = computed(() => {
|
||||
if (!nodePack) return null
|
||||
|
||||
const { publisher, author } = nodePack
|
||||
return publisher?.name ?? publisher?.id ?? author
|
||||
})
|
||||
|
||||
const formattedLatestVersionDate = computed(() => {
|
||||
if (!nodePack.latest_version?.createdAt) return null
|
||||
|
||||
return d(new Date(nodePack.latest_version.createdAt), {
|
||||
dateStyle: 'medium'
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.selected-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.selected-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border: 4px solid var(--p-primary-color);
|
||||
border-radius: 0.5rem;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
</style>
|
||||
@@ -1,35 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="min-h-12 flex justify-between items-center px-4 py-2 text-xs text-muted font-medium leading-3"
|
||||
>
|
||||
<div v-if="nodePack.downloads" class="flex items-center gap-1.5">
|
||||
<i class="pi pi-download text-muted"></i>
|
||||
<span>{{ formattedDownloads }}</span>
|
||||
</div>
|
||||
<PackInstallButton v-if="!isInstalled" :node-packs="[nodePack]" />
|
||||
<PackEnableToggle v-else :node-pack="nodePack" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PackEnableToggle from '@/components/dialog/content/manager/button/PackEnableToggle.vue'
|
||||
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
const { nodePack } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
}>()
|
||||
|
||||
const { isPackInstalled } = useComfyManagerStore()
|
||||
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
|
||||
|
||||
const { n } = useI18n()
|
||||
|
||||
const formattedDownloads = computed(() =>
|
||||
nodePack.downloads ? n(nodePack.downloads) : ''
|
||||
)
|
||||
</script>
|
||||
@@ -1,41 +0,0 @@
|
||||
<template>
|
||||
<img
|
||||
:src="isImageError ? DEFAULT_ICON : imgSrc"
|
||||
:alt="nodePack.name + ' icon'"
|
||||
class="object-contain rounded-lg max-h-72 max-w-72"
|
||||
:style="{ width: cssWidth, height: cssHeight }"
|
||||
@error="isImageError = true"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
const DEFAULT_ICON = '/assets/images/fallback-gradient-avatar.svg'
|
||||
|
||||
const {
|
||||
nodePack,
|
||||
width = '4.5rem',
|
||||
height = '4.5rem'
|
||||
} = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
width?: string
|
||||
height?: string
|
||||
}>()
|
||||
|
||||
const isImageError = ref(false)
|
||||
const shouldShowFallback = computed(
|
||||
() => !nodePack.icon || nodePack.icon.trim() === '' || isImageError.value
|
||||
)
|
||||
const imgSrc = computed(() =>
|
||||
shouldShowFallback.value ? DEFAULT_ICON : nodePack.icon
|
||||
)
|
||||
|
||||
const convertToCssValue = (value: string | number) =>
|
||||
typeof value === 'number' ? `${value}rem` : value
|
||||
|
||||
const cssWidth = computed(() => convertToCssValue(width))
|
||||
const cssHeight = computed(() => convertToCssValue(height))
|
||||
</script>
|
||||
@@ -1,39 +0,0 @@
|
||||
<template>
|
||||
<div class="relative w-24 h-24">
|
||||
<div
|
||||
v-for="(pack, index) in nodePacks.slice(0, maxVisible)"
|
||||
:key="pack.id"
|
||||
class="absolute"
|
||||
:style="{
|
||||
bottom: `${index * offset}px`,
|
||||
right: `${index * offset}px`,
|
||||
zIndex: maxVisible - index
|
||||
}"
|
||||
>
|
||||
<div class="border rounded-lg p-0.5">
|
||||
<PackIcon :node-pack="pack" width="4.5rem" height="4.5rem" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="nodePacks.length > maxVisible"
|
||||
class="absolute -top-2 -right-2 bg-primary rounded-full w-7 h-7 flex items-center justify-center text-xs font-bold shadow-md z-10"
|
||||
>
|
||||
+{{ nodePacks.length - maxVisible }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
const {
|
||||
nodePacks,
|
||||
maxVisible = 3,
|
||||
offset = 8
|
||||
} = defineProps<{
|
||||
nodePacks: components['schemas']['Node'][]
|
||||
maxVisible?: number
|
||||
offset?: number
|
||||
}>()
|
||||
</script>
|
||||
@@ -34,7 +34,6 @@
|
||||
<script setup lang="ts">
|
||||
import Divider from 'primevue/divider'
|
||||
import Tag from 'primevue/tag'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import SystemStatsPanel from '@/components/common/SystemStatsPanel.vue'
|
||||
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
|
||||
@@ -44,10 +43,4 @@ import PanelTemplate from './PanelTemplate.vue'
|
||||
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
const aboutPanelStore = useAboutPanelStore()
|
||||
|
||||
onMounted(async () => {
|
||||
if (!systemStatsStore.systemStats) {
|
||||
await systemStatsStore.fetchSystemStats()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
</div>
|
||||
|
||||
<template v-if="creditHistory.length > 0">
|
||||
<div class="flex-grow">
|
||||
<div class="grow">
|
||||
<DataTable :value="creditHistory" :show-headers="false">
|
||||
<Column field="title" :header="$t('g.name')">
|
||||
<template #body="{ data }">
|
||||
@@ -112,12 +112,12 @@ import Divider from 'primevue/divider'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { formatMetronomeCurrency } from '@/utils/formatUtil'
|
||||
|
||||
@@ -128,10 +128,10 @@ interface CreditHistoryItemData {
|
||||
isPositive: boolean
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogService = useDialogService()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const commandStore = useCommandStore()
|
||||
const loading = computed(() => authStore.loading)
|
||||
const balanceLoading = computed(() => authStore.isFetchingBalance)
|
||||
|
||||
@@ -160,15 +160,8 @@ const handleCreditsHistoryClick = async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
|
||||
const handleMessageSupport = () => {
|
||||
dialogService.showIssueReportDialog({
|
||||
title: t('issueReport.contactSupportTitle'),
|
||||
subtitle: t('issueReport.contactSupportDescription'),
|
||||
panelProps: {
|
||||
errorType: 'BillingSupport',
|
||||
defaultFields: ['Workflow', 'Logs', 'SystemStats', 'Settings']
|
||||
}
|
||||
})
|
||||
const handleMessageSupport = async () => {
|
||||
await commandStore.execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
const handleFaqClick = () => {
|
||||
|
||||
@@ -295,6 +295,8 @@ async function resetAllKeybindings() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
:deep(.p-datatable-tbody) > tr > td {
|
||||
@apply p-1;
|
||||
min-height: 2rem;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<TabPanel :value="props.value" class="h-full w-full" :class="props.class">
|
||||
<div class="flex flex-col h-full w-full gap-2">
|
||||
<slot name="header" />
|
||||
<ScrollPanel class="flex-grow h-0 pr-2">
|
||||
<ScrollPanel class="grow h-0 pr-2">
|
||||
<slot />
|
||||
</ScrollPanel>
|
||||
<slot name="footer" />
|
||||
|
||||
@@ -6,7 +6,7 @@ import Tooltip from 'primevue/tooltip'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SettingItem from './SettingItem.vue'
|
||||
import SettingItem from '@/platform/settings/components/SettingItem.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
@@ -96,8 +96,8 @@ import Message from 'primevue/message'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
import {
|
||||
AuditLog,
|
||||
EventType,
|
||||
useCustomerEventsService
|
||||
} from '@/services/customerEventsService'
|
||||
|
||||
@@ -57,14 +57,23 @@
|
||||
class="w-8 h-8 mt-4"
|
||||
style="--pc-spinner-color: #000"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
class="mt-4 w-32"
|
||||
severity="secondary"
|
||||
:label="$t('auth.signOut.signOut')"
|
||||
icon="pi pi-sign-out"
|
||||
@click="handleSignOut"
|
||||
/>
|
||||
<div v-else class="mt-4 flex flex-col gap-2">
|
||||
<Button
|
||||
class="w-32"
|
||||
severity="secondary"
|
||||
:label="$t('auth.signOut.signOut')"
|
||||
icon="pi pi-sign-out"
|
||||
@click="handleSignOut"
|
||||
/>
|
||||
<Button
|
||||
v-if="!isApiKeyLogin"
|
||||
class="w-32"
|
||||
severity="danger"
|
||||
:label="$t('auth.deleteAccount.deleteAccount')"
|
||||
icon="pi pi-trash"
|
||||
@click="handleDeleteAccount"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Section -->
|
||||
@@ -100,6 +109,7 @@ const dialogService = useDialogService()
|
||||
const {
|
||||
loading,
|
||||
isLoggedIn,
|
||||
isApiKeyLogin,
|
||||
isEmailProvider,
|
||||
userDisplayName,
|
||||
userEmail,
|
||||
@@ -107,6 +117,7 @@ const {
|
||||
providerName,
|
||||
providerIcon,
|
||||
handleSignOut,
|
||||
handleSignIn
|
||||
handleSignIn,
|
||||
handleDeleteAccount
|
||||
} = useCurrentUser()
|
||||
</script>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import Tag from 'primevue/tag'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { KeyComboImpl } from '@/stores/keybindingStore'
|
||||
import type { KeyComboImpl } from '@/stores/keybindingStore'
|
||||
|
||||
const { keyCombo, isModified = false } = defineProps<{
|
||||
keyCombo: KeyComboImpl
|
||||
|
||||
@@ -79,7 +79,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Form, FormSubmitEvent } from '@primevue/forms'
|
||||
import type { FormSubmitEvent } from '@primevue/forms'
|
||||
import { Form } from '@primevue/forms'
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import Button from 'primevue/button'
|
||||
import InputText from 'primevue/inputtext'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Form } from '@primevue/forms'
|
||||
import { VueWrapper, mount } from '@vue/test-utils'
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Button from 'primevue/button'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
@@ -10,7 +11,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import SignInForm from './SignInForm.vue'
|
||||
|
||||
@@ -76,7 +76,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Form, FormSubmitEvent } from '@primevue/forms'
|
||||
import type { FormSubmitEvent } from '@primevue/forms'
|
||||
import { Form } from '@primevue/forms'
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import Button from 'primevue/button'
|
||||
import InputText from 'primevue/inputtext'
|
||||
@@ -133,6 +134,8 @@ const handleForgotPassword = async (
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
.text-link-disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Form, FormField, FormSubmitEvent } from '@primevue/forms'
|
||||
import type { FormSubmitEvent } from '@primevue/forms'
|
||||
import { Form, FormField } from '@primevue/forms'
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import Button from 'primevue/button'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="w-full px-6 py-4 shadow-lg flex items-center justify-between"
|
||||
:class="{
|
||||
'rounded-t-none': progressDialogContent.isExpanded,
|
||||
'rounded-lg': !progressDialogContent.isExpanded
|
||||
}"
|
||||
>
|
||||
<div class="justify-center text-sm font-bold leading-none">
|
||||
<div class="flex items-center">
|
||||
<template v-if="isInProgress">
|
||||
<i class="pi pi-spin pi-spinner mr-2 text-3xl" />
|
||||
<span>{{ currentTaskName }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="pi pi-check-circle mr-2 text-green-500" />
|
||||
<span class="leading-none">{{
|
||||
$t('manager.restartToApplyChanges')
|
||||
}}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span v-if="isInProgress" class="text-xs font-bold text-neutral-600">
|
||||
{{ comfyManagerStore.uncompletedCount }} {{ $t('g.progressCountOf') }}
|
||||
{{ comfyManagerStore.taskLogs.length }}
|
||||
</span>
|
||||
<div class="flex items-center">
|
||||
<Button
|
||||
v-if="!isInProgress"
|
||||
rounded
|
||||
outlined
|
||||
class="px-4 py-2 rounded-md mr-4"
|
||||
@click="handleRestart"
|
||||
>
|
||||
{{ $t('g.restart') }}
|
||||
</Button>
|
||||
<Button
|
||||
:icon="
|
||||
progressDialogContent.isExpanded
|
||||
? 'pi pi-chevron-up'
|
||||
: 'pi pi-chevron-right'
|
||||
"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
severity="secondary"
|
||||
:aria-label="progressDialogContent.isExpanded ? 'Collapse' : 'Expand'"
|
||||
@click.stop="progressDialogContent.toggle"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
severity="secondary"
|
||||
aria-label="Close"
|
||||
@click.stop="closeDialog"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import { useComfyManagerService } from '@/services/comfyManagerService'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import {
|
||||
useComfyManagerStore,
|
||||
useManagerProgressDialogStore
|
||||
} from '@/stores/comfyManagerStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const progressDialogContent = useManagerProgressDialogStore()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
|
||||
const isInProgress = computed(() => comfyManagerStore.uncompletedCount > 0)
|
||||
|
||||
const closeDialog = () => {
|
||||
dialogStore.closeDialog({ key: 'global-manager-progress-dialog' })
|
||||
}
|
||||
|
||||
const fallbackTaskName = t('g.installing')
|
||||
const currentTaskName = computed(() => {
|
||||
if (!comfyManagerStore.taskLogs.length) return fallbackTaskName
|
||||
const task = comfyManagerStore.taskLogs.at(-1)
|
||||
return task?.taskName ?? fallbackTaskName
|
||||
})
|
||||
|
||||
const handleRestart = async () => {
|
||||
const onReconnect = async () => {
|
||||
// Refresh manager state
|
||||
|
||||
comfyManagerStore.clearLogs()
|
||||
comfyManagerStore.setStale()
|
||||
|
||||
// Refresh node definitions
|
||||
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
|
||||
|
||||
// Reload workflow
|
||||
await useWorkflowService().reloadCurrentWorkflow()
|
||||
}
|
||||
useEventListener(api, 'reconnected', onReconnect, { once: true })
|
||||
|
||||
await useComfyManagerService().rebootComfyUI()
|
||||
closeDialog()
|
||||
}
|
||||
</script>
|
||||
@@ -16,8 +16,8 @@ import { computed } from 'vue'
|
||||
|
||||
import DomWidget from '@/components/graph/widgets/DomWidget.vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
@@ -34,7 +34,7 @@ const updateWidgets = () => {
|
||||
const widget = widgetState.widget
|
||||
|
||||
// Early exit for non-visible widgets
|
||||
if (!widget.isVisible()) {
|
||||
if (!widget.isVisible() || !widgetState.active) {
|
||||
widgetState.visible = false
|
||||
continue
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user