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()
}
/** Initials for an NPC's avatar disc — "Bandit Leader" → BL, "gwen" → GW.
*
* Deliberately not `monogram`: that one drops short words, which is right for
* scenario titles and wrong for names, and it has no id to fall back on.
*/
export function npcInitials(name, id) {
const src = String(name || id || '?').trim() || '?'
const words = src.split(/[\s_-]+/).filter(Boolean)
const letters = words.length > 1 ? words[0][0] + words[1][0] : src.slice(0, 2)
return letters.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 (
setFailed(true)} />
)
}
const style = { background: `linear-gradient(150deg, ${ramp[0]}, ${ramp[1]})` }
if (icon) {
return
}
return (
)
}
// ---------- Loading skeletons ----------
/** Placeholder cards shown while a list loads, so the grid doesn't pop in. */
export function CardSkeleton({ count = 3, lines = 2 }) {
return (