save rosters in localStorage, better mobile support for 10th edition, add tailwind
This commit is contained in:
Generated
+3915
-1663
File diff suppressed because it is too large
Load Diff
+31
-20
@@ -1,22 +1,33 @@
|
||||
{
|
||||
"name": "fancyscribe",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"jszip": "^3.10.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.28",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@vitejs/plugin-react": "^3.1.0",
|
||||
"vite": "^4.2.0"
|
||||
}
|
||||
"name": "fancyscribe",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"jszip": "^3.10.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.31",
|
||||
"@types/react-dom": "^18.2.14",
|
||||
"@vitejs/plugin-react": "^4.1.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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
+74
-83
@@ -1,7 +1,6 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { useRef, useState, useEffect, Fragment } from "react";
|
||||
import factionBackground from "../assets/factionBackground.png";
|
||||
import adeptusAstartesIcon from "../assets/adeptusAstartesIcon.png";
|
||||
import rangedIcon from "../assets/rangedIcon.png";
|
||||
import { Arrow, wavyLine } from "../assets/icons";
|
||||
import { Weapons, hasDifferentProfiles } from "./Weapons";
|
||||
import { useLocalStorage } from "../helpers/useLocalStorage";
|
||||
@@ -85,7 +84,7 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
|
||||
const weaponDescriptions = weapons
|
||||
.filter(
|
||||
(weapon) =>
|
||||
(weapon.range === "-" || weapon.range === "") && weapon.abilities
|
||||
(weapon.range === "-" || weapon.range === "") && weapon.abilities,
|
||||
)
|
||||
.sort((a, b) => a.selectionName.localeCompare(b.selectionName));
|
||||
const modelsWithDifferentProfiles = weapons.filter((weapon, index) => {
|
||||
@@ -97,7 +96,7 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
|
||||
((previousWeapon &&
|
||||
hasDifferentProfiles(
|
||||
previousWeapon.selectionName,
|
||||
previousWeapon.name
|
||||
previousWeapon.name,
|
||||
) &&
|
||||
selectionName === previousWeapon.selectionName) ||
|
||||
(nextWeapon &&
|
||||
@@ -135,7 +134,7 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
|
||||
model.oc === firstModel.oc &&
|
||||
model.bs === firstModel.bs &&
|
||||
model.ws === firstModel.ws &&
|
||||
model.attacks === firstModel.attacks
|
||||
model.attacks === firstModel.attacks,
|
||||
);
|
||||
};
|
||||
if (areAllModelsTheSame(modelStats)) {
|
||||
@@ -164,6 +163,7 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<input type="checkbox" onChange={() => setHide(!hide)} />
|
||||
@@ -200,7 +200,7 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
|
||||
style={{
|
||||
backgroundColor: "var(--primary-color-transparent)",
|
||||
height: 80,
|
||||
minWidth: 340,
|
||||
minWidth: "33%",
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
@@ -286,57 +286,53 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
|
||||
style={{ width: "100%", height: "100%", objectFit: "contain" }}
|
||||
/>
|
||||
)}
|
||||
<label
|
||||
className="button print-display-none"
|
||||
style={{
|
||||
position: "absolute",
|
||||
border: "1px solid #999",
|
||||
top: 2,
|
||||
right: 1,
|
||||
padding: "1px 4px",
|
||||
fontSize: "0.8rem",
|
||||
backgroundColor: "#f0f0f0",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={uploadRef}
|
||||
type="file"
|
||||
className="print-display-none"
|
||||
accept=".jpg,.png,.jpeg,.gif,.bmp,.tif,.tiff,.webp,.svg,.jfif,.pjpeg,.pjp,.avif,.apng,.ico,.cur,.ani"
|
||||
onChange={(e) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
let reader = new FileReader();
|
||||
reader.onload = function (ev) {
|
||||
setImage(ev.target.result);
|
||||
}.bind(this);
|
||||
reader.readAsDataURL(e.target.files[0]);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: "none",
|
||||
}}
|
||||
/>
|
||||
{hasImage ? "Change image" : "Upload image "}
|
||||
</label>
|
||||
{hasImage && (
|
||||
<button
|
||||
<div className="absolute right-[1px] top-[2px] flex gap-1">
|
||||
<label
|
||||
className="button print-display-none"
|
||||
style={{
|
||||
position: "absolute",
|
||||
border: "1px solid #999",
|
||||
top: 2,
|
||||
right: 84,
|
||||
padding: "2px 4px",
|
||||
padding: "1px 4px",
|
||||
fontSize: "0.8rem",
|
||||
backgroundColor: "#f0f0f0",
|
||||
}}
|
||||
onClick={() => {
|
||||
setImage(undefined);
|
||||
}}
|
||||
>
|
||||
Clear image
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={uploadRef}
|
||||
type="file"
|
||||
className="print-display-none"
|
||||
accept=".jpg,.png,.jpeg,.gif,.bmp,.tif,.tiff,.webp,.svg,.jfif,.pjpeg,.pjp,.avif,.apng,.ico,.cur,.ani"
|
||||
onChange={(e) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
let reader = new FileReader();
|
||||
reader.onload = function (ev) {
|
||||
setImage(ev.target.result);
|
||||
}.bind(this);
|
||||
reader.readAsDataURL(e.target.files[0]);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: "none",
|
||||
}}
|
||||
/>
|
||||
{hasImage ? "Change image" : "Upload image "}
|
||||
</label>
|
||||
{hasImage && (
|
||||
<button
|
||||
className="button print-display-none"
|
||||
style={{
|
||||
border: "1px solid #999",
|
||||
padding: "1px 4px",
|
||||
fontSize: "0.8rem",
|
||||
backgroundColor: "#f0f0f0",
|
||||
}}
|
||||
onClick={() => {
|
||||
setImage(undefined);
|
||||
}}
|
||||
>
|
||||
Clear image
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -383,30 +379,24 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<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 &&
|
||||
!weaponDescriptions.length && (
|
||||
<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
|
||||
colSpan={7}
|
||||
style={{
|
||||
textAlign: "left",
|
||||
fontSize: ".8em",
|
||||
paddingLeft: 0,
|
||||
verticalAlign: "middle",
|
||||
}}
|
||||
>
|
||||
Before selecting targets for this weapon, select one of
|
||||
@@ -419,14 +409,7 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
|
||||
|
||||
<Keywords keywords={keywords} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: "var(--size-20) var(--size-20) 50px var(--size-20)",
|
||||
flex: "1",
|
||||
maxWidth: 400,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<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]">
|
||||
<div
|
||||
style={{
|
||||
fontSize: "1.1em",
|
||||
@@ -461,14 +444,14 @@ const ModelStats = ({ modelStat, index, showName, modelList }) => {
|
||||
|
||||
const showWeapons = index === 0;
|
||||
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="T" characteristic={toughness} index={index} />
|
||||
<Characteristic title="SV" characteristic={save} index={index} />
|
||||
<Characteristic title="W" characteristic={wounds} index={index} />
|
||||
<Characteristic title="LD" characteristic={leadership} index={index} />
|
||||
<Characteristic title="OC" characteristic={oc} index={index} />
|
||||
<div style={{ display: "flex", alignItems: "center", marginLeft: -10 }}>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
{showName && (
|
||||
<div
|
||||
style={{
|
||||
@@ -530,6 +513,7 @@ const FactionIcon = ({ catalog }) => {
|
||||
};
|
||||
|
||||
const Keywords = ({ keywords }) => {
|
||||
const joinedKeywords = [...keywords].join(", ");
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -542,15 +526,20 @@ const Keywords = ({ keywords }) => {
|
||||
alignItems: "center",
|
||||
backgroundColor: "#BCBCBE",
|
||||
border: "2px solid var(--primary-color)",
|
||||
width: "calc(100% - 18px)",
|
||||
width: "calc(100% - 0.9rem)",
|
||||
backgroundSize: "contain",
|
||||
minHeight: 54,
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "1.1em" }}>KEYWORDS:</span>
|
||||
<span style={{ fontSize: "1em", fontWeight: 800 }}>
|
||||
{[...keywords].join(", ")}
|
||||
<span
|
||||
style={{
|
||||
fontSize: joinedKeywords.length > 80 ? ".8em" : "1em",
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
{joinedKeywords}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -572,7 +561,7 @@ const Factions = ({ factions }) => {
|
||||
color: "#fff",
|
||||
border: "2px solid var(--primary-color)",
|
||||
borderLeft: "none",
|
||||
width: "calc(100% - 18px)",
|
||||
width: "calc(100% - 0.9rem)",
|
||||
backgroundSize: "contain",
|
||||
minHeight: 54,
|
||||
}}
|
||||
@@ -670,8 +659,8 @@ const FancyBox = ({ children }) => {
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
minWidth: 40,
|
||||
minHeight: 40,
|
||||
minWidth: "3rem",
|
||||
minHeight: "3rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -691,7 +680,7 @@ const OtherAbilities = ({ abilities }) => {
|
||||
let keys = [...Object.keys(abilities)?.filter((key) => key !== "Abilities")];
|
||||
return keys.map((key) => {
|
||||
return (
|
||||
<>
|
||||
<Fragment key={key}>
|
||||
<thead>
|
||||
<tr
|
||||
style={{
|
||||
@@ -725,7 +714,7 @@ const OtherAbilities = ({ abilities }) => {
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</>
|
||||
</Fragment>
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -746,6 +735,8 @@ const ForceRules = ({ rules, onePerPage }) => {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: 4,
|
||||
paddingBottom: 2,
|
||||
}}
|
||||
>
|
||||
<input type="checkbox" onChange={() => setHide(!hide)} />
|
||||
|
||||
+24
-16
@@ -14,18 +14,24 @@ export const Weapons = ({ title, weapons, modelStats, forceRules }) => {
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
<th style={{ width: 37 }}>
|
||||
<div style={{ display: "flex" }}>
|
||||
<th className="w-[2.2em]">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
minWidth: ".7em",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<img src={isMelee ? meleeIcon : rangedIcon} />
|
||||
</div>
|
||||
</th>
|
||||
<th style={{ textAlign: "left" }}>{title}</th>
|
||||
<th>RANGE</th>
|
||||
<th>A</th>
|
||||
<th>{isMelee ? "WS" : "BS"}</th>
|
||||
<th>S</th>
|
||||
<th>AP</th>
|
||||
<th>D</th>
|
||||
<th className="w-[5%]">RANGE</th>
|
||||
<th className="w-[5%]">A</th>
|
||||
<th className="w-[5%]">{isMelee ? "WS" : "BS"}</th>
|
||||
<th className="w-[5%]">S</th>
|
||||
<th className="w-[5%]">AP</th>
|
||||
<th className="w-[5%]">D</th>
|
||||
</tr>
|
||||
</thead>
|
||||
)}
|
||||
@@ -44,7 +50,7 @@ export const Weapons = ({ title, weapons, modelStats, forceRules }) => {
|
||||
))}
|
||||
{weapons.length > 0 && (
|
||||
<tr className="emptyRow">
|
||||
<td style={{ width: 37, borderTop: "none" }}></td>
|
||||
<td style={{ borderTop: "none" }}></td>
|
||||
<td colSpan={7}></td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -102,7 +108,9 @@ const Weapon = ({ weapon, previousWeapon, nextWeapon, isMelee, className }) => {
|
||||
<>
|
||||
<tr className={className}>
|
||||
<td style={{ borderTop: "none", backgroundColor: "#dfe0e2" }}>
|
||||
{differentProfiles && Arrow}
|
||||
{differentProfiles && (
|
||||
<div className="flex justify-center">{Arrow}</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ textAlign: "left" }}>
|
||||
<div
|
||||
@@ -131,9 +139,9 @@ const Weapon = ({ weapon, previousWeapon, nextWeapon, isMelee, className }) => {
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>{range}</td>
|
||||
<td>{attacks}</td>
|
||||
<td>
|
||||
<td className="align-middle">{range}</td>
|
||||
<td className="align-middle">{attacks}</td>
|
||||
<td className="align-middle">
|
||||
{isMelee
|
||||
? ws.join
|
||||
? ws.join("|")
|
||||
@@ -142,9 +150,9 @@ const Weapon = ({ weapon, previousWeapon, nextWeapon, isMelee, className }) => {
|
||||
? bs.join("|")
|
||||
: bs}
|
||||
</td>
|
||||
<td>{str}</td>
|
||||
<td>{ap}</td>
|
||||
<td>{damage}</td>
|
||||
<td className="align-middle">{str}</td>
|
||||
<td className="align-middle">{ap}</td>
|
||||
<td className="align-middle">{damage}</td>
|
||||
</tr>
|
||||
{abilities && abilities !== "-" && (
|
||||
<tr className={className + " noBorderTop"}>
|
||||
|
||||
+20
-9
@@ -84,7 +84,7 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
|
||||
.filter(
|
||||
(weapon) =>
|
||||
(weapon.range === "Melee" || weapon.type === "Melee") &&
|
||||
weapon.range !== ""
|
||||
weapon.range !== "",
|
||||
)
|
||||
.sort((a, b) => a.selectionName.localeCompare(b.selectionName));
|
||||
if (meleeWeapons.length === 0) {
|
||||
@@ -101,14 +101,14 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
|
||||
const rangedWeapons = weapons
|
||||
.filter(
|
||||
(weapon) =>
|
||||
weapon.range !== "Melee" && weapon.range !== "-" && weapon.range !== ""
|
||||
weapon.range !== "Melee" && weapon.range !== "-" && weapon.range !== "",
|
||||
)
|
||||
.sort((a, b) => a.selectionName.localeCompare(b.selectionName));
|
||||
|
||||
const weaponDescriptions = weapons
|
||||
.filter(
|
||||
(weapon) =>
|
||||
(weapon.range === "-" || weapon.range === "") && weapon.abilities
|
||||
(weapon.range === "-" || weapon.range === "") && weapon.abilities,
|
||||
)
|
||||
.sort((a, b) => a.selectionName.localeCompare(b.selectionName));
|
||||
const modelsWithDifferentProfiles = weapons.filter((weapon, index) => {
|
||||
@@ -120,7 +120,7 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
|
||||
((previousWeapon &&
|
||||
hasDifferentProfiles(
|
||||
previousWeapon.selectionName,
|
||||
previousWeapon.name
|
||||
previousWeapon.name,
|
||||
) &&
|
||||
selectionName === previousWeapon.selectionName) ||
|
||||
(nextWeapon &&
|
||||
@@ -168,6 +168,7 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<label className="print-display-none">
|
||||
@@ -377,12 +378,21 @@ const Unit = ({ unit, index, catalog, onePerPage, forceRules }) => {
|
||||
<tbody>
|
||||
{weaponDescriptions.map((weapon, index) => (
|
||||
<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
|
||||
style={{
|
||||
textAlign: "left",
|
||||
fontSize: ".8em",
|
||||
paddingLeft: 0,
|
||||
verticalAlign: "middle",
|
||||
}}
|
||||
>
|
||||
{weapon.name} - {weapon.abilities}
|
||||
@@ -457,13 +467,13 @@ const ModelStats = ({ modelStat, index, showName, showWeapons, modelList }) => {
|
||||
modelList = modelList.filter((model) => !model.includes("Sergeant"));
|
||||
}
|
||||
let modelListMatches = modelList.filter((model) =>
|
||||
model.includes(name + " w")
|
||||
model.includes(name + " w"),
|
||||
);
|
||||
if (modelListMatches.length === 0) {
|
||||
modelListMatches = modelList.filter((model) => model.includes(name));
|
||||
}
|
||||
modelListMatches = modelListMatches.map((model) =>
|
||||
model.replaceAll(name, "")
|
||||
model.replaceAll(name, ""),
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -616,7 +626,7 @@ const Abilities = ({ abilities }) => {
|
||||
if (!abilities) return null;
|
||||
let keys = [...abilities.keys()];
|
||||
keys = keys.filter(
|
||||
(key) => key !== "Stratagem: Warlord Trait" && key !== "Stratagem: Relic"
|
||||
(key) => key !== "Stratagem: Warlord Trait" && key !== "Stratagem: Relic",
|
||||
);
|
||||
return (
|
||||
<div
|
||||
@@ -747,7 +757,7 @@ const WoundTracker = ({ woundTracker }) => {
|
||||
if (!woundTracker || woundTracker.length === 0) return null;
|
||||
const uniqueWoundTracker = woundTracker.filter((woundTrack, index) => {
|
||||
const firstIndex = woundTracker.findIndex(
|
||||
(woundTrack2) => woundTrack2.name === woundTrack.name
|
||||
(woundTrack2) => woundTrack2.name === woundTrack.name,
|
||||
);
|
||||
return firstIndex === index;
|
||||
});
|
||||
@@ -913,6 +923,7 @@ const ForceRules = ({ rules, onePerPage }) => {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<input type="checkbox" onChange={() => setHide(!hide)} />
|
||||
|
||||
+95
-11
@@ -7,11 +7,15 @@ 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(9); // [9, 10]
|
||||
const [edition, setEdition] = useState(10); // [9, 10]
|
||||
const [onePerPage, setOnePerPage] = useState(false);
|
||||
const [primaryColor, setPrimaryColor] = useState("#536766");
|
||||
const uploadRef = useRef();
|
||||
@@ -27,7 +31,7 @@ function App() {
|
||||
reader.onloadend = async () => {
|
||||
const content = reader.result;
|
||||
const xmldata = await unzip(content);
|
||||
parseXML(xmldata);
|
||||
parseXML(xmldata, true);
|
||||
};
|
||||
reader.readAsBinaryString(files[0]);
|
||||
} else {
|
||||
@@ -54,9 +58,20 @@ function App() {
|
||||
// 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);
|
||||
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) => {
|
||||
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 doc = parser.parseFromString(xmldata, "text/xml");
|
||||
if (!doc) return;
|
||||
@@ -85,17 +100,18 @@ function App() {
|
||||
document.title = `FancyScribe ${rosterName}`;
|
||||
}
|
||||
console.log(doc);
|
||||
|
||||
let roster;
|
||||
if (gameType == "Warhammer 40,000 9th Edition") {
|
||||
const roster = Create40kRoster(doc);
|
||||
console.log(roster);
|
||||
roster = Create40kRoster(doc, gameType);
|
||||
if (roster && roster.forces.length > 0) {
|
||||
setRoster(roster);
|
||||
setEdition(9);
|
||||
setError("");
|
||||
}
|
||||
} else if (gameType == "Warhammer 40,000 10th Edition") {
|
||||
const roster = Create40kRoster10th(doc);
|
||||
console.log(roster);
|
||||
roster = Create40kRoster10th(doc, gameType);
|
||||
|
||||
if (roster && roster.forces.length > 0) {
|
||||
setRoster(roster);
|
||||
setEdition(10);
|
||||
@@ -104,6 +120,10 @@ function App() {
|
||||
} else {
|
||||
setError("No support for game type '" + gameType + "'.");
|
||||
}
|
||||
if (roster && addToLocalStorage) {
|
||||
console.log(roster);
|
||||
setRosters(stringifyJSON([roster, ...rostersJSON]));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -130,6 +150,14 @@ function App() {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (edition === 9) {
|
||||
document.body.style.minWidth = "600px";
|
||||
} else {
|
||||
document.body.style.minWidth = "";
|
||||
}
|
||||
}, [edition]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="App"
|
||||
@@ -145,8 +173,7 @@ function App() {
|
||||
color: "#fff",
|
||||
fontWeight: 800,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
FancyScribe{" "}
|
||||
@@ -165,6 +192,60 @@ function App() {
|
||||
</div>
|
||||
|
||||
<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
|
||||
className="print-display-none"
|
||||
style={{ display: "flex", width: "100%", gap: 8 }}
|
||||
@@ -206,6 +287,7 @@ function App() {
|
||||
Print roster
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="print-display-none"
|
||||
style={{ display: roster ? "flex" : "none", width: "100%", gap: 16 }}
|
||||
@@ -221,6 +303,7 @@ function App() {
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<input
|
||||
type="color"
|
||||
className="rounded-sm border border-gray-400"
|
||||
style={{ height: 24, width: 32, padding: "0 2px" }}
|
||||
value={primaryColor}
|
||||
onChange={(e) => {
|
||||
@@ -254,9 +337,10 @@ function App() {
|
||||
{edition === 10 && (
|
||||
<Roster10th roster={roster} onePerPage={onePerPage} />
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 0",
|
||||
paddingTop: 8,
|
||||
fontSize: "1.7rem",
|
||||
display: roster ? "none" : "flex",
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
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" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
+223
-200
@@ -1,213 +1,236 @@
|
||||
:root {
|
||||
font-family: "Noto Sans", sans-serif, Inter, system-ui, Avenir, Helvetica,
|
||||
Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: #111113;
|
||||
background-color: #ffffff;
|
||||
@layer base {
|
||||
:root {
|
||||
font-family: "Noto Sans", sans-serif, Inter, system-ui, Avenir, Helvetica,
|
||||
Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
color-scheme: light dark;
|
||||
color: #111113;
|
||||
background-color: #ffffff;
|
||||
|
||||
--size-20: 20px;
|
||||
--size-24: 24px;
|
||||
--size-32: 32px;
|
||||
}
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
|
||||
@font-face {
|
||||
font-family: "ConduitITCStd";
|
||||
src: url("/fonts/ConduitITCStd ExtraBold.woff2") format("woff2");
|
||||
font-weight: 800;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "ConduitITCStd";
|
||||
src: url("/fonts/ConduitITCStd Bold.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "ConduitITCStd";
|
||||
src: url("/fonts/ConduitITCStd-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 600px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button,
|
||||
.button {
|
||||
background-color: #f0f0f0;
|
||||
color: #111113;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #999;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover,
|
||||
.button:hover {
|
||||
border-color: var(--primary-color);
|
||||
background-color: var(--primary-color-transparent);
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible,
|
||||
.button:focus,
|
||||
.button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: var(--size-24) var(--size-20);
|
||||
max-width: 1150px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background-color: var(--primary-color);
|
||||
width: 100%;
|
||||
color: #fff;
|
||||
font-size: 1.7em;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.subheader {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rosterUpload {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px 24px;
|
||||
width: 100%;
|
||||
height: 450px;
|
||||
border: 10px solid #999;
|
||||
border-radius: 16px;
|
||||
font-weight: bold;
|
||||
font-size: 1.5rem;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: color 0.25s, border 0.25s, font-size 0.25s, background-color 0.25s;
|
||||
}
|
||||
|
||||
.rosterUpload:hover {
|
||||
border-color: var(--primary-color);
|
||||
background-color: var(--primary-color-transparent);
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.rosterUploaded,
|
||||
.rosterUploaded:hover {
|
||||
height: auto;
|
||||
border: 2px solid #999;
|
||||
font-size: 1rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.weapons-table th {
|
||||
font-size: 1.1em;
|
||||
padding: 2px 6px;
|
||||
height: 27px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.weapons-table td {
|
||||
padding: 1px 6px;
|
||||
border-top: 1px dotted #9e9fa1;
|
||||
text-align: center;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.noBorderTop td {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.emptyRow {
|
||||
height: 22.5px;
|
||||
}
|
||||
|
||||
.rowOtherColor {
|
||||
background-color: #d0d1d3;
|
||||
}
|
||||
|
||||
.avoid-page-break {
|
||||
position: relative;
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.page-break:not(.print-display-none) {
|
||||
page-break-after: page;
|
||||
break-after: page;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.print-display-none,
|
||||
.print-display-none * {
|
||||
display: none;
|
||||
--size-20: 20px;
|
||||
--size-24: 24px;
|
||||
--size-32: 32px;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-width: 1000px;
|
||||
@font-face {
|
||||
font-family: "ConduitITCStd";
|
||||
src: url("/fonts/ConduitITCStd ExtraBold.woff2") format("woff2");
|
||||
font-weight: 800;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "ConduitITCStd";
|
||||
src: url("/fonts/ConduitITCStd Bold.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "ConduitITCStd";
|
||||
src: url("/fonts/ConduitITCStd-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button,
|
||||
.button {
|
||||
background-color: #f0f0f0;
|
||||
color: #111113;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #999;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover,
|
||||
.button:hover {
|
||||
border-color: var(--primary-color);
|
||||
background-color: var(--primary-color-transparent);
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible,
|
||||
.button:focus,
|
||||
.button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 0;
|
||||
display: block;
|
||||
max-width: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: var(--size-24) var(--size-20);
|
||||
max-width: 1150px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
:root {
|
||||
font-size: 11px;
|
||||
--size-20: 8px;
|
||||
--size-24: 12px;
|
||||
--size-32: 16px;
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px var(--size-20);
|
||||
background-color: var(--primary-color);
|
||||
width: 100%;
|
||||
color: #fff;
|
||||
font-size: 1.7em;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.subheader {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rosterUpload {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px 24px;
|
||||
width: 100%;
|
||||
height: 450px;
|
||||
border: 10px solid #999;
|
||||
border-radius: 16px;
|
||||
font-weight: bold;
|
||||
font-size: 1.5rem;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.25s,
|
||||
border 0.25s,
|
||||
font-size 0.25s,
|
||||
background-color 0.25s;
|
||||
}
|
||||
|
||||
.rosterUpload:hover {
|
||||
border-color: var(--primary-color);
|
||||
background-color: var(--primary-color-transparent);
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.rosterUploaded,
|
||||
.rosterUploaded:hover {
|
||||
height: auto;
|
||||
border: 2px solid #999;
|
||||
font-size: 1rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.weapons-table th {
|
||||
font-size: 1.1em;
|
||||
padding: 2px 0.4rem;
|
||||
height: 27px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.weapons-table td {
|
||||
padding: 1px 0.4rem;
|
||||
border-top: 1px dotted #9e9fa1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.noBorderTop td {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.emptyRow {
|
||||
height: 22.5px;
|
||||
}
|
||||
|
||||
.rowOtherColor {
|
||||
background-color: #d0d1d3;
|
||||
}
|
||||
|
||||
.avoid-page-break {
|
||||
position: relative;
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.page-break:not(.print-display-none) {
|
||||
page-break-after: page;
|
||||
break-after: page;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.print-display-none,
|
||||
.print-display-none * {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-width: 1000px;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 0;
|
||||
display: block;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
:root {
|
||||
font-size: 11px;
|
||||
--size-20: 8px;
|
||||
--size-24: 12px;
|
||||
--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
@@ -168,7 +168,7 @@ export class Model extends BaseNotes {
|
||||
]) {
|
||||
if (
|
||||
!deduped.some(
|
||||
(e) => upgrade.getSelectionName() === e.getSelectionName()
|
||||
(e) => upgrade.getSelectionName() === e.getSelectionName(),
|
||||
)
|
||||
) {
|
||||
deduped.push(upgrade);
|
||||
@@ -232,7 +232,7 @@ export class Unit extends BaseNotes {
|
||||
for (const freeformCostType in this.cost.freeformValues) {
|
||||
if (this.cost.freeformValues[freeformCostType] === 0) continue;
|
||||
extraCosts.push(
|
||||
`${this.cost.freeformValues[freeformCostType]}${freeformCostType}`
|
||||
`${this.cost.freeformValues[freeformCostType]}${freeformCostType}`,
|
||||
);
|
||||
}
|
||||
return extraCosts.length
|
||||
@@ -315,7 +315,7 @@ export class Unit extends BaseNotes {
|
||||
|
||||
this.modelList = this.models.map(
|
||||
(model) =>
|
||||
(model.count > 1 ? `${model.count}x ` : "") + model.nameAndGear()
|
||||
(model.count > 1 ? `${model.count}x ` : "") + model.nameAndGear(),
|
||||
);
|
||||
this.rangedWeapons = this.models
|
||||
.map((m) => m.rangedWeapons)
|
||||
@@ -330,13 +330,13 @@ export class Unit extends BaseNotes {
|
||||
this.spells.push(
|
||||
...this.models
|
||||
.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.explosions.push(
|
||||
...this.models
|
||||
.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 {
|
||||
cost = new Costs();
|
||||
forces = [];
|
||||
gameType = "";
|
||||
}
|
||||
|
||||
export class Costs {
|
||||
@@ -386,12 +387,12 @@ export class Costs {
|
||||
}
|
||||
}
|
||||
|
||||
export function Create40kRoster10th(doc) {
|
||||
export function Create40kRoster10th(doc, gameType) {
|
||||
// Determine roster type (game system).
|
||||
let info = doc.querySelector("roster");
|
||||
if (info) {
|
||||
const roster = new Roster40k();
|
||||
|
||||
roster.gameType = gameType;
|
||||
const name = info.getAttributeNode("name")?.nodeValue;
|
||||
if (name) {
|
||||
roster.name = name;
|
||||
@@ -688,7 +689,7 @@ function ParseUnit(root) {
|
||||
|
||||
// First, find model stats. These have typeName=Unit.
|
||||
const modelStatsProfiles = Array.from(
|
||||
root.querySelectorAll('profile[typeName="Unit"],profile[typeName="Model"]')
|
||||
root.querySelectorAll('profile[typeName="Unit"],profile[typeName="Model"]'),
|
||||
);
|
||||
ParseModelStatsProfiles(modelStatsProfiles, unit, unitName);
|
||||
seenProfiles.push(...modelStatsProfiles);
|
||||
@@ -711,7 +712,7 @@ function ParseUnit(root) {
|
||||
// Some units are under a root selection with type="upgrade".
|
||||
if (modelSelections.length === 0) {
|
||||
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".
|
||||
@@ -726,7 +727,7 @@ function ParseUnit(root) {
|
||||
// Now, parse the model -- profiles for stats, and selections for upgrades.
|
||||
for (const modelSelection of modelSelections) {
|
||||
const profiles = Array.from(
|
||||
modelSelection.querySelectorAll("profiles>profile")
|
||||
modelSelection.querySelectorAll("profiles>profile"),
|
||||
);
|
||||
const unseenProfiles = profiles.filter((e) => !seenProfiles.includes(e));
|
||||
seenProfiles.push(...unseenProfiles);
|
||||
@@ -742,13 +743,13 @@ function ParseUnit(root) {
|
||||
// Find all upgrades on the model. This may include weapons that were
|
||||
// parsed from profiles (above), so dedupe those in nameAndGear().
|
||||
for (const upgradeSelection of modelSelection.querySelectorAll(
|
||||
'selections>selection[type="upgrade"]'
|
||||
'selections>selection[type="upgrade"]',
|
||||
)) {
|
||||
// Ignore selections without abilities but with sub-selection upgrades,
|
||||
// since those sub-selections will be picked up individually.
|
||||
if (
|
||||
upgradeSelection.querySelector(
|
||||
'selections>selection[type="upgrade"]'
|
||||
'selections>selection[type="upgrade"]',
|
||||
) &&
|
||||
!HasImmediateProfileWithTypeName(upgradeSelection, "Abilities")
|
||||
)
|
||||
@@ -941,7 +942,7 @@ function ParseModelProfiles(profiles, model, unit) {
|
||||
profile,
|
||||
profileName,
|
||||
typeName,
|
||||
unit.abilities.Abilities
|
||||
unit.abilities.Abilities,
|
||||
);
|
||||
} else {
|
||||
// Everything else, like Prayers and Warlord Traits.
|
||||
@@ -950,7 +951,7 @@ function ParseModelProfiles(profiles, model, unit) {
|
||||
profile,
|
||||
profileName,
|
||||
typeName,
|
||||
unit.abilities[typeName]
|
||||
unit.abilities[typeName],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+14
-13
@@ -190,7 +190,7 @@ export class Model extends BaseNotes {
|
||||
for (const upgrade of [...this.weapons, ...this.upgrades]) {
|
||||
if (
|
||||
!deduped.some(
|
||||
(e) => upgrade.getSelectionName() === e.getSelectionName()
|
||||
(e) => upgrade.getSelectionName() === e.getSelectionName(),
|
||||
)
|
||||
) {
|
||||
deduped.push(upgrade);
|
||||
@@ -251,7 +251,7 @@ export class Unit extends BaseNotes {
|
||||
for (const freeformCostType in this.cost.freeformValues) {
|
||||
if (this.cost.freeformValues[freeformCostType] === 0) continue;
|
||||
extraCosts.push(
|
||||
`${this.cost.freeformValues[freeformCostType]}${freeformCostType}`
|
||||
`${this.cost.freeformValues[freeformCostType]}${freeformCostType}`,
|
||||
);
|
||||
}
|
||||
return extraCosts.length
|
||||
@@ -334,7 +334,7 @@ export class Unit extends BaseNotes {
|
||||
|
||||
this.modelList = this.models.map(
|
||||
(model) =>
|
||||
(model.count > 1 ? `${model.count}x ` : "") + model.nameAndGear()
|
||||
(model.count > 1 ? `${model.count}x ` : "") + model.nameAndGear(),
|
||||
);
|
||||
this.weapons = this.models
|
||||
.map((m) => m.weapons)
|
||||
@@ -345,13 +345,13 @@ export class Unit extends BaseNotes {
|
||||
this.spells.push(
|
||||
...this.models
|
||||
.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.explosions.push(
|
||||
...this.models
|
||||
.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 {
|
||||
cost = new Costs();
|
||||
forces = [];
|
||||
gameType = "";
|
||||
}
|
||||
|
||||
export class Costs {
|
||||
@@ -401,12 +402,12 @@ export class Costs {
|
||||
}
|
||||
}
|
||||
|
||||
export function Create40kRoster(doc) {
|
||||
export function Create40kRoster(doc, gameType) {
|
||||
// Determine roster type (game system).
|
||||
let info = doc.querySelector("roster");
|
||||
if (info) {
|
||||
const roster = new Roster40k();
|
||||
|
||||
roster.gameType = gameType;
|
||||
const name = info.getAttributeNode("name")?.nodeValue;
|
||||
if (name) {
|
||||
roster.name = name;
|
||||
@@ -723,7 +724,7 @@ function ParseUnit(root) {
|
||||
|
||||
// First, find model stats. These have typeName=Unit.
|
||||
const modelStatsProfiles = Array.from(
|
||||
root.querySelectorAll('profile[typeName="Unit"],profile[typeName="Model"]')
|
||||
root.querySelectorAll('profile[typeName="Unit"],profile[typeName="Model"]'),
|
||||
);
|
||||
ParseModelStatsProfiles(modelStatsProfiles, unit, unitName);
|
||||
seenProfiles.push(...modelStatsProfiles);
|
||||
@@ -746,7 +747,7 @@ function ParseUnit(root) {
|
||||
// Some units are under a root selection with type="upgrade".
|
||||
if (modelSelections.length === 0) {
|
||||
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".
|
||||
@@ -761,7 +762,7 @@ function ParseUnit(root) {
|
||||
// Now, parse the model -- profiles for stats, and selections for upgrades.
|
||||
for (const modelSelection of modelSelections) {
|
||||
const profiles = Array.from(
|
||||
modelSelection.querySelectorAll("profiles>profile")
|
||||
modelSelection.querySelectorAll("profiles>profile"),
|
||||
);
|
||||
const unseenProfiles = profiles.filter((e) => !seenProfiles.includes(e));
|
||||
seenProfiles.push(...unseenProfiles);
|
||||
@@ -777,13 +778,13 @@ function ParseUnit(root) {
|
||||
// Find all upgrades on the model. This may include weapons that were
|
||||
// parsed from profiles (above), so dedupe those in nameAndGear().
|
||||
for (const upgradeSelection of modelSelection.querySelectorAll(
|
||||
'selections>selection[type="upgrade"]'
|
||||
'selections>selection[type="upgrade"]',
|
||||
)) {
|
||||
// Ignore selections without abilities but with sub-selection upgrades,
|
||||
// since those sub-selections will be picked up individually.
|
||||
if (
|
||||
upgradeSelection.querySelector(
|
||||
'selections>selection[type="upgrade"]'
|
||||
'selections>selection[type="upgrade"]',
|
||||
) &&
|
||||
!HasImmediateProfileWithTypeName(upgradeSelection, "Abilities")
|
||||
)
|
||||
@@ -965,7 +966,7 @@ function ParseModelProfiles(profiles, model, unit) {
|
||||
profile,
|
||||
profileName,
|
||||
typeName,
|
||||
unit.abilities.Abilities
|
||||
unit.abilities.Abilities,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
Reference in New Issue
Block a user