Add first iteration of the Image DB editor

This commit is contained in:
Oliver 2026-01-18 23:34:24 -07:00
parent 773c506570
commit c7c02702d7
13 changed files with 425 additions and 7 deletions

View file

@ -10,6 +10,13 @@
"edit": "Edit Artist: {name}"
},
"add-link": "Add New Link"
},
"ImageApp": {
"title": {
"upload": "Upload New Image",
"edit-named": "Editing Image: {name}",
"edit-generic": "Editing Image"
}
}
},
"dialogs": {

View file

@ -1,17 +1,20 @@
// Applications
import { ArtistApp } from "./apps/Artist.mjs";
import { ImageApp } from "./apps/Image.mjs";
// Utils
import { getFile, lastModifiedAt, uploadJson } from "./utils/fs.mjs";
import { getFile, hashFile, lastModifiedAt, uploadJson } from "./utils/fs.mjs";
const { deepFreeze } = foundry.utils;
export const api = deepFreeze({
Apps: {
ArtistApp,
ImageApp,
},
utils: {
fs: {
hashFile,
lastModifiedAt,
getFile,
uploadJson,

197
module/apps/Image.mjs Normal file
View file

@ -0,0 +1,197 @@
import { __ID__, filePath } from "../consts.mjs";
import { determineFileExtension, getFile, hashFile, lastModifiedAt, uploadFile, uploadJson } from "../utils/fs.mjs";
import { DBConnectorMixin } from "./mixins/DBConnector.mjs";
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
export class ImageApp extends
DBConnectorMixin(
HandlebarsApplicationMixin(
ApplicationV2
)) {
// #region Options
static dbType = `Image`;
static dbPath = `storage/db/images.json`;
static DEFAULT_OPTIONS = {
tag: `form`,
classes: [
__ID__,
`ImageApp`,
],
position: {
width: 600,
},
window: {
contentClasses: [
`standard-form`,
],
},
form: {
handler: this.#onSubmit,
submitOnChange: false,
closeOnSubmit: true,
},
actions: {},
};
static PARTS = {
header: { template: filePath(`templates/ImageApp/header.hbs`) },
preview: { template: filePath(`templates/ImageApp/preview.hbs`) },
form: { template: filePath(`templates/ImageApp/form.hbs`) },
footer: { template: filePath(`templates/partials/footer.hbs`) },
};
// #endregion Options
// #region Instance Data
/** The artist that is being edited, or the default artist values */
_doc = { path: ``, tags: [], artists: [] };
get title() {
if (this._docID) {
if (this._doc.name) {
return _loc(
`TB.apps.ImageApp.title.edit-named`,
{ name: this._doc.name },
);
} else {
return _loc(`TB.apps.ImageApp.title.edit-generic`);
}
};
return _loc(`TB.apps.ImageApp.title.upload`);
};
// #endregion Instance Data
// #region Lifecycle
_onFirstRender() {
if (this._doc?.name) {
this._updateFrame({ window: { title: this.title } });
};
this.element.querySelector(`input[type="file"]`)?.addEventListener(
`change`,
this.#changeImage.bind(this),
);
};
/** @this {ImageApp} */
static async #onSubmit(event, element, formData) {
const data = formData.object;
// Validate the DB hasn't been updated since opening
if (!(await this.isAbleToSave())) { return };
// Upload new image to server
if (!this._docID) {
const hash = this._doc.hash;
const extension = determineFileExtension(this.#file);
const path = `storage/tokens/${hash}.${extension}`;
if (!this.#file) {
// TODO: ERROR
return;
}
const file = new File(
[this.#file],
`${hash}.${extension}`,
{ type: this.#file.type },
);
await uploadFile(`tokens`, file);
const images = await getFile(this.constructor.dbPath);
images[hash] = {
name: data.name,
tags: data.tags,
artists: data.artists,
path: this.#file ? path : ``,
};
await uploadJson(`db`, `images.json`, images);
}
else {
const images = await getFile(this.constructor.dbPath);
Object.assign(images[this._docID], {
name: data.name,
tage: data.tags,
artists: data.artists,
});
await uploadJson(`db`, `images.json`, images);
};
};
// #endregion Lifecycle
// #region Data Prep
#lastArtistEdit = null;
#artistCache = [];
async _prepareContext() {
// Get all of the available artists for the dropdown
const lastModified = await lastModifiedAt(`storage/db/artists.json`);
if (
this.#lastArtistEdit == null
|| lastModified !== this.#lastArtistEdit
) {
this.#artistCache = [];
const artistDB = await getFile(`storage/db/artists.json`);
for (const [id, artist] of Object.entries(artistDB)) {
this.#artistCache.push({ value: id, label: artist.name });
};
};
const ctx = {
meta: {
idp: this.id,
},
imageTypes: Object.values(CONST.IMAGE_FILE_EXTENSIONS).join(`,`),
imageURL: this._doc.path.startsWith(`blob`)
? this._doc.path
: filePath(this._doc.path),
image: this._doc,
artists: this.#artistCache,
};
return ctx;
};
// #endregion Data Prep
// #region Actions
/** @type {File | null} */
#file = null;
async #changeImage(event) {
/** @type {File} */
const file = this.#file = event.target.files[0];
// Prevent memory leaks
URL.revokeObjectURL(this._doc.path);
if (!file) {
this._docID = null;
this._doc = { path: ``, artists: [], tags: [] };
this._updateFrame({ window: { title: this.title } });
await this.render({ parts: [`preview`, `form`] });
return;
};
// Ensure we don't already have the file uploaded
const extension = determineFileExtension(file);
const hash = await hashFile(file);
const path = `storage/tokens/${hash}.${extension}`;
const lastModified = await lastModifiedAt(path);
if (lastModified) {
this._docID = hash;
this._doc.hash = hash;
await this._fetchDocument();
this._updateFrame({ window: { title: this.title } });
await this.render({ parts: [`preview`, `form`] });
return;
};
// Temporarily blob the image so it can be displayed in-app
const url = URL.createObjectURL(file);
this._doc.path = url;
this._doc.hash = hash;
await this.render({ parts: [`preview`] });
};
// #endregion Actions
};

View file

@ -4,8 +4,18 @@ export const __ID__ = `token-browser`;
* @param {string} path
*/
export function filePath(path) {
if (path.startsWith(`modules/${__ID__}/`)) {
return path;
};
if (path.startsWith(`/`)) {
path = path.slice(1);
};
return `modules/${__ID__}/${path}`;
};
let _devMode;
export function devMode() {
if (_devMode != null) { return _devMode };
_devMode = game.modules.get(__ID__).flags.inDev ?? false;
return _devMode;
};

View file

@ -0,0 +1,6 @@
import { options } from "./options.mjs";
export default {
// MARK: Complex
"tb-options": options,
};

View file

@ -0,0 +1,43 @@
/**
* @typedef {object} Option
* @property {string} [label]
* @property {string|number} value
* @property {boolean} [disabled]
*/
/**
* @param {string | number | Array<string | number>} selected
* @param {Array<Option | string>} opts
* @param {any} meta
*/
export function options(selected, opts, meta) {
const chosen = new Set();
if (!Array.isArray(selected)) {
selected = new Set([selected]);
chosen.add(selected);
} else {
selected.forEach(opt => chosen.add(opt));
};
const { localize = false } = meta.hash;
selected = Handlebars.escapeExpression(selected);
const htmlOptions = [];
for (let opt of opts) {
if (typeof opt === `string`) {
opt = { label: opt, value: opt };
};
opt.value = Handlebars.escapeExpression(opt.value);
htmlOptions.push(
`<option
value="${opt.value}"
${chosen.has(opt.value) ? `selected` : ``}
${opt.disabled ? `disabled` : ``}
>
${localize ? _loc(opt.label) : opt.label}
</option>`,
);
};
return new Handlebars.SafeString(htmlOptions.join(`\n`));
};

View file

@ -1,5 +1,8 @@
import { api } from "../api.mjs";
import helpers from "../handlebarsHelpers/_index.mjs";
Hooks.on(`init`, () => {
globalThis.tb = api;
Handlebars.registerHelper(helpers);
});

View file

@ -1,7 +1,33 @@
import { __ID__, filePath } from "../consts.mjs";
import { __ID__, devMode, filePath } from "../consts.mjs";
const { fetchJsonWithTimeout } = foundry.utils;
/**
* Returns a pseudo-hash for a file. If the browser is in a secure
* context this will return a true SHA-1 hash of the file, otherwise
* a random ID is generated using the character set A-Za-z0-9.
*
* @param {File} file The file to hash
*/
export async function hashFile(file) {
// Handle fallback when hashing isn't possible
if (!window.isSecureContext || !crypto.subtle.digest) {
return foundry.utils.randomID(40);
};
// Actually hash the file since we can
const bytes = await file.arrayBuffer();
const buffer = await crypto.subtle.digest(`SHA-1`, bytes);
const intArray = new Uint8Array(buffer);
if (Uint8Array.prototype.toHex) {
return intArray.toHex();
};
return Array.from(intArray)
.map(b => b.toString(16).padStart(2, `0`))
.join(``);
};
export async function lastModifiedAt(path) {
try {
const response = await fetch(filePath(path), { method: `HEAD` });
@ -24,6 +50,22 @@ export async function getFile(path) {
* @param {any} data
*/
export async function uploadJson(path, filename, data) {
const content = JSON.stringify(
data,
undefined,
devMode() ? `\t` : undefined,
);
try {
const file = new File(
[content],
filename,
{ type: `text/plain` },
);
await uploadFile(path, file);
} catch {};
};
export async function uploadFile(path, file) {
// uploadPersistent adds "storage" into the path automatically
if (path.startsWith(`storage/`)) {
@ -35,11 +77,6 @@ export async function uploadJson(path, filename, data) {
const picker = foundry.applications.apps.FilePicker.implementation;
try {
const file = new File(
[JSON.stringify(data)],
filename,
{ type: `text/plain` },
);
await picker.uploadPersistent(
__ID__,
path,
@ -47,3 +84,12 @@ export async function uploadJson(path, filename, data) {
);
} catch {};
};
export function determineFileExtension(file) {
for (const [short, long] of Object.entries(CONST.IMAGE_FILE_EXTENSIONS)) {
if (long === file.type) {
return short;
};
};
return null;
};

46
styles/apps/ImageApp.css Normal file
View file

@ -0,0 +1,46 @@
.token-browser.ImageApp {
> .window-content {
display: grid;
grid-template-columns: 200px auto;
grid-template-rows: auto auto;
}
header, footer {
grid-column: 1 / -1;
}
.inputs {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
grid-template-rows: repeat(3, min-content);
align-self: start;
gap: 16px 8px;
label {
align-self: end;
height: var(--input-height);
display: flex;
align-items: center;
}
}
.preview {
width: 200px;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
align-self: center;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
.placeholder {
font-style: italic;
text-align: center;
}
}
}

View file

@ -10,3 +10,4 @@
/* Apps */
@import url("./apps/common.css") layer(apps);
@import url("./apps/ArtistApp.css") layer(apps);
@import url("./apps/ImageApp.css") layer(apps);

View file

@ -0,0 +1,30 @@
<div class="inputs">
<label for="{{meta.idp}}-name">
Name (Optional)
</label>
<input
id="{{meta.idp}}-name"
type="text"
name="name"
value="{{image.name}}"
>
<label for="{{meta.idp}}-tags">
Tags (Optional)
</label>
<string-tags
id="{{meta.idp}}-tags"
value="{{image.tags}}"
name="tags"
></string-tags>
<label for="{{meta.idp}}-artists">
Artists (Optional)
</label>
<multi-select
id="{{meta.idp}}-artists"
name="artists"
>
{{ tb-options image.artists artists }}
</multi-select>
</div>

View file

@ -0,0 +1,14 @@
<header>
<div class="row">
<label for="{{meta.idp}}-image">
Image File
</label>
{{!-- TODO: when adding support for editing, make this readonly --}}
<input
type="file"
id="{{meta.idp}}-image"
accept="{{imageTypes}}"
name="file"
>
</div>
</header>

View file

@ -0,0 +1,12 @@
<div class="preview">
{{#if image.path}}
<img
src="{{imageURL}}"
alt=""
>
{{else}}
<span class="placeholder">
Select an image to see the preview
</span>
{{/if}}
</div>