3 Commits

Author SHA1 Message Date
6461039bd7 refactor: Migrate the app to TypeScript
Convert the React app from JS/JSX to TS/TSX and add type-checking:
- Rename sheet-main, book, BookIndex, CharacterSheet to .ts(x) and
  add types (Fields, Bond, ClassEntry, Spell, CheckMap; loose
  SavedData for abbreviated save/share payloads)
- Add globals.d.ts for CSS imports and the __BOOK_DATA__ global
- tsconfig.json (strict, noEmit) and a 'typecheck' npm script
- webpack: handle ts/tsx via @babel/preset-typescript
- Enforce types with a tracked pre-commit hook (core.hooksPath),
  wired up automatically via the 'prepare' script
- Update stale Justfile format target for the src/ layout

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:51:53 +00:00
0ba87ac547 chore: organize scripts 2026-06-17 00:03:04 +00:00
406641c522 chore: Organize PDFs 2026-06-17 00:02:30 +00:00
15 changed files with 384 additions and 53 deletions

6
.githooks/pre-commit Executable file
View File

@@ -0,0 +1,6 @@
#!/bin/sh
# Run TypeScript type-checking before each commit.
# Babel strips types without checking them, so this is what actually
# enforces type correctness. Bypass with `git commit --no-verify`.
npm run typecheck

View File

@@ -20,8 +20,9 @@ deploy: build
format: format:
npx prettier --write books/ npx prettier --write books/
npx prettier --write fabula-ultima-sheet.js npx prettier --write src/
npx prettier --write webpack.config.js npx prettier --write webpack.config.js
npx prettier --write fabula-ultima-sheet.css
npx prettier --write fabula-ultima-sheet.html typecheck:
npm run typecheck

209
package-lock.json generated
View File

