diff --git a/langs/en-ca.json b/langs/en-ca.json index 619bffb..d32b3ba 100644 --- a/langs/en-ca.json +++ b/langs/en-ca.json @@ -10,6 +10,13 @@ "edit": "Edit Artist: {name}" }, "add-link": "Add New Link" + }, + "ImageApp": { + "title": { + "upload": "Upload New Image", + "edit-named": "Editing Image: {name}", + "edit-generic": "Editing Image" + } } }, "dialogs": { diff --git a/module/api.mjs b/module/api.mjs index e3d4be1..cfb1faf 100644 --- a/module/api.mjs +++ b/module/api.mjs @@ -1,17 +1,20 @@ // Applications import { ArtistApp } from "./apps/Artist.mjs"; +import { ImageApp } from "./apps/Image.mjs"; // Utils -import { getFile, lastModifiedAt, uploadJson } from "./utils/fs.mjs"; +import { getFile, hashFile, lastModifiedAt, uploadJson } from "./utils/fs.mjs"; const { deepFreeze } = foundry.utils; export const api = deepFreeze({ Apps: { ArtistApp, + ImageApp, }, utils: { fs: { + hashFile, lastModifiedAt, getFile, uploadJson, diff --git a/module/apps/Image.mjs b/module/apps/Image.mjs new file mode 100644 index 0000000..36a9eee --- /dev/null +++ b/module/apps/Image.mjs @@ -0,0 +1,197 @@ +import { __ID__, filePath } from "../consts.mjs"; +import { determineFileExtension, getFile, hashFile, lastModifiedAt, uploadFile, uploadJson } from "../utils/fs.mjs"; +import { DBConnectorMixin } from "./mixins/DBConnector.mjs"; + +const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api; + +export class ImageApp extends + DBConnectorMixin( + HandlebarsApplicationMixin( + ApplicationV2 +)) { + + // #region Options + static dbType = `Image`; + static dbPath = `storage/db/images.json`; + + static DEFAULT_OPTIONS = { + tag: `form`, + classes: [ + __ID__, + `ImageApp`, + ], + position: { + width: 600, + }, + window: { + contentClasses: [ + `standard-form`, + ], + }, + form: { + handler: this.#onSubmit, + submitOnChange: false, + closeOnSubmit: true, + }, + actions: {}, + }; + + static PARTS = { + header: { template: filePath(`templates/ImageApp/header.hbs`) }, + preview: { template: filePath(`templates/ImageApp/preview.hbs`) }, + form: { template: filePath(`templates/ImageApp/form.hbs`) }, + footer: { template: filePath(`templates/partials/footer.hbs`) }, + }; + // #endregion Options + + // #region Instance Data + /** The artist that is being edited, or the default artist values */ + _doc = { path: ``, tags: [], artists: [] }; + + get title() { + if (this._docID) { + if (this._doc.name) { + return _loc( + `TB.apps.ImageApp.title.edit-named`, + { name: this._doc.name }, + ); + } else { + return _loc(`TB.apps.ImageApp.title.edit-generic`); + } + }; + return _loc(`TB.apps.ImageApp.title.upload`); + }; + // #endregion Instance Data + + // #region Lifecycle + _onFirstRender() { + if (this._doc?.name) { + this._updateFrame({ window: { title: this.title } }); + }; + + this.element.querySelector(`input[type="file"]`)?.addEventListener( + `change`, + this.#changeImage.bind(this), + ); + }; + + /** @this {ImageApp} */ + static async #onSubmit(event, element, formData) { + const data = formData.object; + + // Validate the DB hasn't been updated since opening + if (!(await this.isAbleToSave())) { return }; + + + // Upload new image to server + if (!this._docID) { + const hash = this._doc.hash; + const extension = determineFileExtension(this.#file); + const path = `storage/tokens/${hash}.${extension}`; + + if (!this.#file) { + // TODO: ERROR + return; + } + const file = new File( + [this.#file], + `${hash}.${extension}`, + { type: this.#file.type }, + ); + await uploadFile(`tokens`, file); + + const images = await getFile(this.constructor.dbPath); + images[hash] = { + name: data.name, + tags: data.tags, + artists: data.artists, + path: this.#file ? path : ``, + }; + await uploadJson(`db`, `images.json`, images); + } + else { + const images = await getFile(this.constructor.dbPath); + Object.assign(images[this._docID], { + name: data.name, + tage: data.tags, + artists: data.artists, + }); + await uploadJson(`db`, `images.json`, images); + }; + }; + // #endregion Lifecycle + + // #region Data Prep + #lastArtistEdit = null; + #artistCache = []; + async _prepareContext() { + + // Get all of the available artists for the dropdown + const lastModified = await lastModifiedAt(`storage/db/artists.json`); + if ( + this.#lastArtistEdit == null + || lastModified !== this.#lastArtistEdit + ) { + this.#artistCache = []; + const artistDB = await getFile(`storage/db/artists.json`); + for (const [id, artist] of Object.entries(artistDB)) { + this.#artistCache.push({ value: id, label: artist.name }); + }; + }; + + const ctx = { + meta: { + idp: this.id, + }, + imageTypes: Object.values(CONST.IMAGE_FILE_EXTENSIONS).join(`,`), + imageURL: this._doc.path.startsWith(`blob`) + ? this._doc.path + : filePath(this._doc.path), + image: this._doc, + artists: this.#artistCache, + }; + + return ctx; + }; + // #endregion Data Prep + + // #region Actions + /** @type {File | null} */ + #file = null; + async #changeImage(event) { + /** @type {File} */ + const file = this.#file = event.target.files[0]; + + // Prevent memory leaks + URL.revokeObjectURL(this._doc.path); + + if (!file) { + this._docID = null; + this._doc = { path: ``, artists: [], tags: [] }; + this._updateFrame({ window: { title: this.title } }); + await this.render({ parts: [`preview`, `form`] }); + return; + }; + + // Ensure we don't already have the file uploaded + const extension = determineFileExtension(file); + const hash = await hashFile(file); + const path = `storage/tokens/${hash}.${extension}`; + const lastModified = await lastModifiedAt(path); + if (lastModified) { + this._docID = hash; + this._doc.hash = hash; + await this._fetchDocument(); + this._updateFrame({ window: { title: this.title } }); + await this.render({ parts: [`preview`, `form`] }); + return; + }; + + // Temporarily blob the image so it can be displayed in-app + const url = URL.createObjectURL(file); + this._doc.path = url; + this._doc.hash = hash; + await this.render({ parts: [`preview`] }); + }; + // #endregion Actions +}; diff --git a/module/consts.mjs b/module/consts.mjs index 270e3ee..8275b7a 100644 --- a/module/consts.mjs +++ b/module/consts.mjs @@ -4,8 +4,18 @@ export const __ID__ = `token-browser`; * @param {string} path */ export function filePath(path) { + if (path.startsWith(`modules/${__ID__}/`)) { + return path; + }; if (path.startsWith(`/`)) { path = path.slice(1); }; return `modules/${__ID__}/${path}`; }; + +let _devMode; +export function devMode() { + if (_devMode != null) { return _devMode }; + _devMode = game.modules.get(__ID__).flags.inDev ?? false; + return _devMode; +}; diff --git a/module/handlebarsHelpers/_index.mjs b/module/handlebarsHelpers/_index.mjs new file mode 100644 index 0000000..167bf65 --- /dev/null +++ b/module/handlebarsHelpers/_index.mjs @@ -0,0 +1,6 @@ +import { options } from "./options.mjs"; + +export default { + // MARK: Complex + "tb-options": options, +}; diff --git a/module/handlebarsHelpers/options.mjs b/module/handlebarsHelpers/options.mjs new file mode 100644 index 0000000..c46b52f --- /dev/null +++ b/module/handlebarsHelpers/options.mjs @@ -0,0 +1,43 @@ +/** + * @typedef {object} Option + * @property {string} [label] + * @property {string|number} value + * @property {boolean} [disabled] + */ + +/** + * @param {string | number | Array} selected + * @param {Array`, + ); + }; + return new Handlebars.SafeString(htmlOptions.join(`\n`)); +}; diff --git a/module/hooks/init.mjs b/module/hooks/init.mjs index f97a1eb..c6511c8 100644 --- a/module/hooks/init.mjs +++ b/module/hooks/init.mjs @@ -1,5 +1,8 @@ import { api } from "../api.mjs"; +import helpers from "../handlebarsHelpers/_index.mjs"; Hooks.on(`init`, () => { globalThis.tb = api; + + Handlebars.registerHelper(helpers); }); diff --git a/module/utils/fs.mjs b/module/utils/fs.mjs index 2dc76e1..860da75 100644 --- a/module/utils/fs.mjs +++ b/module/utils/fs.mjs @@ -1,7 +1,33 @@ -import { __ID__, filePath } from "../consts.mjs"; +import { __ID__, devMode, filePath } from "../consts.mjs"; const { fetchJsonWithTimeout } = foundry.utils; +/** + * Returns a pseudo-hash for a file. If the browser is in a secure + * context this will return a true SHA-1 hash of the file, otherwise + * a random ID is generated using the character set A-Za-z0-9. + * + * @param {File} file The file to hash + */ +export async function hashFile(file) { + // Handle fallback when hashing isn't possible + if (!window.isSecureContext || !crypto.subtle.digest) { + return foundry.utils.randomID(40); + }; + + // Actually hash the file since we can + const bytes = await file.arrayBuffer(); + const buffer = await crypto.subtle.digest(`SHA-1`, bytes); + + const intArray = new Uint8Array(buffer); + if (Uint8Array.prototype.toHex) { + return intArray.toHex(); + }; + return Array.from(intArray) + .map(b => b.toString(16).padStart(2, `0`)) + .join(``); +}; + export async function lastModifiedAt(path) { try { const response = await fetch(filePath(path), { method: `HEAD` }); @@ -24,6 +50,22 @@ export async function getFile(path) { * @param {any} data */ export async function uploadJson(path, filename, data) { + const content = JSON.stringify( + data, + undefined, + devMode() ? `\t` : undefined, + ); + try { + const file = new File( + [content], + filename, + { type: `text/plain` }, + ); + await uploadFile(path, file); + } catch {}; +}; + +export async function uploadFile(path, file) { // uploadPersistent adds "storage" into the path automatically if (path.startsWith(`storage/`)) { @@ -35,11 +77,6 @@ export async function uploadJson(path, filename, data) { const picker = foundry.applications.apps.FilePicker.implementation; try { - const file = new File( - [JSON.stringify(data)], - filename, - { type: `text/plain` }, - ); await picker.uploadPersistent( __ID__, path, @@ -47,3 +84,12 @@ export async function uploadJson(path, filename, data) { ); } catch {}; }; + +export function determineFileExtension(file) { + for (const [short, long] of Object.entries(CONST.IMAGE_FILE_EXTENSIONS)) { + if (long === file.type) { + return short; + }; + }; + return null; +}; diff --git a/styles/apps/ImageApp.css b/styles/apps/ImageApp.css new file mode 100644 index 0000000..209f8c0 --- /dev/null +++ b/styles/apps/ImageApp.css @@ -0,0 +1,46 @@ +.token-browser.ImageApp { + > .window-content { + display: grid; + grid-template-columns: 200px auto; + grid-template-rows: auto auto; + } + + header, footer { + grid-column: 1 / -1; + } + + .inputs { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); + grid-template-rows: repeat(3, min-content); + align-self: start; + gap: 16px 8px; + + label { + align-self: end; + height: var(--input-height); + display: flex; + align-items: center; + } + } + + .preview { + width: 200px; + height: 200px; + display: flex; + align-items: center; + justify-content: center; + align-self: center; + + img { + width: 100%; + height: 100%; + object-fit: contain; + } + + .placeholder { + font-style: italic; + text-align: center; + } + } +} diff --git a/styles/main.css b/styles/main.css index 26bca91..ed501a8 100644 --- a/styles/main.css +++ b/styles/main.css @@ -10,3 +10,4 @@ /* Apps */ @import url("./apps/common.css") layer(apps); @import url("./apps/ArtistApp.css") layer(apps); +@import url("./apps/ImageApp.css") layer(apps); diff --git a/templates/ImageApp/form.hbs b/templates/ImageApp/form.hbs new file mode 100644 index 0000000..f84fd39 --- /dev/null +++ b/templates/ImageApp/form.hbs @@ -0,0 +1,30 @@ +
+ + + + + + + + + {{ tb-options image.artists artists }} + +
diff --git a/templates/ImageApp/header.hbs b/templates/ImageApp/header.hbs new file mode 100644 index 0000000..f323ca1 --- /dev/null +++ b/templates/ImageApp/header.hbs @@ -0,0 +1,14 @@ +
+
+ + {{!-- TODO: when adding support for editing, make this readonly --}} + +
+
diff --git a/templates/ImageApp/preview.hbs b/templates/ImageApp/preview.hbs new file mode 100644 index 0000000..df9eb9a --- /dev/null +++ b/templates/ImageApp/preview.hbs @@ -0,0 +1,12 @@ +
+ {{#if image.path}} + + {{else}} + + Select an image to see the preview + + {{/if}} +