661 lines
19 KiB
React
661 lines
19 KiB
React
import JSZip from "jszip";
|
|
import { Create40kRoster } from "./roster40k";
|
|
import { Create40kRoster10th } from "./roster40k-10th";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
import Demo0 from "./assets/Demo0.png";
|
|
import Demo1 from "./assets/Demo1.png";
|
|
import { Roster } from "./9th/Roster";
|
|
import { Roster as Roster10th } from "./10th/Roster";
|
|
import { useLocalStorage } from "./helpers/useLocalStorage";
|
|
import { parseJSON, stringifyJSON } from "./helpers/json";
|
|
import { Create40kRoster11th } from "./roster40k-11th";
|
|
import { applyTransforms, defaultToggles } from "./transforms";
|
|
|
|
// The transforms, in the order they are offered in the UI. These are what makes
|
|
// this a fork: they rewrite the roster into the generic datasheets the official
|
|
// cards print, rather than a record of one particular list.
|
|
const TRANSFORMS = [
|
|
[
|
|
"mergeDuplicateUnits",
|
|
"Merge duplicates",
|
|
"Fold copies of a datasheet - taken only because their wargear is mutually exclusive - into a single card showing every option.",
|
|
],
|
|
[
|
|
"removeLeaderAbilities",
|
|
"Drop Leader/Support",
|
|
"Remove the attachment rules. Once the army is built they say nothing you need mid-game, and they push the rules you do need off the card.",
|
|
],
|
|
[
|
|
"convertChoiceAbilities",
|
|
"Split choice abilities",
|
|
"Print each option of a 'select one each turn' ability as its own titled row, the way the official datasheets do.",
|
|
],
|
|
];
|
|
|
|
const PARSERS = {
|
|
"Warhammer 40,000 9th Edition": [Create40kRoster, 9],
|
|
"Warhammer 40,000 10th Edition": [Create40kRoster10th, 10],
|
|
"Warhammer 40,000 11th Edition": [Create40kRoster11th, 11],
|
|
};
|
|
|
|
const throttle = (func, limit) => {
|
|
let lastFunc;
|
|
let lastRan;
|
|
return (...args) => {
|
|
if (!lastRan) {
|
|
func(...args);
|
|
lastRan = Date.now();
|
|
} else {
|
|
clearTimeout(lastFunc);
|
|
lastFunc = setTimeout(
|
|
() => {
|
|
if (Date.now() - lastRan >= limit) {
|
|
func(...args);
|
|
lastRan = Date.now();
|
|
}
|
|
},
|
|
limit - (Date.now() - lastRan),
|
|
);
|
|
}
|
|
};
|
|
};
|
|
|
|
function App() {
|
|
// Saved rosters hold the *raw* roster XML rather than the parsed roster, so
|
|
// that flipping a transform toggle can rebuild them without a re-upload.
|
|
// The keys are BrevyScribe's own, so nothing upstream FancyScribe left in
|
|
// localStorage is read back in the wrong shape.
|
|
const [rosters, setRosters] = useLocalStorage("brevyscribe.rosters", "[]");
|
|
const rostersJSON = parseJSON(rosters ?? "[]");
|
|
const [savedToggles, setSavedToggles] = useLocalStorage(
|
|
"brevyscribe.transforms",
|
|
stringifyJSON(defaultToggles),
|
|
);
|
|
// Memoised so the rebuild effect can depend on the toggles themselves rather
|
|
// than their serialised form; a fresh object every render would re-run it.
|
|
const toggles = useMemo(
|
|
() => ({ ...defaultToggles, ...parseJSON(savedToggles ?? "{}") }),
|
|
[savedToggles],
|
|
);
|
|
// { xml, save } - the roster to show, and whether it belongs in the list.
|
|
const [source, setSource] = useState();
|
|
const [error, setError] = useState();
|
|
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();
|
|
|
|
const throttledSetPrimaryColor = useRef(
|
|
throttle((color) => setPrimaryColor(color), 50),
|
|
).current;
|
|
|
|
async function handleFileSelect(event) {
|
|
const files = event?.target?.files;
|
|
|
|
if (files) {
|
|
const reader = new FileReader();
|
|
reader.onerror = () => {
|
|
reader.abort();
|
|
setError("Failed to read roster file.");
|
|
};
|
|
reader.onloadend = async () => {
|
|
const content = reader.result;
|
|
setSource({ xml: await unzip(content), save: true });
|
|
};
|
|
reader.readAsBinaryString(files[0]);
|
|
} else {
|
|
let example;
|
|
switch (event) {
|
|
case "ultras":
|
|
example = await fetch("Ultramarines Example.rosz");
|
|
break;
|
|
case "chaos daemons":
|
|
example = await fetch("Chaos Demons Mix.rosz");
|
|
break;
|
|
case "death guard":
|
|
example = await fetch("Death Guard Example.rosz");
|
|
break;
|
|
case "thousand sons":
|
|
example = await fetch("Thousand Sons Example.rosz");
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
if (!example) return;
|
|
const arrayBuffer = await example.arrayBuffer();
|
|
// Create a new Blob object from the zip file contents
|
|
const zipBlob = new Blob([arrayBuffer], { type: "application/zip" });
|
|
setSource({ xml: await unzip(zipBlob), save: false });
|
|
}
|
|
}
|
|
|
|
// Already in the list, so there is nothing to save; the stored XML goes
|
|
// through the same rebuild as a fresh upload.
|
|
const loadFromLocalStorage = (saved) =>
|
|
setSource({ xml: saved.xml, save: false });
|
|
|
|
const unzip = async (file) => {
|
|
if (file?.charAt && file.charAt(0) !== "P") {
|
|
console.log(file.replace(/\u00a0/g, " "));
|
|
return file
|
|
.replaceAll("Â", " ")
|
|
.replaceAll("â", "'")
|
|
.replaceAll("â", "■"); // Â is a common issue with UTF-8 encoding in XML files
|
|
} else {
|
|
const jszip = new JSZip();
|
|
const zip = await jszip.loadAsync(file);
|
|
return zip.file(/[^/]+\.ros/)[0].async("string"); // Get roster files that are in the root
|
|
}
|
|
};
|
|
|
|
// Rebuild whenever a new roster arrives or a transform is toggled. The
|
|
// transforms run here rather than over the uploaded file, so flipping a
|
|
// toggle rebuilds from the original XML and nothing has to be re-uploaded.
|
|
//
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: the roster list is
|
|
// read to append to it, so depending on it would re-run this on its own write.
|
|
useEffect(() => {
|
|
if (!source?.xml) return;
|
|
|
|
const doc = new DOMParser().parseFromString(source.xml, "text/xml");
|
|
const info = doc?.querySelector("roster");
|
|
if (!info) return;
|
|
|
|
// Determine roster type (game system).
|
|
const gameType = info.getAttribute("gameSystemName");
|
|
if (!gameType) return;
|
|
const parser = PARSERS[gameType];
|
|
if (!parser) {
|
|
setError(`No support for game type '${gameType}'.`);
|
|
return;
|
|
}
|
|
|
|
const report = applyTransforms(doc, toggles);
|
|
const [createRoster, rosterEdition] = parser;
|
|
const built = createRoster(doc, gameType);
|
|
if (!built || built.forces.length === 0) return;
|
|
|
|
setRoster(built);
|
|
setEdition(rosterEdition);
|
|
setError("");
|
|
|
|
const name = info.getAttribute("name");
|
|
if (name) document.title = `BrevyScribe ${name}`;
|
|
console.log(built, report);
|
|
|
|
if (source.save) {
|
|
// Keyed on the XML, so re-saving the same roster moves it to the front
|
|
// of the list rather than adding a second copy.
|
|
setRosters(
|
|
stringifyJSON([
|
|
{ name: name || "Roster", xml: source.xml },
|
|
...rostersJSON.filter((entry) => entry.xml !== source.xml),
|
|
]),
|
|
);
|
|
}
|
|
}, [source, toggles, setRosters]);
|
|
|
|
useEffect(() => {
|
|
// Check if the browser is Safari, and if so, remove the accept attribute
|
|
// from the file input element. This is because Safari doesn't support
|
|
// extensions on the accept attribute for input type=file
|
|
// (https://caniuse.com/input-file-accept). If set, they will not allow any
|
|
// file to be selected.
|
|
if (
|
|
navigator.userAgent.match(/AppleWebKit.*Safari/) &&
|
|
!navigator.userAgent.includes("Chrome")
|
|
) {
|
|
uploadRef.current?.removeAttribute("accept");
|
|
}
|
|
}, []);
|
|
useEffect(() => {
|
|
if (roster) {
|
|
js_colorPicker.value = getPrimaryColor(roster.forces[0].catalog);
|
|
setPrimaryColor(getPrimaryColor(roster.forces[0].catalog));
|
|
setColorUserChoice(false);
|
|
}
|
|
}, [roster]);
|
|
|
|
const handleDrag = (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (edition === 9) {
|
|
document.body.style.minWidth = "600px";
|
|
} else {
|
|
document.body.style.minWidth = "";
|
|
}
|
|
}, [edition]);
|
|
|
|
return (
|
|
<div
|
|
id="js_app"
|
|
className="App"
|
|
style={{
|
|
"--primary-color": primaryColor,
|
|
"--primary-color-transparent": primaryColor + "60",
|
|
}}
|
|
>
|
|
<div className="header print-display-none">
|
|
<a
|
|
href="/"
|
|
style={{
|
|
color: "#fff",
|
|
fontWeight: 800,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
}}
|
|
>
|
|
BrevyScribe{" "}
|
|
<span
|
|
style={{
|
|
fontSize: "0.8rem",
|
|
fontWeight: 400,
|
|
}}
|
|
>
|
|
Generic datasheets, not list-specific ones
|
|
</span>
|
|
</a>
|
|
<div className="subheader">
|
|
A fancy way to print your Warhammer 40k datasheets
|
|
</div>
|
|
</div>
|
|
|
|
<div className="body">
|
|
<div
|
|
className="print-display-none max-w-[95vw]"
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: roster ? "row" : "column",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
}}
|
|
>
|
|
<div
|
|
className="print-display-none"
|
|
style={{
|
|
fontSize: roster ? "1rem" : "1.7rem",
|
|
fontWeight: roster ? 600 : "",
|
|
display: rostersJSON.length > 0 ? "" : "none",
|
|
}}
|
|
>
|
|
Your rosters
|
|
</div>
|
|
<div
|
|
className="print-display-none"
|
|
style={{
|
|
gap: 8,
|
|
flexWrap: "wrap",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
display: "flex",
|
|
}}
|
|
>
|
|
{rostersJSON.map((r, index) => (
|
|
<div key={index} style={{ position: "relative" }}>
|
|
<button
|
|
className="print-display-none"
|
|
style={{ fontSize: roster ? "" : "1.2rem" }}
|
|
onClick={() => loadFromLocalStorage(r)}
|
|
>
|
|
{r.name}
|
|
</button>
|
|
<button
|
|
onClick={() =>
|
|
setRosters(
|
|
stringifyJSON(rostersJSON.filter((_, i) => i !== index)),
|
|
)
|
|
}
|
|
className="print-display-none absolute right-[-4px] top-[-4px] rounded-full border-0 bg-red-600 fill-white p-[2px] text-sm transition duration-150 ease-in-out hover:bg-red-800 focus:outline-none"
|
|
>
|
|
<svg height="16" viewBox="0 0 16 16" width="16">
|
|
<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="print-display-none" />
|
|
<div
|
|
className="print-display-none max-w-[95vw]"
|
|
style={{ display: "flex", width: "100%", gap: 8 }}
|
|
>
|
|
<input
|
|
type="file"
|
|
ref={uploadRef}
|
|
accept=".ros,.rosz"
|
|
name="rosterUpload"
|
|
id="rosterUpload"
|
|
onChange={handleFileSelect}
|
|
style={{ display: "none" }}
|
|
/>
|
|
<label
|
|
htmlFor="rosterUpload"
|
|
className={"rosterUpload " + (roster ? "rosterUploaded" : "")}
|
|
id="rosterUploadContainer"
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
|
e.target.files = e.dataTransfer.files;
|
|
handleFileSelect(e);
|
|
}
|
|
}}
|
|
onDragEnter={handleDrag}
|
|
onDragLeave={handleDrag}
|
|
onDragOver={handleDrag}
|
|
>
|
|
<div id="preloadContainer">
|
|
<span>Upload roster file (.ros, .rosz)</span>
|
|
</div>
|
|
</label>
|
|
|
|
<button
|
|
style={{ display: roster ? "" : "none" }}
|
|
onClick={() => window.print()}
|
|
>
|
|
Print roster
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
className="print-display-none max-w-[95vw]"
|
|
style={{
|
|
display: roster ? "flex" : "none",
|
|
width: "100%",
|
|
gap: 16,
|
|
flexWrap: "wrap",
|
|
}}
|
|
>
|
|
<span style={{ fontWeight: 600, minHeight: 26 }}>Datasheets:</span>
|
|
{TRANSFORMS.map(([key, label, description]) => (
|
|
<label
|
|
key={key}
|
|
title={description}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 4,
|
|
minHeight: 26,
|
|
}}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={toggles[key]}
|
|
onChange={(e) =>
|
|
setSavedToggles(
|
|
stringifyJSON({ ...toggles, [key]: e.target.checked }),
|
|
)
|
|
}
|
|
/>
|
|
<span className="select-none">{label}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
|
|
<div
|
|
className="print-display-none max-w-[95vw]"
|
|
style={{ display: roster ? "flex" : "none", width: "100%", gap: 16 }}
|
|
>
|
|
<label
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 4,
|
|
minHeight: 26,
|
|
}}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
onChange={(e) => setOnePerPage(e.target.checked)}
|
|
/>
|
|
<span className="select-none">
|
|
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
|
|
id="js_colorPicker"
|
|
type="color"
|
|
className="rounded-sm border border-gray-400"
|
|
style={{ height: 24, width: 32, padding: "0 2px" }}
|
|
onChange={(e) => {
|
|
setColorUserChoice(true);
|
|
throttledSetPrimaryColor(e.target.value);
|
|
}}
|
|
/>
|
|
<span> Custom Color</span>
|
|
</label>
|
|
|
|
{roster && colorUserChoice && (
|
|
<button
|
|
onClick={() => {
|
|
setPrimaryColor(getPrimaryColor(roster.forces[0].catalog));
|
|
setColorUserChoice(false);
|
|
js_colorPicker.value = getPrimaryColor(
|
|
roster.forces[0].catalog,
|
|
);
|
|
}}
|
|
style={{
|
|
padding: "2px 4px",
|
|
borderRadius: 4,
|
|
borderWidth: 1,
|
|
}}
|
|
>
|
|
Reset color
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="print-display-none" style={{ color: "red" }}>
|
|
{error}
|
|
</div>
|
|
{edition === 9 && <Roster roster={roster} onePerPage={onePerPage} />}
|
|
{(edition === 10 || edition === 11) && (
|
|
<Roster10th
|
|
roster={roster}
|
|
onePerPage={onePerPage}
|
|
colorUserChoice={colorUserChoice}
|
|
primaryColor={primaryColor}
|
|
showLore={showLore}
|
|
/>
|
|
)}
|
|
|
|
<div
|
|
className="max-w-[95vw]"
|
|
style={{
|
|
paddingTop: 8,
|
|
fontSize: "1.7rem",
|
|
display: roster ? "none" : "flex",
|
|
}}
|
|
>
|
|
Examples
|
|
</div>
|
|
<div
|
|
className="max-w-[95vw]"
|
|
style={{
|
|
gap: 8,
|
|
flexWrap: "wrap",
|
|
justifyContent: "center",
|
|
display: roster ? "none" : "flex",
|
|
}}
|
|
>
|
|
<button
|
|
className="print-display-none"
|
|
style={{ fontSize: "1.2rem" }}
|
|
onClick={() => handleFileSelect("thousand sons")}
|
|
>
|
|
Thousand Sons (10th)
|
|
</button>
|
|
<button
|
|
className="print-display-none"
|
|
style={{ fontSize: "1.2rem" }}
|
|
onClick={() => handleFileSelect("death guard")}
|
|
>
|
|
Death Guard (10th)
|
|
</button>
|
|
<button
|
|
className="print-display-none"
|
|
style={{ fontSize: "1.2rem" }}
|
|
onClick={() => handleFileSelect("ultras")}
|
|
>
|
|
Ultramarines (9th)
|
|
</button>
|
|
<button
|
|
className="print-display-none"
|
|
style={{ fontSize: "1.2rem" }}
|
|
onClick={() => handleFileSelect("chaos daemons")}
|
|
>
|
|
Chaos Daemons (9th)
|
|
</button>
|
|
</div>
|
|
<div
|
|
className="print-display-none max-w-[95vw]"
|
|
style={{ display: roster ? "none" : "" }}
|
|
>
|
|
<div style={{ padding: "8px 0", fontSize: "1.7rem" }}>About</div>
|
|
<div style={{ fontSize: "1.2rem" }}>
|
|
BrevyScribe renders{" "}
|
|
<a
|
|
href="https://www.battlescribe.net/"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
BattleScribe
|
|
</a>{" "}
|
|
or <a href="https://www.newrecruit.eu/">New Recruit</a> roster files
|
|
in an opinionated format inspired by the 10th edition datacards.
|
|
</div>
|
|
<div style={{ fontSize: "1.2rem" }}>
|
|
It differs from its upstream in what it prints: rather than a record
|
|
of one particular list, it rewrites the roster into the generic
|
|
datasheets the official cards show. Duplicate copies of a unit are
|
|
folded into one card carrying every option, the Leader and Support
|
|
attachment rules are dropped, and a "select one each
|
|
turn" ability is split into one titled row per option. Use the{" "}
|
|
<b>Datasheets</b> toggles above to turn any of that off.
|
|
</div>
|
|
<div style={{ fontSize: "1.2rem" }}>
|
|
BrevyScribe is a fork of{" "}
|
|
<a
|
|
href="https://github.com/NilsUeter/fancyscribe"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
FancyScribe
|
|
</a>{" "}
|
|
by Nils Ueter, which does all the heavy lifting here. Additional
|
|
inspiration and large parts of the parsing logic come from the{" "}
|
|
<a
|
|
href="https://rweyrauch.github.io/PrettyScribe"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
PrettyScribe
|
|
</a>{" "}
|
|
website.
|
|
</div>
|
|
<div style={{ padding: "8px 0", fontSize: "1.7rem" }}>
|
|
Output Examples
|
|
</div>
|
|
<img src={Demo0} className="pb-4" style={{ width: "100%" }} />
|
|
<img src={Demo1} style={{ width: "100%" }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const getPrimaryColor = (catalog) => {
|
|
switch (catalog.replace("Xenos - ", "")) {
|
|
case "Imperium - Adeptus Astartes":
|
|
return "#536766";
|
|
case "Imperium - Adeptus Astartes - Blood Angels":
|
|
return "#761119";
|
|
case "Imperium - Adeptus Astartes - Space Wolves":
|
|
return "#3e646f";
|
|
case "Imperium - Adeptus Astartes - Imperial Fists":
|
|
return "#b87d00";
|
|
case "Imperium - Adeptus Astartes - Raven Guard":
|
|
return "#2b2b2b";
|
|
case "Imperium - Adeptus Astartes - Salamanders":
|
|
return "#1b623f";
|
|
case "Imperium - Adeptus Astartes - White Scars":
|
|
return "#783028";
|
|
case "Imperium - Adeptus Astartes - Dark Angels":
|
|
return "#014419";
|
|
case "Imperium - Adeptus Astartes - Black Templars":
|
|
return "#002f42";
|
|
case "Imperium - Adeptus Astartes - Deathwatch":
|
|
return "#44494d";
|
|
case "Imperium - Adeptus Custodes":
|
|
return "#765c41";
|
|
case "Imperium - Adeptus Mechanicus":
|
|
return "#a03332";
|
|
case "Imperium - Adepta Sororitas":
|
|
return "#5e0a00";
|
|
case "Imperium - Astra Militarum":
|
|
return "#375441";
|
|
case "Imperium - Grey Knights":
|
|
return "#4a6672";
|
|
case "Imperium - Imperial Knights":
|
|
return "#03495e";
|
|
case "Chaos - Daemons":
|
|
return "#383c46";
|
|
case "Chaos - Chaos Space Marines":
|
|
return "#1d3138";
|
|
case "Chaos - World Eaters":
|
|
return "#883531";
|
|
case "Chaos - Death Guard":
|
|
return "#576011";
|
|
case "Chaos - Chaos Knights":
|
|
return "#405c58";
|
|
case "Chaos - Thousand Sons":
|
|
return "#015d68";
|
|
case "Necrons":
|
|
return "#005c2f";
|
|
case "Orks":
|
|
return "#4b6621";
|
|
case "Tyranids":
|
|
case "Tyranids - Genestealer Cults":
|
|
return "#44264C";
|
|
case "Aeldari - Craftworlds":
|
|
return "#1f787f";
|
|
case "Aeldari - Harlequins":
|
|
return "#6f322f";
|
|
case "Leagues of Votann":
|
|
return "#7d4c08";
|
|
case "T'au Empire":
|
|
return "#206173";
|
|
default:
|
|
console.log(catalog);
|
|
return "#536766";
|
|
}
|
|
};
|
|
|
|
export default App;
|