Add accounts, presets, live sheet panel and equipment tiles

- Login with registration code, sessions, rate limiting, per-user characters
- Share characters with other users (read-only), admin CLI
- Rules presets with a Presets tab, applied/pending changes with diff in a side sheet
- Equipment as tiles with amounts, starting kit removed
- Rename nav tab to "Character", add "New character" button
- Stop tracking _to_delete/

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
Claude
2026-10-20 20:37:09 +00:00
parent afbdc4b139
commit 5f3b2b2874
27 changed files with 857 additions and 244 deletions
+5
View File
@@ -0,0 +1,5 @@
# Copy this file to ".env" (it is ignored by git) and fill it in.
# The code people must enter to create an account:
AW_REGISTRATION_CODE=
# 1 when running behind a reverse proxy that sets X-Forwarded-For / X-Forwarded-Proto / X-Forwarded-Host:
AW_TRUST_PROXY=0
+2
View File
@@ -5,3 +5,5 @@ backend/__pycache__/
backend/app/__pycache__/
backend/aw.db
db/
.env
_to_delete/
+36 -11
View File
@@ -12,7 +12,29 @@ Website for creating characters for the Arcane Wasteland TTRPG, with a searchabl
docker compose up -d --build
```
Then open `http://<server>:8080`. Characters are stored in `./db/aw.db`. For HTTPS and a domain, put the container behind a reverse proxy (nginx, Caddy, Traefik).
Then open `http://<server>:8080`. Users, characters and presets are stored in `./db/aw.db`.
### Login and accounts
Everything (the whole API, rules, characters) is behind a login. The only public things are the login page itself and the app's JavaScript.
1. Copy `.env.example` to `.env` and set `AW_REGISTRATION_CODE`. People need this code **once** to create an account; with an empty code registration is disabled.
2. Open the site, choose "Create account", enter a username, a password (min. 8 characters) and the registration code.
3. Sessions last 30 days (HttpOnly cookie, stored server-side). Logging out or changing the password ends them.
Wrong logins and wrong registration codes are throttled (10 failures per address, 8 per username, 5 wrong codes per address and 30 per hour for the whole server), so a short code is not trivially guessable, but a long one is still better. Passwords are stored as scrypt hashes.
**HTTPS is a must when the site is reachable from the internet**, otherwise passwords travel in plain text. Put the container behind a reverse proxy (Caddy, nginx, Traefik) that terminates TLS, pass the `Host` header through, and set `AW_TRUST_PROXY=1` in `.env` so the app sees the real client address and marks the cookie `Secure`.
Admin tasks (list users, reset a password, delete a user):
```bash
docker compose exec awcc python -m app.admin list
docker compose exec awcc python -m app.admin passwd <username>
docker compose exec awcc python -m app.admin delete <username>
```
Characters and presets made before accounts existed are adopted automatically by the account that logs in from the same browser (the old secret keys are used once and then removed).
## Edit the rules
@@ -36,7 +58,7 @@ In the creator, the Concept page only has a "Rules preset" selector. Presets are
### Equipment
`equipment` lists every item with `id`, `name`, `cat` (category), `stat` (damage / armor / type), `kg` and `note`. The starting-kit rules are in `creation.kit` (allowed weapon categories, armor tiers, number of resources). Weights are approximate; for ranges in the source document the upper value is used.
`equipment` lists every item with `id`, `name`, `cat` (category), `stat` (damage / armor / type), `kg` and `note`. There are no starting-kit rules: players pick any items (with an amount) on the Equipment page and the total weight is checked against the carry capacity. Weights are approximate; for ranges in the source document the upper value is used.
## Development
@@ -51,9 +73,7 @@ cd frontend && npm install && npm run dev
## Saving and sharing
There are no user accounts. When saved, a character gets an ID and a secret write key that stays in the browser ("My characters" page). The link `/#creator/<ID>` lets others view the character; only the holder of the key can change it. To move to another browser use the JSON backup (Summary page: "JSON backup" and "Import JSON").
If the site is reachable from the internet, protect it with Basic Auth at the reverse proxy.
Characters are saved in your account ("My characters" page). Only the owner can see a character, change it or delete it. On "My characters" the **Share** button lets you add usernames (e.g. your DM): they get read-only access, see the character under "Shared with me" and can open it through `/#creator/<ID>`. Saving a viewed character creates their own copy. Use the JSON backup (Summary page) to move a character between servers.
While creating, the full character sheet stays visible next to the wizard. Edits are shown as a difference (old → new, added / removed items) until you press **Apply** (or **Apply & next**); **Discard** drops them.
@@ -61,12 +81,17 @@ Each wizard page has a **Reset this page** button that clears only that page's d
## API
All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/login` need the session cookie.
| Method | Path | Purpose |
|---|---|---|
| GET | `/api/rules` | Rule data |
| POST | `/api/characters` | Create a character, returns `id` and `token` |
| GET | `/api/characters/{id}` | Read a character |
| PUT / DELETE | `/api/characters/{id}` | Change / delete, header `X-Edit-Token` |
| POST | `/api/presets` | Create a preset, returns `id` and `token` |
| PUT / DELETE | `/api/presets/{id}` | Change / delete, header `X-Edit-Token` |
| GET | `/api/auth/me` | Current user (or `null`) |
| POST | `/api/auth/register` / `login` / `logout` / `password` | Accounts |
| GET | `/api/rules` | Rule data including presets |
| GET / POST | `/api/characters` | Own and shared-with-me characters / create one |
| GET | `/api/characters/{id}` | Read (owner or shared users) |
| POST / DELETE | `/api/characters/{id}/shares[/{username}]` | Share / unshare (owner only) |
| PUT / DELETE | `/api/characters/{id}` | Change / delete (owner only) |
| POST | `/api/presets` | Create a preset |
| PUT / DELETE | `/api/presets/{id}` | Change / delete (owner only) |
| POST | `/api/export/pdf` or `/docx` | Character JSON in the body, file in the response |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
"""Small admin CLI: python -m app.admin list | passwd <user> | delete <user>"""
import getpass
import sys
from . import auth
from .main import db
def main(argv):
if not argv:
print(__doc__)
return 1
cmd = argv[0]
with db() as con:
if cmd == "list":
for r in con.execute("SELECT username, created FROM users ORDER BY id"):
print(r["username"])
elif cmd == "passwd" and len(argv) == 2:
pw = getpass.getpass("New password: ")
if len(pw) < auth.MIN_PASSWORD:
print(f"Password must have at least {auth.MIN_PASSWORD} characters.")
return 1
cur = con.execute("UPDATE users SET pw_hash=? WHERE username=?", (auth.hash_password(pw), argv[1]))
if not cur.rowcount:
print("No such user.")
return 1
con.execute("DELETE FROM sessions WHERE user_id=(SELECT id FROM users WHERE username=?)", (argv[1],))
print("Password changed, user logged out everywhere.")
elif cmd == "delete" and len(argv) == 2:
row = con.execute("SELECT id FROM users WHERE username=?", (argv[1],)).fetchone()
if not row:
print("No such user.")
return 1
con.execute("DELETE FROM sessions WHERE user_id=?", (row["id"],))
con.execute("DELETE FROM shares WHERE user_id=?", (row["id"],))
con.execute("DELETE FROM users WHERE id=?", (row["id"],))
print("User deleted. Their characters and presets stay on the server without an owner.")
else:
print(__doc__)
return 1
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+106
View File
@@ -0,0 +1,106 @@
"""Accounts, sessions and brute-force protection (standard library only)."""
import hashlib
import hmac
import os
import re
import secrets
import sqlite3
import threading
import time
SESSION_COOKIE = "aw_session"
SESSION_DAYS = 30
USERNAME_RE = re.compile(r"^[A-Za-z0-9_.-]{3,32}$")
MIN_PASSWORD = 8
MAX_PASSWORD = 200
# scrypt parameters (about 16 MB of memory per hash)
_N, _R, _P = 2 ** 14, 8, 1
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
h = hashlib.scrypt(password.encode(), salt=salt, n=_N, r=_R, p=_P, dklen=32)
return f"scrypt${_N}${_R}${_P}${salt.hex()}${h.hex()}"
def verify_password(password: str, stored: str) -> bool:
try:
_, n, r, p, salt, h = stored.split("$")
calc = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=int(n), r=int(r), p=int(p), dklen=32)
return hmac.compare_digest(calc.hex(), h)
except Exception:
return False
_DUMMY = hash_password("dummy-password-for-timing")
def init_tables(con: sqlite3.Connection) -> None:
con.execute("""CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE COLLATE NOCASE,
pw_hash TEXT NOT NULL, created REAL NOT NULL)""")
con.execute("""CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY, user_id INTEGER NOT NULL, expires REAL NOT NULL)""")
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def create_session(con: sqlite3.Connection, user_id: int) -> str:
token = secrets.token_urlsafe(32)
con.execute("DELETE FROM sessions WHERE expires < ?", (time.time(),))
con.execute("INSERT INTO sessions VALUES (?,?,?)", (_hash_token(token), user_id, time.time() + SESSION_DAYS * 86400))
return token
def user_for_token(con: sqlite3.Connection, token: str | None):
if not token:
return None
row = con.execute(
"SELECT u.id, u.username, s.expires FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ?",
(_hash_token(token),)).fetchone()
if not row or row["expires"] < time.time():
return None
return {"id": row["id"], "username": row["username"]}
def end_session(con: sqlite3.Connection, token: str | None) -> None:
if token:
con.execute("DELETE FROM sessions WHERE token_hash = ?", (_hash_token(token),))
def timing_dummy(password: str) -> None:
"""Burn the same time as a real check when the user does not exist."""
verify_password(password, _DUMMY)
class Limiter:
"""In-memory failure counter: too many failures within the window block further attempts."""
def __init__(self):
self.lock = threading.Lock()
self.hits: dict[str, list[float]] = {}
def blocked(self, key: str, limit: int, window: int) -> int:
"""Returns seconds to wait (0 = allowed)."""
now = time.time()
with self.lock:
xs = [t for t in self.hits.get(key, []) if now - t < window]
self.hits[key] = xs
return int(window - (now - xs[0])) + 1 if len(xs) >= limit else 0
def fail(self, key: str) -> None:
with self.lock:
self.hits.setdefault(key, []).append(time.time())
if len(self.hits) > 20000: # keep memory bounded
self.hits.clear()
def reset(self, key: str) -> None:
with self.lock:
self.hits.pop(key, None)
def registration_code() -> str:
return os.environ.get("AW_REGISTRATION_CODE", "")
+9 -12
View File
@@ -2,20 +2,17 @@
def kit_items(ch: dict, rules: dict) -> list:
"""Returns [(label, item, qty)] for the starting kit and extra items."""
"""Returns [(category, item, qty)] for the carried items. Old starting-kit slots count as one item each."""
items = {e["id"]: e for e in rules.get("equipment", [])}
cats = {c["id"]: c["name"] for c in rules.get("equipment_categories", [])}
kit = ch.get("kit") or {}
out = []
for label, key in (("Weapon", "weapon"), ("Armor", "armor"), ("Tool", "tool")):
if kit.get(key) in items:
out.append((label, items[kit[key]], 1))
for rid in kit.get("resources", []):
if rid in items:
out.append(("Resource", items[rid], 1))
for iid, qty in (kit.get("extras") or {}).items():
if iid in items and int(qty) > 0:
out.append(("Extra", items[iid], int(qty)))
return out
qty: dict = {}
for iid in [kit.get("weapon"), kit.get("armor"), kit.get("tool"), *(kit.get("resources") or [])]:
if iid:
qty[iid] = qty.get(iid, 0) + 1
for iid, n in (kit.get("extras") or {}).items():
qty[iid] = qty.get(iid, 0) + int(n)
return [(cats.get(items[i]["cat"], ""), items[i], n) for i, n in qty.items() if i in items and n > 0]
def compute(ch: dict, rules: dict) -> dict:
+4 -4
View File
@@ -40,8 +40,8 @@ def _sheet(ch: dict, rules: dict) -> dict:
"derived_rows": [[d["name"], str(c["derived"][d["id"]])] for d in rules["derived"]],
"skill_rows": [
[s["name"], "/".join(a["short"] for a in rules["attributes"] if a["id"] in s["attrs"]),
str(s["points"]), {0: "", 1: "Proficient", 2: "Expertise"}.get(s["pips"], ""), str(s["total"])]
for s in c["skills"] if s["points"] or s["prof"] or (s["unlocked"] and s["type"] == "advanced")
str(s["points"]), {1: "Proficient", 2: "Expertise"}.get(s["pips"]) or ("" if s["usable"] else "Locked"), str(s["total"])]
for s in c["skills"]
],
"augment_rows": [[a["name"], KIND.get(a["kind"], a["kind"]), SOURCE.get(a["source"]) or "-", str(a["cost"])]
for a in c["augments"]],
@@ -92,7 +92,7 @@ def to_docx(ch: dict, rules: dict) -> bytes:
doc.add_paragraph("-")
doc.add_heading(f"Equipment (carried {c['weight']} / {c['carry_max']} kg)", 1)
if s["gear_rows"]:
table(["", "Item", "Damage / Armor / Type", "Weight"], s["gear_rows"])
table(["Category", "Item", "Damage / Armor / Type", "Weight"], s["gear_rows"])
else:
doc.add_paragraph("-")
if s["other"]:
@@ -166,7 +166,7 @@ def to_pdf(ch: dict, rules: dict) -> bytes:
el.append(Paragraph(f"Equipment (carried {c['weight']} / {c['carry_max']} kg)", h2))
if s["gear_rows"]:
el.append(tbl(["", "Item", "Damage / Armor / Type", "Weight"], s["gear_rows"], [22 * mm, 65 * mm, 55 * mm, 22 * mm]))
el.append(tbl(["Category", "Item", "Damage / Armor / Type", "Weight"], s["gear_rows"], [30 * mm, 60 * mm, 50 * mm, 22 * mm]))
else:
el.append(Paragraph("-", body))
if s["other"]:
+301 -58
View File
@@ -7,10 +7,11 @@ import time
import uuid
from pathlib import Path
from fastapi import Body, FastAPI, Header, HTTPException, Response
from fastapi.responses import FileResponse
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from . import auth
from .export import to_docx, to_pdf
BASE = Path(__file__).resolve().parent.parent
@@ -19,8 +20,14 @@ DB_PATH = os.environ.get("AW_DB", str(BASE / "aw.db"))
STATIC = Path(os.environ.get("AW_STATIC", BASE / "static"))
MAX_BODY = 200_000 # bytes per character
MAX_PRESETS = 200
MAX_CHARACTERS = 300 # per user
MAX_SHARES = 30 # per character
# No public API docs: everything about the API stays behind the login
app = FastAPI(title="Arcane Wasteland Character Creator", docs_url=None, redoc_url=None, openapi_url=None)
limiter = auth.Limiter()
TRUST_PROXY = os.environ.get("AW_TRUST_PROXY", "") == "1" # behind a reverse proxy that sets X-Forwarded-For / -Proto
app = FastAPI(title="Arcane Wasteland Character Creator")
def load_rules() -> dict:
@@ -38,19 +45,28 @@ def db() -> sqlite3.Connection:
con.execute("""CREATE TABLE IF NOT EXISTS presets (
id TEXT PRIMARY KEY, token TEXT NOT NULL, data TEXT NOT NULL,
created REAL NOT NULL, updated REAL NOT NULL)""")
auth.init_tables(con)
con.execute("""CREATE TABLE IF NOT EXISTS shares (
character_id TEXT NOT NULL, user_id INTEGER NOT NULL, PRIMARY KEY (character_id, user_id))""")
for table in ("characters", "presets"): # older databases: add the owner column
if "owner_id" not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]:
con.execute(f"ALTER TABLE {table} ADD COLUMN owner_id INTEGER")
return con
def custom_presets() -> list:
with db() as con:
rows = con.execute("SELECT id, data FROM presets ORDER BY created").fetchall()
return [{"id": r["id"], **json.loads(r["data"]), "custom": True} for r in rows]
def custom_presets(con, uid=None) -> list:
rows = con.execute("""SELECT p.id, p.data, p.owner_id, u.username FROM presets p
LEFT JOIN users u ON u.id = p.owner_id ORDER BY p.created""").fetchall()
return [{"id": r["id"], **json.loads(r["data"]), "custom": True, "author": r["username"] or "",
"mine": uid is not None and r["owner_id"] == uid} for r in rows]
def all_rules() -> dict:
def all_rules(uid=None) -> dict:
"""Rules from rules.json plus the presets created in the app."""
r = load_rules()
r["presets"] = [{**p, "builtin": True} for p in r.get("presets", [])] + custom_presets()
with db() as con:
custom = custom_presets(con, uid)
r["presets"] = [{**p, "builtin": True} for p in r.get("presets", [])] + custom
return r
@@ -76,99 +92,321 @@ def check_size(ch: dict):
raise HTTPException(413, "Character too large")
@app.get("/api/rules")
def get_rules():
return all_rules()
# ---------------------------------------------------------------- security
def client_ip(request: Request) -> str:
if TRUST_PROXY:
fwd = request.headers.get("x-forwarded-for", "")
if fwd:
return fwd.split(",")[-1].strip() # the address the reverse proxy saw
return request.client.host if request.client else "?"
@app.post("/api/presets")
def create_preset(body: dict = Body(...)):
def is_https(request: Request) -> bool:
if TRUST_PROXY:
return request.headers.get("x-forwarded-proto", "") == "https"
return request.url.scheme == "https"
@app.middleware("http")
async def security(request: Request, call_next):
# Block cross-site writes (cookies are SameSite=Lax as well)
if request.method not in ("GET", "HEAD", "OPTIONS"):
origin = request.headers.get("origin")
hosts = {request.headers.get("host", "")}
if TRUST_PROXY:
hosts.add(request.headers.get("x-forwarded-host", ""))
if origin and origin.split("://", 1)[-1] not in hosts:
return JSONResponse({"detail": "Cross-site request refused"}, status_code=403)
resp = await call_next(request)
resp.headers["X-Content-Type-Options"] = "nosniff"
resp.headers["X-Frame-Options"] = "DENY"
resp.headers["Referrer-Policy"] = "same-origin"
resp.headers["Content-Security-Policy"] = ("default-src 'self'; style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; frame-ancestors 'none'")
if request.url.path.startswith("/api/"):
resp.headers["Cache-Control"] = "no-store"
return resp
def current_user(request: Request) -> dict:
with db() as con:
user = auth.user_for_token(con, request.cookies.get(auth.SESSION_COOKIE))
if not user:
raise HTTPException(401, "Not logged in")
return user
def set_cookie(request: Request, resp: Response, token: str):
resp.set_cookie(auth.SESSION_COOKIE, token, max_age=auth.SESSION_DAYS * 86400, httponly=True,
samesite="lax", secure=is_https(request), path="/")
def throttle(key: str, limit: int, window: int):
wait = limiter.blocked(key, limit, window)
if wait:
raise HTTPException(429, f"Too many attempts. Try again in {wait // 60 + 1} minute(s).",
headers={"Retry-After": str(wait)})
def check_password_rules(pw: str):
if not isinstance(pw, str) or len(pw) < auth.MIN_PASSWORD:
raise HTTPException(422, f"The password needs at least {auth.MIN_PASSWORD} characters")
if len(pw) > auth.MAX_PASSWORD:
raise HTTPException(422, "The password is too long")
# ---------------------------------------------------------------- auth routes (public)
@app.get("/api/auth/me")
def me(request: Request):
with db() as con:
user = auth.user_for_token(con, request.cookies.get(auth.SESSION_COOKIE))
return {"user": user, "registration_open": bool(auth.registration_code())}
@app.post("/api/auth/register")
def register(request: Request, response: Response, body: dict = Body(...)):
code_cfg = auth.registration_code()
if not code_cfg:
raise HTTPException(403, "Registration is disabled on this server")
ip = client_ip(request)
# The shared code may be weak, so wrong guesses are throttled per address and for the whole server.
throttle(f"reg:{ip}", 5, 900)
throttle("reg:all", 30, 3600)
username, password, code = str(body.get("username", "")).strip(), body.get("password", ""), str(body.get("code", ""))
if not secrets.compare_digest(code.encode(), code_cfg.encode()):
limiter.fail(f"reg:{ip}"); limiter.fail("reg:all")
raise HTTPException(403, "Wrong registration code")
if not auth.USERNAME_RE.match(username):
raise HTTPException(422, "Username: 3-32 characters, letters, digits and . _ - only")
check_password_rules(password)
with db() as con:
if con.execute("SELECT 1 FROM users WHERE username=?", (username,)).fetchone():
raise HTTPException(409, "This username is taken")
cur = con.execute("INSERT INTO users (username, pw_hash, created) VALUES (?,?,?)",
(username, auth.hash_password(password), time.time()))
token = auth.create_session(con, cur.lastrowid)
set_cookie(request, response, token)
return {"user": {"id": cur.lastrowid, "username": username}}
@app.post("/api/auth/login")
def login(request: Request, response: Response, body: dict = Body(...)):
username, password = str(body.get("username", "")).strip(), str(body.get("password", ""))[:auth.MAX_PASSWORD]
ip = client_ip(request)
keys = (f"login:{ip}", f"login:u:{username.lower()}")
throttle(keys[0], 10, 600)
throttle(keys[1], 8, 600)
with db() as con:
row = con.execute("SELECT id, username, pw_hash FROM users WHERE username=?", (username,)).fetchone()
ok = bool(row) and auth.verify_password(password, row["pw_hash"])
if not row:
auth.timing_dummy(password)
if not ok:
for k in keys:
limiter.fail(k)
raise HTTPException(401, "Wrong username or password")
token = auth.create_session(con, row["id"])
limiter.reset(keys[1])
set_cookie(request, response, token)
return {"user": {"id": row["id"], "username": row["username"]}}
@app.post("/api/auth/logout")
def logout(request: Request, response: Response):
with db() as con:
auth.end_session(con, request.cookies.get(auth.SESSION_COOKIE))
response.delete_cookie(auth.SESSION_COOKIE, path="/")
return {"ok": True}
# ---------------------------------------------------------------- everything below needs a login
api = APIRouter(prefix="/api", dependencies=[Depends(current_user)])
@api.post("/auth/password")
def change_password(request: Request, response: Response, body: dict = Body(...), user: dict = Depends(current_user)):
throttle(f"pw:{user['id']}", 8, 600)
old, new = str(body.get("old", "")), body.get("new", "")
check_password_rules(new)
with db() as con:
row = con.execute("SELECT pw_hash FROM users WHERE id=?", (user["id"],)).fetchone()
if not auth.verify_password(old, row["pw_hash"]):
limiter.fail(f"pw:{user['id']}")
raise HTTPException(403, "The current password is wrong")
con.execute("UPDATE users SET pw_hash=? WHERE id=?", (auth.hash_password(new), user["id"]))
con.execute("DELETE FROM sessions WHERE user_id=?", (user["id"],)) # log out all other devices
token = auth.create_session(con, user["id"])
set_cookie(request, response, token)
return {"ok": True}
@api.get("/rules")
def get_rules(user: dict = Depends(current_user)):
return all_rules(user["id"])
# ---- presets
def _owned(con, table: str, rid: str, user: dict):
row = con.execute(f"SELECT owner_id FROM {table} WHERE id=?", (rid,)).fetchone()
if not row:
raise HTTPException(404, "Not found")
if row["owner_id"] != user["id"]:
raise HTTPException(403, "This belongs to another user")
@api.post("/presets")
def create_preset(body: dict = Body(...), user: dict = Depends(current_user)):
data = clean_preset(body, load_rules())
with db() as con:
if con.execute("SELECT COUNT(*) FROM presets").fetchone()[0] >= MAX_PRESETS:
raise HTTPException(409, "Too many presets on this server")
pid, token, now = "u-" + uuid.uuid4().hex[:8], secrets.token_urlsafe(16), time.time()
con.execute("INSERT INTO presets VALUES (?,?,?,?,?)", (pid, token, json.dumps(data), now, now))
return {"id": pid, "token": token}
pid, now = "u-" + uuid.uuid4().hex[:8], time.time()
con.execute("INSERT INTO presets (id, token, data, created, updated, owner_id) VALUES (?,?,?,?,?,?)",
(pid, "", json.dumps(data), now, now, user["id"]))
return {"id": pid}
@app.put("/api/presets/{pid}")
def update_preset(pid: str, body: dict = Body(...), x_edit_token: str = Header(default="")):
@api.put("/presets/{pid}")
def update_preset(pid: str, body: dict = Body(...), user: dict = Depends(current_user)):
data = clean_preset(body, load_rules())
with db() as con:
row = con.execute("SELECT token FROM presets WHERE id=?", (pid,)).fetchone()
if not row:
raise HTTPException(404, "Preset not found")
if not secrets.compare_digest(row["token"], x_edit_token):
raise HTTPException(403, "No write access to this preset")
_owned(con, "presets", pid, user)
con.execute("UPDATE presets SET data=?, updated=? WHERE id=?", (json.dumps(data), time.time(), pid))
return {"ok": True}
@app.delete("/api/presets/{pid}")
def delete_preset(pid: str, x_edit_token: str = Header(default="")):
@api.delete("/presets/{pid}")
def delete_preset(pid: str, user: dict = Depends(current_user)):
with db() as con:
row = con.execute("SELECT token FROM presets WHERE id=?", (pid,)).fetchone()
if not row:
raise HTTPException(404, "Preset not found")
if not secrets.compare_digest(row["token"], x_edit_token):
raise HTTPException(403, "No write access to this preset")
_owned(con, "presets", pid, user)
con.execute("DELETE FROM presets WHERE id=?", (pid,))
return {"ok": True}
@app.post("/api/characters")
def create_character(ch: dict = Body(...)):
# ---- characters (any logged-in user can open a shared link, only the owner can change or delete)
def _name(data: str) -> str:
return json.loads(data).get("name") or "Nameless"
@api.get("/characters")
def my_characters(user: dict = Depends(current_user)):
with db() as con:
rows = con.execute("SELECT id, data, updated FROM characters WHERE owner_id=? ORDER BY updated DESC",
(user["id"],)).fetchall()
shares: dict = {}
for r in con.execute("""SELECT s.character_id, u.username FROM shares s JOIN users u ON u.id = s.user_id
JOIN characters c ON c.id = s.character_id WHERE c.owner_id=? ORDER BY u.username""",
(user["id"],)):
shares.setdefault(r["character_id"], []).append(r["username"])
shared = con.execute("""SELECT c.id, c.data, c.updated, o.username AS owner FROM shares s
JOIN characters c ON c.id = s.character_id LEFT JOIN users o ON o.id = c.owner_id
WHERE s.user_id=? ORDER BY c.updated DESC""", (user["id"],)).fetchall()
return {"mine": [{"id": r["id"], "name": _name(r["data"]), "updated": r["updated"], "shared_with": shares.get(r["id"], [])}
for r in rows],
"shared": [{"id": r["id"], "name": _name(r["data"]), "updated": r["updated"], "owner": r["owner"] or "?"}
for r in shared]}
@api.post("/characters")
def create_character(ch: dict = Body(...), user: dict = Depends(current_user)):
check_size(ch)
cid, token, now = uuid.uuid4().hex[:12], secrets.token_urlsafe(16), time.time()
with db() as con:
con.execute("INSERT INTO characters VALUES (?,?,?,?,?)", (cid, token, json.dumps(ch), now, now))
return {"id": cid, "token": token}
if con.execute("SELECT COUNT(*) FROM characters WHERE owner_id=?", (user["id"],)).fetchone()[0] >= MAX_CHARACTERS:
raise HTTPException(409, "Character limit reached")
cid, now = uuid.uuid4().hex[:12], time.time()
con.execute("INSERT INTO characters (id, token, data, created, updated, owner_id) VALUES (?,?,?,?,?,?)",
(cid, "", json.dumps(ch), now, now, user["id"]))
return {"id": cid}
@app.get("/api/characters/{cid}")
def read_character(cid: str):
@api.get("/characters/{cid}")
def read_character(cid: str, user: dict = Depends(current_user)):
"""Only the owner and the users the character is shared with may read it."""
with db() as con:
row = con.execute("SELECT data FROM characters WHERE id=?", (cid,)).fetchone()
if not row:
row = con.execute("SELECT data, owner_id FROM characters WHERE id=?", (cid,)).fetchone()
allowed = bool(row) and (row["owner_id"] == user["id"] or con.execute(
"SELECT 1 FROM shares WHERE character_id=? AND user_id=?", (cid, user["id"])).fetchone())
if not allowed:
raise HTTPException(404, "Character not found")
return json.loads(row["data"])
@app.put("/api/characters/{cid}")
def update_character(cid: str, ch: dict = Body(...), x_edit_token: str = Header(default="")):
@api.post("/characters/{cid}/shares")
def add_share(cid: str, body: dict = Body(...), user: dict = Depends(current_user)):
name = str(body.get("username", "")).strip()
with db() as con:
_owned(con, "characters", cid, user)
target = con.execute("SELECT id, username FROM users WHERE username=?", (name,)).fetchone()
if not target:
raise HTTPException(404, "No user with this name")
if target["id"] == user["id"]:
raise HTTPException(422, "This is your own character")
if con.execute("SELECT COUNT(*) FROM shares WHERE character_id=?", (cid,)).fetchone()[0] >= MAX_SHARES:
raise HTTPException(409, "Shared with too many users")
con.execute("INSERT OR IGNORE INTO shares VALUES (?,?)", (cid, target["id"]))
return {"username": target["username"]}
@api.delete("/characters/{cid}/shares/{username}")
def remove_share(cid: str, username: str, user: dict = Depends(current_user)):
with db() as con:
_owned(con, "characters", cid, user)
con.execute("DELETE FROM shares WHERE character_id=? AND user_id=(SELECT id FROM users WHERE username=?)",
(cid, username))
return {"ok": True}
@api.put("/characters/{cid}")
def update_character(cid: str, ch: dict = Body(...), user: dict = Depends(current_user)):
check_size(ch)
with db() as con:
row = con.execute("SELECT token FROM characters WHERE id=?", (cid,)).fetchone()
if not row:
raise HTTPException(404, "Character not found")
if not secrets.compare_digest(row["token"], x_edit_token):
raise HTTPException(403, "No write access to this character")
_owned(con, "characters", cid, user)
con.execute("UPDATE characters SET data=?, updated=? WHERE id=?", (json.dumps(ch), time.time(), cid))
return {"ok": True}
@app.delete("/api/characters/{cid}")
def delete_character(cid: str, x_edit_token: str = Header(default="")):
@api.delete("/characters/{cid}")
def delete_character(cid: str, user: dict = Depends(current_user)):
with db() as con:
row = con.execute("SELECT token FROM characters WHERE id=?", (cid,)).fetchone()
if not row:
raise HTTPException(404, "Character not found")
if not secrets.compare_digest(row["token"], x_edit_token):
raise HTTPException(403, "No write access to this character")
_owned(con, "characters", cid, user)
con.execute("DELETE FROM characters WHERE id=?", (cid,))
con.execute("DELETE FROM shares WHERE character_id=?", (cid,))
return {"ok": True}
@api.post("/claim/{kind}/{rid}")
def claim(kind: str, rid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""Takes over something created before accounts existed, using its old secret key."""
table = {"characters": "characters", "presets": "presets"}.get(kind)
if not table:
raise HTTPException(404, "Unknown kind")
throttle(f"claim:{user['id']}", 60, 600)
with db() as con:
row = con.execute(f"SELECT token, owner_id FROM {table} WHERE id=?", (rid,)).fetchone()
key = str(body.get("token", ""))
if not row or row["owner_id"] is not None or not row["token"] or not secrets.compare_digest(row["token"], key):
limiter.fail(f"claim:{user['id']}")
raise HTTPException(404, "Nothing to claim")
con.execute(f"UPDATE {table} SET owner_id=?, token='' WHERE id=?", (user["id"], rid))
return {"ok": True}
# ---- export
def _filename(ch: dict, ext: str) -> str:
base = re.sub(r"[^A-Za-z0-9_-]+", "_", ch.get("name") or "Character").strip("_") or "Character"
return f"{base}.{ext}"
@app.post("/api/export/{fmt}")
def export(fmt: str, ch: dict = Body(...)):
@api.post("/export/{fmt}")
def export(fmt: str, ch: dict = Body(...), user: dict = Depends(current_user)):
check_size(ch)
rules = all_rules()
rules = all_rules(user["id"])
if fmt == "docx":
data = to_docx(ch, rules)
mime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
@@ -181,12 +419,17 @@ def export(fmt: str, ch: dict = Body(...)):
headers={"Content-Disposition": f'attachment; filename="{_filename(ch, fmt)}"'})
# Serve the built frontend with an SPA fallback
app.include_router(api)
# Serve the built frontend with an SPA fallback (the app shell is public, all data is behind the API login)
if STATIC.is_dir():
app.mount("/assets", StaticFiles(directory=STATIC / "assets"), name="assets")
@app.get("/{path:path}")
def spa(path: str):
if path.startswith("api/"):
raise HTTPException(404, "Not found")
f = (STATIC / path).resolve()
if path and f.is_file() and STATIC in f.parents:
return FileResponse(f)
-12
View File
@@ -21,18 +21,6 @@
"talents_max": 2,
"proficiency_bonus": 10,
"expertise_bonus": 20,
"kit": {
"weapon_cats": [
"melee",
"firearm"
],
"armor_tiers": [
"light"
],
"resources_min": 1,
"resources_max": 2,
"tools": 1
},
"carry_per_strength_mod": 10
},
+9 -3
View File
@@ -3,7 +3,13 @@ services:
build: .
restart: unless-stopped
ports:
- "8080:8000" # Host-Port bei Bedarf ändern, oder hinter einen Reverse Proxy hängen
- "8080:8000" # change the host port, or put the container behind a reverse proxy
environment:
# Code that new users must enter to create an account (set it in the .env file next to this file).
# Empty = registration is disabled.
AW_REGISTRATION_CODE: ${AW_REGISTRATION_CODE:-}
# Set to 1 when a reverse proxy (nginx, Caddy, Traefik) in front sets X-Forwarded-* headers.
AW_TRUST_PROXY: ${AW_TRUST_PROXY:-0}
volumes:
- ./db:/db # SQLite-Datenbank (Charaktere)
- ./backend/data:/app/data:ro # rules.json: bearbeiten, Seite neu laden, fertig
- ./db:/db # SQLite database (users, characters, presets)
- ./backend/data:/app/data:ro # rules.json: edit, reload the page, done
+34
View File
@@ -0,0 +1,34 @@
import { useState } from 'react'
export default function Account({ user, onLogout }) {
const [f, setF] = useState({ old: '', new: '', new2: '' })
const [msg, setMsg] = useState('')
const [err, setErr] = useState('')
const set = (k) => (e) => setF({ ...f, [k]: e.target.value })
const change = async (e) => {
e.preventDefault(); setMsg(''); setErr('')
if (f.new !== f.new2) return setErr('The two new passwords do not match.')
const r = await fetch('/api/auth/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ old: f.old, new: f.new }) })
const data = await r.json().catch(() => ({}))
if (!r.ok) return setErr(typeof data.detail === 'string' ? data.detail : 'Could not change the password.')
setF({ old: '', new: '', new2: '' }); setMsg('Password changed. Other devices were logged out.')
}
return (
<section className="page narrow">
<h1>Account</h1>
<p>Logged in as <strong>{user}</strong>.</p>
<form className="card" onSubmit={change}>
<h3>Change password</h3>
<label>Current password<input type="password" value={f.old} onChange={set('old')} autoComplete="current-password" required /></label>
<label>New password<input type="password" value={f.new} onChange={set('new')} autoComplete="new-password" required /></label>
<label>Repeat new password<input type="password" value={f.new2} onChange={set('new2')} autoComplete="new-password" required /></label>
{err && <p className="warn" role="alert">{err}</p>}
{msg && <p className="toast" role="status">{msg}</p>}
<button type="submit">Change password</button>
</form>
<p><button className="ghost" onClick={onLogout}>Log out</button></p>
</section>
)
}
+36 -7
View File
@@ -3,6 +3,9 @@ import Creator from './Creator.jsx'
import Rules from './Rules.jsx'
import Characters from './Characters.jsx'
import Presets from './Presets.jsx'
import Login from './Login.jsx'
import Account from './Account.jsx'
import { claimLegacy } from './legacy.js'
const useHash = () => {
const [h, setH] = useState(window.location.hash.slice(1) || 'creator')
@@ -16,16 +19,40 @@ const useHash = () => {
export default function App() {
const hash = useHash()
const [auth, setAuth] = useState(null) // null = checking, {user, registration_open}
const [rules, setRules] = useState(null)
const [error, setError] = useState('')
// Used to load a saved character into the creator
const [loadRequest, setLoadRequest] = useState(null)
const [charName, setCharName] = useState('') // name of the character being worked on, shown in the nav
// Ask the server who we are; any later 401 (session expired) sends us back to the login
useEffect(() => {
fetch('/api/auth/me').then((r) => r.json()).then(setAuth).catch(() => setError('Could not reach the server.'))
const out = () => { setAuth((a) => ({ user: null, registration_open: a?.registration_open })); setRules(null) }
window.addEventListener('aw-unauthorized', out)
return () => window.removeEventListener('aw-unauthorized', out)
}, [])
const username = auth?.user?.username
const loadRules = () => fetch('/api/rules').then((r) => r.json()).then(setRules).catch(() => setError('Could not load the rules.'))
useEffect(() => { loadRules() }, [])
useEffect(() => {
if (!username) return
claimLegacy().finally(loadRules)
}, [username]) // eslint-disable-line
const logout = async () => {
await fetch('/api/auth/logout', { method: 'POST' }).catch(() => {})
window.location.hash = 'creator'
setAuth({ user: null, registration_open: auth.registration_open }); setRules(null)
}
if (error) return <main><p className="warn">{error}</p></main>
if (!auth) return <main><p className="muted">Loading …</p></main>
if (!auth.user) return <main><Login registrationOpen={auth.registration_open} onDone={(u) => setAuth({ ...auth, user: u })} /></main>
const page = hash.split('/')[0]
const nav = [['creator', 'Create character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters']]
const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters']]
return (
<>
@@ -36,19 +63,21 @@ export default function App() {
</a>
<nav>
{nav.map(([id, label]) => (
<a key={id} href={`#${id}`} className={page === id ? 'active' : ''}>{label}</a>
<a key={id} href={`#${id}`} className={`${page === id ? 'active' : ''} ${id === 'creator' ? 'navchar' : ''}`}>{label}</a>
))}
<a href="#account" className={page === 'account' ? 'active' : ''} title="Account">{username}</a>
</nav>
</header>
<main>
{error && <p className="warn">{error}</p>}
{!rules && !error && <p className="muted">Loading rules …</p>}
{rules && page === 'creator' && <Creator rules={rules} loadRequest={loadRequest} clearLoad={() => setLoadRequest(null)} />}
{!rules && <p className="muted">Loading rules …</p>}
{rules && page === 'creator' && <Creator rules={rules} user={username} loadRequest={loadRequest} clearLoad={() => setLoadRequest(null)} onName={setCharName} />}
{rules && page === 'rules' && <Rules rules={rules} section={hash.split('/')[1]} />}
{rules && page === 'presets' && <Presets rules={rules} reload={loadRules} />}
{rules && page === 'characters' && (
<Characters onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }} />
<Characters onOpen={(entry) => { setLoadRequest(entry); window.location.hash = 'creator' }}
onNew={() => { setLoadRequest({ new: true }); window.location.hash = 'creator' }} />
)}
{page === 'account' && <Account user={username} onLogout={logout} />}
</main>
</>
)
+89 -34
View File
@@ -1,50 +1,105 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
const KEY = 'aw_chars'
export const loadList = () => {
try { return JSON.parse(localStorage.getItem(KEY)) || [] } catch { return [] }
}
export const saveList = (l) => {
try { localStorage.setItem(KEY, JSON.stringify(l)) } catch { /* storage blocked */ }
}
export const rememberCharacter = (id, token, name) => {
const l = loadList().filter((c) => c.id !== id)
l.unshift({ id, token, name: name || 'Nameless', updated: Date.now() })
saveList(l)
}
function SharePanel({ c, reload }) {
const [name, setName] = useState('')
const [err, setErr] = useState('')
export default function Characters({ onOpen }) {
const [list, setList] = useState(loadList)
const remove = async (c) => {
if (!window.confirm(`Really delete "${c.name}"?`)) return
await fetch(`/api/characters/${c.id}`, { method: 'DELETE', headers: { 'X-Edit-Token': c.token } }).catch(() => {})
const l = list.filter((x) => x.id !== c.id)
saveList(l); setList(l)
const add = async (e) => {
e.preventDefault(); setErr('')
if (!name.trim()) return
const r = await fetch(`/api/characters/${c.id}/shares`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: name }) })
const data = await r.json().catch(() => ({}))
if (!r.ok) return setErr(typeof data.detail === 'string' ? data.detail : 'Could not share.')
setName(''); reload()
}
const remove = async (u) => {
await fetch(`/api/characters/${c.id}/shares/${encodeURIComponent(u)}`, { method: 'DELETE' }).catch(() => {})
reload()
}
return (
<div className="sharepanel">
<p className="small muted">These users can view this character (read-only), for example your DM:</p>
<div className="chips">
{c.shared_with.length === 0 && <span className="small muted">Nobody yet.</span>}
{c.shared_with.map((u) => (
<span key={u} className="chip on">{u} <button className="linklike" onClick={() => remove(u)} aria-label={`Stop sharing with ${u}`}>×</button></span>
))}
</div>
<form className="row" onSubmit={add}>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Username" aria-label="Username to share with" />
<button type="submit">Share</button>
</form>
{err && <p className="warn small">{err}</p>}
</div>
)
}
export default function Characters({ onOpen, onNew }) {
const [data, setData] = useState(null)
const [open, setOpen] = useState(null) // id of the character whose share panel is open
const [err, setErr] = useState('')
const load = () => fetch('/api/characters').then((r) => r.json()).then(setData).catch(() => setErr('Could not load your characters.'))
useEffect(() => { load() }, [])
const remove = async (c) => {
if (!window.confirm(`Really delete "${c.name}"?`)) return
const r = await fetch(`/api/characters/${c.id}`, { method: 'DELETE' }).catch(() => null)
if (!r || !r.ok) return setErr('Deleting failed.')
load()
}
const when = (t) => new Date(t * 1000).toLocaleString('en-GB')
return (
<section className="page narrow">
<h1>My characters</h1>
<div className="row">
<h1>My characters</h1>
<button onClick={onNew}>+ New character</button>
</div>
<p className="muted">
This list is stored in this browser. The characters themselves live on the server and can be shared by link
(<code>#creator/&lt;ID&gt;</code>), but only this browser holds the key needed to change them.
These characters are saved in your account. Only you and the users you share a character with can see it.
Shared users can look at it but not change it.
</p>
{list.length === 0 && <p className="card">Nothing saved yet. Create a character and click "Save".</p>}
{err && <p className="warn">{err}</p>}
{data && data.mine.length === 0 && <p className="card">Nothing saved yet. Use "New character", then click "Save" on the Summary page.</p>}
<ul className="charlist">
{list.map((c) => (
<li key={c.id} className="card row">
<div>
<strong>{c.name}</strong>
<div className="muted small">ID {c.id} · {new Date(c.updated).toLocaleString('en-GB')}</div>
</div>
<div className="actions">
<button onClick={() => onOpen(c)}>Open</button>
<button className="ghost danger" onClick={() => remove(c)}>Delete</button>
{(data?.mine || []).map((c) => (
<li key={c.id} className="card">
<div className="row">
<div>
<strong>{c.name}</strong>
<div className="muted small">
ID {c.id} · {when(c.updated)}{c.shared_with.length > 0 && ` · shared with ${c.shared_with.join(', ')}`}
</div>
</div>
<div className="actions">
<button onClick={() => onOpen(c)}>Open</button>
<button className="ghost" onClick={() => setOpen(open === c.id ? null : c.id)}>Share</button>
<button className="ghost danger" onClick={() => remove(c)}>Delete</button>
</div>
</div>
{open === c.id && <SharePanel c={c} reload={load} />}
</li>
))}
</ul>
{data && data.shared.length > 0 && (
<>
<h2>Shared with me</h2>
<ul className="charlist">
{data.shared.map((c) => (
<li key={c.id} className="card row">
<div>
<strong>{c.name}</strong>
<div className="muted small">from {c.owner} · {when(c.updated)}</div>
</div>
<div className="actions"><button onClick={() => onOpen(c)}>View</button></div>
</li>
))}
</ul>
</>
)}
</section>
)
}
+66 -74
View File
@@ -1,26 +1,25 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { applyPreset, applyProfession, compute, effectiveRules, emptyCharacter, kitItems, normalize, resetStep, setProfessionPicks } from './calc.js'
import { rememberCharacter } from './Characters.jsx'
import SheetPanel from './SheetPanel.jsx'
const DRAFT = 'aw_draft'
const STEPS = ['Concept', 'Attributes', 'Profession', 'Skills', 'Talents', 'Mutations', 'Equipment', 'Summary']
const SOURCE = { noxium: 'Noxium', diesium: 'Diesium', hybrid: 'Hybrid', precyber: 'Pre-meteor cybernetics' }
const Badge = () => <span className="badge" title="Not yet worked out in the brainstorm">Placeholder</span>
function readDraft() {
try { return JSON.parse(localStorage.getItem(DRAFT)) } catch { return null }
function readDraft(key) {
try { return JSON.parse(localStorage.getItem(key)) } catch { return null }
}
export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
export default function Creator({ rules: baseRules, user, loadRequest, clearLoad, onName }) {
const DRAFT = `aw_draft:${user}` // drafts are kept per account
const freshCharacter = () => applyPreset(emptyCharacter(baseRules), baseRules, baseRules.default_preset || 'standard')
const [ch, setCh] = useState(() => {
const d = readDraft()
const d = readDraft(DRAFT)
return d?.ch ? normalize(d.ch, baseRules) : freshCharacter()
})
// `ch` is the working copy the pages edit; `committed` is the applied state shown as "before" in the side panel
const [committed, setCommitted] = useState(() => {
const d = readDraft()
const d = readDraft(DRAFT)
const src = d?.committed || d?.ch
return src ? normalize(src, baseRules) : freshCharacter()
})
@@ -28,7 +27,7 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
const pending = JSON.stringify(ch) !== JSON.stringify(committed)
const apply = () => setCommitted(ch)
const discard = () => setCh(committed)
const [saved, setSaved] = useState(() => readDraft()?.saved || null) // {id, token}
const [saved, setSaved] = useState(() => readDraft(DRAFT)?.saved || null) // {id}
const [step, setStep] = useState(0)
const [msg, setMsg] = useState('')
const fileRef = useRef(null)
@@ -42,6 +41,8 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
const set = (patch) => setCh((x) => ({ ...x, ...patch }))
const cr = rules.creation
useEffect(() => { onName?.(ch.name) }, [ch.name]) // eslint-disable-line
// Keep the draft in this browser
useEffect(() => {
try { localStorage.setItem(DRAFT, JSON.stringify({ ch, committed, saved })) } catch { /* ignore */ }
@@ -50,8 +51,17 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
// Load a character picked in "My characters"
useEffect(() => {
if (!loadRequest) return
if (loadRequest.new) { // "New character" button on the My characters page
const untouched = JSON.stringify(ch) === JSON.stringify(freshCharacter()) && !saved
if (untouched || window.confirm('Start a new character? Your current draft is discarded unless you saved it.')) {
setBoth(freshCharacter()); setSaved(null); setStep(0)
}
clearLoad()
return
}
fetch(`/api/characters/${loadRequest.id}`).then((r) => { if (!r.ok) throw new Error(); return r.json() })
.then((data) => { setBoth(normalize(data, baseRules)); setSaved({ id: loadRequest.id, token: loadRequest.token }); setStep(7) })
.then((data) => { setBoth(normalize(data, baseRules)); setSaved(loadRequest.owner ? null : { id: loadRequest.id }); setStep(7)
if (loadRequest.owner) setMsg(`This is ${loadRequest.owner}'s character. You can look at it; "Save" creates your own copy.`) })
.catch(() => setMsg('Could not load the character.'))
.finally(clearLoad)
}, [loadRequest]) // eslint-disable-line
@@ -62,7 +72,7 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
if (!id || loadRequest) return
fetch(`/api/characters/${id}`).then((r) => { if (!r.ok) throw new Error(); return r.json() })
.then((data) => { setBoth(normalize(data, baseRules)); setSaved(null); setStep(7); setMsg('Shared character loaded. "Save" creates your own copy.') })
.catch(() => setMsg('Shared character not found.'))
.catch(() => setMsg('Character not found, or it is not shared with you.'))
}, []) // eslint-disable-line
// Moving between pages: pending edits are applied first (Next applies silently, other jumps ask).
@@ -89,15 +99,14 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
const save = async () => {
try {
if (saved) {
const r = await fetch(`/api/characters/${saved.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-Edit-Token': saved.token }, body: JSON.stringify(ch) })
const r = await fetch(`/api/characters/${saved.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ch) })
if (!r.ok) throw new Error()
rememberCharacter(saved.id, saved.token, ch.name)
flash('Saved.')
} else {
const r = await fetch('/api/characters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ch) })
if (!r.ok) throw new Error()
const { id, token } = await r.json()
setSaved({ id, token }); rememberCharacter(id, token, ch.name)
const { id } = await r.json()
setSaved({ id })
flash('Saved. The share link is shown below.')
}
} catch { flash('Saving failed.') }
@@ -357,69 +366,44 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
)
// --- Equipment ---
const kitRule = cr.kit
const items = rules.equipment
const catName = (id) => rules.equipment_categories.find((x) => x.id === id).name
const setKit = (patch) => set({ kit: { ...ch.kit, ...patch } })
const weaponItems = items.filter((e) => kitRule.weapon_cats.includes(e.cat))
const armorItems = items.filter((e) => e.cat === 'armor' && kitRule.armor_tiers.includes(e.tier))
const resourceItems = items.filter((e) => e.cat === 'resource')
const toolItems = items.filter((e) => e.cat === 'tool')
const ItemCard = ({ e, on, onClick, disabled }) => (
<button className={`card choice ${on ? 'on' : ''}`} onClick={onClick} disabled={disabled}>
<strong>{e.name}</strong>
<span className="small muted">{e.stat}{e.tier ? ` (${e.tier})` : ''} · {e.kg} kg</span>
{e.note && <span className="small">{e.note}</span>}
</button>
)
const SingleSlot = ({ title, list, slot }) => (
<>
<h3>{title}</h3>
<div className="cards">{list.map((e) => (
<ItemCard key={e.id} e={e} on={ch.kit[slot] === e.id} onClick={() => setKit({ [slot]: ch.kit[slot] === e.id ? '' : e.id })} />
))}</div>
</>
)
const setExtra = (id, qty) => {
const extras = { ...ch.kit.extras }
if (qty <= 0) delete extras[id]; else extras[id] = qty
setKit({ extras })
set({ kit: { ...ch.kit, extras } })
}
const overWeight = c.weight > c.carryMax
const stepGear = (
<>
<h2>Equipment</h2>
<p className="muted">Starting gear: one weapon (melee or firearm), light armor or clothing, {kitRule.resources_min}-{kitRule.resources_max} resources and one tool. Everything else can be added as extra items below.</p>
<p className="muted">Pick everything your character carries. Ask your GM what is allowed. Use + and − on a tile to change the amount.</p>
<div className={`pool ${overWeight ? 'over' : ''}`}>Carried: <strong>{c.weight}</strong> / {c.carryMax} kg (Strength Mod x {cr.carry_per_strength_mod}){overWeight && ' – overloaded'}</div>
<SingleSlot title="Weapon" list={weaponItems} slot="weapon" />
<SingleSlot title="Armor or clothing" list={armorItems} slot="armor" />
<h3>Resources ({ch.kit.resources.length}/{kitRule.resources_max})</h3>
<div className="cards">{resourceItems.map((e) => {
const on = ch.kit.resources.includes(e.id)
return <ItemCard key={e.id} e={e} on={on} disabled={!on && ch.kit.resources.length >= kitRule.resources_max}
onClick={() => setKit({ resources: on ? ch.kit.resources.filter((x) => x !== e.id) : [...ch.kit.resources, e.id] })} />
})}</div>
<SingleSlot title="Tool or utility item" list={toolItems} slot="tool" />
<h3>Extra items</h3>
<p className="small muted">Anything else from the document's item lists. Ask your GM what you may take along.</p>
{rules.equipment_categories.map((cat) => (
<details key={cat.id}>
<summary>{cat.name} <span className="muted small">({items.filter((e) => e.cat === cat.id).reduce((n, e) => n + (ch.kit.extras[e.id] || 0), 0)} chosen)</span></summary>
<div className="extras">{items.filter((e) => e.cat === cat.id).map((e) => (
<div key={e.id} className="extra">
<div><strong>{e.name}</strong><div className="small muted">{e.stat}{e.tier ? ` (${e.tier})` : ''} · {e.kg} kg{e.note ? ` · ${e.note}` : ''}</div></div>
<div className="stepper">
<button onClick={() => setExtra(e.id, (ch.kit.extras[e.id] || 0) - 1)} aria-label={`${e.name} minus`}>−</button>
<span className="val">{ch.kit.extras[e.id] || 0}</span>
<button onClick={() => setExtra(e.id, (ch.kit.extras[e.id] || 0) + 1)} aria-label={`${e.name} plus`}>+</button>
</div>
</div>
))}</div>
</details>
))}
{rules.equipment_categories.map((cat) => {
const list = items.filter((e) => e.cat === cat.id)
const n = list.reduce((sum, e) => sum + (ch.kit.extras[e.id] || 0), 0)
return (
<div key={cat.id}>
<h4>{cat.name} {n > 0 && <span className="muted small">({n} chosen)</span>}</h4>
<div className="cards">{list.map((e) => {
const qty = ch.kit.extras[e.id] || 0
return (
<div key={e.id} className={`card tile ${qty > 0 ? 'on' : ''}`}>
<strong>{e.name}</strong>
<span className="small muted">{e.stat}{e.tier ? ` (${e.tier})` : ''} · {e.kg} kg</span>
{e.note && <span className="small">{e.note}</span>}
<div className="stepper tilestepper">
<button className="ghost" onClick={() => setExtra(e.id, qty - 1)} disabled={qty === 0} aria-label={`${e.name} minus`}>−</button>
<span className="val">{qty}</span>
<button onClick={() => setExtra(e.id, qty + 1)} aria-label={`${e.name} plus`}>+</button>
</div>
</div>
)
})}</div>
</div>
)
})}
<label>Other items (free text)<textarea rows="3" value={ch.gear.other} onChange={(e) => set({ gear: { ...ch.gear, other: e.target.value } })} placeholder="Anything not in the lists, e.g. a unique starter multi-tool" /></label>
<label>Notes<textarea rows="4" value={ch.notes} onChange={(e) => set({ notes: e.target.value })} /></label>
</>
@@ -438,10 +422,6 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
if (c.profLeft > 0) warnings.push(`${c.profLeft} proficiency pick${c.profLeft > 1 ? 's' : ''} left.`)
if (ch.talents.length < cr.talents_min) warnings.push('No starting talent chosen.')
if (over) warnings.push('Augmentation Capacity exceeded.')
if (!ch.kit.weapon) warnings.push('No starting weapon chosen.')
if (!ch.kit.armor) warnings.push('No armor or clothing chosen.')
if (ch.kit.resources.length < kitRule.resources_min) warnings.push('No starting resource chosen.')
if (!ch.kit.tool) warnings.push('No tool or utility item chosen.')
if (overWeight) warnings.push('Carrying more than the carry capacity.')
const gearLines = kitItems(ch, rules)
@@ -460,11 +440,6 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
<table><tbody>{rules.derived.map((d) => <tr key={d.id}><td>{d.name}</td><td>{c.derived[d.id]}</td></tr>)}</tbody></table>
</div>
<div>
<h4>Skills</h4>
<table><tbody>
{c.skills.filter((s) => s.points || s.prof || (s.unlocked && s.type === 'advanced')).map((s) => <tr key={s.id}><td>{s.name}</td><td>{s.total}</td><td className="muted">{s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : ''}</td></tr>)}
{!c.skills.some((s) => s.points || s.prof || s.unlocked) && <tr><td className="muted">None</td></tr>}
</tbody></table>
<h4>Talents</h4>
<ul className="desclist">{rules.talents.filter((t) => ch.talents.includes(t.id)).map((t) => <li key={t.id}><strong>{t.name}</strong><div className="rowdesc">{t.desc}</div></li>)}</ul>
{!ch.talents.length && <p>–</p>}
@@ -477,6 +452,23 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) {
{ch.gear.other && <p className="small"><strong>Other:</strong> {ch.gear.other}</p>}
</div>
</div>
<h4>Skills</h4>
<div className="skillcols">
{rules.skill_groups.map((g) => (
<div key={g.id} className="skillgroup">
<h5>{g.name}</h5>
<table><tbody>
{c.skills.filter((s) => s.group === g.id).map((s) => (
<tr key={s.id} className={s.usable ? '' : 'dim'}>
<td>{s.name}{s.type === 'advanced' && <span className="muted small"> (adv.)</span>}</td>
<td>{s.total}</td>
<td className="muted">{s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : !s.usable ? 'Locked' : s.points ? `+${s.points}` : ''}</td>
</tr>
))}
</tbody></table>
</div>
))}
</div>
</div>
<div className="actions wrap">
<button onClick={() => download('pdf')}>Download as PDF</button>
+53
View File
@@ -0,0 +1,53 @@
import { useState } from 'react'
export default function Login({ registrationOpen, onDone }) {
const [mode, setMode] = useState('login')
const [f, setF] = useState({ username: '', password: '', password2: '', code: '' })
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const set = (k) => (e) => setF({ ...f, [k]: e.target.value })
const register = mode === 'register'
const submit = async (e) => {
e.preventDefault()
setErr('')
if (register && f.password !== f.password2) return setErr('The two passwords do not match.')
setBusy(true)
try {
const r = await fetch(`/api/auth/${register ? 'register' : 'login'}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(register ? { username: f.username, password: f.password, code: f.code } : { username: f.username, password: f.password }),
})
const data = await r.json().catch(() => ({}))
if (!r.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'Something went wrong.')
onDone(data.user)
} catch (ex) { setErr(ex.message) } finally { setBusy(false) }
}
return (
<section className="page narrow loginbox">
<h1>Arcane Wasteland</h1>
<p className="muted">Character creator. Please {register ? 'create an account' : 'log in'} to continue.</p>
<div className="chips">
<button className={`chip ${!register ? 'on' : ''}`} onClick={() => { setMode('login'); setErr('') }}>Log in</button>
{registrationOpen && <button className={`chip ${register ? 'on' : ''}`} onClick={() => { setMode('register'); setErr('') }}>Create account</button>}
</div>
<form className="card" onSubmit={submit}>
<label>Username<input value={f.username} onChange={set('username')} autoComplete="username" autoFocus required /></label>
<label>Password<input type="password" value={f.password} onChange={set('password')} autoComplete={register ? 'new-password' : 'current-password'} required /></label>
{register && (
<>
<label>Repeat password<input type="password" value={f.password2} onChange={set('password2')} autoComplete="new-password" required /></label>
<label>Registration code
<input type="password" value={f.code} onChange={set('code')} autoComplete="off" required />
<span className="small muted">Ask the person who runs this server.</span>
</label>
<p className="small muted">Username: 3-32 characters (letters, digits, . _ -). Password: at least 8 characters.</p>
</>
)}
{err && <p className="warn" role="alert">{err}</p>}
<button type="submit" disabled={busy}>{register ? 'Create account' : 'Log in'}</button>
</form>
</section>
)
}
+6 -17
View File
@@ -1,20 +1,15 @@
import { useState } from 'react'
const KEY = 'aw_presets' // {presetId: editToken}, only for presets created in this browser
const loadTokens = () => { try { return JSON.parse(localStorage.getItem(KEY)) || {} } catch { return {} } }
const saveTokens = (t) => { try { localStorage.setItem(KEY, JSON.stringify(t)) } catch { /* storage blocked */ } }
const BLANK = { id: null, name: '', desc: '', creation: {} }
export default function Presets({ rules, reload }) {
const [tokens, setTokens] = useState(loadTokens)
const [sel, setSel] = useState(rules.default_preset || 'standard')
const [draft, setDraft] = useState(null) // preset being edited/created, null = viewing
const [msg, setMsg] = useState('')
const presets = rules.presets || []
const current = draft || presets.find((p) => p.id === sel) || presets[0]
const editable = !!draft
const own = !!current?.id && !!tokens[current.id]
const own = !!current?.mine
const base = rules.creation
const groups = [...new Set((rules.tunables || []).map((x) => x.group))]
const value = (k) => (current.creation?.[k] ?? base[k])
@@ -36,17 +31,13 @@ export default function Presets({ rules, reload }) {
try {
const res = await fetch(draft.id ? `/api/presets/${draft.id}` : '/api/presets', {
method: draft.id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json', ...(draft.id ? { 'X-Edit-Token': tokens[draft.id] } : {}) },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail || 'Saving failed')
const data = await res.json()
let id = draft.id
if (!id) {
id = data.id
const t = { ...tokens, [id]: data.token }
saveTokens(t); setTokens(t)
}
if (!id) id = data.id
await reload()
setSel(id); setDraft(null); flash('Preset saved.')
} catch (e) { flash(String(e.message || e)) }
@@ -55,22 +46,20 @@ export default function Presets({ rules, reload }) {
const remove = async () => {
if (!window.confirm(`Delete the preset "${current.name}"? Characters that already use it keep their numbers.`)) return
try {
const res = await fetch(`/api/presets/${current.id}`, { method: 'DELETE', headers: { 'X-Edit-Token': tokens[current.id] } })
const res = await fetch(`/api/presets/${current.id}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Deleting failed')
const t = { ...tokens }; delete t[current.id]
saveTokens(t); setTokens(t)
await reload(); setSel(rules.default_preset || 'standard')
} catch (e) { flash(String(e.message || e)) }
}
const kind = (p) => (p.builtin ? 'Built in' : tokens[p.id] ? 'Yours' : 'Shared')
const kind = (p) => (p.builtin ? 'Built in' : p.mine ? 'Yours' : p.author ? `By ${p.author}` : 'Shared')
return (
<section className="page">
<h1>Rules presets</h1>
<p className="muted narrow">
A preset is a named set of creation numbers (points for attributes and skills, talents, proficiency ...). Pick one on the Concept page of the creator.
Presets are stored on the server, so everyone using this site can use them; only the browser that created a preset can change or delete it.
Presets are stored on the server, so everyone using this site can use them; only the account that created a preset can change or delete it.
Built-in presets come from <code>rules.json</code>. Duplicate one to make your own version.
</p>
<div className="presets-layout">
+1 -1
View File
@@ -7,7 +7,7 @@ function Rows({ now, before, valueLabel }) {
return (
<table className="difftable"><tbody>
{rows.map((r) => (
<tr key={r.k} className={`d-${r.status}`}>
<tr key={r.k} className={`d-${r.status} ${r.dim ? 'dim' : ''}`}>
<td>{r.status === 'added' ? '+ ' : r.status === 'removed' ? '− ' : ''}{r.label}{r.desc && <div className="rowdesc">{r.desc}</div>}</td>
<td>
{r.status === 'changed' && r.old.val !== r.val && r.old.val !== undefined && <span className="was">{r.old.val} → </span>}
+13 -8
View File
@@ -12,7 +12,7 @@ export function emptyCharacter(rules) {
preset: rules.default_preset || 'standard', creation: {}, // creation = overrides of rules.creation for this character
skill_points: {}, proficiencies: {}, proficiency_picks: {}, unlocked: [],
talents: [], augments: [],
kit: { weapon: '', armor: '', tool: '', resources: [], extras: {} },
kit: { extras: {} }, // item id -> quantity
gear: { other: '' },
notes: '',
}
@@ -65,7 +65,7 @@ export function normalize(ch, rules) {
const preset = ch.preset || rules.default_preset || 'standard'
const creation = ch.creation ?? presetOverrides(rules, preset)
const base = emptyCharacter(effectiveRules(rules, { creation }))
const out = { ...base, ...ch, preset, creation, kit: { ...base.kit, ...(ch.kit || {}) }, gear: { ...base.gear, ...(ch.gear || {}) } }
const out = { ...base, ...ch, preset, creation, kit: { extras: {}, ...(ch.kit || {}) }, gear: { ...base.gear, ...(ch.gear || {}) } }
if (typeof out.skill_pool === 'number') { // older characters stored the pool size on the character
if (out.skill_pool !== effectiveRules(rules, out).creation.skill_points_default) {
out.creation = { ...out.creation, skill_points_default: out.skill_pool }
@@ -73,6 +73,13 @@ export function normalize(ch, rules) {
}
}
delete out.skill_pool
// Older characters had starting-kit slots (weapon, armor, tool, resources): they become ordinary items
const k = out.kit
if (k.weapon || k.armor || k.tool || (k.resources || []).length) {
const extras = { ...k.extras }
;[k.weapon, k.armor, k.tool, ...(k.resources || [])].filter(Boolean).forEach((id) => { extras[id] = (extras[id] || 0) + 1 })
out.kit = { extras }
}
// The preset may have been deleted since: fall back to whatever matches the numbers
if (out.preset !== 'custom' && !(rules.presets || []).some((x) => x.id === out.preset)) out.preset = matchPreset(rules, out.creation)
// Older characters: advanced skills that already hold points count as unlocked
@@ -92,12 +99,10 @@ export function pruneLocked(ch, rules) {
export function kitItems(ch, rules) {
const items = Object.fromEntries(rules.equipment.map((e) => [e.id, e]))
const k = ch.kit
const out = []
;[['Weapon', k.weapon], ['Armor', k.armor], ['Tool', k.tool]].forEach(([label, id]) => { if (items[id]) out.push({ label, item: items[id], qty: 1 }) })
k.resources.forEach((id) => { if (items[id]) out.push({ label: 'Resource', item: items[id], qty: 1 }) })
Object.entries(k.extras).forEach(([id, qty]) => { if (items[id] && qty > 0) out.push({ label: 'Extra', item: items[id], qty }) })
return out
const cats = Object.fromEntries((rules.equipment_categories || []).map((c) => [c.id, c.name]))
return Object.entries(ch.kit.extras || {})
.filter(([id, qty]) => items[id] && qty > 0)
.map(([id, qty]) => ({ label: cats[items[id].cat] || '', item: items[id], qty }))
}
export function compute(ch, rules) {
+16
View File
@@ -0,0 +1,16 @@
// Characters and presets made before accounts existed were protected by secret keys in this browser.
// After login they are handed over to the account once; the old keys are then removed.
export async function claimLegacy() {
const post = (kind, id, token) =>
fetch(`/api/claim/${kind}/${id}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }) }).catch(() => null)
try {
const chars = JSON.parse(localStorage.getItem('aw_chars')) || []
for (const c of chars) if (c?.id && c?.token) await post('characters', c.id, c.token)
localStorage.removeItem('aw_chars')
} catch { /* nothing to claim */ }
try {
const presets = JSON.parse(localStorage.getItem('aw_presets')) || {}
for (const [id, token] of Object.entries(presets)) await post('presets', id, token)
localStorage.removeItem('aw_presets')
} catch { /* nothing to claim */ }
}
+9
View File
@@ -3,4 +3,13 @@ import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './styles.css'
// A 401 from any data call means the session ended: tell the app so it shows the login page.
const realFetch = window.fetch.bind(window)
window.fetch = async (...args) => {
const res = await realFetch(...args)
const url = String(typeof args[0] === 'string' ? args[0] : args[0]?.url || '')
if (res.status === 401 && url.startsWith('/api/') && !url.startsWith('/api/auth/login')) window.dispatchEvent(new Event('aw-unauthorized'))
return res
}
createRoot(document.getElementById('root')).render(<App />)
+2 -3
View File
@@ -8,7 +8,7 @@ export function sheetModel(ch, baseRules) {
const cls = rules.classes.find((k) => k.id === ch.class_id)
const sub = cls?.subclasses.find((x) => x.id === ch.subclass_id)
const preset = ch.preset === 'custom' ? 'Custom' : baseRules.presets?.find((p) => p.id === ch.preset)?.name || 'Custom'
const training = (s) => (s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : s.type === 'advanced' && s.unlocked ? 'Unlocked' : '')
const training = (s) => (s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : !s.usable ? 'Locked' : s.type === 'advanced' ? 'Unlocked' : '')
return {
c,
head: [
@@ -19,8 +19,7 @@ export function sheetModel(ch, baseRules) {
],
attrs: rules.attributes.map((a) => ({ k: a.id, label: a.name, val: c.attrs[a.id], extra: `Mod ${c.mods[a.id]}` })),
derived: rules.derived.map((d) => ({ k: d.id, label: d.name, val: c.derived[d.id] })),
skills: c.skills.filter((s) => s.points || s.prof || (s.unlocked && s.type === 'advanced'))
.map((s) => ({ k: s.id, label: s.name, val: s.total, extra: training(s) })),
skills: c.skills.map((s) => ({ k: s.id, label: s.name, val: s.total, extra: training(s), dim: !s.usable })),
talents: rules.talents.filter((t) => ch.talents.includes(t.id)).map((t) => ({ k: t.id, label: t.name, desc: t.desc })),
augments: rules.augments.filter((a) => ch.augments.includes(a.id)).map((a) => ({ k: a.id, label: a.name, desc: `${a.kind === 'implant' ? 'Implant' : 'Mutation'} · cost ${a.cost}${a.desc ? ` · ${a.desc}` : ''}` })),
gear: kitItems(ch, rules).map((g) => ({ k: g.item.id, label: g.item.name, val: g.qty, desc: [g.item.stat, `${g.item.kg} kg`, g.item.note].filter(Boolean).join(' · ') })),
+15
View File
@@ -147,3 +147,18 @@ ul.desclist { list-style: none; padding: 0; margin: 0 0 .5rem; display: grid; ga
main { max-width: 1280px; }
@media (max-width: 1100px) { .creator-body { grid-template-columns: minmax(0, 1fr) 360px; } }
@media (max-width: 800px) { .creator-body { grid-template-columns: 1fr; } }
tr.dim td { opacity: .55; }
.skillcols { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0 2rem; }
.skillgroup h5 { margin: .8em 0 .2em; color: var(--diesium); font-size: .9rem; }
.loginbox { margin-top: 3rem; }
.loginbox form button[type="submit"] { margin-top: .5rem; }
.sharepanel { margin-top: .8rem; padding-top: .6rem; border-top: 1px solid var(--line); }
.sharepanel form input { flex: 1; }
.chip button.linklike { color: inherit; text-decoration: none; margin-left: .2rem; }
.top nav a.navchar { max-width: 22ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card.tile { display: flex; flex-direction: column; gap: .3rem; }
.card.tile.on { border-color: var(--noxium); box-shadow: inset 0 0 0 1px var(--noxium); background: var(--panel2); }
.tilestepper { margin-top: auto; padding-top: .4rem; justify-content: flex-end; }