Add the most basic form of the Art Browser that just lets users see and filter the tokens
This commit is contained in:
parent
82ba07414c
commit
547816701d
9 changed files with 412 additions and 0 deletions
|
|
@ -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
231
module/apps/ArtBrowser.mjs
Normal 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
|
||||
};
|
||||
64
styles/apps/ArtBrowser.css
Normal file
64
styles/apps/ArtBrowser.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
7
styles/resets/hr.css
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
.token-browser > .window-content hr {
|
||||
all: initial;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 12px 0;
|
||||
border-top: var(--sidebar-separator);
|
||||
}
|
||||
11
templates/ArtBrowser/image/grid.hbs
Normal file
11
templates/ArtBrowser/image/grid.hbs
Normal 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>
|
||||
29
templates/ArtBrowser/image/list.hbs
Normal file
29
templates/ArtBrowser/image/list.hbs
Normal 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>
|
||||
32
templates/ArtBrowser/images.hbs
Normal file
32
templates/ArtBrowser/images.hbs
Normal 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>
|
||||
34
templates/ArtBrowser/sidebar.hbs
Normal file
34
templates/ArtBrowser/sidebar.hbs
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue