import { __ID__, filePath } from "../consts.mjs"; import { getFile, lastModifiedAt } from "../utils/fs.mjs"; import { imagePath } from "../utils/imagePath.mjs"; import { paginate } from "../utils/pagination.mjs"; import { ImageApp } from "./ImageApp.mjs"; const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api; const { FormDataExtended } = foundry.applications.ux; const { deepClone } = foundry.utils; const PAGE_SIZE = 8; export class ArtBrowser extends HandlebarsApplicationMixin(ApplicationV2) { // #region Options static DEFAULT_OPTIONS = { classes: [ __ID__, `ArtBrowser`, ], position: {}, window: {}, actions: { nextPage: this.#nextPage, prevPage: this.#prevPage, uploadImage: this.#uploadImage, select: this.#selectImage, }, }; static PARTS = { sidebar: { template: filePath(`templates/ArtBrowser/sidebar.hbs`), }, images: { template: filePath(`templates/ArtBrowser/images.hbs`), templates: [ filePath(`templates/ArtBrowser/image/grid.hbs`), filePath(`templates/ArtBrowser/image/list.hbs`), ], }, }; // #endregion Options // #region Instance Data #page = 1; #selectCount = 0; #onSubmit = null; #layout = `grid`; filters = { name: ``, tags: [], artists: [], }; constructor({ selectCount = 0, onSubmit = null, layout = `grid`, ...opts } = {}) { super(opts); this.#selectCount = selectCount; this.#onSubmit = onSubmit; this.#layout = layout; }; async setPage(page) { if (this.#page == page) { return }; this.#page = page; if (this.rendered) { return this.render({ parts: [`images`] }); }; return; }; async changeLayout(layout) { console.log(`changing layout to:`, layout) if (![`grid`, `list`].includes(layout)) { throw `Cannot set layout to: ${layout}`; }; this.#layout = layout; await this.render({ parts: [`images`] }); }; get selectMode() { if (this.#selectCount === 0) { return `view`; } else if (this.#selectCount === 1) { return `single`; }; return `multi`; }; // #endregion Instance Data // #region Lifecycle #imagesDB = { lastModified: null, data: undefined, }; async #getImages() { const newLastModified = await lastModifiedAt(`storage/db/images.json`); if (this.#imagesDB.lastModified && newLastModified === this.#imagesDB.lastModified) { return; }; this.#imagesDB.lastModified = newLastModified; this.#imagesDB.data = await getFile(`storage/db/images.json`); }; #artistDB = { lastModified: null, data: undefined, }; async #getArtists() { const newLastModified = await lastModifiedAt(`storage/db/artists.json`); if (this.#artistDB.lastModified && newLastModified === this.#artistDB.lastModified) { return; }; this.#artistDB.lastModified = newLastModified; this.#artistDB.data = await getFile(`storage/db/artists.json`); }; async render(...args) { await this.#getArtists(); await this.#getImages(); return super.render(...args); }; #imageUploadHook; #imageUpdateHook; async _onFirstRender(...args) { this.#imageUploadHook = Hooks.on( `${__ID__}.imageUploaded`, () => this.render({ parts: [`images`] }), ); this.#imageUpdateHook = Hooks.on( `${__ID__}.imageUpdated`, () => this.render({ parts: [`images`] }), ); return super._onFirstRender(...args); }; async _onRender(ctx, options) { if (options.parts?.includes(`sidebar`)) { this.element.querySelector(`form.filters`)?.addEventListener( `change`, this.#onFilterSubmit.bind(this), ); }; if (options.parts?.includes(`images`)) { this._updateSelectedCount(); const layoutTypeInputs = this.element.querySelectorAll(`input[name="layoutType"]`); for (const element of layoutTypeInputs) { element.addEventListener(`change`, this.#onLayoutChange.bind(this)); }; }; }; _updateSelectedCount() { if (!this.rendered || this.selectMode === `single`) return; const element = this.element.querySelector(`.selected-count`); if (element) { element.innerText = `${this.#selected.size} Selected`; element.innerText = _loc( `TB.apps.ArtBrowser.selected`, { current: this.#selected.size, required: this.#selectCount, }, ); }; }; async close(options) { this.#submit(null); return super.close(options); }; #submitted = false; async #submit(value) { if (this.#submitted) return; this.#onSubmit?.(value); this.#submitted = true; this.close(); }; _tearDown(options) { super._tearDown(options); Hooks.off(`${__ID__}.imageUploaded`, this.#imageUploadHook); Hooks.off(`${__ID__}.imageUpdated`, this.#imageUpdateHook); }; // #endregion Lifecycle // #region Data Prep _prepareContext() { return { meta: { idp: this.id, }, is: { multi: this.#selectCount > 1, single: this.#selectCount === 1, }, layout: this.#layout, can: { upload: true, }, }; }; _preparePartContext(partID, ctx) { switch (partID) { case `sidebar`: { this._prepareSidebarContext(ctx); break; }; case `images`: { this._prepareImagesContext(ctx); break; }; }; return ctx; }; _prepareSidebarContext(ctx) { // TODO: grab all existing tags for dataset ctx.name = this.filters.name; ctx.tags = this.filters.tags; ctx.artists = this.filters.artists; ctx.artistList = []; for (const [id, artist] of Object.entries(this.#artistDB.data)) { ctx.artistList.push({ value: id, label: artist.name }); }; }; _prepareImagesContext(ctx) { ctx.images = []; const allImages = Object.entries(deepClone(this.#imagesDB.data)); const images = []; for (const [id, image] of allImages) { // Check if it matches the required filters if (this.filters.name && !image.name.includes(this.filters.name)) { continue; }; const hasArtistFilter = this.filters.artists.length > 0; const hasAnArtist = this.filters.artists.some(artist => image.artists.includes(artist)); const hasAllTags = this.filters.tags.every(tag => image.tags.includes(tag)); if ((!hasAnArtist && hasArtistFilter) || !hasAllTags) { continue }; // Populate ephemeral data for rendering image.id = id; image.selected = this.#selected.has(imagePath(image)); // Convert all of the artist IDs into the actual data image.artists = image.artists .map(artistID => { if (artistID in this.#artistDB.data) { const artist = this.#artistDB.data[artistID]; artist.id = artistID; return artist; }; return { name: artistID, id: artistID }; }); images.push(image); }; // Paginate after filtering and sorting to give page continuity images.sort((a, b) => a.name.localeCompare(b.name)); const paginated = paginate(images, this.#page, PAGE_SIZE); ctx.images = paginated.page; ctx.page = this.#page; ctx.pages = paginated.total; ctx.has = { prev: paginated.prev, next: paginated.next }; }; // #endregion Data Prep // #region Actions async #onFilterSubmit(event) { event.preventDefault(); event.stopPropagation(); const data = (new FormDataExtended(event.currentTarget)).object; this.filters = data; this.render({ parts: [`images`] }); }; async #onLayoutChange(event) { event.preventDefault(); event.stopPropagation(); const newLayout = event.target.value; await this.changeLayout(newLayout); }; /** @this {ArtBrowser} */ static async #nextPage() { this.#page += 1; this.render({ parts: [`images`] }); }; /** @this {ArtBrowser} */ static async #prevPage() { this.#page -= 1; this.render({ parts: [`images`] }); }; /** @this {ArtBrowser} */ static async #uploadImage() { const app = new ImageApp(); app.render({ force: true }); }; #selected = new Set(); /** @this {ArtBrowser} */ static async #selectImage(event, target) { if (this.#selectCount === 0) { return }; const imageID = target.closest(`[data-image-id]`)?.dataset.imageId; const image = this.#imagesDB.data[imageID]; if (!imageID || !image) { return }; const path = imagePath(image); if (this.#selectCount > 1) { if (!target.checked) { this.#selected.delete(path); } else { this.#selected.add(path); }; if (this.#selected.size >= this.#selectCount) { await this.#submit(Array.from(this.#selected)); } else { this._updateSelectedCount(); }; } else { await this.#submit(path); }; }; // #endregion Actions // #region Factories /** * Opens an ArtBrowser intended to get the user to select some number * of tokens. * * @param {number} count The amount of tokens that the user needs to * select * @returns A promise that resolves to a string, string[], or null */ static async select(count = 1) { if (count < 1) { throw "Unable to select 0 or fewer tokens"; }; return new Promise((resolve) => { const app = new this({ selectCount: count, onSubmit: resolve, }); app.render({ force: true, }); }); }; // #endregion Factories };