diff --git a/module/api.mjs b/module/api.mjs index cfb1faf..a58da17 100644 --- a/module/api.mjs +++ b/module/api.mjs @@ -1,4 +1,5 @@ // Applications +import { ArtBrowser } from "./apps/ArtBrowser.mjs"; import { ArtistApp } from "./apps/Artist.mjs"; import { ImageApp } from "./apps/Image.mjs"; @@ -9,6 +10,7 @@ const { deepFreeze } = foundry.utils; export const api = deepFreeze({ Apps: { + ArtBrowser, ArtistApp, ImageApp, }, diff --git a/module/apps/ArtBrowser.mjs b/module/apps/ArtBrowser.mjs new file mode 100644 index 0000000..c8a40ec --- /dev/null +++ b/module/apps/ArtBrowser.mjs @@ -0,0 +1,231 @@ +import { __ID__, filePath } from "../consts.mjs"; +import { getFile, lastModifiedAt } from "../utils/fs.mjs"; +import { paginate } from "../utils/pagination.mjs"; +import { ImageApp } from "./Image.mjs"; + +const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api; +const { FormDataExtended } = foundry.applications.ux; +const { deepClone } = foundry.utils; + +const PAGE_SIZE = 52; + +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, + }, + }; + + static PARTS = { + sidebar: { + template: filePath(`templates/ArtBrowser/sidebar.hbs`), + }, + images: { + template: filePath(`templates/ArtBrowser/images.hbs`), + templates: [ + filePath(`templates/ArtBrowser/image/grid.hbs`), + ], + }, + }; + // #endregion Options + + // #region Instance Data + #page = 1; + filters = { + name: ``, + tags: [], + artists: [], + }; + + async setPage(page) { + if (this.#page == page) { return }; + this.#page = page; + if (this.rendered) { + return this.render({ parts: [`images`] }); + }; + return; + }; + // #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), + ); + }; + }; + + _tearDown(...args) { + super._tearDown(...args); + Hooks.off(`${__ID__}.imageUploaded`, this.#imageUploadHook); + Hooks.off(`${__ID__}.imageUpdated`, this.#imageUpdateHook); + }; + // #endregion Lifecycle + + // #region Data Prep + _prepareContext() { + return { + meta: { + idp: this.id, + }, + selectMode: `view`, + }; + }; + + _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) { + image.id = id; + + // 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 }; + + // 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`] }); + }; + + /** @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 }); + }; + // #endregion Actions +}; diff --git a/styles/apps/ArtBrowser.css b/styles/apps/ArtBrowser.css new file mode 100644 index 0000000..22a1324 --- /dev/null +++ b/styles/apps/ArtBrowser.css @@ -0,0 +1,64 @@ +.token-browser.ArtBrowser { + > .window-content { + display: grid; + grid-template-columns: 175px auto; + gap: 0; + } + + form { + --spacing: 12px; + border-right: var(--sidebar-separator); + margin-right: var(--spacing); + padding-right: var(--spacing); + } + + .image-list { + --size: 100px; + list-style-type: none; + display: grid; + grid-template-columns: repeat(4, var(--size)); + gap: 8px; + padding: 0; + margin: 0; + max-height: 450px; + overflow-y: auto; + } + + .image { + display: flex; + flex-direction: column; + gap: 8px; + border-radius: 4px; + overflow: hidden; + transition: background 100ms ease-in-out; + + &.grid { + &:hover { + background: var(--color-cool-4); + } + } + + img { + width: 100%; + aspect-ratio: 1; + object-fit: contain; + } + + .details { + display: flex; + flex-direction: column; + gap: 4px; + padding: 0px 4px 4px; + } + } + + .paginated { + display: flex; + flex-direction: column; + gap: 6px; /* Due to the grow element, this actually means 12px */ + } + + .page-nav { + justify-content: center; + } +} diff --git a/styles/main.css b/styles/main.css index ed501a8..d82318f 100644 --- a/styles/main.css +++ b/styles/main.css @@ -2,6 +2,7 @@ /* Resets */ @import url("./resets/lists.css") layer(resets); +@import url("./resets/hr.css") layer(resets); /* Elements */ @import url("./elements/utils.css") layer(elements); @@ -9,5 +10,6 @@ /* Apps */ @import url("./apps/common.css") layer(apps); +@import url("./apps/ArtBrowser.css") layer(apps); @import url("./apps/ArtistApp.css") layer(apps); @import url("./apps/ImageApp.css") layer(apps); diff --git a/styles/resets/hr.css b/styles/resets/hr.css new file mode 100644 index 0000000..8c95ca6 --- /dev/null +++ b/styles/resets/hr.css @@ -0,0 +1,7 @@ +.token-browser > .window-content hr { + all: initial; + display: block; + width: 100%; + margin: 12px 0; + border-top: var(--sidebar-separator); +} diff --git a/templates/ArtBrowser/image/grid.hbs b/templates/ArtBrowser/image/grid.hbs new file mode 100644 index 0000000..27c9a47 --- /dev/null +++ b/templates/ArtBrowser/image/grid.hbs @@ -0,0 +1,11 @@ +