10 Commits

Author SHA1 Message Date
4f62a567ee ci: Atomically replace share-svc binary on deploy
scp overwrites in place, which fails with ETXTBSY ("text file busy") on
repeat deploys because systemd is executing /usr/local/bin/share-svc.
Copy to a temp path, chmod, then mv it over the destination so rename(2)
swaps the dir entry without touching the busy inode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 01:47:36 +00:00
efea999a2b ci: Decode base64-encoded deploy SSH key
All checks were successful
Deploy / deploy (push) Successful in 1m16s
Storing the raw PEM as a secret mangled it (CRLF/whitespace/newline),
causing ssh to fail with "error in libcrypto" at the copy step. Store the
key base64-encoded and decode it in the workflow so the PEM round-trips
intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 00:46:53 +00:00
aed051afaa ci: Add Gitea Actions deploy workflow
Some checks failed
Deploy / deploy (push) Failing after 1m28s
Build the frontend and the Go share-service, rsync/scp both to the Caddy
host, and restart share-svc + reload caddy. Triggers on push to master
(and manual dispatch). Requires a DEPLOY_SSH_KEY secret; host/user/paths
default to the Justfile values and are overridable via repo variables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 02:16:23 +00:00
410eb3a5a8 chore: Add deploy plumbing for the share service
Add a hardened systemd unit, a Caddy reverse-proxy snippet that maps
/fabula/api/* to the loopback service, and Justfile build-server/
deploy-server recipes that build a static binary and ship + restart it.
Includes server/README documenting the API, config, and deploy steps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 02:01:37 +00:00
6609956711 chore: Add go feature to devcontainer, bump node version 2026-06-16 21:46:51 -04:00
122a4cc881 feat: Add share-link backend and wire up the sheet
Add a small Go + SQLite service (server/) that stores character-sheet
JSON blobs and returns short, content-addressed IDs, so sheets can be
shared via a compact ?s=<id> link instead of an oversized inline payload.

CharacterSheet.tsx now POSTs to the service on share and fetches by ID on
load, falling back to the self-contained ?c= inline link when the backend
is unreachable. Legacy ?c= links still decode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 01:44:15 +00:00
21c8bf5be1 perf: Shorten shared character sheet URLs
Use URL-safe base64 (base64url) for the ?c= payload so it no longer
needs percent-encoding, and prune empty string/array fields before
encoding. Decoding accepts standard base64, so existing links still load.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 01:13:12 +00:00
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
25 changed files with 930 additions and 74 deletions

View File

@@ -5,10 +5,23 @@
"resolved": "ghcr.io/anthropics/devcontainer-features/claude-code@sha256:cfc2e7d3e9fd3b9b01f8d5cb158508a884c8c0ede2e23ed10f32dea5d4ffe69a",
"integrity": "sha256:cfc2e7d3e9fd3b9b01f8d5cb158508a884c8c0ede2e23ed10f32dea5d4ffe69a"
},
"ghcr.io/devcontainers/features/node:2.0.0": {
"version": "2.0.0",
"resolved": "ghcr.io/devcontainers/features/node@sha256:fedd4c11f7adfb64283b578dddc7da906728daa25fa293351c9d913231acf12f",
"integrity": "sha256:fedd4c11f7adfb64283b578dddc7da906728daa25fa293351c9d913231acf12f"
"ghcr.io/devcontainers-community/npm-features/typescript:1": {
"version": "1.1.0",
"resolved": "ghcr.io/devcontainers-community/npm-features/typescript@sha256:13a0f63e88513a6022431c39b7ca4ec732ba0760cdb6d882638f4ddf73deb0e7",
"integrity": "sha256:13a0f63e88513a6022431c39b7ca4ec732ba0760cdb6d882638f4ddf73deb0e7",
"dependsOn": [
"ghcr.io/devcontainers/features/node"
]
},
"ghcr.io/devcontainers/features/go:1": {
"version": "1.3.4",
"resolved": "ghcr.io/devcontainers/features/go@sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032",
"integrity": "sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032"
},
"ghcr.io/devcontainers/features/node:2.1.0": {
"version": "2.1.0",
"resolved": "ghcr.io/devcontainers/features/node@sha256:586c9a6f7dd40bd3ba2cd41e7f2f88dcc31fbe5d1442afcbf07ffbc66b686857",
"integrity": "sha256:586c9a6f7dd40bd3ba2cd41e7f2f88dcc31fbe5d1442afcbf07ffbc66b686857"
},
"ghcr.io/jsburckhardt/devcontainer-features/just:1": {
"version": "1.0.0",

View File

@@ -1,8 +1,11 @@
{
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"features": {
"ghcr.io/devcontainers/features/node:2.0.0": {},
"ghcr.io/devcontainers/features/node:2.1.0": {},
"ghcr.io/devcontainers/features/go:1": {},
"ghcr.io/jsburckhardt/devcontainer-features/just:1": {},
"ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {}
"ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {},
"ghcr.io/devcontainers/features/node:2": {},
"ghcr.io/devcontainers-community/npm-features/typescript:1": {}
}
}

View File

@@ -0,0 +1,87 @@
name: Deploy
# Builds the static frontend and the Go share-service, ships both to the
# Caddy host, and restarts the service.
#
# Required repo secret:
# DEPLOY_SSH_KEY - base64-encoded private SSH key authorized for
# ${DEPLOY_USER}@${DEPLOY_HOST}. Generate with:
# base64 -w0 < deploy_key # Linux
# base64 -i deploy_key | tr -d '\n' # macOS
#
# Override host/user/paths via repo variables (Settings > Actions > Variables)
# if they ever change; the defaults below match the Justfile.
on:
push:
branches: [master]
workflow_dispatch:
env:
DEPLOY_HOST: ${{ vars.DEPLOY_HOST || 'goldfish.malzahn.lan' }}
DEPLOY_USER: ${{ vars.DEPLOY_USER || 'root' }}
WWW_ROOT: ${{ vars.WWW_ROOT || '/usr/share/caddy/public_html/fabula' }}
GO_ARCH: ${{ vars.GO_ARCH || 'amd64' }}
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# --- Build frontend ---
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build # -> dist/
# --- Build backend (static, no cgo) ---
- uses: actions/setup-go@v5
with:
go-version-file: server/go.mod
cache-dependency-path: server/go.sum
- name: Build share-svc
working-directory: server
run: CGO_ENABLED=0 GOOS=linux GOARCH="$GO_ARCH" go build -o share-svc .
# --- Deploy ---
- name: Configure SSH
run: |
command -v rsync >/dev/null || { apt-get update && apt-get install -y rsync; }
mkdir -p ~/.ssh && chmod 700 ~/.ssh
# DEPLOY_SSH_KEY is stored base64-encoded so the PEM survives the
# secret round-trip intact (no CRLF/whitespace/newline mangling).
echo "${{ secrets.DEPLOY_SSH_KEY }}" | base64 -d > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
- name: Copy frontend
run: |
rsync -az --delete -e "ssh -i ~/.ssh/deploy_key" \
dist/ "$DEPLOY_USER@$DEPLOY_HOST:$WWW_ROOT/"
- name: Copy backend binary + unit file
run: |
# Copy to a temp path then atomically rename into place. You can't
# overwrite a running executable in place (ETXTBSY / "Text file busy"),
# but rename(2) just swaps the directory entry while the old inode keeps
# serving the running process until the restart step picks up the new
# binary. chmod first so the exec bit survives the rename (scp doesn't
# preserve mode without -p).
scp -i ~/.ssh/deploy_key server/share-svc \
"$DEPLOY_USER@$DEPLOY_HOST:/usr/local/bin/share-svc.new"
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" \
'chmod 0755 /usr/local/bin/share-svc.new && mv -f /usr/local/bin/share-svc.new /usr/local/bin/share-svc'
scp -i ~/.ssh/deploy_key server/share-svc.service \
"$DEPLOY_USER@$DEPLOY_HOST:/etc/systemd/system/share-svc.service"
- name: Restart services
run: |
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" '
systemctl daemon-reload &&
systemctl enable --now share-svc &&
systemctl restart share-svc &&
systemctl reload caddy
'

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

@@ -6,8 +6,12 @@ user := "root"
host := "goldfish.malzahn.lan"
www-root := "/usr/share/caddy/public_html/fabula"
# Target architecture of the deploy host (override: `just go-arch=arm64 deploy-server`)
go-arch := "amd64"
clean:
rm -rf dist/*
rm -f server/share-svc
serve:
npm run dev
@@ -18,10 +22,21 @@ build:
deploy: build
scp -r dist/* {{ user }}@{{ host }}:{{ www-root }}/
# Build the share service as a static binary for the deploy host.
build-server:
cd server && CGO_ENABLED=0 GOOS=linux GOARCH={{ go-arch }} go build -o share-svc .
# Ship the binary + unit file and (re)start the service. Requires root on host.
deploy-server: build-server
scp server/share-svc {{ user }}@{{ host }}:/usr/local/bin/share-svc
scp server/share-svc.service {{ user }}@{{ host }}:/etc/systemd/system/share-svc.service
ssh {{ user }}@{{ host }} 'systemctl daemon-reload && systemctl enable --now share-svc && systemctl restart share-svc'
format:
npx prettier --write books/
npx prettier --write fabula-ultima-sheet.js
npx prettier --write src/
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": {
"@babel/core": "^7.29.7",
"@babel/preset-react": "^7.29.7",
"@babel/preset-typescript": "^7.29.7",
"@babel/register": "^7.29.7",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"babel-loader": "^10.1.1",
"copy-webpack-plugin": "^14.0.0",
"css-loader": "^7.1.4",
@@ -18,6 +21,7 @@
"react": "^19.2.7",
"react-dom": "^19.2.7",
"style-loader": "^4.0.0",
"typescript": "^6.0.3",
"webpack": "^5.107.2",
"webpack-cli": "^7.0.3",
"webpack-dev-server": "^5.2.4"
@@ -171,6 +175,38 @@
"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": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
@@ -181,6 +217,20 @@
"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": {
"version": "7.29.7",
"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"
}
},
"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": {
"version": "7.29.7",
"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_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": {
"version": "7.29.7",
"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"
}
},
"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": {
"version": "7.29.7",
"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"
}
},
"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": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.29.7.tgz",
@@ -389,6 +537,26 @@
"@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": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/register/-/register-7.29.7.tgz",
@@ -1396,6 +1564,26 @@
"dev": true,
"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": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz",
@@ -2751,6 +2939,13 @@
"dev": true,
"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": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -6587,6 +6782,20 @@
"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": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",

View File

@@ -1,12 +1,17 @@
{
"scripts": {
"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": {
"@babel/core": "^7.29.7",
"@babel/preset-react": "^7.29.7",
"@babel/preset-typescript": "^7.29.7",
"@babel/register": "^7.29.7",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"babel-loader": "^10.1.1",
"copy-webpack-plugin": "^14.0.0",
"css-loader": "^7.1.4",
@@ -17,6 +22,7 @@
"react": "^19.2.7",
"react-dom": "^19.2.7",
"style-loader": "^4.0.0",
"typescript": "^6.0.3",
"webpack": "^5.107.2",
"webpack-cli": "^7.0.3",
"webpack-dev-server": "^5.2.4"

Binary file not shown.

Binary file not shown.

7
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
# Compiled binary
/share-svc
# SQLite database + WAL/SHM sidecars
*.db
*.db-wal
*.db-shm

21
server/Caddyfile.snippet Normal file
View File

@@ -0,0 +1,21 @@
# Caddy config for the share-link service.
#
# The frontend (served under /fabula/) calls /fabula/api/s, but the Go service
# routes plain /api/s. `uri strip_prefix /fabula` rewrites /fabula/api/s ->
# /api/s before proxying to the service on loopback.
#
# Place this INSIDE your existing site block, and make sure this /fabula/api/*
# handler comes BEFORE the static handler that serves /fabula/* — `handle`
# blocks match in source order, first match wins.
handle /fabula/api/* {
uri strip_prefix /fabula
reverse_proxy 127.0.0.1:8090
}
# ... your existing static file handler for /fabula/* follows here, e.g.:
#
# handle /fabula/* {
# root * /usr/share/caddy/public_html
# file_server
# }

51
server/README.md Normal file
View File

@@ -0,0 +1,51 @@
# share-svc
Tiny Go + SQLite service that stores character-sheet JSON blobs and returns
short, content-addressed IDs, so sheets share as `...?s=<id>` instead of a giant
inline `?c=` payload.
## API
| Method | Path | Body | Response |
| ------ | ------------ | ----------- | ------------------- |
| POST | `/api/s` | sheet JSON | `{"id":"<id>"}` |
| GET | `/api/s/{id}`| | sheet JSON |
IDs are `base64url(sha256(json))[:12]`, so identical sheets dedupe and re-sharing
is idempotent. Payloads are stored deflated in a single SQLite file.
### Config (env)
| Var | Default | Notes |
| ------------- | ------------- | ------------------------------------------------ |
| `ADDR` | `:8090` | Listen address. The unit binds `127.0.0.1:8090`. |
| `DB_PATH` | `shares.db` | SQLite file path. |
| `CORS_ORIGIN` | _(unset)_ | Set to allow a cross-origin dev frontend. |
## Deploy
From the repo root:
```sh
just deploy-server # build (linux/amd64) + ship + restart
just go-arch=arm64 deploy-server # if the host is arm64
```
That installs `/usr/local/bin/share-svc` and `share-svc.service`, then
`daemon-reload` + `enable --now` + `restart`. The SQLite DB lives in
`/var/lib/share-svc/` (created by the unit's `StateDirectory`).
### One-time Caddy setup
Add the `/fabula/api/*` reverse-proxy block from `Caddyfile.snippet` to your site
block (before the static `/fabula/*` handler) and reload Caddy.
## Local dev
`webpack serve` (port 8080) does not serve `/api/*`, so `?s=` sharing falls back
to inline `?c=` links. To exercise short links locally, run the service and point
the frontend at it:
```sh
CORS_ORIGIN=http://localhost:8080 go run ./server
```

16
server/go.mod Normal file
View File

@@ -0,0 +1,16 @@
module github.com/drew/fabula-ultima-html/server
go 1.26.4
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.52.0 // indirect
)

21
server/go.sum Normal file
View File

@@ -0,0 +1,21 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=

199
server/main.go Normal file
View File

@@ -0,0 +1,199 @@
// Command share-svc is a tiny HTTP service that stores character-sheet JSON
// blobs and hands back short, content-addressed IDs so they can be shared via a
// compact URL (e.g. ...?s=a1b2c3d4) instead of stuffing the whole sheet into the
// query string.
//
// API:
//
// POST /api/s body: sheet JSON -> 200 {"id":"<id>"}
// GET /api/s/{id} -> 200 sheet JSON
//
// Storage is a single SQLite file; payloads are deflated on the way in and
// inflated on the way out. IDs are derived from the SHA-256 of the JSON, so
// posting the same sheet twice yields the same ID (free dedupe + idempotency).
package main
import (
"bytes"
"compress/flate"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"os"
"time"
_ "modernc.org/sqlite"
)
const (
// idLen is how many base64url chars of the content hash form an ID.
// 12 chars ~= 72 bits, far more than enough to avoid collisions here.
idLen = 12
// maxBody caps an accepted sheet payload (pre-compression).
maxBody = 512 << 10 // 512 KiB
)
type store struct {
db *sql.DB
}
func openStore(path string) (*store, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
// Single writer; WAL keeps reads fast and concurrent.
if _, err := db.Exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;`); err != nil {
return nil, err
}
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS shares (
id TEXT PRIMARY KEY,
data BLOB NOT NULL,
created INTEGER NOT NULL
)`); err != nil {
return nil, err
}
return &store{db: db}, nil
}
// put stores raw (uncompressed) JSON bytes and returns its short ID. Storing the
// same bytes again is a no-op that returns the same ID.
func (s *store) put(raw []byte) (string, error) {
sum := sha256.Sum256(raw)
id := base64.RawURLEncoding.EncodeToString(sum[:])[:idLen]
deflated, err := deflate(raw)
if err != nil {
return "", err
}
// INSERT OR IGNORE: a matching ID already holds identical content.
if _, err := s.db.Exec(
`INSERT OR IGNORE INTO shares (id, data, created) VALUES (?, ?, ?)`,
id, deflated, time.Now().Unix(),
); err != nil {
return "", err
}
return id, nil
}
// get returns the stored JSON bytes for an ID, or sql.ErrNoRows if unknown.
func (s *store) get(id string) ([]byte, error) {
var deflated []byte
err := s.db.QueryRow(`SELECT data FROM shares WHERE id = ?`, id).Scan(&deflated)
if err != nil {
return nil, err
}
return inflate(deflated)
}
func deflate(b []byte) ([]byte, error) {
var buf bytes.Buffer
w, err := flate.NewWriter(&buf, flate.BestCompression)
if err != nil {
return nil, err
}
if _, err := w.Write(b); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func inflate(b []byte) ([]byte, error) {
r := flate.NewReader(bytes.NewReader(b))
defer r.Close()
return io.ReadAll(r)
}
func main() {
addr := envOr("ADDR", ":8090")
dbPath := envOr("DB_PATH", "shares.db")
corsOrigin := os.Getenv("CORS_ORIGIN") // empty = same-origin only
st, err := openStore(dbPath)
if err != nil {
log.Fatalf("open store: %v", err)
}
mux := http.NewServeMux()
mux.HandleFunc("POST /api/s", func(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBody))
if err != nil {
http.Error(w, "payload too large", http.StatusRequestEntityTooLarge)
return
}
if !json.Valid(raw) {
http.Error(w, "body must be JSON", http.StatusBadRequest)
return
}
id, err := st.put(raw)
if err != nil {
log.Printf("put: %v", err)
http.Error(w, "storage error", http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"id": id})
})
mux.HandleFunc("GET /api/s/{id}", func(w http.ResponseWriter, r *http.Request) {
raw, err := st.get(r.PathValue("id"))
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "not found", http.StatusNotFound)
return
}
if err != nil {
log.Printf("get: %v", err)
http.Error(w, "storage error", http.StatusInternalServerError)
return
}
// Shares are immutable, so let clients/proxies cache aggressively.
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("Content-Type", "application/json")
w.Write(raw)
})
handler := withCORS(corsOrigin, mux)
srv := &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
}
log.Printf("share-svc listening on %s (db=%s)", addr, dbPath)
log.Fatal(srv.ListenAndServe())
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
// withCORS optionally allows a cross-origin frontend (handy when webpack-dev
// runs on a different port than the API). When origin is empty it's a no-op.
func withCORS(origin string, next http.Handler) http.Handler {
if origin == "" {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}

35
server/share-svc.service Normal file
View File

@@ -0,0 +1,35 @@
[Unit]
Description=Fabula Ultima share-link service
Documentation=https://git.illaoi.pro/drew/fabula-ultima-html
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/share-svc
# Bind to loopback only; Caddy reverse-proxies public traffic to it.
Environment=ADDR=127.0.0.1:8090
Environment=DB_PATH=/var/lib/share-svc/shares.db
Restart=on-failure
RestartSec=2
# Run as an ephemeral, unprivileged user. StateDirectory creates and chowns
# /var/lib/share-svc so the SQLite file persists across restarts.
DynamicUser=yes
StateDirectory=share-svc
# Hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6
RestrictNamespaces=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
[Install]
WantedBy=multi-user.target

View File

@@ -1,6 +1,12 @@
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 total = pageNums.length;
@@ -13,9 +19,9 @@ export default function BookIndex({ title, logoText, pages }) {
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);
if (idx === -1) return;
const sec = document.getElementById(`page-${n}`);
@@ -62,8 +68,9 @@ export default function BookIndex({ title, logoText, pages }) {
// Keyboard navigation
useEffect(() => {
const handleKeyDown = e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
const handleKeyDown = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
setCurrentIdx(prev => {
if ((e.key === 'ArrowLeft' || e.key === 'ArrowUp') && prev > 0) {
goTo(pageNums[prev - 1], true, true);

View File

@@ -25,7 +25,71 @@ const DISCIPLINES = [
"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: "",
charPronouns: "",
charIdentity: "",
@@ -64,27 +128,42 @@ const BLANK_FIELDS = {
ohDesc: "",
};
const BLANK_BONDS = Array.from({ length: 6 }, () => ({
const BLANK_BONDS: Bond[] = Array.from({ length: 6 }, () => ({
name: "",
feelings: [],
}));
async function compressToBase64(str) {
// URL-safe base64 (RFC 4648 §5): avoids +, /, = so the result needs no
// percent-encoding in a query string. Accepts standard base64 on decode too,
// so links shared before this change still load.
function bytesToBase64Url(bytes: Uint8Array) {
let binary = "";
for (let i = 0; i < bytes.length; i++)
binary += String.fromCharCode(bytes[i]);
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
function base64UrlToBytes(b64: string) {
let std = b64.replace(/-/g, "+").replace(/_/g, "/");
if (std.length % 4) std += "=".repeat(4 - (std.length % 4));
return Uint8Array.from(atob(std), (c) => c.charCodeAt(0));
}
async function compressToBase64(str: string) {
const stream = new CompressionStream("deflate-raw");
const writer = stream.writable.getWriter();
writer.write(new TextEncoder().encode(str));
writer.close();
const buf = await new Response(stream.readable).arrayBuffer();
const bytes = new Uint8Array(buf);
let binary = "";
for (let i = 0; i < bytes.length; i++)
binary += String.fromCharCode(bytes[i]);
return btoa(binary);
return bytesToBase64Url(new Uint8Array(buf));
}
async function decompressFromBase64(b64) {
async function decompressFromBase64(b64: string) {
try {
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const bytes = base64UrlToBytes(b64);
const stream = new DecompressionStream("deflate-raw");
const writer = stream.writable.getWriter();
writer.write(bytes);
@@ -96,6 +175,23 @@ async function decompressFromBase64(b64) {
}
}
// Drop empty strings and empty arrays before encoding: a sheet is mostly blank
// text fields, and applyData() restores any missing key to its default.
function pruneEmpty(data: Record<string, unknown>) {
return Object.fromEntries(
Object.entries(data).filter(
([, v]) => v !== "" && !(Array.isArray(v) && v.length === 0),
),
);
}
// Resolve a share-service path relative to the current page so it works under
// any base path (e.g. /fabula/) where Caddy reverse-proxies /api/* to the
// backend. Returns e.g. https://host/fabula/api/s.
function apiURL(path: string) {
return new URL(path, window.location.href).toString();
}
export default function CharacterSheet() {
const [activeTab, setActiveTab] = useState("main");
const [urlMode, setUrlMode] = useState(false);
@@ -103,15 +199,15 @@ export default function CharacterSheet() {
const [copyStatus, setCopyStatus] = useState(false);
const [level, setLevel] = useState(1);
const [fp, setFp] = useState(0);
const [fields, setFields] = useState(BLANK_FIELDS);
const [bonds, setBonds] = useState(BLANK_BONDS);
const [primaryClasses, setPrimaryClasses] = useState([]);
const [otherClasses, setOtherClasses] = useState([]);
const [spells, setSpells] = useState([]);
const [statuses, setStatuses] = useState({});
const [martial, setMartial] = useState({});
const [disciplines, setDisciplines] = useState({});
const [theme, setTheme] = useState(
const [fields, setFields] = useState<Fields>(BLANK_FIELDS);
const [bonds, setBonds] = useState<Bond[]>(BLANK_BONDS);
const [primaryClasses, setPrimaryClasses] = useState<ClassEntry[]>([]);
const [otherClasses, setOtherClasses] = useState<ClassEntry[]>([]);
const [spells, setSpells] = useState<Spell[]>([]);
const [statuses, setStatuses] = useState<CheckMap>({});
const [martial, setMartial] = useState<CheckMap>({});
const [disciplines, setDisciplines] = useState<CheckMap>({});
const [theme, setTheme] = useState<string>(
() =>
localStorage.getItem("fabulaUltimaTheme") ||
(window.matchMedia("(prefers-color-scheme: light)").matches
@@ -119,7 +215,7 @@ export default function CharacterSheet() {
: "dark"),
);
const importFileRef = useRef(null);
const importFileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
document.documentElement.dataset.theme = theme;
@@ -127,14 +223,15 @@ export default function CharacterSheet() {
}, [theme]);
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 hpMax = parseInt(fields.hpMax) || 0;
const hpCur = parseInt(fields.hpCur) || 0;
const hpMax = parseInt(String(fields.hpMax)) || 0;
const hpCur = parseInt(String(fields.hpCur)) || 0;
const inCrisis = hpMax > 0 && hpCur <= Math.floor(hpMax / 2);
const fpTotal = Math.max(10, fp);
@@ -210,7 +307,7 @@ export default function CharacterSheet() {
],
);
const applyData = useCallback((d) => {
const applyData = useCallback((d: SavedData) => {
setFields({
charName: d.n ?? d.name ?? "",
charPronouns: d.pn ?? d.pronouns ?? "",
@@ -266,7 +363,7 @@ export default function CharacterSheet() {
const rawBonds = d.bo ?? d.bonds;
if (rawBonds)
setBonds(
rawBonds.map((b) => ({
rawBonds.map((b: SavedData) => ({
name: b.n ?? b.name ?? "",
feelings: b.f ?? b.feelings ?? [],
})),
@@ -275,7 +372,7 @@ export default function CharacterSheet() {
const rawPrimary = d.pc ?? d.primaryClasses;
if (rawPrimary)
setPrimaryClasses(
rawPrimary.map((c) => ({
rawPrimary.map((c: SavedData) => ({
name: c.n ?? c.name ?? "",
benefits: c.b ?? c.benefits ?? "",
skills: c.s ?? c.skills ?? "",
@@ -285,7 +382,7 @@ export default function CharacterSheet() {
const rawOther = d.oc ?? d.otherClasses;
if (rawOther)
setOtherClasses(
rawOther.map((c) => ({
rawOther.map((c: SavedData) => ({
name: c.n ?? c.name ?? "",
benefits: c.b ?? c.benefits ?? "",
skills: c.s ?? c.skills ?? "",
@@ -295,7 +392,7 @@ export default function CharacterSheet() {
const rawSpells = d.sp ?? d.spells;
if (rawSpells)
setSpells(
rawSpells.map((s) => ({
rawSpells.map((s: SavedData) => ({
name: s.n ?? s.name ?? "",
notes: s.nt ?? s.notes ?? "",
mp: s.mp ?? "",
@@ -307,8 +404,23 @@ export default function CharacterSheet() {
// Init
useEffect(() => {
const encoded = new URLSearchParams(window.location.search).get("c");
if (encoded) {
const params = new URLSearchParams(window.location.search);
const shortId = params.get("s");
const encoded = params.get("c");
if (shortId) {
// Short link: fetch the sheet JSON from the share service.
fetch(apiURL("api/s/" + encodeURIComponent(shortId)))
.then((res) => {
if (!res.ok) throw new Error(String(res.status));
return res.json();
})
.then((data) => {
applyData(data);
setUrlMode(true);
})
.catch(() => tryAutoLoad());
} else if (encoded) {
// Legacy/offline self-contained link with the data inline.
decompressFromBase64(encoded)
.then((json) => {
applyData(JSON.parse(json));
@@ -369,13 +481,13 @@ export default function CharacterSheet() {
}, [collectData]);
const handleImportFile = useCallback(
(e) => {
const file = e.target.files[0];
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
applyData(JSON.parse(ev.target.result));
applyData(JSON.parse(ev.target?.result as string));
saveSheet();
} catch {
alert("Could not import: invalid JSON file.");
@@ -387,37 +499,48 @@ export default function CharacterSheet() {
);
const copyShareURL = useCallback(async () => {
const encoded = await compressToBase64(JSON.stringify(collectData()));
await navigator.clipboard.writeText(
window.location.origin +
window.location.pathname +
"?c=" +
encodeURIComponent(encoded),
);
const json = JSON.stringify(pruneEmpty(collectData()));
const base = window.location.origin + window.location.pathname;
let shareURL: string;
try {
// Preferred: store the sheet server-side and share a short ?s=<id> link.
const res = await fetch(apiURL("api/s"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: json,
});
if (!res.ok) throw new Error(String(res.status));
const { id } = await res.json();
shareURL = base + "?s=" + id;
} catch {
// Backend unreachable: fall back to a self-contained inline ?c= link.
shareURL = base + "?c=" + (await compressToBase64(json));
}
await navigator.clipboard.writeText(shareURL);
setCopyStatus(true);
setTimeout(() => setCopyStatus(false), 2000);
}, [collectData]);
const calcHP = useCallback(() => {
const mig = parseInt(fields.migBase) || 6;
const mig = parseInt(String(fields.migBase)) || 6;
const max = mig * 5 + level;
setFields((prev) => ({ ...prev, hpMax: max, hpCur: prev.hpCur || max }));
}, [fields.migBase, level]);
const calcMP = useCallback(() => {
const wlp = parseInt(fields.wlpBase) || 6;
const wlp = parseInt(String(fields.wlpBase)) || 6;
const max = wlp * 5 + level;
setFields((prev) => ({ ...prev, mpMax: max, mpCur: prev.mpCur || max }));
}, [fields.wlpBase, level]);
const toggleStatus = (s) =>
const toggleStatus = (s: string) =>
setStatuses((prev) => ({ ...prev, [s]: !prev[s] }));
const toggleMartial = (m) =>
const toggleMartial = (m: string) =>
setMartial((prev) => ({ ...prev, [m]: !prev[m] }));
const toggleDisc = (d) =>
const toggleDisc = (d: string) =>
setDisciplines((prev) => ({ ...prev, [d]: !prev[d] }));
const toggleFeeling = (bondIdx, feeling) => {
const toggleFeeling = (bondIdx: number, feeling: string) => {
setBonds((prev) =>
prev.map((b, i) => {
if (i !== bondIdx) return b;
@@ -647,12 +770,12 @@ export default function CharacterSheet() {
<span className="icon"></span> Attributes
</div>
<div className="attr-grid">
{[
{([
{ label: "Dexterity", base: "dexBase", cur: "dexCur" },
{ label: "Insight", base: "insBase", cur: "insCur" },
{ label: "Might", base: "migBase", cur: "migCur" },
{ label: "Willpower", base: "wlpBase", cur: "wlpCur" },
].map(({ label, base, cur }) => (
] as const).map(({ label, base, cur }) => (
<div key={label} className="attr-block">
<div className="attr-name">{label}</div>
<div className="attr-inputs">
@@ -917,7 +1040,7 @@ export default function CharacterSheet() {
))}
</div>
<div style={{ marginTop: 14 }}>
{[
{([
{
slot: "Accessory",
name: "accName",
@@ -946,7 +1069,7 @@ export default function CharacterSheet() {
namePh: "Weapon / shield",
descPh: "Damage / effect",
},
].map(({ slot, name, desc, namePh, descPh }) => (
] as const).map(({ slot, name, desc, namePh, descPh }) => (
<div key={slot} className="equip-row">
<div className="equip-slot">{slot}</div>
<div className="equip-fields">
@@ -1346,6 +1469,7 @@ export default function CharacterSheet() {
<button
className="btn-load btn-import btn-lg"
onClick={() => {
if (!importFileRef.current) return;
importFileRef.current.value = "";
importFileRef.current.click();
}}

View File

@@ -1,8 +1,8 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import BookIndex from './BookIndex.jsx';
import BookIndex from './BookIndex';
const { title, logoText, pages } = window.__BOOK_DATA__;
createRoot(document.getElementById('root')).render(
createRoot(document.getElementById('root')!).render(
<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 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 {
entry: {
sheet: "./src/sheet-main.jsx",
book: "./src/book.js",
sheet: "./src/sheet-main.tsx",
book: "./src/book.tsx",
},
output: {
filename: isProd ? "[name].[contenthash].js" : "[name].js",
@@ -56,11 +56,13 @@ module.exports = (env, argv) => {
module: {
rules: [
{
test: /\.(js|jsx)$/,
test: /\.(js|jsx|ts|tsx)$/,
exclude: /node_modules/,
use: {
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: {
extensions: [".js", ".jsx"],
extensions: [".ts", ".tsx", ".js", ".jsx"],
},
plugins: [
...(isProd