mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2025-09-13 13:05:07 +00:00
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:
@@ -1,4 +1,5 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { ComfyDialog, $el } from "../../scripts/ui.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
|
||||
|
||||
const id = "Comfy.NodeTemplates";
|
||||
const file = "comfy.templates.json";
|
||||
|
||||
class ManageTemplates extends ComfyDialog {
|
||||
constructor() {
|
||||
super();
|
||||
this.load().then((v) => {
|
||||
this.templates = v;
|
||||
});
|
||||
|
||||
this.element.classList.add("comfy-manage-templates");
|
||||
this.templates = this.load();
|
||||
this.draggedEl = null;
|
||||
this.saveVisualCue = null;
|
||||
this.emptyImg = new Image();
|
||||
this.emptyImg.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=';
|
||||
this.emptyImg.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
|
||||
|
||||
this.importInput = $el("input", {
|
||||
type: "file",
|
||||
@@ -67,17 +72,50 @@ class ManageTemplates extends ComfyDialog {
|
||||
return btns;
|
||||
}
|
||||
|
||||
load() {
|
||||
const templates = localStorage.getItem(id);
|
||||
if (templates) {
|
||||
return JSON.parse(templates);
|
||||
async load() {
|
||||
let templates = [];
|
||||
if (app.storageLocation === "server") {
|
||||
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 {
|
||||
return [];
|
||||
const json = localStorage.getItem(id);
|
||||
if (json) {
|
||||
templates = JSON.parse(json);
|
||||
}
|
||||
}
|
||||
|
||||
return templates ?? [];
|
||||
}
|
||||
|
||||
store() {
|
||||
localStorage.setItem(id, JSON.stringify(this.templates));
|
||||
async store() {
|
||||
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() {
|
||||
@@ -85,14 +123,14 @@ class ManageTemplates extends ComfyDialog {
|
||||
if (file.type === "application/json" || file.name.endsWith(".json")) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
var importFile = JSON.parse(reader.result);
|
||||
if (importFile && importFile?.templates) {
|
||||
const importFile = JSON.parse(reader.result);
|
||||
if (importFile?.templates) {
|
||||
for (const template of importFile.templates) {
|
||||
if (template?.name && template?.data) {
|
||||
this.templates.push(template);
|
||||
}
|
||||
}
|
||||
this.store();
|
||||
await this.store();
|
||||
}
|
||||
};
|
||||
await reader.readAsText(file);
|
||||
@@ -159,7 +197,7 @@ class ManageTemplates extends ComfyDialog {
|
||||
e.currentTarget.style.border = "1px dashed transparent";
|
||||
e.currentTarget.removeAttribute("draggable");
|
||||
|
||||
// rearrange the elements in the localStorage
|
||||
// rearrange the elements
|
||||
this.element.querySelectorAll('.tempateManagerRow').forEach((el,i) => {
|
||||
var prev_i = el.dataset.id;
|
||||
|
||||
|
@@ -16,5 +16,33 @@
|
||||
window.graph = app.graph;
|
||||
</script>
|
||||
</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>
|
||||
|
@@ -12,6 +12,13 @@ class ComfyApi extends EventTarget {
|
||||
}
|
||||
|
||||
fetchApi(route, options) {
|
||||
if (!options) {
|
||||
options = {};
|
||||
}
|
||||
if (!options.headers) {
|
||||
options.headers = {};
|
||||
}
|
||||
options.headers["Comfy-User"] = this.user;
|
||||
return fetch(this.apiURL(route), options);
|
||||
}
|
||||
|
||||
@@ -315,6 +322,99 @@ class ComfyApi extends EventTarget {
|
||||
async interrupt() {
|
||||
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();
|
||||
|
@@ -1291,10 +1291,92 @@ export class ComfyApp {
|
||||
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
|
||||
*/
|
||||
async setup() {
|
||||
await this.#setUser();
|
||||
await this.ui.settings.load();
|
||||
await this.#loadExtensions();
|
||||
|
||||
// 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) {
|
||||
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 {
|
||||
#type;
|
||||
#text;
|
||||
@@ -526,7 +269,7 @@ export class ComfyUI {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.dialog = new ComfyDialog();
|
||||
this.settings = new ComfySettingsDialog();
|
||||
this.settings = new ComfySettingsDialog(app);
|
||||
|
||||
this.batchCount = 1;
|
||||
this.lastQueueSize = 0;
|
||||
|
32
web/scripts/ui/dialog.js
Normal file
32
web/scripts/ui/dialog.js
Normal file
@@ -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";
|
||||
}
|
||||
}
|
307
web/scripts/ui/settings.js
Normal file
307
web/scripts/ui/settings.js
Normal file
@@ -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();
|
||||
}
|
||||
}
|
34
web/scripts/ui/spinner.css
Normal file
34
web/scripts/ui/spinner.css
Normal file
@@ -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);
|
||||
}
|
||||
}
|
9
web/scripts/ui/spinner.js
Normal file
9
web/scripts/ui/spinner.js
Normal file
@@ -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;
|
||||
}
|
135
web/scripts/ui/userSelection.css
Normal file
135
web/scripts/ui/userSelection.css
Normal file
@@ -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;
|
||||
}
|
114
web/scripts/ui/userSelection.js
Normal file
114
web/scripts/ui/userSelection.js
Normal file
@@ -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
|
||||
const parts = {
|
||||
d: (d) => d.getDate(),
|
||||
@@ -65,3 +67,22 @@ export function applyTextReplacements(app, value) {
|
||||
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%;
|
||||
}
|
||||
|
||||
.comfy-btn,
|
||||
.comfy-menu > button,
|
||||
.comfy-menu-btns button,
|
||||
.comfy-menu .comfy-list button,
|
||||
@@ -133,6 +134,7 @@ body {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.comfy-btn:hover:not(:disabled),
|
||||
.comfy-menu > button:hover,
|
||||
.comfy-menu-btns button:hover,
|
||||
.comfy-menu .comfy-list button:hover,
|
||||
|
Reference in New Issue
Block a user