Add first iteration of the Image DB editor
This commit is contained in:
parent
773c506570
commit
c7c02702d7
13 changed files with 425 additions and 7 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
197
module/apps/Image.mjs
Normal 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
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
6
module/handlebarsHelpers/_index.mjs
Normal file
6
module/handlebarsHelpers/_index.mjs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { options } from "./options.mjs";
|
||||
|
||||
export default {
|
||||
// MARK: Complex
|
||||
"tb-options": options,
|
||||
};
|
||||
43
module/handlebarsHelpers/options.mjs
Normal file
43
module/handlebarsHelpers/options.mjs
Normal 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`));
|
||||
};
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
import { api } from "../api.mjs";
|
||||
import helpers from "../handlebarsHelpers/_index.mjs";
|
||||
|
||||
Hooks.on(`init`, () => {
|
||||
globalThis.tb = api;
|
||||
|
||||
Handlebars.registerHelper(helpers);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
46
styles/apps/ImageApp.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
30
templates/ImageApp/form.hbs
Normal file
30
templates/ImageApp/form.hbs
Normal 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>
|
||||
14
templates/ImageApp/header.hbs
Normal file
14
templates/ImageApp/header.hbs
Normal 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>
|
||||
12
templates/ImageApp/preview.hbs
Normal file
12
templates/ImageApp/preview.hbs
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue