Store user settings/data on the server and multi user support (#2160)
* wip per user data * Rename, hide menu * better error rework default user * store pretty * Add userdata endpoints Change nodetemplates to userdata * add multi user message * make normal arg * Fix tests * Ignore user dir * user tests * Changed to default to browser storage and add server-storage arg * fix crash on empty templates * fix settings added before load * ignore parse errors
This commit is contained in:
parent
6a10640f0d
commit
235727fed7
|
@ -15,3 +15,4 @@ venv/
|
||||||
!/web/extensions/logging.js.example
|
!/web/extensions/logging.js.example
|
||||||
!/web/extensions/core/
|
!/web/extensions/core/
|
||||||
/tests-ui/data/object_info.json
|
/tests-ui/data/object_info.json
|
||||||
|
/user/
|
|
@ -0,0 +1,54 @@
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
|
||||||
|
class AppSettings():
|
||||||
|
def __init__(self, user_manager):
|
||||||
|
self.user_manager = user_manager
|
||||||
|
|
||||||
|
def get_settings(self, request):
|
||||||
|
file = self.user_manager.get_request_user_filepath(
|
||||||
|
request, "comfy.settings.json")
|
||||||
|
if os.path.isfile(file):
|
||||||
|
with open(file) as f:
|
||||||
|
return json.load(f)
|
||||||
|
else:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def save_settings(self, request, settings):
|
||||||
|
file = self.user_manager.get_request_user_filepath(
|
||||||
|
request, "comfy.settings.json")
|
||||||
|
with open(file, "w") as f:
|
||||||
|
f.write(json.dumps(settings, indent=4))
|
||||||
|
|
||||||
|
def add_routes(self, routes):
|
||||||
|
@routes.get("/settings")
|
||||||
|
async def get_settings(request):
|
||||||
|
return web.json_response(self.get_settings(request))
|
||||||
|
|
||||||
|
@routes.get("/settings/{id}")
|
||||||
|
async def get_setting(request):
|
||||||
|
value = None
|
||||||
|
settings = self.get_settings(request)
|
||||||
|
setting_id = request.match_info.get("id", None)
|
||||||
|
if setting_id and setting_id in settings:
|
||||||
|
value = settings[setting_id]
|
||||||
|
return web.json_response(value)
|
||||||
|
|
||||||
|
@routes.post("/settings")
|
||||||
|
async def post_settings(request):
|
||||||
|
settings = self.get_settings(request)
|
||||||
|
new_settings = await request.json()
|
||||||
|
self.save_settings(request, {**settings, **new_settings})
|
||||||
|
return web.Response(status=200)
|
||||||
|
|
||||||
|
@routes.post("/settings/{id}")
|
||||||
|
async def post_setting(request):
|
||||||
|
setting_id = request.match_info.get("id", None)
|
||||||
|
if not setting_id:
|
||||||
|
return web.Response(status=400)
|
||||||
|
settings = self.get_settings(request)
|
||||||
|
settings[setting_id] = await request.json()
|
||||||
|
self.save_settings(request, settings)
|
||||||
|
return web.Response(status=200)
|
|
@ -0,0 +1,141 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from aiohttp import web
|
||||||
|
from comfy.cli_args import args
|
||||||
|
from folder_paths import user_directory
|
||||||
|
from .app_settings import AppSettings
|
||||||
|
|
||||||
|
default_user = "default"
|
||||||
|
users_file = os.path.join(user_directory, "users.json")
|
||||||
|
|
||||||
|
|
||||||
|
class UserManager():
|
||||||
|
def __init__(self):
|
||||||
|
global user_directory
|
||||||
|
|
||||||
|
self.settings = AppSettings(self)
|
||||||
|
if not os.path.exists(user_directory):
|
||||||
|
os.mkdir(user_directory)
|
||||||
|
if not args.multi_user:
|
||||||
|
print("****** User settings have been changed to be stored on the server instead of browser storage. ******")
|
||||||
|
print("****** For multi-user setups add the --multi-user CLI argument to enable multiple user profiles. ******")
|
||||||
|
|
||||||
|
if args.multi_user:
|
||||||
|
if os.path.isfile(users_file):
|
||||||
|
with open(users_file) as f:
|
||||||
|
self.users = json.load(f)
|
||||||
|
else:
|
||||||
|
self.users = {}
|
||||||
|
else:
|
||||||
|
self.users = {"default": "default"}
|
||||||
|
|
||||||
|
def get_request_user_id(self, request):
|
||||||
|
user = "default"
|
||||||
|
if args.multi_user and "comfy-user" in request.headers:
|
||||||
|
user = request.headers["comfy-user"]
|
||||||
|
|
||||||
|
if user not in self.users:
|
||||||
|
raise KeyError("Unknown user: " + user)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
def get_request_user_filepath(self, request, file, type="userdata", create_dir=True):
|
||||||
|
global user_directory
|
||||||
|
|
||||||
|
if type == "userdata":
|
||||||
|
root_dir = user_directory
|
||||||
|
else:
|
||||||
|
raise KeyError("Unknown filepath type:" + type)
|
||||||
|
|
||||||
|
user = self.get_request_user_id(request)
|
||||||
|
path = user_root = os.path.abspath(os.path.join(root_dir, user))
|
||||||
|
|
||||||
|
# prevent leaving /{type}
|
||||||
|
if os.path.commonpath((root_dir, user_root)) != root_dir:
|
||||||
|
return None
|
||||||
|
|
||||||
|
parent = user_root
|
||||||
|
|
||||||
|
if file is not None:
|
||||||
|
# prevent leaving /{type}/{user}
|
||||||
|
path = os.path.abspath(os.path.join(user_root, file))
|
||||||
|
if os.path.commonpath((user_root, path)) != user_root:
|
||||||
|
return None
|
||||||
|
parent = os.path.join(path, os.pardir)
|
||||||
|
|
||||||
|
if create_dir and not os.path.exists(parent):
|
||||||
|
os.mkdir(parent)
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
def add_user(self, name):
|
||||||
|
name = name.strip()
|
||||||
|
if not name:
|
||||||
|
raise ValueError("username not provided")
|
||||||
|
user_id = re.sub("[^a-zA-Z0-9-_]+", '-', name)
|
||||||
|
user_id = user_id + "_" + str(uuid.uuid4())
|
||||||
|
|
||||||
|
self.users[user_id] = name
|
||||||
|
|
||||||
|
global users_file
|
||||||
|
with open(users_file, "w") as f:
|
||||||
|
json.dump(self.users, f)
|
||||||
|
|
||||||
|
return user_id
|
||||||
|
|
||||||
|
def add_routes(self, routes):
|
||||||
|
self.settings.add_routes(routes)
|
||||||
|
|
||||||
|
@routes.get("/users")
|
||||||
|
async def get_users(request):
|
||||||
|
if args.multi_user:
|
||||||
|
return web.json_response({"storage": "server", "users": self.users})
|
||||||
|
else:
|
||||||
|
user_dir = self.get_request_user_filepath(request, None, create_dir=False)
|
||||||
|
return web.json_response({
|
||||||
|
"storage": "server" if args.server_storage else "browser",
|
||||||
|
"migrated": os.path.exists(user_dir)
|
||||||
|
})
|
||||||
|
|
||||||
|
@routes.post("/users")
|
||||||
|
async def post_users(request):
|
||||||
|
body = await request.json()
|
||||||
|
username = body["username"]
|
||||||
|
if username in self.users.values():
|
||||||
|
return web.json_response({"error": "Duplicate username."}, status=400)
|
||||||
|
|
||||||
|
user_id = self.add_user(username)
|
||||||
|
return web.json_response(user_id)
|
||||||
|
|
||||||
|
@routes.get("/userdata/{file}")
|
||||||
|
async def getuserdata(request):
|
||||||
|
file = request.match_info.get("file", None)
|
||||||
|
if not file:
|
||||||
|
return web.Response(status=400)
|
||||||
|
|
||||||
|
path = self.get_request_user_filepath(request, file)
|
||||||
|
if not path:
|
||||||
|
return web.Response(status=403)
|
||||||
|
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return web.Response(status=404)
|
||||||
|
|
||||||
|
return web.FileResponse(path)
|
||||||
|
|
||||||
|
@routes.post("/userdata/{file}")
|
||||||
|
async def post_userdata(request):
|
||||||
|
file = request.match_info.get("file", None)
|
||||||
|
if not file:
|
||||||
|
return web.Response(status=400)
|
||||||
|
|
||||||
|
path = self.get_request_user_filepath(request, file)
|
||||||
|
if not path:
|
||||||
|
return web.Response(status=403)
|
||||||
|
|
||||||
|
body = await request.read()
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(body)
|
||||||
|
|
||||||
|
return web.Response(status=200)
|
|
@ -112,6 +112,9 @@ parser.add_argument("--windows-standalone-build", action="store_true", help="Win
|
||||||
|
|
||||||
parser.add_argument("--disable-metadata", action="store_true", help="Disable saving prompt metadata in files.")
|
parser.add_argument("--disable-metadata", action="store_true", help="Disable saving prompt metadata in files.")
|
||||||
|
|
||||||
|
parser.add_argument("--server-storage", action="store_true", help="Saves settings and other user configuration on the server instead of in browser storage.")
|
||||||
|
parser.add_argument("--multi-user", action="store_true", help="Enables per-user storage. If enabled, server-storage will be unconditionally enabled.")
|
||||||
|
|
||||||
if comfy.options.args_parsing:
|
if comfy.options.args_parsing:
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
else:
|
else:
|
||||||
|
@ -122,3 +125,6 @@ if args.windows_standalone_build:
|
||||||
|
|
||||||
if args.disable_auto_launch:
|
if args.disable_auto_launch:
|
||||||
args.auto_launch = False
|
args.auto_launch = False
|
||||||
|
|
||||||
|
if args.multi_user:
|
||||||
|
args.server_storage = True
|
|
@ -34,6 +34,7 @@ folder_names_and_paths["classifiers"] = ([os.path.join(models_dir, "classifiers"
|
||||||
output_directory = os.path.join(os.path.dirname(os.path.realpath(__file__)), "output")
|
output_directory = os.path.join(os.path.dirname(os.path.realpath(__file__)), "output")
|
||||||
temp_directory = os.path.join(os.path.dirname(os.path.realpath(__file__)), "temp")
|
temp_directory = os.path.join(os.path.dirname(os.path.realpath(__file__)), "temp")
|
||||||
input_directory = os.path.join(os.path.dirname(os.path.realpath(__file__)), "input")
|
input_directory = os.path.join(os.path.dirname(os.path.realpath(__file__)), "input")
|
||||||
|
user_directory = os.path.join(os.path.dirname(os.path.realpath(__file__)), "user")
|
||||||
|
|
||||||
filename_list_cache = {}
|
filename_list_cache = {}
|
||||||
|
|
||||||
|
|
|
@ -30,6 +30,7 @@ from comfy.cli_args import args
|
||||||
import comfy.utils
|
import comfy.utils
|
||||||
import comfy.model_management
|
import comfy.model_management
|
||||||
|
|
||||||
|
from app.user_manager import UserManager
|
||||||
|
|
||||||
class BinaryEventTypes:
|
class BinaryEventTypes:
|
||||||
PREVIEW_IMAGE = 1
|
PREVIEW_IMAGE = 1
|
||||||
|
@ -72,6 +73,7 @@ class PromptServer():
|
||||||
mimetypes.init()
|
mimetypes.init()
|
||||||
mimetypes.types_map['.js'] = 'application/javascript; charset=utf-8'
|
mimetypes.types_map['.js'] = 'application/javascript; charset=utf-8'
|
||||||
|
|
||||||
|
self.user_manager = UserManager()
|
||||||
self.supports = ["custom_nodes_from_web"]
|
self.supports = ["custom_nodes_from_web"]
|
||||||
self.prompt_queue = None
|
self.prompt_queue = None
|
||||||
self.loop = loop
|
self.loop = loop
|
||||||
|
@ -532,6 +534,7 @@ class PromptServer():
|
||||||
return web.Response(status=200)
|
return web.Response(status=200)
|
||||||
|
|
||||||
def add_routes(self):
|
def add_routes(self):
|
||||||
|
self.user_manager.add_routes(self.routes)
|
||||||
self.app.add_routes(self.routes)
|
self.app.add_routes(self.routes)
|
||||||
|
|
||||||
for name, dir in nodes.EXTENSION_WEB_DIRS.items():
|
for name, dir in nodes.EXTENSION_WEB_DIRS.items():
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
{
|
{
|
||||||
"presets": ["@babel/preset-env"]
|
"presets": ["@babel/preset-env"],
|
||||||
|
"plugins": ["babel-plugin-transform-import-meta"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/preset-env": "^7.22.20",
|
"@babel/preset-env": "^7.22.20",
|
||||||
"@types/jest": "^29.5.5",
|
"@types/jest": "^29.5.5",
|
||||||
|
"babel-plugin-transform-import-meta": "^2.2.1",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0"
|
"jest-environment-jsdom": "^29.7.0"
|
||||||
}
|
}
|
||||||
|
@ -2591,6 +2592,19 @@
|
||||||
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
|
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/babel-plugin-transform-import-meta": {
|
||||||
|
"version": "2.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/babel-plugin-transform-import-meta/-/babel-plugin-transform-import-meta-2.2.1.tgz",
|
||||||
|
"integrity": "sha512-AxNh27Pcg8Kt112RGa3Vod2QS2YXKKJ6+nSvRtv7qQTJAdx0MZa4UHZ4lnxHUWA2MNbLuZQv5FVab4P1CoLOWw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/template": "^7.4.4",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/babel-preset-current-node-syntax": {
|
"node_modules/babel-preset-current-node-syntax": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz",
|
||||||
|
@ -5233,6 +5247,12 @@
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
|
||||||
|
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/type-detect": {
|
"node_modules/type-detect": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
|
||||||
|
|
|
@ -24,6 +24,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/preset-env": "^7.22.20",
|
"@babel/preset-env": "^7.22.20",
|
||||||
"@types/jest": "^29.5.5",
|
"@types/jest": "^29.5.5",
|
||||||
|
"babel-plugin-transform-import-meta": "^2.2.1",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0"
|
"jest-environment-jsdom": "^29.7.0"
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,295 @@
|
||||||
|
// @ts-check
|
||||||
|
/// <reference path="../node_modules/@types/jest/index.d.ts" />
|
||||||
|
const { start } = require("../utils");
|
||||||
|
const lg = require("../utils/litegraph");
|
||||||
|
|
||||||
|
describe("users", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
lg.setup(global);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
lg.teardown(global);
|
||||||
|
});
|
||||||
|
|
||||||
|
function expectNoUserScreen() {
|
||||||
|
// Ensure login isnt visible
|
||||||
|
const selection = document.querySelectorAll("#comfy-user-selection")?.[0];
|
||||||
|
expect(selection["style"].display).toBe("none");
|
||||||
|
const menu = document.querySelectorAll(".comfy-menu")?.[0];
|
||||||
|
expect(window.getComputedStyle(menu)?.display).not.toBe("none");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("multi-user", () => {
|
||||||
|
function mockAddStylesheet() {
|
||||||
|
const utils = require("../../web/scripts/utils");
|
||||||
|
utils.addStylesheet = jest.fn().mockReturnValue(Promise.resolve());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForUserScreenShow() {
|
||||||
|
mockAddStylesheet();
|
||||||
|
|
||||||
|
// Wait for "show" to be called
|
||||||
|
const { UserSelectionScreen } = require("../../web/scripts/ui/userSelection");
|
||||||
|
let resolve, reject;
|
||||||
|
const fn = UserSelectionScreen.prototype.show;
|
||||||
|
const p = new Promise((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
jest.spyOn(UserSelectionScreen.prototype, "show").mockImplementation(async (...args) => {
|
||||||
|
const res = fn(...args);
|
||||||
|
await new Promise(process.nextTick); // wait for promises to resolve
|
||||||
|
resolve();
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
// @ts-ignore
|
||||||
|
setTimeout(() => reject("timeout waiting for UserSelectionScreen to be shown."), 500);
|
||||||
|
await p;
|
||||||
|
await new Promise(process.nextTick); // wait for promises to resolve
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testUserScreen(onShown, users) {
|
||||||
|
if (!users) {
|
||||||
|
users = {};
|
||||||
|
}
|
||||||
|
const starting = start({
|
||||||
|
resetEnv: true,
|
||||||
|
userConfig: { storage: "server", users },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure no current user
|
||||||
|
expect(localStorage["Comfy.userId"]).toBeFalsy();
|
||||||
|
expect(localStorage["Comfy.userName"]).toBeFalsy();
|
||||||
|
|
||||||
|
await waitForUserScreenShow();
|
||||||
|
|
||||||
|
const selection = document.querySelectorAll("#comfy-user-selection")?.[0];
|
||||||
|
expect(selection).toBeTruthy();
|
||||||
|
|
||||||
|
// Ensure login is visible
|
||||||
|
expect(window.getComputedStyle(selection)?.display).not.toBe("none");
|
||||||
|
// Ensure menu is hidden
|
||||||
|
const menu = document.querySelectorAll(".comfy-menu")?.[0];
|
||||||
|
expect(window.getComputedStyle(menu)?.display).toBe("none");
|
||||||
|
|
||||||
|
const isCreate = await onShown(selection);
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
selection.querySelectorAll("form")[0].submit();
|
||||||
|
await new Promise(process.nextTick); // wait for promises to resolve
|
||||||
|
|
||||||
|
// Wait for start
|
||||||
|
const s = await starting;
|
||||||
|
|
||||||
|
// Ensure login is removed
|
||||||
|
expect(document.querySelectorAll("#comfy-user-selection")).toHaveLength(0);
|
||||||
|
expect(window.getComputedStyle(menu)?.display).not.toBe("none");
|
||||||
|
|
||||||
|
// Ensure settings + templates are saved
|
||||||
|
const { api } = require("../../web/scripts/api");
|
||||||
|
expect(api.createUser).toHaveBeenCalledTimes(+isCreate);
|
||||||
|
expect(api.storeSettings).toHaveBeenCalledTimes(+isCreate);
|
||||||
|
expect(api.storeUserData).toHaveBeenCalledTimes(+isCreate);
|
||||||
|
if (isCreate) {
|
||||||
|
expect(api.storeUserData).toHaveBeenCalledWith("comfy.templates.json", null, { stringify: false });
|
||||||
|
expect(s.app.isNewUserSession).toBeTruthy();
|
||||||
|
} else {
|
||||||
|
expect(s.app.isNewUserSession).toBeFalsy();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { users, selection, ...s };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("allows user creation if no users", async () => {
|
||||||
|
const { users } = await testUserScreen((selection) => {
|
||||||
|
// Ensure we have no users flag added
|
||||||
|
expect(selection.classList.contains("no-users")).toBeTruthy();
|
||||||
|
|
||||||
|
// Enter a username
|
||||||
|
const input = selection.getElementsByTagName("input")[0];
|
||||||
|
input.focus();
|
||||||
|
input.value = "Test User";
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(users).toStrictEqual({
|
||||||
|
"Test User!": "Test User",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(localStorage["Comfy.userId"]).toBe("Test User!");
|
||||||
|
expect(localStorage["Comfy.userName"]).toBe("Test User");
|
||||||
|
});
|
||||||
|
it("allows user creation if no current user but other users", async () => {
|
||||||
|
const users = {
|
||||||
|
"Test User 2!": "Test User 2",
|
||||||
|
};
|
||||||
|
|
||||||
|
await testUserScreen((selection) => {
|
||||||
|
expect(selection.classList.contains("no-users")).toBeFalsy();
|
||||||
|
|
||||||
|
// Enter a username
|
||||||
|
const input = selection.getElementsByTagName("input")[0];
|
||||||
|
input.focus();
|
||||||
|
input.value = "Test User 3";
|
||||||
|
return true;
|
||||||
|
}, users);
|
||||||
|
|
||||||
|
expect(users).toStrictEqual({
|
||||||
|
"Test User 2!": "Test User 2",
|
||||||
|
"Test User 3!": "Test User 3",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(localStorage["Comfy.userId"]).toBe("Test User 3!");
|
||||||
|
expect(localStorage["Comfy.userName"]).toBe("Test User 3");
|
||||||
|
});
|
||||||
|
it("allows user selection if no current user but other users", async () => {
|
||||||
|
const users = {
|
||||||
|
"A!": "A",
|
||||||
|
"B!": "B",
|
||||||
|
"C!": "C",
|
||||||
|
};
|
||||||
|
|
||||||
|
await testUserScreen((selection) => {
|
||||||
|
expect(selection.classList.contains("no-users")).toBeFalsy();
|
||||||
|
|
||||||
|
// Check user list
|
||||||
|
const select = selection.getElementsByTagName("select")[0];
|
||||||
|
const options = select.getElementsByTagName("option");
|
||||||
|
expect(
|
||||||
|
[...options]
|
||||||
|
.filter((o) => !o.disabled)
|
||||||
|
.reduce((p, n) => {
|
||||||
|
p[n.getAttribute("value")] = n.textContent;
|
||||||
|
return p;
|
||||||
|
}, {})
|
||||||
|
).toStrictEqual(users);
|
||||||
|
|
||||||
|
// Select an option
|
||||||
|
select.focus();
|
||||||
|
select.value = options[2].value;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}, users);
|
||||||
|
|
||||||
|
expect(users).toStrictEqual(users);
|
||||||
|
|
||||||
|
expect(localStorage["Comfy.userId"]).toBe("B!");
|
||||||
|
expect(localStorage["Comfy.userName"]).toBe("B");
|
||||||
|
});
|
||||||
|
it("doesnt show user screen if current user", async () => {
|
||||||
|
const starting = start({
|
||||||
|
resetEnv: true,
|
||||||
|
userConfig: {
|
||||||
|
storage: "server",
|
||||||
|
users: {
|
||||||
|
"User!": "User",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
localStorage: {
|
||||||
|
"Comfy.userId": "User!",
|
||||||
|
"Comfy.userName": "User",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await new Promise(process.nextTick); // wait for promises to resolve
|
||||||
|
|
||||||
|
expectNoUserScreen();
|
||||||
|
|
||||||
|
await starting;
|
||||||
|
});
|
||||||
|
it("allows user switching", async () => {
|
||||||
|
const { app } = await start({
|
||||||
|
resetEnv: true,
|
||||||
|
userConfig: {
|
||||||
|
storage: "server",
|
||||||
|
users: {
|
||||||
|
"User!": "User",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
localStorage: {
|
||||||
|
"Comfy.userId": "User!",
|
||||||
|
"Comfy.userName": "User",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// cant actually test switching user easily but can check the setting is present
|
||||||
|
expect(app.ui.settings.settingsLookup["Comfy.SwitchUser"]).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("single-user", () => {
|
||||||
|
it("doesnt show user creation if no default user", async () => {
|
||||||
|
const { app } = await start({
|
||||||
|
resetEnv: true,
|
||||||
|
userConfig: { migrated: false, storage: "server" },
|
||||||
|
});
|
||||||
|
expectNoUserScreen();
|
||||||
|
|
||||||
|
// It should store the settings
|
||||||
|
const { api } = require("../../web/scripts/api");
|
||||||
|
expect(api.storeSettings).toHaveBeenCalledTimes(1);
|
||||||
|
expect(api.storeUserData).toHaveBeenCalledTimes(1);
|
||||||
|
expect(api.storeUserData).toHaveBeenCalledWith("comfy.templates.json", null, { stringify: false });
|
||||||
|
expect(app.isNewUserSession).toBeTruthy();
|
||||||
|
});
|
||||||
|
it("doesnt show user creation if default user", async () => {
|
||||||
|
const { app } = await start({
|
||||||
|
resetEnv: true,
|
||||||
|
userConfig: { migrated: true, storage: "server" },
|
||||||
|
});
|
||||||
|
expectNoUserScreen();
|
||||||
|
|
||||||
|
// It should store the settings
|
||||||
|
const { api } = require("../../web/scripts/api");
|
||||||
|
expect(api.storeSettings).toHaveBeenCalledTimes(0);
|
||||||
|
expect(api.storeUserData).toHaveBeenCalledTimes(0);
|
||||||
|
expect(app.isNewUserSession).toBeFalsy();
|
||||||
|
});
|
||||||
|
it("doesnt allow user switching", async () => {
|
||||||
|
const { app } = await start({
|
||||||
|
resetEnv: true,
|
||||||
|
userConfig: { migrated: true, storage: "server" },
|
||||||
|
});
|
||||||
|
expectNoUserScreen();
|
||||||
|
|
||||||
|
expect(app.ui.settings.settingsLookup["Comfy.SwitchUser"]).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("browser-user", () => {
|
||||||
|
it("doesnt show user creation if no default user", async () => {
|
||||||
|
const { app } = await start({
|
||||||
|
resetEnv: true,
|
||||||
|
userConfig: { migrated: false, storage: "browser" },
|
||||||
|
});
|
||||||
|
expectNoUserScreen();
|
||||||
|
|
||||||
|
// It should store the settings
|
||||||
|
const { api } = require("../../web/scripts/api");
|
||||||
|
expect(api.storeSettings).toHaveBeenCalledTimes(0);
|
||||||
|
expect(api.storeUserData).toHaveBeenCalledTimes(0);
|
||||||
|
expect(app.isNewUserSession).toBeFalsy();
|
||||||
|
});
|
||||||
|
it("doesnt show user creation if default user", async () => {
|
||||||
|
const { app } = await start({
|
||||||
|
resetEnv: true,
|
||||||
|
userConfig: { migrated: true, storage: "server" },
|
||||||
|
});
|
||||||
|
expectNoUserScreen();
|
||||||
|
|
||||||
|
// It should store the settings
|
||||||
|
const { api } = require("../../web/scripts/api");
|
||||||
|
expect(api.storeSettings).toHaveBeenCalledTimes(0);
|
||||||
|
expect(api.storeUserData).toHaveBeenCalledTimes(0);
|
||||||
|
expect(app.isNewUserSession).toBeFalsy();
|
||||||
|
});
|
||||||
|
it("doesnt allow user switching", async () => {
|
||||||
|
const { app } = await start({
|
||||||
|
resetEnv: true,
|
||||||
|
userConfig: { migrated: true, storage: "browser" },
|
||||||
|
});
|
||||||
|
expectNoUserScreen();
|
||||||
|
|
||||||
|
expect(app.ui.settings.settingsLookup["Comfy.SwitchUser"]).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,10 +1,18 @@
|
||||||
const { mockApi } = require("./setup");
|
const { mockApi } = require("./setup");
|
||||||
const { Ez } = require("./ezgraph");
|
const { Ez } = require("./ezgraph");
|
||||||
const lg = require("./litegraph");
|
const lg = require("./litegraph");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const html = fs.readFileSync(path.resolve(__dirname, "../../web/index.html"))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param { Parameters<mockApi>[0] & { resetEnv?: boolean, preSetup?(app): Promise<void> } } config
|
* @param { Parameters<typeof mockApi>[0] & {
|
||||||
|
* resetEnv?: boolean,
|
||||||
|
* preSetup?(app): Promise<void>,
|
||||||
|
* localStorage?: Record<string, string>
|
||||||
|
* } } config
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export async function start(config = {}) {
|
export async function start(config = {}) {
|
||||||
|
@ -12,12 +20,18 @@ export async function start(config = {}) {
|
||||||
jest.resetModules();
|
jest.resetModules();
|
||||||
jest.resetAllMocks();
|
jest.resetAllMocks();
|
||||||
lg.setup(global);
|
lg.setup(global);
|
||||||
|
localStorage.clear();
|
||||||
|
sessionStorage.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Object.assign(localStorage, config.localStorage ?? {});
|
||||||
|
document.body.innerHTML = html;
|
||||||
|
|
||||||
mockApi(config);
|
mockApi(config);
|
||||||
const { app } = require("../../web/scripts/app");
|
const { app } = require("../../web/scripts/app");
|
||||||
config.preSetup?.(app);
|
config.preSetup?.(app);
|
||||||
await app.setup();
|
await app.setup();
|
||||||
|
|
||||||
return { ...Ez.graph(app, global["LiteGraph"], global["LGraphCanvas"]), app };
|
return { ...Ez.graph(app, global["LiteGraph"], global["LGraphCanvas"]), app };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -18,9 +18,21 @@ function* walkSync(dir) {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param { { mockExtensions?: string[], mockNodeDefs?: Record<string, ComfyObjectInfo> } } config
|
* @param {{
|
||||||
|
* mockExtensions?: string[],
|
||||||
|
* mockNodeDefs?: Record<string, ComfyObjectInfo>,
|
||||||
|
* settings?: Record<string, string>
|
||||||
|
* userConfig?: {storage: "server" | "browser", users?: Record<string, any>, migrated?: boolean },
|
||||||
|
* userData?: Record<string, any>
|
||||||
|
* }} config
|
||||||
*/
|
*/
|
||||||
export function mockApi({ mockExtensions, mockNodeDefs } = {}) {
|
export function mockApi(config = {}) {
|
||||||
|
let { mockExtensions, mockNodeDefs, userConfig, settings, userData } = {
|
||||||
|
userConfig,
|
||||||
|
settings: {},
|
||||||
|
userData: {},
|
||||||
|
...config,
|
||||||
|
};
|
||||||
if (!mockExtensions) {
|
if (!mockExtensions) {
|
||||||
mockExtensions = Array.from(walkSync(path.resolve("../web/extensions/core")))
|
mockExtensions = Array.from(walkSync(path.resolve("../web/extensions/core")))
|
||||||
.filter((x) => x.endsWith(".js"))
|
.filter((x) => x.endsWith(".js"))
|
||||||
|
@ -40,6 +52,26 @@ export function mockApi({ mockExtensions, mockNodeDefs } = {}) {
|
||||||
getNodeDefs: jest.fn(() => mockNodeDefs),
|
getNodeDefs: jest.fn(() => mockNodeDefs),
|
||||||
init: jest.fn(),
|
init: jest.fn(),
|
||||||
apiURL: jest.fn((x) => "../../web/" + x),
|
apiURL: jest.fn((x) => "../../web/" + x),
|
||||||
|
createUser: jest.fn((username) => {
|
||||||
|
if(username in userConfig.users) {
|
||||||
|
return { status: 400, json: () => "Duplicate" }
|
||||||
|
}
|
||||||
|
userConfig.users[username + "!"] = username;
|
||||||
|
return { status: 200, json: () => username + "!" }
|
||||||
|
}),
|
||||||
|
getUserConfig: jest.fn(() => userConfig ?? { storage: "browser", migrated: false }),
|
||||||
|
getSettings: jest.fn(() => settings),
|
||||||
|
storeSettings: jest.fn((v) => Object.assign(settings, v)),
|
||||||
|
getUserData: jest.fn((f) => {
|
||||||
|
if (f in userData) {
|
||||||
|
return { status: 200, json: () => userData[f] };
|
||||||
|
} else {
|
||||||
|
return { status: 404 };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
storeUserData: jest.fn((file, data) => {
|
||||||
|
userData[file] = data;
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
jest.mock("../../web/scripts/api", () => ({
|
jest.mock("../../web/scripts/api", () => ({
|
||||||
get api() {
|
get api() {
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { app } from "../../scripts/app.js";
|
import { app } from "../../scripts/app.js";
|
||||||
|
import { api } from "../../scripts/api.js";
|
||||||
import { ComfyDialog, $el } from "../../scripts/ui.js";
|
import { ComfyDialog, $el } from "../../scripts/ui.js";
|
||||||
import { GroupNodeConfig, GroupNodeHandler } from "./groupNode.js";
|
import { GroupNodeConfig, GroupNodeHandler } from "./groupNode.js";
|
||||||
|
|
||||||
|
@ -20,16 +21,20 @@ import { GroupNodeConfig, GroupNodeHandler } from "./groupNode.js";
|
||||||
// Open the manage dialog and Drag and drop elements using the "Name:" label as handle
|
// Open the manage dialog and Drag and drop elements using the "Name:" label as handle
|
||||||
|
|
||||||
const id = "Comfy.NodeTemplates";
|
const id = "Comfy.NodeTemplates";
|
||||||
|
const file = "comfy.templates.json";
|
||||||
|
|
||||||
class ManageTemplates extends ComfyDialog {
|
class ManageTemplates extends ComfyDialog {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
this.load().then((v) => {
|
||||||
|
this.templates = v;
|
||||||
|
});
|
||||||
|
|
||||||
this.element.classList.add("comfy-manage-templates");
|
this.element.classList.add("comfy-manage-templates");
|
||||||
this.templates = this.load();
|
|
||||||
this.draggedEl = null;
|
this.draggedEl = null;
|
||||||
this.saveVisualCue = null;
|
this.saveVisualCue = null;
|
||||||
this.emptyImg = new Image();
|
this.emptyImg = new Image();
|
||||||
this.emptyImg.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=';
|
this.emptyImg.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
|
||||||
|
|
||||||
this.importInput = $el("input", {
|
this.importInput = $el("input", {
|
||||||
type: "file",
|
type: "file",
|
||||||
|
@ -67,17 +72,50 @@ class ManageTemplates extends ComfyDialog {
|
||||||
return btns;
|
return btns;
|
||||||
}
|
}
|
||||||
|
|
||||||
load() {
|
async load() {
|
||||||
const templates = localStorage.getItem(id);
|
let templates = [];
|
||||||
if (templates) {
|
if (app.storageLocation === "server") {
|
||||||
return JSON.parse(templates);
|
if (app.isNewUserSession) {
|
||||||
|
// New user so migrate existing templates
|
||||||
|
const json = localStorage.getItem(id);
|
||||||
|
if (json) {
|
||||||
|
templates = JSON.parse(json);
|
||||||
|
}
|
||||||
|
await api.storeUserData(file, json, { stringify: false });
|
||||||
|
} else {
|
||||||
|
const res = await api.getUserData(file);
|
||||||
|
if (res.status === 200) {
|
||||||
|
try {
|
||||||
|
templates = await res.json();
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
} else if (res.status !== 404) {
|
||||||
|
console.error(res.status + " " + res.statusText);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return [];
|
const json = localStorage.getItem(id);
|
||||||
|
if (json) {
|
||||||
|
templates = JSON.parse(json);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return templates ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
store() {
|
async store() {
|
||||||
localStorage.setItem(id, JSON.stringify(this.templates));
|
if(app.storageLocation === "server") {
|
||||||
|
const templates = JSON.stringify(this.templates, undefined, 4);
|
||||||
|
localStorage.setItem(id, templates); // Backwards compatibility
|
||||||
|
try {
|
||||||
|
await api.storeUserData(file, templates, { stringify: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert(error.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(id, JSON.stringify(this.templates));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async importAll() {
|
async importAll() {
|
||||||
|
@ -85,14 +123,14 @@ class ManageTemplates extends ComfyDialog {
|
||||||
if (file.type === "application/json" || file.name.endsWith(".json")) {
|
if (file.type === "application/json" || file.name.endsWith(".json")) {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = async () => {
|
reader.onload = async () => {
|
||||||
var importFile = JSON.parse(reader.result);
|
const importFile = JSON.parse(reader.result);
|
||||||
if (importFile && importFile?.templates) {
|
if (importFile?.templates) {
|
||||||
for (const template of importFile.templates) {
|
for (const template of importFile.templates) {
|
||||||
if (template?.name && template?.data) {
|
if (template?.name && template?.data) {
|
||||||
this.templates.push(template);
|
this.templates.push(template);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.store();
|
await this.store();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
await reader.readAsText(file);
|
await reader.readAsText(file);
|
||||||
|
@ -159,7 +197,7 @@ class ManageTemplates extends ComfyDialog {
|
||||||
e.currentTarget.style.border = "1px dashed transparent";
|
e.currentTarget.style.border = "1px dashed transparent";
|
||||||
e.currentTarget.removeAttribute("draggable");
|
e.currentTarget.removeAttribute("draggable");
|
||||||
|
|
||||||
// rearrange the elements in the localStorage
|
// rearrange the elements
|
||||||
this.element.querySelectorAll('.tempateManagerRow').forEach((el,i) => {
|
this.element.querySelectorAll('.tempateManagerRow').forEach((el,i) => {
|
||||||
var prev_i = el.dataset.id;
|
var prev_i = el.dataset.id;
|
||||||
|
|
||||||
|
|
|
@ -16,5 +16,33 @@
|
||||||
window.graph = app.graph;
|
window.graph = app.graph;
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="litegraph"></body>
|
<body class="litegraph">
|
||||||
|
<div id="comfy-user-selection" class="comfy-user-selection" style="display: none;">
|
||||||
|
<main class="comfy-user-selection-inner">
|
||||||
|
<h1>ComfyUI</h1>
|
||||||
|
<form>
|
||||||
|
<section>
|
||||||
|
<label>New user:
|
||||||
|
<input placeholder="Enter a username" />
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
<div class="comfy-user-existing">
|
||||||
|
<span class="or-separator">OR</span>
|
||||||
|
<section>
|
||||||
|
<label>
|
||||||
|
Existing user:
|
||||||
|
<select>
|
||||||
|
<option hidden disabled selected value> Select a user </option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<span class="comfy-user-error"> </span>
|
||||||
|
<button class="comfy-btn comfy-user-button-next">Next</button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -12,6 +12,13 @@ class ComfyApi extends EventTarget {
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchApi(route, options) {
|
fetchApi(route, options) {
|
||||||
|
if (!options) {
|
||||||
|
options = {};
|
||||||
|
}
|
||||||
|
if (!options.headers) {
|
||||||
|
options.headers = {};
|
||||||
|
}
|
||||||
|
options.headers["Comfy-User"] = this.user;
|
||||||
return fetch(this.apiURL(route), options);
|
return fetch(this.apiURL(route), options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -315,6 +322,99 @@ class ComfyApi extends EventTarget {
|
||||||
async interrupt() {
|
async interrupt() {
|
||||||
await this.#postItem("interrupt", null);
|
await this.#postItem("interrupt", null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets user configuration data and where data should be stored
|
||||||
|
* @returns { Promise<{ storage: "server" | "browser", users?: Promise<string, unknown>, migrated?: boolean }> }
|
||||||
|
*/
|
||||||
|
async getUserConfig() {
|
||||||
|
return (await this.fetchApi("/users")).json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new user
|
||||||
|
* @param { string } username
|
||||||
|
* @returns The fetch response
|
||||||
|
*/
|
||||||
|
createUser(username) {
|
||||||
|
return this.fetchApi("/users", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all setting values for the current user
|
||||||
|
* @returns { Promise<string, unknown> } A dictionary of id -> value
|
||||||
|
*/
|
||||||
|
async getSettings() {
|
||||||
|
return (await this.fetchApi("/settings")).json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a setting for the current user
|
||||||
|
* @param { string } id The id of the setting to fetch
|
||||||
|
* @returns { Promise<unknown> } The setting value
|
||||||
|
*/
|
||||||
|
async getSetting(id) {
|
||||||
|
return (await this.fetchApi(`/settings/${encodeURIComponent(id)}`)).json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a dictionary of settings for the current user
|
||||||
|
* @param { Record<string, unknown> } settings Dictionary of setting id -> value to save
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
async storeSettings(settings) {
|
||||||
|
return this.fetchApi(`/settings`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(settings)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a setting for the current user
|
||||||
|
* @param { string } id The id of the setting to update
|
||||||
|
* @param { unknown } value The value of the setting
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
async storeSetting(id, value) {
|
||||||
|
return this.fetchApi(`/settings/${encodeURIComponent(id)}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(value)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a user data file for the current user
|
||||||
|
* @param { string } file The name of the userdata file to load
|
||||||
|
* @param { RequestInit } [options]
|
||||||
|
* @returns { Promise<unknown> } The fetch response object
|
||||||
|
*/
|
||||||
|
async getUserData(file, options) {
|
||||||
|
return this.fetchApi(`/userdata/${encodeURIComponent(file)}`, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a user data file for the current user
|
||||||
|
* @param { string } file The name of the userdata file to save
|
||||||
|
* @param { unknown } data The data to save to the file
|
||||||
|
* @param { RequestInit & { stringify?: boolean, throwOnError?: boolean } } [options]
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
async storeUserData(file, data, options = { stringify: true, throwOnError: true }) {
|
||||||
|
const resp = await this.fetchApi(`/userdata/${encodeURIComponent(file)}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: options?.stringify ? JSON.stringify(data) : data,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
if (resp.status !== 200) {
|
||||||
|
throw new Error(`Error storing user data file '${file}': ${resp.status} ${(await resp).statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = new ComfyApi();
|
export const api = new ComfyApi();
|
||||||
|
|
|
@ -1291,10 +1291,92 @@ export class ComfyApp {
|
||||||
await Promise.all(extensionPromises);
|
await Promise.all(extensionPromises);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async #migrateSettings() {
|
||||||
|
this.isNewUserSession = true;
|
||||||
|
// Store all current settings
|
||||||
|
const settings = Object.keys(this.ui.settings).reduce((p, n) => {
|
||||||
|
const v = localStorage[`Comfy.Settings.${n}`];
|
||||||
|
if (v) {
|
||||||
|
try {
|
||||||
|
p[n] = JSON.parse(v);
|
||||||
|
} catch (error) {}
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
await api.storeSettings(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
async #setUser() {
|
||||||
|
const userConfig = await api.getUserConfig();
|
||||||
|
this.storageLocation = userConfig.storage;
|
||||||
|
if (typeof userConfig.migrated == "boolean") {
|
||||||
|
// Single user mode migrated true/false for if the default user is created
|
||||||
|
if (!userConfig.migrated && this.storageLocation === "server") {
|
||||||
|
// Default user not created yet
|
||||||
|
await this.#migrateSettings();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.multiUserServer = true;
|
||||||
|
let user = localStorage["Comfy.userId"];
|
||||||
|
const users = userConfig.users ?? {};
|
||||||
|
if (!user || !users[user]) {
|
||||||
|
// This will rarely be hit so move the loading to on demand
|
||||||
|
const { UserSelectionScreen } = await import("./ui/userSelection.js");
|
||||||
|
|
||||||
|
this.ui.menuContainer.style.display = "none";
|
||||||
|
const { userId, username, created } = await new UserSelectionScreen().show(users, user);
|
||||||
|
this.ui.menuContainer.style.display = "";
|
||||||
|
|
||||||
|
user = userId;
|
||||||
|
localStorage["Comfy.userName"] = username;
|
||||||
|
localStorage["Comfy.userId"] = user;
|
||||||
|
|
||||||
|
if (created) {
|
||||||
|
api.user = user;
|
||||||
|
await this.#migrateSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.user = user;
|
||||||
|
|
||||||
|
this.ui.settings.addSetting({
|
||||||
|
id: "Comfy.SwitchUser",
|
||||||
|
name: "Switch User",
|
||||||
|
type: (name) => {
|
||||||
|
let currentUser = localStorage["Comfy.userName"];
|
||||||
|
if (currentUser) {
|
||||||
|
currentUser = ` (${currentUser})`;
|
||||||
|
}
|
||||||
|
return $el("tr", [
|
||||||
|
$el("td", [
|
||||||
|
$el("label", {
|
||||||
|
textContent: name,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
$el("td", [
|
||||||
|
$el("button", {
|
||||||
|
textContent: name + (currentUser ?? ""),
|
||||||
|
onclick: () => {
|
||||||
|
delete localStorage["Comfy.userId"];
|
||||||
|
delete localStorage["Comfy.userName"];
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up the app on the page
|
* Set up the app on the page
|
||||||
*/
|
*/
|
||||||
async setup() {
|
async setup() {
|
||||||
|
await this.#setUser();
|
||||||
|
await this.ui.settings.load();
|
||||||
await this.#loadExtensions();
|
await this.#loadExtensions();
|
||||||
|
|
||||||
// Create and mount the LiteGraph in the DOM
|
// Create and mount the LiteGraph in the DOM
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
import {api} from "./api.js";
|
import { api } from "./api.js";
|
||||||
|
import { ComfyDialog as _ComfyDialog } from "./ui/dialog.js";
|
||||||
|
import { ComfySettingsDialog } from "./ui/settings.js";
|
||||||
|
|
||||||
|
export const ComfyDialog = _ComfyDialog;
|
||||||
|
|
||||||
export function $el(tag, propsOrChildren, children) {
|
export function $el(tag, propsOrChildren, children) {
|
||||||
const split = tag.split(".");
|
const split = tag.split(".");
|
||||||
|
@ -167,267 +171,6 @@ function dragElement(dragEl, settings) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ComfyDialog {
|
|
||||||
constructor() {
|
|
||||||
this.element = $el("div.comfy-modal", {parent: document.body}, [
|
|
||||||
$el("div.comfy-modal-content", [$el("p", {$: (p) => (this.textElement = p)}), ...this.createButtons()]),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
createButtons() {
|
|
||||||
return [
|
|
||||||
$el("button", {
|
|
||||||
type: "button",
|
|
||||||
textContent: "Close",
|
|
||||||
onclick: () => this.close(),
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.element.style.display = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
show(html) {
|
|
||||||
if (typeof html === "string") {
|
|
||||||
this.textElement.innerHTML = html;
|
|
||||||
} else {
|
|
||||||
this.textElement.replaceChildren(html);
|
|
||||||
}
|
|
||||||
this.element.style.display = "flex";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ComfySettingsDialog extends ComfyDialog {
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.element = $el("dialog", {
|
|
||||||
id: "comfy-settings-dialog",
|
|
||||||
parent: document.body,
|
|
||||||
}, [
|
|
||||||
$el("table.comfy-modal-content.comfy-table", [
|
|
||||||
$el("caption", {textContent: "Settings"}),
|
|
||||||
$el("tbody", {$: (tbody) => (this.textElement = tbody)}),
|
|
||||||
$el("button", {
|
|
||||||
type: "button",
|
|
||||||
textContent: "Close",
|
|
||||||
style: {
|
|
||||||
cursor: "pointer",
|
|
||||||
},
|
|
||||||
onclick: () => {
|
|
||||||
this.element.close();
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
this.settings = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
getSettingValue(id, defaultValue) {
|
|
||||||
const settingId = "Comfy.Settings." + id;
|
|
||||||
const v = localStorage[settingId];
|
|
||||||
return v == null ? defaultValue : JSON.parse(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
setSettingValue(id, value) {
|
|
||||||
const settingId = "Comfy.Settings." + id;
|
|
||||||
localStorage[settingId] = JSON.stringify(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
addSetting({id, name, type, defaultValue, onChange, attrs = {}, tooltip = "", options = undefined}) {
|
|
||||||
if (!id) {
|
|
||||||
throw new Error("Settings must have an ID");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.settings.find((s) => s.id === id)) {
|
|
||||||
throw new Error(`Setting ${id} of type ${type} must have a unique ID.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const settingId = `Comfy.Settings.${id}`;
|
|
||||||
const v = localStorage[settingId];
|
|
||||||
let value = v == null ? defaultValue : JSON.parse(v);
|
|
||||||
|
|
||||||
// Trigger initial setting of value
|
|
||||||
if (onChange) {
|
|
||||||
onChange(value, undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.settings.push({
|
|
||||||
render: () => {
|
|
||||||
const setter = (v) => {
|
|
||||||
if (onChange) {
|
|
||||||
onChange(v, value);
|
|
||||||
}
|
|
||||||
localStorage[settingId] = JSON.stringify(v);
|
|
||||||
value = v;
|
|
||||||
};
|
|
||||||
value = this.getSettingValue(id, defaultValue);
|
|
||||||
|
|
||||||
let element;
|
|
||||||
const htmlID = id.replaceAll(".", "-");
|
|
||||||
|
|
||||||
const labelCell = $el("td", [
|
|
||||||
$el("label", {
|
|
||||||
for: htmlID,
|
|
||||||
classList: [tooltip !== "" ? "comfy-tooltip-indicator" : ""],
|
|
||||||
textContent: name,
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (typeof type === "function") {
|
|
||||||
element = type(name, setter, value, attrs);
|
|
||||||
} else {
|
|
||||||
switch (type) {
|
|
||||||
case "boolean":
|
|
||||||
element = $el("tr", [
|
|
||||||
labelCell,
|
|
||||||
$el("td", [
|
|
||||||
$el("input", {
|
|
||||||
id: htmlID,
|
|
||||||
type: "checkbox",
|
|
||||||
checked: value,
|
|
||||||
onchange: (event) => {
|
|
||||||
const isChecked = event.target.checked;
|
|
||||||
if (onChange !== undefined) {
|
|
||||||
onChange(isChecked)
|
|
||||||
}
|
|
||||||
this.setSettingValue(id, isChecked);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
])
|
|
||||||
break;
|
|
||||||
case "number":
|
|
||||||
element = $el("tr", [
|
|
||||||
labelCell,
|
|
||||||
$el("td", [
|
|
||||||
$el("input", {
|
|
||||||
type,
|
|
||||||
value,
|
|
||||||
id: htmlID,
|
|
||||||
oninput: (e) => {
|
|
||||||
setter(e.target.value);
|
|
||||||
},
|
|
||||||
...attrs
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
break;
|
|
||||||
case "slider":
|
|
||||||
element = $el("tr", [
|
|
||||||
labelCell,
|
|
||||||
$el("td", [
|
|
||||||
$el("div", {
|
|
||||||
style: {
|
|
||||||
display: "grid",
|
|
||||||
gridAutoFlow: "column",
|
|
||||||
},
|
|
||||||
}, [
|
|
||||||
$el("input", {
|
|
||||||
...attrs,
|
|
||||||
value,
|
|
||||||
type: "range",
|
|
||||||
oninput: (e) => {
|
|
||||||
setter(e.target.value);
|
|
||||||
e.target.nextElementSibling.value = e.target.value;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
$el("input", {
|
|
||||||
...attrs,
|
|
||||||
value,
|
|
||||||
id: htmlID,
|
|
||||||
type: "number",
|
|
||||||
style: {maxWidth: "4rem"},
|
|
||||||
oninput: (e) => {
|
|
||||||
setter(e.target.value);
|
|
||||||
e.target.previousElementSibling.value = e.target.value;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
break;
|
|
||||||
case "combo":
|
|
||||||
element = $el("tr", [
|
|
||||||
labelCell,
|
|
||||||
$el("td", [
|
|
||||||
$el(
|
|
||||||
"select",
|
|
||||||
{
|
|
||||||
oninput: (e) => {
|
|
||||||
setter(e.target.value);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
(typeof options === "function" ? options(value) : options || []).map((opt) => {
|
|
||||||
if (typeof opt === "string") {
|
|
||||||
opt = { text: opt };
|
|
||||||
}
|
|
||||||
const v = opt.value ?? opt.text;
|
|
||||||
return $el("option", {
|
|
||||||
value: v,
|
|
||||||
textContent: opt.text,
|
|
||||||
selected: value + "" === v + "",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
break;
|
|
||||||
case "text":
|
|
||||||
default:
|
|
||||||
if (type !== "text") {
|
|
||||||
console.warn(`Unsupported setting type '${type}, defaulting to text`);
|
|
||||||
}
|
|
||||||
|
|
||||||
element = $el("tr", [
|
|
||||||
labelCell,
|
|
||||||
$el("td", [
|
|
||||||
$el("input", {
|
|
||||||
value,
|
|
||||||
id: htmlID,
|
|
||||||
oninput: (e) => {
|
|
||||||
setter(e.target.value);
|
|
||||||
},
|
|
||||||
...attrs,
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (tooltip) {
|
|
||||||
element.title = tooltip;
|
|
||||||
}
|
|
||||||
|
|
||||||
return element;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const self = this;
|
|
||||||
return {
|
|
||||||
get value() {
|
|
||||||
return self.getSettingValue(id, defaultValue);
|
|
||||||
},
|
|
||||||
set value(v) {
|
|
||||||
self.setSettingValue(id, v);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
show() {
|
|
||||||
this.textElement.replaceChildren(
|
|
||||||
$el("tr", {
|
|
||||||
style: {display: "none"},
|
|
||||||
}, [
|
|
||||||
$el("th"),
|
|
||||||
$el("th", {style: {width: "33%"}})
|
|
||||||
]),
|
|
||||||
...this.settings.map((s) => s.render()),
|
|
||||||
)
|
|
||||||
this.element.showModal();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ComfyList {
|
class ComfyList {
|
||||||
#type;
|
#type;
|
||||||
#text;
|
#text;
|
||||||
|
@ -526,7 +269,7 @@ export class ComfyUI {
|
||||||
constructor(app) {
|
constructor(app) {
|
||||||
this.app = app;
|
this.app = app;
|
||||||
this.dialog = new ComfyDialog();
|
this.dialog = new ComfyDialog();
|
||||||
this.settings = new ComfySettingsDialog();
|
this.settings = new ComfySettingsDialog(app);
|
||||||
|
|
||||||
this.batchCount = 1;
|
this.batchCount = 1;
|
||||||
this.lastQueueSize = 0;
|
this.lastQueueSize = 0;
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { $el } from "../ui.js";
|
||||||
|
|
||||||
|
export class ComfyDialog {
|
||||||
|
constructor() {
|
||||||
|
this.element = $el("div.comfy-modal", { parent: document.body }, [
|
||||||
|
$el("div.comfy-modal-content", [$el("p", { $: (p) => (this.textElement = p) }), ...this.createButtons()]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
createButtons() {
|
||||||
|
return [
|
||||||
|
$el("button", {
|
||||||
|
type: "button",
|
||||||
|
textContent: "Close",
|
||||||
|
onclick: () => this.close(),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.element.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
show(html) {
|
||||||
|
if (typeof html === "string") {
|
||||||
|
this.textElement.innerHTML = html;
|
||||||
|
} else {
|
||||||
|
this.textElement.replaceChildren(html);
|
||||||
|
}
|
||||||
|
this.element.style.display = "flex";
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,307 @@
|
||||||
|
import { $el } from "../ui.js";
|
||||||
|
import { api } from "../api.js";
|
||||||
|
import { ComfyDialog } from "./dialog.js";
|
||||||
|
|
||||||
|
export class ComfySettingsDialog extends ComfyDialog {
|
||||||
|
constructor(app) {
|
||||||
|
super();
|
||||||
|
this.app = app;
|
||||||
|
this.settingsValues = {};
|
||||||
|
this.settingsLookup = {};
|
||||||
|
this.element = $el(
|
||||||
|
"dialog",
|
||||||
|
{
|
||||||
|
id: "comfy-settings-dialog",
|
||||||
|
parent: document.body,
|
||||||
|
},
|
||||||
|
[
|
||||||
|
$el("table.comfy-modal-content.comfy-table", [
|
||||||
|
$el("caption", { textContent: "Settings" }),
|
||||||
|
$el("tbody", { $: (tbody) => (this.textElement = tbody) }),
|
||||||
|
$el("button", {
|
||||||
|
type: "button",
|
||||||
|
textContent: "Close",
|
||||||
|
style: {
|
||||||
|
cursor: "pointer",
|
||||||
|
},
|
||||||
|
onclick: () => {
|
||||||
|
this.element.close();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get settings() {
|
||||||
|
return Object.values(this.settingsLookup);
|
||||||
|
}
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
if (this.app.storageLocation === "browser") {
|
||||||
|
this.settingsValues = localStorage;
|
||||||
|
} else {
|
||||||
|
this.settingsValues = await api.getSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger onChange for any settings added before load
|
||||||
|
for (const id in this.settingsLookup) {
|
||||||
|
this.settingsLookup[id].onChange?.(this.settingsValues[this.getId(id)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getId(id) {
|
||||||
|
if (this.app.storageLocation === "browser") {
|
||||||
|
id = "Comfy.Settings." + id;
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSettingValue(id, defaultValue) {
|
||||||
|
let value = this.settingsValues[this.getId(id)];
|
||||||
|
if(value != null) {
|
||||||
|
if(this.app.storageLocation === "browser") {
|
||||||
|
try {
|
||||||
|
value = JSON.parse(value);
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value ?? defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSettingValueAsync(id, value) {
|
||||||
|
const json = JSON.stringify(value);
|
||||||
|
localStorage["Comfy.Settings." + id] = json; // backwards compatibility for extensions keep setting in storage
|
||||||
|
|
||||||
|
let oldValue = this.getSettingValue(id, undefined);
|
||||||
|
this.settingsValues[this.getId(id)] = value;
|
||||||
|
|
||||||
|
if (id in this.settingsLookup) {
|
||||||
|
this.settingsLookup[id].onChange?.(value, oldValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.storeSetting(id, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSettingValue(id, value) {
|
||||||
|
this.setSettingValueAsync(id, value).catch((err) => {
|
||||||
|
alert(`Error saving setting '${id}'`);
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addSetting({ id, name, type, defaultValue, onChange, attrs = {}, tooltip = "", options = undefined }) {
|
||||||
|
if (!id) {
|
||||||
|
throw new Error("Settings must have an ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id in this.settingsLookup) {
|
||||||
|
throw new Error(`Setting ${id} of type ${type} must have a unique ID.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let skipOnChange = false;
|
||||||
|
let value = this.getSettingValue(id);
|
||||||
|
if (value == null) {
|
||||||
|
if (this.app.isNewUserSession) {
|
||||||
|
// Check if we have a localStorage value but not a setting value and we are a new user
|
||||||
|
const localValue = localStorage["Comfy.Settings." + id];
|
||||||
|
if (localValue) {
|
||||||
|
value = JSON.parse(localValue);
|
||||||
|
this.setSettingValue(id, value); // Store on the server
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value == null) {
|
||||||
|
value = defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger initial setting of value
|
||||||
|
if (!skipOnChange) {
|
||||||
|
onChange?.(value, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.settingsLookup[id] = {
|
||||||
|
id,
|
||||||
|
onChange,
|
||||||
|
name,
|
||||||
|
render: () => {
|
||||||
|
const setter = (v) => {
|
||||||
|
if (onChange) {
|
||||||
|
onChange(v, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setSettingValue(id, v);
|
||||||
|
value = v;
|
||||||
|
};
|
||||||
|
value = this.getSettingValue(id, defaultValue);
|
||||||
|
|
||||||
|
let element;
|
||||||
|
const htmlID = id.replaceAll(".", "-");
|
||||||
|
|
||||||
|
const labelCell = $el("td", [
|
||||||
|
$el("label", {
|
||||||
|
for: htmlID,
|
||||||
|
classList: [tooltip !== "" ? "comfy-tooltip-indicator" : ""],
|
||||||
|
textContent: name,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (typeof type === "function") {
|
||||||
|
element = type(name, setter, value, attrs);
|
||||||
|
} else {
|
||||||
|
switch (type) {
|
||||||
|
case "boolean":
|
||||||
|
element = $el("tr", [
|
||||||
|
labelCell,
|
||||||
|
$el("td", [
|
||||||
|
$el("input", {
|
||||||
|
id: htmlID,
|
||||||
|
type: "checkbox",
|
||||||
|
checked: value,
|
||||||
|
onchange: (event) => {
|
||||||
|
const isChecked = event.target.checked;
|
||||||
|
if (onChange !== undefined) {
|
||||||
|
onChange(isChecked);
|
||||||
|
}
|
||||||
|
this.setSettingValue(id, isChecked);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
case "number":
|
||||||
|
element = $el("tr", [
|
||||||
|
labelCell,
|
||||||
|
$el("td", [
|
||||||
|
$el("input", {
|
||||||
|
type,
|
||||||
|
value,
|
||||||
|
id: htmlID,
|
||||||
|
oninput: (e) => {
|
||||||
|
setter(e.target.value);
|
||||||
|
},
|
||||||
|
...attrs,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
case "slider":
|
||||||
|
element = $el("tr", [
|
||||||
|
labelCell,
|
||||||
|
$el("td", [
|
||||||
|
$el(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
display: "grid",
|
||||||
|
gridAutoFlow: "column",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[
|
||||||
|
$el("input", {
|
||||||
|
...attrs,
|
||||||
|
value,
|
||||||
|
type: "range",
|
||||||
|
oninput: (e) => {
|
||||||
|
setter(e.target.value);
|
||||||
|
e.target.nextElementSibling.value = e.target.value;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
$el("input", {
|
||||||
|
...attrs,
|
||||||
|
value,
|
||||||
|
id: htmlID,
|
||||||
|
type: "number",
|
||||||
|
style: { maxWidth: "4rem" },
|
||||||
|
oninput: (e) => {
|
||||||
|
setter(e.target.value);
|
||||||
|
e.target.previousElementSibling.value = e.target.value;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
case "combo":
|
||||||
|
element = $el("tr", [
|
||||||
|
labelCell,
|
||||||
|
$el("td", [
|
||||||
|
$el(
|
||||||
|
"select",
|
||||||
|
{
|
||||||
|
oninput: (e) => {
|
||||||
|
setter(e.target.value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(typeof options === "function" ? options(value) : options || []).map((opt) => {
|
||||||
|
if (typeof opt === "string") {
|
||||||
|
opt = { text: opt };
|
||||||
|
}
|
||||||
|
const v = opt.value ?? opt.text;
|
||||||
|
return $el("option", {
|
||||||
|
value: v,
|
||||||
|
textContent: opt.text,
|
||||||
|
selected: value + "" === v + "",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
case "text":
|
||||||
|
default:
|
||||||
|
if (type !== "text") {
|
||||||
|
console.warn(`Unsupported setting type '${type}, defaulting to text`);
|
||||||
|
}
|
||||||
|
|
||||||
|
element = $el("tr", [
|
||||||
|
labelCell,
|
||||||
|
$el("td", [
|
||||||
|
$el("input", {
|
||||||
|
value,
|
||||||
|
id: htmlID,
|
||||||
|
oninput: (e) => {
|
||||||
|
setter(e.target.value);
|
||||||
|
},
|
||||||
|
...attrs,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tooltip) {
|
||||||
|
element.title = tooltip;
|
||||||
|
}
|
||||||
|
|
||||||
|
return element;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const self = this;
|
||||||
|
return {
|
||||||
|
get value() {
|
||||||
|
return self.getSettingValue(id, defaultValue);
|
||||||
|
},
|
||||||
|
set value(v) {
|
||||||
|
self.setSettingValue(id, v);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
show() {
|
||||||
|
this.textElement.replaceChildren(
|
||||||
|
$el(
|
||||||
|
"tr",
|
||||||
|
{
|
||||||
|
style: { display: "none" },
|
||||||
|
},
|
||||||
|
[$el("th"), $el("th", { style: { width: "33%" } })]
|
||||||
|
),
|
||||||
|
...this.settings.sort((a, b) => a.name.localeCompare(b.name)).map((s) => s.render())
|
||||||
|
);
|
||||||
|
this.element.showModal();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
.lds-ring {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
}
|
||||||
|
.lds-ring div {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0.15em solid #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
|
||||||
|
border-color: #fff transparent transparent transparent;
|
||||||
|
}
|
||||||
|
.lds-ring div:nth-child(1) {
|
||||||
|
animation-delay: -0.45s;
|
||||||
|
}
|
||||||
|
.lds-ring div:nth-child(2) {
|
||||||
|
animation-delay: -0.3s;
|
||||||
|
}
|
||||||
|
.lds-ring div:nth-child(3) {
|
||||||
|
animation-delay: -0.15s;
|
||||||
|
}
|
||||||
|
@keyframes lds-ring {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { addStylesheet } from "../utils.js";
|
||||||
|
|
||||||
|
addStylesheet(import.meta.url);
|
||||||
|
|
||||||
|
export function createSpinner() {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.innerHTML = `<div class="lds-ring"><div></div><div></div><div></div><div></div></div>`;
|
||||||
|
return div.firstElementChild;
|
||||||
|
}
|
|
@ -0,0 +1,135 @@
|
||||||
|
.comfy-user-selection {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: sans-serif;
|
||||||
|
background: linear-gradient(var(--tr-even-bg-color), var(--tr-odd-bg-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.comfy-user-selection-inner {
|
||||||
|
background: var(--comfy-menu-bg);
|
||||||
|
margin-top: -30vh;
|
||||||
|
padding: 20px 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
min-width: 365px;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comfy-user-selection-inner form {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comfy-user-selection-inner h1 {
|
||||||
|
margin: 10px 0 30px 0;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comfy-user-selection-inner label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comfy-user-selection input,
|
||||||
|
.comfy-user-selection select {
|
||||||
|
background-color: var(--comfy-input-bg);
|
||||||
|
color: var(--input-text);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 5px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comfy-user-selection input::placeholder {
|
||||||
|
color: var(--descrip-text);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comfy-user-existing {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-users .comfy-user-existing {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comfy-user-selection-inner .or-separator {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 10px;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
color: var(--descrip-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comfy-user-selection-inner .or-separator {
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: center;
|
||||||
|
margin-left: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comfy-user-selection-inner .or-separator::before,
|
||||||
|
.comfy-user-selection-inner .or-separator::after {
|
||||||
|
content: "";
|
||||||
|
background-color: var(--border-color);
|
||||||
|
position: relative;
|
||||||
|
height: 1px;
|
||||||
|
vertical-align: middle;
|
||||||
|
display: inline-block;
|
||||||
|
width: calc(50% - 20px);
|
||||||
|
top: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comfy-user-selection-inner .or-separator::before {
|
||||||
|
right: 10px;
|
||||||
|
margin-left: -50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comfy-user-selection-inner .or-separator::after {
|
||||||
|
left: 10px;
|
||||||
|
margin-right: -50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comfy-user-selection-inner section {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
margin: -10px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comfy-user-selection-inner section.selected {
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comfy-user-selection-inner footer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comfy-user-selection-inner .comfy-user-error {
|
||||||
|
color: var(--error-text);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comfy-user-button-next {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
width: 100px;
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { api } from "../api.js";
|
||||||
|
import { $el } from "../ui.js";
|
||||||
|
import { addStylesheet } from "../utils.js";
|
||||||
|
import { createSpinner } from "./spinner.js";
|
||||||
|
|
||||||
|
export class UserSelectionScreen {
|
||||||
|
async show(users, user) {
|
||||||
|
// This will rarely be hit so move the loading to on demand
|
||||||
|
await addStylesheet(import.meta.url);
|
||||||
|
const userSelection = document.getElementById("comfy-user-selection");
|
||||||
|
userSelection.style.display = "";
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const input = userSelection.getElementsByTagName("input")[0];
|
||||||
|
const select = userSelection.getElementsByTagName("select")[0];
|
||||||
|
const inputSection = input.closest("section");
|
||||||
|
const selectSection = select.closest("section");
|
||||||
|
const form = userSelection.getElementsByTagName("form")[0];
|
||||||
|
const error = userSelection.getElementsByClassName("comfy-user-error")[0];
|
||||||
|
const button = userSelection.getElementsByClassName("comfy-user-button-next")[0];
|
||||||
|
|
||||||
|
let inputActive = null;
|
||||||
|
input.addEventListener("focus", () => {
|
||||||
|
inputSection.classList.add("selected");
|
||||||
|
selectSection.classList.remove("selected");
|
||||||
|
inputActive = true;
|
||||||
|
});
|
||||||
|
select.addEventListener("focus", () => {
|
||||||
|
inputSection.classList.remove("selected");
|
||||||
|
selectSection.classList.add("selected");
|
||||||
|
inputActive = false;
|
||||||
|
select.style.color = "";
|
||||||
|
});
|
||||||
|
select.addEventListener("blur", () => {
|
||||||
|
if (!select.value) {
|
||||||
|
select.style.color = "var(--descrip-text)";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (inputActive == null) {
|
||||||
|
error.textContent = "Please enter a username or select an existing user.";
|
||||||
|
} else if (inputActive) {
|
||||||
|
const username = input.value.trim();
|
||||||
|
if (!username) {
|
||||||
|
error.textContent = "Please enter a username.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new user
|
||||||
|
input.disabled = select.disabled = input.readonly = select.readonly = true;
|
||||||
|
const spinner = createSpinner();
|
||||||
|
button.prepend(spinner);
|
||||||
|
try {
|
||||||
|
const resp = await api.createUser(username);
|
||||||
|
if (resp.status >= 300) {
|
||||||
|
let message = "Error creating user: " + resp.status + " " + resp.statusText;
|
||||||
|
try {
|
||||||
|
const res = await resp.json();
|
||||||
|
if(res.error) {
|
||||||
|
message = res.error;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({ username, userId: await resp.json(), created: true });
|
||||||
|
} catch (err) {
|
||||||
|
spinner.remove();
|
||||||
|
error.textContent = err.message ?? err.statusText ?? err ?? "An unknown error occurred.";
|
||||||
|
input.disabled = select.disabled = input.readonly = select.readonly = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (!select.value) {
|
||||||
|
error.textContent = "Please select an existing user.";
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
resolve({ username: users[select.value], userId: select.value, created: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
const name = localStorage["Comfy.userName"];
|
||||||
|
if (name) {
|
||||||
|
input.value = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (input.value) {
|
||||||
|
// Focus the input, do this separately as sometimes browsers like to fill in the value
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
const userIds = Object.keys(users ?? {});
|
||||||
|
if (userIds.length) {
|
||||||
|
for (const u of userIds) {
|
||||||
|
$el("option", { textContent: users[u], value: u, parent: select });
|
||||||
|
}
|
||||||
|
select.style.color = "var(--descrip-text)";
|
||||||
|
|
||||||
|
if (select.value) {
|
||||||
|
// Focus the select, do this separately as sometimes browsers like to fill in the value
|
||||||
|
select.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
userSelection.classList.add("no-users");
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
}).then((r) => {
|
||||||
|
userSelection.remove();
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { $el } from "./ui.js";
|
||||||
|
|
||||||
// Simple date formatter
|
// Simple date formatter
|
||||||
const parts = {
|
const parts = {
|
||||||
d: (d) => d.getDate(),
|
d: (d) => d.getDate(),
|
||||||
|
@ -65,3 +67,22 @@ export function applyTextReplacements(app, value) {
|
||||||
return ((widget.value ?? "") + "").replaceAll(/\/|\\/g, "_");
|
return ((widget.value ?? "") + "").replaceAll(/\/|\\/g, "_");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function addStylesheet(urlOrFile, relativeTo) {
|
||||||
|
return new Promise((res, rej) => {
|
||||||
|
let url;
|
||||||
|
if (urlOrFile.endsWith(".js")) {
|
||||||
|
url = urlOrFile.substr(0, urlOrFile.length - 2) + "css";
|
||||||
|
} else {
|
||||||
|
url = new URL(urlOrFile, relativeTo ?? `${window.location.protocol}//${window.location.host}`).toString();
|
||||||
|
}
|
||||||
|
$el("link", {
|
||||||
|
parent: document.head,
|
||||||
|
rel: "stylesheet",
|
||||||
|
type: "text/css",
|
||||||
|
href: url,
|
||||||
|
onload: res,
|
||||||
|
onerror: rej,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -121,6 +121,7 @@ body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comfy-btn,
|
||||||
.comfy-menu > button,
|
.comfy-menu > button,
|
||||||
.comfy-menu-btns button,
|
.comfy-menu-btns button,
|
||||||
.comfy-menu .comfy-list button,
|
.comfy-menu .comfy-list button,
|
||||||
|
@ -133,6 +134,7 @@ body {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comfy-btn:hover:not(:disabled),
|
||||||
.comfy-menu > button:hover,
|
.comfy-menu > button:hover,
|
||||||
.comfy-menu-btns button:hover,
|
.comfy-menu-btns button:hover,
|
||||||
.comfy-menu .comfy-list button:hover,
|
.comfy-menu .comfy-list button:hover,
|
||||||
|
|
Loading…
Reference in New Issue