├── .github ├── FUNDING.yml └── workflows │ └── publish_action.yml ├── .gitignore ├── LICENSE ├── README.md ├── README_for_developer.md ├── __init__.py ├── dist └── workspace_web │ ├── App-qo42s2ji.js │ ├── AppIsDirtyEventListener-MhB0DEkr.js │ ├── GalleryModal-7uZByh-M.js │ ├── IconCopy-iXaxUe59.js │ ├── IconSearch-ZN34j33e.js │ ├── MediaPreview-Saiymhm2.js │ ├── ModelManagerTopbar-jQR5azdv.js │ ├── RecentFilesDrawer-_YgQRVlY.js │ ├── ShareDialog-nc3gNuNm.js │ ├── SpotlightSearch-5RTzhpQn.js │ ├── assets │ ├── App-JXePnJiV.css │ └── ModelManagerTopbar--iv3sdjQ.css │ ├── chunk-3RSXBRAN-0rzvZpzF.js │ ├── chunk-7D6N5TE5-rpRnbuGR.js │ ├── chunk-JARCRF6W-U6cp0GaY.js │ ├── chunk-NTCQBYKE-IkW3A38m.js │ ├── chunk-VTV6N5LE-VOXf0Qhu.js │ ├── civitUtils-zqcTmmqF.js │ ├── input.js │ └── useDebounceFn-ld478fd0.js ├── entry └── entry.js ├── pyproject.toml ├── requirements.txt ├── scripts └── setupGitHooks.js ├── service ├── db_service.py ├── file_sync_service.py ├── media_service.py ├── model_manager │ ├── missing_models.py │ ├── model_installer.py │ ├── model_list.py │ ├── model_preview.py │ └── nodes_installer.py ├── node_service.py ├── scan_my_workflows_folder.py ├── setting_service.py └── twoway_sync_folder_service.py └── ui ├── .eslintrc.cjs ├── .prettierrc ├── index.html ├── package-lock.json ├── package.json ├── src ├── Api.ts ├── App.tsx ├── MyCSSReset.tsx ├── RecentFilesDrawer │ ├── AddTagToWorkflowPopover.tsx │ ├── FilesListFolderItem.tsx │ ├── FilesListFolderItemRightClickMenu.tsx │ ├── ImportFlowsFileInput.tsx │ ├── InsertWorkflowToCanvas.ts │ ├── ItemsList.tsx │ ├── ManageTagsModal.tsx │ ├── MoreActionMenu.tsx │ ├── MultipleSelectionOperation.tsx │ ├── MyTagsRow.tsx │ ├── RecentFilesDrawer.tsx │ ├── RecentFilesDrawerMenu.tsx │ ├── WorkflowListItem.tsx │ ├── WorkflowListItemActionButtons.tsx │ ├── WorkspaceSettingsModal.tsx │ └── types.ts ├── WorkspaceContext.ts ├── apis │ ├── TwowaySyncApi.ts │ └── TwowaySyncFolderApi.ts ├── components │ ├── AlertDialogProvider.tsx │ ├── Carousel │ │ └── Carousel.tsx │ ├── CopyShareLinkMenuItem.tsx │ ├── CreateVersionDialog.tsx │ ├── CustomMenu.tsx │ ├── CustomSelector.tsx │ ├── DeleteConfirm.tsx │ ├── Draggable.tsx │ ├── DropdownTitle.tsx │ ├── EditFlowName.tsx │ ├── EditFolderName.tsx │ ├── HoverMenu.tsx │ ├── MediaPreview.tsx │ ├── Overlay.tsx │ ├── SearchInput.tsx │ ├── SpotlightSearch.tsx │ ├── VersionHistoryDrawer.tsx │ └── copySharelink.tsx ├── const.ts ├── customHooks │ ├── useDebounce.ts │ ├── useDebounceFn.ts │ └── useStateRef.ts ├── db-tables │ ├── ChangelogsTable.ts │ ├── DiskFileUtils.ts │ ├── FoldersTable.ts │ ├── IndexDBUtils.ts │ ├── MediaTable.ts │ ├── ModelsTable.ts │ ├── TableBase.ts │ ├── UserSettingsTable.ts │ ├── WorkflowVersionsTable.ts │ ├── WorkflowsTable.ts │ ├── WorkspaceDB.ts │ ├── indexdb.ts │ └── tagsTable.ts ├── defaultGraph.ts ├── gallery │ ├── GalleryContext.ts │ ├── GalleryModal.tsx │ ├── components │ │ ├── AllPromptForm │ │ │ └── AllPromptForm.tsx │ │ ├── FormItem │ │ │ ├── CheckboxBase.tsx │ │ │ ├── FormItemComponent.tsx │ │ │ ├── InputBase.tsx │ │ │ ├── InputSlider.tsx │ │ │ ├── NoSupport.tsx │ │ │ ├── SelectBase.tsx │ │ │ ├── TextareaBase.tsx │ │ │ └── types.ts │ │ ├── GalleryCarouselImageViewer.tsx │ │ ├── GalleryGridView.tsx │ │ ├── GalleryMediaItem.tsx │ │ ├── GalleryRightCol.tsx │ │ ├── GalleryRightColHeaderButtons.tsx │ │ ├── MetaBox │ │ │ ├── MetadataForm.tsx │ │ │ ├── metaBoxContext.ts │ │ │ └── utils.ts │ │ └── TopForm │ │ │ └── TopForm.tsx │ └── utils.ts ├── index.css ├── main.tsx ├── model-manager │ ├── ManagerContext.ts │ ├── api │ │ └── modelsApi.ts │ ├── civitSearchTypes.ts │ ├── civitiModelFull.json │ ├── comfy-types │ │ └── comfy.d.ts │ ├── components │ │ └── Overlay.tsx │ ├── hooks │ │ ├── ServerEventListener.ts │ │ ├── useDebaunce.ts │ │ └── useUpdateModels.ts │ ├── install-models │ │ ├── AddApiKeyPopover.tsx │ │ ├── ChooseFolder.tsx │ │ ├── InstallModelSearchBar.tsx │ │ ├── InstallModelsButton.tsx │ │ ├── InstallModelsModal.tsx │ │ ├── InstallProgress.tsx │ │ ├── ModelCard.tsx │ │ └── util │ │ │ ├── getModelFromCivitAPI.ts │ │ │ ├── getModelFromSearch.ts │ │ │ └── modelTypes.ts │ ├── missing-models-drawer │ │ ├── MissingModelItem.tsx │ │ └── MissingModelsListDrawer.tsx │ ├── modelGetCivitiResp.json │ ├── models-list-drawer │ │ ├── ModelItem.tsx │ │ ├── ModelsList.tsx │ │ ├── ModelsListDrawer.tsx │ │ └── ModelsTags.tsx │ ├── topbar │ │ ├── InstallMissingModelsButton.tsx │ │ ├── ModelDropEventListener.tsx │ │ ├── ModelManagerTopbar.tsx │ │ └── index.css │ ├── types.ts │ └── utils.tsx ├── settings │ ├── AutosaveSetting.tsx │ ├── CloudHostSetting.tsx │ ├── CommonCheckboxSettings.tsx │ ├── CommonNumberSetting.tsx │ ├── EnableTwowaySyncConfirm.tsx │ ├── FolderOnTopSettings.tsx │ ├── SelectMyWorkflowsDir.tsx │ ├── SharekeySetting.tsx │ ├── ShortcutSettings.tsx │ ├── ShowNsfwModelThumbnailSettings.tsx │ └── TwoWaySyncSettings.tsx ├── share │ ├── ShareDialog.tsx │ ├── ShareDialogWorkflowVersionRadio.tsx │ ├── SharedTopbarButton.tsx │ └── shareUtils.tsx ├── spacejson │ ├── ResourceDepsForm.tsx │ └── handleDownloadSpaceJson.ts ├── topbar │ ├── AppIsDirtyEventListener.tsx │ ├── TabBroadcastChannel.ts │ ├── Topbar.css │ ├── Topbar.tsx │ ├── TopbarNewWorkflowButton.tsx │ └── VersionNameTopbar.tsx ├── types │ ├── comfy.d.ts │ ├── dbTypes.ts │ ├── litegraph.d.ts │ ├── types.ts │ └── workspace.d.ts ├── utils.tsx ├── utils │ ├── OsPathUtils.ts │ ├── civitUtils.ts │ ├── comfyapp.ts │ ├── deepJsonDiffCheck.ts │ ├── downloadJsonFile.ts │ ├── downloadWorkflowsZip.ts │ ├── encryptUtils.ts │ ├── findSfwImage.ts │ ├── jsonUtils.ts │ ├── mediaMetadataUtils.ts │ ├── privacyUtils.ts │ ├── saveShareKey.ts │ ├── showAlert.css │ ├── showAlert.ts │ └── twowaySyncUtils.ts ├── versionHistory │ └── CreateVersionLogin.tsx └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [11cafe] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/workflows/publish_action.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Comfy registry 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "pyproject.toml" 9 | 10 | jobs: 11 | publish-node: 12 | name: Publish Custom Node to registry 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Check out code 16 | uses: actions/checkout@v4 17 | - name: Publish Custom Node 18 | uses: Comfy-Org/publish-node-action@main 19 | with: 20 | personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} ## Add your own personal access token to your Github Repository secrets and reference it here. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | hash 3 | .env 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | pnpm-debug.log* 11 | lerna-debug.log* 12 | 13 | ui/node_modules 14 | # ui/src/utils/encryptUtils.ts 15 | db 16 | dist-ssr 17 | *.local 18 | backup 19 | # Editor directories and files 20 | .vscode/* 21 | !.vscode/extensions.json 22 | .idea 23 | .DS_Store 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | .cache 30 | # dev 31 | ui/yarn.lock -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 11cafe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README_for_developer.md: -------------------------------------------------------------------------------- 1 | # ☕️ ComfyUI Workspace Manager - Comfyspace 2 | 3 | This is a document specially prepared for developers, explaining some special development details.Installation 4 | 5 | ## Clean up indexdb if it's in bad state 6 | 7 | Make sure you have .json files under `ComfyUI/comfyui-workspace-manager/db`, so your indexdb data (version history, gallery image data) can be recovered after deleting 8 | 9 | Screenshot 2024-03-12 at 12 35 24 PM 10 | 11 | 1. F12 -> Application -> IndexedDB -> delete current indexdb 12 | 13 | Screenshot 2024-03-12 at 12 30 48 PM 14 | 15 | 3. If you want to recover your data like version history, gallery image data, F12 -> Application -> Local Storage -> delete WORKSPACE_INDEXDB_BACKFILL key in localstorage so your indexdb can be backedup (you don't have to do this if you only need workflows .json data) 16 | 17 | Screenshot 2024-03-12 at 12 33 08 PM 18 | 19 | ## Install custom git hooks 20 | 21 | When running the project for the first time, it is recommended that you execute the following command to install our customized git hooks 22 | 23 | ```javascript 24 | cd ui 25 | npm run setupGithooks 26 | ``` 27 | 28 | Current hooks include: 29 | 30 | 1. When switching to a non-main/beta branch, additional .gitignore logic is automatically added to ignore the "/dist" folder. 31 | 32 | ## How to use Hot Module Replacement 33 | 34 | 1. npm run dev starts the project; 35 | 2. If the dist directory currently exists, please delete the dist directory or keep the dist directory empty; 36 | 3. Modify ComfyUI/web/index.html and add the following code. It should be noted that the port number in localhost:5173 needs to be consistent with the port number of the vite local service started by npm run dev.![image](https://github.com/11cafe/comfyui-workspace-manager/assets/26196917/ef7eabc5-8683-4f9a-93f3-e3ba2b0d3449) 37 | 38 | ```javascript 39 | import RefreshRuntime from "http://localhost:5173/@react-refresh"; 40 | RefreshRuntime.injectIntoGlobalHook(window); 41 | window.$RefreshReg$ = () => {}; 42 | window.$RefreshSig$ = () => (type) => type; 43 | window.__vite_plugin_react_preamble_installed__ = true; 44 | 45 | const head = document.getElementsByTagName("head")[0]; 46 | const viteClientScript = document.createElement("script"); 47 | viteClientScript.src = "http://localhost:5173/@vite/client"; 48 | viteClientScript.type = "module"; 49 | head.appendChild(viteClientScript); 50 | const workspaceMainScript = document.createElement("script"); 51 | workspaceMainScript.src = "http://localhost:5173/src/main.tsx"; 52 | workspaceMainScript.type = "module"; 53 | head.appendChild(workspaceMainScript); 54 | ``` 55 | -------------------------------------------------------------------------------- /dist/workspace_web/AppIsDirtyEventListener-MhB0DEkr.js: -------------------------------------------------------------------------------- 1 | import{r as l,N as r}from"./input.js";import{W as S,ag as w,w as p,E as f,ah as _,ai as C}from"./App-qo42s2ji.js";import{u as b}from"./useDebounceFn-ld478fd0.js";function R(){const{isDirty:d,setIsDirty:h,setRoute:g,saveCurWorkflow:v,setCurFlowIDAndName:k}=l.useContext(S),E=l.useRef(d);l.useEffect(()=>{E.current=d},[d]),l.useEffect(()=>{const c=e=>{var n;if(document.visibilityState==="hidden")return;const t=C(e);if(t)switch(t===f.openSpotlightSearch&&e.preventDefault(),window.dispatchEvent(new CustomEvent(_,{detail:{shortcutType:t}})),t){case f.SAVE:v();break;case f.SAVE_AS:g("saveAsModal");break;case f.openSpotlightSearch:g("spotlightSearch");break}else(n=e.target)!=null&&n.matches("input, textarea")&&Object.keys(r.canvas.selected_nodes??{}).length&&m()},u=async()=>{var t,n,a,i;if(!((t=r)!=null&&t.graph)){console.error("🦄 Error in workspace manager! app.graph is not available in restoreCurWorkflow()");return}const e=(a=(n=r.graph.extra)==null?void 0:n[w])==null?void 0:a.id;if(e){const s=await((i=p)==null?void 0:i.get(e));s&&k(s),!(s!=null&&s.saveLock)&&s&&s.json!==JSON.stringify(r.graph.serialize())&&h(!0)}},o=r.graph.onConfigure;return r.graph.onConfigure=function(){o==null||o.apply(this,arguments),setTimeout(()=>{var t,n,a,i;const e=(n=(t=r.graph.extra)==null?void 0:t[w])==null?void 0:n.id;(e==null||e!=((i=(a=p)==null?void 0:a.curWorkflow)==null?void 0:i.id))&&k(null)},500)},document.addEventListener("click",e=>{Object.keys(r.canvas.selected_nodes??{}).length&&(r.canvas.node_over!=null||r.canvas.node_capturing_input!=null||r.canvas.node_widget!=null)&&m()}),document.addEventListener("keydown",c),u(),()=>{document.removeEventListener("keydown",c)}},[]);const y=async()=>{var u,o,e,t;if((o=(u=p)==null?void 0:u.curWorkflow)!=null&&o.saveLock||E.current)return;const c=r.graph.serialize();JSON.stringify(c)!==((t=(e=p)==null?void 0:e.curWorkflow)==null?void 0:t.json)&&h(!0)},[m,D]=b(y,900);return null}export{R as default}; 2 | -------------------------------------------------------------------------------- /dist/workspace_web/IconCopy-iXaxUe59.js: -------------------------------------------------------------------------------- 1 | import{d as a}from"./App-qo42s2ji.js";var p=a("copy","IconCopy",[["path",{d:"M7 7m0 2.667a2.667 2.667 0 0 1 2.667 -2.667h8.666a2.667 2.667 0 0 1 2.667 2.667v8.666a2.667 2.667 0 0 1 -2.667 2.667h-8.666a2.667 2.667 0 0 1 -2.667 -2.667z",key:"svg-0"}],["path",{d:"M4.012 16.737a2.005 2.005 0 0 1 -1.012 -1.737v-10c0 -1.1 .9 -2 2 -2h10c.75 0 1.158 .385 1.5 1",key:"svg-1"}]]);export{p as I}; 2 | -------------------------------------------------------------------------------- /dist/workspace_web/IconSearch-ZN34j33e.js: -------------------------------------------------------------------------------- 1 | import{d as a}from"./App-qo42s2ji.js";var r=a("search","IconSearch",[["path",{d:"M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0",key:"svg-0"}],["path",{d:"M21 21l-6 -6",key:"svg-1"}]]);export{r as I}; 2 | -------------------------------------------------------------------------------- /dist/workspace_web/assets/App-JXePnJiV.css: -------------------------------------------------------------------------------- 1 | .dragPanelIcon{display:none!important}.workspaceManagerPanel:hover .dragPanelIcon{display:block!important} 2 | -------------------------------------------------------------------------------- /dist/workspace_web/assets/ModelManagerTopbar--iv3sdjQ.css: -------------------------------------------------------------------------------- 1 | .drag-model-manager-top-bar-icon{visibility:hidden!important}.model-manager-top-bar:hover .drag-model-manager-top-bar-icon{visibility:visible!important} 2 | -------------------------------------------------------------------------------- /dist/workspace_web/chunk-3RSXBRAN-0rzvZpzF.js: -------------------------------------------------------------------------------- 1 | import{f as x,j as l,g as i,e as w,k as C,o as E,l as I,r as f,a5 as P}from"./input.js";import{ab as z}from"./App-qo42s2ji.js";var v=x(function(o,s){const{children:t,placeholder:n,className:r,...a}=o;return l.jsxs(i.select,{...a,ref:s,className:w("chakra-select",r),children:[n&&l.jsx("option",{value:"",children:n}),t]})});v.displayName="SelectField";function F(e,o){const s={},t={};for(const[n,r]of Object.entries(e))o.includes(n)?s[n]=r:t[n]=r;return[s,t]}var H=x((e,o)=>{var s;const t=C("Select",e),{rootProps:n,placeholder:r,icon:a,color:c,height:_,h:d,minH:h,minHeight:y,iconColor:p,iconSize:u,...j}=E(e),[g,N]=F(j,P),m=z(N),k={width:"100%",height:"fit-content",position:"relative",color:c},b={paddingEnd:"2rem",...t.field,_focus:{zIndex:"unset",...(s=t.field)==null?void 0:s._focus}};return l.jsxs(i.div,{className:"chakra-select__wrapper",__css:k,...g,...n,children:[l.jsx(v,{ref:o,height:d??_,minH:h??y,placeholder:r,...m,__css:b,children:e.children}),l.jsx(S,{"data-disabled":I(m.disabled),...(p||c)&&{color:p||c},__css:t.icon,...u&&{fontSize:u},children:a})]})});H.displayName="Select";var M=e=>l.jsx("svg",{viewBox:"0 0 24 24",...e,children:l.jsx("path",{fill:"currentColor",d:"M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"})}),R=i("div",{baseStyle:{position:"absolute",display:"inline-flex",alignItems:"center",justifyContent:"center",pointerEvents:"none",top:"50%",transform:"translateY(-50%)"}}),S=e=>{const{children:o=l.jsx(M,{}),...s}=e,t=f.cloneElement(o,{role:"presentation",className:"chakra-select__icon",focusable:!1,"aria-hidden":!0,style:{width:"1em",height:"1em",color:"currentColor"}});return l.jsx(R,{...s,className:"chakra-select__icon-wrapper",children:f.isValidElement(o)?t:null})};S.displayName="SelectIcon";export{H as S}; 2 | -------------------------------------------------------------------------------- /dist/workspace_web/chunk-JARCRF6W-U6cp0GaY.js: -------------------------------------------------------------------------------- 1 | import{b as D,r as h,j as t,g as i,a2 as v,f as j,k as T,o as B,H as E,e as $}from"./input.js";import{u as z}from"./chunk-7D6N5TE5-rpRnbuGR.js";var[Z,F]=D({name:"CheckboxGroupContext",strict:!1});function M(o){const[s,n]=h.useState(o),[e,r]=h.useState(!1);return o!==s&&(r(!0),n(o)),e}function W(o){return t.jsx(i.svg,{width:"1.2em",viewBox:"0 0 12 10",style:{fill:"none",strokeWidth:2,stroke:"currentColor",strokeDasharray:16},...o,children:t.jsx("polyline",{points:"1.5 6 4.5 9 10.5 1"})})}function X(o){return t.jsx(i.svg,{width:"1.2em",viewBox:"0 0 24 24",style:{stroke:"currentColor",strokeWidth:4},...o,children:t.jsx("line",{x1:"21",x2:"3",y1:"12",y2:"12"})})}function H(o){const{isIndeterminate:s,isChecked:n,...e}=o,r=s?X:W;return n||s?t.jsx(i.div,{style:{display:"flex",alignItems:"center",justifyContent:"center",height:"100%"},children:t.jsx(r,{...e})}):null}var L={display:"inline-flex",alignItems:"center",justifyContent:"center",verticalAlign:"top",userSelect:"none",flexShrink:0},O={cursor:"pointer",display:"inline-flex",alignItems:"center",verticalAlign:"top",position:"relative"},q=v({from:{opacity:0,strokeDashoffset:16,transform:"scale(0.95)"},to:{opacity:1,strokeDashoffset:0,transform:"scale(1)"}}),J=v({from:{opacity:0},to:{opacity:1}}),K=v({from:{transform:"scaleX(0.65)"},to:{transform:"scaleX(1)"}}),Q=j(function(s,n){const e=F(),r={...e,...s},a=T("Checkbox",r),c=B(s),{spacing:x="0.5rem",className:C,children:m,iconColor:u,iconSize:d,icon:k=t.jsx(H,{}),isChecked:f,isDisabled:g=e==null?void 0:e.isDisabled,onChange:p,inputProps:w,..._}=c;let y=f;e!=null&&e.value&&c.value&&(y=e.value.includes(c.value));let b=p;e!=null&&e.onChange&&c.value&&(b=E(e.onChange,p));const{state:l,getInputProps:A,getCheckboxProps:S,getLabelProps:G,getRootProps:P}=z({..._,isDisabled:g,isChecked:y,onChange:b}),I=M(l.isChecked),R=h.useMemo(()=>({animation:I?l.isIndeterminate?`${J} 20ms linear, ${K} 200ms linear`:`${q} 200ms linear`:void 0,fontSize:d,color:u,...a.icon}),[u,d,I,l.isIndeterminate,a.icon]),N=h.cloneElement(k,{__css:R,isIndeterminate:l.isIndeterminate,isChecked:l.isChecked});return t.jsxs(i.label,{__css:{...O,...a.container},className:$("chakra-checkbox",C),...P(),children:[t.jsx("input",{className:"chakra-checkbox__input",...A(w,n)}),t.jsx(i.span,{__css:{...L,...a.control},className:"chakra-checkbox__control",...S(),children:N}),m&&t.jsx(i.span,{className:"chakra-checkbox__label",...G(),__css:{marginStart:x,...a.label},children:m})]})});Q.displayName="Checkbox";var U=j(function(s,n){const{templateAreas:e,gap:r,rowGap:a,columnGap:c,column:x,row:C,autoFlow:m,autoRows:u,templateRows:d,autoColumns:k,templateColumns:f,...g}=s,p={display:"grid",gridTemplateAreas:e,gridGap:r,gridRowGap:a,gridColumnGap:c,gridAutoColumns:k,gridColumn:x,gridRow:C,gridAutoFlow:m,gridAutoRows:u,gridTemplateRows:d,gridTemplateColumns:f};return t.jsx(i.div,{ref:n,__css:p,...g})});U.displayName="Grid";export{Q as C,U as G}; 2 | -------------------------------------------------------------------------------- /dist/workspace_web/chunk-NTCQBYKE-IkW3A38m.js: -------------------------------------------------------------------------------- 1 | import{f as t}from"./App-qo42s2ji.js";import{f as o,j as s}from"./input.js";var e=o((a,r)=>s.jsx(t,{align:"center",...a,direction:"column",ref:r}));e.displayName="VStack";export{e as V}; 2 | -------------------------------------------------------------------------------- /dist/workspace_web/chunk-VTV6N5LE-VOXf0Qhu.js: -------------------------------------------------------------------------------- 1 | import{f as l,n as f,o as h,j as e,g as n,e as m,k as y,r as o}from"./input.js";import{u as w}from"./chunk-7D6N5TE5-rpRnbuGR.js";var N=l(function(a,i){const s=f("Link",a),{className:c,isExternal:t,...r}=h(a);return e.jsx(n.a,{target:t?"_blank":void 0,rel:t?"noopener":void 0,ref:i,className:m("chakra-link",c),...r,__css:s})});N.displayName="Link";var j=l(function(a,i){const s=y("Switch",a),{spacing:c="0.5rem",children:t,...r}=h(a),{getIndicatorProps:u,getInputProps:p,getCheckboxProps:x,getRootProps:_,getLabelProps:g}=w(r),b=o.useMemo(()=>({display:"inline-block",position:"relative",verticalAlign:"middle",lineHeight:0,...s.container}),[s.container]),S=o.useMemo(()=>({display:"inline-flex",flexShrink:0,justifyContent:"flex-start",boxSizing:"content-box",cursor:"pointer",...s.track}),[s.track]),d=o.useMemo(()=>({userSelect:"none",marginStart:c,...s.label}),[c,s.label]);return e.jsxs(n.label,{..._(),className:m("chakra-switch",a.className),__css:b,children:[e.jsx("input",{className:"chakra-switch__input",...p({},i)}),e.jsx(n.span,{...x(),className:"chakra-switch__track",__css:S,children:e.jsx(n.span,{__css:s.thumb,className:"chakra-switch__thumb",...u()})}),t&&e.jsx(n.span,{className:"chakra-switch__label",...g(),__css:d,children:t})]})});j.displayName="Switch";export{N as L,j as S}; 2 | -------------------------------------------------------------------------------- /dist/workspace_web/civitUtils-zqcTmmqF.js: -------------------------------------------------------------------------------- 1 | import{h as m}from"./App-qo42s2ji.js";const r="WORKSPACE_CIVIT_API_KEY_STORAGE_KEY";function h(){return localStorage.getItem(r)}function g(e){localStorage.setItem(r,e)}function v(e){return`https://civitai.com/api/download/models/${e}`}async function w(e){var s,a,n;try{const l=`https://civitai.com/api/v1/model-versions/by-hash/${e}`,t=await(await fetch(l)).json();let i;if(await((s=m)==null?void 0:s.getSetting("showNsfwModelThumbnail"))===!0)i=(n=(a=t==null?void 0:t.images)==null?void 0:a[0])==null?void 0:n.url;else if(!t.model.nsfw){const o=t.images.find(c=>c.nsfwLevel==1);i=o==null?void 0:o.url}return{modelName:t.model.name,civitModelID:String(t.modelId),civitModelVersionID:String(t.id),imageUrl:i??void 0}}catch{return{}}}export{v as a,w as f,h as g,g as s}; 2 | -------------------------------------------------------------------------------- /dist/workspace_web/useDebounceFn-ld478fd0.js: -------------------------------------------------------------------------------- 1 | import{r}from"./input.js";function a(u,c){const e=r.useRef(0),t=r.useCallback(()=>{e.current!==null&&(clearTimeout(e.current),e.current=0)},[]),n=r.useCallback((...o)=>{t(),e.current=setTimeout(()=>u(...o),c)},[u,c,t]);return r.useEffect(()=>()=>{t()},[]),[n,t]}export{a as u}; 2 | -------------------------------------------------------------------------------- /entry/entry.js: -------------------------------------------------------------------------------- 1 | //@ts-ignore 2 | import { api } from "../../scripts/api.js"; 3 | 4 | setTimeout(() => { 5 | import(api.api_base + "/workspace_web/input.js"); 6 | }, 500); 7 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "comfyui-workspace-manager" 3 | description = "A ComfyUI custom node for project management to centralize the management of all your workflows in one place. Seamlessly switch between workflows, create and update them within a single workspace, like Google Docs." 4 | version = "1.0.0" 5 | license = { file = "LICENSE" } 6 | 7 | [project.urls] 8 | Repository = "https://github.com/11cafe/comfyui-workspace-manager" 9 | 10 | [tool.comfy] 11 | PublisherId = "weixuan11" 12 | DisplayName = "comfyui-workspace-manager" 13 | Icon = "" -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | send2trash 2 | -------------------------------------------------------------------------------- /scripts/setupGitHooks.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const { exec } = require('child_process'); 3 | const path = require('path'); 4 | 5 | const files = [ 6 | { path: '../.git/info/exclude.dist', content: '/dist' }, 7 | { 8 | path: '../.git/hooks/post-checkout', 9 | content: ` 10 | #!/bin/sh 11 | 12 | # Get the current branch name 13 | BRANCH_NAME=$(git rev-parse --symbolic-full-name --abbrev-ref HEAD) 14 | 15 | # Remove previously set exclude files 16 | rm -f .git/info/exclude 17 | 18 | # When not in the beta/main branch, add additional files that need to be ignored 19 | if [ "$BRANCH_NAME" != "beta" ] && [ "$BRANCH_NAME" != "main" ]; then 20 | cp .git/info/exclude.dist .git/info/exclude 21 | # elif [ "$BRANCH_NAME" = "xxx" ]; then 22 | # cp .git/info/exclude.xxx .git/info/exclude 23 | fi`, 24 | }, 25 | ]; 26 | 27 | function createFile(filePath, content) { 28 | fs.writeFile(filePath, content, (err) => { 29 | if (err) { 30 | console.error(`An error occurred while creating file ${filePath}:`, err); 31 | } 32 | }); 33 | } 34 | 35 | function executeCommand(command, workingDirectory = '../') { 36 | exec(command, { cwd: path.resolve(__dirname, workingDirectory) }, (error, stdout, stderr) => { 37 | if (error) { 38 | console.error(`An error occurred while executing command "${command}":`, error); 39 | return; 40 | } 41 | if (stderr) { 42 | console.error(`Command error "${stderr}"`); 43 | return; 44 | } 45 | }); 46 | } 47 | 48 | function main() { 49 | files.forEach((file) => { 50 | createFile(file.path, file.content); 51 | }); 52 | executeCommand('git config advice.ignoredHook false'); 53 | executeCommand('chmod +x .git/hooks/post-checkout'); 54 | console.log('Custom git hooks installation completed') 55 | } 56 | 57 | main(); 58 | -------------------------------------------------------------------------------- /service/db_service.py: -------------------------------------------------------------------------------- 1 | 2 | import server 3 | import os 4 | import folder_paths 5 | import json 6 | import asyncio 7 | from aiohttp import web 8 | from .model_manager.model_preview import get_thumbnail_for_image_file 9 | 10 | workspace_path = os.path.join(os.path.dirname(os.path.dirname(__file__))) 11 | comfy_path = os.path.dirname(folder_paths.__file__) 12 | db_dir_path = os.path.join(workspace_path, "db") 13 | DEFAULT_USER = "guest" 14 | 15 | @server.PromptServer.instance.routes.post("/workspace/save_db") 16 | async def save_db(request): 17 | # Extract parameters from the request 18 | data = await request.json() 19 | table = data['table'] 20 | json_data = data['json'] 21 | 22 | file_name = f'{db_dir_path}/{table}.json' 23 | # Offload file writing to a separate thread 24 | def write_json_string_to_db(file_name, json_data): 25 | if not os.path.exists(db_dir_path): 26 | os.makedirs(db_dir_path) 27 | # Write the JSON data to the specified file 28 | with open(file_name, 'w') as file: 29 | file.write(json.dumps(json_data, indent=4)) 30 | await asyncio.to_thread(write_json_string_to_db, file_name, json_data) 31 | return web.Response(text=f"JSON saved to {file_name}") 32 | 33 | def read_table(table): 34 | if not table: 35 | return None 36 | file_name = f'{db_dir_path}/{table}.json' 37 | if not os.path.exists(file_name): 38 | return None 39 | 40 | try: 41 | with open(file_name, 'r', encoding='utf-8') as file: 42 | data = json.load(file) 43 | return data 44 | except json.JSONDecodeError as e: 45 | return None 46 | 47 | 48 | @server.PromptServer.instance.routes.get("/workspace/get_db") 49 | async def get_workspace(request): 50 | # Extract the table parameter from the query string 51 | table = request.query.get('table') 52 | data = await asyncio.to_thread(read_table, table) 53 | return web.json_response(data) 54 | -------------------------------------------------------------------------------- /service/model_manager/model_preview.py: -------------------------------------------------------------------------------- 1 | import os 2 | from PIL import Image 3 | import base64 4 | from io import BytesIO 5 | from pathlib import Path 6 | 7 | MAX_IMAGE_SIZE = 250 8 | 9 | def preview_file(filename: str): 10 | preview_exts = [".jpg", ".png", ".jpeg", ".gif"] 11 | preview_exts = [*preview_exts, *[".preview" + x for x in preview_exts]] 12 | for ext in preview_exts: 13 | try: 14 | pathStr = os.path.splitext(filename)[0] + ext 15 | path = Path(pathStr).resolve() 16 | if os.path.exists(path): 17 | # because ComfyUI has extra model path feature 18 | # the path might not be relative to the ComfyUI root 19 | # so instead of returning the path, we return the image data directly, to avoid security issues 20 | bytes = get_thumbnail_for_image_file(path) 21 | # Get the base64 string 22 | img_base64 = base64.b64encode(bytes).decode() 23 | # Return the base64 string 24 | return f"data:image/jpeg;base64, {img_base64}" 25 | except Exception as e: 26 | print(f"Error opening image preview: {e}") 27 | return None 28 | 29 | 30 | def get_thumbnail_for_image_file(file_path: Path): 31 | try: 32 | with Image.open(file_path) as img: 33 | # If the image is too large, resize it 34 | if img.width > MAX_IMAGE_SIZE and img.height > MAX_IMAGE_SIZE: 35 | # Calculate new width to maintain aspect ratio 36 | width = int(img.width * MAX_IMAGE_SIZE / img.height) 37 | # Resize the image 38 | img = img.resize((width, MAX_IMAGE_SIZE)) 39 | img = img.convert("RGB") 40 | # Save the image to a BytesIO object 41 | buffer = BytesIO() 42 | img.save(buffer, format="JPEG", quality=85) 43 | return buffer.getvalue() 44 | except Exception as e: 45 | print(f"Error opening image preview: {e}") 46 | return None 47 | -------------------------------------------------------------------------------- /service/model_manager/nodes_installer.py: -------------------------------------------------------------------------------- 1 | from .model_installer import download_url_with_wget 2 | import server 3 | from aiohttp import web 4 | import aiohttp 5 | import requests 6 | import folder_paths 7 | import os 8 | import sys 9 | import threading 10 | import subprocess # don't remove this 11 | from urllib.parse import urlparse 12 | import subprocess 13 | import os 14 | import json 15 | import urllib.request 16 | 17 | workspace_path = os.path.join(os.path.dirname(__file__)) 18 | comfy_path = os.path.dirname(folder_paths.__file__) 19 | 20 | @server.PromptServer.instance.routes.post("/workspace/find_nodes") 21 | async def install_nodes(request): 22 | post_params = await request.json() 23 | # [{'authorName': 'Fannovel16', 'gitHtmlUrl': 'https://github.com/Fannovel16/comfyui_controlnet_aux', 'totalInstalls': 1, 'description': None, 'id': 'TilePreprocessor'}] 24 | resp = fetch_server(post_params['nodes']) 25 | return web.json_response(resp, content_type='application/json') 26 | 27 | 28 | async def install_node(gitUrl): 29 | print(f"Installing custom node from git '{gitUrl}'") 30 | try: 31 | if gitUrl.endswith("/"): 32 | gitUrl = gitUrl[:-1] 33 | repo_name = os.path.splitext(os.path.basename(gitUrl))[0] 34 | repo_path = os.path.join(comfy_path, 'custom_nodes', repo_name) 35 | print('repo_path', repo_path) 36 | try: 37 | Repo.clone_from(gitUrl+'.git', repo_path) 38 | except Exception as e: 39 | print(f"Error cloning repo: {e}") 40 | return f"Error cloning repo: {e}\n" 41 | return f"Installed custom node from git '{gitUrl}'\n" 42 | except Exception as e: 43 | return f"Error installing custom node from git '{gitUrl}': {e}\n" 44 | 45 | 46 | @server.PromptServer.instance.routes.post("/workspace/install_nodes") 47 | async def install_nodes(request): 48 | response = web.StreamResponse() 49 | response.headers['Content-Type'] = 'text/plain' 50 | await response.prepare(request) 51 | 52 | post_params = await request.json() 53 | nodes = post_params['nodes'] 54 | 55 | tasks = [] 56 | print(f"Installing custom nodes", nodes) 57 | custom_node_path = os.path.join(comfy_path, 'custom_nodes') 58 | for custom_node in nodes: 59 | gitUrl = custom_node['gitHtmlUrl'] 60 | print(f"Cloning repository: {gitUrl}") 61 | run_script(["git", "clone", gitUrl+'.git'], custom_node_path) 62 | 63 | 64 | def handle_stream(stream, prefix): 65 | for line in stream: 66 | print(prefix + line, end='') 67 | 68 | 69 | def run_script(cmd, cwd='.'): 70 | if len(cmd) > 0 and cmd[0].startswith("#"): 71 | print(f"[model-manager] Unexpected behavior: `{cmd}`") 72 | return 0 73 | 74 | process = subprocess.Popen( 75 | cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1) 76 | 77 | stdout_thread = threading.Thread( 78 | target=handle_stream, args=(process.stdout, "")) 79 | stderr_thread = threading.Thread( 80 | target=handle_stream, args=(process.stderr, "[!]")) 81 | 82 | stdout_thread.start() 83 | stderr_thread.start() 84 | 85 | stdout_thread.join() 86 | stderr_thread.join() 87 | 88 | return process.wait() 89 | 90 | -------------------------------------------------------------------------------- /service/setting_service.py: -------------------------------------------------------------------------------- 1 | 2 | import server 3 | import os 4 | import folder_paths 5 | import json 6 | import asyncio 7 | from aiohttp import web 8 | from .db_service import DEFAULT_USER, read_table, db_dir_path 9 | 10 | comfy_path = os.path.dirname(folder_paths.__file__) 11 | 12 | @server.PromptServer.instance.routes.get("/workspace/get_settings") 13 | def get_settings_endpoint(request): 14 | settings = get_settings() 15 | return web.json_response(text=json.dumps(settings), content_type='application/json') 16 | 17 | def get_settings(): 18 | new_settings_path = os.path.join(db_dir_path, 'settings.json') 19 | if os.path.exists(new_settings_path): 20 | # to deprecate and remove legacy settings file 21 | # if os.path.exists(f'{db_dir_path}/useSettings.json'): 22 | # os.remove(f'{db_dir_path}/useSettings.json') 23 | with open(new_settings_path, 'r') as file: 24 | return json.load(file) 25 | data = read_table('userSettings') 26 | if (data): 27 | records = json.loads(data) 28 | if DEFAULT_USER in records and 'myWorkflowsDir' in records[DEFAULT_USER]: 29 | return records[DEFAULT_USER] 30 | return None 31 | 32 | @server.PromptServer.instance.routes.post("/workspace/save_settings") 33 | async def save_settings(request): 34 | data = await request.json() 35 | json_data = data 36 | file_name = f'{db_dir_path}/settings.json' 37 | # Offload file writing to a separate thread 38 | def write_json_string_to_db(file_name, json_data): 39 | if not os.path.exists(db_dir_path): 40 | os.makedirs(db_dir_path) 41 | # Write the JSON data to the specified file 42 | with open(file_name, 'w') as file: 43 | file.write(json.dumps(json_data, indent=4)) 44 | await asyncio.to_thread(write_json_string_to_db, file_name, json_data) 45 | return web.Response(text=f"JSON saved to {file_name}") 46 | 47 | def get_my_workflows_dir(): 48 | data = get_settings() 49 | if (data): 50 | if 'myWorkflowsDir' in data: 51 | curDir = data['myWorkflowsDir'] 52 | 53 | # this is to be compatible of a bug that a dict is stored in userSettings.myWorkflowsDir 54 | # should not be needed once all users refresh their settings 55 | if not isinstance(curDir, str): 56 | curDir = curDir.get('path', None) 57 | 58 | if curDir and os.path.exists(curDir): 59 | return curDir 60 | return os.path.join(comfy_path, 'my_workflows') 61 | -------------------------------------------------------------------------------- /service/twoway_sync_folder_service.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import shutil 3 | from aiohttp import web 4 | import os 5 | from pathlib import Path 6 | import server 7 | from .setting_service import get_my_workflows_dir 8 | try: 9 | from send2trash import send2trash 10 | except ImportError: 11 | send2trash = None 12 | 13 | @server.PromptServer.instance.routes.post('/workspace/folder/create') 14 | async def create_folder(request): 15 | reqJson = await request.json() 16 | data = await asyncio.to_thread(create_folder_sync, reqJson) 17 | return web.json_response(data, content_type='application/json') 18 | 19 | def create_folder_sync(reqJson): 20 | folder_path = reqJson.get('path') 21 | folder_path = os.path.join(get_my_workflows_dir(), folder_path) 22 | try: 23 | Path(folder_path).mkdir(parents=True, exist_ok=True) 24 | return {"success": True} 25 | except Exception as e: 26 | return {"success": False, "error": str(e)} 27 | 28 | @server.PromptServer.instance.routes.post('/workspace/folder/delete') 29 | async def delete_folder(request): 30 | reqJson = await request.json() 31 | data = await asyncio.to_thread(delete_folder_sync, reqJson) 32 | return web.json_response(data, content_type='application/json') 33 | 34 | def delete_folder_sync(reqJson): 35 | folder_path = reqJson.get('path') 36 | folder_path = os.path.join(get_my_workflows_dir(), folder_path) 37 | try: 38 | if send2trash: 39 | send2trash(folder_path) 40 | else: 41 | shutil.rmtree(folder_path) 42 | print("❌⛔️send2trash is not available. Deleting file permanently. Please `pip install send2trash`") 43 | return {"success": True} 44 | except Exception as e: 45 | return {"success": False, "error": str(e)} 46 | 47 | @server.PromptServer.instance.routes.post('/workspace/folder/rename') 48 | async def rename_folder(request): 49 | reqJson = await request.json() 50 | data = await asyncio.to_thread(rename_folder_sync, reqJson) 51 | return web.json_response(data, content_type='application/json') 52 | 53 | def rename_folder_sync(reqJson): 54 | current_path = reqJson.get('absPath') 55 | current_path = os.path.join(get_my_workflows_dir(), current_path) 56 | new_name = reqJson.get('newName') 57 | try: 58 | new_path = Path(current_path).parent / new_name 59 | Path(current_path).rename(new_path) 60 | return {"success": True} 61 | except Exception as e: 62 | return {"success": False, "error": str(e)} 63 | 64 | @server.PromptServer.instance.routes.post('/workspace/folder/move') 65 | async def move_folder(request): 66 | reqJson = await request.json() 67 | data = await asyncio.to_thread(move_folder_sync, reqJson) 68 | return web.json_response(data, content_type='application/json') 69 | 70 | def move_folder_sync(reqJson): 71 | current_path = reqJson.get('folder') 72 | current_path = os.path.join(get_my_workflows_dir(), current_path) 73 | new_path = os.path.join(get_my_workflows_dir(), reqJson.get('newParentPath',"")) 74 | try: 75 | shutil.move(current_path, new_path) 76 | return {"success": True} 77 | except Exception as e: 78 | return {"success": False, "error": str(e)} 79 | 80 | -------------------------------------------------------------------------------- /ui/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | 'prettier', 9 | ], 10 | ignorePatterns: ['dist', '.eslintrc.cjs'], 11 | parser: '@typescript-eslint/parser', 12 | plugins: ['react-refresh', '@typescript-eslint', 'prettier'], 13 | rules: { 14 | 'react-refresh/only-export-components': [ 15 | 'warn', 16 | { allowConstantExport: true }, 17 | ], 18 | 'prettier/prettier': ['error'], 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /ui/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": true, 3 | "htmlWhitespaceSensitivity": "css", 4 | "insertPragma": false, 5 | "jsxBracketSameLine": false, 6 | "printWidth": 80, 7 | "proseWrap": "always", 8 | "quoteProps": "as-needed", 9 | "requirePragma": false, 10 | "semi": true, 11 | "tabWidth": 2, 12 | "trailingComma": "all", 13 | "useTabs": false, 14 | "endOfLine": "auto" 15 | } 16 | -------------------------------------------------------------------------------- /ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "comfyui-workspace-manager", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview", 11 | "setupGithooks": "node ../scripts/setupGitHooks" 12 | }, 13 | "dependencies": { 14 | "@chakra-ui/react": "^2.8.2", 15 | "@emotion/react": "^11.11.1", 16 | "@emotion/styled": "^11.11.0", 17 | "@tabler/icons-react": "^2.42.0", 18 | "chakra-react-select": "^4.7.6", 19 | "dexie": "^3.2.4", 20 | "dexie-react-hooks": "^1.1.7", 21 | "fuse.js": "^7.0.0", 22 | "jszip": "^3.10.1", 23 | "nanoid": "^5.0.6", 24 | "react": "^18.2.0", 25 | "react-dom": "^18.2.0" 26 | }, 27 | "devDependencies": { 28 | "@types/react": "^18.2.37", 29 | "@types/react-dom": "^18.2.15", 30 | "@types/uuid": "^9.0.7", 31 | "@typescript-eslint/eslint-plugin": "^6.10.0", 32 | "@typescript-eslint/parser": "^6.10.0", 33 | "@vitejs/plugin-react": "^4.2.0", 34 | "eslint": "^8.53.0", 35 | "eslint-config-prettier": "^9.1.0", 36 | "eslint-plugin-prettier": "^5.1.3", 37 | "eslint-plugin-react-hooks": "^4.6.0", 38 | "eslint-plugin-react-refresh": "^0.4.4", 39 | "prettier": "^3.2.4", 40 | "rollup-plugin-watch": "^1.0.4", 41 | "typescript": "^5.2.2", 42 | "vite": "^5.0.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /ui/src/RecentFilesDrawer/ItemsList.tsx: -------------------------------------------------------------------------------- 1 | import { DragEvent, useContext, useEffect, useState } from "react"; 2 | import { RecentFilesContext } from "../WorkspaceContext"; 3 | import { 4 | foldersTable, 5 | isFolder, 6 | workflowsTable, 7 | } from "../db-tables/WorkspaceDB"; 8 | import { Folder, Workflow } from "../types/dbTypes"; 9 | import FilesListFolderItem from "./FilesListFolderItem"; 10 | import WorkflowListItem from "./WorkflowListItem"; 11 | import { Box } from "@chakra-ui/react"; 12 | import { userSettingsTable } from "../db-tables/WorkspaceDB"; 13 | 14 | export default function ItemsList({ 15 | items, 16 | }: { 17 | items: Array; 18 | }) { 19 | const [folderOnTop, setFolderOnTop] = useState(false); 20 | const folders = items.filter(isFolder); 21 | const workflows = items.filter((item): item is Workflow => !isFolder(item)); 22 | const parentFolderID = workflows[0]?.parentFolderID; 23 | 24 | const [isDraggingOver, setIsDraggingOver] = useState(false); 25 | const { onRefreshFilesList, draggingFile, refreshFolderStamp } = 26 | useContext(RecentFilesContext); 27 | 28 | const handleDrop = async (e: DragEvent) => { 29 | e.preventDefault(); 30 | e.stopPropagation(); 31 | if (!draggingFile) return setIsDraggingOver(false); 32 | if (isFolder(draggingFile)) { 33 | if (draggingFile.id === parentFolderID) return setIsDraggingOver(false); 34 | await foldersTable?.update(draggingFile.id, { 35 | parentFolderID: parentFolderID, 36 | }); 37 | } else { 38 | await workflowsTable?.updateFolder(draggingFile.id, { 39 | parentFolderID: parentFolderID, 40 | }); 41 | } 42 | await onRefreshFilesList?.(); 43 | setIsDraggingOver(false); 44 | }; 45 | 46 | useEffect(() => { 47 | userSettingsTable?.getSetting("foldersOnTop").then((res) => { 48 | setFolderOnTop(res ?? false); 49 | }); 50 | }, [refreshFolderStamp]); 51 | 52 | return ( 53 | <> 54 | {folderOnTop && 55 | folders.map((folder) => ( 56 | 57 | ))} 58 | { 63 | e.preventDefault(); 64 | e.stopPropagation(); 65 | setIsDraggingOver(true); 66 | }} 67 | onDragLeave={(e) => { 68 | e.preventDefault(); 69 | e.stopPropagation(); 70 | setIsDraggingOver(false); 71 | }} 72 | onDrop={handleDrop} 73 | className="droppable" 74 | > 75 | {folderOnTop 76 | ? workflows.map((workflow) => ( 77 | 78 | )) 79 | : items.map((n) => { 80 | if (isFolder(n)) { 81 | return ; 82 | } 83 | return ; 84 | })} 85 | 86 | 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /ui/src/RecentFilesDrawer/ManageTagsModal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | HStack, 3 | Modal, 4 | ModalOverlay, 5 | ModalContent, 6 | ModalHeader, 7 | ModalBody, 8 | ModalCloseButton, 9 | Tag as ChakraTag, 10 | IconButton, 11 | } from "@chakra-ui/react"; 12 | import { useEffect, useState } from "react"; 13 | import { tagsTable } from "../db-tables/WorkspaceDB"; 14 | import { IconTrash } from "@tabler/icons-react"; 15 | import { Tag } from "../types/dbTypes"; 16 | 17 | export default function ManageTagsModal({ onclose }: { onclose: () => void }) { 18 | const [allTags, setAllTags] = useState([]); 19 | const loadTags = async () => { 20 | const tags = await tagsTable?.listAll(); 21 | setAllTags(tags ?? []); 22 | }; 23 | useEffect(() => { 24 | loadTags(); 25 | }, []); 26 | return ( 27 | 28 | 29 | 30 | My Tags 31 | 32 | 33 | {allTags.map((tag) => ( 34 | 35 | {tag.name} 36 | { 38 | await tagsTable?.delete(tag.name); 39 | loadTags(); 40 | }} 41 | aria-label="delete-tag" 42 | colorScheme="red" 43 | variant={"ghost"} 44 | icon={} 45 | /> 46 | 47 | ))} 48 | 49 | 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /ui/src/RecentFilesDrawer/MultipleSelectionOperation.tsx: -------------------------------------------------------------------------------- 1 | import { Checkbox, HStack, IconButton, Text, Tooltip } from "@chakra-ui/react"; 2 | import { IconListCheck, IconX, IconDownload } from "@tabler/icons-react"; 3 | import DeleteConfirm from "../components/DeleteConfirm"; 4 | import { ChangeEvent } from "react"; 5 | import { workflowsTable } from "../db-tables/WorkspaceDB"; 6 | import { downloadWorkflowsZip } from "../utils/downloadWorkflowsZip"; 7 | 8 | type Props = { 9 | multipleState: boolean; 10 | selectedKeys: string[]; 11 | isSelectedAll: boolean; 12 | changeMultipleState: (isMultiple: boolean) => void; 13 | batchOperationCallback: (type: string, value?: unknown) => void; 14 | }; 15 | 16 | export default function MultipleSelectionOperation(props: Props) { 17 | const { 18 | multipleState, 19 | changeMultipleState, 20 | selectedKeys, 21 | isSelectedAll, 22 | batchOperationCallback, 23 | } = props; 24 | 25 | const batchExport = async () => { 26 | const selectedList = (await workflowsTable?.batchQuery(selectedKeys)) ?? []; 27 | downloadWorkflowsZip(selectedList); 28 | }; 29 | 30 | const notChecked = selectedKeys.length === 0; 31 | 32 | return ( 33 | 34 | {multipleState ? ( 35 | <> 36 | ) => { 39 | batchOperationCallback("selectAll", e.target.checked); 40 | }} 41 | /> 42 | 43 | } 48 | onClick={batchExport} 49 | /> 50 | 51 | 52 | { 58 | await workflowsTable?.batchDeleteFlow(selectedKeys); 59 | batchOperationCallback("batchDelete"); 60 | }} 61 | /> 62 | 63 | 64 | {selectedKeys.length > 0 && ( 65 | {`Selected ${selectedKeys.length}`} 66 | )} 67 | 68 | } 72 | onClick={() => { 73 | changeMultipleState(false); 74 | }} 75 | /> 76 | 77 | 78 | ) : ( 79 | 80 | } 85 | onClick={() => { 86 | changeMultipleState(true); 87 | }} 88 | /> 89 | 90 | )} 91 | 92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /ui/src/RecentFilesDrawer/MyTagsRow.tsx: -------------------------------------------------------------------------------- 1 | import { Button, HStack, IconButton } from "@chakra-ui/react"; 2 | import { useEffect, useState } from "react"; 3 | import { Tag } from "../types/dbTypes"; 4 | import { tagsTable } from "../db-tables/WorkspaceDB"; 5 | import { IconChevronDown, IconChevronUp, IconX } from "@tabler/icons-react"; 6 | const MAX_TAGS_TO_SHOW = 6; 7 | 8 | interface Props { 9 | selectedTag?: string; 10 | setSelectedTag: (tag?: string) => void; 11 | } 12 | export default function MyTagsRow({ setSelectedTag, selectedTag }: Props) { 13 | const [showAllTags, setShowAllTags] = useState(false); 14 | 15 | const [tags, setTags] = useState([]); 16 | useEffect(() => { 17 | tagsTable?.listAll().then((tags) => { 18 | setTags(tags); 19 | }); 20 | }, []); 21 | return ( 22 | 23 | {selectedTag != null && ( 24 | } 28 | onClick={() => { 29 | setSelectedTag(undefined); 30 | }} 31 | /> 32 | )} 33 | {tags.slice(0, showAllTags ? undefined : MAX_TAGS_TO_SHOW).map((tag) => ( 34 | 49 | ))} 50 | {(tags.length ?? 0) > MAX_TAGS_TO_SHOW && ( 51 | : } 55 | onClick={() => setShowAllTags(!showAllTags)} 56 | /> 57 | )} 58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /ui/src/RecentFilesDrawer/RecentFilesDrawerMenu.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | IconButton, 3 | Menu, 4 | MenuButton, 5 | MenuList, 6 | MenuItem, 7 | useColorMode, 8 | } from "@chakra-ui/react"; 9 | import { 10 | IconMenu2, 11 | IconMoon, 12 | IconSettings, 13 | IconSun, 14 | } from "@tabler/icons-react"; 15 | import { useState } from "react"; 16 | import WorkspaceSettingsModal from "./WorkspaceSettingsModal"; 17 | const ICON_SIZE = 16; 18 | type Props = { 19 | // onclose: () => void; 20 | }; 21 | export default function RecentFilesDrawerMenu({}: Props) { 22 | const [isSettingsOpen, setIsSettingsOpen] = useState(false); 23 | const { colorMode, toggleColorMode } = useColorMode(); 24 | 25 | return ( 26 | <> 27 | 28 | } 32 | size={"sm"} 33 | variant="outline" 34 | /> 35 | 36 | setIsSettingsOpen(true)} 38 | icon={} 39 | fontSize={16} 40 | > 41 | Settings 42 | 43 | 48 | ) : ( 49 | 50 | ) 51 | } 52 | fontSize={16} 53 | > 54 | {colorMode === "light" ? "Dark" : "Light"} Mode 55 | 56 | 57 | 58 | {isSettingsOpen && ( 59 | setIsSettingsOpen(false)} /> 60 | )} 61 | 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /ui/src/RecentFilesDrawer/WorkflowListItemActionButtons.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, IconButton, Tooltip } from "@chakra-ui/react"; 2 | import DeleteConfirm from "../components/DeleteConfirm"; 3 | import MoreActionMenu from "./MoreActionMenu"; 4 | import { useContext } from "react"; 5 | import { RecentFilesContext } from "../WorkspaceContext"; 6 | import { Workflow } from "../types/dbTypes"; 7 | 8 | export default function WorkflowListItemActionButtons({ 9 | workflow, 10 | }: { 11 | workflow: Workflow; 12 | }) { 13 | const { onDeleteFlow } = useContext(RecentFilesContext); 14 | return ( 15 | 16 | { 19 | onDeleteFlow && onDeleteFlow(workflow.id); 20 | }} 21 | /> 22 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /ui/src/RecentFilesDrawer/WorkspaceSettingsModal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | HStack, 3 | Modal, 4 | ModalOverlay, 5 | ModalContent, 6 | ModalHeader, 7 | ModalBody, 8 | ModalCloseButton, 9 | StackDivider, 10 | VStack, 11 | } from "@chakra-ui/react"; 12 | import { ShortcutSettings } from "../settings/ShortcutSettings"; 13 | import TwoWaySyncSettings from "../settings/TwoWaySyncSettings"; 14 | import CommonCheckboxSettings from "../settings/CommonCheckboxSettings"; 15 | import SelectMyWorkflowsDir from "../settings/SelectMyWorkflowsDir"; 16 | import { CommonNumberSetting } from "../settings/CommonNumberSetting"; 17 | import CloudHostSetting from "../settings/CloudHostSetting"; 18 | import SharekeySetting from "../settings/SharekeySetting"; 19 | 20 | export default function WorkspaceSettingsModal({ 21 | onClose, 22 | }: { 23 | onClose: () => void; 24 | }) { 25 | return ( 26 | <> 27 | 28 | 29 | 30 | Settings 31 | 32 | 33 | 34 | } 36 | spacing={4} 37 | align="stretch" 38 | w="100%" 39 | > 40 | 41 | 42 | 43 | 44 | 48 | 54 | 58 | 59 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /ui/src/RecentFilesDrawer/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The key value of the sort type in the local cache 3 | */ 4 | export const sortTypeLocalStorageKey = "CWM_WORKFLOWS_SORT_TYPE"; 5 | 6 | export enum ESortTypes { 7 | /** 8 | * Sort by last opened time, most recently modified first 9 | */ 10 | RECENTLY_OPENED = "recentlyOpened", 11 | /** 12 | * Sort by update time, most recently modified first 13 | */ 14 | RECENTLY_MODIFIED = "newest", 15 | /** 16 | * Sort by update time, latest modified last 17 | */ 18 | OLDEST_MODIFIED = "oldest", 19 | /** 20 | * Sort alphabetically, from A to Z 21 | */ 22 | AZ = "name A-Z", 23 | /** 24 | * Sort alphabetically, from Z to A 25 | */ 26 | ZA = "name Z-A", 27 | } 28 | 29 | export type ImportWorkflow = { 30 | json: string; 31 | name?: string; 32 | }; 33 | 34 | export const importMenuItemList = [ 35 | { 36 | title: "Import Workflows", 37 | type: "file", 38 | }, 39 | { 40 | title: "Import Folder", 41 | type: "folder", 42 | }, 43 | ]; 44 | 45 | // Solve TS errors caused by webkitdirectory 46 | declare module "react" { 47 | interface InputHTMLAttributes extends HTMLAttributes { 48 | webkitdirectory?: string; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /ui/src/WorkspaceContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import { Folder, Workflow, WorkflowVersion } from "./types/dbTypes"; 3 | import { Session, WorkspaceRoute } from "./types/types"; 4 | export type JsonDiff = { 5 | old: Object; 6 | new: Object; 7 | } | null; 8 | 9 | export const WorkspaceContext = createContext<{ 10 | curFlowID: string | null; 11 | onDuplicateWorkflow?: (flowID: string, newFlowName?: string) => void; 12 | loadWorkflowID: ( 13 | id: string | null, 14 | versionID?: string | null, 15 | forceLoad?: boolean, 16 | ) => void; 17 | setIsDirty: (dirty: boolean) => void; 18 | saveCurWorkflow: (force?: boolean) => Promise; 19 | discardUnsavedChanges: () => Promise; 20 | isDirty: boolean; 21 | loadNewWorkflow: (input?: { json: string; name?: string }) => void; 22 | loadFilePath: (path: string, overwriteCurrent?: boolean) => void; 23 | setRoute: (route: WorkspaceRoute) => void; 24 | route: WorkspaceRoute; 25 | curVersion: WorkflowVersion | null; 26 | setCurVersion: (version: WorkflowVersion | null) => void; 27 | setCurFlowIDAndName: (workflow: Workflow | null) => void; 28 | session: Session | null; 29 | updateSession: (session: Session) => void; 30 | }>({ 31 | curFlowID: null, 32 | loadWorkflowID: () => {}, 33 | saveCurWorkflow: async () => {}, 34 | discardUnsavedChanges: async () => {}, 35 | isDirty: false, 36 | loadNewWorkflow: () => {}, 37 | loadFilePath: () => {}, 38 | setRoute: () => {}, 39 | route: "root", 40 | curVersion: null, 41 | setIsDirty: () => {}, 42 | setCurVersion: () => {}, 43 | setCurFlowIDAndName: () => {}, 44 | session: null, 45 | updateSession: () => {}, 46 | }); 47 | 48 | export const RecentFilesContext = createContext<{ 49 | onRefreshFilesList?: () => void; 50 | draggingFile?: Workflow | Folder; 51 | setDraggingFile?: (file: Workflow | Folder) => void; 52 | isMultiSelecting?: boolean; 53 | multiSelectedFlowsID?: string[]; 54 | onMultiSelectFlow?: (flowId: string, selected: boolean) => void; 55 | onDeleteFlow?: (flowId: string) => void; 56 | refreshFolderStamp: number; 57 | setRefreshFolderStamp: (stamp: number) => void; 58 | }>({ 59 | refreshFolderStamp: 0, 60 | setRefreshFolderStamp: () => {}, 61 | }); 62 | -------------------------------------------------------------------------------- /ui/src/apis/TwowaySyncFolderApi.ts: -------------------------------------------------------------------------------- 1 | import { fetchApi } from "../Api"; 2 | import { Folder } from "../types/dbTypes"; 3 | import { sanitizeRelPath } from "../utils/OsPathUtils"; 4 | 5 | export namespace TwowayFolderSyncAPI { 6 | export async function genFolderRelPath({ 7 | parentFolderID, 8 | name, 9 | }: { 10 | parentFolderID: string | null; 11 | name: string; 12 | }): Promise { 13 | return sanitizeRelPath(`${parentFolderID ?? ""}/${name}`); 14 | } 15 | 16 | export async function createFolder(folder: Folder) { 17 | await fetchApi("/workspace/folder/create", { 18 | method: "POST", 19 | headers: { 20 | "Content-Type": "application/json", 21 | }, 22 | body: JSON.stringify({ 23 | path: sanitizeRelPath(folder.id), 24 | }), 25 | }); 26 | } 27 | export async function moveFolder( 28 | folderToBeMoved: string, 29 | newParentPath: string, 30 | ) { 31 | const resp = await fetchApi("/workspace/folder/move", { 32 | method: "POST", 33 | headers: { 34 | "Content-Type": "application/json", 35 | }, 36 | body: JSON.stringify({ 37 | folder: folderToBeMoved, 38 | newParentPath: newParentPath, 39 | }), 40 | }); 41 | const result = await resp.json(); 42 | if (result.error) { 43 | alert("Failed to move folder: " + result.error); 44 | } 45 | return; 46 | } 47 | export async function deleteFolder(relPath: string) { 48 | try { 49 | const response = await fetchApi("/workspace/folder/delete", { 50 | method: "POST", 51 | headers: { 52 | "Content-Type": "application/json", 53 | }, 54 | body: JSON.stringify({ 55 | path: relPath, 56 | }), 57 | }); 58 | const result = await response.text(); 59 | return result; 60 | } catch (error) { 61 | console.error("Error deleting file:", error); 62 | } 63 | } 64 | 65 | export async function genFilesCountInFolder( 66 | folderRelPath: string, 67 | ): Promise { 68 | try { 69 | const response = await fetchApi("/workspace/file/count_files", { 70 | method: "POST", 71 | headers: { 72 | "Content-Type": "application/json", 73 | }, 74 | body: JSON.stringify({ 75 | path: folderRelPath, 76 | }), 77 | }); 78 | const result = await response.json(); 79 | if (result.error) { 80 | console.error("Error counting files:", result.error); 81 | } 82 | return result.count; 83 | } catch (error) { 84 | console.error("Error renaming file:", error); 85 | } 86 | return null; 87 | } 88 | 89 | export async function renameFolder( 90 | folderRelPath: string, 91 | newName: string, 92 | ): Promise { 93 | try { 94 | const response = await fetchApi("/workspace/folder/rename", { 95 | method: "POST", 96 | headers: { 97 | "Content-Type": "application/json", 98 | }, 99 | body: JSON.stringify({ 100 | absPath: folderRelPath, 101 | newName: newName, 102 | }), 103 | }); 104 | const result = await response.json(); 105 | if (result.error) { 106 | alert("Failed to rename folder: " + result.error); 107 | } 108 | } catch (error) { 109 | console.error("Error renaming file:", error); 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /ui/src/components/Carousel/Carousel.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Flex, IconButton, Image } from "@chakra-ui/react"; 3 | import { isImageFormat } from "../../utils.tsx"; 4 | import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react"; 5 | 6 | type Media = { 7 | id: string; 8 | imageUrl: string; 9 | }; 10 | 11 | interface CarouselProps { 12 | media: Media[]; 13 | currentNum: number; 14 | setMediaAct: (media: Media) => void; 15 | } 16 | 17 | const Carousel: React.FC = ({ 18 | setMediaAct, 19 | media, 20 | currentNum, 21 | }) => { 22 | const paginate = (newDirection: number) => { 23 | const cur = (currentNum + newDirection + media.length) % media.length; 24 | if (setMediaAct) { 25 | setMediaAct(media[cur]); 26 | } 27 | }; 28 | 29 | const imageSize = { 30 | width: "100%", // Adjust the width as needed 31 | height: "100%", // Adjust the height as needed 32 | }; 33 | 34 | if (media.length === 0) 35 | return

