Create the most basic version of the ArtistBrowser app

This commit is contained in:
Oliver 2026-02-01 18:31:11 -07:00
parent 1c32a71db9
commit d5a114a44a
9 changed files with 348 additions and 1 deletions

View file

@ -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",

View file

@ -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,
},

View file

@ -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;
};

View file

@ -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;
}
}

View file

@ -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);

View file

@ -3,7 +3,7 @@
<button
type="button"
data-action="openApp"
data-app="ArtistList"
data-app="ArtistBrowser"
>
View All
</button>

View file

@ -0,0 +1,28 @@
<div class="entry artist">
<div class="row">
<h2>{{artist.name}}</h2>
<div class="grow"></div>
<div>
{{artist.imageCount}} Images
</div>
</div>
{{#if artist.links}}
<ul class="chip-list">
{{#each artist.links as |link|}}
<li class="chip">
<a href="{{link.url}}">{{link.name}}</a>
</li>
{{/each}}
</ul>
{{/if}}
{{#if artist.commonTags}}
<section>
<h3>Common Image Tags</h3>
<ul class="chip-list">
{{#each artist.commonTags as |tag|}}
<li class="chip">{{tag.name}} ({{tag.count}})</li>
{{/each}}
</ul>
</section>
{{/if}}
</div>

View file

@ -0,0 +1,34 @@
<div class="paginated">
<div class="row">
{{#if can.upload}}
<button data-action="createArtist">Create New Artist</button>
{{/if}}
</div>
{{#if artists}}
<ul class="entry-list list">
{{#each artists as | artist |}}
{{> (it-filePath "templates/ArtistBrowser/artist.hbs") artist=artist }}
{{/each}}
</ul>
{{else}}
<span class="placeholder">
{{ localize "" }}
</span>
{{/if}}
<div class="grow"></div>
<div class="row page-nav">
<button
data-action="prevPage"
{{disabled (not has.prev)}}
>
Prev
</button>
{{page}} / {{pages}}
<button
data-action="nextPage"
{{disabled (not has.next)}}
>
Next
</button>
</div>
</div>

View file

@ -0,0 +1,18 @@
<form autocomplete="off" class="filters">
<label for="{{meta.idp}}-name">Name</label>
<input
id="{{meta.idp}}-name"
type="text"
name="name"
value="{{name}}"
>
<hr>
<label for="{{meta.idp}}-sort">
Sort By
</label>
<select name="sortBy" id="{{meta.idp}}-sort">
{{it-options sortBy sortOptions localize=true}}
</select>
</form>