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:
@@ -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
@@ -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
@@ -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/<ID></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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -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 />)
|
||||
|
||||
@@ -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(' · ') })),
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user