Editing a beat opened a fixed 110px box. Player actions are one line, but AI beats are several paragraphs, so editing one meant working through a keyhole. New AutoTextarea sizes to its content on mount and on every change; CSS keeps a 110px floor so a short player action doesn't collapse, and a 65vh ceiling that scrolls internally so Save/Cancel can't be pushed off the screen. On phones both composers are one flex row, which left almost nothing to type into: measured at a 390px composer, the Play textarea got 138px (Do/Say/Story eat ~180px, Send ~75px) and the chat textarea 310px. Both now wrap, with the buttons on their own line and the textarea taking the full 390px — Play keeps Do/Say/Story and Send together on the top row, chat drops Send beneath. Play stays two rows, so nothing gets taller. Desktop is unchanged; every rule is inside the existing max-width:720px block. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FGY1yvzSeKgTtRfeVtDmx
339 lines
12 KiB
React
339 lines
12 KiB
React
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||
import { api } from './api'
|
||
|
||
export function downloadJSON(obj, filename) {
|
||
const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = filename
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
|
||
export function pickJSONFile() {
|
||
return new Promise((resolve, reject) => {
|
||
const input = document.createElement('input')
|
||
input.type = 'file'
|
||
input.accept = '.json,application/json'
|
||
input.onchange = () => {
|
||
const file = input.files[0]
|
||
if (!file) return reject(new Error('No file selected'))
|
||
const reader = new FileReader()
|
||
reader.onload = () => {
|
||
try { resolve(JSON.parse(reader.result)) }
|
||
catch { reject(new Error('Not valid JSON')) }
|
||
}
|
||
reader.onerror = () => reject(new Error('Could not read file'))
|
||
reader.readAsText(file)
|
||
}
|
||
input.click()
|
||
})
|
||
}
|
||
|
||
// ---------- Scenario art ----------
|
||
|
||
// FNV-1a. Any stable hash works; the point is that a given title always maps to
|
||
// the same plate, so the library looks the same on every visit and every device.
|
||
function hashString(str) {
|
||
let hash = 2166136261
|
||
for (let i = 0; i < str.length; i++) {
|
||
hash ^= str.charCodeAt(i)
|
||
hash = Math.imul(hash, 16777619)
|
||
}
|
||
return hash >>> 0
|
||
}
|
||
|
||
// Deep jewel ramps that sit under gold without competing with it — the accent
|
||
// stays the brightest thing on the card. Ordered so adjacent library entries
|
||
// rarely land on neighbouring hues.
|
||
const ART_RAMPS = [
|
||
['#14424a', '#0b2328'], // drowned teal
|
||
['#4d2130', '#250f19'], // wine
|
||
['#2b2a5c', '#13122c'], // indigo
|
||
['#4a2a17', '#231309'], // rust
|
||
['#24401f', '#0f1c0c'], // moss
|
||
['#3a2b52', '#1b1328'], // violet ash
|
||
['#1f3346', '#0f1a25'], // cold slate
|
||
['#4a3a16', '#231b09'], // ochre
|
||
]
|
||
|
||
// Up to two letters from the title's most significant words.
|
||
function monogram(title) {
|
||
const words = (title || '')
|
||
.replace(/^\[[^\]]*\]\s*/, '') // drop a leading "[Demo]" style label
|
||
.split(/\s+/)
|
||
.filter((word) => word.length > 2 && !/^(the|and|of|a|an|from|for)$/i.test(word))
|
||
const letters = (words.length ? words : ['?']).slice(0, 2).map((w) => w[0])
|
||
return letters.join('').toUpperCase()
|
||
}
|
||
|
||
/** A scenario's plate: uploaded picture, else emoji, else generated art.
|
||
*
|
||
* `large` is for the Continue cards, where the plate carries more weight. The
|
||
* generated tier means no card is ever an empty box, so a fresh library still
|
||
* reads as a shelf of distinct things.
|
||
*/
|
||
export function ScenarioArt({ image, icon, title, large = false }) {
|
||
const [failed, setFailed] = useState(false)
|
||
const ramp = ART_RAMPS[hashString(title || '') % ART_RAMPS.length]
|
||
const className = `art${large ? ' art-lg' : ''}`
|
||
|
||
// A stored image can 404 (scenario deleted its art in another tab, or an
|
||
// external URL rotted) — fall through to the generated tier rather than
|
||
// showing a broken-image glyph.
|
||
if (image && !failed) {
|
||
return (
|
||
<span className={`${className} art-photo`}>
|
||
<img src={image} alt="" loading="lazy" onError={() => setFailed(true)} />
|
||
</span>
|
||
)
|
||
}
|
||
|
||
const style = { background: `linear-gradient(150deg, ${ramp[0]}, ${ramp[1]})` }
|
||
if (icon) {
|
||
return <span className={`${className} art-icon`} style={style} aria-hidden="true">{icon}</span>
|
||
}
|
||
return (
|
||
<span className={`${className} art-mono`} style={style} aria-hidden="true">
|
||
{monogram(title)}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
// ---------- Loading skeletons ----------
|
||
|
||
/** Placeholder cards shown while a list loads, so the grid doesn't pop in. */
|
||
export function CardSkeleton({ count = 3, lines = 2 }) {
|
||
return (
|
||
<div className="card-grid" aria-hidden="true">
|
||
{Array.from({ length: count }, (_, i) => (
|
||
<div key={i} className="card skeleton-card" style={{ animationDelay: `${i * 70}ms` }}>
|
||
<div className="sk sk-plate" />
|
||
<div className="sk sk-title" />
|
||
{Array.from({ length: lines }, (_, line) => (
|
||
<div key={line} className="sk sk-line" style={{ width: line === lines - 1 ? '62%' : '100%' }} />
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---------- Toasts ----------
|
||
|
||
const ToastContext = createContext(() => {})
|
||
|
||
/** `const toast = useToast()` then `toast('Imported')` or `toast(msg, 'error')`. */
|
||
export function useToast() {
|
||
return useContext(ToastContext)
|
||
}
|
||
|
||
const TOAST_MS = 4200
|
||
|
||
export function ToastHost({ children }) {
|
||
const [toasts, setToasts] = useState([])
|
||
const nextId = useRef(1)
|
||
const timers = useRef(new Map())
|
||
|
||
const dismiss = useCallback((id) => {
|
||
setToasts((current) => current.filter((t) => t.id !== id))
|
||
const timer = timers.current.get(id)
|
||
if (timer) {
|
||
clearTimeout(timer)
|
||
timers.current.delete(id)
|
||
}
|
||
}, [])
|
||
|
||
const push = useCallback((message, kind = 'info') => {
|
||
const id = nextId.current++
|
||
setToasts((current) => [...current, { id, message: String(message), kind }])
|
||
timers.current.set(id, setTimeout(() => dismiss(id), TOAST_MS))
|
||
}, [dismiss])
|
||
|
||
// Clear pending timers if the host ever unmounts mid-flight.
|
||
useEffect(() => () => timers.current.forEach(clearTimeout), [])
|
||
|
||
return (
|
||
<ToastContext.Provider value={push}>
|
||
{children}
|
||
<div className="toast-host" role="status" aria-live="polite">
|
||
{toasts.map((toast) => (
|
||
<button
|
||
key={toast.id}
|
||
type="button"
|
||
className={`toast toast-${toast.kind}`}
|
||
onClick={() => dismiss(toast.id)}
|
||
title="Dismiss"
|
||
>
|
||
<span className="toast-mark" aria-hidden="true">{toast.kind === 'error' ? '✕' : '❖'}</span>
|
||
<span>{toast.message}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</ToastContext.Provider>
|
||
)
|
||
}
|
||
|
||
// Unique ${Placeholder} names, in order of first appearance, across the given texts.
|
||
export function extractPlaceholders(...texts) {
|
||
const names = []
|
||
for (const text of texts) {
|
||
for (const match of (text || '').matchAll(/\$\{([^}]+)\}/g)) {
|
||
const name = match[1].trim()
|
||
if (name && !names.includes(name)) names.push(name)
|
||
}
|
||
}
|
||
return names
|
||
}
|
||
|
||
export function PlaceholderModal({ title, names, onSubmit, onCancel }) {
|
||
const [values, setValues] = useState(Object.fromEntries(names.map((n) => [n, ''])))
|
||
|
||
const submit = (e) => {
|
||
e.preventDefault()
|
||
onSubmit(values)
|
||
}
|
||
|
||
return (
|
||
<div className="modal-overlay" onClick={onCancel}>
|
||
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
|
||
<h2>{title}</h2>
|
||
<p className="modal-hint">This scenario asks a few questions before you begin.</p>
|
||
{names.map((name, i) => (
|
||
<label key={name} className="field">
|
||
<span className="label">{name}</span>
|
||
<input
|
||
type="text"
|
||
autoFocus={i === 0}
|
||
value={values[name]}
|
||
onChange={(e) => setValues({ ...values, [name]: e.target.value })}
|
||
/>
|
||
</label>
|
||
))}
|
||
<div className="modal-buttons">
|
||
<button type="button" onClick={onCancel}>Cancel</button>
|
||
<button type="submit" className="primary">Begin Adventure</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Phase 8: register/login for the hosted multi-user mode. `onAuthed(me)` gets
|
||
// the fresh /auth/me payload after success.
|
||
export function AuthModal({ mode: initialMode, onClose, onAuthed }) {
|
||
const [mode, setMode] = useState(initialMode || 'register')
|
||
const [email, setEmail] = useState('')
|
||
const [password, setPassword] = useState('')
|
||
const [error, setError] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
const registering = mode === 'register'
|
||
|
||
const submit = async (e) => {
|
||
e.preventDefault()
|
||
setBusy(true)
|
||
setError('')
|
||
try {
|
||
const me = registering
|
||
? await api.register(email, password)
|
||
: await api.login(email, password)
|
||
onAuthed(me, mode)
|
||
} catch (err) {
|
||
setError(err.message)
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="modal-overlay" onClick={onClose}>
|
||
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
|
||
<h2>{registering ? 'Create an account' : 'Log in'}</h2>
|
||
<p className="modal-hint">
|
||
{registering
|
||
? 'Everything you’ve played as a guest stays with your new account, and you can pick it up from any device.'
|
||
: 'Welcome back — log in to reach your adventures.'}
|
||
</p>
|
||
<label className="field">
|
||
<span className="label">Email</span>
|
||
<input type="email" autoFocus required value={email}
|
||
onChange={(e) => setEmail(e.target.value)} />
|
||
</label>
|
||
<label className="field">
|
||
<span className="label">Password{registering ? ' (at least 8 characters)' : ''}</span>
|
||
<input type="password" required minLength={registering ? 8 : undefined} value={password}
|
||
onChange={(e) => setPassword(e.target.value)} />
|
||
</label>
|
||
{error && <div className="test-error" style={{ marginTop: 4 }}>{error}</div>}
|
||
<div className="modal-buttons" style={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<button type="button" className="linklike" onClick={() => { setMode(registering ? 'login' : 'register'); setError('') }}>
|
||
{registering ? 'Have an account? Log in' : 'New here? Create an account'}
|
||
</button>
|
||
<div style={{ display: 'flex', gap: 10 }}>
|
||
<button type="button" onClick={onClose}>Cancel</button>
|
||
<button type="submit" className="primary" disabled={busy}>
|
||
{busy ? '…' : registering ? 'Sign up' : 'Log in'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** A textarea that grows to fit its content instead of sitting at a fixed
|
||
height. Editing an AI beat means editing a few paragraphs, and a fixed box
|
||
made that a keyhole. CSS min/max-height still bound it (the cap scrolls),
|
||
so callers control the floor and ceiling from the stylesheet. */
|
||
export function AutoTextarea({ value, ...props }) {
|
||
const ref = useRef(null)
|
||
|
||
useEffect(() => {
|
||
const el = ref.current
|
||
if (!el) return
|
||
// Reset first: scrollHeight only ever grows while an explicit height is set,
|
||
// so without this the box could never shrink back down.
|
||
el.style.height = 'auto'
|
||
el.style.height = `${el.scrollHeight}px`
|
||
}, [value])
|
||
|
||
return <textarea ref={ref} value={value} {...props} />
|
||
}
|
||
|
||
export function Field({ label, value, onChange, textarea, rows, placeholder }) {
|
||
return (
|
||
<label className="field">
|
||
<span className="label">{label}</span>
|
||
{textarea ? (
|
||
<textarea rows={rows || 3} value={value} placeholder={placeholder} onChange={(e) => onChange(e.target.value)} />
|
||
) : (
|
||
<input type="text" value={value} placeholder={placeholder} onChange={(e) => onChange(e.target.value)} />
|
||
)}
|
||
</label>
|
||
)
|
||
}
|
||
|
||
export function StoryCardRow({ card, onChange, onDelete }) {
|
||
return (
|
||
<div className="storycard">
|
||
<div className="row">
|
||
<input type="text" placeholder="Name (not sent to AI)" value={card.name}
|
||
onChange={(e) => onChange({ ...card, name: e.target.value })} />
|
||
<input type="text" placeholder="Type (e.g. Character)" value={card.type}
|
||
onChange={(e) => onChange({ ...card, type: e.target.value })} />
|
||
</div>
|
||
<div className="row">
|
||
<input type="text" placeholder="Triggers, comma-separated" value={card.keys}
|
||
onChange={(e) => onChange({ ...card, keys: e.target.value })} />
|
||
</div>
|
||
<textarea rows={2} placeholder="Entry — sent to the AI when a trigger matches" value={card.entry}
|
||
onChange={(e) => onChange({ ...card, entry: e.target.value })} />
|
||
<div style={{ textAlign: 'right', marginTop: 6 }}>
|
||
<button className="danger" style={{ padding: '3px 10px', fontSize: '0.78rem' }} onClick={onDelete}>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|