diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..690dd8d --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,93 @@ +import globals from "globals"; +import pluginJs from "@eslint/js"; +import stylistic from "@stylistic/eslint-plugin"; + +export default [ + // Tell eslint to ignore files that I don't mind being formatted slightly differently + { ignores: [ `scripts/`, `foundry/` ] }, + { + languageOptions: { + globals: globals.browser, + }, + }, + pluginJs.configs.recommended, + // MARK: Foundry Globals + { + languageOptions: { + globals: { + _loc: `readonly`, + CONFIG: `writable`, + CONST: `readonly`, + game: `readonly`, + Handlebars: `readonly`, + Hooks: `readonly`, + ui: `readonly`, + Actor: `readonly`, + Item: `readonly`, + foundry: `readonly`, + ChatMessage: `readonly`, + ActiveEffect: `readonly`, + Dialog: `readonly`, + renderTemplate: `readonly`, + TextEditor: `readonly`, + fromUuid: `readonly`, + Combat: `readonly`, + Combatant: `readonly`, + canvas: `readonly`, + Token: `readonly`, + Tour: `readonly`, + }, + }, + }, + // MARK: Project Specific + { + plugins: { + "@stylistic": stylistic, + }, + languageOptions: { + globals: {}, + }, + rules: { + "curly": `error`, + "func-names": [`warn`, `as-needed`], + "grouped-accessor-pairs": `error`, + "no-alert": `error`, + "no-empty": [`error`, { allowEmptyCatch: true }], + "no-implied-eval": `error`, + "no-invalid-this": `error`, + "no-lonely-if": `error`, + "no-unneeded-ternary": `error`, + "no-nested-ternary": `error`, + "no-var": `error`, + "no-unused-vars": [ + `error`, + { + "vars": `local`, + "args": `after-used`, + "varsIgnorePattern": `^_`, + "argsIgnorePattern": `^_`, + }, + ], + "sort-imports": [`warn`, { "ignoreCase": true, "allowSeparatedGroups": true }], + "@stylistic/semi": [`warn`, `always`, { "omitLastInOneLineBlock": true }], + "@stylistic/no-trailing-spaces": `warn`, + "@stylistic/space-before-blocks": [`warn`, `always`], + "@stylistic/space-infix-ops": `warn`, + "@stylistic/eol-last": `warn`, + "@stylistic/operator-linebreak": [`warn`, `before`], + "@stylistic/indent": [`warn`, `tab`], + "@stylistic/brace-style": [`off`], + "@stylistic/quotes": [`warn`, `backtick`, { "avoidEscape": true }], + "@stylistic/comma-dangle": [`warn`, { arrays: `always-multiline`, objects: `always-multiline`, imports: `always-multiline`, exports: `always-multiline`, functions: `always-multiline` }], + "@stylistic/comma-style": [`warn`, `last`], + "@stylistic/dot-location": [`error`, `property`], + "@stylistic/no-confusing-arrow": `error`, + "@stylistic/no-whitespace-before-property": `error`, + "@stylistic/nonblock-statement-body-position": [ + `error`, + `beside`, + { "overrides": { "while": `below` } }, + ], + }, + }, +]; diff --git a/langs/en-ca.json b/langs/en-ca.json new file mode 100644 index 0000000..06cfedd --- /dev/null +++ b/langs/en-ca.json @@ -0,0 +1,29 @@ +{ + "TB": { + "common": { + "unsaved": "This change hasn't been saved, if you close without saving it will be undone." + }, + "apps": { + "ArtistApp": { + "title": { + "create": "Create New Artist", + "edit": "Edit Artist: {name}" + }, + "add-link": "Add New Link" + } + }, + "dialogs": { + "Link": { + "title": "Create/Edit Link", + "name": "Name", + "url": "URL" + } + }, + "notifs": { + "error": { + "db-out-of-date": "Database out of date, please try again.", + "artist-ID-404": "An artist cannot be found with ID: {id}" + } + } + } +} diff --git a/module.json b/module.json index 163ec85..a96cdd8 100644 --- a/module.json +++ b/module.json @@ -19,6 +19,13 @@ "layer": "modules.token-browser" } ], + "languages": [ + { + "lang": "en", + "name": "English (Canadian)", + "path": "langs/en-ca.json" + } + ], "flags": { "inDev": true }, diff --git a/module/api.mjs b/module/api.mjs new file mode 100644 index 0000000..e3d4be1 --- /dev/null +++ b/module/api.mjs @@ -0,0 +1,20 @@ +// Applications +import { ArtistApp } from "./apps/Artist.mjs"; + +// Utils +import { getFile, lastModifiedAt, uploadJson } from "./utils/fs.mjs"; + +const { deepFreeze } = foundry.utils; + +export const api = deepFreeze({ + Apps: { + ArtistApp, + }, + utils: { + fs: { + lastModifiedAt, + getFile, + uploadJson, + }, + }, +}); diff --git a/module/apps/Artist.mjs b/module/apps/Artist.mjs new file mode 100644 index 0000000..72bcb4d --- /dev/null +++ b/module/apps/Artist.mjs @@ -0,0 +1,193 @@ +import { __ID__, filePath } from "../consts.mjs"; +import { getFile, lastModifiedAt, uploadJson } from "../utils/fs.mjs"; +import { promptViaTemplate } from "../utils/dialogs.mjs"; + +const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api; +const { randomID } = foundry.utils; + +export class ArtistApp extends HandlebarsApplicationMixin(ApplicationV2) { + + // #region Options + static DEFAULT_OPTIONS = { + tag: `form`, + classes: [ + __ID__, + `ArtistApp`, + ], + position: { + width: 350, + }, + window: { + contentClasses: [ + `standard-form`, + ], + }, + form: { + handler: this.#onSubmit, + submitOnChange: false, + closeOnSubmit: true, + }, + actions: { + addNewLink: this.#addNewLink, + deleteLink: this.#deleteLink, + }, + }; + + static PARTS = { + form: { + template: filePath(`templates/ArtistApp/form.hbs`), + }, + linkList: { + template: filePath(`templates/ArtistApp/linkList.hbs`), + }, + footer: { + template: filePath(`templates/ArtistApp/footer.hbs`), + }, + }; + // #endregion Options + + // #region Instance Data + /** @type { null | string } */ + #artistID = null; + + /** The artist that is being edited, or the default artist values */ + #artist = { name: ``, links: [] }; + + /** @type { null | string } */ + #lastModified = null; + + constructor({ artistID = null, ...opts } = {}) { + super(opts); + this.#artistID = artistID; + }; + + get title() { + if (this.#artistID && this.#artist.name) { + return game.i18n.format( + `TB.apps.ArtistApp.title.edit`, + { name: this.#artist.name }, + ); + }; + return game.i18n.localize(`TB.apps.ArtistApp.title.create`); + }; + // #endregion Instance Data + + // #region Lifecycle + /** Whether or not the database values have been initialized */ + #connected = false; + + /** + * This fetches the DB data that we care about for the purposes of being able + * to create/edit entries in the Artists DB + */ + async #connectToDB() { + if (this.#connected) { return true }; + this.#lastModified ??= await lastModifiedAt(`storage/db/artists.json`); + + if (this.#artistID) { + const artists = await getFile(`storage/db/artists.json`); + + if (artists[this.#artistID] == null) { + ui.notifications.error(_loc( + `TB.notifs.error.artist-ID-404`, + { id: this.#artistID }, + )); + return false; + }; + + Object.assign(this.#artist, artists[this.#artistID]); + }; + this.#connected = true; + return true; + }; + + /** + * Ensures that the app is in a state where it is allowed to render + * before actually letting it render at all. + */ + async render(options, ...args) { + const allowed = await this.#connectToDB(); + if (!allowed) { return this }; + return super.render(options, ...args); + }; + + /** + * Makes it so that we update the frame's title to include the Artist name if + * we're editing an existing artist instead of creating a new one. + */ + _onFirstRender() { + if (this.#artist.name) { + this._updateFrame({ window: { title: this.title } }); + }; + }; + + /** @this {ArtistApp} */ + static async #onSubmit(event, element, formData) { + const artist = formData.object; + + if (artist.name.length === 0) { return }; + artist.links = this.#artist.links; + artist.links.forEach(link => { + delete link.isNew; + }); + + // Validate the DB hasn't been updated since + if (this.#artistID) { + const newLastModified = await lastModifiedAt(`storage/db/artists.json`); + if (newLastModified !== this.#lastModified) { + ui.notifications.error( + `TB.notifs.error.db-out-of-date`, + { localize: true }, + ); + return; + }; + }; + + const artists = getFile(`storage/db/artists.json`); + + let id = this.#artistID; + if (!this.#artistID) { + do { + id = randomID(); + } while (artists[id] != null); + }; + artists[id] = artist; + + await uploadJson(`db`, `artists.json`, artists); + }; + // #endregion Lifecycle + + // #region Data Prep + async _prepareContext() { + const ctx = { + meta: { + idp: this.id, + }, + artist: this.#artist, + }; + + return ctx; + }; + // #endregion Data Prep + + // #region Actions + /** @this {ArtistApp} */ + static async #addNewLink() { + const link = await promptViaTemplate( + `TB.dialogs.Link.title`, + `templates/Dialogs/Link.hbs`, + ); + link.isNew = true; + this.#artist.links.push(link); + this.render({ parts: [ `linkList` ] }); + }; + + /** @this {ArtistApp} */ + static async #deleteLink(event, element) { + const index = element.closest(`[data-index]`)?.dataset.index; + if (index == null) { return }; + this.#artist.links.splice(index, 1); + this.render({ parts: [ `linkList` ] }); + }; + // #endregion Actions +}; diff --git a/module/consts.mjs b/module/consts.mjs new file mode 100644 index 0000000..270e3ee --- /dev/null +++ b/module/consts.mjs @@ -0,0 +1,11 @@ +export const __ID__ = `token-browser`; + +/** + * @param {string} path + */ +export function filePath(path) { + if (path.startsWith(`/`)) { + path = path.slice(1); + }; + return `modules/${__ID__}/${path}`; +}; diff --git a/module/hooks/init.mjs b/module/hooks/init.mjs new file mode 100644 index 0000000..f97a1eb --- /dev/null +++ b/module/hooks/init.mjs @@ -0,0 +1,5 @@ +import { api } from "../api.mjs"; + +Hooks.on(`init`, () => { + globalThis.tb = api; +}); diff --git a/module/hooks/ready.mjs b/module/hooks/ready.mjs new file mode 100644 index 0000000..3ad54f4 --- /dev/null +++ b/module/hooks/ready.mjs @@ -0,0 +1,3 @@ +Hooks.on(`ready`, () => { + globalThis._loc = game.i18n.format.bind(game.i18n); +}); diff --git a/module/token-browser.mjs b/module/token-browser.mjs new file mode 100644 index 0000000..cb60dce --- /dev/null +++ b/module/token-browser.mjs @@ -0,0 +1,2 @@ +import "./hooks/init.mjs"; +import "./hooks/ready.mjs"; diff --git a/module/utils/dialogs.mjs b/module/utils/dialogs.mjs new file mode 100644 index 0000000..9346088 --- /dev/null +++ b/module/utils/dialogs.mjs @@ -0,0 +1,14 @@ +import { filePath } from "../consts.mjs"; + +const { DialogV2 } = foundry.applications.api; +const { renderTemplate } = foundry.applications.handlebars; + +export async function promptViaTemplate(title, template, context = {}) { + const content = await renderTemplate(filePath(template), context); + return DialogV2.input({ + window: { + title, + }, + content, + }); +}; diff --git a/module/utils/fs.mjs b/module/utils/fs.mjs new file mode 100644 index 0000000..2dc76e1 --- /dev/null +++ b/module/utils/fs.mjs @@ -0,0 +1,49 @@ +import { __ID__, filePath } from "../consts.mjs"; + +const { fetchJsonWithTimeout } = foundry.utils; + +export async function lastModifiedAt(path) { + try { + const response = await fetch(filePath(path), { method: `HEAD` }); + return response.headers.get(`Last-Modified`); + } catch { + return null; + }; +}; + +export async function getFile(path) { + try { + return fetchJsonWithTimeout(filePath(path)); + } catch { + return undefined; + }; +}; + +/** + * @param {string} path + * @param {any} data + */ +export async function uploadJson(path, filename, data) { + + // uploadPersistent adds "storage" into the path automatically + if (path.startsWith(`storage/`)) { + path = path.slice(8); + }; + if (path.endsWith(`/`)) { + path = path.slice(0, -1); + }; + + const picker = foundry.applications.apps.FilePicker.implementation; + try { + const file = new File( + [JSON.stringify(data)], + filename, + { type: `text/plain` }, + ); + await picker.uploadPersistent( + __ID__, + path, + file, + ); + } catch {}; +}; diff --git a/styles/apps/ArtistApp.css b/styles/apps/ArtistApp.css new file mode 100644 index 0000000..03a2372 --- /dev/null +++ b/styles/apps/ArtistApp.css @@ -0,0 +1,48 @@ +.token-browser.ArtistApp { + > .window-content { + display: flex; + flex-direction: column; + gap: 0.5rem; + color: var(--color-form-label); + } + + label, .label { + font-weight: bold; + } + + footer { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.5rem; + } + + .large { + font-size: 1rem; + } + + ul { + display: flex; + flex-flow: row wrap; + gap: 8px; + + li { + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + padding: 2px 4px; + background: var(--content-link-background); + color: var(--content-link-text-color); + border: 1px solid var(--content-link-border-color); + border-radius: 4px; + + button { + padding: 0; + border: none; + min-height: 0; + height: 1rem; + width: 1rem; + } + } + } +} diff --git a/styles/elements/lists.css b/styles/elements/lists.css new file mode 100644 index 0000000..a957d0a --- /dev/null +++ b/styles/elements/lists.css @@ -0,0 +1,7 @@ +.token-browser > .window-content { + ul { + margin: 0; + padding: 0; + list-style: none; + } +} diff --git a/styles/elements/utils.css b/styles/elements/utils.css new file mode 100644 index 0000000..2521cc1 --- /dev/null +++ b/styles/elements/utils.css @@ -0,0 +1,9 @@ +.token-browser > .window-content { + .row { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.5rem; + } + .grow { flex-grow: 1 } +} diff --git a/styles/main.css b/styles/main.css new file mode 100644 index 0000000..ae11b3f --- /dev/null +++ b/styles/main.css @@ -0,0 +1,11 @@ +@layer resets, elements, apps; + +/* Resets */ +@import url("./resets/lists.css") layer(resets); + +/* Elements */ +@import url("./elements/utils.css") layer(elements); +@import url("./elements/lists.css") layer(elements); + +/* Apps */ +@import url("./apps/ArtistApp.css") layer(apps); diff --git a/styles/resets/lists.css b/styles/resets/lists.css new file mode 100644 index 0000000..897ae3f --- /dev/null +++ b/styles/resets/lists.css @@ -0,0 +1,5 @@ +.token-browser > .window-content { + li { + margin: 0; + } +} diff --git a/templates/ArtistApp/footer.hbs b/templates/ArtistApp/footer.hbs new file mode 100644 index 0000000..8107a19 --- /dev/null +++ b/templates/ArtistApp/footer.hbs @@ -0,0 +1,4 @@ + diff --git a/templates/ArtistApp/form.hbs b/templates/ArtistApp/form.hbs new file mode 100644 index 0000000..27d0399 --- /dev/null +++ b/templates/ArtistApp/form.hbs @@ -0,0 +1,15 @@ +