save rosters in localStorage, better mobile support for 10th edition, add tailwind

This commit is contained in:
NilsUeter
2023-10-20 22:11:23 +02:00
parent 4777d84819
commit 2c49e646b4
13 changed files with 4462 additions and 2030 deletions
+2789 -537
View File
File diff suppressed because it is too large Load Diff
+15 -4
View File
@@ -14,9 +14,20 @@
"react-dom": "^18.2.0" "react-dom": "^18.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.0.28", "@types/react": "^18.2.31",
"@types/react-dom": "^18.0.11", "@types/react-dom": "^18.2.14",
"@vitejs/plugin-react": "^3.1.0", "@vitejs/plugin-react": "^4.1.0",
"vite": "^4.2.0" "autoprefixer": "^10.4.16",
"postcss": "^8.4.31",
"prettier": "^3.0.3",
"prettier-plugin-tailwindcss": "^0.5.6",
"tailwindcss": "^3.3.3",
"vite": "^4.5.0"
},
"prettier": {
"useTabs": true,
"plugins": [
"prettier-plugin-tailwindcss"
]
} }
} }
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+37 -46
View File
@@ -1,7 +1,6 @@
import { useRef, useState, useEffect } from "react"; import { useRef, useState, useEffect, Fragment } from "react";
import factionBackground from "../assets/factionBackground.png"; import factionBackground from "../assets/factionBackground.png";
import adeptusAstartesIcon from "../assets/adeptusAstartesIcon.png"; import adeptusAstartesIcon from "../assets/adeptusAstartesIcon.png";
import rangedIcon from "../assets/rangedIcon.png";
import { Arrow, wavyLine } from "../assets/icons"; import { Arrow, wavyLine } from "../assets/icons";
import { Weapons, hasDifferentProfiles } from "./Weapons"; import { Weapons, hasDifferentProfiles } from "./Weapons";
import { useLocalStorage } from "../helpers/useLocalStorage"; import { useLocalStorage } from "../helpers/useLocalStorage";
@@ -85,7 +84,7 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
const weaponDescriptions = weapons const weaponDescriptions = weapons
.filter( .filter(
(weapon) => (weapon) =>
(weapon.range === "-" || weapon.range === "") && weapon.abilities (weapon.range === "-" || weapon.range === "") && weapon.abilities,
) )
.sort((a, b) => a.selectionName.localeCompare(b.selectionName)); .sort((a, b) => a.selectionName.localeCompare(b.selectionName));
const modelsWithDifferentProfiles = weapons.filter((weapon, index) => { const modelsWithDifferentProfiles = weapons.filter((weapon, index) => {
@@ -97,7 +96,7 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
((previousWeapon && ((previousWeapon &&
hasDifferentProfiles( hasDifferentProfiles(
previousWeapon.selectionName, previousWeapon.selectionName,
previousWeapon.name previousWeapon.name,
) && ) &&
selectionName === previousWeapon.selectionName) || selectionName === previousWeapon.selectionName) ||
(nextWeapon && (nextWeapon &&
@@ -135,7 +134,7 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
model.oc === firstModel.oc && model.oc === firstModel.oc &&
model.bs === firstModel.bs && model.bs === firstModel.bs &&
model.ws === firstModel.ws && model.ws === firstModel.ws &&
model.attacks === firstModel.attacks model.attacks === firstModel.attacks,
); );
}; };
if (areAllModelsTheSame(modelStats)) { if (areAllModelsTheSame(modelStats)) {
@@ -164,6 +163,7 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "flex-end", justifyContent: "flex-end",
gap: 4,
}} }}
> >
<input type="checkbox" onChange={() => setHide(!hide)} /> <input type="checkbox" onChange={() => setHide(!hide)} />
@@ -200,7 +200,7 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
style={{ style={{
backgroundColor: "var(--primary-color-transparent)", backgroundColor: "var(--primary-color-transparent)",
height: 80, height: 80,
minWidth: 340, minWidth: "33%",
}} }}
></div> ></div>
<div <div
@@ -286,13 +286,11 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
style={{ width: "100%", height: "100%", objectFit: "contain" }} style={{ width: "100%", height: "100%", objectFit: "contain" }}
/> />
)} )}
<div className="absolute right-[1px] top-[2px] flex gap-1">
<label <label
className="button print-display-none" className="button print-display-none"
style={{ style={{
position: "absolute",
border: "1px solid #999", border: "1px solid #999",
top: 2,
right: 1,
padding: "1px 4px", padding: "1px 4px",
fontSize: "0.8rem", fontSize: "0.8rem",
backgroundColor: "#f0f0f0", backgroundColor: "#f0f0f0",
@@ -322,11 +320,8 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
<button <button
className="button print-display-none" className="button print-display-none"
style={{ style={{
position: "absolute",
border: "1px solid #999", border: "1px solid #999",
top: 2, padding: "1px 4px",
right: 84,
padding: "2px 4px",
fontSize: "0.8rem", fontSize: "0.8rem",
backgroundColor: "#f0f0f0", backgroundColor: "#f0f0f0",
}} }}
@@ -339,6 +334,7 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
)} )}
</div> </div>
</div> </div>
</div>
<div <div
style={{ style={{
display: "flex", display: "flex",
@@ -383,30 +379,24 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
style={{ width: "100%" }} style={{ width: "100%" }}
> >
<tbody> <tbody>
{weaponDescriptions.map((weapon, index) => (
<tr key={index} className="emptyRow noBorderTop">
<td style={{ width: 37, borderTop: "none" }}>{Arrow}</td>
<td
style={{
textAlign: "left",
fontSize: ".8em",
paddingLeft: 0,
}}
>
{weapon.name} - {weapon.abilities}
</td>
</tr>
))}
{modelsWithDifferentProfiles.length > 0 && {modelsWithDifferentProfiles.length > 0 &&
!weaponDescriptions.length && ( !weaponDescriptions.length && (
<tr className="emptyRow noBorderTop"> <tr className="emptyRow noBorderTop">
<td style={{ width: 37, borderTop: "none" }}>{Arrow}</td> <td
style={{
borderTop: "none",
verticalAlign: "middle",
}}
>
<div className="flex justify-center">{Arrow}</div>
</td>
<td <td
colSpan={7} colSpan={7}
style={{ style={{
textAlign: "left", textAlign: "left",
fontSize: ".8em", fontSize: ".8em",
paddingLeft: 0, paddingLeft: 0,
verticalAlign: "middle",
}} }}
> >
Before selecting targets for this weapon, select one of Before selecting targets for this weapon, select one of
@@ -419,14 +409,7 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
<Keywords keywords={keywords} /> <Keywords keywords={keywords} />
</div> </div>
<div <div className=" relative max-w-[400px] flex-1 p-1 pb-[50px] print:p-[20px] sm:p-2 sm:pb-[50px] sm:print:p-[20px] sm:print:pb-[50px] md:p-[20px] md:pb-[50px] md:print:p-[20px] md:print:pb-[50px]">
style={{
padding: "var(--size-20) var(--size-20) 50px var(--size-20)",
flex: "1",
maxWidth: 400,
position: "relative",
}}
>
<div <div
style={{ style={{
fontSize: "1.1em", fontSize: "1.1em",
@@ -461,14 +444,14 @@ const ModelStats = ({ modelStat, index, showName, modelList }) => {
const showWeapons = index === 0; const showWeapons = index === 0;
return ( return (
<div style={{ display: "flex", gap: 16 }}> <div style={{ display: "flex", gap: "1.2rem", flexWrap: "wrap" }}>
<Characteristic title="M" characteristic={move} index={index} /> <Characteristic title="M" characteristic={move} index={index} />
<Characteristic title="T" characteristic={toughness} index={index} /> <Characteristic title="T" characteristic={toughness} index={index} />
<Characteristic title="SV" characteristic={save} index={index} /> <Characteristic title="SV" characteristic={save} index={index} />
<Characteristic title="W" characteristic={wounds} index={index} /> <Characteristic title="W" characteristic={wounds} index={index} />
<Characteristic title="LD" characteristic={leadership} index={index} /> <Characteristic title="LD" characteristic={leadership} index={index} />
<Characteristic title="OC" characteristic={oc} index={index} /> <Characteristic title="OC" characteristic={oc} index={index} />
<div style={{ display: "flex", alignItems: "center", marginLeft: -10 }}> <div style={{ display: "flex", alignItems: "center" }}>
{showName && ( {showName && (
<div <div
style={{ style={{
@@ -530,6 +513,7 @@ const FactionIcon = ({ catalog }) => {
}; };
const Keywords = ({ keywords }) => { const Keywords = ({ keywords }) => {
const joinedKeywords = [...keywords].join(", ");
return ( return (
<div <div
style={{ style={{
@@ -542,15 +526,20 @@ const Keywords = ({ keywords }) => {
alignItems: "center", alignItems: "center",
backgroundColor: "#BCBCBE", backgroundColor: "#BCBCBE",
border: "2px solid var(--primary-color)", border: "2px solid var(--primary-color)",
width: "calc(100% - 18px)", width: "calc(100% - 0.9rem)",
backgroundSize: "contain", backgroundSize: "contain",
minHeight: 54, minHeight: 54,
gap: 3, gap: 3,
}} }}
> >
<span style={{ fontSize: "1.1em" }}>KEYWORDS:</span> <span style={{ fontSize: "1.1em" }}>KEYWORDS:</span>
<span style={{ fontSize: "1em", fontWeight: 800 }}> <span
{[...keywords].join(", ")} style={{
fontSize: joinedKeywords.length > 80 ? ".8em" : "1em",
fontWeight: 800,
}}
>
{joinedKeywords}
</span> </span>
</div> </div>
); );
@@ -572,7 +561,7 @@ const Factions = ({ factions }) => {
color: "#fff", color: "#fff",
border: "2px solid var(--primary-color)", border: "2px solid var(--primary-color)",
borderLeft: "none", borderLeft: "none",
width: "calc(100% - 18px)", width: "calc(100% - 0.9rem)",
backgroundSize: "contain", backgroundSize: "contain",
minHeight: 54, minHeight: 54,
}} }}
@@ -670,8 +659,8 @@ const FancyBox = ({ children }) => {
> >
<div <div
style={{ style={{
minWidth: 40, minWidth: "3rem",
minHeight: 40, minHeight: "3rem",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
@@ -691,7 +680,7 @@ const OtherAbilities = ({ abilities }) => {
let keys = [...Object.keys(abilities)?.filter((key) => key !== "Abilities")]; let keys = [...Object.keys(abilities)?.filter((key) => key !== "Abilities")];
return keys.map((key) => { return keys.map((key) => {
return ( return (
<> <Fragment key={key}>
<thead> <thead>
<tr <tr
style={{ style={{
@@ -725,7 +714,7 @@ const OtherAbilities = ({ abilities }) => {
</tr> </tr>
))} ))}
</tbody> </tbody>
</> </Fragment>
); );
}); });
}; };
@@ -746,6 +735,8 @@ const ForceRules = ({ rules, onePerPage }) => {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "flex-end", justifyContent: "flex-end",
gap: 4,
paddingBottom: 2,
}} }}
> >
<input type="checkbox" onChange={() => setHide(!hide)} /> <input type="checkbox" onChange={() => setHide(!hide)} />
+24 -16
View File
@@ -14,18 +14,24 @@ export const Weapons = ({ title, weapons, modelStats, forceRules }) => {
color: "#fff", color: "#fff",
}} }}
> >
<th style={{ width: 37 }}> <th className="w-[2.2em]">
<div style={{ display: "flex" }}> <div
style={{
display: "flex",
minWidth: ".7em",
justifyContent: "center",
}}
>
<img src={isMelee ? meleeIcon : rangedIcon} /> <img src={isMelee ? meleeIcon : rangedIcon} />
</div> </div>
</th> </th>
<th style={{ textAlign: "left" }}>{title}</th> <th style={{ textAlign: "left" }}>{title}</th>
<th>RANGE</th> <th className="w-[5%]">RANGE</th>
<th>A</th> <th className="w-[5%]">A</th>
<th>{isMelee ? "WS" : "BS"}</th> <th className="w-[5%]">{isMelee ? "WS" : "BS"}</th>
<th>S</th> <th className="w-[5%]">S</th>
<th>AP</th> <th className="w-[5%]">AP</th>
<th>D</th> <th className="w-[5%]">D</th>
</tr> </tr>
</thead> </thead>
)} )}
@@ -44,7 +50,7 @@ export const Weapons = ({ title, weapons, modelStats, forceRules }) => {
))} ))}
{weapons.length > 0 && ( {weapons.length > 0 && (
<tr className="emptyRow"> <tr className="emptyRow">
<td style={{ width: 37, borderTop: "none" }}></td> <td style={{ borderTop: "none" }}></td>
<td colSpan={7}></td> <td colSpan={7}></td>
</tr> </tr>
)} )}
@@ -102,7 +108,9 @@ const Weapon = ({ weapon, previousWeapon, nextWeapon, isMelee, className }) => {
<> <>
<tr className={className}> <tr className={className}>
<td style={{ borderTop: "none", backgroundColor: "#dfe0e2" }}> <td style={{ borderTop: "none", backgroundColor: "#dfe0e2" }}>
{differentProfiles && Arrow} {differentProfiles && (
<div className="flex justify-center">{Arrow}</div>
)}
</td> </td>
<td style={{ textAlign: "left" }}> <td style={{ textAlign: "left" }}>
<div <div
@@ -131,9 +139,9 @@ const Weapon = ({ weapon, previousWeapon, nextWeapon, isMelee, className }) => {
)} )}
</div> </div>
</td> </td>
<td>{range}</td> <td className="align-middle">{range}</td>
<td>{attacks}</td> <td className="align-middle">{attacks}</td>
<td> <td className="align-middle">
{isMelee {isMelee
? ws.join ? ws.join
? ws.join("|") ? ws.join("|")
@@ -142,9 +150,9 @@ const Weapon = ({ weapon, previousWeapon, nextWeapon, isMelee, className }) => {
? bs.join("|") ? bs.join("|")
: bs} : bs}
</td> </td>
<td>{str}</td> <td className="align-middle">{str}</td>
<td>{ap}</td> <td className="align-middle">{ap}</td>
<td>{damage}</td> <td className="align-middle">{damage}</td>
</tr> </tr>
{abilities && abilities !== "-" && ( {abilities && abilities !== "-" && (
<tr className={className + " noBorderTop"}> <tr className={className + " noBorderTop"}>
+20 -9
View File
@@ -84,7 +84,7 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
.filter( .filter(
(weapon) => (weapon) =>
(weapon.range === "Melee" || weapon.type === "Melee") && (weapon.range === "Melee" || weapon.type === "Melee") &&
weapon.range !== "" weapon.range !== "",
) )
.sort((a, b) => a.selectionName.localeCompare(b.selectionName)); .sort((a, b) => a.selectionName.localeCompare(b.selectionName));
if (meleeWeapons.length === 0) { if (meleeWeapons.length === 0) {
@@ -101,14 +101,14 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
const rangedWeapons = weapons const rangedWeapons = weapons
.filter( .filter(
(weapon) => (weapon) =>
weapon.range !== "Melee" && weapon.range !== "-" && weapon.range !== "" weapon.range !== "Melee" && weapon.range !== "-" && weapon.range !== "",
) )
.sort((a, b) => a.selectionName.localeCompare(b.selectionName)); .sort((a, b) => a.selectionName.localeCompare(b.selectionName));
const weaponDescriptions = weapons const weaponDescriptions = weapons
.filter( .filter(
(weapon) => (weapon) =>
(weapon.range === "-" || weapon.range === "") && weapon.abilities (weapon.range === "-" || weapon.range === "") && weapon.abilities,
) )
.sort((a, b) => a.selectionName.localeCompare(b.selectionName)); .sort((a, b) => a.selectionName.localeCompare(b.selectionName));
const modelsWithDifferentProfiles = weapons.filter((weapon, index) => { const modelsWithDifferentProfiles = weapons.filter((weapon, index) => {
@@ -120,7 +120,7 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
((previousWeapon && ((previousWeapon &&
hasDifferentProfiles( hasDifferentProfiles(
previousWeapon.selectionName, previousWeapon.selectionName,
previousWeapon.name previousWeapon.name,
) && ) &&
selectionName === previousWeapon.selectionName) || selectionName === previousWeapon.selectionName) ||
(nextWeapon && (nextWeapon &&
@@ -168,6 +168,7 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "flex-end", justifyContent: "flex-end",
gap: 4,
}} }}
> >
<label className="print-display-none"> <label className="print-display-none">
@@ -377,12 +378,21 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
<tbody> <tbody>
{weaponDescriptions.map((weapon, index) => ( {weaponDescriptions.map((weapon, index) => (
<tr key={index} className="emptyRow noBorderTop"> <tr key={index} className="emptyRow noBorderTop">
<td style={{ width: 37, borderTop: "none" }}>{Arrow}</td> <td
style={{
width: 37,
borderTop: "none",
verticalAlign: "middle",
}}
>
{Arrow}
</td>
<td <td
style={{ style={{
textAlign: "left", textAlign: "left",
fontSize: ".8em", fontSize: ".8em",
paddingLeft: 0, paddingLeft: 0,
verticalAlign: "middle",
}} }}
> >
{weapon.name} - {weapon.abilities} {weapon.name} - {weapon.abilities}
@@ -457,13 +467,13 @@ const ModelStats = ({ modelStat, index, showName, showWeapons, modelList }) => {
modelList = modelList.filter((model) => !model.includes("Sergeant")); modelList = modelList.filter((model) => !model.includes("Sergeant"));
} }
let modelListMatches = modelList.filter((model) => let modelListMatches = modelList.filter((model) =>
model.includes(name + " w") model.includes(name + " w"),
); );
if (modelListMatches.length === 0) { if (modelListMatches.length === 0) {
modelListMatches = modelList.filter((model) => model.includes(name)); modelListMatches = modelList.filter((model) => model.includes(name));
} }
modelListMatches = modelListMatches.map((model) => modelListMatches = modelListMatches.map((model) =>
model.replaceAll(name, "") model.replaceAll(name, ""),
); );
return ( return (
@@ -616,7 +626,7 @@ const Abilities = ({ abilities }) => {
if (!abilities) return null; if (!abilities) return null;
let keys = [...abilities.keys()]; let keys = [...abilities.keys()];
keys = keys.filter( keys = keys.filter(
(key) => key !== "Stratagem: Warlord Trait" && key !== "Stratagem: Relic" (key) => key !== "Stratagem: Warlord Trait" && key !== "Stratagem: Relic",
); );
return ( return (
<div <div
@@ -747,7 +757,7 @@ const WoundTracker = ({ woundTracker }) => {
if (!woundTracker || woundTracker.length === 0) return null; if (!woundTracker || woundTracker.length === 0) return null;
const uniqueWoundTracker = woundTracker.filter((woundTrack, index) => { const uniqueWoundTracker = woundTracker.filter((woundTrack, index) => {
const firstIndex = woundTracker.findIndex( const firstIndex = woundTracker.findIndex(
(woundTrack2) => woundTrack2.name === woundTrack.name (woundTrack2) => woundTrack2.name === woundTrack.name,
); );
return firstIndex === index; return firstIndex === index;
}); });
@@ -913,6 +923,7 @@ const ForceRules = ({ rules, onePerPage }) => {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "flex-end", justifyContent: "flex-end",
gap: 4,
}} }}
> >
<input type="checkbox" onChange={() => setHide(!hide)} /> <input type="checkbox" onChange={() => setHide(!hide)} />
+95 -11
View File
@@ -7,11 +7,15 @@ import Demo0 from "./assets/Demo0.png";
import Demo1 from "./assets/Demo1.png"; import Demo1 from "./assets/Demo1.png";
import { Roster } from "./9th/Roster"; import { Roster } from "./9th/Roster";
import { Roster as Roster10th } from "./10th/Roster"; import { Roster as Roster10th } from "./10th/Roster";
import { useLocalStorage } from "./helpers/useLocalStorage";
import { parseJSON, stringifyJSON } from "./helpers/json";
function App() { function App() {
const [rosters, setRosters] = useLocalStorage("rosters", "[]");
const rostersJSON = parseJSON(rosters ?? "[]");
const [error, setError] = useState(); const [error, setError] = useState();
const [roster, setRoster] = useState(); const [roster, setRoster] = useState();
const [edition, setEdition] = useState(9); // [9, 10] const [edition, setEdition] = useState(10); // [9, 10]
const [onePerPage, setOnePerPage] = useState(false); const [onePerPage, setOnePerPage] = useState(false);
const [primaryColor, setPrimaryColor] = useState("#536766"); const [primaryColor, setPrimaryColor] = useState("#536766");
const uploadRef = useRef(); const uploadRef = useRef();
@@ -27,7 +31,7 @@ function App() {
reader.onloadend = async () => { reader.onloadend = async () => {
const content = reader.result; const content = reader.result;
const xmldata = await unzip(content); const xmldata = await unzip(content);
parseXML(xmldata); parseXML(xmldata, true);
}; };
reader.readAsBinaryString(files[0]); reader.readAsBinaryString(files[0]);
} else { } else {
@@ -54,9 +58,20 @@ function App() {
// Create a new Blob object from the zip file contents // Create a new Blob object from the zip file contents
const zipBlob = new Blob([arrayBuffer], { type: "application/zip" }); const zipBlob = new Blob([arrayBuffer], { type: "application/zip" });
const xmldata = await unzip(zipBlob); const xmldata = await unzip(zipBlob);
parseXML(xmldata); parseXML(xmldata, false);
} }
} }
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("");
}
};
const unzip = async (file) => { const unzip = async (file) => {
if (file?.charAt && file.charAt(0) !== "P") { if (file?.charAt && file.charAt(0) !== "P") {
@@ -68,7 +83,7 @@ function App() {
} }
}; };
function parseXML(xmldata) { function parseXML(xmldata, addToLocalStorage) {
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(xmldata, "text/xml"); const doc = parser.parseFromString(xmldata, "text/xml");
if (!doc) return; if (!doc) return;
@@ -85,17 +100,18 @@ function App() {
document.title = `FancyScribe ${rosterName}`; document.title = `FancyScribe ${rosterName}`;
} }
console.log(doc); console.log(doc);
let roster;
if (gameType == "Warhammer 40,000 9th Edition") { if (gameType == "Warhammer 40,000 9th Edition") {
const roster = Create40kRoster(doc); roster = Create40kRoster(doc, gameType);
console.log(roster);
if (roster && roster.forces.length > 0) { if (roster && roster.forces.length > 0) {
setRoster(roster); setRoster(roster);
setEdition(9); setEdition(9);
setError(""); setError("");
} }
} else if (gameType == "Warhammer 40,000 10th Edition") { } else if (gameType == "Warhammer 40,000 10th Edition") {
const roster = Create40kRoster10th(doc); roster = Create40kRoster10th(doc, gameType);
console.log(roster);
if (roster && roster.forces.length > 0) { if (roster && roster.forces.length > 0) {
setRoster(roster); setRoster(roster);
setEdition(10); setEdition(10);
@@ -104,6 +120,10 @@ function App() {
} else { } else {
setError("No support for game type '" + gameType + "'."); setError("No support for game type '" + gameType + "'.");
} }
if (roster && addToLocalStorage) {
console.log(roster);
setRosters(stringifyJSON([roster, ...rostersJSON]));
}
} }
useEffect(() => { useEffect(() => {
@@ -130,6 +150,14 @@ function App() {
e.stopPropagation(); e.stopPropagation();
}; };
useEffect(() => {
if (edition === 9) {
document.body.style.minWidth = "600px";
} else {
document.body.style.minWidth = "";
}
}, [edition]);
return ( return (
<div <div
className="App" className="App"
@@ -145,8 +173,7 @@ function App() {
color: "#fff", color: "#fff",
fontWeight: 800, fontWeight: 800,
display: "flex", display: "flex",
alignItems: "center", flexDirection: "column",
gap: 6,
}} }}
> >
FancyScribe{" "} FancyScribe{" "}
@@ -165,6 +192,60 @@ function App() {
</div> </div>
<div className="body"> <div className="body">
<div
className="print-display-none"
style={{
display: "flex",
flexDirection: roster ? "row" : "column",
alignItems: "center",
gap: 8,
}}
>
<div
className="print-display-none"
style={{
fontSize: "1.7rem",
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-600 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"></path>
</svg>
</button>
</div>
))}
</div>
</div>
<div className="print-display-none"></div>
<div <div
className="print-display-none" className="print-display-none"
style={{ display: "flex", width: "100%", gap: 8 }} style={{ display: "flex", width: "100%", gap: 8 }}
@@ -206,6 +287,7 @@ function App() {
Print roster Print roster
</button> </button>
</div> </div>
<div <div
className="print-display-none" className="print-display-none"
style={{ display: roster ? "flex" : "none", width: "100%", gap: 16 }} style={{ display: roster ? "flex" : "none", width: "100%", gap: 16 }}
@@ -221,6 +303,7 @@ function App() {
<label style={{ display: "flex", alignItems: "center", gap: 4 }}> <label style={{ display: "flex", alignItems: "center", gap: 4 }}>
<input <input
type="color" type="color"
className="rounded-sm border border-gray-400"
style={{ height: 24, width: 32, padding: "0 2px" }} style={{ height: 24, width: 32, padding: "0 2px" }}
value={primaryColor} value={primaryColor}
onChange={(e) => { onChange={(e) => {
@@ -254,9 +337,10 @@ function App() {
{edition === 10 && ( {edition === 10 && (
<Roster10th roster={roster} onePerPage={onePerPage} /> <Roster10th roster={roster} onePerPage={onePerPage} />
)} )}
<div <div
style={{ style={{
padding: "8px 0", paddingTop: 8,
fontSize: "1.7rem", fontSize: "1.7rem",
display: roster ? "none" : "flex", display: roster ? "none" : "flex",
}} }}
+6 -1
View File
@@ -1,5 +1,10 @@
export const Arrow = ( export const Arrow = (
<svg viewBox="0 0 16 8" width={16} height={8} fill="var(--primary-color)"> <svg
viewBox="0 0 16 8"
width={"1.2rem"}
height={8}
fill="var(--primary-color)"
>
<path d="m0 0h10l6 4-6 4h-10z" /> <path d="m0 0h10l6 4-6 4h-10z" />
</svg> </svg>
); );
+31
View File
@@ -0,0 +1,31 @@
function replacer(key, value) {
if (value instanceof Map) {
return {
dataType: "Map",
value: Array.from(value.entries()), // or with spread: value: [...value]
};
} else if (value instanceof Set) {
return ["__isSet", ...value];
} else {
return value;
}
}
function reviver(key, value) {
if (value instanceof Array && value[0] === "__isSet") {
return new Set(value.slice(1));
}
if (typeof value === "object" && value !== null) {
if (value.dataType === "Map") {
return new Map(value.value);
}
}
return value;
}
export const parseJSON = (string) => {
return JSON.parse(string, reviver);
};
export const stringifyJSON = (value) => {
return JSON.stringify(value, replacer);
};
+89 -66
View File
@@ -1,4 +1,9 @@
:root { @tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
font-family: "Noto Sans", sans-serif, Inter, system-ui, Avenir, Helvetica, font-family: "Noto Sans", sans-serif, Inter, system-ui, Avenir, Helvetica,
Arial, sans-serif; Arial, sans-serif;
line-height: 1.5; line-height: 1.5;
@@ -18,52 +23,51 @@
--size-20: 20px; --size-20: 20px;
--size-24: 24px; --size-24: 24px;
--size-32: 32px; --size-32: 32px;
} }
@font-face { @font-face {
font-family: "ConduitITCStd"; font-family: "ConduitITCStd";
src: url("/fonts/ConduitITCStd ExtraBold.woff2") format("woff2"); src: url("/fonts/ConduitITCStd ExtraBold.woff2") format("woff2");
font-weight: 800; font-weight: 800;
} }
@font-face { @font-face {
font-family: "ConduitITCStd"; font-family: "ConduitITCStd";
src: url("/fonts/ConduitITCStd Bold.woff2") format("woff2"); src: url("/fonts/ConduitITCStd Bold.woff2") format("woff2");
font-weight: 700; font-weight: 700;
} }
@font-face { @font-face {
font-family: "ConduitITCStd"; font-family: "ConduitITCStd";
src: url("/fonts/ConduitITCStd-Regular.woff2") format("woff2"); src: url("/fonts/ConduitITCStd-Regular.woff2") format("woff2");
font-weight: 400; font-weight: 400;
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
print-color-adjust: exact; print-color-adjust: exact;
-webkit-print-color-adjust: exact; -webkit-print-color-adjust: exact;
} }
a { a {
font-weight: 500; font-weight: 500;
color: #646cff; color: #646cff;
text-decoration: inherit; text-decoration: inherit;
} }
a:hover { a:hover {
color: #535bf2; color: #535bf2;
} }
body { body {
margin: 0; margin: 0;
min-width: 600px;
min-height: 100vh; min-height: 100vh;
} }
h1 { h1 {
font-size: 3.2em; font-size: 3.2em;
line-height: 1.1; line-height: 1.1;
} }
button, button,
.button { .button {
background-color: #f0f0f0; background-color: #f0f0f0;
color: #111113; color: #111113;
border-radius: 8px; border-radius: 8px;
@@ -74,20 +78,20 @@ button,
font-family: inherit; font-family: inherit;
cursor: pointer; cursor: pointer;
transition: border-color 0.25s; transition: border-color 0.25s;
} }
button:hover, button:hover,
.button:hover { .button:hover {
border-color: var(--primary-color); border-color: var(--primary-color);
background-color: var(--primary-color-transparent); background-color: var(--primary-color-transparent);
} }
button:focus, button:focus,
button:focus-visible, button:focus-visible,
.button:focus, .button:focus,
.button:focus-visible { .button:focus-visible {
outline: 4px auto -webkit-focus-ring-color; outline: 4px auto -webkit-focus-ring-color;
} }
.body { .body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -97,26 +101,26 @@ button:focus-visible,
max-width: 1150px; max-width: 1150px;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
.header { .header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 8px 16px; padding: 8px var(--size-20);
background-color: var(--primary-color); background-color: var(--primary-color);
width: 100%; width: 100%;
color: #fff; color: #fff;
font-size: 1.7em; font-size: 1.7em;
font-weight: 800; font-weight: 800;
} }
.subheader { .subheader {
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
} }
.rosterUpload { .rosterUpload {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -132,64 +136,67 @@ button:focus-visible,
font-size: 1.5rem; font-size: 1.5rem;
text-transform: uppercase; text-transform: uppercase;
cursor: pointer; cursor: pointer;
transition: color 0.25s, border 0.25s, font-size 0.25s, background-color 0.25s; transition:
} color 0.25s,
border 0.25s,
font-size 0.25s,
background-color 0.25s;
}
.rosterUpload:hover { .rosterUpload:hover {
border-color: var(--primary-color); border-color: var(--primary-color);
background-color: var(--primary-color-transparent); background-color: var(--primary-color-transparent);
font-size: 2rem; font-size: 2rem;
} }
.rosterUploaded, .rosterUploaded,
.rosterUploaded:hover { .rosterUploaded:hover {
height: auto; height: auto;
border: 2px solid #999; border: 2px solid #999;
font-size: 1rem; font-size: 1rem;
border-radius: 8px; border-radius: 8px;
} }
.weapons-table th { .weapons-table th {
font-size: 1.1em; font-size: 1.1em;
padding: 2px 6px; padding: 2px 0.4rem;
height: 27px; height: 27px;
font-weight: 600; font-weight: 600;
} }
.weapons-table td { .weapons-table td {
padding: 1px 6px; padding: 1px 0.4rem;
border-top: 1px dotted #9e9fa1; border-top: 1px dotted #9e9fa1;
text-align: center; text-align: center;
vertical-align: baseline; }
}
.noBorderTop td { .noBorderTop td {
border-top: none; border-top: none;
} }
.emptyRow { .emptyRow {
height: 22.5px; height: 22.5px;
} }
.rowOtherColor { .rowOtherColor {
background-color: #d0d1d3; background-color: #d0d1d3;
} }
.avoid-page-break { .avoid-page-break {
position: relative; position: relative;
page-break-inside: avoid; page-break-inside: avoid;
break-inside: avoid; break-inside: avoid;
} }
.page-break:not(.print-display-none) { .page-break:not(.print-display-none) {
page-break-after: page; page-break-after: page;
break-after: page; break-after: page;
} }
@media print { @media print {
.print-display-none, .print-display-none,
.print-display-none * { .print-display-none * {
display: none; display: none !important;
} }
#root { #root {
@@ -201,13 +208,29 @@ button:focus-visible,
display: block; display: block;
max-width: none; max-width: none;
} }
} }
@media screen and (max-width: 800px) { @media screen and (max-width: 800px) {
:root { :root {
font-size: 11px; font-size: 11px;
--size-20: 8px; --size-20: 8px;
--size-24: 12px; --size-24: 12px;
--size-32: 16px; --size-32: 16px;
} }
}
@media screen and (max-width: 600px) {
:root {
font-size: 9px;
line-height: 1.3;
--size-20: 8px;
--size-24: 12px;
--size-32: 16px;
}
.body {
padding-left: 0;
padding-right: 0;
}
}
} }
+15 -14
View File
@@ -168,7 +168,7 @@ export class Model extends BaseNotes {
]) { ]) {
if ( if (
!deduped.some( !deduped.some(
(e) => upgrade.getSelectionName() === e.getSelectionName() (e) => upgrade.getSelectionName() === e.getSelectionName(),
) )
) { ) {
deduped.push(upgrade); deduped.push(upgrade);
@@ -232,7 +232,7 @@ export class Unit extends BaseNotes {
for (const freeformCostType in this.cost.freeformValues) { for (const freeformCostType in this.cost.freeformValues) {
if (this.cost.freeformValues[freeformCostType] === 0) continue; if (this.cost.freeformValues[freeformCostType] === 0) continue;
extraCosts.push( extraCosts.push(
`${this.cost.freeformValues[freeformCostType]}${freeformCostType}` `${this.cost.freeformValues[freeformCostType]}${freeformCostType}`,
); );
} }
return extraCosts.length return extraCosts.length
@@ -315,7 +315,7 @@ export class Unit extends BaseNotes {
this.modelList = this.models.map( this.modelList = this.models.map(
(model) => (model) =>
(model.count > 1 ? `${model.count}x ` : "") + model.nameAndGear() (model.count > 1 ? `${model.count}x ` : "") + model.nameAndGear(),
); );
this.rangedWeapons = this.models this.rangedWeapons = this.models
.map((m) => m.rangedWeapons) .map((m) => m.rangedWeapons)
@@ -330,13 +330,13 @@ export class Unit extends BaseNotes {
this.spells.push( this.spells.push(
...this.models ...this.models
.map((m) => m.psychicPowers) .map((m) => m.psychicPowers)
.reduce((acc, val) => acc.concat(val), []) .reduce((acc, val) => acc.concat(val), []),
); );
this.psykers.push(...this.models.map((m) => m.psyker).filter((p) => p)); this.psykers.push(...this.models.map((m) => m.psyker).filter((p) => p));
this.explosions.push( this.explosions.push(
...this.models ...this.models
.map((m) => m.explosions) .map((m) => m.explosions)
.reduce((acc, val) => acc.concat(val), []) .reduce((acc, val) => acc.concat(val), []),
); );
} }
} }
@@ -353,6 +353,7 @@ export class Force extends BaseNotes {
export class Roster40k extends BaseNotes { export class Roster40k extends BaseNotes {
cost = new Costs(); cost = new Costs();
forces = []; forces = [];
gameType = "";
} }
export class Costs { export class Costs {
@@ -386,12 +387,12 @@ export class Costs {
} }
} }
export function Create40kRoster10th(doc) { export function Create40kRoster10th(doc, gameType) {
// Determine roster type (game system). // Determine roster type (game system).
let info = doc.querySelector("roster"); let info = doc.querySelector("roster");
if (info) { if (info) {
const roster = new Roster40k(); const roster = new Roster40k();
roster.gameType = gameType;
const name = info.getAttributeNode("name")?.nodeValue; const name = info.getAttributeNode("name")?.nodeValue;
if (name) { if (name) {
roster.name = name; roster.name = name;
@@ -688,7 +689,7 @@ function ParseUnit(root) {
// First, find model stats. These have typeName=Unit. // First, find model stats. These have typeName=Unit.
const modelStatsProfiles = Array.from( const modelStatsProfiles = Array.from(
root.querySelectorAll('profile[typeName="Unit"],profile[typeName="Model"]') root.querySelectorAll('profile[typeName="Unit"],profile[typeName="Model"]'),
); );
ParseModelStatsProfiles(modelStatsProfiles, unit, unitName); ParseModelStatsProfiles(modelStatsProfiles, unit, unitName);
seenProfiles.push(...modelStatsProfiles); seenProfiles.push(...modelStatsProfiles);
@@ -711,7 +712,7 @@ function ParseUnit(root) {
// Some units are under a root selection with type="upgrade". // Some units are under a root selection with type="upgrade".
if (modelSelections.length === 0) { if (modelSelections.length === 0) {
modelSelections.push( modelSelections.push(
...Array.from(root.querySelectorAll('selection[type="model"]')) ...Array.from(root.querySelectorAll('selection[type="model"]')),
); );
} }
// Some single-model units have type="unit" or type="upgrade". // Some single-model units have type="unit" or type="upgrade".
@@ -726,7 +727,7 @@ function ParseUnit(root) {
// Now, parse the model -- profiles for stats, and selections for upgrades. // Now, parse the model -- profiles for stats, and selections for upgrades.
for (const modelSelection of modelSelections) { for (const modelSelection of modelSelections) {
const profiles = Array.from( const profiles = Array.from(
modelSelection.querySelectorAll("profiles>profile") modelSelection.querySelectorAll("profiles>profile"),
); );
const unseenProfiles = profiles.filter((e) => !seenProfiles.includes(e)); const unseenProfiles = profiles.filter((e) => !seenProfiles.includes(e));
seenProfiles.push(...unseenProfiles); seenProfiles.push(...unseenProfiles);
@@ -742,13 +743,13 @@ function ParseUnit(root) {
// Find all upgrades on the model. This may include weapons that were // Find all upgrades on the model. This may include weapons that were
// parsed from profiles (above), so dedupe those in nameAndGear(). // parsed from profiles (above), so dedupe those in nameAndGear().
for (const upgradeSelection of modelSelection.querySelectorAll( for (const upgradeSelection of modelSelection.querySelectorAll(
'selections>selection[type="upgrade"]' 'selections>selection[type="upgrade"]',
)) { )) {
// Ignore selections without abilities but with sub-selection upgrades, // Ignore selections without abilities but with sub-selection upgrades,
// since those sub-selections will be picked up individually. // since those sub-selections will be picked up individually.
if ( if (
upgradeSelection.querySelector( upgradeSelection.querySelector(
'selections>selection[type="upgrade"]' 'selections>selection[type="upgrade"]',
) && ) &&
!HasImmediateProfileWithTypeName(upgradeSelection, "Abilities") !HasImmediateProfileWithTypeName(upgradeSelection, "Abilities")
) )
@@ -941,7 +942,7 @@ function ParseModelProfiles(profiles, model, unit) {
profile, profile,
profileName, profileName,
typeName, typeName,
unit.abilities.Abilities unit.abilities.Abilities,
); );
} else { } else {
// Everything else, like Prayers and Warlord Traits. // Everything else, like Prayers and Warlord Traits.
@@ -950,7 +951,7 @@ function ParseModelProfiles(profiles, model, unit) {
profile, profile,
profileName, profileName,
typeName, typeName,
unit.abilities[typeName] unit.abilities[typeName],
); );
} }
} }
+14 -13
View File
@@ -190,7 +190,7 @@ export class Model extends BaseNotes {
for (const upgrade of [...this.weapons, ...this.upgrades]) { for (const upgrade of [...this.weapons, ...this.upgrades]) {
if ( if (
!deduped.some( !deduped.some(
(e) => upgrade.getSelectionName() === e.getSelectionName() (e) => upgrade.getSelectionName() === e.getSelectionName(),
) )
) { ) {
deduped.push(upgrade); deduped.push(upgrade);
@@ -251,7 +251,7 @@ export class Unit extends BaseNotes {
for (const freeformCostType in this.cost.freeformValues) { for (const freeformCostType in this.cost.freeformValues) {
if (this.cost.freeformValues[freeformCostType] === 0) continue; if (this.cost.freeformValues[freeformCostType] === 0) continue;
extraCosts.push( extraCosts.push(
`${this.cost.freeformValues[freeformCostType]}${freeformCostType}` `${this.cost.freeformValues[freeformCostType]}${freeformCostType}`,
); );
} }
return extraCosts.length return extraCosts.length
@@ -334,7 +334,7 @@ export class Unit extends BaseNotes {
this.modelList = this.models.map( this.modelList = this.models.map(
(model) => (model) =>
(model.count > 1 ? `${model.count}x ` : "") + model.nameAndGear() (model.count > 1 ? `${model.count}x ` : "") + model.nameAndGear(),
); );
this.weapons = this.models this.weapons = this.models
.map((m) => m.weapons) .map((m) => m.weapons)
@@ -345,13 +345,13 @@ export class Unit extends BaseNotes {
this.spells.push( this.spells.push(
...this.models ...this.models
.map((m) => m.psychicPowers) .map((m) => m.psychicPowers)
.reduce((acc, val) => acc.concat(val), []) .reduce((acc, val) => acc.concat(val), []),
); );
this.psykers.push(...this.models.map((m) => m.psyker).filter((p) => p)); this.psykers.push(...this.models.map((m) => m.psyker).filter((p) => p));
this.explosions.push( this.explosions.push(
...this.models ...this.models
.map((m) => m.explosions) .map((m) => m.explosions)
.reduce((acc, val) => acc.concat(val), []) .reduce((acc, val) => acc.concat(val), []),
); );
} }
} }
@@ -368,6 +368,7 @@ export class Force extends BaseNotes {
export class Roster40k extends BaseNotes { export class Roster40k extends BaseNotes {
cost = new Costs(); cost = new Costs();
forces = []; forces = [];
gameType = "";
} }
export class Costs { export class Costs {
@@ -401,12 +402,12 @@ export class Costs {
} }
} }
export function Create40kRoster(doc) { export function Create40kRoster(doc, gameType) {
// Determine roster type (game system). // Determine roster type (game system).
let info = doc.querySelector("roster"); let info = doc.querySelector("roster");
if (info) { if (info) {
const roster = new Roster40k(); const roster = new Roster40k();
roster.gameType = gameType;
const name = info.getAttributeNode("name")?.nodeValue; const name = info.getAttributeNode("name")?.nodeValue;
if (name) { if (name) {
roster.name = name; roster.name = name;
@@ -723,7 +724,7 @@ function ParseUnit(root) {
// First, find model stats. These have typeName=Unit. // First, find model stats. These have typeName=Unit.
const modelStatsProfiles = Array.from( const modelStatsProfiles = Array.from(
root.querySelectorAll('profile[typeName="Unit"],profile[typeName="Model"]') root.querySelectorAll('profile[typeName="Unit"],profile[typeName="Model"]'),
); );
ParseModelStatsProfiles(modelStatsProfiles, unit, unitName); ParseModelStatsProfiles(modelStatsProfiles, unit, unitName);
seenProfiles.push(...modelStatsProfiles); seenProfiles.push(...modelStatsProfiles);
@@ -746,7 +747,7 @@ function ParseUnit(root) {
// Some units are under a root selection with type="upgrade". // Some units are under a root selection with type="upgrade".
if (modelSelections.length === 0) { if (modelSelections.length === 0) {
modelSelections.push( modelSelections.push(
...Array.from(root.querySelectorAll('selection[type="model"]')) ...Array.from(root.querySelectorAll('selection[type="model"]')),
); );
} }
// Some single-model units have type="unit" or type="upgrade". // Some single-model units have type="unit" or type="upgrade".
@@ -761,7 +762,7 @@ function ParseUnit(root) {
// Now, parse the model -- profiles for stats, and selections for upgrades. // Now, parse the model -- profiles for stats, and selections for upgrades.
for (const modelSelection of modelSelections) { for (const modelSelection of modelSelections) {
const profiles = Array.from( const profiles = Array.from(
modelSelection.querySelectorAll("profiles>profile") modelSelection.querySelectorAll("profiles>profile"),
); );
const unseenProfiles = profiles.filter((e) => !seenProfiles.includes(e)); const unseenProfiles = profiles.filter((e) => !seenProfiles.includes(e));
seenProfiles.push(...unseenProfiles); seenProfiles.push(...unseenProfiles);
@@ -777,13 +778,13 @@ function ParseUnit(root) {
// Find all upgrades on the model. This may include weapons that were // Find all upgrades on the model. This may include weapons that were
// parsed from profiles (above), so dedupe those in nameAndGear(). // parsed from profiles (above), so dedupe those in nameAndGear().
for (const upgradeSelection of modelSelection.querySelectorAll( for (const upgradeSelection of modelSelection.querySelectorAll(
'selections>selection[type="upgrade"]' 'selections>selection[type="upgrade"]',
)) { )) {
// Ignore selections without abilities but with sub-selection upgrades, // Ignore selections without abilities but with sub-selection upgrades,
// since those sub-selections will be picked up individually. // since those sub-selections will be picked up individually.
if ( if (
upgradeSelection.querySelector( upgradeSelection.querySelector(
'selections>selection[type="upgrade"]' 'selections>selection[type="upgrade"]',
) && ) &&
!HasImmediateProfileWithTypeName(upgradeSelection, "Abilities") !HasImmediateProfileWithTypeName(upgradeSelection, "Abilities")
) )
@@ -965,7 +966,7 @@ function ParseModelProfiles(profiles, model, unit) {
profile, profile,
profileName, profileName,
typeName, typeName,
unit.abilities.Abilities unit.abilities.Abilities,
); );
} }
} }
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [],
};