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 (