No images found, let's start generating 🪄

; 36 | const item = media.at(currentNum); 37 | if (!item) return null; 38 | return ( 39 | 47 |
{ 56 | window.open(item.imageUrl); 57 | }} 58 | > 59 | {isImageFormat(item.imageUrl) ? ( 60 | {`image-${item.id}`} 67 | ) : ( 68 | 79 | )} 80 |
81 | 82 | } 88 | onClick={() => paginate(-1)} 89 | position="absolute" 90 | left="0" 91 | zIndex="2" 92 | /> 93 | } 99 | onClick={() => paginate(1)} 100 | position="absolute" 101 | right="0" 102 | zIndex="2" 103 | /> 104 |
105 | ); 106 | }; 107 | 108 | export default Carousel; 109 | -------------------------------------------------------------------------------- /ui/src/components/CopyShareLinkMenuItem.tsx: -------------------------------------------------------------------------------- 1 | import { HStack, MenuItem, Spinner, useToast } from "@chakra-ui/react"; 2 | import { IconLink } from "@tabler/icons-react"; 3 | import { useContext, useState } from "react"; 4 | import { WorkspaceContext } from "../WorkspaceContext"; 5 | import { copySharelink } from "./copySharelink"; 6 | import { Workflow } from "../types/dbTypes"; 7 | 8 | export default function CopyShareLinkMenuItem({ 9 | curFlow, 10 | }: { 11 | curFlow: Workflow; 12 | }) { 13 | const { session } = useContext(WorkspaceContext); 14 | const toast = useToast(); 15 | const [loading, setloading] = useState(false); 16 | 17 | return ( 18 | { 20 | if (!curFlow) { 21 | return; 22 | } 23 | if (!session?.shareKey) { 24 | alert( 25 | "Please set your share key first in Settings to enable cloud share.", 26 | ); 27 | return; 28 | } 29 | setloading(true); 30 | const res = await copySharelink(session.shareKey, curFlow!); 31 | if (res?.link) { 32 | navigator.clipboard.writeText(res.link); 33 | toast({ 34 | title: "Link copied", 35 | status: "success", 36 | duration: 2000, 37 | }); 38 | } else { 39 | toast({ 40 | title: "Failed to copy link", 41 | status: "error", 42 | description: res?.error, 43 | duration: null, 44 | }); 45 | } 46 | setloading(false); 47 | }} 48 | icon={loading ? : } 49 | iconSpacing={1} 50 | alignItems={"center"} 51 | isDisabled={loading} 52 | > 53 | 54 |

Copy share link

55 |
56 |
57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /ui/src/components/CustomMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | import { Box, useOutsideClick } from "@chakra-ui/react"; 3 | 4 | type Props = { 5 | menuButton: React.ReactElement; 6 | options: React.ReactElement; 7 | isOpen: boolean; 8 | onClose: () => void; 9 | }; 10 | export default function CustomMenu({ 11 | options, 12 | menuButton, 13 | isOpen, 14 | onClose, 15 | }: Props) { 16 | const ref = useRef(null); 17 | useOutsideClick({ 18 | ref: ref, 19 | handler: () => onClose(), 20 | }); 21 | 22 | return ( 23 | 24 | {menuButton} 25 | {isOpen && ( 26 | 34 | {options} 35 | 36 | )} 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /ui/src/components/CustomSelector.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect } from "react"; 2 | import { Box, Button, Card } from "@chakra-ui/react"; 3 | import { IconChevronDown } from "@tabler/icons-react"; 4 | 5 | export interface CustomSelectorOption { 6 | label: string; 7 | value: T; 8 | icon?: React.ReactElement; 9 | } 10 | 11 | type Props = { 12 | options: CustomSelectorOption[]; 13 | value: T; 14 | onChange: (value: T) => void; 15 | }; 16 | export default function CustomSelector({ 17 | options, 18 | value, 19 | onChange, 20 | }: Props) { 21 | const [isOpen, setIsOpen] = useState(false); 22 | const ref = useRef(null); 23 | const selectedOption = options.find((option) => option.value === value); 24 | 25 | useEffect(() => { 26 | const handleClickOutside = (event: any) => { 27 | if (ref.current && !ref.current.contains(event.target)) { 28 | setIsOpen(false); 29 | } 30 | }; 31 | document.addEventListener("mousedown", handleClickOutside); 32 | return () => { 33 | document.removeEventListener("mousedown", handleClickOutside); 34 | }; 35 | }, [ref]); 36 | 37 | const toggleDropdown = () => setIsOpen(!isOpen); 38 | 39 | return ( 40 | 41 | 48 | {isOpen && ( 49 | 59 | {options.map((option) => ( 60 | 73 | ))} 74 | 75 | )} 76 | 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /ui/src/components/DeleteConfirm.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Popover, 4 | PopoverTrigger, 5 | PopoverContent, 6 | PopoverCloseButton, 7 | PopoverArrow, 8 | PopoverBody, 9 | Text, 10 | IconButton, 11 | Tooltip, 12 | Box, 13 | } from "@chakra-ui/react"; 14 | import { IconTrash } from "@tabler/icons-react"; 15 | 16 | type Props = { 17 | promptMessage: string; 18 | isDisabled?: boolean; 19 | variant?: string; 20 | tooltipText?: string; 21 | onDelete: () => void; 22 | }; 23 | 24 | export default function DeleteConfirm(props: Props) { 25 | const { 26 | promptMessage = "Are you sure you want to delete this?", 27 | variant = "ghost", 28 | onDelete, 29 | isDisabled = false, 30 | tooltipText, 31 | } = props; 32 | 33 | return ( 34 | 35 | {({ onClose }) => ( 36 | <> 37 | 38 | 39 | 40 | } 44 | isDisabled={isDisabled} 45 | variant={variant} 46 | /> 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | {promptMessage} 55 | 56 | 66 | 67 | 68 | 69 | )} 70 | 71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /ui/src/components/Draggable.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useState, 3 | useCallback, 4 | MouseEvent, 5 | useRef, 6 | PropsWithChildren, 7 | } from "react"; 8 | 9 | const POSITION = { x: 0, y: 0 }; 10 | 11 | interface Props { 12 | onDragEnd: (position: { x: number; y: number }) => void; 13 | dragIconId: string; 14 | } 15 | 16 | export default function Draggable({ 17 | children, 18 | onDragEnd, 19 | dragIconId, 20 | }: PropsWithChildren) { 21 | const originRef = useRef(POSITION); 22 | const isDraggingRef = useRef(false); 23 | const [translation, setTranslation] = useState(POSITION); 24 | 25 | const handleMouseDown = (e: MouseEvent) => { 26 | if ( 27 | !(e.target instanceof Element) || 28 | !(e.target.parentNode instanceof Element) 29 | ) { 30 | return; 31 | } 32 | if ([e.target?.id, e.target?.parentNode?.id].includes(dragIconId)) { 33 | originRef.current = { 34 | x: e.clientX, 35 | y: e.clientY, 36 | }; 37 | isDraggingRef.current = true; 38 | window.addEventListener("mousemove", handleMouseMove); 39 | window.addEventListener("mouseup", handleMouseUp); 40 | const bodyEle = document.getElementsByTagName("body")[0]; 41 | bodyEle.style.userSelect = "none"; 42 | } 43 | }; 44 | 45 | const handleMouseMove = useCallback((e: globalThis.MouseEvent) => { 46 | const translation = { 47 | x: e.clientX - originRef.current.x, 48 | y: e.clientY - originRef.current.y, 49 | }; 50 | 51 | setTranslation(translation); 52 | }, []); 53 | 54 | const handleMouseUp = useCallback(() => { 55 | setTranslation((state) => { 56 | setTimeout(() => { 57 | onDragEnd(state); 58 | }, 0); 59 | return POSITION; 60 | }); 61 | isDraggingRef.current = false; 62 | const bodyEle = document.getElementsByTagName("body")[0]; 63 | bodyEle.style.userSelect = "auto"; 64 | window.removeEventListener("mousemove", handleMouseMove); 65 | window.removeEventListener("mouseup", handleMouseUp); 66 | }, [onDragEnd, handleMouseMove]); 67 | 68 | const styles = { 69 | transform: `translate(${translation.x}px, ${translation.y}px)`, 70 | zIndex: isDraggingRef.current ? 1000 : 1, 71 | }; 72 | 73 | return ( 74 |
75 | {children} 76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /ui/src/components/EditFolderName.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Modal, 4 | ModalOverlay, 5 | ModalContent, 6 | ModalHeader, 7 | ModalCloseButton, 8 | ModalBody, 9 | ModalFooter, 10 | FormControl, 11 | FormLabel, 12 | FormErrorMessage, 13 | Input, 14 | } from "@chakra-ui/react"; 15 | import { useContext, ChangeEvent, useState } from "react"; 16 | import { foldersTable } from "../db-tables/WorkspaceDB"; 17 | import { RecentFilesContext } from "../WorkspaceContext"; 18 | import { Folder } from "../types/dbTypes"; 19 | 20 | type Props = { 21 | folder: Folder; 22 | onclose: () => void; 23 | }; 24 | 25 | export default function EditFolderNameModal({ folder, onclose }: Props) { 26 | // const { curFlowID } = useContext(WorkspaceContext); 27 | const [editName, setEditName] = useState(folder.name); 28 | const [submitError, setSubmitError] = useState(""); 29 | const { onRefreshFilesList } = useContext(RecentFilesContext); 30 | const handleChange = (event: ChangeEvent) => { 31 | setEditName(event.target.value); 32 | submitError && setSubmitError(""); 33 | }; 34 | 35 | const onSubmit = async () => { 36 | const trimEditName = editName.trim(); 37 | setEditName(trimEditName); 38 | if (trimEditName === folder.name) return onclose(); 39 | const uniqName = await foldersTable?.generateUniqueName( 40 | trimEditName, 41 | folder.parentFolderID ?? "", 42 | ); 43 | if (uniqName !== trimEditName) { 44 | setSubmitError( 45 | "The name is duplicated, please modify it and submit again.", 46 | ); 47 | } else { 48 | await foldersTable?.update(folder.id, { 49 | name: trimEditName, 50 | }); 51 | onRefreshFilesList && onRefreshFilesList(); 52 | onclose(); 53 | } 54 | }; 55 | 56 | return ( 57 | 58 | 59 | 60 | Edit folder name 61 | 62 | 63 | 64 | Folder name 65 | { 70 | e.code === "Enter" && !submitError && editName && onSubmit(); 71 | }} 72 | /> 73 | {submitError && {submitError}} 74 | 75 | 76 | 77 | 85 | 86 | 87 | 88 | 89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /ui/src/components/HoverMenu.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from "react"; 2 | import CustomMenu from "./CustomMenu"; 3 | import { Box, Menu, MenuList } from "@chakra-ui/react"; 4 | type Props = { 5 | menuButton: React.ReactElement; 6 | menuContent: React.ReactElement; 7 | onClose?: () => void; 8 | }; 9 | export default function HoverMenu({ menuButton, menuContent, onClose }: Props) { 10 | const closeTimeoutId = useRef(); 11 | const [isOpen, setIsOpen] = useState(false); 12 | const delayedClose = () => { 13 | closeTimeoutId.current = setTimeout(() => setIsOpen(false), 400); // delay of 300ms 14 | }; 15 | 16 | const onOpen = () => { 17 | setIsOpen(true); 18 | clearTimeout(closeTimeoutId.current); 19 | closeTimeoutId.current = undefined; 20 | }; 21 | return ( 22 | 32 | {menuButton} 33 | 34 | } 35 | options={ 36 | 37 | 43 | {menuContent} 44 | 45 | 46 | } 47 | /> 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /ui/src/components/MediaPreview.tsx: -------------------------------------------------------------------------------- 1 | import { Image } from "@chakra-ui/react"; 2 | import { isImageFormat } from "../utils"; 3 | import { useEffect, useState } from "react"; 4 | 5 | type Props = { 6 | mediaLocalPath: string; 7 | size: number; 8 | autoPlay?: boolean; 9 | onBrokenLink?: () => void; 10 | hideBrokenImage?: boolean; 11 | isPreview?: boolean; 12 | objectFit?: "cover" | "contain"; 13 | }; 14 | export default function MediaPreview({ 15 | mediaLocalPath, 16 | size, 17 | isPreview, 18 | autoPlay, 19 | hideBrokenImage, 20 | objectFit, 21 | onBrokenLink, 22 | }: Props) { 23 | const [isVisible, setIsVisible] = useState(true); 24 | 25 | useEffect(() => { 26 | const checkMediaExists = async () => { 27 | try { 28 | const response = await fetch( 29 | isPreview 30 | ? `/workspace/preview_media?filename=${mediaLocalPath}` 31 | : `/workspace/view_media?filename=${mediaLocalPath}`, 32 | ); 33 | if (response.status == 404) { 34 | setIsVisible(false); 35 | onBrokenLink?.(); 36 | return; 37 | } 38 | } catch (error) { 39 | console.error("Error checking media exists", error); 40 | } 41 | }; 42 | 43 | hideBrokenImage && checkMediaExists(); 44 | }, []); 45 | 46 | if (!isVisible) return null; 47 | return isImageFormat(mediaLocalPath) ? ( 48 | workflow image renamed or moved from output folder 55 | ) : null; 56 | } 57 | -------------------------------------------------------------------------------- /ui/src/components/Overlay.tsx: -------------------------------------------------------------------------------- 1 | export function Overlay({ 2 | backgroundColor, 3 | }: { 4 | backgroundColor?: string | null; 5 | }) { 6 | const bg = 7 | backgroundColor === undefined 8 | ? "rgba(0,0,0,0.5)" 9 | : backgroundColor ?? undefined; 10 | return ( 11 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /ui/src/components/SearchInput.tsx: -------------------------------------------------------------------------------- 1 | import { Box, IconButton, Input } from "@chakra-ui/react"; 2 | import { IconSearch, IconX } from "@tabler/icons-react"; 3 | import { CSSProperties } from "react"; 4 | 5 | type Props = { 6 | searchValue: string; 7 | onUpdateSearchValue: (value: string) => void; 8 | placeholder?: string; 9 | style?: CSSProperties; 10 | }; 11 | 12 | const IconSearchStyle: CSSProperties = { 13 | position: "absolute", 14 | marginLeft: "10px", 15 | width: "20px", 16 | height: "20px", 17 | top: "50%", 18 | color: "#A0AEC0", 19 | transform: "translateY(-50%)", 20 | }; 21 | 22 | export default function SearchInput(props: Props) { 23 | const { searchValue, onUpdateSearchValue, style } = props; 24 | const isSearchValueNotEmpty = searchValue !== ""; 25 | 26 | return ( 27 | 28 | 29 | {isSearchValueNotEmpty && ( 30 | } 40 | onClick={() => onUpdateSearchValue("")} 41 | aria-label="clear input button" 42 | /> 43 | )} 44 | onUpdateSearchValue(target.value)} 51 | /> 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /ui/src/components/copySharelink.tsx: -------------------------------------------------------------------------------- 1 | import { userSettingsTable, workflowsTable } from "../db-tables/WorkspaceDB"; 2 | import { getNodeDefs } from "../share/shareUtils"; 3 | import { EWorkflowPrivacy, Workflow } from "../types/dbTypes"; 4 | import { ShareWorkflowData } from "../types/types"; 5 | import { app } from "../utils/comfyapp"; 6 | 7 | export async function copySharelink( 8 | shareKey: string, 9 | curWorkflow: Workflow, 10 | ): Promise<{ 11 | link?: string | null; 12 | error?: string | null; 13 | } | null> { 14 | if (curWorkflow.cloudID) { 15 | const link = 16 | userSettingsTable?.settings?.cloudHost + 17 | "/workflow/" + 18 | curWorkflow.cloudID; 19 | return { 20 | link, 21 | }; 22 | } 23 | const prompt = await app.graphToPrompt(); 24 | const graph = app.graph.serialize(); 25 | if (!graph.extra) { 26 | graph.extra = {}; 27 | } 28 | graph.extra.apiPrompt = prompt.output ?? null; 29 | const input: ShareWorkflowData = { 30 | workflow: { 31 | name: curWorkflow?.name ?? "Untitled", 32 | cloudID: curWorkflow?.cloudID, 33 | }, 34 | version: { 35 | name: "v1", 36 | json: JSON.stringify(graph), 37 | }, 38 | nodeDefs: getNodeDefs(), 39 | privacy: EWorkflowPrivacy.UNLISTED, 40 | }; 41 | return await fetch( 42 | userSettingsTable?.settings?.cloudHost + "/api/createCloudflow", 43 | { 44 | method: "POST", 45 | headers: { 46 | "Content-Type": "application/json", 47 | Authorization: `Bearer ${shareKey}`, 48 | }, 49 | body: JSON.stringify(input), 50 | }, 51 | ) 52 | .then((resp) => resp.json()) 53 | .then((data) => { 54 | if (data?.data) { 55 | const cloudID = data.data.workflowID; 56 | workflowsTable?.updateMetaInfo(curWorkflow?.id!, { 57 | cloudID, 58 | }); 59 | 60 | const link = 61 | userSettingsTable?.settings?.cloudHost + "/workflow/" + cloudID; 62 | return { 63 | link, 64 | }; 65 | } else { 66 | return { 67 | error: "Failed to create version. " + (data?.error ?? ""), 68 | }; 69 | } 70 | }) 71 | .catch((e) => { 72 | return { 73 | error: "Failed to create version. " + (e?.error ?? ""), 74 | }; 75 | }); 76 | } 77 | -------------------------------------------------------------------------------- /ui/src/const.ts: -------------------------------------------------------------------------------- 1 | export const LEGACY_COMFYSPACE_TRACKING_FIELD_NAME = "comfyspace_tracking"; 2 | export const COMFYSPACE_TRACKING_FIELD_NAME = "workspace_info"; 3 | export const COMFYSPACE_AUTH_ENDPOINT = "comfyspace_auth"; 4 | export const COMFYSPACE_AUTH_REDIRECT_URL = "http://localhost:8188/"; 5 | export const DRAWER_Z_INDEX = 9999999; 6 | export const UPGRADE_TO_2WAY_SYNC_KEY = "upgrade_to_2way_sync"; 7 | export const TOPBAR_BUTTON_HEIGHT = 28; 8 | export const SHORTCUT_TRIGGER_EVENT = "SHORTCUT_TRIGGER_EVENT"; 9 | -------------------------------------------------------------------------------- /ui/src/customHooks/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export const useDebounce = (value: T, delay: number): T => { 4 | const [debouncedValue, setDebouncedValue] = useState(value); 5 | 6 | useEffect(() => { 7 | const handler = setTimeout(() => { 8 | setDebouncedValue(value); 9 | }, delay); 10 | 11 | return () => { 12 | clearTimeout(handler); 13 | }; 14 | }, [value, delay]); 15 | 16 | return debouncedValue; 17 | }; 18 | -------------------------------------------------------------------------------- /ui/src/customHooks/useDebounceFn.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from "react"; 2 | 3 | export default function useDebounceFn void>( 4 | fn: T, 5 | delay: number, 6 | ): [T, () => void] { 7 | const timeoutRef = useRef(0); 8 | 9 | const cancel = useCallback(() => { 10 | if (timeoutRef.current !== null) { 11 | clearTimeout(timeoutRef.current); 12 | timeoutRef.current = 0; 13 | } 14 | }, []); 15 | 16 | const debouncedFn = useCallback( 17 | (...args: Parameters) => { 18 | cancel(); 19 | timeoutRef.current = setTimeout(() => fn(...args), delay); 20 | }, 21 | [fn, delay, cancel], 22 | ) as T; 23 | 24 | useEffect(() => { 25 | return () => { 26 | cancel(); 27 | }; 28 | }, []); 29 | 30 | return [debouncedFn, cancel]; 31 | } 32 | -------------------------------------------------------------------------------- /ui/src/customHooks/useStateRef.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef, useState, SetStateAction, Dispatch } from "react"; 2 | 3 | const isFunction = ( 4 | setStateAction: SetStateAction, 5 | ): setStateAction is (prevState: S) => S => 6 | typeof setStateAction === "function"; 7 | 8 | type ReadOnlyRefObject = { 9 | readonly current: T; 10 | }; 11 | 12 | type UseStateRef = { 13 | ( 14 | initialState: S | (() => S), 15 | ): [S, Dispatch>, ReadOnlyRefObject]; 16 | (): [ 17 | S | undefined, 18 | Dispatch>, 19 | ReadOnlyRefObject, 20 | ]; 21 | }; 22 | 23 | export const useStateRef: UseStateRef = (initialState?: S | (() => S)) => { 24 | const [state, setState] = useState(initialState); 25 | const ref = useRef(state); 26 | 27 | const dispatch: typeof setState = useCallback((setStateAction: any) => { 28 | ref.current = isFunction(setStateAction) 29 | ? setStateAction(ref.current) 30 | : setStateAction; 31 | 32 | setState(ref.current); 33 | }, []); 34 | 35 | return [state, dispatch, ref]; 36 | }; 37 | -------------------------------------------------------------------------------- /ui/src/db-tables/ChangelogsTable.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from "nanoid"; 2 | import { Table, userSettingsTable } from "./WorkspaceDB"; 3 | import { Changelog } from "../types/dbTypes"; 4 | import { TableBase } from "./TableBase"; 5 | import { indexdb } from "./indexdb"; 6 | const LIMIT = 80; 7 | export class ChangelogsTable extends TableBase { 8 | static readonly TABLE_NAME: Table = "changelogs"; 9 | constructor() { 10 | super("changelogs"); 11 | } 12 | static async load(): Promise { 13 | const instance = new ChangelogsTable(); 14 | return instance; 15 | } 16 | 17 | public async listByWorkflowID(workflowID: string): Promise { 18 | const objects = await indexdb["changelogs"] 19 | .where("workflowID") 20 | .equals(workflowID) 21 | .reverse() 22 | .sortBy("createTime"); 23 | return objects; 24 | } 25 | public async getLastestByWorkflowID(workflowID: string): Promise { 26 | const objects = await this.listByWorkflowID(workflowID); 27 | return objects[0]; 28 | } 29 | public async create(input: { 30 | json: string; 31 | workflowID: string; 32 | isAutoSave: boolean; 33 | }): Promise { 34 | const latest = await this.getLastestByWorkflowID(input.workflowID); 35 | // only create when there is a change 36 | if (latest != null && latest.json === input.json) { 37 | return null; 38 | } 39 | 40 | const change: Changelog = { 41 | id: nanoid(), 42 | json: input.json, 43 | workflowID: input.workflowID, 44 | createTime: Date.now(), 45 | isAutoSave: input.isAutoSave, 46 | }; 47 | await indexdb.changelogs.add(change); 48 | await this.deleteLogsExceedLimit(input.workflowID); 49 | return change; 50 | } 51 | async deleteLogsExceedLimit(workflowID: string) { 52 | const all = await indexdb.changelogs 53 | .where("workflowID") 54 | .equals(workflowID) 55 | .reverse() 56 | .sortBy("createTime"); 57 | const limit = userSettingsTable?.settings?.maximumChangelogNumber ?? LIMIT; 58 | if (all.length > limit) { 59 | const toDelete = all.slice(limit); 60 | await indexdb.changelogs.bulkDelete(toDelete.map((c) => c.id)); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /ui/src/db-tables/DiskFileUtils.ts: -------------------------------------------------------------------------------- 1 | import { foldersTable, workflowsTable, userSettingsTable } from "./WorkspaceDB"; 2 | import { TwowayFolderSyncAPI } from "../apis/TwowaySyncFolderApi"; 3 | 4 | export async function genFolderRelPath( 5 | folderId: string | null, 6 | ): Promise { 7 | let filePath = ""; 8 | let curFolderID = folderId; 9 | while (curFolderID != null) { 10 | const folder = await foldersTable?.get(curFolderID); 11 | if (folder == null) { 12 | break; 13 | } 14 | const folderName = folder.name; 15 | filePath = `${folderName}/${filePath}`; 16 | curFolderID = folder.parentFolderID ?? null; 17 | } 18 | 19 | return filePath; 20 | } 21 | 22 | export async function getFileCountInFolder(folderId: string): Promise { 23 | const twoWaySyncEnabled = await userSettingsTable?.getSetting("twoWaySync"); 24 | if (twoWaySyncEnabled) { 25 | return (await TwowayFolderSyncAPI.genFilesCountInFolder(folderId)) ?? 0; 26 | } 27 | const allFlows = (await workflowsTable?.listAll()) ?? []; 28 | const allFolders = (await foldersTable?.listAll()) ?? []; 29 | const nestedFolderIdStack = [folderId]; 30 | let count = 0; 31 | 32 | while (nestedFolderIdStack.length > 0) { 33 | const curFolderId = nestedFolderIdStack.shift(); 34 | 35 | if (curFolderId) { 36 | for (const flow of allFlows) { 37 | if (flow.parentFolderID === curFolderId) { 38 | count++; 39 | } 40 | } 41 | 42 | const curNestedFolderIds = allFolders 43 | .filter((f) => f.parentFolderID === curFolderId) 44 | .map((f) => f.id); 45 | 46 | if (curNestedFolderIds.length) { 47 | nestedFolderIdStack.push(...curNestedFolderIds); 48 | } 49 | } 50 | } 51 | 52 | return count; 53 | } 54 | -------------------------------------------------------------------------------- /ui/src/db-tables/IndexDBUtils.ts: -------------------------------------------------------------------------------- 1 | // Legacy indexdb backup, to be deleted after indexdb migration is done 2 | 3 | const WORKSPACE_KEY = "backup"; 4 | const WORKSPACE_TABLE = "workspace"; 5 | let indexDB: IDBDatabase | null = null; 6 | // Function to open a database 7 | function openDatabase(): Promise { 8 | return new Promise((resolve, reject) => { 9 | const request = indexedDB.open("WorkspaceDB", 3); 10 | 11 | request.onupgradeneeded = (event) => { 12 | const db = (event.target as IDBOpenDBRequest).result; 13 | // Create an object store if it doesn't exist 14 | if (!db.objectStoreNames.contains(WORKSPACE_TABLE)) { 15 | db.createObjectStore(WORKSPACE_TABLE, { keyPath: "id" }); 16 | } 17 | }; 18 | 19 | request.onsuccess = (event) => { 20 | resolve((event.target as IDBOpenDBRequest).result); 21 | }; 22 | 23 | request.onerror = (event) => { 24 | reject((event.target as IDBOpenDBRequest).error); 25 | }; 26 | }); 27 | } 28 | 29 | // Function to write data to the database 30 | async function writeWorkspaceTable(data: string): Promise { 31 | try { 32 | if (indexDB == null) indexDB = await openDatabase(); 33 | 34 | return new Promise((resolve, reject) => { 35 | if (indexDB == null) return reject("indexDB is null"); 36 | const transaction = indexDB.transaction([WORKSPACE_TABLE], "readwrite"); 37 | const store = transaction.objectStore(WORKSPACE_TABLE); 38 | const request = store.put({ id: WORKSPACE_KEY, value: data }); 39 | 40 | request.onsuccess = () => resolve(); 41 | request.onerror = () => reject(request.error); 42 | }); 43 | } catch (error) { 44 | console.error("Error while opening database:", error); 45 | } 46 | } 47 | 48 | // Function to read data from the database 49 | async function readDataFromDatabase(): Promise { 50 | try { 51 | if (indexDB == null) indexDB = await openDatabase(); 52 | 53 | return new Promise((resolve, reject) => { 54 | if (indexDB == null) return reject("indexDB is null"); 55 | 56 | const transaction = indexDB.transaction([WORKSPACE_TABLE], "readonly"); 57 | const store = transaction.objectStore(WORKSPACE_TABLE); 58 | const request = store.get(WORKSPACE_KEY); 59 | 60 | request.onsuccess = () => { 61 | if (request.result) { 62 | resolve(request.result.value); 63 | } else { 64 | resolve(undefined); // Key not found 65 | } 66 | }; 67 | request.onerror = () => reject(request.error); 68 | }); 69 | } catch (error) { 70 | console.error("Error while reading from database:", error); 71 | } 72 | } 73 | 74 | export async function getWorkspaceIndexDB() { 75 | try { 76 | const comfyspaceData = await readDataFromDatabase(); 77 | 78 | return comfyspaceData; 79 | } catch (error) { 80 | console.error("Error retrieving data from IndexedDB:", error); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /ui/src/db-tables/MediaTable.ts: -------------------------------------------------------------------------------- 1 | import { Table, workflowsTable } from "./WorkspaceDB"; 2 | import { TableBase } from "./TableBase"; 3 | import { Media } from "../types/dbTypes"; 4 | import { indexdb } from "./indexdb"; 5 | 6 | export class MediaTable extends TableBase { 7 | static readonly TABLE_NAME: Table = "media"; 8 | constructor() { 9 | super("media"); 10 | } 11 | public async listByWorkflowID(workflowID: string): Promise { 12 | const medias = await indexdb["media"] 13 | .where("workflowID") 14 | .equals(workflowID) 15 | .reverse() 16 | .sortBy("createTime"); 17 | return medias; 18 | } 19 | 20 | public async create(input: { 21 | localPath: string; 22 | workflowID: string; 23 | }): Promise { 24 | const format = input.localPath.split(".").pop(); 25 | if (format == null) return null; 26 | 27 | const md: Media = { 28 | id: input.localPath, 29 | localPath: input.localPath, 30 | workflowID: input.workflowID, 31 | createTime: Date.now(), 32 | }; 33 | await workflowsTable?.updateMetaInfo(input.workflowID, { 34 | latestImage: md.localPath, 35 | }); 36 | // save indexdb 37 | indexdb.media.put(md); 38 | return md; 39 | } 40 | 41 | async delete(id: string): Promise { 42 | super.delete(id); 43 | if (workflowsTable?.curWorkflow?.coverMediaPath === id) { 44 | workflowsTable?.updateMetaInfo(workflowsTable.curWorkflow.id, { 45 | coverMediaPath: undefined, 46 | }); 47 | } 48 | if (workflowsTable?.curWorkflow?.latestImage === id) { 49 | workflowsTable?.updateMetaInfo(workflowsTable.curWorkflow.id, { 50 | latestImage: undefined, 51 | }); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /ui/src/db-tables/ModelsTable.ts: -------------------------------------------------------------------------------- 1 | import { Model } from "../types/dbTypes"; 2 | import { Table } from "./WorkspaceDB"; 3 | import { TableBase } from "./TableBase"; 4 | 5 | export class ModelsTable extends TableBase { 6 | static readonly TABLE_NAME: Table = "models"; 7 | constructor() { 8 | super("models"); 9 | } 10 | static async load(): Promise { 11 | const instance = new ModelsTable(); 12 | return instance; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /ui/src/db-tables/UserSettingsTable.ts: -------------------------------------------------------------------------------- 1 | import type { UserSettings } from "../types/dbTypes"; 2 | import { TableBase } from "./TableBase"; 3 | import { MODEL_TYPE_TO_FOLDER_MAPPING } from "../model-manager/install-models/util/modelTypes"; 4 | import { fetchApi, fetchMyWorkflowsDir } from "../Api"; 5 | 6 | export class UserSettingsTable extends TableBase { 7 | public defaultSettings: UserSettings; 8 | public readonly DEFAULT_USER = "guest"; 9 | static readonly TABLE_NAME = "userSettings"; 10 | 11 | private _settings: UserSettings | undefined = undefined; 12 | 13 | get settings() { 14 | return this._settings; 15 | } 16 | 17 | constructor() { 18 | super("userSettings"); 19 | this.defaultSettings = { 20 | id: this.DEFAULT_USER, 21 | topBarStyle: { 22 | top: 0, 23 | left: 0, 24 | }, 25 | myWorkflowsDir: "", 26 | shortcuts: { 27 | save: "Shift+S", 28 | saveAs: "Control+Alt+S", 29 | openSpotlightSearch: "Control+P", 30 | }, 31 | defaultFolders: MODEL_TYPE_TO_FOLDER_MAPPING, 32 | autoSave: false, 33 | autoSaveDuration: 3, 34 | twoWaySync: false, 35 | foldersOnTop: false, 36 | cloudHost: "https://www.nodecafe.co", 37 | maximumChangelogNumber: 80, 38 | hideCoverImage: false, 39 | disableUnsavedWarning: false, 40 | }; 41 | } 42 | 43 | public async getSetting( 44 | key: K, 45 | ): Promise { 46 | const currentUserSettings: UserSettings | undefined = await this.get( 47 | this.DEFAULT_USER, 48 | ); 49 | if (key === "shortcuts" && currentUserSettings?.shortcuts) { 50 | return { 51 | ...this.defaultSettings.shortcuts, 52 | ...currentUserSettings.shortcuts, 53 | } as UserSettings[K]; 54 | } 55 | return currentUserSettings?.[key] ?? this.defaultSettings[key]; 56 | } 57 | public async get(_id: string): Promise { 58 | const obj = await fetchApi("/workspace/get_settings", { 59 | headers: { 60 | "Content-Type": "application/json", 61 | }, 62 | }).then((res) => res.json()); 63 | return obj as any; 64 | } 65 | 66 | public async upsert(newPairs: Partial) { 67 | const oldSettings = 68 | (await this.get(this.DEFAULT_USER)) ?? this.defaultSettings; 69 | const newSettings = { 70 | ...oldSettings, 71 | ...newPairs, 72 | }; 73 | await fetchApi("/workspace/save_settings", { 74 | method: "POST", 75 | body: JSON.stringify(newSettings), 76 | headers: { 77 | "Content-Type": "application/json", 78 | }, 79 | }); 80 | this._settings = { 81 | ...this.defaultSettings, 82 | ...newSettings, 83 | }; 84 | } 85 | 86 | static async load(): Promise { 87 | const instance = new UserSettingsTable(); 88 | const myWorkflowsDir = await fetchMyWorkflowsDir(); 89 | 90 | instance.defaultSettings.myWorkflowsDir = myWorkflowsDir.path!; 91 | 92 | await instance.get(instance.DEFAULT_USER).then((res) => { 93 | instance._settings = { 94 | ...instance.defaultSettings, 95 | ...res, 96 | }; 97 | }); 98 | if ( 99 | instance._settings?.cloudHost?.includes("nodecafe.org") || 100 | instance._settings?.cloudHost?.includes("comfyspace.art") 101 | ) { 102 | // overwrite legacy comfyspace.art 103 | await instance.upsert({ 104 | cloudHost: instance.defaultSettings.cloudHost, 105 | }); 106 | } 107 | return instance; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /ui/src/db-tables/WorkflowVersionsTable.ts: -------------------------------------------------------------------------------- 1 | import { Table } from "./WorkspaceDB"; 2 | import { WorkflowVersion } from "../types/dbTypes"; 3 | import { TableBase } from "./TableBase"; 4 | import { indexdb } from "./indexdb"; 5 | 6 | export class WorkflowVersionsTable extends TableBase { 7 | static readonly TABLE_NAME: Table = "workflowVersions"; 8 | constructor() { 9 | super("workflowVersions"); 10 | } 11 | static async load(): Promise { 12 | const instance = new WorkflowVersionsTable(); 13 | return instance; 14 | } 15 | 16 | public async listByWorkflowID( 17 | workflowID: string, 18 | ): Promise { 19 | const objects = (await indexdb[this.tableName] 20 | .where("workflowID") 21 | .equals(workflowID) 22 | .reverse() 23 | .sortBy("createTime")) as WorkflowVersion[]; 24 | return objects ?? []; 25 | } 26 | 27 | public async getLatestByWorkflowID( 28 | workflowID: string, 29 | ): Promise { 30 | const objects = await this.listByWorkflowID(workflowID); 31 | if (objects?.length) return objects[0]; 32 | 33 | return null; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /ui/src/db-tables/WorkspaceDB.ts: -------------------------------------------------------------------------------- 1 | import { ChangelogsTable } from "./ChangelogsTable"; 2 | import { WorkflowsTable } from "./WorkflowsTable"; 3 | import { FoldersTable } from "./FoldersTable"; 4 | import { MediaTable } from "./MediaTable"; 5 | import { UserSettingsTable } from "./UserSettingsTable"; 6 | import { TagsTable } from "./tagsTable"; 7 | import { indexdb } from "./indexdb"; 8 | import { Folder, Workflow } from "../types/dbTypes"; 9 | import { WorkflowVersionsTable } from "./WorkflowVersionsTable"; 10 | 11 | export type Table = 12 | | "workflows" 13 | | "tags" 14 | | "userSettings" 15 | | "folders" 16 | | "changelogs" 17 | | "media" 18 | | "models" 19 | | "workflowVersions"; 20 | 21 | export function isFolder(n: Folder | Workflow): n is Folder { 22 | return "type" in n && n.type === "folder"; 23 | } 24 | 25 | export let workflowsTable: WorkflowsTable | null = null; 26 | export let tagsTable: TagsTable | null = null; 27 | export let userSettingsTable: UserSettingsTable | null = null; 28 | export let foldersTable: FoldersTable | null = null; 29 | export let changelogsTable: ChangelogsTable | null = null; 30 | export let mediaTable: MediaTable | null = null; 31 | export let workflowVersionsTable: WorkflowVersionsTable | null = null; 32 | 33 | export async function loadDBs() { 34 | const loadWorkflows = async () => { 35 | workflowsTable = await WorkflowsTable.load(); 36 | }; 37 | const loadTags = async () => { 38 | tagsTable = await TagsTable.load(); 39 | }; 40 | const loadUserSettings = async () => { 41 | userSettingsTable = await UserSettingsTable.load(); 42 | }; 43 | const loadFolders = async () => { 44 | foldersTable = await FoldersTable.load(); 45 | }; 46 | const loadChangelogs = async () => { 47 | changelogsTable = await ChangelogsTable.load(); 48 | }; 49 | const loadMedia = async () => { 50 | mediaTable = new MediaTable(); 51 | }; 52 | const loadWorkflowVersions = async () => { 53 | workflowVersionsTable = await WorkflowVersionsTable.load(); 54 | }; 55 | await Promise.all([ 56 | loadWorkflows(), 57 | loadTags(), 58 | loadUserSettings(), 59 | loadFolders(), 60 | loadChangelogs(), 61 | loadMedia(), 62 | loadWorkflowVersions(), 63 | ]); 64 | } 65 | -------------------------------------------------------------------------------- /ui/src/db-tables/indexdb.ts: -------------------------------------------------------------------------------- 1 | import Dexie, { Table } from "dexie"; 2 | import { 3 | Workflow, 4 | LocalCache, 5 | Changelog, 6 | Folder, 7 | Media, 8 | Model, 9 | Tag, 10 | UserSettings, 11 | WORKSPACE_INDEXDB_NAME, 12 | WorkflowVersion, 13 | } from "../types/dbTypes"; 14 | 15 | class ManagerDB extends Dexie { 16 | workflows!: Table; 17 | changelogs!: Table; 18 | media!: Table; 19 | folders!: Table; 20 | tags!: Table; 21 | userSettings!: Table; 22 | models!: Table; 23 | cache!: Table; 24 | workflowVersions!: Table; 25 | 26 | constructor() { 27 | super(WORKSPACE_INDEXDB_NAME); 28 | this.version(1) 29 | .stores({ 30 | workflows: "&id, name, parentFolderID", // Primary key and indexed props 31 | changelogs: "&id, workflowID", 32 | media: "&id, workflowID", 33 | folders: "&id, name, parentFolderID", 34 | tags: "&name", 35 | userSettings: "&id", 36 | models: "&id, fileName, fileHash", 37 | cache: "&id", 38 | }) 39 | .upgrade((trans) => {}); 40 | this.version(2) 41 | .stores({ 42 | workflows: "&id, name, parentFolderID,cloudID", 43 | changelogs: "&id, workflowID", 44 | media: "&id, workflowID", 45 | folders: "&id, name, parentFolderID", 46 | tags: "&name", 47 | userSettings: "&id", 48 | models: "&id, fileName, fileHash", 49 | cache: "&id", 50 | workflowVersions: "&id, name, workflowID,cloudID", 51 | }) 52 | .upgrade((trans) => {}); 53 | } 54 | } 55 | 56 | export const indexdb = new ManagerDB(); 57 | -------------------------------------------------------------------------------- /ui/src/db-tables/tagsTable.ts: -------------------------------------------------------------------------------- 1 | import { Tag } from "../types/dbTypes"; 2 | import { Table } from "./WorkspaceDB"; 3 | import { TableBase } from "./TableBase"; 4 | 5 | export class TagsTable extends TableBase { 6 | static readonly TABLE_NAME: Table = "tags"; 7 | 8 | constructor() { 9 | super("tags"); 10 | } 11 | 12 | static async load(): Promise { 13 | const instance = new TagsTable(); 14 | return instance; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /ui/src/gallery/GalleryContext.ts: -------------------------------------------------------------------------------- 1 | import { type Dispatch, type SetStateAction, createContext } from "react"; 2 | import type { Media } from "../types/dbTypes.ts"; 3 | 4 | interface GalleryContextProps { 5 | showAllImages: boolean; 6 | curMedia: Media | null; 7 | setCurMedia: (media: Media | null) => void; 8 | setMediaList: Dispatch>; 9 | setShowAllImages: (showAllImages: boolean) => void; 10 | } 11 | 12 | export const GalleryContext = createContext({ 13 | showAllImages: false, 14 | curMedia: null, 15 | setCurMedia(): void {}, 16 | setMediaList(): void {}, 17 | setShowAllImages(): void {}, 18 | }); 19 | -------------------------------------------------------------------------------- /ui/src/gallery/components/AllPromptForm/AllPromptForm.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Flex, Text, Stack } from "@chakra-ui/react"; 2 | import { FormItemComponent } from "../FormItem/FormItemComponent.tsx"; 3 | import { isInTopField } from "../MetaBox/MetadataForm.tsx"; 4 | import { useCallback, useContext, useEffect, useState } from "react"; 5 | import { MetaBoxContext } from "../MetaBox/metaBoxContext.ts"; 6 | import { PromptNodeInputItem } from "../MetaBox/utils.ts"; 7 | 8 | export default function AllPromptForm() { 9 | const { topFields, calcInputList, showNodeName } = useContext(MetaBoxContext); 10 | 11 | const groupInputsByNodeType = useCallback( 12 | (inputList: PromptNodeInputItem[]) => { 13 | const groupedInputs: PromptNodeInputItem[][] = []; 14 | inputList.forEach((input) => { 15 | const lastGroup = groupedInputs[groupedInputs.length - 1]; 16 | if ( 17 | !lastGroup || 18 | lastGroup[0].nodeID !== input.nodeID || 19 | lastGroup[0].classType !== input.classType 20 | ) { 21 | groupedInputs.push([input]); 22 | } else { 23 | lastGroup.push(input); 24 | } 25 | }); 26 | return groupedInputs; 27 | }, 28 | [], 29 | ); 30 | 31 | useEffect(() => {}, [showNodeName]); 32 | 33 | if (!showNodeName) { 34 | return ( 35 | 36 | {calcInputList.map((input) => { 37 | if ( 38 | isInTopField(topFields, { 39 | name: input.inputName, 40 | promptKey: input.nodeID, 41 | classType: input.classType, 42 | }) 43 | ) { 44 | return null; 45 | } 46 | return ( 47 | 51 | ); 52 | })} 53 | 54 | ); 55 | } 56 | const nodes = groupInputsByNodeType(calcInputList); 57 | return ( 58 | 59 | {nodes.map((nodeInputs) => { 60 | if (!nodeInputs[0]) { 61 | return null; 62 | } 63 | return ( 64 | 68 | 69 | {nodeInputs.map((input) => { 70 | if ( 71 | isInTopField(topFields, { 72 | name: input.inputName, 73 | promptKey: input.nodeID, 74 | classType: input.classType, 75 | }) 76 | ) { 77 | return null; 78 | } 79 | 80 | return ( 81 | 85 | ); 86 | })} 87 | 88 | 89 | ); 90 | })} 91 | 92 | ); 93 | } 94 | 95 | function CustomAccordionPanel({ 96 | title, 97 | children, 98 | }: { 99 | title: string; 100 | children: React.ReactNode; 101 | }) { 102 | return ( 103 | 104 | 105 | {title} 106 | 107 |
{children}
108 |
109 | ); 110 | } 111 | -------------------------------------------------------------------------------- /ui/src/gallery/components/FormItem/CheckboxBase.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Checkbox, Flex } from "@chakra-ui/react"; 3 | import { PromptNodeInputItem } from "../MetaBox/utils.ts"; 4 | 5 | export const CheckboxBase: FC< 6 | { 7 | inputItem: PromptNodeInputItem; 8 | } & { 9 | [key in string]: any; 10 | } 11 | > = (props) => { 12 | const inputItem = props.inputItem; 13 | return ( 14 | 15 | 16 | {inputItem.label ?? inputItem.inputName} 17 | 18 | { 21 | props?.updateMetaData?.({ 22 | promptKey: props.promptKey, 23 | name: props.name, 24 | value: e.target.checked, 25 | }); 26 | }} 27 | /> 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /ui/src/gallery/components/FormItem/InputBase.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Flex, Input } from "@chakra-ui/react"; 3 | import { PromptNodeInputItem } from "../MetaBox/utils.ts"; 4 | 5 | export const InputBase: FC< 6 | { 7 | inputItem: PromptNodeInputItem; 8 | } & { 9 | [key in string]: any; 10 | } 11 | > = ({ inputItem }) => { 12 | return ( 13 | 14 | 15 | {inputItem.label ?? inputItem.inputName} 16 | 17 | {}} /> 18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /ui/src/gallery/components/FormItem/InputSlider.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { 3 | Flex, 4 | NumberDecrementStepper, 5 | NumberIncrementStepper, 6 | NumberInput, 7 | NumberInputField, 8 | NumberInputStepper, 9 | Slider, 10 | SliderFilledTrack, 11 | SliderThumb, 12 | SliderTrack, 13 | } from "@chakra-ui/react"; 14 | import { PromptNodeInputItem } from "../MetaBox/utils.ts"; 15 | 16 | export const InputSlider: FC< 17 | { 18 | inputItem: PromptNodeInputItem; 19 | } & { 20 | [key in string]: any; 21 | } 22 | > = (props) => { 23 | const inputItem = props.inputItem; 24 | return ( 25 | 26 | 27 | 28 | {inputItem.label ?? inputItem.inputName} 29 | 30 | {}} 37 | > 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | {}} 53 | > 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /ui/src/gallery/components/FormItem/NoSupport.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Flex } from "@chakra-ui/react"; 3 | import { PromptNodeInputItem } from "../MetaBox/utils.ts"; 4 | 5 | export const NoSupport: FC<{ inputItem: PromptNodeInputItem }> = (props) => { 6 | return ( 7 | 8 | 9 | {props.inputItem.label ?? props.inputItem.inputName} 10 | 11 | No Support 12 | 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /ui/src/gallery/components/FormItem/SelectBase.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Flex, Select } from "@chakra-ui/react"; 3 | import { PromptNodeInputItem } from "../MetaBox/utils.ts"; 4 | 5 | export const SelectBase: FC< 6 | { 7 | inputItem: PromptNodeInputItem; 8 | } & { 9 | [key in string]: any; 10 | } 11 | > = (props) => { 12 | const { inputItem } = props; 13 | return ( 14 | 15 | 16 | {inputItem.label ?? inputItem.inputName} 17 | 18 | 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /ui/src/gallery/components/FormItem/TextareaBase.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Flex, Textarea } from "@chakra-ui/react"; 3 | import { PromptNodeInputItem } from "../MetaBox/utils.ts"; 4 | 5 | export const TextareaBase: FC< 6 | { 7 | inputItem: PromptNodeInputItem; 8 | } & { 9 | [key in string]: any; 10 | } 11 | > = (props) => { 12 | const inputItem = props.inputItem; 13 | return ( 14 | 15 | {inputItem.label ?? inputItem.inputName} 16 |