Add lore texts
This commit is contained in:
+128
-5
@@ -19,7 +19,11 @@ import { Arrow, wavyLine } from "../assets/icons";
|
||||
import { Weapons, hasDifferentProfiles } from "./Weapons";
|
||||
import { useIndexedDB } from "../helpers/useIndexedDB"; // New hook for IndexedDB
|
||||
import { ImgEditor } from "./ImgEditor";
|
||||
import { trySettingLocalStorage } from "../helpers/useLocalStorage";
|
||||
import {
|
||||
trySettingLocalStorage,
|
||||
useLocalStorage,
|
||||
} from "../helpers/useLocalStorage";
|
||||
import { useLore } from "../helpers/useLore";
|
||||
import { HIDE_UNIT_COMPOSITION, ShortSummaryTable } from "../fork";
|
||||
|
||||
const getShortSummarySubtitle = (force) => {
|
||||
@@ -46,6 +50,7 @@ export const Roster = ({
|
||||
onePerPage,
|
||||
colorUserChoice,
|
||||
primaryColor,
|
||||
showLore,
|
||||
}) => {
|
||||
if (!roster) {
|
||||
return null;
|
||||
@@ -70,6 +75,7 @@ export const Roster = ({
|
||||
force={force}
|
||||
onePerPage={onePerPage}
|
||||
colorUserChoice={colorUserChoice}
|
||||
showLore={showLore}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
@@ -77,7 +83,7 @@ export const Roster = ({
|
||||
);
|
||||
};
|
||||
|
||||
const Force = ({ force, onePerPage, colorUserChoice }) => {
|
||||
const Force = ({ force, onePerPage, colorUserChoice, showLore }) => {
|
||||
const { units, factionRules, rules, catalog } = force;
|
||||
const mergedRules = new Map([...factionRules, ...rules]);
|
||||
|
||||
@@ -173,6 +179,7 @@ const Force = ({ force, onePerPage, colorUserChoice }) => {
|
||||
onePerPage={onePerPage}
|
||||
forceRules={rules}
|
||||
colorUserChoice={colorUserChoice}
|
||||
showLore={showLore}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -181,7 +188,14 @@ const Force = ({ force, onePerPage, colorUserChoice }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
|
||||
const Unit = ({
|
||||
unit,
|
||||
catalog,
|
||||
onePerPage,
|
||||
forceRules,
|
||||
colorUserChoice,
|
||||
showLore,
|
||||
}) => {
|
||||
const [hide, setHide] = useState(false);
|
||||
const hideModelCount = HIDE_UNIT_COMPOSITION;
|
||||
const uploadRef = useRef();
|
||||
@@ -201,6 +215,38 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
|
||||
const hasImage = image && image !== "undefined";
|
||||
const [bgRemoved, setBgRemoved] = useState(false);
|
||||
|
||||
// Flavour text, as the official cards print it beside the model image. The
|
||||
// export is matched on the unit name, and whatever it comes back with can be
|
||||
// edited in place - the name match is a heuristic, and a few names carry
|
||||
// lore for more than one faction. An empty edit falls back to the export.
|
||||
const lore = useLore();
|
||||
const [loreOverride, setLoreOverride] = useLocalStorage(`lore_${name}`);
|
||||
const loreText =
|
||||
loreOverride && loreOverride !== "undefined"
|
||||
? loreOverride
|
||||
: lore?.lookup(name, catalog);
|
||||
const hasLore = showLore && Boolean(loreText);
|
||||
|
||||
// The panel is positioned absolutely, so a long legend cannot push the header
|
||||
// taller by itself and the last lines would be clipped - the longest entry in
|
||||
// the export overruns a 15rem header at any width below about 1300px. Measure
|
||||
// the text instead and let the header grow. Observed rather than measured
|
||||
// once, because the wrap changes with the card width.
|
||||
const loreRef = useRef(null);
|
||||
const [loreHeight, setLoreHeight] = useState(0);
|
||||
// hasLore is what mounts the observed element, so the effect has to re-run on
|
||||
// it; a ref is not a reactive value, so the rule cannot see that.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: see above
|
||||
useEffect(() => {
|
||||
const element = loreRef.current;
|
||||
if (!element) return;
|
||||
const observer = new ResizeObserver(() =>
|
||||
setLoreHeight(element.scrollHeight),
|
||||
);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [hasLore]);
|
||||
|
||||
const weapons = [...meleeWeapons, ...rangedWeapons];
|
||||
|
||||
const weaponDescriptions = weapons
|
||||
@@ -332,6 +378,8 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
|
||||
<div
|
||||
className="min-h-[15rem]"
|
||||
style={{
|
||||
// 15rem unless the legend needs more; 44px is the panel's padding.
|
||||
minHeight: hasLore ? `max(15rem, ${loreHeight + 44}px)` : undefined,
|
||||
paddingTop: 24,
|
||||
paddingBottom: 4,
|
||||
background:
|
||||
@@ -430,20 +478,95 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{hasLore && (
|
||||
<>
|
||||
{/* Wider than the text it sits behind, so the model image fades
|
||||
into the dark rather than ending at a hard edge. */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: "40%",
|
||||
zIndex: 101,
|
||||
pointerEvents: "none",
|
||||
background:
|
||||
"linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,.55) 40%, rgba(0,0,0,.7) 100%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: "29%",
|
||||
zIndex: 102,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "30px 14px 14px 6px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Editable in place: see the note on `loreText` above. */}
|
||||
<div
|
||||
ref={loreRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
spellCheck={false}
|
||||
title="Click to correct this text. Clearing it restores the text from Lore.csv."
|
||||
onBlur={(e) =>
|
||||
setLoreOverride(e.currentTarget.innerText.trim())
|
||||
}
|
||||
style={{
|
||||
fontStyle: "italic",
|
||||
// index.css turns font synthesis off globally, so if the
|
||||
// italic face fails to load this would print upright. Let
|
||||
// this one element fall back to a slanted upright face.
|
||||
fontSynthesis: "style",
|
||||
fontSize: "1rem",
|
||||
lineHeight: 1.32,
|
||||
textShadow: "0 1px 2px rgba(0,0,0,.6)",
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
{loreText}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
// The image gives up its right-hand third to the lore panel, which
|
||||
// is where the official cards put the flavour text. Cards without
|
||||
// lore keep the full-width image they have always had.
|
||||
right: hasLore ? "29%" : 0,
|
||||
top: 0,
|
||||
height: "100%",
|
||||
bottom: 0,
|
||||
width: "60%",
|
||||
width: hasLore ? "40%" : "60%",
|
||||
zIndex: 100,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{hasImage && <ImgEditor image={image} name={name} />}
|
||||
<div className="absolute right-[1px] top-[3px] flex items-center gap-1.5">
|
||||
{showLore && lore && !loreText && (
|
||||
<button
|
||||
type="button"
|
||||
className="button print-display-none border-none bg-[#f0f0f0e6] hover:bg-[#f0f0f0]"
|
||||
style={{
|
||||
padding: "1px 4px",
|
||||
fontSize: "0.8rem",
|
||||
}}
|
||||
onClick={() => setLoreOverride(`Lore for ${name}.`)}
|
||||
title="Lore.csv has no entry under this name. Add the text by hand."
|
||||
>
|
||||
Add lore
|
||||
</button>
|
||||
)}
|
||||
{hasImage && !bgRemoved && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
+18
@@ -84,6 +84,7 @@ function App() {
|
||||
const [roster, setRoster] = useState();
|
||||
const [edition, setEdition] = useState(10); // [9, 10, 11]
|
||||
const [onePerPage, setOnePerPage] = useState(false);
|
||||
const [showLore, setShowLore] = useState(true);
|
||||
const [primaryColor, setPrimaryColor] = useState("#536766");
|
||||
const [colorUserChoice, setColorUserChoice] = useState(false);
|
||||
const uploadRef = useRef();
|
||||
@@ -419,6 +420,22 @@ function App() {
|
||||
One Datacard per Page when Printing
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
minHeight: 26,
|
||||
}}
|
||||
title="Print the flavour text from public/Lore.csv beside the model image, the way the official datasheets do."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showLore}
|
||||
onChange={(e) => setShowLore(e.target.checked)}
|
||||
/>
|
||||
<span className="select-none">Show Lore Text</span>
|
||||
</label>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<input
|
||||
@@ -464,6 +481,7 @@ function App() {
|
||||
onePerPage={onePerPage}
|
||||
colorUserChoice={colorUserChoice}
|
||||
primaryColor={primaryColor}
|
||||
showLore={showLore}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Lookup of the flavour text that official datasheets print to the right of the
|
||||
// model image. The data lives in `public/Lore.csv`, a pipe-delimited export of
|
||||
// `name|legend` (plus an optional `faction` column, see `buildLoreIndex`).
|
||||
//
|
||||
// Nothing about roster names is reliable enough for an exact lookup: the same
|
||||
// unit is spelled "Tech-priest Dominus" in one place and "Tech-Priest Dominus"
|
||||
// in another, a roster may name a unit in the plural where the export uses the
|
||||
// singular ("Myphitic Blight-haulers" / "Myphitic Blight-hauler"), and faction
|
||||
// catalogues prefix names that the export does not ("Thousand Sons Chaos
|
||||
// Spawn" / "Chaos Spawn"). So the index is keyed by a normalised form and
|
||||
// consulted through three widening attempts.
|
||||
|
||||
/**
|
||||
* Case, accents, curly quotes and punctuation all vary between the export and
|
||||
* the roster, and none of them carry meaning here, so collapse the lot.
|
||||
*/
|
||||
export const normalizeName = (name) =>
|
||||
String(name ?? "")
|
||||
.normalize("NFKD")
|
||||
.replace(/\p{M}/gu, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.trim();
|
||||
|
||||
/**
|
||||
* Crude singularisation of the last word, which is the only place a roster and
|
||||
* the export tend to disagree on number. Deliberately not a real stemmer: it
|
||||
* runs over both sides of the comparison, so it only has to be consistent, not
|
||||
* correct.
|
||||
*/
|
||||
const singularize = (word) => {
|
||||
if (word.length < 4) return word;
|
||||
if (word.endsWith("ies")) return `${word.slice(0, -3)}y`;
|
||||
if (/(?:ss|sh|ch|x|z)es$/.test(word)) return word.slice(0, -2);
|
||||
if (word.endsWith("s") && !word.endsWith("ss")) return word.slice(0, -1);
|
||||
return word;
|
||||
};
|
||||
|
||||
const stemKey = (normalized) => {
|
||||
const words = normalized.split(" ");
|
||||
if (!words.length) return normalized;
|
||||
words[words.length - 1] = singularize(words[words.length - 1]);
|
||||
return words.join(" ");
|
||||
};
|
||||
|
||||
const splitLines = (text) =>
|
||||
String(text ?? "")
|
||||
.replace(/^\ufeff/, "")
|
||||
.split(/\r?\n/);
|
||||
|
||||
/**
|
||||
* Parses the export into `{ name, legend, faction }` rows. Rows without a
|
||||
* legend are dropped - the export carries a few hundred of them, one per unit
|
||||
* whose lore has not been transcribed yet, and they would otherwise shadow a
|
||||
* usable entry for the same name.
|
||||
*
|
||||
* Columns are located by the header line, so adding a `faction` column (see
|
||||
* `pickEntry`) does not need a code change.
|
||||
*/
|
||||
export const parseLore = (text) => {
|
||||
const lines = splitLines(text).filter((line) => line.trim());
|
||||
if (!lines.length) return [];
|
||||
|
||||
const header = lines[0].split("|").map((h) => h.trim().toLowerCase());
|
||||
const nameCol = header.indexOf("name");
|
||||
const legendCol = header.indexOf("legend");
|
||||
const factionCol = header.indexOf("faction");
|
||||
if (nameCol === -1 || legendCol === -1) return [];
|
||||
|
||||
const rows = [];
|
||||
for (const line of lines.slice(1)) {
|
||||
const fields = line.split("|");
|
||||
const name = fields[nameCol]?.trim();
|
||||
const legend = fields[legendCol]?.trim();
|
||||
if (!name || !legend) continue;
|
||||
rows.push({
|
||||
name,
|
||||
legend,
|
||||
faction: factionCol === -1 ? "" : (fields[factionCol]?.trim() ?? ""),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
||||
/**
|
||||
* Several names carry more than one legend - either the same unit reworded
|
||||
* between editions, or a genuinely different unit sharing a name across
|
||||
* factions (Chaos Daemons and Death Guard both field Plaguebearers; three
|
||||
* different armies field a Ministorum Priest).
|
||||
*
|
||||
* Given a `faction` column in the export, that ambiguity is resolvable and we
|
||||
* prefer the entry whose faction matches the card. Without one - which is the
|
||||
* case for today's export - fall back to the longest legend. That is not
|
||||
* always the *right* variant, but it is deterministic, and the competing
|
||||
* variants are near-identical rewrites in all but a handful of cases.
|
||||
*/
|
||||
const pickEntry = (entries, faction) => {
|
||||
const wanted = normalizeName(faction);
|
||||
if (wanted) {
|
||||
const match = entries.find((entry) => {
|
||||
const entryFaction = normalizeName(entry.faction);
|
||||
return (
|
||||
entryFaction &&
|
||||
(entryFaction === wanted ||
|
||||
wanted.includes(entryFaction) ||
|
||||
entryFaction.includes(wanted))
|
||||
);
|
||||
});
|
||||
if (match) return match;
|
||||
}
|
||||
return entries.reduce((best, entry) =>
|
||||
entry.legend.length > best.legend.length ? entry : best,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the lookup. `lookup(name, faction)` returns the legend string, or
|
||||
* `undefined` when the unit has no entry.
|
||||
*/
|
||||
export const buildLoreIndex = (text) => {
|
||||
const exact = new Map();
|
||||
const stems = new Map();
|
||||
|
||||
for (const row of parseLore(text)) {
|
||||
const key = normalizeName(row.name);
|
||||
if (!key) continue;
|
||||
if (!exact.has(key)) exact.set(key, []);
|
||||
exact.get(key).push(row);
|
||||
|
||||
const stem = stemKey(key);
|
||||
if (!stems.has(stem)) stems.set(stem, []);
|
||||
stems.get(stem).push(row);
|
||||
}
|
||||
|
||||
// Multi-word keys, longest first, for the trailing-name pass below. Single
|
||||
// word keys are excluded: "Guard" or "Rangers" would match half the export.
|
||||
const suffixKeys = [...stems.keys()]
|
||||
.filter((key) => key.includes(" "))
|
||||
.sort((a, b) => b.length - a.length);
|
||||
|
||||
const lookup = (name, faction) => {
|
||||
const normalized = normalizeName(name);
|
||||
if (!normalized) return undefined;
|
||||
|
||||
const entries =
|
||||
exact.get(normalized) ??
|
||||
stems.get(stemKey(normalized)) ??
|
||||
// Last resort: the roster name ends with a name we know, which is how
|
||||
// faction-prefixed datasheets ("Thousand Sons Chaos Spawn") arrive.
|
||||
stems.get(
|
||||
suffixKeys.find((key) => stemKey(normalized).endsWith(` ${key}`)),
|
||||
);
|
||||
|
||||
return entries ? pickEntry(entries, faction).legend : undefined;
|
||||
};
|
||||
|
||||
return { lookup, size: exact.size };
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildLoreIndex, normalizeName, parseLore } from "./lore";
|
||||
|
||||
const csv = (...lines) => `name|legend\n${lines.join("\n")}\n`;
|
||||
|
||||
describe("normalizeName", () => {
|
||||
it("collapses the casing, punctuation and quote style that vary between exports", () => {
|
||||
expect(normalizeName("Tech-priest Dominus")).toBe("tech priest dominus");
|
||||
expect(normalizeName("Tech-Priest Dominus")).toBe("tech priest dominus");
|
||||
expect(normalizeName("Khorne’s Hounds")).toBe("khorne s hounds");
|
||||
expect(normalizeName("Khorne's Hounds")).toBe("khorne s hounds");
|
||||
});
|
||||
|
||||
it("survives missing input", () => {
|
||||
expect(normalizeName(undefined)).toBe("");
|
||||
expect(normalizeName(null)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLore", () => {
|
||||
it("reads the pipe-delimited export, BOM and CRLF included", () => {
|
||||
const rows = parseLore("name|legend\r\nCustodian Guard|Stalwart.\r\n");
|
||||
expect(rows).toEqual([
|
||||
{ name: "Custodian Guard", legend: "Stalwart.", faction: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops rows whose legend has not been filled in", () => {
|
||||
const rows = parseLore(csv("Webway Gate|", "Bonesinger|Sings to bone."));
|
||||
expect(rows.map((row) => row.name)).toEqual(["Bonesinger"]);
|
||||
});
|
||||
|
||||
it("locates columns by header, so an added faction column just works", () => {
|
||||
const rows = parseLore(
|
||||
"legend|faction|name\nSings to bone.|Aeldari|Bonesinger\n",
|
||||
);
|
||||
expect(rows).toEqual([
|
||||
{ name: "Bonesinger", legend: "Sings to bone.", faction: "Aeldari" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns nothing for junk rather than throwing", () => {
|
||||
expect(parseLore("")).toEqual([]);
|
||||
expect(parseLore("unit;text\nfoo;bar")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildLoreIndex", () => {
|
||||
it("matches names that differ only in case or punctuation", () => {
|
||||
const { lookup } = buildLoreIndex(csv("Tech-priest Dominus|Theocrat."));
|
||||
expect(lookup("Tech-Priest Dominus")).toBe("Theocrat.");
|
||||
});
|
||||
|
||||
it("matches a plural roster name against a singular entry", () => {
|
||||
const { lookup } = buildLoreIndex(csv("Myphitic Blight-hauler|Belching."));
|
||||
expect(lookup("Myphitic Blight-haulers")).toBe("Belching.");
|
||||
});
|
||||
|
||||
it("matches a singular roster name against a plural entry", () => {
|
||||
const { lookup } = buildLoreIndex(csv("Plaguebearers|Foot soldiers."));
|
||||
expect(lookup("Plaguebearer")).toBe("Foot soldiers.");
|
||||
});
|
||||
|
||||
it("strips a faction prefix the export does not carry", () => {
|
||||
const { lookup } = buildLoreIndex(csv("Chaos Spawn|Roiling flesh."));
|
||||
expect(lookup("Thousand Sons Chaos Spawn")).toBe("Roiling flesh.");
|
||||
});
|
||||
|
||||
it("will not match on a single trailing word", () => {
|
||||
const { lookup } = buildLoreIndex(csv("Guard|Some other unit entirely."));
|
||||
expect(lookup("Custodian Guard")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefers the entry whose faction matches the card", () => {
|
||||
const { lookup } = buildLoreIndex(
|
||||
"name|legend|faction\n" +
|
||||
"Plaguebearers|Daemon version, which is the longer of the two.|Chaos Daemons\n" +
|
||||
"Plaguebearers|Guard version.|Death Guard\n",
|
||||
);
|
||||
expect(lookup("Plaguebearers", "Death Guard")).toBe("Guard version.");
|
||||
expect(lookup("Plaguebearers", "Chaos Daemons")).toBe(
|
||||
"Daemon version, which is the longer of the two.",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the longest legend when the faction cannot decide it", () => {
|
||||
const { lookup } = buildLoreIndex(
|
||||
csv("Servitors|Short.", "Servitors|The longer, fuller entry."),
|
||||
);
|
||||
expect(lookup("Servitors")).toBe("The longer, fuller entry.");
|
||||
expect(lookup("Servitors", "Adeptus Mechanicus")).toBe(
|
||||
"The longer, fuller entry.",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined for a unit the export does not cover", () => {
|
||||
const { lookup } = buildLoreIndex(csv("Bonesinger|Sings to bone."));
|
||||
expect(lookup("Rein and Raus")).toBeUndefined();
|
||||
expect(lookup("")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Guards the shipped export itself: a re-export that changes the delimiter or
|
||||
// the header names would otherwise fail silently, every card simply losing its
|
||||
// flavour text.
|
||||
describe("public/Lore.csv", () => {
|
||||
const index = buildLoreIndex(readFileSync("public/Lore.csv", "utf8"));
|
||||
|
||||
it("parses into a usable number of entries", () => {
|
||||
expect(index.size).toBeGreaterThan(1000);
|
||||
});
|
||||
|
||||
it("covers the units in the bundled example rosters", () => {
|
||||
for (const name of [
|
||||
"Custodian Guard",
|
||||
"Bladeguard Veteran Squad",
|
||||
"Plague Marines",
|
||||
"Myphitic Blight-haulers",
|
||||
"Thousand Sons Chaos Spawn",
|
||||
]) {
|
||||
expect(index.lookup(name), name).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { buildLoreIndex } from "./lore";
|
||||
|
||||
// `public/Lore.csv` is ~400KB, so it is fetched once, lazily, and shared by
|
||||
// every card rather than bundled into the main chunk. The promise is cached at
|
||||
// module scope: a roster renders 20-odd Units, and they must not each kick off
|
||||
// their own request.
|
||||
let pending;
|
||||
|
||||
const loadLore = () => {
|
||||
if (!pending) {
|
||||
pending = fetch("Lore.csv")
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error(`Lore.csv: ${response.status}`);
|
||||
return response.text();
|
||||
})
|
||||
.then(buildLoreIndex)
|
||||
.catch((error) => {
|
||||
// A missing or unreadable export is not worth failing a card over -
|
||||
// the datasheet simply prints without its flavour text.
|
||||
console.error(error);
|
||||
return { lookup: () => undefined, size: 0 };
|
||||
});
|
||||
}
|
||||
return pending;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves to the lore index, or `null` until it has loaded.
|
||||
*/
|
||||
export const useLore = () => {
|
||||
const [index, setIndex] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
loadLore().then((loaded) => live && setIndex(loaded));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return index;
|
||||
};
|
||||
@@ -40,6 +40,14 @@
|
||||
src: url("/fonts/ConduitITCStd-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
}
|
||||
/* Datasheet lore text. Without a real italic face there would be none at all:
|
||||
font-synthesis is off below, so the browser may not slant an upright one. */
|
||||
@font-face {
|
||||
font-family: "ConduitITCStd";
|
||||
src: url("/fonts/ConduitITCStd Italic.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
|
||||
Reference in New Issue
Block a user