Compare commits
10 Commits
f462205463
...
shorten-sh
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f62a567ee | |||
| efea999a2b | |||
| aed051afaa | |||
| 410eb3a5a8 | |||
| 6609956711 | |||
| 122a4cc881 | |||
| 21c8bf5be1 | |||
| 6461039bd7 | |||
| 0ba87ac547 | |||
| 406641c522 |
@@ -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",
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
87
.gitea/workflows/deploy.yaml
Normal file
87
.gitea/workflows/deploy.yaml
Normal 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
6
.githooks/pre-commit
Executable 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
|
||||
21
Justfile
21
Justfile
@@ -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
209
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
BIN
pdf/Fabula_Ultima_-_Natural_Fantasy_Atlas_ENG_v1_1.pdf
Normal file
BIN
pdf/Fabula_Ultima_-_Natural_Fantasy_Atlas_ENG_v1_1.pdf
Normal file
Binary file not shown.
BIN
pdf/Fabula_Ultima_TTJRPG.pdf
Normal file
BIN
pdf/Fabula_Ultima_TTJRPG.pdf
Normal file
Binary file not shown.
7
server/.gitignore
vendored
Normal file
7
server/.gitignore
vendored
Normal 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
21
server/Caddyfile.snippet
Normal 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
51
server/README.md
Normal 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
16
server/go.mod
Normal 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
21
server/go.sum
Normal 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
199
server/main.go
Normal 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
35
server/share-svc.service
Normal 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
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}}
|
||||
@@ -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
16
src/globals.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@@ -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
18
tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user