Browse Source
* 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 errorspull/2501/head
pythongosssss
10 months ago
committed by
GitHub
25 changed files with 1496 additions and 282 deletions
@ -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) |
@ -1,3 +1,4 @@ |
|||||||
{ |
{ |
||||||
"presets": ["@babel/preset-env"] |
"presets": ["@babel/preset-env"], |
||||||
|
"plugins": ["babel-plugin-transform-import-meta"] |
||||||
} |
} |
||||||
|
@ -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(); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
@ -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; |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
Loading…
Reference in new issue