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
+74 -83
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 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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
}}
+6 -1
View File
@@ -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>
);
+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);
};
+223 -200
View File
@@ -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
View File
@@ -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
View File
@@ -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,
);
}
}