Start migration to Express/Vue/Parcel
2
.gitignore
vendored
@ -1,3 +1,5 @@
|
|||||||
.tern-port
|
.tern-port
|
||||||
/node_modules/
|
/node_modules/
|
||||||
/decks
|
/decks
|
||||||
|
/dist/
|
||||||
|
/.cache/
|
||||||
|
@ -1,4 +0,0 @@
|
|||||||
body {
|
|
||||||
background-color: #eee;
|
|
||||||
color: #444;
|
|
||||||
}
|
|
@ -1,8 +0,0 @@
|
|||||||
#cardEditor {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
background-color: gray;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
@ -1,88 +0,0 @@
|
|||||||
* {
|
|
||||||
touch-action: pan-y;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0 0 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
display: inline-block;
|
|
||||||
border-radius: 5px;
|
|
||||||
width: 142px;
|
|
||||||
height: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#card-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#hand {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0px;
|
|
||||||
width: 100%;
|
|
||||||
height: 20%;
|
|
||||||
background-color: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-pile {
|
|
||||||
width: 100px;
|
|
||||||
height: 100px;
|
|
||||||
color: #eee;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-moz-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shake {
|
|
||||||
animation: shake 0.8s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shake {
|
|
||||||
10%, 90% {transform: translate(-1px, 0);}
|
|
||||||
20%, 80% {transform: translate(2px, 0);}
|
|
||||||
30%, 50%, 70% {transform: translate(-4px, 0);}
|
|
||||||
40%, 60% {transform: translate(4px, 0);}
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck {
|
|
||||||
background-color: blue;
|
|
||||||
}
|
|
||||||
|
|
||||||
.discard {
|
|
||||||
background-color: red;
|
|
||||||
}
|
|
||||||
|
|
||||||
#shade {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 100;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: grey;
|
|
||||||
opacity: 0.5;
|
|
||||||
filter: alpha(opacity=50);
|
|
||||||
}
|
|
||||||
|
|
||||||
#modal-content {
|
|
||||||
padding: 5px;
|
|
||||||
border-radius: 5px;
|
|
||||||
position: fixed;
|
|
||||||
z-index: 101;
|
|
||||||
top: 5%;
|
|
||||||
left: 5%;
|
|
||||||
width: 90%;
|
|
||||||
background-color: white;
|
|
||||||
overflow-y: auto;
|
|
||||||
max-height: 90%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#modal-content .card {
|
|
||||||
margin: 2px;
|
|
||||||
}
|
|
@ -1,34 +0,0 @@
|
|||||||
<html>
|
|
||||||
<head>
|
|
||||||
<script src="/js/editor.js"></script>
|
|
||||||
<link rel="stylesheet" type="text/css" href="/css/editor.css">
|
|
||||||
<link rel="stylesheet" type="text/css" href="/css/common.css">
|
|
||||||
<title>Editor</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div>
|
|
||||||
<button type="button" id="saveButton">Save Deck</button>
|
|
||||||
<button type="button" id="jsonInputDownload">Download Input JSON</button>
|
|
||||||
<button type="button" id="outputDownload">Download Tabletop Output JSON</button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label> Upload JSON: WARNING: WILL CLEAR DECK
|
|
||||||
<input id="jsonUpload" type="file">
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<form id="deckForm">
|
|
||||||
<div> <label> Deck Name: <input type="text" id="deckName"></label> </div>
|
|
||||||
<div>
|
|
||||||
<label> Deck Type: <select id="deckType">
|
|
||||||
<option value="hero">hero</option>
|
|
||||||
<option value="villain">villain</option>
|
|
||||||
<option value="environment">environment</option>
|
|
||||||
</select> </label>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form id="cardForm"></form>
|
|
||||||
|
|
||||||
<div id="deck"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,16 +0,0 @@
|
|||||||
<html>
|
|
||||||
<head>
|
|
||||||
<script src="/js/interact.js"></script>
|
|
||||||
<script src="/js/playfield.js"></script>
|
|
||||||
<link rel="stylesheet" type="text/css" href="/css/playfield.css">
|
|
||||||
<link rel="stylesheet" type="text/css" href="/css/common.css">
|
|
||||||
<title>Playfield</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="card-container">
|
|
||||||
<div class="card-pile deck" data-pile="deck">DECK</div>
|
|
||||||
<div class="card-pile discard" data-pile="discard">DISCARD</div>
|
|
||||||
<div id="hand"></div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
253
js/editor.js
@ -1,253 +0,0 @@
|
|||||||
//jshint browser:true
|
|
||||||
//jshint esversion:6
|
|
||||||
//jshint latedef:nofunc
|
|
||||||
|
|
||||||
let deckJSON, template;
|
|
||||||
let selected;
|
|
||||||
let deckName = window.location.pathname.split('/')[2];
|
|
||||||
|
|
||||||
document.title = "Editor|" + deckName;
|
|
||||||
|
|
||||||
window.addEventListener("load", () => {
|
|
||||||
// load deck input json
|
|
||||||
fetch("deck.input.json")
|
|
||||||
.then(data => data.json())
|
|
||||||
.then(json => {
|
|
||||||
deckJSON = json;
|
|
||||||
makeSVGs(deckJSON);
|
|
||||||
})
|
|
||||||
.catch(error => console.error(error));
|
|
||||||
|
|
||||||
// deck JSON uploader
|
|
||||||
document.querySelector('#jsonUpload').addEventListener('change', event => {
|
|
||||||
let files = event.target.files;
|
|
||||||
let reader = new FileReader();
|
|
||||||
reader.onload = event => {
|
|
||||||
deckJSON = JSON.parse(event.target.result);
|
|
||||||
makeSVGs(deckJSON);
|
|
||||||
};
|
|
||||||
reader.readAsText(files[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Upload on save button
|
|
||||||
document.querySelector('#saveButton').addEventListener('click', upload);
|
|
||||||
|
|
||||||
// download input JSON
|
|
||||||
document.querySelector('#jsonInputDownload').addEventListener(
|
|
||||||
'click',
|
|
||||||
() => downloadFile('data:application/json;charset=utf-8,' +
|
|
||||||
encodeURIComponent(JSON.stringify(deckJSON)),
|
|
||||||
deckName + '.input.json'));
|
|
||||||
|
|
||||||
// download input JSON
|
|
||||||
document.querySelector('#outputDownload').addEventListener(
|
|
||||||
'click',
|
|
||||||
() => downloadFile('deck.tts.json',
|
|
||||||
deckName + '.json'));
|
|
||||||
|
|
||||||
|
|
||||||
// handle changes to deck editor
|
|
||||||
document.querySelector('#deckForm').addEventListener('input', event => {
|
|
||||||
let prop = event.target.id.substring(4).toLowerCase();
|
|
||||||
deckJSON[prop] = event.target.value;
|
|
||||||
if (prop === 'type') {
|
|
||||||
makeSVGs(deckJSON);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// handle changes to card editor
|
|
||||||
document.querySelector('#cardForm').addEventListener('input', event => {
|
|
||||||
let prop = event.target.id.substring(5);
|
|
||||||
if (prop !== "count") {
|
|
||||||
wrapSVGText(selected.svg.querySelector('#' + prop),
|
|
||||||
String(event.target.value));
|
|
||||||
}
|
|
||||||
if (event.target.value) {
|
|
||||||
selected.json[prop] = event.target.value;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
delete selected.json[prop];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// chrome doesn't seem to send input event on file select
|
|
||||||
document.querySelector('#cardForm').addEventListener('change', event => {
|
|
||||||
let prop = event.target.id.substring(5);
|
|
||||||
if (prop === "image") {
|
|
||||||
let files = event.target.files;
|
|
||||||
let reader = new FileReader();
|
|
||||||
reader.onload = e => {
|
|
||||||
selected.svg.querySelector('#' + prop)
|
|
||||||
.setAttributeNS("http://www.w3.org/1999/xlink", "href", e.target.result);
|
|
||||||
selected.json[prop] = e.target.result;
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(files[0]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('beforeunload',
|
|
||||||
e => e.returnValue = "Unsaved changes blah blah");
|
|
||||||
});
|
|
||||||
|
|
||||||
function downloadFile(file, name) {
|
|
||||||
let dl = document.createElement('a');
|
|
||||||
dl.setAttribute('href', file);
|
|
||||||
dl.setAttribute('download', name);
|
|
||||||
document.body.appendChild(dl);
|
|
||||||
dl.click();
|
|
||||||
document.body.removeChild(dl);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSVGTemplate(name, callback) {
|
|
||||||
return fetch("/template/" + name + ".svg")
|
|
||||||
.then(response => response.text())
|
|
||||||
.then(str => (new window.DOMParser()).parseFromString(str, "text/xml").activeElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function makeSVGs(deckJSON) {
|
|
||||||
document.querySelector('#deckName').value = deckJSON.name || "";
|
|
||||||
document.querySelector('#deckType').value = deckJSON.type || "";
|
|
||||||
|
|
||||||
let deck = document.querySelector('#deck');
|
|
||||||
deck.innerHTML = "";
|
|
||||||
|
|
||||||
let template = await fetch(`/template/${deckJSON.type}/input.json`)
|
|
||||||
.then(data => data.json());
|
|
||||||
|
|
||||||
let cardCount = Object.entries(template.cardTypes)
|
|
||||||
.map(ct => deckJSON[ct[0]].length * (ct[1].back ? 2 : 1))
|
|
||||||
.reduce((sum, current) => sum + current, 0);
|
|
||||||
|
|
||||||
// note: needs to be a for loop because it needs to be synchronous
|
|
||||||
// and also have await
|
|
||||||
// Although I suppose I could prefetch the SVGs and then do the rest...
|
|
||||||
for (let cardType of Object.entries(template.cardTypes)) {
|
|
||||||
let backSVG;
|
|
||||||
if (cardType[1].back) {
|
|
||||||
let backTemplate = cardType[1].back.template || (cardType[0] + "-back");
|
|
||||||
backSVG = await getSVGTemplate(deckJSON.type + "/" + backTemplate);
|
|
||||||
}
|
|
||||||
let templateSVG = await getSVGTemplate(deckJSON.type + "/" + cardType[0]);
|
|
||||||
console.log(templateSVG);
|
|
||||||
|
|
||||||
// build card SVGs
|
|
||||||
deckJSON[cardType[0]].forEach(card => {
|
|
||||||
makeCardSVG(deck, cardType[1], templateSVG, card);
|
|
||||||
|
|
||||||
// if there is a back, build it too
|
|
||||||
if (cardType[1].back) {
|
|
||||||
makeCardSVG(deck, cardType[1].back, backSVG, card, back=true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// set div width/height based on number of cards
|
|
||||||
deck.style.width = Math.ceil(Math.sqrt(cardCount)) *
|
|
||||||
parseInt(templateSVG.getAttribute("width")) + "pt";
|
|
||||||
deck.style.height = Math.ceil(Math.sqrt(cardCount)) *
|
|
||||||
parseInt(templateSVG.getAttribute("height")) + "pt";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function setForm(cardTemplate, card) {
|
|
||||||
let form = document.querySelector('#cardForm');
|
|
||||||
form.innerHTML = "";
|
|
||||||
|
|
||||||
Object.entries(cardTemplate.inputs).forEach(prop => {
|
|
||||||
let div = form.appendChild(document.createElement('div'));
|
|
||||||
let label = div.appendChild(document.createElement('label'));
|
|
||||||
label.textContent = prop[0];
|
|
||||||
|
|
||||||
let input = label.appendChild(
|
|
||||||
document.createElement(prop[1] === 'textarea' ? 'textarea' : 'input'));
|
|
||||||
input.id = "card-" + prop[0];
|
|
||||||
|
|
||||||
if (prop[1] === "image") {
|
|
||||||
input.type = "file";
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
input.type = prop[1];
|
|
||||||
input.value = card[prop[0]] || "";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeCardSVG(deck, cardInputTemplate, templateSVG, card, back=false) {
|
|
||||||
let propSource = (back && card.back) ? card.back : card;
|
|
||||||
let cardSVG = deck.appendChild(templateSVG.cloneNode(true));
|
|
||||||
cardSVG.addEventListener('click', () => {
|
|
||||||
selected = {svg: cardSVG, json: card};
|
|
||||||
setForm(cardInputTemplate, card);
|
|
||||||
}, true);
|
|
||||||
Object.keys(cardInputTemplate.inputs).forEach(prop => {
|
|
||||||
let inputProp = propSource[prop] || card[prop] || "";
|
|
||||||
wrapSVGText(cardSVG.querySelector('#' + prop), String(inputProp));
|
|
||||||
});
|
|
||||||
Object.entries(cardInputTemplate.hide || []).forEach(hidable => {
|
|
||||||
cardSVG.querySelector('#' + hidable[0])
|
|
||||||
.setAttribute('display', hidable[1] in propSource ? '' : 'none');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function upload() {
|
|
||||||
let deck = document.querySelector('#deck');
|
|
||||||
|
|
||||||
// POST the generated SVGs to the server
|
|
||||||
let data = (new XMLSerializer()).serializeToString(deck);
|
|
||||||
fetch('upload', {
|
|
||||||
method: 'post',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({body: data, json: deckJSON})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function wrapSVGText(e, string) {
|
|
||||||
// TODO: bold or italic text
|
|
||||||
e.innerHTML = ""; // clear element
|
|
||||||
let lines = string.split("\n");
|
|
||||||
if (e.getAttribute('default-font-size'))
|
|
||||||
e.setAttribute('font-size', e.getAttribute('default-font-size'));
|
|
||||||
e.setAttribute('default-font-size', e.getAttribute('font-size'));
|
|
||||||
while (lines.length > 0) {
|
|
||||||
let words = lines.shift().split(" ");
|
|
||||||
let tspan = document.createElementNS("http://www.w3.org/2000/svg", "tspan");
|
|
||||||
tspan.setAttribute('x', e.getAttribute('x'));
|
|
||||||
if (e.innerHTML !== "") tspan.setAttribute('dy', e.getAttribute('font-size'));
|
|
||||||
e.appendChild(tspan);
|
|
||||||
let line = [];
|
|
||||||
while(words.length > 0) {
|
|
||||||
let word = words.shift();
|
|
||||||
if (word === "") word = " ";
|
|
||||||
line.push(word);
|
|
||||||
tspan.innerHTML = line.join(" ");
|
|
||||||
// horizontal overflow
|
|
||||||
// TODO: actually use units (also applies to vertical)
|
|
||||||
if (parseFloat(e.getAttribute("width")) &&
|
|
||||||
tspan.getComputedTextLength() > parseFloat(e.getAttribute("width"))) {
|
|
||||||
// if we have height, we can line wrap
|
|
||||||
if (parseFloat(e.getAttribute("height")) &&
|
|
||||||
e.children.length * parseFloat(e.getAttribute('font-size')) <
|
|
||||||
parseFloat(e.getAttribute('height'))) {
|
|
||||||
words.unshift(line.pop());
|
|
||||||
tspan.innerHTML = line.join(" ");
|
|
||||||
line = [];
|
|
||||||
|
|
||||||
tspan = document.createElementNS("http://www.w3.org/2000/svg", "tspan");
|
|
||||||
tspan.setAttribute('x', e.getAttribute('x'));
|
|
||||||
tspan.setAttribute('dy', e.getAttribute('font-size'));
|
|
||||||
e.appendChild(tspan);
|
|
||||||
}
|
|
||||||
// vertical overflow or horizontal overflow with no height variable
|
|
||||||
// TODO: better with recursion instead?
|
|
||||||
else {
|
|
||||||
e.innerHTML = ""; // remove all tspans
|
|
||||||
// TODO: maybe binary search font size later if I really care
|
|
||||||
e.setAttribute('font-size', parseFloat(e.getAttribute('font-size')) * 0.9);
|
|
||||||
words = [];
|
|
||||||
lines = string.split('\n');
|
|
||||||
console.log("resetting, size= " + e.getAttribute('font-size'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
5978
js/interact.js
319
js/playfield.js
@ -1,319 +0,0 @@
|
|||||||
//jshint browser:true
|
|
||||||
//jshint esversion:6
|
|
||||||
//jshint latedef:nofunc
|
|
||||||
/* globals interact:true */
|
|
||||||
|
|
||||||
let deckNum, deckJSON, cardCount, deckWidth, deckHeight,
|
|
||||||
piles = {'deck': [], discard: []};
|
|
||||||
|
|
||||||
document.title = "Playfield|" + window.location.pathname.split('/')[2];
|
|
||||||
|
|
||||||
interact.dynamicDrop(true);
|
|
||||||
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
fetch("deck.json")
|
|
||||||
.then(data => data.json())
|
|
||||||
.then(json => {
|
|
||||||
deckJSON = json;
|
|
||||||
deckNum = Object.keys(deckJSON.CustomDeck)[0];
|
|
||||||
piles.deck = deckJSON.DeckIDs.map(c => c - deckNum * 100);
|
|
||||||
cardCount = piles.deck.length;
|
|
||||||
shuffle(piles.deck);
|
|
||||||
deckWidth = deckJSON.CustomDeck[deckNum].NumWidth;
|
|
||||||
deckHeight = deckJSON.CustomDeck[deckNum].NumHeight;
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("contextmenu", event => event.preventDefault());
|
|
||||||
});
|
|
||||||
|
|
||||||
let cardInteract = interact('.card', {ignoreFrom: '.in-list'})
|
|
||||||
.draggable({
|
|
||||||
restrict: {
|
|
||||||
restriction: "parent",
|
|
||||||
endOnly: true,
|
|
||||||
elementRect: { top: 0, left: 0, bottom: 1, right: 1 }
|
|
||||||
},
|
|
||||||
|
|
||||||
snap: {
|
|
||||||
targets: [() => {
|
|
||||||
// TODO: maybe change to dropzone?
|
|
||||||
let pos = document.body.getBoundingClientRect(),
|
|
||||||
hand = document.getElementById('hand'),
|
|
||||||
style = window.getComputedStyle(hand),
|
|
||||||
handHeight = parseInt(style.getPropertyValue("height"));
|
|
||||||
return {y: pos.bottom,
|
|
||||||
range: handHeight/2};
|
|
||||||
}],
|
|
||||||
relativePoints: [{x: 0.5 , y: 1}]
|
|
||||||
},
|
|
||||||
|
|
||||||
onmove: event => {
|
|
||||||
dragMoveListener(event);
|
|
||||||
// raise to top
|
|
||||||
event.target.parentElement.removeChild(event.target);
|
|
||||||
document.querySelector("#card-container").appendChild(event.target);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.on('doubletap', event => {
|
|
||||||
let scale = parseFloat(event.target.getAttribute('data-scale')) === 2 ? 1 : 2;
|
|
||||||
event.target.setAttribute('data-scale', scale);
|
|
||||||
dragMoveListener({target: event.target, dx: 0, dy: 0});
|
|
||||||
})
|
|
||||||
.on('tap', event => {
|
|
||||||
let card = event.target;
|
|
||||||
let cardNum = parseInt(card.getAttribute('data-num'));
|
|
||||||
if (card.dataset.flipped === "true") {
|
|
||||||
card.dataset.flipped = false;
|
|
||||||
let style = window.getComputedStyle(card);
|
|
||||||
card.style.backgroundPositionX =
|
|
||||||
-(cardNum % deckWidth) * parseInt(style.getPropertyValue("width")) + "px";
|
|
||||||
card.style.backgroundPositionY =
|
|
||||||
-Math.floor(cardNum/deckWidth) * parseInt(style.getPropertyValue("height")) + "px";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cardData = deckJSON.ContainedObjects.find(c => c.CardID === (cardNum + deckNum * 100));
|
|
||||||
let backNum = cardData.States && cardData.States["2"].CardID;
|
|
||||||
if (backNum) {
|
|
||||||
card.dataset.flipped = true;
|
|
||||||
let style = window.getComputedStyle(card);
|
|
||||||
card.style.backgroundPositionX =
|
|
||||||
-(backNum % deckWidth) * parseInt(style.getPropertyValue("width")) + "px";
|
|
||||||
card.style.backgroundPositionY =
|
|
||||||
-Math.floor(backNum/deckWidth) * parseInt(style.getPropertyValue("height")) + "px";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
interact('.card.in-list')
|
|
||||||
.draggable({
|
|
||||||
restrict: {
|
|
||||||
restriction: "parent",
|
|
||||||
endOnly: false,
|
|
||||||
elementRect: { top: 0, left: 0, bottom: 1, right: 1 }
|
|
||||||
},
|
|
||||||
autoScroll: false,
|
|
||||||
|
|
||||||
onmove: dragMoveListener,
|
|
||||||
onend: event => {
|
|
||||||
// reset transform and data attributes
|
|
||||||
event.target.style.webkitTransform = event.target.style.transform = '';
|
|
||||||
|
|
||||||
event.target.removeAttribute('data-x');
|
|
||||||
event.target.removeAttribute('data-y');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.dropzone({
|
|
||||||
accept: '.card',
|
|
||||||
ondropmove: event => {
|
|
||||||
let target = event.target;
|
|
||||||
// TODO: there must be a better way to do this
|
|
||||||
let relCursorPos = (event.dragEvent.pageX -
|
|
||||||
target.getBoundingClientRect().left) /
|
|
||||||
parseInt(window.getComputedStyle(target).width);
|
|
||||||
|
|
||||||
let oldOffsetLeft = event.relatedTarget.offsetLeft;
|
|
||||||
let oldOffsetTop = event.relatedTarget.offsetTop;
|
|
||||||
|
|
||||||
// move the object in the DOM
|
|
||||||
if (relCursorPos < 0.5 || relCursorPos === Infinity) {
|
|
||||||
target.parentElement.insertBefore(event.relatedTarget, target);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
target.parentElement.insertBefore(event.relatedTarget, target.nextSibling);
|
|
||||||
}
|
|
||||||
|
|
||||||
// update position
|
|
||||||
dragMoveListener({target: event.relatedTarget,
|
|
||||||
dx: oldOffsetLeft - event.relatedTarget.offsetLeft,
|
|
||||||
dy: oldOffsetTop - event.relatedTarget.offsetTop});
|
|
||||||
|
|
||||||
// rebuild source pile
|
|
||||||
piles[target.getAttribute('data-pile')] =
|
|
||||||
Array.from(target.parentElement.children).map(
|
|
||||||
c => c.getAttribute('data-num'));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.on('tap', event => {
|
|
||||||
let target = event.target;
|
|
||||||
console.log(`Drawing ${target.getAttribute('data-num')} from ${target.getAttribute('data-pile')}`);
|
|
||||||
|
|
||||||
let listDiv = target.parentElement;
|
|
||||||
// re-parent
|
|
||||||
document.querySelector("#card-container").appendChild(target);
|
|
||||||
|
|
||||||
// rebuild source pile
|
|
||||||
piles[target.getAttribute('data-pile')] =
|
|
||||||
Array.from(listDiv.children).map(c => c.getAttribute('data-num'));
|
|
||||||
|
|
||||||
// remove list class
|
|
||||||
target.classList.remove('in-list');
|
|
||||||
|
|
||||||
// fix position
|
|
||||||
target.style.position = "fixed";
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
interact('.card-pile')
|
|
||||||
.dropzone({
|
|
||||||
accept: '.card:not(.in-pile)',
|
|
||||||
ondrop: event => {
|
|
||||||
// TODO: fix possible duped zeros
|
|
||||||
let pileName = event.target.getAttribute('data-pile');
|
|
||||||
let cardNum = event.relatedTarget.getAttribute('data-num');
|
|
||||||
if (event.dragEvent.shiftKey) {
|
|
||||||
console.log(`Adding ${cardNum} bottom of to ${pileName}`);
|
|
||||||
piles[pileName].unshift(cardNum);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
console.log(`Adding ${cardNum} to ${pileName}`);
|
|
||||||
piles[pileName].push(cardNum);
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove from DOM
|
|
||||||
event.relatedTarget.parentElement.removeChild(event.relatedTarget);
|
|
||||||
|
|
||||||
// update deck text
|
|
||||||
event.target.innerHTML = `${pileName.toUpperCase()}<br>${piles[pileName].length}/${cardCount}`;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.draggable({manualStart: true})
|
|
||||||
.on('move', event => {
|
|
||||||
let interaction = event.interaction;
|
|
||||||
let pileName = event.target.getAttribute('data-pile');
|
|
||||||
let pile = piles[pileName];
|
|
||||||
|
|
||||||
// if the pointer was moved while being held down
|
|
||||||
// and an interaction hasn't started yet
|
|
||||||
// and there are cards in the pile
|
|
||||||
if (interaction.pointerIsDown &&
|
|
||||||
!interaction.interacting() &&
|
|
||||||
pile.length > 0) {
|
|
||||||
// draw a new card
|
|
||||||
let newCard = makeCard(pile.pop());
|
|
||||||
newCard.style.position = "fixed";
|
|
||||||
newCard.style.top = event.pageY;
|
|
||||||
newCard.style.left = event.pageX;
|
|
||||||
// insert the card to the page
|
|
||||||
document.querySelector("#card-container").appendChild(newCard);
|
|
||||||
|
|
||||||
// update deck text
|
|
||||||
event.target.innerHTML = `${pileName.toUpperCase()}<br>${pile.length}/${cardCount}`;
|
|
||||||
|
|
||||||
// start a drag interaction targeting the clone
|
|
||||||
interaction.start({name: 'drag'}, cardInteract, newCard);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.on('hold', event => {
|
|
||||||
let pile = piles[event.target.getAttribute('data-pile')];
|
|
||||||
let searchBox = document.createElement('input');
|
|
||||||
let container = document.createElement('div');
|
|
||||||
let cardList = document.createElement('div');
|
|
||||||
searchBox.setAttribute('type', 'search');
|
|
||||||
searchBox.setAttribute('placeholder', 'Filter');
|
|
||||||
searchBox.addEventListener('input', event => {
|
|
||||||
let input = event.target.value;
|
|
||||||
Array.from(cardList.children).forEach(card => {
|
|
||||||
let cardNum = parseInt(card.getAttribute('data-num'));
|
|
||||||
let cardData = deckJSON.ContainedObjects.find(c => c.CardID === (cardNum + deckNum * 100));
|
|
||||||
card.style.display =
|
|
||||||
(cardData.Nickname.toLowerCase().includes(input.toLowerCase()) ||
|
|
||||||
cardData.Description.toLowerCase().includes(input.toLowerCase())) ?
|
|
||||||
"": "none";
|
|
||||||
});
|
|
||||||
});
|
|
||||||
container.appendChild(searchBox);
|
|
||||||
|
|
||||||
pile.forEach(cardNum => {
|
|
||||||
let newCard = makeCard(cardNum);
|
|
||||||
newCard.classList.add('in-list');
|
|
||||||
newCard.setAttribute('data-pile', event.target.getAttribute('data-pile'));
|
|
||||||
cardList.appendChild(newCard);
|
|
||||||
});
|
|
||||||
container.appendChild(cardList);
|
|
||||||
showModal(container);
|
|
||||||
})
|
|
||||||
.on('tap', event => {
|
|
||||||
shuffle(piles[event.target.getAttribute('data-pile')]);
|
|
||||||
event.target.classList.add("shake");
|
|
||||||
// reset animation so it can be played again
|
|
||||||
event.target.onanimationend = e => {
|
|
||||||
event.target.classList.remove("shake");
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function makeCard(cardNum) {
|
|
||||||
// draw a new card
|
|
||||||
let card = document.createElement('div');
|
|
||||||
card.className = "card";
|
|
||||||
|
|
||||||
// temporary add so getComputedStyle works on Chrome
|
|
||||||
document.body.appendChild(card);
|
|
||||||
let style = window.getComputedStyle(card);
|
|
||||||
card.setAttribute('data-num', cardNum);
|
|
||||||
card.style.backgroundPositionX =
|
|
||||||
-(cardNum % deckWidth) * parseInt(style.getPropertyValue("width")) + "px";
|
|
||||||
card.style.backgroundPositionY =
|
|
||||||
-Math.floor(cardNum/deckWidth) * parseInt(style.getPropertyValue("height")) + "px";
|
|
||||||
let faceURI = encodeURI(deckJSON.CustomDeck[deckNum].FaceURL);
|
|
||||||
card.style.backgroundImage = `url("${faceURI}")`;
|
|
||||||
card.style.backgroundSize = `${deckWidth * 100}% ${deckHeight * 100}%`;
|
|
||||||
document.body.removeChild(card);
|
|
||||||
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showModal(content) {
|
|
||||||
let shade = document.createElement('div');
|
|
||||||
shade.id = "shade";
|
|
||||||
shade.className = "modal";
|
|
||||||
shade.addEventListener('click', hideModal);
|
|
||||||
document.body.appendChild(shade);
|
|
||||||
|
|
||||||
let modal = document.createElement('div');
|
|
||||||
modal.id = "modal-content";
|
|
||||||
modal.className = "modal";
|
|
||||||
modal.appendChild(content);
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideModal() {
|
|
||||||
document.querySelectorAll('.modal').forEach(
|
|
||||||
e => e.parentElement.removeChild(e));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fisher-Yates shuffle from https://bost.ocks.org/mike/shuffle/
|
|
||||||
function shuffle(array) {
|
|
||||||
let m = array.length, t, i;
|
|
||||||
|
|
||||||
// While there remain elements to shuffle…
|
|
||||||
while (m) {
|
|
||||||
// Pick a remaining element…
|
|
||||||
i = Math.floor(Math.random() * m--);
|
|
||||||
|
|
||||||
// And swap it with the current element.
|
|
||||||
t = array[m];
|
|
||||||
array[m] = array[i];
|
|
||||||
array[i] = t;
|
|
||||||
}
|
|
||||||
|
|
||||||
return array;
|
|
||||||
}
|
|
||||||
|
|
||||||
function dragMoveListener (event) {
|
|
||||||
let target = event.target;
|
|
||||||
|
|
||||||
// keep the dragged position in the data-x/data-y attribute
|
|
||||||
let x = (parseFloat(target.getAttribute('data-x')) || 0) + event.dx;
|
|
||||||
let y = (parseFloat(target.getAttribute('data-y')) || 0) + event.dy;
|
|
||||||
let scale = (parseFloat(target.getAttribute('data-scale')) || 1);
|
|
||||||
|
|
||||||
// translate and scale the element
|
|
||||||
target.style.webkitTransform =
|
|
||||||
target.style.transform =
|
|
||||||
'translate(' + x + 'px, ' + y + 'px) scale(' + scale + ')';
|
|
||||||
|
|
||||||
// update the posiion attributes
|
|
||||||
target.setAttribute('data-x', x);
|
|
||||||
target.setAttribute('data-y', y);
|
|
||||||
}
|
|
||||||
|
|
6739
package-lock.json
generated
21
package.json
@ -5,11 +5,28 @@
|
|||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
"start": "node server.js"
|
"start": "node src/server.js",
|
||||||
|
"start-parcel": "parcel src/index.html",
|
||||||
|
"build": "parcel build --public-url ./ src/index.html"
|
||||||
},
|
},
|
||||||
"author": "Adam Goldsmith",
|
"author": "Adam Goldsmith",
|
||||||
"license": " CC-BY-SA-4.0",
|
"license": " CC-BY-SA-4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"phantom": "^4.0.12"
|
"express": "^4.16.4",
|
||||||
|
"interactjs": "^1.3.4",
|
||||||
|
"parcel-bundler": "^1.11.0",
|
||||||
|
"phantom": "^4.0.12",
|
||||||
|
"v-runtime-template": "^1.5.2",
|
||||||
|
"vue": "^2.5.21",
|
||||||
|
"vue-hot-reload-api": "^2.3.1",
|
||||||
|
"vue-router": "^3.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vue/component-compiler-utils": "^2.3.1",
|
||||||
|
"parcel": "^1.11.0",
|
||||||
|
"vue-template-compiler": "^2.5.21"
|
||||||
|
},
|
||||||
|
"alias": {
|
||||||
|
"vue": "./node_modules/vue/dist/vue.common.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
263
server.js
@ -1,263 +0,0 @@
|
|||||||
// jshint node:true
|
|
||||||
// jshint esversion:6
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const http = require('http'),
|
|
||||||
fs = require('fs'),
|
|
||||||
path = require('path'),
|
|
||||||
url = require('url'),
|
|
||||||
phantom = require('phantom'),
|
|
||||||
port = 8080;
|
|
||||||
|
|
||||||
const decks = ["the_Unholy_Priest_update_2", "NZoths_Invasion_1.2", "Puffer_Fish_input_1.3"];
|
|
||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
|
||||||
const uri = url.parse(req.url);
|
|
||||||
|
|
||||||
let pathParts = uri.pathname.split("/");
|
|
||||||
switch (pathParts[1]) {
|
|
||||||
case '':
|
|
||||||
case 'index.html':
|
|
||||||
sendIndex(res);
|
|
||||||
break;
|
|
||||||
case 'css':
|
|
||||||
switch (pathParts[2]) {
|
|
||||||
case 'playfield.css':
|
|
||||||
case 'editor.css':
|
|
||||||
case 'common.css':
|
|
||||||
sendFile(res, path.join('css', pathParts[2]), 'text/css');
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
send404(res, uri);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'js':
|
|
||||||
switch (pathParts[2]) {
|
|
||||||
case 'playfield.js':
|
|
||||||
case 'editor.js':
|
|
||||||
case 'interact.js':
|
|
||||||
sendFile(res, path.join('js', pathParts[2]), 'application/javascript');
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
send404(res, uri);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'template':
|
|
||||||
pathParts.splice(0, 2); // remove first two elements
|
|
||||||
let item = pathParts.join("/");
|
|
||||||
console.log("template/" + item);
|
|
||||||
switch (item) {
|
|
||||||
case "card.json":
|
|
||||||
case "deck.json":
|
|
||||||
case "environment/input.json":
|
|
||||||
case "hero/input.json":
|
|
||||||
case "villain/input.json":
|
|
||||||
sendFile(res, path.join("template", item), 'application/json');
|
|
||||||
break;
|
|
||||||
case "environment/deck.svg":
|
|
||||||
case "hero/deck.svg":
|
|
||||||
case "hero/character-back.svg":
|
|
||||||
case "hero/character.svg":
|
|
||||||
case "villain/deck.svg":
|
|
||||||
case "villain/character.svg":
|
|
||||||
case "villain/instructions.svg":
|
|
||||||
sendFile(res, path.join("template", item), 'image/svg+xml');
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
send404(res, uri);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'deck':
|
|
||||||
if (pathParts.length < 3 || pathParts[2] === '') {
|
|
||||||
sendIndex(res);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let deckName = decodeURI(pathParts[2]);
|
|
||||||
switch (pathParts[3] || '') {
|
|
||||||
case '':
|
|
||||||
sendDeckIndex(res, deckName);
|
|
||||||
break;
|
|
||||||
case 'play':
|
|
||||||
sendFile(res, 'html/playfield.html');
|
|
||||||
break;
|
|
||||||
case 'editor':
|
|
||||||
sendFile(res, 'html/editor.html');
|
|
||||||
break;
|
|
||||||
case 'deck.png':
|
|
||||||
sendFile(res, `decks/${deckName}.png`, 'image/png');
|
|
||||||
break;
|
|
||||||
case 'deck.json':
|
|
||||||
sendFileJSON(res, deckName);
|
|
||||||
break;
|
|
||||||
case 'deck.tts.json':
|
|
||||||
sendFile(res, `decks/${deckName}.json`, 'application/json');
|
|
||||||
break;
|
|
||||||
case 'deck.input.json':
|
|
||||||
sendFile(res, `decks/${deckName}.input.json`, 'application/json');
|
|
||||||
break;
|
|
||||||
case 'upload':
|
|
||||||
handleUpload(res, req);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
send404(res, uri);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
send404(res, uri);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
server.listen(process.env.PORT || port);
|
|
||||||
console.log('listening on 8080');
|
|
||||||
|
|
||||||
function sendIndex(res) {
|
|
||||||
const html = `
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<link rel="stylesheet" type="text/css" href="/css/common.css">
|
|
||||||
<title>Index</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<label>Create New Deck: <input type="text" onchange="window.location='/deck/' + event.target.value + '/editor'"></label>
|
|
||||||
<ul>
|
|
||||||
${(decks.map(d => `<li><a href="/deck/${d}">${d}</a></li>`).join(' '))}
|
|
||||||
</ul>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
res.writeHead(200, {'Content-type': 'text/html; charset=utf-8'});
|
|
||||||
res.end(html, 'utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendDeckIndex(res, deckName) {
|
|
||||||
const html = `
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<link rel="stylesheet" type="text/css" href="/css/common.css">
|
|
||||||
<title>${deckName}</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<ul>
|
|
||||||
<li><a href="${deckName}/play">Play!</a></li>
|
|
||||||
<li><a href="${deckName}/editor">Editor</a></li>
|
|
||||||
</ul>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
res.writeHead(200, {'Content-type': 'text/html; charset=utf-8'});
|
|
||||||
res.end(html, 'utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendFileJSON(res, deckName) {
|
|
||||||
fs.readFile(`decks/${deckName}.json`, (error, content) => {
|
|
||||||
console.log(JSON.parse(content));
|
|
||||||
res.writeHead(200, {'Content-type': 'application/json; charset=utf-8'});
|
|
||||||
res.end(JSON.stringify(JSON.parse(content).ObjectStates[0]), 'utf-8');
|
|
||||||
if (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleUpload(res, req) {
|
|
||||||
let body = '';
|
|
||||||
|
|
||||||
req.on('data', data => {
|
|
||||||
body += data;
|
|
||||||
// check for file > 100MB
|
|
||||||
if (body.length > 1e8) {
|
|
||||||
req.connection.destroy();
|
|
||||||
console.log('upload too big');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('end', () => {
|
|
||||||
const json = JSON.parse(body);
|
|
||||||
const deckJSON = json.json;
|
|
||||||
const cardTemplate = fs.readFileSync('template/card.json');
|
|
||||||
const template = JSON.parse(fs.readFileSync(`template/${deckJSON.type}/input.json`));
|
|
||||||
const cardCount = Object.entries(template.cardTypes)
|
|
||||||
.map(ct => deckJSON[ct[0]].length * (ct[1].back ? 2 : 1))
|
|
||||||
.reduce((sum, current) => sum + current, 0);
|
|
||||||
|
|
||||||
let deckOut = JSON.parse(fs.readFileSync('template/deck.json'));
|
|
||||||
deckOut.ObjectStates[0].Nickname = deckJSON.name;
|
|
||||||
|
|
||||||
Object.assign(deckOut.ObjectStates[0].CustomDeck['1'],
|
|
||||||
{NumWidth: Math.ceil(Math.sqrt(cardCount)),
|
|
||||||
NumHeight: Math.ceil(Math.sqrt(cardCount)),
|
|
||||||
FaceURL: `http://${req.headers.host}/deck/${deckJSON.name}/deck.png`,
|
|
||||||
BackURL: "http://cloud-3.steamusercontent.com/ugc/156906385556221451/CE2C3AFE1759790CB0B532FFD636D05A99EC91F4/"});
|
|
||||||
|
|
||||||
let index = 100;
|
|
||||||
deckOut.ObjectStates[0].ContainedObjects =
|
|
||||||
Object.entries(template.cardTypes).map(
|
|
||||||
cardType => deckJSON[cardType[0]].map(cardIn => {
|
|
||||||
let cardOut = JSON.parse(cardTemplate);
|
|
||||||
Object.assign(cardOut, {Nickname: cardIn.name,
|
|
||||||
Description: cardIn.keywords,
|
|
||||||
CardID: index});
|
|
||||||
|
|
||||||
for (let ii=0; ii<(cardIn.count || 1); ii++) {
|
|
||||||
deckOut.ObjectStates[0].DeckIDs.push(index);
|
|
||||||
}
|
|
||||||
index++;
|
|
||||||
|
|
||||||
if(cardType[1].back) {
|
|
||||||
let cardBack = JSON.parse(cardTemplate);
|
|
||||||
Object.assign(cardBack, {Nickname: cardIn.back.name,
|
|
||||||
Description: cardIn.back.keywords,
|
|
||||||
CardID: index});
|
|
||||||
cardOut.States = {"2": cardBack};
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
return cardOut;
|
|
||||||
}))
|
|
||||||
.reduce((sum, cur) => sum.concat(cur), []);
|
|
||||||
|
|
||||||
fs.writeFileSync(`decks/${deckJSON.name}.json`, JSON.stringify(deckOut));
|
|
||||||
fs.writeFileSync(`decks/${deckJSON.name}.input.json`, JSON.stringify(deckJSON));
|
|
||||||
|
|
||||||
console.log("making page");
|
|
||||||
phantom.create().then(
|
|
||||||
ph => ph.createPage().then(
|
|
||||||
page => {
|
|
||||||
page.on('onLoadFinished', status => {
|
|
||||||
if (status !== 'success') {
|
|
||||||
console.log('Failed to load page');
|
|
||||||
ph.exit(1);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
page.render(`decks/${deckJSON.name}.png`);
|
|
||||||
page.close().then(() => ph.exit());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
page.property('zoomFactor', 2); // pretty arbitrary
|
|
||||||
page.property('content', '<body style="margin:0;">' + json.body + '</body>');
|
|
||||||
}));
|
|
||||||
decks.push(deckJSON.name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function send404(res, uri) {
|
|
||||||
res.writeHead(404, {'Content-type': "text/html; charset=utf-8"});
|
|
||||||
const html = `
|
|
||||||
<head>
|
|
||||||
<title>404 Not Found</title>
|
|
||||||
<link rel="stylesheet" href="/css/common.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Error 404: Path ${uri.pathname} not found</h1>
|
|
||||||
You seem to have gone to the wrong place, would you like to go
|
|
||||||
back to the <a href="/">main page</a>?
|
|
||||||
</body>`;
|
|
||||||
res.end(html, 'utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendFile(res, filename, contentType='text/html; charset=utf-8') {
|
|
||||||
fs.readFile(filename, (error, content) => {
|
|
||||||
res.writeHead(200, {'Content-type': contentType});
|
|
||||||
res.end(content, 'utf-8');
|
|
||||||
if (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
9
src/App.vue
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<template>
|
||||||
|
<router-view></router-view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'App',
|
||||||
|
};
|
||||||
|
</script>
|
32
src/DeckIndex.vue
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<template>
|
||||||
|
<body>
|
||||||
|
<label>Create New Deck:
|
||||||
|
<input type="text"
|
||||||
|
@change="window.location='/deck/' + event.target.value + '/editor'">
|
||||||
|
</label>
|
||||||
|
<ul>
|
||||||
|
<li v-for="deck in decks">
|
||||||
|
{{ deck }}:
|
||||||
|
<router-link :to="'/play/' + deck"> Play </router-link>
|
||||||
|
<router-link :to="'/edit/' + deck"> Edit </router-link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</body>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'DeckIndex',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
decks: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
fetch('/decks.json')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => this.decks = d);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
187
src/Editor.vue
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div id="controls">
|
||||||
|
<div>
|
||||||
|
<button type="button" @click="upload"> Save Deck </button>
|
||||||
|
<button type="button" @click="jsonInputDownload">
|
||||||
|
Download Input JSON
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button"
|
||||||
|
@click="downloadFile('deck.tts.json', deckName + '.json')">
|
||||||
|
Download Tabletop Output JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label> Upload JSON: WARNING: WILL CLEAR DECK
|
||||||
|
<input @change="jsonUpload" type="file">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form>
|
||||||
|
<div>
|
||||||
|
<label> Deck Name: <input type="text" v-model="deckName"> </label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label> Deck Type:
|
||||||
|
<select v-model="deckInfo.type">
|
||||||
|
<option value="hero">hero</option>
|
||||||
|
<option value="villain">villain</option>
|
||||||
|
<option value="environment">environment</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="cardEditor" v-if="selected">
|
||||||
|
<button class="close-editor" @click="selected = null">X</button>
|
||||||
|
<div v-for="(type, prop) in selectedCardProps">
|
||||||
|
<label> {{ prop }}
|
||||||
|
<input v-if="type === Number" v-model="selected.card[prop]"/>
|
||||||
|
<input v-if="type === 'file'" v-model="selected.card[prop]"/>
|
||||||
|
<textarea v-else rows="1" v-model="selected.card[prop]"> </textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="deck" :style="deckStyle">
|
||||||
|
<div v-for="card in cards" @click="selected = card">
|
||||||
|
<Hero v-bind="card.card"> </Hero>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Hero from './template/hero/Hero.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Editor',
|
||||||
|
|
||||||
|
components: {Hero},
|
||||||
|
|
||||||
|
props: ['deckName'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
selected: null,
|
||||||
|
deckInfo: {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
deckName() {
|
||||||
|
document.title = "Editor|" + deckName;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
fetch('/decks/' + this.deckName + '.input.json')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(j => this.deckInfo = j);
|
||||||
|
/* window.addEventListener(
|
||||||
|
* 'beforeunload', e => e.returnValue = "Unsaved changes blah blah"); */
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
cards() {
|
||||||
|
return Object
|
||||||
|
.keys(this.deckInfo)
|
||||||
|
.filter(cardType => typeof this.deckInfo[cardType] !== 'string')
|
||||||
|
.flatMap(cardType => this.deckInfo[cardType].flatMap((card, index) => {
|
||||||
|
let cardWrapper = {
|
||||||
|
type: cardType,
|
||||||
|
card: this.deckInfo[cardType][index],
|
||||||
|
};
|
||||||
|
return Array(card.count || 1).fill(cardWrapper);
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
deckStyle() {
|
||||||
|
// find minimum box to fit cards
|
||||||
|
let rows = Math.ceil(Math.sqrt(this.cards.length));
|
||||||
|
let columns = this.cards.length > rows * (rows - 1) ? rows : rows - 1;
|
||||||
|
return {
|
||||||
|
display: 'grid',
|
||||||
|
'grid-template-columns': `repeat(${rows}, 1fr)`,
|
||||||
|
'grid-template-rows': `repeat(${columns}, 1fr)`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
selectedCardProps() {
|
||||||
|
// todo: make better
|
||||||
|
return Hero.props;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
// deck JSON uploader
|
||||||
|
jsonUpload(event) {
|
||||||
|
let files = event.target.files;
|
||||||
|
let reader = new FileReader();
|
||||||
|
reader.onload = event => {
|
||||||
|
this.deckInfo = JSON.parse(event.target.result);
|
||||||
|
};
|
||||||
|
reader.readAsText(files[0]);
|
||||||
|
},
|
||||||
|
|
||||||
|
// download input JSON
|
||||||
|
jsonInputDownload() {
|
||||||
|
console.log(JSON.stringify(this.deckInfo));
|
||||||
|
this.downloadFile('data:application/json;charset=utf-8,' +
|
||||||
|
encodeURIComponent(JSON.stringify(this.deckInfo)),
|
||||||
|
this.deckName + '.input.json')
|
||||||
|
},
|
||||||
|
|
||||||
|
// chrome doesn't seem to send input event on file select
|
||||||
|
fileUploaded(event) {
|
||||||
|
let prop = event.target.id.substring(5);
|
||||||
|
if (prop === "image") {
|
||||||
|
let files = event.target.files;
|
||||||
|
let reader = new FileReader();
|
||||||
|
reader.onload = e => {
|
||||||
|
selected.svg.querySelector('#' + prop)
|
||||||
|
.setAttributeNS("http://www.w3.org/1999/xlink", "href", e.target.result);
|
||||||
|
selected.json[prop] = e.target.result;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(files[0]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
downloadFile(file, name) {
|
||||||
|
let dl = document.createElement('a');
|
||||||
|
dl.setAttribute('href', file);
|
||||||
|
dl.setAttribute('download', name);
|
||||||
|
document.body.appendChild(dl);
|
||||||
|
dl.click();
|
||||||
|
document.body.removeChild(dl);
|
||||||
|
},
|
||||||
|
|
||||||
|
upload() {
|
||||||
|
// POST the generated SVGs to the server
|
||||||
|
let data = (new XMLSerializer()).serializeToString(this.$refs.deck);
|
||||||
|
fetch('upload', {
|
||||||
|
method: 'post',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({body: data, json: this.deckInfo})
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#cardEditor {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
background-color: gray;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-editor {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
</style>
|
4
src/index.html
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<body>
|
||||||
|
<div id="charSheet"> </div>
|
||||||
|
<script type="text/javascript" src="index.js"></script>
|
||||||
|
</body>
|
21
src/index.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import Vue from 'vue';
|
||||||
|
import VueRouter from 'vue-router';
|
||||||
|
|
||||||
|
import App from './App.vue';
|
||||||
|
import DeckIndex from './DeckIndex.vue';
|
||||||
|
import Editor from './Editor.vue';
|
||||||
|
|
||||||
|
Vue.use(VueRouter);
|
||||||
|
|
||||||
|
const router = new VueRouter({
|
||||||
|
routes: [
|
||||||
|
{path: '/', component: DeckIndex},
|
||||||
|
{path: '/edit/:deckName', component: Editor, props: true},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#charSheet',
|
||||||
|
render: h => h(App),
|
||||||
|
router,
|
||||||
|
});
|
109
src/server.js
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
// jshint node:true
|
||||||
|
// jshint esversion:6
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const express = require('express'),
|
||||||
|
Bundler = require('parcel-bundler'),
|
||||||
|
fs = require('fs'),
|
||||||
|
path = require('path'),
|
||||||
|
phantom = require('phantom'),
|
||||||
|
port = process.env.PORT || 1234;
|
||||||
|
|
||||||
|
const decks = ["the_Unholy_Priest_update_2",
|
||||||
|
"NZoths_Invasion_1.2",
|
||||||
|
"Puffer_Fish_input_1.3"];
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.use('/template', express.static('template'));
|
||||||
|
app.use('/decks', express.static('decks'));
|
||||||
|
app.get('/decks.json', (req, res) => res.json(decks));
|
||||||
|
app.post('/upload', handleUpload);
|
||||||
|
|
||||||
|
let bundler = new Bundler(path.join(__dirname, 'index.html'));
|
||||||
|
app.use(bundler.middleware());
|
||||||
|
|
||||||
|
app.listen(port, () => console.log(`App listening on port ${port}!`));
|
||||||
|
|
||||||
|
function handleUpload(req, res) {
|
||||||
|
const json = request.body;
|
||||||
|
const deckJSON = json.json;
|
||||||
|
const cardTemplate = fs.readFileSync('template/card.json');
|
||||||
|
const template = JSON.parse(fs.readFileSync(`template/${deckJSON.type}/input.json`));
|
||||||
|
const cardCount = Object.entries(template.cardTypes)
|
||||||
|
.map(ct => deckJSON[ct[0]].length * (ct[1].back ? 2 : 1))
|
||||||
|
.reduce((sum, current) => sum + current, 0);
|
||||||
|
|
||||||
|
let deckOut = JSON.parse(fs.readFileSync('template/deck.json'));
|
||||||
|
deckOut.ObjectStates[0].Nickname = deckJSON.name;
|
||||||
|
|
||||||
|
Object.assign(deckOut.ObjectStates[0].CustomDeck['1'],
|
||||||
|
{NumWidth: Math.ceil(Math.sqrt(cardCount)),
|
||||||
|
NumHeight: Math.ceil(Math.sqrt(cardCount)),
|
||||||
|
FaceURL: `http://${req.headers.host}/deck/${deckJSON.name}/deck.png`,
|
||||||
|
BackURL: "http://cloud-3.steamusercontent.com/ugc/156906385556221451/CE2C3AFE1759790CB0B532FFD636D05A99EC91F4/"});
|
||||||
|
|
||||||
|
let index = 100;
|
||||||
|
deckOut.ObjectStates[0].ContainedObjects =
|
||||||
|
Object.entries(template.cardTypes).map(
|
||||||
|
cardType => deckJSON[cardType[0]].map(cardIn => {
|
||||||
|
let cardOut = JSON.parse(cardTemplate);
|
||||||
|
Object.assign(cardOut, {Nickname: cardIn.name,
|
||||||
|
Description: cardIn.keywords,
|
||||||
|
CardID: index});
|
||||||
|
|
||||||
|
for (let ii=0; ii<(cardIn.count || 1); ii++) {
|
||||||
|
deckOut.ObjectStates[0].DeckIDs.push(index);
|
||||||
|
}
|
||||||
|
index++;
|
||||||
|
|
||||||
|
if(cardType[1].back) {
|
||||||
|
let cardBack = JSON.parse(cardTemplate);
|
||||||
|
Object.assign(cardBack, {Nickname: cardIn.back.name,
|
||||||
|
Description: cardIn.back.keywords,
|
||||||
|
CardID: index});
|
||||||
|
cardOut.States = {"2": cardBack};
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
return cardOut;
|
||||||
|
}))
|
||||||
|
.reduce((sum, cur) => sum.concat(cur), []);
|
||||||
|
|
||||||
|
fs.writeFileSync(`decks/${deckJSON.name}.json`, JSON.stringify(deckOut));
|
||||||
|
fs.writeFileSync(`decks/${deckJSON.name}.input.json`, JSON.stringify(deckJSON));
|
||||||
|
|
||||||
|
console.log("making page");
|
||||||
|
phantom.create().then(
|
||||||
|
ph => ph.createPage().then(
|
||||||
|
page => {
|
||||||
|
page.on('onLoadFinished', status => {
|
||||||
|
if (status !== 'success') {
|
||||||
|
console.log('Failed to load page');
|
||||||
|
ph.exit(1);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
page.render(`decks/${deckJSON.name}.png`);
|
||||||
|
page.close().then(() => ph.exit());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
page.property('zoomFactor', 2); // pretty arbitrary
|
||||||
|
page.property('content', '<body style="margin:0;">' + json.body + '</body>');
|
||||||
|
}));
|
||||||
|
decks.push(deckJSON.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function send404(res, uri) {
|
||||||
|
res.writeHead(404, {'Content-type': "text/html; charset=utf-8"});
|
||||||
|
const html = `
|
||||||
|
<head>
|
||||||
|
<title>404 Not Found</title>
|
||||||
|
<link rel="stylesheet" href="/css/common.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Error 404: Path ${uri.pathname} not found</h1>
|
||||||
|
You seem to have gone to the wrong place, would you like to go
|
||||||
|
back to the <a href="/">main page</a>?
|
||||||
|
</body>`;
|
||||||
|
res.end(html, 'utf-8');
|
||||||
|
}
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 278 KiB |
26
src/template/hero/Hero.vue
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<v-runtime-template :template="template"> </v-runtime-template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import fs from 'fs';
|
||||||
|
import VRuntimeTemplate from "v-runtime-template";
|
||||||
|
|
||||||
|
const templates = {
|
||||||
|
'character': fs.readFileSync(__dirname + '/character.svg', 'utf8'),
|
||||||
|
'character-back': fs.readFileSync(__dirname + '/character-back.svg', 'utf8'),
|
||||||
|
'deck': fs.readFileSync(__dirname + '/deck.svg', 'utf8'),
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'HeroCharacter',
|
||||||
|
props: ['name', 'hp', 'keywords', 'text', 'quote', 'quoteCitation', 'artist'],
|
||||||
|
components: {VRuntimeTemplate},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
template: templates['deck'],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
Before Width: | Height: | Size: 845 B After Width: | Height: | Size: 845 B |
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
@ -310,21 +310,23 @@
|
|||||||
<path d="m133.05 234.55c-7.6094 4.4883-21.801 7.2969-31.816 8.4258h-56.82c-17.391-2.0781-28.762-5.7461-34.582-10.348l4e-3 -9.8086c7.7344-6.6289 31.883-12.094 62.988-12.094 36.301 0 65.734 7.6094 65.734 17 0 0.36719-0.13672 1.0977-0.13672 1.0977 10.293 1.4922 9.9648 10.367 9.9648 10.367-7.5196-7.125-15.336-4.6406-15.336-4.6406z" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="1.25"/>
|
<path d="m133.05 234.55c-7.6094 4.4883-21.801 7.2969-31.816 8.4258h-56.82c-17.391-2.0781-28.762-5.7461-34.582-10.348l4e-3 -9.8086c7.7344-6.6289 31.883-12.094 62.988-12.094 36.301 0 65.734 7.6094 65.734 17 0 0.36719-0.13672 1.0977-0.13672 1.0977 10.293 1.4922 9.9648 10.367 9.9648 10.367-7.5196-7.125-15.336-4.6406-15.336-4.6406z" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="1.25"/>
|
||||||
<path d="m171.5 27.502h-162v121.59h162z" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2"/>
|
<path d="m171.5 27.502h-162v121.59h162z" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2"/>
|
||||||
<g font-family="'RedStateBlueState BB'">
|
<g font-family="'RedStateBlueState BB'">
|
||||||
<g id="keywordBox">
|
<text x="14.173298" y="24.167526" font-family="'CrashLanding BB'" font-size="23px"> {{ name }} </text>
|
||||||
|
<g v-if="keywords">
|
||||||
<path d="m126.21 156.92h-106.91v-11.305h106.91z" fill="#f8c711"/>
|
<path d="m126.21 156.92h-106.91v-11.305h106.91z" fill="#f8c711"/>
|
||||||
<use transform="translate(18.45,145.7) matrix(.68531 0 0 .70654 .85059 .10938)" width="100%" height="100%" mask="url(#f)" xlink:href="#l"/>
|
<use transform="translate(18.45,145.7) matrix(.68531 0 0 .70654 .85059 .10938)" width="100%" height="100%" mask="url(#f)" xlink:href="#l"/>
|
||||||
<path d="m126.96 144.87h-108.41v12.805h108.41zm-0.75 12.055h-106.91v-11.305h106.91z"/>
|
<path d="m126.96 144.87h-108.41v12.805h108.41zm-0.75 12.055h-106.91v-11.305h106.91z"/>
|
||||||
<text id="keywords" x="21.05547" y="154.41197" font-family="'RedStateBlueState BB'" font-size="11.5px" font-weight="bold">Keywords</text>
|
<text x="21.05547" y="154.41197" font-size="11.5px" font-weight="bold"> {{ keywords }} </text>
|
||||||
</g>
|
</g>
|
||||||
<text id="name" x="14.173298" y="24.167526" font-family="'CrashLanding BB'" font-size="23px">Header</text>
|
<g v-if="hp">
|
||||||
<g id="hpMark">
|
|
||||||
<path d="m171.5 9.5h-29.047l-17.832 3.8359 16.25 5.9336-14.375 8.5625 13.312-0.25-9.5 13.379 15.312-8.2539-8.25 15.941 13.875-13.004 5.0625 13.754 7.125-19.379 6.9375 4.3125-3.0898-9.9141 4.2188-0.25z" fill="url(#k)" stroke="#000" stroke-width=".8"/>
|
<path d="m171.5 9.5h-29.047l-17.832 3.8359 16.25 5.9336-14.375 8.5625 13.312-0.25-9.5 13.379 15.312-8.2539-8.25 15.941 13.875-13.004 5.0625 13.754 7.125-19.379 6.9375 4.3125-3.0898-9.9141 4.2188-0.25z" fill="url(#k)" stroke="#000" stroke-width=".8"/>
|
||||||
<text id="hp" x="154.03" y="30.44" fill="#000000" font-family="'CrashLanding BB'" font-size="35px" text-align="center" text-anchor="middle">3</text>
|
<text x="154.03" y="30.44" fill="#000000" font-family="'CrashLanding BB'" font-size="35px" text-align="center" text-anchor="middle"> {{ hp }} </text>
|
||||||
</g>
|
</g>
|
||||||
<image id="image" x="21" y="34" width="162px" height="121px" preserveAspectRatio="xMidYMid"/>
|
<image id="image" x="21" y="34" width="162px" height="121px" preserveAspectRatio="xMidYMid"/>
|
||||||
<text id="text" x="14.48318" y="165.95828" width="152" height="48.5" font-family="'RedStateBlueState BB'" font-size="8.7px">Text Here</text>
|
<foreignObject x="14.5" y="160" width="152" height="48.5" font-family="'RedStateBlueState BB'" font-size="8.7px">
|
||||||
<text id="quote" x="72.514046" y="221.9552" font-size="6.8px" font-style="italic" text-align="center" text-anchor="middle">"Type Quote Here!"</text>
|
<div xmlns="http://www.w3.org/1999/xhtml" v-html="text"> {{ text }} </div>
|
||||||
<text id="quoteCitation" x="101.80201" y="229.85782" font-size="5.8px" text-align="end" text-anchor="end">- Name, Comic #</text>
|
</foreignObject>
|
||||||
<text id="artist" x="168.4621" y="247.85452" fill="#ffffff" font-family="'RedStateBlueState BB'" font-size="5.3px" text-align="end" text-anchor="end">Art By</text>
|
<text x="72.514046" y="222" font-size="6.8px" font-style="italic" text-align="center" text-anchor="middle"> {{ quote }} </text>
|
||||||
|
<text x="101.80201" y="230" font-size="5.8px" text-align="end" text-anchor="end">- {{ quoteCitation }} </text>
|
||||||
|
<text x="168.4621" y="247.85452" fill="#ffffff" font-family="'RedStateBlueState BB'" font-size="5.3px" text-align="end" text-anchor="end"> Art By {{ artist }} </text>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
Before Width: | Height: | Size: 314 KiB After Width: | Height: | Size: 314 KiB |
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
Before Width: | Height: | Size: 316 KiB After Width: | Height: | Size: 316 KiB |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |