From 6b81d8d83b070b5cce605eca741236f9049b1d9b Mon Sep 17 00:00:00 2001 From: Oliver-Akins Date: Sun, 29 Jun 2025 13:35:11 -0600 Subject: [PATCH] Add a helper application to manage attributes on characters --- module/apps/AttributeManager.mjs | 171 ++++++++++++++++++ module/apps/PlayerSheet.mjs | 24 ++- module/utils/toID.mjs | 13 ++ styles/Apps/AttributeManager.css | 33 ++++ styles/Apps/PlayerSheet.css | 9 +- styles/Apps/common.css | 8 + styles/main.css | 2 + templates/AttributeManager/attribute-list.hbs | 35 ++++ templates/AttributeManager/controls.hbs | 13 ++ templates/PlayerSheet/attributes.hbs | 2 +- 10 files changed, 298 insertions(+), 12 deletions(-) create mode 100644 module/apps/AttributeManager.mjs create mode 100644 module/utils/toID.mjs create mode 100644 styles/Apps/AttributeManager.css create mode 100644 styles/Apps/common.css create mode 100644 templates/AttributeManager/attribute-list.hbs create mode 100644 templates/AttributeManager/controls.hbs diff --git a/module/apps/AttributeManager.mjs b/module/apps/AttributeManager.mjs new file mode 100644 index 0000000..105b5bf --- /dev/null +++ b/module/apps/AttributeManager.mjs @@ -0,0 +1,171 @@ +import { __ID__, filePath } from "../consts.mjs"; +import { Logger } from "../utils/Logger.mjs"; +import { toID } from "../utils/toID.mjs"; + +const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api; +const { deepClone, diffObject, randomID, setProperty } = foundry.utils; + +export class AttributeManager extends HandlebarsApplicationMixin(ApplicationV2) { + + // #region Options + static DEFAULT_OPTIONS = { + tag: `form`, + classes: [ + __ID__, + `AttributeManager`, + ], + position: { + width: 400, + height: 350, + }, + window: { + resizable: true, + }, + form: { + submitOnChange: false, + closeOnSubmit: true, + handler: this.#onSubmit, + }, + actions: { + addNew: this.#addNew, + removeAttribute: this.#remove, + }, + }; + + static PARTS = { + attributes: { + template: filePath(`templates/AttributeManager/attribute-list.hbs`), + }, + controls: { + template: filePath(`templates/AttributeManager/controls.hbs`), + }, + }; + // #endregion Options + + // #region Instance Data + /** @type {string | null} */ + #doc = null; + + #attributes; + + constructor({ document , ...options } = {}) { + super(options); + this.#doc = document; + this.#attributes = deepClone(document.system.attr); + }; + + get title() { + return `Attributes: ${this.#doc.name}`; + }; + // #endregion Instance Data + + // #region Lifecycle + async _onRender(context, options) { + await super._onRender(context, options); + + const elements = this.element + .querySelectorAll(`[data-bind]`); + for (const input of elements) { + input.addEventListener(`change`, this.#bindListener.bind(this)); + }; + }; + // #endregion Lifecycle + + // #region Data Prep + async _preparePartContext(partId) { + const ctx = {}; + + ctx.actor = this.#doc; + + switch (partId) { + case `attributes`: { + await this._prepareAttributeContext(ctx); + }; + }; + + return ctx; + }; + + async _prepareAttributeContext(ctx) { + const attrs = []; + for (const [id, data] of Object.entries(this.#attributes)) { + if (data == null) { continue }; + attrs.push({ + id, + name: data.name, + isRange: data.isRange, + isNew: data.isNew ?? false, + }); + }; + ctx.attrs = attrs; + }; + // #endregion Data Prep + + // #region Actions + /** + * @param {Event} event + */ + async #bindListener(event) { + const target = event.target; + const data = target.dataset; + const binding = data.bind; + + let value = target.value; + switch (target.type) { + case `checkbox`: { + value = target.checked; + }; + }; + + Logger.debug(`Updating ${binding} value to ${value}`); + setProperty(this.#attributes, binding, value); + await this.render(); + }; + + /** @this {AttributeManager} */ + static async #addNew() { + const id = randomID(); + this.#attributes[id] = { + name: ``, + isRange: false, + isNew: true, + }; + await this.render({ parts: [ `attributes` ]}); + }; + + /** @this {AttributeManager} */ + static async #remove($e, element) { + const attribute = element.closest(`[data-attribute]`)?.dataset.attribute; + if (!attribute) { return }; + delete this.#attributes[attribute]; + this.#attributes[`-=${attribute}`] = null; + await this.render({ parts: [ `attributes` ] }); + }; + + /** @this {AttributeManager} */ + static async #onSubmit() { + const entries = Object.entries(this.#attributes) + .map(([id, attr]) => { + if (attr == null) { + return [ id, attr ]; + }; + + if (attr.isNew) { + delete attr.isNew; + return [ toID(attr.name), attr ]; + }; + + return [ id, attr ]; + }); + const data = Object.fromEntries(entries); + + const diff = diffObject( + this.#doc.system.attr, + data, + { inner: false, deletionKeys: true }, + ); + + await this.#doc.update({ "system.attr": diff }); + }; + // #endregion Actions +}; diff --git a/module/apps/PlayerSheet.mjs b/module/apps/PlayerSheet.mjs index 53d14a9..697d95a 100644 --- a/module/apps/PlayerSheet.mjs +++ b/module/apps/PlayerSheet.mjs @@ -1,4 +1,5 @@ import { __ID__, filePath } from "../consts.mjs"; +import { AttributeManager } from "./AttributeManager.mjs"; const { HandlebarsApplicationMixin } = foundry.applications.api; const { ActorSheetV2 } = foundry.applications.sheets; @@ -12,17 +13,29 @@ export class PlayerSheet extends HandlebarsApplicationMixin(ActorSheetV2) { `PlayerSheet`, ], position: { - width: 400, - height: 500, + width: 575, + height: 740, }, window: { resizable: true, + controls: [ + { + icon: `fa-solid fa-at`, + label: `Manage Attributes`, + action: `manageAttributes`, + visible: () => { + return game.user.isGM; + }, + }, + ], }, form: { submitOnChange: true, closeOnSubmit: false, }, - actions: {}, + actions: { + manageAttributes: this.#manageAttributes, + }, }; static PARTS = { @@ -82,5 +95,10 @@ export class PlayerSheet extends HandlebarsApplicationMixin(ActorSheetV2) { // #endregion Data Prep // #region Actions + /** @this {PlayerSheet} */ + static async #manageAttributes() { + const app = new AttributeManager({ document: this.actor }); + await app.render({ force: true }); + }; // #endregion Actions }; diff --git a/module/utils/toID.mjs b/module/utils/toID.mjs new file mode 100644 index 0000000..384ed22 --- /dev/null +++ b/module/utils/toID.mjs @@ -0,0 +1,13 @@ +/** + * A helper method that converts an arbitrary string into a format that can be + * used as an object key easily. + * + * @param {string} text The text to convert + * @returns The converted ID + */ +export function toID(text) { + return text + .toLowerCase() + .replace(/\s/g, `_`) + .replace(/\W/g, ``); +}; diff --git a/styles/Apps/AttributeManager.css b/styles/Apps/AttributeManager.css new file mode 100644 index 0000000..a36d35c --- /dev/null +++ b/styles/Apps/AttributeManager.css @@ -0,0 +1,33 @@ +.taf.AttributeManager { + .attributes { + display: flex; + flex-direction: column; + gap: 8px; + } + + .attribute { + display: grid; + grid-template-columns: 1fr auto auto; + align-items: center; + gap: 8px; + padding: 8px; + border: 1px solid rebeccapurple; + border-radius: 4px; + + label { + display: flex; + flex-direction: row; + align-items: center; + } + } + + .controls { + display: flex; + flex-direction: row; + gap: 8px; + + button { + flex-grow: 1; + } + } +} diff --git a/styles/Apps/PlayerSheet.css b/styles/Apps/PlayerSheet.css index fe843ef..9351278 100644 --- a/styles/Apps/PlayerSheet.css +++ b/styles/Apps/PlayerSheet.css @@ -1,17 +1,10 @@ .taf.PlayerSheet { - > .window-content { - padding: 0.5rem; - display: flex; - flex-direction: column; - gap: 0.5rem; - } - .sheet-header, fieldset, .content { border-radius: 8px; border: 1px solid rebeccapurple; } - .window-content > header { + .sheet-header { display: flex; flex-direction: row; align-items: center; diff --git a/styles/Apps/common.css b/styles/Apps/common.css new file mode 100644 index 0000000..ce56fdd --- /dev/null +++ b/styles/Apps/common.css @@ -0,0 +1,8 @@ +.taf { + > .window-content { + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + } +} diff --git a/styles/main.css b/styles/main.css index e195149..b231f6a 100644 --- a/styles/main.css +++ b/styles/main.css @@ -9,4 +9,6 @@ @import url("./elements/prose-mirror.css") layer(elements); /* Apps */ +@import url("./Apps/common.css") layer(apps); @import url("./Apps/PlayerSheet.css") layer(apps); +@import url("./Apps/AttributeManager.css") layer(apps); diff --git a/templates/AttributeManager/attribute-list.hbs b/templates/AttributeManager/attribute-list.hbs new file mode 100644 index 0000000..f15d9a9 --- /dev/null +++ b/templates/AttributeManager/attribute-list.hbs @@ -0,0 +1,35 @@ +
+ {{#each attrs as |attr|}} +
+ {{#if attr.isNew}} + + {{else}} + {{ attr.name }} + {{/if}} + + +
+ {{else}} +

No attributes yet

+ {{/each}} +
diff --git a/templates/AttributeManager/controls.hbs b/templates/AttributeManager/controls.hbs new file mode 100644 index 0000000..59dbb9b --- /dev/null +++ b/templates/AttributeManager/controls.hbs @@ -0,0 +1,13 @@ +
+ + +
diff --git a/templates/PlayerSheet/attributes.hbs b/templates/PlayerSheet/attributes.hbs index 86c033b..80d6085 100644 --- a/templates/PlayerSheet/attributes.hbs +++ b/templates/PlayerSheet/attributes.hbs @@ -1,7 +1,7 @@ {{#if hasAttributes}}
{{#each attrs as | attr |}} -
+
{{ attr.name }}