diff --git a/langs/en-ca.json b/langs/en-ca.json index 478ee82..aa6239f 100644 --- a/langs/en-ca.json +++ b/langs/en-ca.json @@ -7,6 +7,14 @@ "ArtBrowser": { "selected": "{current}/{required} Selected" }, + "ArtistBrowser": { + "sort-options": { + "name-asc": "Name (A -> Z)", + "name-desc": "Name (Z -> A)", + "image-count-asc": "Image Count (0 -> 9)", + "image-count-desc": "Image Count (9 -> 0)" + } + }, "ArtistApp": { "title": { "create": "Create New Artist", diff --git a/module/api.mjs b/module/api.mjs index 828313a..569529e 100644 --- a/module/api.mjs +++ b/module/api.mjs @@ -1,6 +1,7 @@ // Applications import { ArtBrowser } from "./apps/ArtBrowser.mjs"; import { ArtistApp } from "./apps/ArtistApp.mjs"; +import { ArtistBrowser } from "./apps/ArtistBrowser.mjs"; import { ImageApp } from "./apps/ImageApp.mjs"; // Utils @@ -11,6 +12,7 @@ const { deepFreeze } = foundry.utils; export const api = deepFreeze({ Apps: { ArtBrowser, + ArtistBrowser, ArtistApp, ImageApp, }, diff --git a/module/apps/ArtistBrowser.mjs b/module/apps/ArtistBrowser.mjs new file mode 100644 index 0000000..4b20de3 --- /dev/null +++ b/module/apps/ArtistBrowser.mjs @@ -0,0 +1,235 @@ +import { __ID__, filePath } from "../consts.mjs"; +import { getFile, lastModifiedAt } from "../utils/fs.mjs"; +import { paginate } from "../utils/pagination.mjs"; + +const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api; +const { FormDataExtended } = foundry.applications.ux; +const { deepClone } = foundry.utils; + +const PAGE_SIZE = 8; + +export class ArtistBrowser extends HandlebarsApplicationMixin(ApplicationV2) { + // #region Options + static DEFAULT_OPTIONS = { + classes: [ + __ID__, + `ArtistBrowser`, + `data-browser`, + ], + position: {}, + window: {}, + actions: { + nextPage: this.#nextPage, + prevPage: this.#prevPage, + }, + }; + + static PARTS = { + sidebar: { + template: filePath(`templates/ArtistBrowser/sidebar.hbs`), + }, + list: { + template: filePath(`templates/ArtistBrowser/list.hbs`), + templates: [ + filePath(`templates/ArtistBrowser/artist.hbs`), + ], + }, + }; + // #endregion Options + + // #region Instance Data + #page = 1; + filters = { + name: ``, + sortBy: `name-asc`, + }; + + constructor({ selectCount = 0, onSubmit = null, ...opts } = {}) { + super(opts); + }; + + get page() { + return this.#page; + }; + + async setPage(page) { + if (this.#page == page) { return }; + this.#page = page; + if (this.rendered) { + await this.render({ parts: [`images`] }); + return; + }; + 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); + }; + + 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(); + }; + }; + // #endregion Lifecycle + + // #region Data Prep + _prepareContext() { + return { + meta: { + idp: this.id, + }, + can: { + upload: true, + }, + }; + }; + + _preparePartContext(partID, ctx) { + switch (partID) { + case `sidebar`: { + this._prepareSidebarContext(ctx); + break; + }; + case `list`: { + this._prepareListContext(ctx); + break; + }; + }; + + return ctx; + }; + + _prepareSidebarContext(ctx) { + ctx.name = this.filters.name; + ctx.sortBy = this.filters.sortBy; + ctx.sortOptions = [ + { value: `name-asc`, label: `IT.apps.ArtistBrowser.sort-options.name-asc` }, + { value: `name-desc`, label: `IT.apps.ArtistBrowser.sort-options.name-desc` }, + { value: `image-count-asc`, label: `IT.apps.ArtistBrowser.sort-options.image-count-asc` }, + { value: `image-count-desc`, label: `IT.apps.ArtistBrowser.sort-options.image-count-desc` }, + ]; + }; + + _prepareListContext(ctx) { + const allArtists = Object.entries(deepClone(this.#artistDB.data)); + const allImages = Object.values(this.#imagesDB.data); + + const artists = []; + for (const [id, artist] of allArtists) { + // Check if it matches the required filters + if (this.filters.name && !artist.name.includes(this.filters.name)) { + continue; + }; + + // Populate ephemeral data for rendering + artist.id = id; + + let imageCount = 0; + let tags = {}; + + for (const image of allImages) { + if (!image.artists.includes(id)) continue; + imageCount++; + for (const tag of image.tags) { + tags[tag] ??= { name: tag, count: 0 }; + tags[tag].count++; + }; + }; + + artist.imageCount = imageCount; + artist.commonTags = Object.values(tags) + .sort((a, b) => b.count - a.count) + .slice(0, 5); + + artists.push(artist); + }; + + // Paginate after filtering and sorting to give page continuity + artists.sort(compareArtists.bind(undefined, this.filters.sortBy)); + const paginated = paginate(artists, this.#page, PAGE_SIZE); + ctx.artists = 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: [`list`] }); + }; + + /** @this {ArtistBrowser} */ + static async #nextPage() { + this.setPage(this.#page + 1); + }; + + /** @this {ArtistBrowser} */ + static async #prevPage() { + this.setPage(this.#page - 1); + }; + // #endregion Actions +}; + +function compareArtists(mode, a, b) { + switch (mode) { + case `name-asc`: { + return a.name.localeCompare(b.name); + }; + case `name-desc`: { + return b.name.localeCompare(a.name); + }; + case `image-count-asc`: { + return a.imageCount - b.imageCount; + }; + case `image-count-desc`: { + return b.imageCount - a.imageCount; + }; + }; + return 0; +}; diff --git a/styles/apps/ArtistBrowser.css b/styles/apps/ArtistBrowser.css new file mode 100644 index 0000000..41fddaa --- /dev/null +++ b/styles/apps/ArtistBrowser.css @@ -0,0 +1,21 @@ +.image-tagger.ArtistBrowser { + .artist { + display: flex; + flex-direction: column; + gap: 8px; + background: var(--color-cool-4); + padding: 8px; + } + + h2, h3 { + font-family: "Signika", "Palatino Linotype", sans-serif; + margin: 0; + } + + h2 { + font-size: 1.5rem; + } + h3 { + font-size: 1.25rem; + } +} diff --git a/styles/main.css b/styles/main.css index 985ddda..ce278a1 100644 --- a/styles/main.css +++ b/styles/main.css @@ -13,6 +13,7 @@ @import url("./apps/common.css") layer(apps); @import url("./apps/data-browser.css") layer(apps); @import url("./apps/ArtBrowser.css") layer(apps); +@import url("./apps/ArtistBrowser.css") layer(apps); @import url("./apps/ArtistApp.css") layer(apps); @import url("./apps/ArtSidebar.css") layer(apps); @import url("./apps/ImageApp.css") layer(apps); diff --git a/templates/ArtSidebar/artists.hbs b/templates/ArtSidebar/artists.hbs index 56759b7..96d83f7 100644 --- a/templates/ArtSidebar/artists.hbs +++ b/templates/ArtSidebar/artists.hbs @@ -3,7 +3,7 @@ diff --git a/templates/ArtistBrowser/artist.hbs b/templates/ArtistBrowser/artist.hbs new file mode 100644 index 0000000..df9ddd3 --- /dev/null +++ b/templates/ArtistBrowser/artist.hbs @@ -0,0 +1,28 @@ +
+
+

{{artist.name}}

+
+
+ {{artist.imageCount}} Images +
+
+ {{#if artist.links}} + + {{/if}} + {{#if artist.commonTags}} +
+

Common Image Tags

+ +
+ {{/if}} +
diff --git a/templates/ArtistBrowser/list.hbs b/templates/ArtistBrowser/list.hbs new file mode 100644 index 0000000..a354373 --- /dev/null +++ b/templates/ArtistBrowser/list.hbs @@ -0,0 +1,34 @@ +
+
+ {{#if can.upload}} + + {{/if}} +
+ {{#if artists}} + + {{else}} + + {{ localize "" }} + + {{/if}} +
+ +
\ No newline at end of file diff --git a/templates/ArtistBrowser/sidebar.hbs b/templates/ArtistBrowser/sidebar.hbs new file mode 100644 index 0000000..43e6115 --- /dev/null +++ b/templates/ArtistBrowser/sidebar.hbs @@ -0,0 +1,18 @@ +
+ + + +
+ + + +