Sheet: large portrait slot, writable notes in summary and lobby
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
@@ -461,7 +461,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
|
||||
<>
|
||||
<h2>Summary</h2>
|
||||
{warnings.length > 0 && <ul className="warn">{warnings.map((w) => <li key={w}>{w}</li>)}</ul>}
|
||||
<PaperSheet data={ch} baseRules={baseRules} />
|
||||
<PaperSheet data={ch} baseRules={baseRules} onNotes={(p) => setBoth({ ...ch, ...p })} />
|
||||
<div className="actions wrap">
|
||||
{!npc && <button onClick={() => download('pdf')}>Download as PDF</button>}
|
||||
{!npc && <button onClick={() => download('docx')}>Download as Word</button>}
|
||||
|
||||
@@ -524,23 +524,32 @@ function DifficultyBar({ lobby, onSet }) {
|
||||
}
|
||||
|
||||
// ---- sheet of one character, refreshed regularly so the DM sees changes the player saves
|
||||
function CharTab({ cid, rules, onRoll, tick }) {
|
||||
function CharTab({ cid, rules, onRoll, tick, editable }) {
|
||||
const [data, setData] = useState(null)
|
||||
const [err, setErr] = useState('')
|
||||
const typing = useRef(0) // time of the last keystroke: refreshes must not overwrite what is being typed
|
||||
const timer = useRef(null)
|
||||
const saveNotes = (patch) => {
|
||||
typing.current = Date.now()
|
||||
setData((d) => ({ ...d, ...patch }))
|
||||
clearTimeout(timer.current)
|
||||
timer.current = setTimeout(() => { send(`/api/characters/${cid}/notes`, 'PUT', patch).catch(() => setErr('Could not save the notes.')) }, 700)
|
||||
}
|
||||
useEffect(() => () => clearTimeout(timer.current), [])
|
||||
useEffect(() => {
|
||||
setData(null); setErr('')
|
||||
const load = () => fetch(`/api/characters/${cid}`).then((r) => { if (!r.ok) throw new Error(); return r.json() }).then(setData).catch(() => setErr('Could not load this character.'))
|
||||
const load = () => fetch(`/api/characters/${cid}`).then((r) => { if (!r.ok) throw new Error(); return r.json() }).then((d) => { if (Date.now() - typing.current > 4000) setData(d) }).catch(() => setErr('Could not load this character.'))
|
||||
load()
|
||||
const t = setInterval(() => !document.hidden && load(), 15000)
|
||||
return () => clearInterval(t)
|
||||
}, [cid])
|
||||
useEffect(() => { // pushed by the server when the player saved the character
|
||||
if (tick) fetch(`/api/characters/${cid}`).then((r) => (r.ok ? r.json() : null)).then((d) => d && setData(d)).catch(() => {})
|
||||
if (tick) fetch(`/api/characters/${cid}`).then((r) => (r.ok ? r.json() : null)).then((d) => d && Date.now() - typing.current > 4000 && setData(d)).catch(() => {})
|
||||
}, [tick]) // eslint-disable-line
|
||||
return (
|
||||
<div>
|
||||
{err && <p className="warn">{err}</p>}
|
||||
{data && <PaperSheet data={data} baseRules={rules} bare onRoll={onRoll} />}
|
||||
{data && <PaperSheet data={data} baseRules={rules} bare onRoll={onRoll} onNotes={editable ? saveNotes : undefined} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -804,7 +813,7 @@ function LobbyView({ id, rules, user }) {
|
||||
{lobby.is_dm && leftTab === 'lobby'
|
||||
? <><MapLibrary activeId={mapState.map?.id} lobbyId={id} act={act} /><SoundSettings lobby={lobby} id={id} act={act} sfx={sfx.current} /><Settings inline lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} /></>
|
||||
: npcTok ? <CharTab key={`npc${npcTok.npc_id}`} cid={npcTok.npc_id} rules={rules} tick={sheetTick.cid === npcTok.npc_id ? sheetTick.n : 0} onRoll={(k, i, n) => check(k, i, n, npcTok.id)} />
|
||||
: cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} tick={sheetTick.cid === cur.character_id ? sheetTick.n : 0} onRoll={cur.me && canCheck ? check : undefined} />
|
||||
: cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} tick={sheetTick.cid === cur.character_id ? sheetTick.n : 0} onRoll={cur.me && canCheck ? check : undefined} editable={cur.me} />
|
||||
: <p className="muted small">{lobby.is_dm ? 'Pick a character in the bar to look at its sheet.' : 'You have no character in this lobby.'}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
+36
-15
@@ -31,6 +31,8 @@ export function printSheet(root, title) {
|
||||
const win = window.open('', '_blank')
|
||||
if (!win) { window.alert('Please allow pop-ups for this site to print the sheet.'); return }
|
||||
const clone = root.cloneNode(true)
|
||||
const src = root.querySelectorAll('textarea'), dst = clone.querySelectorAll('textarea')
|
||||
dst.forEach((t, i) => { t.textContent = src[i].value }) // typed text does not survive cloning on its own
|
||||
clone.style.zoom = ''
|
||||
clone.classList.remove('dark') // printed pages are always light
|
||||
const head = [...document.querySelectorAll('link[rel=stylesheet], style')].map((n) => n.outerHTML).join('')
|
||||
@@ -47,6 +49,17 @@ export function printSheet(root, title) {
|
||||
links.forEach((l) => { l.addEventListener('load', () => { if (--pending === 0) setTimeout(go, 150) }); l.addEventListener('error', () => { if (--pending === 0) go() }) })
|
||||
}
|
||||
|
||||
// Ruled note area. With onChange (own sheet in the summary or the lobby) it can be typed into, otherwise it just shows the text.
|
||||
function Notes({ title, value, onChange }) {
|
||||
return (
|
||||
<div className="psec pnotes"><h3>{title}</h3>
|
||||
{onChange
|
||||
? <textarea className="plines pwrite" value={value || ''} maxLength={5000} onChange={(e) => onChange(e.target.value)} placeholder="Click here to write …" aria-label={title} />
|
||||
: <div className="plines pwrite">{value}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Scales the A4 pages down when the space is narrower than the paper
|
||||
function Fit({ children, rootRef, dark }) {
|
||||
const wrap = useRef(null)
|
||||
@@ -68,7 +81,7 @@ const Pips = ({ n, locked }) => (
|
||||
)
|
||||
|
||||
// The character sheet as A4 pages (page 1: attributes and skills, page 2: talents, mutations, equipment, notes).
|
||||
export default function PaperSheet({ data, baseRules, tools, bare, onRoll }) {
|
||||
export default function PaperSheet({ data, baseRules, tools, bare, onRoll, onNotes }) {
|
||||
const ch = useMemo(() => normalize(data, baseRules), [data, baseRules])
|
||||
const rules = useMemo(() => effectiveRules(baseRules, ch), [baseRules, ch])
|
||||
const c = useMemo(() => compute(ch, rules), [ch, rules])
|
||||
@@ -108,18 +121,26 @@ export default function PaperSheet({ data, baseRules, tools, bare, onRoll }) {
|
||||
)}
|
||||
<Fit rootRef={root} dark={dark}>
|
||||
<section className="paper">
|
||||
<header className="phead">
|
||||
<div className="pname">{name}</div>
|
||||
{ch.portrait && <img className="pportrait" src={ch.portrait} alt="" />}
|
||||
<div className="pbrand">Arcane Wasteland<span>Character sheet</span></div>
|
||||
</header>
|
||||
<div className="pfields">
|
||||
<Field label="Player">{ch.player}</Field>
|
||||
<Field label="Concept">{ch.concept}</Field>
|
||||
<Field label="Class">{cls && [cls.name, sub?.name].filter(Boolean).join(' / ')}</Field>
|
||||
<Field label="Profession">{prof?.name}</Field>
|
||||
<Field label="Rules">{preset}</Field>
|
||||
</div>
|
||||
{(() => {
|
||||
const head = (
|
||||
<header className="phead">
|
||||
<div className="pname">{name}</div>
|
||||
<div className="pbrand">Arcane Wasteland<span>Character sheet</span></div>
|
||||
</header>
|
||||
)
|
||||
const fields = (
|
||||
<div className="pfields">
|
||||
<Field label="Player">{ch.player}</Field>
|
||||
<Field label="Concept">{ch.concept}</Field>
|
||||
<Field label="Class">{cls && [cls.name, sub?.name].filter(Boolean).join(' / ')}</Field>
|
||||
<Field label="Profession">{prof?.name}</Field>
|
||||
<Field label="Rules">{preset}</Field>
|
||||
</div>
|
||||
)
|
||||
return ch.portrait
|
||||
? <div className="pidentity"><img className="pportrait" src={ch.portrait} alt="" /><div className="pidright">{head}{fields}</div></div>
|
||||
: <>{head}{fields}</>
|
||||
})()}
|
||||
|
||||
<div className="pattrs">
|
||||
{rules.attributes.map((a) => (
|
||||
@@ -165,7 +186,7 @@ export default function PaperSheet({ data, baseRules, tools, bare, onRoll }) {
|
||||
{data.loot && <><h3>Loot</h3><p className="pre">{data.loot}</p></>}
|
||||
</div>
|
||||
)}
|
||||
<div className="psec pnotes"><h3>Conditions & notes</h3><div className="plines" /></div>
|
||||
<Notes title="Conditions & notes" value={ch.conditions} onChange={onNotes && ((v) => onNotes({ conditions: v }))} />
|
||||
<Foot n={1} />
|
||||
</section>
|
||||
|
||||
@@ -208,7 +229,7 @@ export default function PaperSheet({ data, baseRules, tools, bare, onRoll }) {
|
||||
|
||||
{ch.background && <div className="psec"><h3>Background</h3><p className="pbg">{ch.background}</p></div>}
|
||||
|
||||
<div className="psec pnotes"><h3>Notes</h3><div className="plines" /></div>
|
||||
<Notes title="Notes" value={ch.notes} onChange={onNotes && ((v) => onNotes({ notes: v }))} />
|
||||
<Foot n={2} />
|
||||
</section>
|
||||
</Fit>
|
||||
|
||||
@@ -14,7 +14,7 @@ export function emptyCharacter(rules) {
|
||||
talents: [], augments: [],
|
||||
kit: { extras: {} }, // item id -> quantity
|
||||
gear: { other: '' },
|
||||
notes: '',
|
||||
notes: '', conditions: '',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-1
@@ -476,7 +476,6 @@ label.chk input[type=checkbox] { width: auto; margin: 0; }
|
||||
.cropwin { position: relative; overflow: hidden; border-radius: 10px; border: 2px solid var(--noxium, #7aa63c); background: #000; cursor: move; touch-action: none; flex: none; }
|
||||
.cropwin img { position: absolute; max-width: none; user-select: none; }
|
||||
.cropctl { display: grid; gap: .5rem; }
|
||||
.pportrait { width: 21mm; height: 21mm; object-fit: cover; border: 1.5px solid var(--pg); border-radius: 1.5mm; flex: none; margin-left: auto; }
|
||||
.pavatar { overflow: hidden; border: 2px solid var(--pc); }
|
||||
.pavatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.maptoken .tokpic { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 50%; object-fit: cover; pointer-events: none; }
|
||||
@@ -564,3 +563,14 @@ body.resizing { cursor: col-resize; user-select: none; }
|
||||
.lobbygrid, .lobbygrid:not(.nosheet) { grid-template-columns: minmax(0, 1fr); }
|
||||
.sheetsplit, .chatsplit { display: none; }
|
||||
}
|
||||
|
||||
/* portrait next to name and identity fields */
|
||||
.pidentity { display: flex; gap: 5mm; align-items: stretch; }
|
||||
.pidright { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3mm; justify-content: flex-end; }
|
||||
.pportrait { width: 36mm; height: 42mm; object-fit: cover; border: 1.5px solid var(--pg); border-radius: 1.5mm; flex: none; align-self: flex-start; }
|
||||
.pidright .pfields { grid-template-columns: 1fr 1fr 1fr; }
|
||||
.pidright .pfield:nth-child(2) { grid-column: span 2; }
|
||||
.pwrite { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; line-height: calc(6.4mm + 1px); padding: 0; color: var(--pi); border: 0; resize: none; width: 100%; box-sizing: border-box; background-attachment: local; min-height: 18mm; }
|
||||
textarea.pwrite { background-color: transparent; outline: none; field-sizing: content; }
|
||||
textarea.pwrite:focus { box-shadow: 0 0 0 1px var(--pg); }
|
||||
.pwrite::placeholder { color: var(--pm); opacity: .6; }
|
||||
|
||||
Reference in New Issue
Block a user