import JSZip from "jszip"; import { Create40kRoster } from "./roster40k"; import { Create40kRoster10th } from "./roster40k-10th"; import { useEffect, useState, useRef } 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"; function App() { const [rosters, setRosters] = useLocalStorage("rosters", "[]"); const rostersJSON = parseJSON(rosters ?? "[]"); const [error, setError] = useState(); const [roster, setRoster] = useState(); const [edition, setEdition] = useState(10); // [9, 10] const [onePerPage, setOnePerPage] = useState(false); const [primaryColor, setPrimaryColor] = useState("#536766"); const [colorUserChoice, setColorUserChoice] = useState(false); const [hideModelSelections, setHideModelSelections] = useState(false); const uploadRef = useRef(); const toggleHideModelSelections = (hide) => { const checkboxes = document.querySelectorAll( 'input[type="checkbox"].hide-model-selection', ); checkboxes.forEach((checkbox) => { if (checkbox.checked !== hide) { checkbox.click(); } }); }; 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; const xmldata = await unzip(content); parseXML(xmldata, 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; } // load example posthog?.capture?.("user_loaded_example", { roster_faction: event, }); const arrayBuffer = await example.arrayBuffer(); // Create a new Blob object from the zip file contents const zipBlob = new Blob([arrayBuffer], { type: "application/zip" }); const xmldata = await unzip(zipBlob); parseXML(xmldata, false, true); } } const loadFromLocalStorage = (roster) => { if (roster.gameType == "Warhammer 40,000 9th Edition") { setRoster(roster); setEdition(9); setError(""); } else if (roster.gameType == "Warhammer 40,000 10th Edition") { setRoster(roster); setEdition(10); setError(""); } posthog?.capture?.("user_loaded_roster_from_localstorage", { roster_faction: roster.forces[0].catalog, roster_type: roster.gameType, }); }; const unzip = async (file) => { if (file?.charAt && file.charAt(0) !== "P") { return file; } 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 } }; function parseXML(xmldata, addToLocalStorage, isExample = false) { const parser = new DOMParser(); const doc = parser.parseFromString(xmldata, "text/xml"); if (!doc) return; // Determine roster type (game system). const info = doc.querySelector("roster"); if (!info) return; const gameType = info.getAttribute("gameSystemName"); if (!gameType) return; const rosterName = info.getAttribute("name"); if (rosterName) { document.title = `FancyScribe ${rosterName}`; } let roster; if (gameType == "Warhammer 40,000 9th Edition") { roster = Create40kRoster(doc, gameType); if (roster && roster.forces.length > 0) { setRoster(roster); setEdition(9); setError(""); } } else if (gameType == "Warhammer 40,000 10th Edition") { roster = Create40kRoster10th(doc, gameType); if (roster && roster.forces.length > 0) { setRoster(roster); setEdition(10); setError(""); } } else { setError("No support for game type '" + gameType + "'."); } if (!roster) { return; } if (addToLocalStorage) { console.log(roster); posthog?.capture?.("user_uploaded_roster", { roster_faction: roster.forces[0].catalog, roster_type: gameType, name: rosterName, }); setRosters(stringifyJSON([roster, ...rostersJSON])); } } 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) { 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 (
FancyScribe{" "} Now with 10th edition support!
A fancy way to view your Warhammer 40k BattleScribe rosters
0 ? "" : "none", }} > Your rosters
{rostersJSON.map((r, index) => (
))}
{ // only show when 10th edition edition === 10 && ( ) }
{roster && primaryColor !== getPrimaryColor(roster.forces[0].catalog) && ( )}
{error}
{edition === 9 && } {edition === 10 && ( )}
Examples
About
FancyScribe is a website that renders{" "} BattleScribe {" "} or New Recruit roster files in an opinionated format inspired by the new 10th edition datacards. Additional inspiration and large parts of the parsing logic come from the{" "} PrettyScribe {" "} website.
FancyScribe is an open-source project and can be found on Github ( FancyScribe ).
If you have any feedback or find any bugs, write{" "} here {" "} or send me a message.
Output Examples
); } 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 "#513627"; 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;