/* 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 (
{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 (
{item.name}
{item.power_type}
{" · "}
{item.power_category}
{item.prerequisites && (
- Pré-requisito
- {item.prerequisites}
)}
❦
{paras.map((para, i) => (
{para}
))}
);
}
function EmptyPage({ folio, eyebrow }) {
return (
);
}
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 */}
{/* Book */}
{chapter.id === "classes"
?
: chapter.id === "racas"
?
: chapter.id === "origens"
?
: chapter.id === "divindade"
?
: chapter.id === "pericias"
?
: chapter.id === "ficha"
?
:
}
{/* Footer — hidden on creator tabs */}
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();
}