Get the module foundations and the Artist app created
This commit is contained in:
parent
8744b6c562
commit
ffa2162fbd
20 changed files with 590 additions and 0 deletions
93
eslint.config.mjs
Normal file
93
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import globals from "globals";
|
||||
import pluginJs from "@eslint/js";
|
||||
import stylistic from "@stylistic/eslint-plugin";
|
||||
|
||||
export default [
|
||||
// Tell eslint to ignore files that I don't mind being formatted slightly differently
|
||||
{ ignores: [ `scripts/`, `foundry/` ] },
|
||||
{
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
pluginJs.configs.recommended,
|
||||
// MARK: Foundry Globals
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
_loc: `readonly`,
|
||||
CONFIG: `writable`,
|
||||
CONST: `readonly`,
|
||||
game: `readonly`,
|
||||
Handlebars: `readonly`,
|
||||
Hooks: `readonly`,
|
||||
ui: `readonly`,
|
||||
Actor: `readonly`,
|
||||
Item: `readonly`,
|
||||
foundry: `readonly`,
|
||||
ChatMessage: `readonly`,
|
||||
ActiveEffect: `readonly`,
|
||||
Dialog: `readonly`,
|
||||
renderTemplate: `readonly`,
|
||||
TextEditor: `readonly`,
|
||||
fromUuid: `readonly`,
|
||||
Combat: `readonly`,
|
||||
Combatant: `readonly`,
|
||||
canvas: `readonly`,
|
||||
Token: `readonly`,
|
||||
Tour: `readonly`,
|
||||
},
|
||||
},
|
||||
},
|
||||
// MARK: Project Specific
|
||||
{
|
||||
plugins: {
|
||||
"@stylistic": stylistic,
|
||||
},
|
||||
languageOptions: {
|
||||
globals: {},
|
||||
},
|
||||
rules: {
|
||||
"curly": `error`,
|
||||
"func-names": [`warn`, `as-needed`],
|
||||
"grouped-accessor-pairs": `error`,
|
||||
"no-alert": `error`,
|
||||
"no-empty": [`error`, { allowEmptyCatch: true }],
|
||||
"no-implied-eval": `error`,
|
||||
"no-invalid-this": `error`,
|
||||
"no-lonely-if": `error`,
|
||||
"no-unneeded-ternary": `error`,
|
||||
"no-nested-ternary": `error`,
|
||||
"no-var": `error`,
|
||||
"no-unused-vars": [
|
||||
`error`,
|
||||
{
|
||||
"vars": `local`,
|
||||
"args": `after-used`,
|
||||
"varsIgnorePattern": `^_`,
|
||||
"argsIgnorePattern": `^_`,
|
||||
},
|
||||
],
|
||||
"sort-imports": [`warn`, { "ignoreCase": true, "allowSeparatedGroups": true }],
|
||||
"@stylistic/semi": [`warn`, `always`, { "omitLastInOneLineBlock": true }],
|
||||
"@stylistic/no-trailing-spaces": `warn`,
|
||||
"@stylistic/space-before-blocks": [`warn`, `always`],
|
||||
"@stylistic/space-infix-ops": `warn`,
|
||||
"@stylistic/eol-last": `warn`,
|
||||
"@stylistic/operator-linebreak": [`warn`, `before`],
|
||||
"@stylistic/indent": [`warn`, `tab`],
|
||||
"@stylistic/brace-style": [`off`],
|
||||
"@stylistic/quotes": [`warn`, `backtick`, { "avoidEscape": true }],
|
||||
"@stylistic/comma-dangle": [`warn`, { arrays: `always-multiline`, objects: `always-multiline`, imports: `always-multiline`, exports: `always-multiline`, functions: `always-multiline` }],
|
||||
"@stylistic/comma-style": [`warn`, `last`],
|
||||
"@stylistic/dot-location": [`error`, `property`],
|
||||
"@stylistic/no-confusing-arrow": `error`,
|
||||
"@stylistic/no-whitespace-before-property": `error`,
|
||||
"@stylistic/nonblock-statement-body-position": [
|
||||
`error`,
|
||||
`beside`,
|
||||
{ "overrides": { "while": `below` } },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
29
langs/en-ca.json
Normal file
29
langs/en-ca.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"TB": {
|
||||
"common": {
|
||||
"unsaved": "This change hasn't been saved, if you close without saving it will be undone."
|
||||
},
|
||||
"apps": {
|
||||
"ArtistApp": {
|
||||
"title": {
|
||||
"create": "Create New Artist",
|
||||
"edit": "Edit Artist: {name}"
|
||||
},
|
||||
"add-link": "Add New Link"
|
||||
}
|
||||
},
|
||||
"dialogs": {
|
||||
"Link": {
|
||||
"title": "Create/Edit Link",
|
||||
"name": "Name",
|
||||
"url": "URL"
|
||||
}
|
||||
},
|
||||
"notifs": {
|
||||
"error": {
|
||||
"db-out-of-date": "Database out of date, please try again.",
|
||||
"artist-ID-404": "An artist cannot be found with ID: {id}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,13 @@
|
|||
"layer": "modules.token-browser"
|
||||
}
|
||||
],
|
||||
"languages": [
|
||||
{
|
||||
"lang": "en",
|
||||
"name": "English (Canadian)",
|
||||
"path": "langs/en-ca.json"
|
||||
}
|
||||
],
|
||||
"flags": {
|
||||
"inDev": true
|
||||
},
|
||||
|
|
|
|||
20
module/api.mjs
Normal file
20
module/api.mjs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// Applications
|
||||
import { ArtistApp } from "./apps/Artist.mjs";
|
||||
|
||||
// Utils
|
||||
import { getFile, lastModifiedAt, uploadJson } from "./utils/fs.mjs";
|
||||
|
||||
const { deepFreeze } = foundry.utils;
|
||||
|
||||
export const api = deepFreeze({
|
||||
Apps: {
|
||||
ArtistApp,
|
||||
},
|
||||
utils: {
|
||||
fs: {
|
||||
lastModifiedAt,
|
||||
getFile,
|
||||
uploadJson,
|
||||
},
|
||||
},
|
||||
});
|
||||
193
module/apps/Artist.mjs
Normal file
193
module/apps/Artist.mjs
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import { __ID__, filePath } from "../consts.mjs";
|
||||
import { getFile, lastModifiedAt, uploadJson } from "../utils/fs.mjs";
|
||||
import { promptViaTemplate } from "../utils/dialogs.mjs";
|
||||
|
||||
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
|
||||
const { randomID } = foundry.utils;
|
||||
|
||||
export class ArtistApp extends HandlebarsApplicationMixin(ApplicationV2) {
|
||||
|
||||
// #region Options
|
||||
static DEFAULT_OPTIONS = {
|
||||
tag: `form`,
|
||||
classes: [
|
||||
__ID__,
|
||||
`ArtistApp`,
|
||||
],
|
||||
position: {
|
||||
width: 350,
|
||||
},
|
||||
window: {
|
||||
contentClasses: [
|
||||
`standard-form`,
|
||||
],
|
||||
},
|
||||
form: {
|
||||
handler: this.#onSubmit,
|
||||
submitOnChange: false,
|
||||
closeOnSubmit: true,
|
||||
},
|
||||
actions: {
|
||||
addNewLink: this.#addNewLink,
|
||||
deleteLink: this.#deleteLink,
|
||||
},
|
||||
};
|
||||
|
||||
static PARTS = {
|
||||
form: {
|
||||
template: filePath(`templates/ArtistApp/form.hbs`),
|
||||
},
|
||||
linkList: {
|
||||
template: filePath(`templates/ArtistApp/linkList.hbs`),
|
||||
},
|
||||
footer: {
|
||||
template: filePath(`templates/ArtistApp/footer.hbs`),
|
||||
},
|
||||
};
|
||||
// #endregion Options
|
||||
|
||||
// #region Instance Data
|
||||
/** @type { null | string } */
|
||||
#artistID = null;
|
||||
|
||||
/** The artist that is being edited, or the default artist values */
|
||||
#artist = { name: ``, links: [] };
|
||||
|
||||
/** @type { null | string } */
|
||||
#lastModified = null;
|
||||
|
||||
constructor({ artistID = null, ...opts } = {}) {
|
||||
super(opts);
|
||||
this.#artistID = artistID;
|
||||
};
|
||||
|
||||
get title() {
|
||||
if (this.#artistID && this.#artist.name) {
|
||||
return game.i18n.format(
|
||||
`TB.apps.ArtistApp.title.edit`,
|
||||
{ name: this.#artist.name },
|
||||
);
|
||||
};
|
||||
return game.i18n.localize(`TB.apps.ArtistApp.title.create`);
|
||||
};
|
||||
// #endregion Instance Data
|
||||
|
||||
// #region Lifecycle
|
||||
/** Whether or not the database values have been initialized */
|
||||
#connected = false;
|
||||
|
||||
/**
|
||||
* This fetches the DB data that we care about for the purposes of being able
|
||||
* to create/edit entries in the Artists DB
|
||||
*/
|
||||
async #connectToDB() {
|
||||
if (this.#connected) { return true };
|
||||
this.#lastModified ??= await lastModifiedAt(`storage/db/artists.json`);
|
||||
|
||||
if (this.#artistID) {
|
||||
const artists = await getFile(`storage/db/artists.json`);
|
||||
|
||||
if (artists[this.#artistID] == null) {
|
||||
ui.notifications.error(_loc(
|
||||
`TB.notifs.error.artist-ID-404`,
|
||||
{ id: this.#artistID },
|
||||
));
|
||||
return false;
|
||||
};
|
||||
|
||||
Object.assign(this.#artist, artists[this.#artistID]);
|
||||
};
|
||||
this.#connected = true;
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensures that the app is in a state where it is allowed to render
|
||||
* before actually letting it render at all.
|
||||
*/
|
||||
async render(options, ...args) {
|
||||
const allowed = await this.#connectToDB();
|
||||
if (!allowed) { return this };
|
||||
return super.render(options, ...args);
|
||||
};
|
||||
|
||||
/**
|
||||
* Makes it so that we update the frame's title to include the Artist name if
|
||||
* we're editing an existing artist instead of creating a new one.
|
||||
*/
|
||||
_onFirstRender() {
|
||||
if (this.#artist.name) {
|
||||
this._updateFrame({ window: { title: this.title } });
|
||||
};
|
||||
};
|
||||
|
||||
/** @this {ArtistApp} */
|
||||
static async #onSubmit(event, element, formData) {
|
||||
const artist = formData.object;
|
||||
|
||||
if (artist.name.length === 0) { return };
|
||||
artist.links = this.#artist.links;
|
||||
artist.links.forEach(link => {
|
||||
delete link.isNew;
|
||||
});
|
||||
|
||||
// Validate the DB hasn't been updated since
|
||||
if (this.#artistID) {
|
||||
const newLastModified = await lastModifiedAt(`storage/db/artists.json`);
|
||||
if (newLastModified !== this.#lastModified) {
|
||||
ui.notifications.error(
|
||||
`TB.notifs.error.db-out-of-date`,
|
||||
{ localize: true },
|
||||
);
|
||||
return;
|
||||
};
|
||||
};
|
||||
|
||||
const artists = getFile(`storage/db/artists.json`);
|
||||
|
||||
let id = this.#artistID;
|
||||
if (!this.#artistID) {
|
||||
do {
|
||||
id = randomID();
|
||||
} while (artists[id] != null);
|
||||
};
|
||||
artists[id] = artist;
|
||||
|
||||
await uploadJson(`db`, `artists.json`, artists);
|
||||
};
|
||||
// #endregion Lifecycle
|
||||
|
||||
// #region Data Prep
|
||||
async _prepareContext() {
|
||||
const ctx = {
|
||||
meta: {
|
||||
idp: this.id,
|
||||
},
|
||||
artist: this.#artist,
|
||||
};
|
||||
|
||||
return ctx;
|
||||
};
|
||||
// #endregion Data Prep
|
||||
|
||||
// #region Actions
|
||||
/** @this {ArtistApp} */
|
||||
static async #addNewLink() {
|
||||
const link = await promptViaTemplate(
|
||||
`TB.dialogs.Link.title`,
|
||||
`templates/Dialogs/Link.hbs`,
|
||||
);
|
||||
link.isNew = true;
|
||||
this.#artist.links.push(link);
|
||||
this.render({ parts: [ `linkList` ] });
|
||||
};
|
||||
|
||||
/** @this {ArtistApp} */
|
||||
static async #deleteLink(event, element) {
|
||||
const index = element.closest(`[data-index]`)?.dataset.index;
|
||||
if (index == null) { return };
|
||||
this.#artist.links.splice(index, 1);
|
||||
this.render({ parts: [ `linkList` ] });
|
||||
};
|
||||
// #endregion Actions
|
||||
};
|
||||
11
module/consts.mjs
Normal file
11
module/consts.mjs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export const __ID__ = `token-browser`;
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
*/
|
||||
export function filePath(path) {
|
||||
if (path.startsWith(`/`)) {
|
||||
path = path.slice(1);
|
||||
};
|
||||
return `modules/${__ID__}/${path}`;
|
||||
};
|
||||
5
module/hooks/init.mjs
Normal file
5
module/hooks/init.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { api } from "../api.mjs";
|
||||
|
||||
Hooks.on(`init`, () => {
|
||||
globalThis.tb = api;
|
||||
});
|
||||
3
module/hooks/ready.mjs
Normal file
3
module/hooks/ready.mjs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Hooks.on(`ready`, () => {
|
||||
globalThis._loc = game.i18n.format.bind(game.i18n);
|
||||
});
|
||||
2
module/token-browser.mjs
Normal file
2
module/token-browser.mjs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import "./hooks/init.mjs";
|
||||
import "./hooks/ready.mjs";
|
||||
14
module/utils/dialogs.mjs
Normal file
14
module/utils/dialogs.mjs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { filePath } from "../consts.mjs";
|
||||
|
||||
const { DialogV2 } = foundry.applications.api;
|
||||
const { renderTemplate } = foundry.applications.handlebars;
|
||||
|
||||
export async function promptViaTemplate(title, template, context = {}) {
|
||||
const content = await renderTemplate(filePath(template), context);
|
||||
return DialogV2.input({
|
||||
window: {
|
||||
title,
|
||||
},
|
||||
content,
|
||||
});
|
||||
};
|
||||
49
module/utils/fs.mjs
Normal file
49
module/utils/fs.mjs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { __ID__, filePath } from "../consts.mjs";
|
||||
|
||||
const { fetchJsonWithTimeout } = foundry.utils;
|
||||
|
||||
export async function lastModifiedAt(path) {
|
||||
try {
|
||||
const response = await fetch(filePath(path), { method: `HEAD` });
|
||||
return response.headers.get(`Last-Modified`);
|
||||
} catch {
|
||||
return null;
|
||||
};
|
||||
};
|
||||
|
||||
export async function getFile(path) {
|
||||
try {
|
||||
return fetchJsonWithTimeout(filePath(path));
|
||||
} catch {
|
||||
return undefined;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
* @param {any} data
|
||||
*/
|
||||
export async function uploadJson(path, filename, data) {
|
||||
|
||||
// uploadPersistent adds "storage" into the path automatically
|
||||
if (path.startsWith(`storage/`)) {
|
||||
path = path.slice(8);
|
||||
};
|
||||
if (path.endsWith(`/`)) {
|
||||
path = path.slice(0, -1);
|
||||
};
|
||||
|
||||
const picker = foundry.applications.apps.FilePicker.implementation;
|
||||
try {
|
||||
const file = new File(
|
||||
[JSON.stringify(data)],
|
||||
filename,
|
||||
{ type: `text/plain` },
|
||||
);
|
||||
await picker.uploadPersistent(
|
||||
__ID__,
|
||||
path,
|
||||
file,
|
||||
);
|
||||
} catch {};
|
||||
};
|
||||
48
styles/apps/ArtistApp.css
Normal file
48
styles/apps/ArtistApp.css
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
.token-browser.ArtistApp {
|
||||
> .window-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
color: var(--color-form-label);
|
||||
}
|
||||
|
||||
label, .label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.large {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
gap: 8px;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 4px;
|
||||
background: var(--content-link-background);
|
||||
color: var(--content-link-text-color);
|
||||
border: 1px solid var(--content-link-border-color);
|
||||
border-radius: 4px;
|
||||
|
||||
button {
|
||||
padding: 0;
|
||||
border: none;
|
||||
min-height: 0;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7
styles/elements/lists.css
Normal file
7
styles/elements/lists.css
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
.token-browser > .window-content {
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
}
|
||||
9
styles/elements/utils.css
Normal file
9
styles/elements/utils.css
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
.token-browser > .window-content {
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.grow { flex-grow: 1 }
|
||||
}
|
||||
11
styles/main.css
Normal file
11
styles/main.css
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
@layer resets, elements, apps;
|
||||
|
||||
/* Resets */
|
||||
@import url("./resets/lists.css") layer(resets);
|
||||
|
||||
/* Elements */
|
||||
@import url("./elements/utils.css") layer(elements);
|
||||
@import url("./elements/lists.css") layer(elements);
|
||||
|
||||
/* Apps */
|
||||
@import url("./apps/ArtistApp.css") layer(apps);
|
||||
5
styles/resets/lists.css
Normal file
5
styles/resets/lists.css
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.token-browser > .window-content {
|
||||
li {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
4
templates/ArtistApp/footer.hbs
Normal file
4
templates/ArtistApp/footer.hbs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<footer>
|
||||
<button type="button" data-action="close">Cancel</button>
|
||||
<button type="submit">Save</button>
|
||||
</footer>
|
||||
15
templates/ArtistApp/form.hbs
Normal file
15
templates/ArtistApp/form.hbs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<div>
|
||||
<div class="row">
|
||||
<label for="{{meta.idp}}-name">
|
||||
Name
|
||||
</label>
|
||||
<div class="grow"></div>
|
||||
<input
|
||||
type="text"
|
||||
id="{{meta.idp}}-name"
|
||||
name="name"
|
||||
value="{{artist.name}}"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
43
templates/ArtistApp/linkList.hbs
Normal file
43
templates/ArtistApp/linkList.hbs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<div>
|
||||
<div class="row">
|
||||
<span class="label">Links</span>
|
||||
<div class="grow"></div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="{{localize 'TB.apps.ArtistApp.add-link'}}"
|
||||
data-tooltip="TB.apps.ArtistApp.add-link"
|
||||
data-action="addNewLink"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<ul>
|
||||
{{#each artist.links as | link |}}
|
||||
<li
|
||||
data-index="{{@key}}"
|
||||
{{#if link.isNew}}
|
||||
data-tooltip="TB.common.unsaved"
|
||||
{{/if}}
|
||||
>
|
||||
<a
|
||||
href="{{link.url}}"
|
||||
target="_blank"
|
||||
rel="noreferrer nofollow"
|
||||
>
|
||||
{{link.name}}
|
||||
</a>
|
||||
{{#if link.isNew}}
|
||||
<div class="large" aria-label="{{localize 'TB.common.unsaved'}}">
|
||||
!
|
||||
</div>
|
||||
{{/if}}
|
||||
<button
|
||||
type="button"
|
||||
data-action="deleteLink"
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
22
templates/Dialogs/Link.hbs
Normal file
22
templates/Dialogs/Link.hbs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<div class="form-group">
|
||||
<label for="{{idp}}-name">
|
||||
{{localize "TB.dialogs.Link.name"}}
|
||||
</label>
|
||||
<input
|
||||
id="{{idp}}-name"
|
||||
type="text"
|
||||
value="{{name}}"
|
||||
name="name"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{idp}}-url">
|
||||
{{localize "TB.dialogs.Link.url"}}
|
||||
</label>
|
||||
<input
|
||||
id="{{idp}}-url"
|
||||
type="text"
|
||||
value="{{url}}"
|
||||
name="url"
|
||||
>
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue