11 Commits

Author SHA1 Message Date
7f81f85735 feat: Add template spell picker from spells.yml; polish spell table
All checks were successful
Deploy / deploy (push) Successful in 1m20s
- Add yaml-loader; import data/spells.yml at build time
- Add SpellTemplate/SpellsFile types to globals.d.ts
- Add 'Add Template Spell' button that opens a modal picker pre-filling
  all spell fields from the YAML data
- Move spell data files into data/ directory
- Split spell rows into inputs row + full-width notes row (colspan=6)
- Shrink delete column to fit-content; bold spell name input
- Add class column to spell table; change MP cost to free-text input
- Auto-resize spell notes textarea on load and on input
- Add 10px padding between spells for visual separation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 17:54:36 -04:00
d655ad4afc fix: Expand spell notes textarea full-width; add inter-spell padding
Split each spell into two table rows: the inputs row (name, MP, targets,
duration, delete) and a full-width notes row (colspan=5). Adds 10px
padding above/below each spell for visual separation between entries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 17:54:36 -04:00
5643b31fb0 fix: Auto-resize spell/class textareas on load; shrink delete column
- Add autoResize helper and wire it via ref callback (mount) and onInput
  (typing) to spell notes and class skills textareas
- Change .spell-del-col from width:40px to width:1px + white-space:nowrap
  so the delete button column shrink-wraps to its content

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 17:54:36 -04:00
85c02d7f7f ci: Atomically replace share-svc binary on deploy
All checks were successful
Deploy / deploy (push) Successful in 48s
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-17 21:48:21 -04:00
de37809eec fix: Copy share URL over HTTP via execCommand fallback
Some checks failed
Deploy / deploy (push) Failing after 44s
navigator.clipboard is only available in a secure context (HTTPS or
localhost), so the Copy URL button threw over plain HTTP. Fall back to
the deprecated document.execCommand("copy") when the Clipboard API is
unavailable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 01:41:17 +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
22 changed files with 1328 additions and 118 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
'

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,6 +22,16 @@ 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 src/

0
data/spells.json Normal file
View File

50
data/spells.schema.json Normal file
View File

@@ -0,0 +1,50 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "data/spells.schema.json",
"title": "Spell Sheet",
"description": "A collection of Fabula Ultima spells and arcana entries.",
"type": "object",
"properties": {
"spells": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name of the spell or arcana."
},
"cost": {
"type": "string",
"minimum": 0,
"description": "MP cost to cast the spell."
},
"targets": {
"type": "string",
"description": "Who or what the spell affects (e.g. \"One creature\", \"All enemies\")."
},
"duration": {
"type": "string",
"description": "How long the spell's effect lasts (e.g. \"Instantaneous\", \"Scene\")."
},
"description": {
"type": "string",
"description": "Full effect description and any special rules for the spell."
},
"class": {
"type": "string",
"enum": ["arcanist", "chimerist", "elementalist", "entropist", "spiritist"],
"description": "The magic-using class this spell belongs to."
},
"offensive": {
"type": "boolean",
"description": "Whether the spell is offensive in nature."
}
},
"required": ["name", "cost", "targets", "duration", "description", "class"],
"additionalProperties": false
}
}
},
"required": ["spells"]
}

287
data/spells.yml Normal file
View File

@@ -0,0 +1,287 @@
spells:
- name: Elemental Shroud
cost: 5 x T
targets: Up to three creatures
duration: scene
description: "You weave magical energy and protect the targets from the fury of the elements. Choose a damage type: air, bolt, earth, fire or ice. Until this spell ends, each target gains Resistance against the chosen damage type."
class: elementalist
- name: Elemental Weapon
cost: "10"
targets: One weapon
duration: Scene
description: "You imbue a weapon with elemental energy. Choose a damage type: air, bolt, earth, fire, or ice. Until this spell ends, all damage dealt by the weapon becomes of the chosen damage type. If you have that weapon equipped while you cast this spell, you may perform a free attack with it as part of the same action. This spell can only be cast on a weapon equipped by a willing creature."
class: elementalist
- name: Flare
cost: "20"
targets: One creature
duration: Instantaneous
description: "You channel a single ray of fire towards your foe, its temperature so high that it will pierce through most defenses. The target suffers【 HR + 25】 fire damage. Damage dealt by this spell ignores Resistances."
class: elementalist
offensive: true
- name: Fulgur
cost: "10 x T"
targets: Up to three creatures
duration: Instantaneous
description: "You weave electricity into a wave of crackling bolts. Each target hit by this spell suffers 【HR + 15】 bolt damage. Opportunity: Each target hit by this spell suffers dazed."
class: elementalist
offensive: true
- name: Glacies
cost: 10 x T
targets: Up to three creatures
duration: Instantaneous
description: "You coat your foes under a thick layer of frost. Each target hit by this spell suffers【 HR + 15】 ice damage. Opportunity: Each target hit by this spell suffers slow."
class: elementalist
offensive: true
- name: Iceberg
cost: "20"
targets: One creature
duration: Instantaneous
description: "A pillar of ice magic envelops your foe, suddenly dropping their body temperature to a critical level. The target suffers【 HR + 25】 ice damage. Damage dealt by this spell ignores Resistances."
class: elementalist
offensive: true
- name: Ignis
cost: 10 x T
targets: Up to three creatures
duration: Instantaneous
description: "You unleash a searing barrage against your foes, conjuring flames out of thin air. Each target hit by this spell suffers【 HR + 15】 fire damage. Opportunity: Each target hit by this spell suffers shaken."
class: elementalist
offensive: true
- name: Soaring Strike
cost: "10"
targets: Self
duration: Instantaneous
description: "The wind carries your strikes across the battlefield. You may immediately perform a free attack with a melee weapon you have equipped. This attack may target creatures that can only be targeted by ranged attacks. If you used a weapon belonging to the brawling or spear Category for this attack, it deals 5 extra damage. If you hit a flying target with this attack, you may force them to land immediately."
class: elementalist
- name: Terra
cost: 10 x T
targets: Up to three creatures
duration: Instantaneous
description: "Spires of jagged rock erupt from the ground beneath your foes, closing around them. Each target hit by this spell suffers【 HR + 15】 earth damage. This spell cannot target creatures who are flying, floating, falling, or otherwise in mid-air. Opportunity: Each target hit by this spell performs one fewer action on their next turn (to a minimum of 0 actions)."
class: elementalist
offensive: true
- name: Thunderbolt
cost: "20"
targets: One creature
duration: Instantaneous
description: "You send lightning striking at your foe. The target suffers【 HR + 25】 bolt damage. Damage dealt by this spell ignores Resistances."
class: elementalist
offensive: true
- name: Ventus
cost: 10 x T
targets: One creature
duration: Instantaneosu
description: "You summon the power of winds against your enemy. Each target hit by this spell suffers【 HR + 15】 air damage. Opportunity: Each flying target hit by this spell is forced to land immediately."
class: elementalist
offensive: true
- name: Acceleration
cost: "20"
targets: One creature
duration: Scene
description: "You bend the fabric of time. Until this spell ends, the target gains the ability to perform a single additional action during each of their turns. Once the target has performed a total of two additional actions granted by this spell, this spell ends."
class: entropist
offensive: false
- name: Anomaly
cost: "20"
targets: One creature
duration: Scene
description: "You alter the very nature of your target. Until this spell ends, if the target would suffer damage of a type they Absorb or are Immune to, they are instead treated as if they were Vulnerable to that damage type. Once that happens, this spell ends."
class: entropist
offensive: true
- name: Dark Weapon
cost: "10"
targets: One equipped weapon
duration: Scene
description: "You imbue a weapon with dark energy. Until this spell ends, all damage dealt by the weapon becomes of the dark type. If you have that weapon equipped while you cast this spell, you may perform a free attack with it as part of the same action. This spell can only be cast on a weapon equipped by a willing creature."
class: entropist
offensive: false
- name: Dispel
cost: "10"
targets: One creature
duration: Instantaneous
description: "You release a wave of negative energy and cleanse all magic from a creature. If the target is affected by one or more spells with a duration of Scene, they are no longer affected by any of those spells instead."
class: entropist
offensive: false
- name: Divination
cost: "10"
targets: Self
duration: Scene
description: "You glimpse briefly into the future. Until this spell ends, after a creature you can see performs a Check, if it was not a fumble nor a critical success, you may force that creature to reroll both dice. Once you have forced two rerolls this way, this spell ends."
class: entropist
offensive: false
- name: Drain Spirit
cost: "5"
targets: One creature
duration: Instantaneous
description: "You consume a creature's psyche. The target loses【 HR + 15】 Mind Points. Then, you recover an amount of Mind Points equal to half the Mind Points loss they suffered (if the loss was reduced to 0 in some way, you recover none)."
class: entropist
offensive: true
- name: Drain Vigor
cost: "10"
targets: One creature
duration: Instantaneous
description: "You steal another creature's life force. The target suffers【 HR + 15】 dark damage. Then, you recover an amount of Hit Points equal to half the Hit Points loss they suffered (if the loss was reduced to 0 in some way, you recover none)."
class: entropist
offensive: true
- name: Gamble
cost: Up to 20
targets: Special
duration: Scene
description: |
You summon a vortex of chaotic energy. Roll your current Willpower die once for every 10 Mind Points spent while casting this spell, then keep the single die you prefer: the number on that die determines the effects of this spell.
1) You lose half of your current Hit Points and half of your current Mind Points.
2-3) Each creature present on the scene, including yourself, suffers poisoned.
4-6) Each creature present on the scene, including yourself, suffers slow.
7-8) Choose up to three creatures you can see: each of them recovers 50 Hit Points and also recovers from all status effects.
9+) Choose any number of creatures you can see: each of them suffers 30 damage. The damage type is determined randomly by rolling a d6: 1. air 2. bolt 3. dark 4. earth 5. fire 6. poison
class: entropist
offensive: false
- name: Mirror
cost: "10"
targets: One creature
duration: Scene
description: "You twist the laws of magic. Until this spell ends, if an offensive (r) spell is cast on the target, the creature who cast that offensive spell will be targeted in their stead (any other targets of the offensive spell will be targeted as normal). Once that happens, this spell ends."
class: entropist
offensive: false
- name: Omega
cost: "20"
targets: One creature
duration: Instantaneous
description: "You invoke doom on your foe, turning strength into frailty. The target loses an amount of Hit Points equal to【 20 + half the target's level】"
class: entropist
offensive: true
- name: Stop
cost: "10"
targets: One creature
duration: Instantaneous
description: "You trap a foe inside a circle of altered time and space. The target will perform one fewer action on their next turn (to a minimum of 0 actions)."
class: entropist
offensive: true
- name: Umbra
cost: 10 x T
targets: Up to three creatures
duration: Scene
description: "A storm of dark energy turns matter into ash. Each target hit by this spell suffers 【HR + 15】 dark damage. Opportunity: Each target hit by this spell suffers weak."
class: entropist
offensive: true
- name: Vortex
cost: "10"
targets: Self
duration: Scene
description: "A roaring gale surrounds you, blowing away arrows and bullets. Until this spell ends, you gain a +2 bonus to your Defense against ranged attacks."
class: elementalist
- name: Aura
cost: 5 x T
targets: Up to three creatures
duration: scene
description: You project your soul outside your body and direct it to surround the targets, shielding them from dangerous magic. Until this spell ends, each target may treat their Magic Defense as being equal to 12 against any effects that target it (they are still free to use their normal Defense score if higher than 12).
class: spiritist
- name: Awaken
cost: "20"
targets: One creature
duration: scene
description: "You allow a creature to focus their vital energy into accomplishing what they previously could not. Choose one Attribute: Dexterity, Insight, Might, or Willpower. Until this spell ends, the target treats the chosen Attribute as if it were one die size higher (up to a maximum of d12)."
class: spiritist
- name: Barrier
cost: 5 x T
targets: Up to three creatures
duration: scene
description: You project your soul outside your body and weave it into a barrier to protect the targets from attacks. Until this spell ends, each target may treat their Defense as being equal to 12 against any effects that target it (they are still free to use their normal Defense score if higher than 12).
class: spiritist
- name: Cleanse
cost: 5 x T
targets: Up to three creatures
duration: Instantaneous
description: You strengthen and purify the soul energy coursing through your companions. Each target recovers from all status effects.
class: spiritist
- name: Enrage
cost: rr 10
targets: One creature
duration: Instantaneous
description: You cause a creature to lose any semblance of temper and act brazenly. The target suffers enraged and cannot perform the Guard or Spell actions during their next turn.
class: spiritist
offensive: true
- name: Hallucination
cost: rr 5 x T
targets: Up to three creatures
duration: Instantaneous
description: "You alter the senses of your enemies, causing them to experience bizarre or frightening hallucinations. Choose dazed or shaken: you inflict the chosen status effect on each target hit by this spell."
class: spiritist
offensive: true
- name: Heal
cost: 10 x T
targets: Up to three creatures
duration: Instantaneous
description: "You invigorate your companions, soothing their pain and healing their fatigue. Each target recovers 40 Hit Points. This amount increases to 50 Hit Points if you are level 20 or higher, or to 60 Hit Points if you are level 40 or higher."
class: spiritist
offensive: false
- name: Lux
cost: 10 x T
targets: Up to three creatures
duration: Instantaneous
description: "You focus your inner energy into a barrage of blinding soul rays. Each target hit by this spell suffers【 HR + 15】 light damage. Opportunity: Each target hit by this spell suffers dazed."
class: spiritist
offensive: true
- name: Mercy
cost: "20"
targets: One creature
duration: Scene
description: "You strengthen the heart of a creature against suffering and despair. Until this spell ends, if the target would be reduced to 0 Hit Points, they are instead left standing with exactly 1 Hit Point. Once that happens, this spell ends."
class: spiritist
offensive: false
- name: Reinforce
cost: 5 x T
targets: Up to three creatures
duration: Scene
description: "You protect the targets from attacks that would corrupt their body and spirit. Choose dazed, enraged, poisoned, shaken, slow, or weak. Until this spell ends, each target becomes immune to the chosen status effect."
class: spiritist
offensive: false
- name: Soul Weapon
cost: "10"
targets: One equipped weapon
duration: Scene
description: "You imbue a weapon with the cleansing energy of your spirit. Until this spell ends, all damage dealt by the weapon becomes of the light type. If you have that weapon equipped while you cast this spell, you may perform a free attack with it as part of the same action. This spell can only be cast on a weapon equipped by a willing creature."
class: spiritist
offensive: false
- name: Torpor
cost: 5 x T
targets: Up to three creatures
duration: Instantaneous
description: "You smother the soul energy coursing through the bodies of your foes, hindering their movements. Choose slow or weak: you inflict the chosen status effect on each target hit by this spell."
class: spiritist
offensive: true

40
package-lock.json generated
View File

@@ -24,7 +24,8 @@
"typescript": "^6.0.3",
"webpack": "^5.107.2",
"webpack-cli": "^7.0.3",
"webpack-dev-server": "^5.2.4"
"webpack-dev-server": "^5.2.4",
"yaml-loader": "^0.9.0"
}
},
"node_modules/@babel/code-frame": {
@@ -4243,6 +4244,13 @@
"node": ">=0.10.0"
}
},
"node_modules/javascript-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/javascript-stringify/-/javascript-stringify-2.1.0.tgz",
"integrity": "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==",
"dev": true,
"license": "MIT"
},
"node_modules/jest-regex-util": {
"version": "30.4.0",
"resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz",
@@ -7233,6 +7241,36 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yaml-loader": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/yaml-loader/-/yaml-loader-0.9.0.tgz",
"integrity": "sha512-N0bPfDR42qCruELfmpF6Q8XnDu1elTULGjvVn7u6rsfDa8WhlmJbjXQRh+x5TGmpOqw8R9qfiUgc3aKP+8Hd4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"javascript-stringify": "^2.0.1",
"yaml": "^2.0.0"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -25,6 +25,7 @@
"typescript": "^6.0.3",
"webpack": "^5.107.2",
"webpack-cli": "^7.0.3",
"webpack-dev-server": "^5.2.4"
"webpack-dev-server": "^5.2.4",
"yaml-loader": "^0.9.0"
}
}

69
scripts/anchors.py Normal file
View File

@@ -0,0 +1,69 @@
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "bs4",
# ]
# ///
from bs4 import BeautifulSoup
import re
from pathlib import Path
# Dictionary to track how many times we've seen each header ID
header_seen = {}
def add_anchors_to_headers(html_content):
# Parse the HTML content
soup = BeautifulSoup(html_content, 'html.parser')
# Find all header tags (h1 through h6)
header_tags = soup.find_all(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
# Add a count-based ID to each header tag
for header in header_tags:
# Extract header text (fallback to tag name if empty)
header_text = header.get_text(strip=True) or header.name
# Normalize header
header_text = header_text.lower().replace(" ", "-")
# Create base ID: hN-<text>
base_id = f"{header.name}-{header_text}"
# Check if we've seen this base ID before
if base_id in header_seen:
# Append next count: -1, -2, -3...
count = header_seen[base_id]
header_seen[base_id] = count + 1
header_id = f"{base_id}-{count + 1}"
else:
# First time seeing this text → count starts at 1
header_seen[base_id] = 1
header_id = base_id
# Add the ID to the header tag
header['id'] = header_id
# Wrap the header in an anchor link (clickable permalink)
# The link points to itself via the id attribute
# anchor_tag = soup.new_tag("a", href=f"#{header_id}", class_="anchor-link")
# anchor_tag.string = f"🔗"
# header.wrap(anchor_tag)
# Return the modified HTML content
return str(soup)
for book in ('./books/core', './books/natural-fantasy-atlas'):
for root, dirs, files in Path(book).walk():
for fn in files:
path = root / fn
if path.suffix != ".html":
continue
with path.open('r') as fh:
raw_html = fh.read()
new_html = add_anchors_to_headers(raw_html)
with path.open('w') as fh:
fh.write(new_html)

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

15
shell.nix Normal file
View File

@@ -0,0 +1,15 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShellNoCC {
packages = with pkgs; [
go
gopls
just
nodejs
starship
typescript-language-server
];
shellHook = ''
eval "$(starship init bash)"
'';
}

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback, useRef } from "react";
import "./fabula-ultima-sheet.css";
import spellsFile from "../data/spells.yml";
const STATUSES = ["Slow", "Enraged", "Dazed", "Weak", "Poisoned", "Shaken"];
const FEELINGS = [
@@ -77,8 +78,9 @@ interface ClassEntry {
interface Spell {
name: string;
spellClass: string;
notes: string;
mp: string | number;
mp: string;
targets: string;
duration: string;
}
@@ -133,22 +135,37 @@ const BLANK_BONDS: Bond[] = Array.from({ length: 6 }, () => ({
feelings: [],
}));
// 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: 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);
@@ -160,6 +177,51 @@ async function decompressFromBase64(b64: string) {
}
}
// 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();
}
function autoResize(el: HTMLTextAreaElement) {
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}
// Copy text to the clipboard. navigator.clipboard is only available in a
// secure context (HTTPS or localhost); over plain HTTP it's undefined, so we
// fall back to the deprecated execCommand("copy"), which still works there.
// Must be called from within a user gesture (e.g. a click handler).
async function copyToClipboard(text: string) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return;
}
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.focus();
ta.select();
try {
document.execCommand("copy");
} finally {
document.body.removeChild(ta);
}
}
export default function CharacterSheet() {
const [activeTab, setActiveTab] = useState("main");
const [urlMode, setUrlMode] = useState(false);
@@ -172,6 +234,7 @@ export default function CharacterSheet() {
const [primaryClasses, setPrimaryClasses] = useState<ClassEntry[]>([]);
const [otherClasses, setOtherClasses] = useState<ClassEntry[]>([]);
const [spells, setSpells] = useState<Spell[]>([]);
const [spellPickerOpen, setSpellPickerOpen] = useState(false);
const [statuses, setStatuses] = useState<CheckMap>({});
const [martial, setMartial] = useState<CheckMap>({});
const [disciplines, setDisciplines] = useState<CheckMap>({});
@@ -255,6 +318,7 @@ export default function CharacterSheet() {
oc: otherClasses.map((c) => ({ n: c.name, b: c.benefits, s: c.skills })),
sp: spells.map((s) => ({
n: s.name,
cl: s.spellClass,
nt: s.notes,
mp: s.mp,
tg: s.targets,
@@ -362,6 +426,7 @@ export default function CharacterSheet() {
setSpells(
rawSpells.map((s: SavedData) => ({
name: s.n ?? s.name ?? "",
spellClass: s.cl ?? s.spellClass ?? "",
notes: s.nt ?? s.notes ?? "",
mp: s.mp ?? "",
targets: s.tg ?? s.targets ?? "",
@@ -372,8 +437,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));
@@ -452,13 +532,24 @@ 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 copyToClipboard(shareURL);
setCopyStatus(true);
setTimeout(() => setCopyStatus(false), 2000);
}, [collectData]);
@@ -1090,6 +1181,8 @@ export default function CharacterSheet() {
<textarea
placeholder="Skill information…"
value={cls.skills || ""}
ref={(el) => { if (el) autoResize(el); }}
onInput={(e) => autoResize(e.currentTarget)}
onChange={(e) =>
setPrimaryClasses((prev) =>
prev.map((c, i) =>
@@ -1239,6 +1332,7 @@ export default function CharacterSheet() {
<thead>
<tr>
<th className="spell-name-col">Name / Notes</th>
<th className="spell-class-col">Class</th>
<th className="spell-mp-col">MP Cost</th>
<th className="spell-targets-col">Targets</th>
<th className="spell-dur-col">Duration</th>
@@ -1247,101 +1341,165 @@ export default function CharacterSheet() {
</thead>
<tbody>
{spells.map((s, i) => (
<tr key={i}>
<td className="spell-name-col">
<input
type="text"
placeholder="Spell / Arcana name…"
value={s.name || ""}
onChange={(e) =>
setSpells((prev) =>
prev.map((sp, j) =>
j === i ? { ...sp, name: e.target.value } : sp,
),
)
}
/>
<textarea
placeholder="Notes / effect description…"
value={s.notes || ""}
onChange={(e) =>
setSpells((prev) =>
prev.map((sp, j) =>
j === i ? { ...sp, notes: e.target.value } : sp,
),
)
}
/>
</td>
<td className="spell-mp-col">
<input
type="number"
placeholder="0"
value={s.mp || ""}
onChange={(e) =>
setSpells((prev) =>
prev.map((sp, j) =>
j === i ? { ...sp, mp: e.target.value } : sp,
),
)
}
style={{ minHeight: 32 }}
/>
</td>
<td className="spell-targets-col">
<input
type="text"
placeholder="Target(s)…"
value={s.targets || ""}
onChange={(e) =>
setSpells((prev) =>
prev.map((sp, j) =>
j === i ? { ...sp, targets: e.target.value } : sp,
),
)
}
/>
</td>
<td className="spell-dur-col">
<input
type="text"
placeholder="Duration…"
value={s.duration || ""}
onChange={(e) =>
setSpells((prev) =>
prev.map((sp, j) =>
j === i ? { ...sp, duration: e.target.value } : sp,
),
)
}
/>
</td>
<td className="spell-del-col">
<button
className="spell-del-btn"
onClick={() =>
setSpells((prev) => prev.filter((_, j) => j !== i))
}
>
</button>
</td>
</tr>
<React.Fragment key={i}>
<tr className="spell-inputs-row">
<td className="spell-name-col">
<input
type="text"
placeholder="Spell / Arcana name…"
value={s.name || ""}
onChange={(e) =>
setSpells((prev) =>
prev.map((sp, j) =>
j === i ? { ...sp, name: e.target.value } : sp,
),
)
}
/>
</td>
<td className="spell-class-col">
<input
type="text"
placeholder="Class…"
value={s.spellClass || ""}
onChange={(e) =>
setSpells((prev) =>
prev.map((sp, j) =>
j === i ? { ...sp, spellClass: e.target.value } : sp,
),
)
}
/>
</td>
<td className="spell-mp-col">
<input
type="text"
placeholder="MP cost…"
value={s.mp || ""}
onChange={(e) =>
setSpells((prev) =>
prev.map((sp, j) =>
j === i ? { ...sp, mp: e.target.value } : sp,
),
)
}
/>
</td>
<td className="spell-targets-col">
<input
type="text"
placeholder="Target(s)…"
value={s.targets || ""}
onChange={(e) =>
setSpells((prev) =>
prev.map((sp, j) =>
j === i ? { ...sp, targets: e.target.value } : sp,
),
)
}
/>
</td>
<td className="spell-dur-col">
<input
type="text"
placeholder="Duration…"
value={s.duration || ""}
onChange={(e) =>
setSpells((prev) =>
prev.map((sp, j) =>
j === i ? { ...sp, duration: e.target.value } : sp,
),
)
}
/>
</td>
<td className="spell-del-col">
<button
className="spell-del-btn"
onClick={() =>
setSpells((prev) => prev.filter((_, j) => j !== i))
}
>
</button>
</td>
</tr>
<tr className="spell-notes-row">
<td colSpan={6}>
<textarea
placeholder="Notes / effect description…"
value={s.notes || ""}
ref={(el) => { if (el) autoResize(el); }}
onInput={(e) => autoResize(e.currentTarget)}
onChange={(e) =>
setSpells((prev) =>
prev.map((sp, j) =>
j === i ? { ...sp, notes: e.target.value } : sp,
),
)
}
/>
</td>
</tr>
</React.Fragment>
))}
</tbody>
</table>
<button
className="add-btn"
style={{ marginTop: 10 }}
onClick={() =>
setSpells((prev) => [
...prev,
{ name: "", notes: "", mp: "", targets: "", duration: "" },
])
}
>
+ Add Spell / Arcana
</button>
<div style={{ display: "flex", gap: 8, marginTop: 10 }}>
<button
className="add-btn"
onClick={() =>
setSpells((prev) => [
...prev,
{ name: "", spellClass: "", notes: "", mp: "", targets: "", duration: "" },
])
}
>
+ Add Spell / Arcana
</button>
<button
className="add-btn"
onClick={() => setSpellPickerOpen(true)}
>
+ Add Template Spell
</button>
</div>
{spellPickerOpen && (
<div className="spell-picker-overlay" onClick={() => setSpellPickerOpen(false)}>
<div className="spell-picker-modal" onClick={(e) => e.stopPropagation()}>
<div className="spell-picker-header">
<span>Choose a spell</span>
<button className="spell-picker-close" onClick={() => setSpellPickerOpen(false)}></button>
</div>
<ul className="spell-picker-list">
{(spellsFile as SpellsFile).spells.map((t, i) => (
<li
key={i}
className="spell-picker-item"
onClick={() => {
setSpells((prev) => [
...prev,
{
name: t.name,
spellClass: t.class,
notes: t.description,
mp: t.cost,
targets: t.targets,
duration: t.duration,
},
]);
setSpellPickerOpen(false);
}}
>
<span className="spell-picker-name">{t.name}</span>
<span className="spell-picker-class">{t.class}</span>
</li>
))}
</ul>
</div>
</div>
)}
</div>
<div className="section">

View File

@@ -765,12 +765,37 @@ input[type="number"] {
min-height: 55px;
}
.spell-inputs-row td {
border-bottom: none;
padding-top: 10px;
}
.spell-notes-row td {
padding-top: 0;
padding-bottom: 10px;
}
.spell-notes-row textarea {
display: block;
width: 100%;
min-height: 0;
box-sizing: border-box;
}
.spell-name-col {
width: 35%;
}
.spell-name-col input {
font-weight: bold;
}
.spell-class-col {
width: 14%;
}
.spell-mp-col {
width: 12%;
width: 10%;
}
.spell-targets-col {
@@ -782,7 +807,8 @@ input[type="number"] {
}
.spell-del-col {
width: 40px;
width: 1px;
white-space: nowrap;
}
.spell-del-btn {
@@ -821,6 +847,82 @@ input[type="number"] {
color: var(--text-bright);
}
.spell-picker-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
}
.spell-picker-modal {
background: var(--surface2);
border: 1px solid var(--border-bright);
width: min(480px, 90vw);
max-height: 70vh;
display: flex;
flex-direction: column;
}
.spell-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid var(--border-bright);
font-family: var(--font-display);
font-size: 0.6rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--teal);
}
.spell-picker-close {
background: none;
border: none;
cursor: pointer;
color: var(--text-dim);
font-size: 0.9rem;
line-height: 1;
padding: 0;
}
.spell-picker-close:hover {
color: var(--red);
}
.spell-picker-list {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
}
.spell-picker-item {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 8px 14px;
cursor: pointer;
border-bottom: 1px solid var(--border);
}
.spell-picker-item:hover {
background: var(--surface3);
}
.spell-picker-name {
font-size: 0.9rem;
}
.spell-picker-class {
font-size: 0.7rem;
color: var(--text-dim);
text-transform: capitalize;
}
/* ── DISCIPLINES ─────────────────────────────────── */
.disciplines-row {
display: flex;

19
src/globals.d.ts vendored
View File

@@ -1,5 +1,24 @@
declare module "*.css";
declare module "*.yml" {
const data: unknown;
export default data;
}
interface SpellTemplate {
name: string;
cost: string;
targets: string;
duration: string;
description: string;
class: string;
offensive?: boolean;
}
interface SpellsFile {
spells: SpellTemplate[];
}
interface BookPage {
n: number;
content: string;

View File

@@ -65,6 +65,10 @@ module.exports = (env, argv) => {
},
},
},
{
test: /\.ya?ml$/,
use: "yaml-loader",
},
{
test: /\.css$/,
use: [