Add the most basic form of the Art Browser that just lets users see and filter the tokens

This commit is contained in:
Oliver 2026-01-23 21:20:09 -07:00
parent 82ba07414c
commit 547816701d
9 changed files with 412 additions and 0 deletions

View file

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

231
module/apps/ArtBrowser.mjs Normal file
View file

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

View file

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

View file

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

7
styles/resets/hr.css Normal file
View file

@ -0,0 +1,7 @@
.token-browser > .window-content hr {
all: initial;
display: block;
width: 100%;
margin: 12px 0;
border-top: var(--sidebar-separator);
}

View file

@ -0,0 +1,11 @@
<li class="image grid">
<img
src="{{tb-filePath image.path}}"
alt=""
>
{{#if (eq selectMode "single")}}
<button>Select</button>
{{else if (eq selectMod "multi")}}
<input type="checkbox">
{{/if}}
</li>

View file

@ -0,0 +1,29 @@
<li class="image">
<img
src="{{tb-filePath image.path}}"
alt=""
>
{{#if (eq selectMode "single")}}
<button>Select</button>
{{else if (eq selectMod "multi")}}
<input type="checkbox">
{{/if}}
<div class="details">
<div>
"<span>{{image.name}}</span>"
{{#if image.artists}}
by
{{#each image.artists as | artist |}}
<span data-artist-id="{{artist.id}}">{{artist.name}}{{ifThen @last "" ","}}</span>
{{/each}}
{{/if}}
</div>
{{#if image.tags}}
<ul class="chip-list">
{{#each image.tags as | tag |}}
<li>{{tag}}</li>
{{/each}}
</ul>
{{/if}}
</div>
</li>

View file

@ -0,0 +1,32 @@
<div class="paginated">
<div class="row">
<button data-action="uploadImage">Upload Image</button>
</div>
{{#if images}}
<ul class="image-list image-list--{{listLayout}}">
{{#each images as | image |}}
{{> (tb-filePath "templates/ArtBrowser/image/grid.hbs") image=image selectMode=@root.selectMode }}
{{/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,34 @@
<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}}-tags">
Tags
</label>
<string-tags
id="{{meta.idp}}-tags"
value="{{tags}}"
name="tags"
></string-tags>
<hr>
<label for="{{meta.idp}}-artists">
Artists
</label>
<multi-select
id="{{meta.idp}}-artists"
name="artists"
>
{{ tb-options artists artistList }}
</multi-select>
</form>