image-tagger/module/apps/ArtBrowser.mjs

340 lines
8 KiB
JavaScript

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`),
],
},
};
// #endregion Options
// #region Instance Data
#page = 1;
#selectCount = 0;
#onSubmit = null;
filters = {
name: ``,
tags: [],
artists: [],
};
constructor({ selectCount = 0, onSubmit = null, ...opts } = {}) {
super(opts);
this.#selectCount = selectCount;
this.#onSubmit = onSubmit;
};
async setPage(page) {
if (this.#page == page) { return };
this.#page = page;
if (this.rendered) {
return this.render({ parts: [`images`] });
};
return;
};
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();
};
};
_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(
`IT.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.selectMode === `multi`,
single: this.selectMode === `single`,
},
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`] });
};
/** @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
};