Get the module foundations and the Artist app created

This commit is contained in:
Oliver 2026-01-17 21:54:41 -07:00
parent 8744b6c562
commit ffa2162fbd
20 changed files with 590 additions and 0 deletions

93
eslint.config.mjs Normal file
View 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
View 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}"
}
}
}
}

View file

@ -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
View 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
View 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
View 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
View file

@ -0,0 +1,5 @@
import { api } from "../api.mjs";
Hooks.on(`init`, () => {
globalThis.tb = api;
});

3
module/hooks/ready.mjs Normal file
View file

@ -0,0 +1,3 @@
Hooks.on(`ready`, () => {
globalThis._loc = game.i18n.format.bind(game.i18n);
});

2
module/token-browser.mjs Normal file
View file

@ -0,0 +1,2 @@
import "./hooks/init.mjs";
import "./hooks/ready.mjs";

14
module/utils/dialogs.mjs Normal file
View 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
View 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
View 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;
}
}
}
}

View file

@ -0,0 +1,7 @@
.token-browser > .window-content {
ul {
margin: 0;
padding: 0;
list-style: none;
}
}

View 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
View 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
View file

@ -0,0 +1,5 @@
.token-browser > .window-content {
li {
margin: 0;
}
}

View file

@ -0,0 +1,4 @@
<footer>
<button type="button" data-action="close">Cancel</button>
<button type="submit">Save</button>
</footer>

View 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>

View 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>

View 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>