/* global React, ReactDOM, Kw, parseText, Parsed, CHAPTERS, TweaksPanel, useTweaks, TweakSection, TweakRadio, TweakToggle, TweakSelect */ const { useState, useEffect, useMemo, useRef, useCallback } = React; const API_BASE = window.API_BASE !== undefined ? window.API_BASE : "http://localhost:8000"; function lsGet(key) { try { return JSON.parse(localStorage.getItem(key)); } catch { return null; } } function lsSet(key, val) { try { localStorage.setItem(key, JSON.stringify(val)); } catch {} } /* ============================================================ PAGE RENDERERS — one function per content type. Adding a new tab = write a renderer here + register in RENDERERS. All field names match the real FastAPI schema. ============================================================ */ function SpellPage({ item, folio, eyebrow }) { const paras = (item.description || "").split("\n\n").filter(Boolean); return (
{eyebrow} {folio}

{item.name}

{item.school} {" · "} {item.circle + "º círculo"} {" · "} {item.type}

{item.casting_time &&
Execução
{item.casting_time}
} {item.range &&
Alcance
{item.range}
} {item.target &&
Alvo / Área
{item.target}
} {item.duration &&
Duração
{item.duration}
} {item.saving_throw &&
Resistência
{item.saving_throw}
}
{paras.map((para, i) => (

{para}

))}
); } function PowerPage({ item, folio, eyebrow }) { const paras = (item.description || "").split("\n\n").filter(Boolean); return (
{eyebrow} {folio}

{item.name}

{item.power_type} {" · "} {item.power_category}

{item.prerequisites && (
Pré-requisito
{item.prerequisites}
)}
{paras.map((para, i) => (

{para}

))}
); } function EmptyPage({ folio, eyebrow }) { return (
{eyebrow} {folio}
✦ ✦ ✦
Fim do capítulo.
); } function CoverPage({ chapter }) { return (

{chapter.label}

{chapter.subtitle === "Em breve" ? "Este capítulo ainda está sendo escrito." : "Folheie pelas próximas páginas, ou use o sumário à esquerda."}

· · ·
); } function Corners() { return ( <> ); } const RENDERERS = { magias: SpellPage, poderes: PowerPage, }; /* ============================================================ FILTERING + SEARCH (client-side, on loaded data) ============================================================ */ function applyFilters(items, filters, query) { let out = items; for (const [groupId, values] of Object.entries(filters)) { if (groupId === "_defs" || !Array.isArray(values) || values.length === 0) continue; const def = filters._defs && filters._defs[groupId]; if (def) out = out.filter((it) => values.some((v) => def.match(it, v))); } if (query && query.trim()) { const q = query.toLowerCase().trim(); out = out.filter((it) => [it.name, it.description, it.school, it.power_type, it.prerequisites] .filter(Boolean) .some((f) => f.toLowerCase().includes(q)) ); } return out; } async function unifiedSearch(query, limit = 12) { if (!query || !query.trim()) return []; try { const res = await fetch( `${API_BASE}/search?query=${encodeURIComponent(query)}&limit=${limit}` ); const results = await res.json(); const CHAPTER_MAP = { spell: "magias", power: "poderes" }; return results.map((r) => ({ item: r.data, chapter: (window.CHAPTERS || []).find((c) => c.id === CHAPTER_MAP[r.result_type]), score: r.score, })).filter((r) => r.chapter); } catch { return []; } } /* ============================================================ BOOK INDEX (left margin: filters) ============================================================ */ function BookIndex({ chapter, filters, setFilters, counts }) { if (chapter.disabled) { return ( ); } if (chapter.id === "racas") { const savedRace = (() => { try { return JSON.parse(localStorage.getItem("arton_char_race")); } catch { return null; } })(); const savedOrigin = (() => { try { return JSON.parse(localStorage.getItem("arton_char_origin")); } catch { return null; } })(); return ( ); } if (chapter.id === "origens") { const savedRace = (() => { try { return JSON.parse(localStorage.getItem("arton_char_race")); } catch { return null; } })(); const savedOrigin = (() => { try { return JSON.parse(localStorage.getItem("arton_char_origin")); } catch { return null; } })(); return ( ); } if (chapter.id === "divindade") { const savedDeity = (() => { try { return JSON.parse(localStorage.getItem("arton_char_deity")); } catch { return null; } })(); return ( ); } if (chapter.id === "classes") { return ( ); } return ( ); } const kbdStyle = { fontFamily: "var(--font-mono)", fontSize: 10, border: "1px solid var(--gilt-2)", borderRadius: 3, padding: "0 4px", fontStyle: "normal", color: "var(--gilt-0)", }; /* ============================================================ TABS (right side: chapters) ============================================================ */ function BookTabs({ chapterId, setChapterId, chapters }) { return ( ); } /* ============================================================ SPREAD (the actual book pages, sliding horizontally) ============================================================ */ function Frame({ chapter, items, idx }) { const Renderer = RENDERERS[chapter.id] || (() => ); const a = items[idx * 2]; const b = items[idx * 2 + 1]; const base = idx * 2; return (
{a ? ( ) : idx === 0 ? ( ) : ( )}
{b ? ( ) : ( )}
); } function Spread({ chapter, items, spreadIdx, animation }) { const [prev, setPrev] = useState(spreadIdx); const [animating, setAnimating] = useState(false); const [direction, setDirection] = useState(1); const transitionRef = useRef(null); useEffect(() => { if (spreadIdx === prev) return; setDirection(spreadIdx > prev ? 1 : -1); setAnimating(true); clearTimeout(transitionRef.current); transitionRef.current = setTimeout(() => { setPrev(spreadIdx); setAnimating(false); }, 560); return () => clearTimeout(transitionRef.current); }, [spreadIdx, prev]); if (animation === "fade") { return (
); } // SLIDE — track holds [outgoing, incoming], both fully rendered. const outgoing = animating ? (direction > 0 ? prev : spreadIdx) : spreadIdx; const incoming = animating ? (direction > 0 ? spreadIdx : prev) : null; return (
0 ? "next" : "prev"} 0.55s var(--ease-page) both` : "none", }} > {incoming != null && }
); } /* ============================================================ COMMAND PALETTE (Ctrl+K) — busca unificada via API semântica ============================================================ */ function CmdK({ open, onClose, onPick }) { const [q, setQ] = useState(""); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const inputRef = useRef(); const debounceRef = useRef(null); useEffect(() => { if (open) setTimeout(() => inputRef.current?.focus(), 50); }, [open]); useEffect(() => { if (!open) { setQ(""); setResults([]); } }, [open]); useEffect(() => { clearTimeout(debounceRef.current); if (!q.trim()) { setResults([]); return; } setLoading(true); debounceRef.current = setTimeout(async () => { const r = await unifiedSearch(q, 12); setResults(r); setLoading(false); }, 280); return () => clearTimeout(debounceRef.current); }, [q]); if (!open) return null; return (
e.stopPropagation()}> setQ(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") onClose(); }} />
{q.trim() === "" && (
Digite o nome de uma magia, poder, condição… ou descreva o efeito.
)} {loading && (
Consultando o oráculo…
)} {!loading && results.map(({ item, chapter }) => (
{ onPick(chapter, item); onClose(); }} >
{item.name}
{item.description && item.description.slice(0, 80) + "…"}
{chapter.label}
))} {!loading && q.trim() !== "" && results.length === 0 && (
O oráculo nada vê.
)}
); } /* ============================================================ HISTORY DRAWER ============================================================ */ function HistoryDrawer({ open, onClose, history, onPick }) { return (

Páginas Visitadas

{history.length === 0 && (

Nenhuma página visitada ainda.

)} {history.map((h, i) => (
onPick(h)}> {h.name} {h.chapterLabel}
))}
); } /* ============================================================ LOADING SCREEN ============================================================ */ function LoadingScreen({ error }) { return (
{error ? ( <>
Falha ao conectar com o oráculo
{error}
Verifique se o servidor está rodando em {API_BASE}
) : (
Abrindo o grimório…
)}
); } /* ============================================================ ROOT APP ============================================================ */ const DEFAULTS = /*EDITMODE-BEGIN*/{ "theme": "light", "animation": "slide", "ornaments": "medium", "fontDisplay": "Cinzel", "fontBody": "EB Garamond" }/*EDITMODE-END*/; function App() { const [tweaks, setTweak] = useTweaks(DEFAULTS); const [ready, setReady] = useState(false); const [loadError, setLoadError] = useState(null); const [chapters, setChapters] = useState([]); const [chapterId, setChapterId] = useState(() => lsGet("arton_chapter") || "magias"); const [filters, setFilters] = useState(() => lsGet(`arton_filters_${lsGet("arton_chapter") || "magias"}`) || {}); const [query, setQuery] = useState(""); const [spreadIdx, setSpreadIdx] = useState(() => lsGet(`arton_spread_${lsGet("arton_chapter") || "magias"}`) || 0); const [cmdkOpen, setCmdkOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [history, setHistory] = useState([]); const [importOpen, setImportOpen] = useState(false); const searchInputRef = useRef(); // wait for DATA_READY promise useEffect(() => { window.DATA_READY.then(() => { setChapters(window.CHAPTERS || []); setReady(true); }).catch((err) => { setLoadError(err.message || String(err)); }); }, []); const chapter = useMemo( () => chapters.find((c) => c.id === chapterId) || chapters[0] || { id: "", label: "", data: [], filterGroups: [], disabled: false }, [chapters, chapterId] ); useEffect(() => { lsSet("arton_chapter", chapterId); setSpreadIdx(lsGet(`arton_spread_${chapterId}`) || 0); setFilters(lsGet(`arton_filters_${chapterId}`) || {}); }, [chapterId]); useEffect(() => { lsSet(`arton_spread_${chapterId}`, spreadIdx); }, [spreadIdx, chapterId]); useEffect(() => { lsSet(`arton_filters_${chapterId}`, filters); }, [filters, chapterId]); const filtersWithDefs = useMemo(() => { const defs = {}; for (const g of chapter.filterGroups || []) defs[g.id] = g; return { ...filters, _defs: defs }; }, [filters, chapter]); const items = useMemo( () => applyFilters(Array.isArray(chapter.data) ? chapter.data : [], filtersWithDefs, query), [chapter, filtersWithDefs, query] ); const totalSpreads = Math.max(1, Math.ceil(items.length / 2)); const counts = useMemo(() => { const r = {}; const data = Array.isArray(chapter.data) ? chapter.data : []; for (const g of chapter.filterGroups || []) { r[g.id] = {}; for (const v of g.values) { r[g.id][v] = data.filter((it) => g.match(it, v)).length; } } return r; }, [chapter]); const next = useCallback( () => setSpreadIdx((i) => Math.min(i + 1, totalSpreads - 1)), [totalSpreads] ); const prev = useCallback(() => setSpreadIdx((i) => Math.max(i - 1, 0)), []); useEffect(() => { const item = items[spreadIdx * 2]; if (!item) return; setHistory((h) => { const next = [ { chapterId: chapter.id, chapterLabel: chapter.label, itemId: item.id, name: item.name }, ...h.filter((x) => x.itemId !== item.id), ].slice(0, 12); return next; }); }, [spreadIdx, items, chapter]); useEffect(() => { const onKey = (e) => { if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") { if (e.key === "Escape") e.target.blur(); return; } if (e.key === "ArrowRight") next(); else if (e.key === "ArrowLeft") prev(); else if (e.key === "/") { e.preventDefault(); searchInputRef.current?.focus(); } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); setCmdkOpen(true); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [next, prev]); useEffect(() => { document.documentElement.setAttribute("data-theme", tweaks.theme); document.documentElement.style.setProperty( "--font-display", `"${tweaks.fontDisplay}", serif` ); document.documentElement.style.setProperty( "--font-body", `"${tweaks.fontBody}", serif` ); }, [tweaks.theme, tweaks.fontDisplay, tweaks.fontBody]); const handlePickResult = (ch, item) => { setChapterId(ch.id); setFilters({}); setQuery(""); setTimeout(() => { const idx = (Array.isArray(ch.data) ? ch.data : []).findIndex((x) => x.id === item.id); if (idx >= 0) setSpreadIdx(Math.floor(idx / 2)); }, 50); }; const handleHistoryPick = (h) => { const ch = chapters.find((c) => c.id === h.chapterId); if (!ch) return; const item = (ch.data || []).find((x) => x.id === h.itemId); if (!item) return; handlePickResult(ch, item); setHistoryOpen(false); }; if (!ready) return ; return (
{/* Top bar */}
A
ESCRIBA DE ARTON guia para escribas e errantes
{ setQuery(e.target.value); setSpreadIdx(0); }} onKeyDown={(e) => { if (e.key === "Escape") e.target.blur(); }} /> / Ctrl+K
{/* Book */}
{chapter.id === "classes" ? : chapter.id === "racas" ? : chapter.id === "origens" ? : chapter.id === "divindade" ? : chapter.id === "pericias" ? : chapter.id === "ficha" ? : }
{/* Footer — hidden on creator tabs */}
{items.length === 0 ? "Nenhum resultado" : `Fólio ${spreadIdx * 2 + 1}${ items[spreadIdx * 2 + 1] ? "–" + (spreadIdx * 2 + 2) : "" } de ${items.length}`}
setCmdkOpen(false)} onPick={handlePickResult} /> setHistoryOpen(false)} history={history} onPick={handleHistoryPick} /> setTweak("theme", v)} /> setTweak("fontDisplay", v)} /> setTweak("fontBody", v)} /> setTweak("animation", v)} /> setTweak("ornaments", v)} /> {window.ImportSheetModal && ( setImportOpen(false)} /> )}
); } ReactDOM.createRoot(document.getElementById("root")).render(); const _attrPanelRoot = document.getElementById("attr-panel-root"); if (_attrPanelRoot && window.AttrPanel) { ReactDOM.createRoot(_attrPanelRoot).render(); }