@@ -7,7 +7,10 @@
"devDependencies": { "devDependencies": {
"@babel/core": "^7.29.7", "@babel/core": "^7.29.7",
"@babel/preset-react": "^7.29.7", "@babel/preset-react": "^7.29.7",
"@babel/preset-typescript": "^7.29.7",
"@babel/register": "^7.29.7", "@babel/register": "^7.29.7",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"babel-loader": "^10.1.1", "babel-loader": "^10.1.1",
"copy-webpack-plugin": "^14.0.0", "copy-webpack-plugin": "^14.0.0",
"css-loader": "^7.1.4", "css-loader": "^7.1.4",
@@ -18,6 +21,7 @@
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"style-loader": "^4.0.0", "style-loader": "^4.0.0",
"typescript": "^6.0.3",
"webpack": "^5.107.2", "webpack": "^5.107.2",
"webpack-cli": "^7.0.3", "webpack-cli": "^7.0.3",
"webpack-dev-server": "^5.2.4" "webpack-dev-server": "^5.2.4"
@@ -171,6 +175,38 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/@babel/helper-create-class-features-plugin": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz",
"integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.29.7",
"@babel/helper-member-expression-to-functions": "^7.29.7",
"@babel/helper-optimise-call-expression": "^7.29.7",
"@babel/helper-replace-supers": "^7.29.7",
"@babel/helper-skip-transparent-expression-wrappers": "^7.29.7",
"@babel/traverse": "^7.29.7",
"semver": "^6.3.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0"
}
},
"node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/@babel/helper-globals": { "node_modules/@babel/helper-globals": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
@@ -181,6 +217,20 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/helper-member-expression-to-functions": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz",
"integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-imports": { "node_modules/@babel/helper-module-imports": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
@@ -213,6 +263,19 @@
"@babel/core": "^7.0.0" "@babel/core": "^7.0.0"
} }
}, },
"node_modules/@babel/helper-optimise-call-expression": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz",
"integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-plugin-utils": { "node_modules/@babel/helper-plugin-utils": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
@@ -223,6 +286,38 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/helper-replace-supers": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz",
"integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-member-expression-to-functions": "^7.29.7",
"@babel/helper-optimise-call-expression": "^7.29.7",
"@babel/traverse": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0"
}
},
"node_modules/@babel/helper-skip-transparent-expression-wrappers": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz",
"integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-string-parser": { "node_modules/@babel/helper-string-parser": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
@@ -299,6 +394,39 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/plugin-syntax-typescript": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz",
"integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-modules-commonjs": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz",
"integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-transforms": "^7.29.7",
"@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-react-display-name": { "node_modules/@babel/plugin-transform-react-display-name": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.29.7.tgz",
@@ -368,6 +496,26 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/plugin-transform-typescript": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz",
"integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.29.7",
"@babel/helper-create-class-features-plugin": "^7.29.7",
"@babel/helper-plugin-utils": "^7.29.7",
"@babel/helper-skip-transparent-expression-wrappers": "^7.29.7",
"@babel/plugin-syntax-typescript": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/preset-react": { "node_modules/@babel/preset-react": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.29.7.tgz",
@@ -389,6 +537,26 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/preset-typescript": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz",
"integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7",
"@babel/helper-validator-option": "^7.29.7",
"@babel/plugin-syntax-jsx": "^7.29.7",
"@babel/plugin-transform-modules-commonjs": "^7.29.7",
"@babel/plugin-transform-typescript": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/register": { "node_modules/@babel/register": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/register/-/register-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.29.7.tgz",
@@ -1396,6 +1564,26 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/react": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@types/retry": { "node_modules/@types/retry": {
"version": "0.12.2", "version": "0.12.2",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz",
@@ -2751,6 +2939,13 @@
"dev": true, "dev": true,
"license": "CC0-1.0" "license": "CC0-1.0"
}, },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -6587,6 +6782,20 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "7.24.6", "version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",

View File

@@ -1,12 +1,17 @@
{ {
"scripts": { "scripts": {
"build": "webpack --mode=production", "build": "webpack --mode=production",
"dev": "webpack serve --mode=development" "dev": "webpack serve --mode=development",
"typecheck": "tsc --noEmit",
"prepare": "git config core.hooksPath .githooks || true"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.29.7", "@babel/core": "^7.29.7",
"@babel/preset-react": "^7.29.7", "@babel/preset-react": "^7.29.7",
"@babel/preset-typescript": "^7.29.7",
"@babel/register": "^7.29.7", "@babel/register": "^7.29.7",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"babel-loader": "^10.1.1", "babel-loader": "^10.1.1",
"copy-webpack-plugin": "^14.0.0", "copy-webpack-plugin": "^14.0.0",
"css-loader": "^7.1.4", "css-loader": "^7.1.4",
@@ -17,6 +22,7 @@
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"style-loader": "^4.0.0", "style-loader": "^4.0.0",
"typescript": "^6.0.3",
"webpack": "^5.107.2", "webpack": "^5.107.2",
"webpack-cli": "^7.0.3", "webpack-cli": "^7.0.3",
"webpack-dev-server": "^5.2.4" "webpack-dev-server": "^5.2.4"

Binary file not shown.

Binary file not shown.

View File

@@ -1,6 +1,12 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
export default function BookIndex({ title, logoText, pages }) { interface BookIndexProps {
title: string;
logoText: string;
pages: BookPage[];
}
export default function BookIndex({ title, logoText, pages }: BookIndexProps) {
const pageNums = useMemo(() => pages.map(p => p.n), [pages]); const pageNums = useMemo(() => pages.map(p => p.n), [pages]);
const total = pageNums.length; const total = pageNums.length;
@@ -13,9 +19,9 @@ export default function BookIndex({ title, logoText, pages }) {
return 0; return 0;
}); });
const contentRef = useRef(null); const contentRef = useRef<HTMLDivElement>(null);
const goTo = useCallback((n, smooth, push) => { const goTo = useCallback((n: number, smooth: boolean, push: boolean) => {
const idx = pageNums.indexOf(n); const idx = pageNums.indexOf(n);
if (idx === -1) return; if (idx === -1) return;
const sec = document.getElementById(`page-${n}`); const sec = document.getElementById(`page-${n}`);
@@ -62,8 +68,9 @@ export default function BookIndex({ title, logoText, pages }) {
// Keyboard navigation // Keyboard navigation
useEffect(() => { useEffect(() => {
const handleKeyDown = e => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; const tag = (e.target as HTMLElement).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
setCurrentIdx(prev => { setCurrentIdx(prev => {
if ((e.key === 'ArrowLeft' || e.key === 'ArrowUp') && prev > 0) { if ((e.key === 'ArrowLeft' || e.key === 'ArrowUp') && prev > 0) {
goTo(pageNums[prev - 1], true, true); goTo(pageNums[prev - 1], true, true);

View File

@@ -25,7 +25,71 @@ const DISCIPLINES = [
"Spiritism", "Spiritism",
]; ];
const BLANK_FIELDS = { interface Fields {
charName: string;
charPronouns: string;
charIdentity: string;
charTheme: string;
charOrigin: string;
charTraits: string;
xpCurrent: string | number;
zenit: string | number;
initMod: string | number;
defense: string | number;
magDef: string | number;
dexBase: string | number;
dexCur: string | number;
insBase: string | number;
insCur: string | number;
migBase: string | number;
migCur: string | number;
wlpBase: string | number;
wlpCur: string | number;
hpMax: string | number;
hpCur: string | number;
mpMax: string | number;
mpCur: string | number;
ipMax: string | number;
ipCur: string | number;
backpack: string;
heroicSkills: string;
ritualsNotes: string;
accName: string;
accDesc: string;
armName: string;
armDesc: string;
mhName: string;
mhDesc: string;
ohName: string;
ohDesc: string;
}
interface Bond {
name: string;
feelings: string[];
}
interface ClassEntry {
name: string;
benefits: string;
skills: string;
}
interface Spell {
name: string;
notes: string;
mp: string | number;
targets: string;
duration: string;
}
type CheckMap = Record<string, boolean>;
// Saved/shared payloads use abbreviated keys (with legacy long-name
// fallbacks), so the shape is intentionally loose.
type SavedData = Record<string, any>;
const BLANK_FIELDS: Fields = {
charName: "", charName: "",
charPronouns: "", charPronouns: "",
charIdentity: "", charIdentity: "",
@@ -64,12 +128,12 @@ const BLANK_FIELDS = {
ohDesc: "", ohDesc: "",
}; };
const BLANK_BONDS = Array.from({ length: 6 }, () => ({ const BLANK_BONDS: Bond[] = Array.from({ length: 6 }, () => ({
name: "", name: "",
feelings: [], feelings: [],
})); }));
async function compressToBase64(str) { async function compressToBase64(str: string) {
const stream = new CompressionStream("deflate-raw"); const stream = new CompressionStream("deflate-raw");
const writer = stream.writable.getWriter(); const writer = stream.writable.getWriter();
writer.write(new TextEncoder().encode(str)); writer.write(new TextEncoder().encode(str));
@@ -82,7 +146,7 @@ async function compressToBase64(str) {
return btoa(binary); return btoa(binary);
} }
async function decompressFromBase64(b64) { async function decompressFromBase64(b64: string) {
try { try {
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const stream = new DecompressionStream("deflate-raw"); const stream = new DecompressionStream("deflate-raw");
@@ -103,15 +167,15 @@ export default function CharacterSheet() {
const [copyStatus, setCopyStatus] = useState(false); const [copyStatus, setCopyStatus] = useState(false);
const [level, setLevel] = useState(1); const [level, setLevel] = useState(1);
const [fp, setFp] = useState(0); const [fp, setFp] = useState(0);
const [fields, setFields] = useState(BLANK_FIELDS); const [fields, setFields] = useState<Fields>(BLANK_FIELDS);
const [bonds, setBonds] = useState(BLANK_BONDS); const [bonds, setBonds] = useState<Bond[]>(BLANK_BONDS);
const [primaryClasses, setPrimaryClasses] = useState([]); const [primaryClasses, setPrimaryClasses] = useState<ClassEntry[]>([]);
const [otherClasses, setOtherClasses] = useState([]); const [otherClasses, setOtherClasses] = useState<ClassEntry[]>([]);
const [spells, setSpells] = useState([]); const [spells, setSpells] = useState<Spell[]>([]);
const [statuses, setStatuses] = useState({}); const [statuses, setStatuses] = useState<CheckMap>({});
const [martial, setMartial] = useState({}); const [martial, setMartial] = useState<CheckMap>({});
const [disciplines, setDisciplines] = useState({}); const [disciplines, setDisciplines] = useState<CheckMap>({});
const [theme, setTheme] = useState( const [theme, setTheme] = useState<string>(
() => () =>
localStorage.getItem("fabulaUltimaTheme") || localStorage.getItem("fabulaUltimaTheme") ||
(window.matchMedia("(prefers-color-scheme: light)").matches (window.matchMedia("(prefers-color-scheme: light)").matches
@@ -119,7 +183,7 @@ export default function CharacterSheet() {
: "dark"), : "dark"),
); );
const importFileRef = useRef(null); const importFileRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
document.documentElement.dataset.theme = theme; document.documentElement.dataset.theme = theme;
@@ -127,14 +191,15 @@ export default function CharacterSheet() {
}, [theme]); }, [theme]);
const f = useCallback( const f = useCallback(
(key, val) => setFields((prev) => ({ ...prev, [key]: val })), <K extends keyof Fields>(key: K, val: Fields[K]) =>
setFields((prev) => ({ ...prev, [key]: val })),
[], [],
); );
const xp = parseInt(fields.xpCurrent) || 0; const xp = parseInt(String(fields.xpCurrent)) || 0;
const xpPct = Math.min((xp % 10) * 10, 100); const xpPct = Math.min((xp % 10) * 10, 100);
const hpMax = parseInt(fields.hpMax) || 0; const hpMax = parseInt(String(fields.hpMax)) || 0;
const hpCur = parseInt(fields.hpCur) || 0; const hpCur = parseInt(String(fields.hpCur)) || 0;
const inCrisis = hpMax > 0 && hpCur <= Math.floor(hpMax / 2); const inCrisis = hpMax > 0 && hpCur <= Math.floor(hpMax / 2);
const fpTotal = Math.max(10, fp); const fpTotal = Math.max(10, fp);
@@ -210,7 +275,7 @@ export default function CharacterSheet() {
], ],
); );
const applyData = useCallback((d) => { const applyData = useCallback((d: SavedData) => {
setFields({ setFields({
charName: d.n ?? d.name ?? "", charName: d.n ?? d.name ?? "",
charPronouns: d.pn ?? d.pronouns ?? "", charPronouns: d.pn ?? d.pronouns ?? "",
@@ -266,7 +331,7 @@ export default function CharacterSheet() {
const rawBonds = d.bo ?? d.bonds; const rawBonds = d.bo ?? d.bonds;
if (rawBonds) if (rawBonds)
setBonds( setBonds(
rawBonds.map((b) => ({ rawBonds.map((b: SavedData) => ({
name: b.n ?? b.name ?? "", name: b.n ?? b.name ?? "",
feelings: b.f ?? b.feelings ?? [], feelings: b.f ?? b.feelings ?? [],
})), })),
@@ -275,7 +340,7 @@ export default function CharacterSheet() {
const rawPrimary = d.pc ?? d.primaryClasses; const rawPrimary = d.pc ?? d.primaryClasses;
if (rawPrimary) if (rawPrimary)
setPrimaryClasses( setPrimaryClasses(
rawPrimary.map((c) => ({ rawPrimary.map((c: SavedData) => ({
name: c.n ?? c.name ?? "", name: c.n ?? c.name ?? "",
benefits: c.b ?? c.benefits ?? "", benefits: c.b ?? c.benefits ?? "",
skills: c.s ?? c.skills ?? "", skills: c.s ?? c.skills ?? "",
@@ -285,7 +350,7 @@ export default function CharacterSheet() {
const rawOther = d.oc ?? d.otherClasses; const rawOther = d.oc ?? d.otherClasses;
if (rawOther) if (rawOther)
setOtherClasses( setOtherClasses(
rawOther.map((c) => ({ rawOther.map((c: SavedData) => ({
name: c.n ?? c.name ?? "", name: c.n ?? c.name ?? "",
benefits: c.b ?? c.benefits ?? "", benefits: c.b ?? c.benefits ?? "",
skills: c.s ?? c.skills ?? "", skills: c.s ?? c.skills ?? "",
@@ -295,7 +360,7 @@ export default function CharacterSheet() {
const rawSpells = d.sp ?? d.spells; const rawSpells = d.sp ?? d.spells;
if (rawSpells) if (rawSpells)
setSpells( setSpells(
rawSpells.map((s) => ({ rawSpells.map((s: SavedData) => ({
name: s.n ?? s.name ?? "", name: s.n ?? s.name ?? "",
notes: s.nt ?? s.notes ?? "", notes: s.nt ?? s.notes ?? "",
mp: s.mp ?? "", mp: s.mp ?? "",
@@ -369,13 +434,13 @@ export default function CharacterSheet() {
}, [collectData]); }, [collectData]);
const handleImportFile = useCallback( const handleImportFile = useCallback(
(e) => { (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (ev) => { reader.onload = (ev) => {
try { try {
applyData(JSON.parse(ev.target.result)); applyData(JSON.parse(ev.target?.result as string));
saveSheet(); saveSheet();
} catch { } catch {
alert("Could not import: invalid JSON file."); alert("Could not import: invalid JSON file.");
@@ -399,25 +464,25 @@ export default function CharacterSheet() {
}, [collectData]); }, [collectData]);
const calcHP = useCallback(() => { const calcHP = useCallback(() => {
const mig = parseInt(fields.migBase) || 6; const mig = parseInt(String(fields.migBase)) || 6;
const max = mig * 5 + level; const max = mig * 5 + level;
setFields((prev) => ({ ...prev, hpMax: max, hpCur: prev.hpCur || max })); setFields((prev) => ({ ...prev, hpMax: max, hpCur: prev.hpCur || max }));
}, [fields.migBase, level]); }, [fields.migBase, level]);
const calcMP = useCallback(() => { const calcMP = useCallback(() => {
const wlp = parseInt(fields.wlpBase) || 6; const wlp = parseInt(String(fields.wlpBase)) || 6;
const max = wlp * 5 + level; const max = wlp * 5 + level;
setFields((prev) => ({ ...prev, mpMax: max, mpCur: prev.mpCur || max })); setFields((prev) => ({ ...prev, mpMax: max, mpCur: prev.mpCur || max }));
}, [fields.wlpBase, level]); }, [fields.wlpBase, level]);
const toggleStatus = (s) => const toggleStatus = (s: string) =>
setStatuses((prev) => ({ ...prev, [s]: !prev[s] })); setStatuses((prev) => ({ ...prev, [s]: !prev[s] }));
const toggleMartial = (m) => const toggleMartial = (m: string) =>
setMartial((prev) => ({ ...prev, [m]: !prev[m] })); setMartial((prev) => ({ ...prev, [m]: !prev[m] }));
const toggleDisc = (d) => const toggleDisc = (d: string) =>
setDisciplines((prev) => ({ ...prev, [d]: !prev[d] })); setDisciplines((prev) => ({ ...prev, [d]: !prev[d] }));
const toggleFeeling = (bondIdx, feeling) => { const toggleFeeling = (bondIdx: number, feeling: string) => {
setBonds((prev) => setBonds((prev) =>
prev.map((b, i) => { prev.map((b, i) => {
if (i !== bondIdx) return b; if (i !== bondIdx) return b;
@@ -647,12 +712,12 @@ export default function CharacterSheet() {
<span className="icon"></span> Attributes <span className="icon"></span> Attributes
</div> </div>
<div className="attr-grid"> <div className="attr-grid">
{[ {([
{ label: "Dexterity", base: "dexBase", cur: "dexCur" }, { label: "Dexterity", base: "dexBase", cur: "dexCur" },
{ label: "Insight", base: "insBase", cur: "insCur" }, { label: "Insight", base: "insBase", cur: "insCur" },
{ label: "Might", base: "migBase", cur: "migCur" }, { label: "Might", base: "migBase", cur: "migCur" },
{ label: "Willpower", base: "wlpBase", cur: "wlpCur" }, { label: "Willpower", base: "wlpBase", cur: "wlpCur" },
].map(({ label, base, cur }) => ( ] as const).map(({ label, base, cur }) => (
<div key={label} className="attr-block"> <div key={label} className="attr-block">
<div className="attr-name">{label}</div> <div className="attr-name">{label}</div>
<div className="attr-inputs"> <div className="attr-inputs">
@@ -917,7 +982,7 @@ export default function CharacterSheet() {
))} ))}
</div> </div>
<div style={{ marginTop: 14 }}> <div style={{ marginTop: 14 }}>
{[ {([
{ {
slot: "Accessory", slot: "Accessory",
name: "accName", name: "accName",
@@ -946,7 +1011,7 @@ export default function CharacterSheet() {
namePh: "Weapon / shield", namePh: "Weapon / shield",
descPh: "Damage / effect", descPh: "Damage / effect",
}, },
].map(({ slot, name, desc, namePh, descPh }) => ( ] as const).map(({ slot, name, desc, namePh, descPh }) => (
<div key={slot} className="equip-row"> <div key={slot} className="equip-row">
<div className="equip-slot">{slot}</div> <div className="equip-slot">{slot}</div>
<div className="equip-fields"> <div className="equip-fields">
@@ -1346,6 +1411,7 @@ export default function CharacterSheet() {
<button <button
className="btn-load btn-import btn-lg" className="btn-load btn-import btn-lg"
onClick={() => { onClick={() => {
if (!importFileRef.current) return;
importFileRef.current.value = ""; importFileRef.current.value = "";
importFileRef.current.click(); importFileRef.current.click();
}} }}

View File

@@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import BookIndex from './BookIndex.jsx'; import BookIndex from './BookIndex';
const { title, logoText, pages } = window.__BOOK_DATA__; const { title, logoText, pages } = window.__BOOK_DATA__;
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')!).render(
<BookIndex title={title} logoText={logoText} pages={pages} /> <BookIndex title={title} logoText={logoText} pages={pages} />
); );

16
src/globals.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
declare module "*.css";
interface BookPage {
n: number;
content: string;
}
interface BookData {
title: string;
logoText: string;
pages: BookPage[];
}
interface Window {
__BOOK_DATA__: BookData;
}

View File

@@ -2,4 +2,4 @@ import React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import CharacterSheet from './CharacterSheet'; import CharacterSheet from './CharacterSheet';
createRoot(document.getElementById('root')).render(<CharacterSheet />); createRoot(document.getElementById('root')!).render(<CharacterSheet />);

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true
},
"include": ["src"]
}

View File

@@ -44,8 +44,8 @@ module.exports = (env, argv) => {
return { return {
entry: { entry: {
sheet: "./src/sheet-main.jsx", sheet: "./src/sheet-main.tsx",
book: "./src/book.js", book: "./src/book.tsx",
}, },
output: { output: {
filename: isProd ? "[name].[contenthash].js" : "[name].js", filename: isProd ? "[name].[contenthash].js" : "[name].js",
@@ -56,11 +56,13 @@ module.exports = (env, argv) => {
module: { module: {
rules: [ rules: [
{ {
test: /\.(js|jsx)$/, test: /\.(js|jsx|ts|tsx)$/,
exclude: /node_modules/, exclude: /node_modules/,
use: { use: {
loader: "babel-loader", loader: "babel-loader",
options: { presets: ["@babel/preset-react"] }, options: {
presets: ["@babel/preset-react", "@babel/preset-typescript"],
},
}, },
}, },
{ {
@@ -73,7 +75,7 @@ module.exports = (env, argv) => {
], ],
}, },
resolve: { resolve: {
extensions: [".js", ".jsx"], extensions: [".ts", ".tsx", ".js", ".jsx"],
}, },
plugins: [ plugins: [
...(isProd ...(isProd